From 99bb486189b94840d62358897d95c15db981b42f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 17:55:27 -0500 Subject: [PATCH 1/3] fix(opencode): strip stale GitHub Copilot Responses itemId GitHub Copilot's gpt-5.5 Responses model is a fork of the OpenAI Responses model. It echoes response item metadata (itemId) under the "openai" providerOptions namespace, while request-time settings like store/reasoningEffort use the "copilot" namespace (sdkKey's mapping). The existing stale-itemId stripping in ProviderTransform.message only covered @ai-sdk/openai, @ai-sdk/azure, and @ai-sdk/amazon-bedrock/mantle, and used the sdkKey-derived namespace for the lookup. For Copilot that key is "copilot", which never holds itemId, so the strip was a no-op and stale itemIds from a prior Copilot connection were replayed as the function_call/text item id, causing a 401 from a since-rotated or re-established Copilot connection. Fixes #31236 --- packages/opencode/src/provider/transform.ts | 16 +++++-- .../opencode/test/provider/transform.test.ts | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 4bb81b753b52..7b1d323a86d9 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -460,16 +460,22 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re } // Strip Responses item IDs before serialization, following Codex and keeping signed request bodies immutable. + // GitHub Copilot's Responses model is a fork of the OpenAI Responses model that still echoes + // item metadata under the "openai" namespace even though request options use "copilot" (sdkKey's + // mapping), so it strips from "openai" rather than `key` to actually hit the stale itemId. + const itemIDKey = model.api.npm === "@ai-sdk/github-copilot" ? "openai" : key if ( options.store !== true && - key && - ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(model.api.npm) + itemIDKey && + ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( + model.api.npm, + ) ) { msgs = mapProviderOptions(msgs, (options) => { - if (!options?.[key] || !("itemId" in options[key])) return options - const metadata = { ...options[key] } + if (!options?.[itemIDKey] || !("itemId" in options[itemIDKey])) return options + const metadata = { ...options[itemIDKey] } delete metadata.itemId - return { ...options, [key]: metadata } + return { ...options, [itemIDKey]: metadata } }) } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index dcbaf8171609..7cd89f86501b 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2268,6 +2268,52 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") }) + test("strips GitHub Copilot itemId from the OpenAI namespace, preserving copilot request options", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { + openai: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + }, + }, + { + // The stale itemId on tool-call parts is what Copilot echoes back as the + // `function_call` item `id`, which is what the upstream connection rejects. + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { + openai: { itemId: "fc_456" }, + copilot: { reasoningEffort: "medium" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[1].providerOptions?.copilot?.reasoningEffort).toBe("medium") + }) + test("preserves metadata for openai package when store is true", () => { const msgs = [ { From 7551638a330c77e66368a8ae577a0db12bf0906e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 18:47:39 -0500 Subject: [PATCH 2/3] fix(core): echo GitHub Copilot Responses item metadata under copilot namespace GitHub Copilot's Responses model (a fork of @ai-sdk/openai's) read request-time options (store, reasoningEffort, ...) from the "copilot" providerOptions namespace, but echoed response/item metadata (itemId, reasoningEncryptedContent, logprobs, responseId, serviceTier, approvalId, imageDetail) under the leftover "openai" namespace on both the write and read sides. Reasoning-item reads already (incorrectly) checked "copilot", so reasoning items were silently dropped on every turn after the first regardless of any connection change. Rename every echoed field to the "copilot" namespace so reads and writes agree, fixing the silent reasoning drop as a side effect and giving transform.ts's itemId stripping a real namespace to act on. Sessions created before this fix still have stale itemIds stored under "openai", so the previous commit's stripping in transform.ts now strips itemId from both "copilot" (current) and "openai" (legacy) for Copilot models. Adds direct unit tests for the previously untested openai-responses-language-model.ts/convert-to-openai-responses-input.ts, verified to fail against the pre-fix code. --- .../convert-to-openai-responses-input.ts | 10 +- .../openai-responses-language-model.ts | 40 ++-- .../openai-responses-language-model.test.ts | 206 ++++++++++++++++++ packages/opencode/src/provider/transform.ts | 31 +-- .../opencode/test/provider/transform.test.ts | 44 +++- 5 files changed, 286 insertions(+), 45 deletions(-) create mode 100644 packages/core/test/github-copilot/openai-responses-language-model.test.ts diff --git a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts index 83e46015dd1e..1e4f86d93355 100644 --- a/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts +++ b/packages/core/src/github-copilot/responses/convert-to-openai-responses-input.ts @@ -86,7 +86,7 @@ export async function convertToOpenAIResponsesInput({ : { image_url: `data:${mediaType};base64,${convertToBase64(part.data)}`, }), - detail: part.providerOptions?.openai?.imageDetail, + detail: part.providerOptions?.copilot?.imageDetail, } } else if (part.mediaType === "application/pdf") { if (part.data instanceof URL) { @@ -127,7 +127,7 @@ export async function convertToOpenAIResponsesInput({ input.push({ role: "assistant", content: [{ type: "output_text", text: part.text }], - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, }) break } @@ -143,7 +143,7 @@ export async function convertToOpenAIResponsesInput({ input.push({ type: "local_shell_call", call_id: part.toolCallId, - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, action: { type: "exec", command: parsedInput.action.command, @@ -162,7 +162,7 @@ export async function convertToOpenAIResponsesInput({ call_id: part.toolCallId, name: part.toolName, arguments: JSON.stringify(part.input), - id: (part.providerOptions?.openai?.itemId as string) ?? undefined, + id: (part.providerOptions?.copilot?.itemId as string) ?? undefined, }) break } @@ -275,7 +275,7 @@ export async function convertToOpenAIResponsesInput({ const output = part.output if (output.type === "execution-denied") { - const approvalId = (output.providerOptions?.openai as { approvalId?: string } | undefined)?.approvalId + const approvalId = (output.providerOptions?.copilot as { approvalId?: string } | undefined)?.approvalId if (approvalId) { continue diff --git a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts index 250d1f6f3405..8df1dcedadb3 100644 --- a/packages/core/src/github-copilot/responses/openai-responses-language-model.ts +++ b/packages/core/src/github-copilot/responses/openai-responses-language-model.ts @@ -525,7 +525,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning" as const, text: summary.text, providerMetadata: { - openai: { + copilot: { itemId: part.id, reasoningEncryptedContent: part.encrypted_content ?? null, }, @@ -563,7 +563,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { toolName: "local_shell", input: JSON.stringify({ action: part.action } satisfies z.infer), providerMetadata: { - openai: { + copilot: { itemId: part.id, }, }, @@ -574,7 +574,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { case "message": { for (const contentPart of part.content) { - if (options.providerOptions?.openai?.logprobs && contentPart.logprobs) { + if (options.providerOptions?.copilot?.logprobs && contentPart.logprobs) { logprobs.push(contentPart.logprobs) } @@ -582,7 +582,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "text", text: contentPart.text, providerMetadata: { - openai: { + copilot: { itemId: part.id, }, }, @@ -622,7 +622,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { toolName: part.name, input: part.arguments, providerMetadata: { - openai: { + copilot: { itemId: part.id, }, }, @@ -724,15 +724,15 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { } const providerMetadata: SharedV3ProviderMetadata = { - openai: { responseId: response.id }, + copilot: { responseId: response.id }, } if (logprobs.length > 0) { - providerMetadata.openai.logprobs = logprobs + providerMetadata.copilot.logprobs = logprobs } if (typeof response.service_tier === "string") { - providerMetadata.openai.serviceTier = response.service_tier + providerMetadata.copilot.serviceTier = response.service_tier } return { @@ -954,7 +954,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "text-start", id: value.item.id, providerMetadata: { - openai: { + copilot: { itemId: value.item.id, }, }, @@ -971,7 +971,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning-start", id: `${value.item.id}:0`, providerMetadata: { - openai: { + copilot: { itemId: value.item.id, reasoningEncryptedContent: value.item.encrypted_content ?? null, }, @@ -994,7 +994,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { toolName: value.item.name, input: value.item.arguments, providerMetadata: { - openai: { + copilot: { itemId: value.item.id, }, }, @@ -1103,7 +1103,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { }, } satisfies z.infer), providerMetadata: { - openai: { itemId: value.item.id }, + copilot: { itemId: value.item.id }, }, }) } else if (value.item.type === "message") { @@ -1122,7 +1122,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning-end", id: `${activeReasoningPart.canonicalId}:${summaryIndex}`, providerMetadata: { - openai: { + copilot: { itemId: activeReasoningPart.canonicalId, reasoningEncryptedContent: value.item.encrypted_content ?? null, }, @@ -1209,7 +1209,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "text-start", id: currentTextId, providerMetadata: { - openai: { itemId: value.item_id }, + copilot: { itemId: value.item_id }, }, }) } @@ -1220,7 +1220,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { delta: value.delta, }) - if (options.providerOptions?.openai?.logprobs && value.logprobs) { + if (options.providerOptions?.copilot?.logprobs && value.logprobs) { logprobs.push(value.logprobs) } } else if (isResponseReasoningSummaryPartAddedChunk(value)) { @@ -1235,7 +1235,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { type: "reasoning-start", id: `${activeItem.canonicalId}:${value.summary_index}`, providerMetadata: { - openai: { + copilot: { itemId: activeItem.canonicalId, reasoningEncryptedContent: activeItem.encryptedContent ?? null, }, @@ -1252,7 +1252,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { id: `${activeItem.canonicalId}:${value.summary_index}`, delta: value.delta, providerMetadata: { - openai: { + copilot: { itemId: activeItem.canonicalId, }, }, @@ -1306,17 +1306,17 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { } const providerMetadata: SharedV3ProviderMetadata = { - openai: { + copilot: { responseId, }, } if (logprobs.length > 0) { - providerMetadata.openai.logprobs = logprobs + providerMetadata.copilot.logprobs = logprobs } if (serviceTier !== undefined) { - providerMetadata.openai.serviceTier = serviceTier + providerMetadata.copilot.serviceTier = serviceTier } controller.enqueue({ diff --git a/packages/core/test/github-copilot/openai-responses-language-model.test.ts b/packages/core/test/github-copilot/openai-responses-language-model.test.ts new file mode 100644 index 000000000000..ab047d04ebe3 --- /dev/null +++ b/packages/core/test/github-copilot/openai-responses-language-model.test.ts @@ -0,0 +1,206 @@ +import { OpenAIResponsesLanguageModel } from "@opencode-ai/core/github-copilot/responses/openai-responses-language-model" +import { convertToOpenAIResponsesInput } from "@opencode-ai/core/github-copilot/responses/convert-to-openai-responses-input" +import { describe, test, expect, mock } from "bun:test" +import type { LanguageModelV3Prompt } from "@ai-sdk/provider" + +const TEST_PROMPT: LanguageModelV3Prompt = [{ role: "user", content: [{ type: "text", text: "Hello" }] }] + +function createMockFetch(body: unknown) { + return mock( + async () => new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }), + ) +} + +function createModel(fetchFn: ReturnType) { + return new OpenAIResponsesLanguageModel("test-model", { + provider: "copilot", + url: () => "https://api.test.com/responses", + headers: () => ({ Authorization: "Bearer test-token" }), + fetch: fetchFn as any, + }) +} + +// GitHub Copilot's Responses model echoes item metadata (itemId, reasoningEncryptedContent, +// responseId, ...) under the "copilot" providerOptions/providerMetadata namespace, matching the +// namespace request options already use. It used to echo this metadata under "openai" (a leftover +// from forking the OpenAI Responses model), which left it unreachable by anything reading the +// "copilot" namespace and let stale itemIds slip past stripping meant for that namespace. +describe("doGenerate", () => { + test("attaches item metadata under the copilot namespace, not openai", async () => { + const mockFetch = createMockFetch({ + id: "resp_1", + created_at: 0, + model: "gpt-5.5", + output: [ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "thinking..." }], + }, + { + type: "message", + role: "assistant", + id: "msg_1", + content: [{ type: "output_text", text: "Hello there", annotations: [] }], + }, + { + type: "function_call", + call_id: "call_1", + name: "bash", + arguments: "{}", + id: "fc_1", + }, + ], + usage: { input_tokens: 10, output_tokens: 5 }, + }) + const model = createModel(mockFetch) + + const { content, providerMetadata } = await model.doGenerate({ + prompt: TEST_PROMPT, + includeRawChunks: false, + } as any) + + const reasoning = content.find((part: any) => part.type === "reasoning") as any + expect(reasoning.providerMetadata?.copilot?.itemId).toBe("rs_1") + expect(reasoning.providerMetadata?.copilot?.reasoningEncryptedContent).toBe("enc_1") + expect(reasoning.providerMetadata?.openai).toBeUndefined() + + const text = content.find((part: any) => part.type === "text") as any + expect(text.providerMetadata?.copilot?.itemId).toBe("msg_1") + expect(text.providerMetadata?.openai).toBeUndefined() + + const toolCall = content.find((part: any) => part.type === "tool-call") as any + expect(toolCall.providerMetadata?.copilot?.itemId).toBe("fc_1") + expect(toolCall.providerMetadata?.openai).toBeUndefined() + + expect(providerMetadata?.copilot?.responseId).toBe("resp_1") + expect(providerMetadata?.openai).toBeUndefined() + }) +}) + +describe("convertToOpenAIResponsesInput", () => { + test("echoes a stale tool-call itemId from the copilot namespace as the function_call id", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { copilot: { itemId: "fc_999" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(input).toEqual([ + { + type: "function_call", + call_id: "call_1", + name: "bash", + arguments: JSON.stringify({ command: "ls" }), + id: "fc_999", + }, + ]) + }) + + test("omits the function_call id once the stale copilot itemId has been stripped", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "call_1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: {}, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect((input[0] as any).id).toBeUndefined() + }) + + test("preserves reasoning items keyed by the copilot namespace instead of dropping them", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking...", + providerOptions: { copilot: { itemId: "rs_1", reasoningEncryptedContent: "enc_1" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(warnings).toEqual([]) + expect(input).toEqual([ + { + type: "reasoning", + id: "rs_1", + encrypted_content: "enc_1", + summary: [{ type: "summary_text", text: "thinking..." }], + }, + ]) + }) + + test("drops reasoning items with no copilot itemId and warns, as before", async () => { + const { input, warnings } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "assistant", + content: [{ type: "reasoning", text: "thinking...", providerOptions: {} }], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect(input).toEqual([]) + expect(warnings).toHaveLength(1) + expect(warnings[0]).toMatchObject({ + message: expect.stringContaining("Non-OpenAI reasoning parts are not supported"), + }) + }) + + test("reads imageDetail from the copilot namespace on user file parts", async () => { + const { input } = await convertToOpenAIResponsesInput({ + prompt: [ + { + role: "user", + content: [ + { + type: "file", + mediaType: "image/png", + data: "aGVsbG8=", + providerOptions: { copilot: { imageDetail: "high" } }, + }, + ], + }, + ], + systemMessageMode: "system", + store: false, + }) + + expect((input[0] as any).content[0].detail).toBe("high") + }) +}) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 7b1d323a86d9..d8bc7fe1f70b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -460,23 +460,28 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re } // Strip Responses item IDs before serialization, following Codex and keeping signed request bodies immutable. - // GitHub Copilot's Responses model is a fork of the OpenAI Responses model that still echoes - // item metadata under the "openai" namespace even though request options use "copilot" (sdkKey's - // mapping), so it strips from "openai" rather than `key` to actually hit the stale itemId. - const itemIDKey = model.api.npm === "@ai-sdk/github-copilot" ? "openai" : key - if ( + // GitHub Copilot's Responses model echoes item metadata under "copilot" (sdkKey's mapping), but + // wrote it under "openai" before that was fixed. Sessions started before the fix still carry + // stale itemIds under "openai", so both namespaces are stripped for Copilot models. + const itemIDKeys = options.store !== true && - itemIDKey && + key && ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( model.api.npm, ) - ) { - msgs = mapProviderOptions(msgs, (options) => { - if (!options?.[itemIDKey] || !("itemId" in options[itemIDKey])) return options - const metadata = { ...options[itemIDKey] } - delete metadata.itemId - return { ...options, [itemIDKey]: metadata } - }) + ? model.api.npm === "@ai-sdk/github-copilot" + ? [key, "openai"] + : [key] + : [] + if (itemIDKeys.length > 0) { + msgs = mapProviderOptions(msgs, (options) => + itemIDKeys.reduce((result, itemIDKey) => { + if (!result?.[itemIDKey] || !("itemId" in result[itemIDKey])) return result + const metadata = { ...result[itemIDKey] } + delete metadata.itemId + return { ...result, [itemIDKey]: metadata } + }, options), + ) } return msgs diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 7cd89f86501b..e41f13915c7f 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2268,7 +2268,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") }) - test("strips GitHub Copilot itemId from the OpenAI namespace, preserving copilot request options", () => { + test("strips GitHub Copilot itemId from the copilot namespace, preserving other copilot options", () => { const copilotModel = { ...openaiModel, id: "github-copilot/gpt-5.5", @@ -2287,7 +2287,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( type: "reasoning", text: "thinking...", providerOptions: { - openai: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, + copilot: { itemId: "rs_123", reasoningEncryptedContent: "encrypted" }, }, }, { @@ -2298,8 +2298,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( toolName: "bash", input: { command: "ls" }, providerOptions: { - openai: { itemId: "fc_456" }, - copilot: { reasoningEffort: "medium" }, + copilot: { itemId: "fc_456", reasoningEffort: "medium" }, }, }, ], @@ -2308,12 +2307,43 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() - expect(result[0].content[0].providerOptions?.openai?.reasoningEncryptedContent).toBe("encrypted") - expect(result[0].content[1].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.copilot?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.copilot?.reasoningEncryptedContent).toBe("encrypted") + expect(result[0].content[1].providerOptions?.copilot?.itemId).toBeUndefined() expect(result[0].content[1].providerOptions?.copilot?.reasoningEffort).toBe("medium") }) + test("also strips GitHub Copilot itemId from the legacy openai namespace for sessions predating the copilot-namespace fix", () => { + const copilotModel = { + ...openaiModel, + id: "github-copilot/gpt-5.5", + providerID: "github-copilot", + api: { + id: "gpt-5.5", + url: "https://api.githubcopilot.com", + npm: "@ai-sdk/github-copilot", + }, + } + const msgs = [ + { + role: "assistant", + content: [ + { + type: "text", + text: "Hello", + providerOptions: { + openai: { itemId: "msg_456" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + }) + test("preserves metadata for openai package when store is true", () => { const msgs = [ { From e6b7ae7b9b7be780476c49b233e86a9e74b104ce Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 19:17:57 -0500 Subject: [PATCH 3/3] fix(opencode): drop unneeded legacy openai-namespace itemId stripping for Copilot Now that the vendored Copilot Responses provider only ever reads providerOptions under the "copilot" namespace (previous commit), a stray itemId left under "openai" from pre-fix sessions is never consulted by the model, so it doesn't need to be stripped. Only the live "copilot" namespace needs stripping, same as every other Responses-backed provider already handled here. --- packages/opencode/src/provider/transform.ts | 25 ++++++------------- .../opencode/test/provider/transform.test.ts | 4 +-- 2 files changed, 10 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index d8bc7fe1f70b..466b05b62161 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -460,28 +460,19 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re } // Strip Responses item IDs before serialization, following Codex and keeping signed request bodies immutable. - // GitHub Copilot's Responses model echoes item metadata under "copilot" (sdkKey's mapping), but - // wrote it under "openai" before that was fixed. Sessions started before the fix still carry - // stale itemIds under "openai", so both namespaces are stripped for Copilot models. - const itemIDKeys = + if ( options.store !== true && key && ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle", "@ai-sdk/github-copilot"].includes( model.api.npm, ) - ? model.api.npm === "@ai-sdk/github-copilot" - ? [key, "openai"] - : [key] - : [] - if (itemIDKeys.length > 0) { - msgs = mapProviderOptions(msgs, (options) => - itemIDKeys.reduce((result, itemIDKey) => { - if (!result?.[itemIDKey] || !("itemId" in result[itemIDKey])) return result - const metadata = { ...result[itemIDKey] } - delete metadata.itemId - return { ...result, [itemIDKey]: metadata } - }, options), - ) + ) { + msgs = mapProviderOptions(msgs, (options) => { + if (!options?.[key] || !("itemId" in options[key])) return options + const metadata = { ...options[key] } + delete metadata.itemId + return { ...options, [key]: metadata } + }) } return msgs diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index e41f13915c7f..550903bf46e9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2313,7 +2313,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( expect(result[0].content[1].providerOptions?.copilot?.reasoningEffort).toBe("medium") }) - test("also strips GitHub Copilot itemId from the legacy openai namespace for sessions predating the copilot-namespace fix", () => { + test("leaves a stray openai namespace on a Copilot model untouched, since Copilot's Responses model only reads the copilot namespace", () => { const copilotModel = { ...openaiModel, id: "github-copilot/gpt-5.5", @@ -2341,7 +2341,7 @@ describe("ProviderTransform.message - strip openai metadata when store=false", ( const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] - expect(result[0].content[0].providerOptions?.openai?.itemId).toBeUndefined() + expect(result[0].content[0].providerOptions?.openai?.itemId).toBe("msg_456") }) test("preserves metadata for openai package when store is true", () => {