From 373cd08b9844fd9106d14543529955060560d173 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:23:57 -0500 Subject: [PATCH] fix(copilot): honor advertised model endpoints (#34958) --- .../src/plugin/provider/github-copilot.ts | 34 +++++++++-------- .../plugin/provider-github-copilot.test.ts | 36 ++++++++++++++++++ packages/llm/src/providers/github-copilot.ts | 9 +++-- packages/llm/test/exports.test.ts | 14 +++++++ .../src/plugin/github-copilot/models.ts | 14 ++++++- packages/opencode/src/provider/provider.ts | 6 ++- .../test/plugin/github-copilot-models.test.ts | 38 +++++++++++++++++++ 7 files changed, 131 insertions(+), 20 deletions(-) diff --git a/packages/core/src/plugin/provider/github-copilot.ts b/packages/core/src/plugin/provider/github-copilot.ts index 682579d7a9..6fd5b50fb4 100644 --- a/packages/core/src/plugin/provider/github-copilot.ts +++ b/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) }), ) }), -}) +} diff --git a/packages/core/test/plugin/provider-github-copilot.test.ts b/packages/core/test/plugin/provider-github-copilot.test.ts index beef89ae8c..b88f04fe4c 100644 --- a/packages/core/test/plugin/provider-github-copilot.test.ts +++ b/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 diff --git a/packages/llm/src/providers/github-copilot.ts b/packages/llm/src/providers/github-copilot.ts index fa6bba7422..cc776d3b15 100644 --- a/packages/llm/src/providers/github-copilot.ts +++ b/packages/llm/src/providers/github-copilot.ts @@ -12,10 +12,12 @@ export const id = ProviderID.make("github-copilot") export type ModelOptions = Omit & 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, diff --git a/packages/llm/test/exports.test.ts b/packages/llm/test/exports.test.ts index 693a21638b..4bed7e2e13 100644 --- a/packages/llm/test/exports.test.ts +++ b/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", () => { diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 7e6608e603..4e571103a5 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -72,6 +72,10 @@ type SelectableItem = Item & { } } } +type CopilotEndpoint = "chat" | "responses" | "messages" +type CopilotModel = Omit & { + 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", diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 0ece8e9ead..c979fc4465 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -218,8 +218,12 @@ function custom(dep: CustomDep): Record { "github-copilot": () => Effect.succeed({ autoload: false, - async getModel(sdk: any, modelID: string, _options?: Record) { + async getModel(sdk: any, modelID: string, _options?: Record, 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) diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 1a63f3cb92..35bfaa6cb4 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/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(