فهرست منبع

fix(core): stop replaying stale GitHub Copilot Responses item IDs (#34686)

Aiden Cline 2 ماه پیش
والد
کامیت
f1407e41c4

+ 5 - 5
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

+ 20 - 20
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<typeof localShellInputSchema>),
             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<typeof localShellInputSchema>),
                   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({

+ 206 - 0
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<typeof mock>) {
+  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")
+  })
+})

+ 3 - 1
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

+ 76 - 0
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 = [
       {