Jelajahi Sumber

fix(copilot): honor advertised model endpoints (#34958)

Aiden Cline 1 bulan lalu
induk
melakukan
373cd08b98

+ 19 - 15
packages/core/src/plugin/provider/github-copilot.ts

@@ -1,19 +1,11 @@
 import { Effect } from "effect"
 import { ModelV2 } from "../../model"
-import { define } from "../internal"
 import { ProviderV2 } from "../../provider"
+import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
 
-function shouldUseResponses(modelID: string) {
-  // Copilot supports Responses for GPT-5 class models, except mini variants
-  // which still need the chat-completions endpoint.
-  const match = /^gpt-(\d+)/.exec(modelID)
-  if (!match) return false
-  return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")
-}
-
-export const GithubCopilotPlugin = define({
+export const GithubCopilotPlugin = {
   id: "github-copilot",
-  effect: Effect.fn(function* (ctx) {
+  effect: Effect.fn(function* (ctx: PluginContext) {
     yield* ctx.catalog.transform(
       Effect.fn(function* (evt) {
         const item = evt.provider.get(ProviderV2.ID.githubCopilot)
@@ -39,10 +31,22 @@ export const GithubCopilotPlugin = define({
           evt.language = evt.sdk.languageModel(evt.model.api.id)
           return
         }
-        evt.language = shouldUseResponses(evt.model.api.id)
-          ? evt.sdk.responses(evt.model.api.id)
-          : evt.sdk.chat(evt.model.api.id)
+        if (evt.options.endpoint === "responses" && evt.sdk.responses) {
+          evt.language = evt.sdk.responses(evt.model.api.id)
+          return
+        }
+        if (evt.options.endpoint === "chat" && evt.sdk.chat) {
+          evt.language = evt.sdk.chat(evt.model.api.id)
+          return
+        }
+        const match = /^gpt-(\d+)/.exec(evt.model.api.id)
+        // Copilot supports Responses for GPT-5 class models, except mini variants
+        // which still need the chat-completions endpoint.
+        evt.language =
+          match && Number(match[1]) >= 5 && !evt.model.api.id.startsWith("gpt-5-mini") && evt.sdk.responses
+            ? evt.sdk.responses(evt.model.api.id)
+            : evt.sdk.chat(evt.model.api.id)
       }),
     )
   }),
-})
+}

+ 36 - 0
packages/core/test/plugin/provider-github-copilot.test.ts

@@ -157,6 +157,42 @@ describe("GithubCopilotPlugin", () => {
     }),
   )
 
+  it.effect("uses advertised Copilot endpoint metadata before model ID fallbacks", () =>
+    Effect.gen(function* () {
+      const plugin = yield* PluginV2.Service
+      const aisdk = yield* AISDK.Service
+      const calls: string[] = []
+      yield* addPlugin()
+      yield* aisdk.runLanguage({
+        model: ModelV2.Info.make({
+          ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("mai-code-1-flash-picker")),
+          api: {
+            id: ModelV2.ID.make("mai-code-1-flash-picker"),
+            type: "aisdk",
+            package: "test-provider",
+            settings: { endpoint: "responses" },
+          },
+        }),
+        sdk: fakeSelectorSdk(calls),
+        options: { endpoint: "responses" },
+      })
+      yield* aisdk.runLanguage({
+        model: ModelV2.Info.make({
+          ...ModelV2.Info.empty(ProviderV2.ID.make("github-copilot"), ModelV2.ID.make("gpt-5")),
+          api: {
+            id: ModelV2.ID.make("gpt-5"),
+            type: "aisdk",
+            package: "test-provider",
+            settings: { endpoint: "chat" },
+          },
+        }),
+        sdk: fakeSelectorSdk(calls),
+        options: { endpoint: "chat" },
+      })
+      expect(calls).toEqual(["responses:mai-code-1-flash-picker", "chat:gpt-5"])
+    }),
+  )
+
   it.effect("uses the API model ID when selecting responses or chat", () =>
     Effect.gen(function* () {
       const plugin = yield* PluginV2.Service

+ 6 - 3
packages/llm/src/providers/github-copilot.ts

@@ -12,10 +12,12 @@ export const id = ProviderID.make("github-copilot")
 export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
   ProviderAuthOption<"optional"> & {
     readonly baseURL: string
+    readonly endpoint?: "chat" | "responses"
     readonly providerOptions?: OpenAIProviderOptionsInput
   }
 
-export const shouldUseResponsesApi = (modelID: string | ModelID) => {
+export const shouldUseResponsesApi = (modelID: string | ModelID, endpoint?: ModelOptions["endpoint"]) => {
+  if (endpoint) return endpoint === "responses"
   const model = String(modelID)
   const match = /^gpt-(\d+)/.exec(model)
   if (!match) return false
@@ -28,7 +30,7 @@ const chatRoute = OpenAIChat.route.with({ provider: id })
 const responsesRoute = OpenAIResponses.route.with({ provider: id })
 
 const defaults = (options: ModelOptions) => {
-  const { apiKey: _, auth: _auth, baseURL: _baseURL, ...rest } = options
+  const { apiKey: _, auth: _auth, baseURL: _baseURL, endpoint: _endpoint, ...rest } = options
   return rest
 }
 
@@ -53,7 +55,8 @@ export const configure = (options: ModelOptions) => {
     chatRoute.with(withOpenAIOptions(modelID, defaults(options))).model({ id: modelID })
   return {
     id,
-    model: (modelID: string | ModelID) => (shouldUseResponsesApi(modelID) ? responses(modelID) : chat(modelID)),
+    model: (modelID: string | ModelID) =>
+      shouldUseResponsesApi(modelID, options.endpoint) ? responses(modelID) : chat(modelID),
     responses,
     chat,
     configure,

+ 14 - 0
packages/llm/test/exports.test.ts

@@ -50,6 +50,20 @@ describe("public exports", () => {
     expect(
       GitHubCopilot.configure({ baseURL: "https://api.githubcopilot.test", apiKey: "fixture" }).model,
     ).toBeFunction()
+    expect(
+      GitHubCopilot.configure({
+        baseURL: "https://api.githubcopilot.test",
+        apiKey: "fixture",
+        endpoint: "responses",
+      }).model("mai-code-1-flash-picker").route.id,
+    ).toBe("openai-responses")
+    expect(
+      GitHubCopilot.configure({
+        baseURL: "https://api.githubcopilot.test",
+        apiKey: "fixture",
+        endpoint: "chat",
+      }).model("gpt-5").route.id,
+    ).toBe("openai-chat")
   })
 
   test("protocol barrels expose supported low-level routes", () => {

+ 13 - 1
packages/opencode/src/plugin/github-copilot/models.ts

@@ -72,6 +72,10 @@ type SelectableItem = Item & {
     }
   }
 }
+type CopilotEndpoint = "chat" | "responses" | "messages"
+type CopilotModel = Omit<Model, "api"> & {
+  api: Model["api"] & { endpoint?: CopilotEndpoint }
+}
 const decodeModels = Schema.decodeUnknownSync(schema)
 const decodeItem = Schema.decodeUnknownOption(item)
 
@@ -86,17 +90,25 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
     (remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
 
   const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
+  const endpoint: CopilotEndpoint | undefined = isMsgApi
+    ? "messages"
+    : remote.supported_endpoints?.includes("/responses")
+      ? "responses"
+      : remote.supported_endpoints?.includes("/chat/completions")
+        ? "chat"
+        : undefined
   const prices = remote.billing?.token_prices
   // Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
   const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
 
-  const model: Model = {
+  const model: CopilotModel = {
     id: key,
     providerID: "github-copilot",
     api: {
       id: remote.id,
       url: isMsgApi ? `${url}/v1` : url,
       npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
+      ...(endpoint ? { endpoint } : {}),
     },
     // API response wins
     status: "active",

+ 5 - 1
packages/opencode/src/provider/provider.ts

@@ -218,8 +218,12 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
     "github-copilot": () =>
       Effect.succeed({
         autoload: false,
-        async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
+        async getModel(sdk: any, modelID: string, _options?: Record<string, any>, model?: Model) {
           if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID)
+          if (model && "endpoint" in model.api) {
+            if (model.api.endpoint === "responses" && sdk.responses) return sdk.responses(modelID)
+            if (model.api.endpoint === "chat" && sdk.chat) return sdk.chat(modelID)
+          }
           const match = /^gpt-(\d+)/.exec(modelID)
           if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return sdk.responses(modelID)
           return sdk.chat(modelID)

+ 38 - 0
packages/opencode/test/plugin/github-copilot-models.test.ts

@@ -187,6 +187,44 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
   expect(models["ignored-non-chat-record"]).toBeUndefined()
 })
 
+test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => {
+  globalThis.fetch = mock(() =>
+    Promise.resolve(
+      new Response(
+        JSON.stringify({
+          data: [
+            {
+              model_picker_enabled: true,
+              id: "mai-code-1-flash-picker",
+              name: "MAI-Code-1-Flash",
+              version: "mai-code-1-flash-picker",
+              supported_endpoints: ["/responses"],
+              capabilities: {
+                family: "oswe-vscode-modelD",
+                limits: {
+                  max_context_window_tokens: 256000,
+                  max_output_tokens: 128000,
+                  max_prompt_tokens: 128000,
+                },
+                supports: {
+                  streaming: true,
+                  structured_outputs: true,
+                  tool_calls: true,
+                },
+              },
+            },
+          ],
+        }),
+        { status: 200 },
+      ),
+    ),
+  ) as unknown as typeof fetch
+
+  const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mai-code-1-flash-picker"]
+
+  expect("endpoint" in model.api ? model.api.endpoint : undefined).toBe("responses")
+})
+
 test("clears existing variants so refreshed models calculate provider-specific variants", async () => {
   globalThis.fetch = mock(() =>
     Promise.resolve(