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 4bb81b753b52..466b05b62161 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -463,7 +463,9 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re if ( options.store !== true && key && - ["@ai-sdk/openai", "@ai-sdk/azure", "@ai-sdk/amazon-bedrock/mantle"].includes(model.api.npm) + ["@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 diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index dcbaf8171609..550903bf46e9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -2268,6 +2268,82 @@ 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 copilot namespace, preserving other copilot 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: { + copilot: { 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: { + copilot: { itemId: "fc_456", reasoningEffort: "medium" }, + }, + }, + ], + }, + ] as any[] + + const result = ProviderTransform.message(msgs, copilotModel, { store: false }) as any[] + + 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("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", + 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).toBe("msg_456") + }) + test("preserves metadata for openai package when store is true", () => { const msgs = [ {