diff --git a/.changeset/show-vercel-response-id.md b/.changeset/show-vercel-response-id.md new file mode 100644 index 00000000000..80947e8b866 --- /dev/null +++ b/.changeset/show-vercel-response-id.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Show the request ID when a model response ends without a finish reason. diff --git a/packages/kilo-vscode/tests/unit/session-outcome.test.ts b/packages/kilo-vscode/tests/unit/session-outcome.test.ts index ec30837e93b..0a5dba04a0a 100644 --- a/packages/kilo-vscode/tests/unit/session-outcome.test.ts +++ b/packages/kilo-vscode/tests/unit/session-outcome.test.ts @@ -43,6 +43,28 @@ describe("terminal", () => { expect(terminal({ reason: "completed", messages: [message("unknown")], todos: [] })?.kind).toBe("unknown") }) + it("includes the Vercel response ID for an unknown finish", () => { + expect( + terminal({ + reason: "completed", + messages: [ + message("unknown", { + name: "APIError", + data: { responseHeaders: { "X-Vercel-Id": "fra1::abc" } }, + }), + ], + todos: [], + hidden: () => true, + }), + ).toEqual({ + kind: "unknown", + tone: "warning", + finish: "unknown", + remaining: 0, + vercelID: "fra1::abc", + }) + }) + it("surfaces filtered and unexpected provider finishes", () => { expect(terminal({ reason: "completed", messages: [message("content-filter")], todos: [] })?.kind).toBe("filtered") expect(terminal({ reason: "completed", messages: [message("other")], todos: [] })?.kind).toBe("unexpected") diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx index 9bacd25fb00..7d5e83aacd4 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/TurnOutcome.tsx @@ -34,7 +34,10 @@ export const TurnOutcome: Component = () => { role="status" title={value().finish ? language.t("session.outcome.finish", { reason: value().finish! }) : undefined} > - {label(value())} + +
{label(value())}
+ {(id) => Request ID: {id()}} +
)} diff --git a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts index dcf8f26faf4..e261fbff33f 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-outcome.ts @@ -7,6 +7,7 @@ export interface TerminalState { kind: TerminalKind tone: TerminalTone finish?: string + vercelID?: string remaining: number } @@ -17,6 +18,14 @@ interface Input { hidden?: (id: string) => boolean } +function vercelID(message: Message | undefined) { + const headers = message?.error?.data?.responseHeaders + if (!headers || typeof headers !== "object" || Array.isArray(headers)) return + return Object.entries(headers).find( + ([name, value]) => name.toLowerCase() === "x-vercel-id" && typeof value === "string", + )?.[1] +} + export function terminal(input: Input): TerminalState | undefined { if (!input.reason) return undefined const last = input.messages[input.messages.length - 1] @@ -29,7 +38,7 @@ export function terminal(input: Input): TerminalState | undefined { return { kind: "error", tone: "critical", finish, remaining } } if (finish === "length") return { kind: "limit", tone: "warning", finish, remaining } - if (finish === "unknown") return { kind: "unknown", tone: "warning", finish, remaining } + if (finish === "unknown") return { kind: "unknown", tone: "warning", finish, remaining, vercelID: vercelID(last) } if (finish === "content-filter") return { kind: "filtered", tone: "warning", finish, remaining } if (finish === "other") return { kind: "unexpected", tone: "warning", finish, remaining } return undefined diff --git a/packages/opencode/src/kilocode/session/processor.ts b/packages/opencode/src/kilocode/session/processor.ts index 92f8e95e9cc..2906c0dfebf 100644 --- a/packages/opencode/src/kilocode/session/processor.ts +++ b/packages/opencode/src/kilocode/session/processor.ts @@ -28,7 +28,7 @@ export namespace KiloSessionProcessor { export const INCOMPLETE_RESPONSE_MESSAGE = "The provider repeatedly ended the response before returning usable output." export class IncompleteResponseError extends Error { - constructor() { + constructor(readonly vercelID?: string) { super(INCOMPLETE_RESPONSE_MESSAGE) this.name = "IncompleteResponseError" } @@ -270,13 +270,13 @@ export namespace KiloSessionProcessor { return Effect.gen(function* () { for (const index of Array.from({ length: INCOMPLETE_RESPONSE_RETRIES + 1 }, (_, index) => index)) { const result = yield* input.run().pipe(Effect.exit) - if (Exit.isFailure(result)) { - const error = Cause.squash(result.cause) - if (!(error instanceof IncompleteResponseError)) return yield* Effect.fail(error) - } else if (!input.replayable()) return + const error = Exit.isFailure(result) ? Cause.squash(result.cause) : undefined + if (error && !(error instanceof IncompleteResponseError)) return yield* Effect.fail(error) + if (!error && !input.replayable()) return yield* input.discard() - if (index === INCOMPLETE_RESPONSE_RETRIES) return yield* Effect.fail(new IncompleteResponseError()) + if (index === INCOMPLETE_RESPONSE_RETRIES) + return yield* Effect.fail(error ?? new IncompleteResponseError()) const wait = SessionRetry.delay(index + 1) yield* input.set({ attempt: index + 1, message: INCOMPLETE_RESPONSE_MESSAGE, next: Date.now() + wait }) yield* Effect.sleep(`${wait} millis`) @@ -289,6 +289,7 @@ export namespace KiloSessionProcessor { return new MessageV2.APIError({ message: error.message, isRetryable: true, + responseHeaders: error.vercelID ? { "x-vercel-id": error.vercelID } : undefined, }).toObject() } diff --git a/packages/opencode/src/kilocode/session/response-metadata.ts b/packages/opencode/src/kilocode/session/response-metadata.ts new file mode 100644 index 00000000000..cf7b174af14 --- /dev/null +++ b/packages/opencode/src/kilocode/session/response-metadata.ts @@ -0,0 +1,17 @@ +import type { ProviderMetadata } from "@opencode-ai/llm" +import { isRecord } from "@/util/record" + +export namespace KiloResponseMetadata { + export function write(metadata: ProviderMetadata | undefined, headers: Record | undefined) { + const id = Object.entries(headers ?? {}).find(([name]) => name.toLowerCase() === "x-vercel-id")?.[1] + if (!id) return metadata + const kilo = isRecord(metadata?.kilo) ? metadata.kilo : {} + return { ...metadata, kilo: { ...kilo, vercelID: id } } + } + + export function read(metadata: ProviderMetadata | undefined) { + const kilo = metadata?.kilo + if (!isRecord(kilo)) return + return typeof kilo.vercelID === "string" ? kilo.vercelID : undefined + } +} diff --git a/packages/opencode/src/session/llm/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts index b806bdbbe20..ddfd3447429 100644 --- a/packages/opencode/src/session/llm/ai-sdk.ts +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -3,6 +3,7 @@ import { Effect, Schema } from "effect" import { type streamText } from "ai" import { errorMessage } from "@/util/error" import { KiloRoutedModel } from "@/kilocode/session/routed-model" // kilocode_change +import { KiloResponseMetadata } from "@/kilocode/session/response-metadata" // kilocode_change type Result = Awaited> type AISDKEvent = Result["fullStream"] extends AsyncIterable ? T : never @@ -106,7 +107,12 @@ export function toLLMEvents( index: state.step++, reason: finishReason(event.finishReason), usage: usage(event.usage), - providerMetadata: KiloRoutedModel.write(metadata, event.response?.modelId), // kilocode_change + // kilocode_change start + providerMetadata: KiloResponseMetadata.write( + KiloRoutedModel.write(metadata, event.response?.modelId), + event.response?.headers, + ), + // kilocode_change end }), ] }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2e8e4bb72f6..c20599959af 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -24,6 +24,7 @@ import { Question } from "@/question" import { KiloSessionProcessor, type ReviewTelemetry } from "@/kilocode/session/processor" import { KiloSessionOverflow } from "@/kilocode/session/overflow" import { KiloRoutedModel } from "@/kilocode/session/routed-model" +import { KiloResponseMetadata } from "@/kilocode/session/response-metadata" import { Suggestion } from "@/kilocode/suggestion" // kilocode_change end import { errorMessage } from "@/util/error" @@ -845,7 +846,9 @@ export const layer = Layer.effect( usage: attempt.usage, }) ) - return yield* Effect.fail(new KiloSessionProcessor.IncompleteResponseError()) + return yield* Effect.fail( + new KiloSessionProcessor.IncompleteResponseError(KiloResponseMetadata.read(value.providerMetadata)), + ) // kilocode_change end // kilocode_change start - pass turn context for slow-snapshot UI/policy handling const completedSnapshot = yield* snapshot.track({ diff --git a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts index 8344c03f0f0..cfb8f3de051 100644 --- a/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts +++ b/packages/opencode/test/kilocode/session-processor-incomplete-response-retry.test.ts @@ -72,13 +72,14 @@ function model(): Provider.Model { } as Provider.Model } -function empty() { +function empty(vercelID?: string) { const usage = new Usage({}) + const providerMetadata = vercelID ? { kilo: { vercelID } } : undefined return [ LLMEvent.stepStart({ index: 0 }), LLMEvent.reasoningStart({ id: "reasoning" }), LLMEvent.reasoningEnd({ id: "reasoning" }), - LLMEvent.stepFinish({ index: 0, reason: "unknown", usage }), + LLMEvent.stepFinish({ index: 0, reason: "unknown", usage, providerMetadata }), LLMEvent.finish({ reason: "unknown", usage }), ] } @@ -242,9 +243,9 @@ describe("session processor incomplete response retry", () => { (dir) => Effect.gen(function* () { const ctx = yield* setup(dir) - yield* ctx.test.reply(...empty()) - yield* ctx.test.reply(...empty()) - yield* ctx.test.reply(...empty()) + yield* ctx.test.reply(...empty("attempt-1")) + yield* ctx.test.reply(...empty("attempt-2")) + yield* ctx.test.reply(...empty("final-id")) yield* ctx.test.push(Stream.fail(new Error("unexpected extra llm call"))) const delay = spyOn(SessionRetry, "delay").mockReturnValue(0) @@ -260,6 +261,7 @@ describe("session processor incomplete response retry", () => { expect(MessageV2.APIError.isInstance(error)).toBe(true) if (!MessageV2.APIError.isInstance(error)) throw new Error("expected API error") expect(error.data.message).toBe(KiloSessionProcessor.INCOMPLETE_RESPONSE_MESSAGE) + expect(error.data.responseHeaders?.["x-vercel-id"]).toBe("final-id") expect(yield* MessageV2.parts(ctx.msg.id)).toEqual([]) }), { git: true }, diff --git a/packages/opencode/test/kilocode/session-response-metadata.test.ts b/packages/opencode/test/kilocode/session-response-metadata.test.ts new file mode 100644 index 00000000000..1c76274b824 --- /dev/null +++ b/packages/opencode/test/kilocode/session-response-metadata.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { LLMAISDK } from "@/session/llm/ai-sdk" +import { KiloResponseMetadata } from "@/kilocode/session/response-metadata" + +describe("session response metadata", () => { + test("carries x-vercel-id from an AI SDK response", async () => { + const events = await Effect.runPromise( + LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), { + type: "finish-step", + response: { + id: "response-1", + timestamp: new Date(0), + modelId: "gpt-test", + headers: { "X-Vercel-Id": "fra1::abc" }, + }, + finishReason: "other", + rawFinishReason: undefined, + providerMetadata: undefined, + usage: { + inputTokens: 1, + outputTokens: 0, + totalTokens: 1, + inputTokenDetails: { noCacheTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0 }, + outputTokenDetails: { textTokens: 0, reasoningTokens: 0 }, + }, + }), + ) + + expect(events).toHaveLength(1) + const event = events[0] + if (event?.type !== "step-finish") throw new Error("expected step-finish") + expect(KiloResponseMetadata.read(event.providerMetadata)).toBe("fra1::abc") + }) + + test("does not add metadata when the header is absent", () => { + expect(KiloResponseMetadata.write(undefined, { server: "vercel" })).toBeUndefined() + }) +})