From c8397a9891b25c5e051f64587e4cecf2cb06127a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Wed, 23 Sep 2026 22:40:58 -0500 Subject: [PATCH] fix(opencode): resolve Copilot models to their bundled SDK and Responses endpoint Copilot serves Grok, Gemini, and MAI Code only on /responses, but the endpoint fallback only routed gpt-5+ there, so they hit /chat/completions whenever the live model metadata was missing. The catalog only carries the generic @ai-sdk/openai-compatible package for this provider, and config-defined models that do not reference a catalog model are merged after the Copilot plugin's SDK remap, so they fall through to the generic SDK instead of the bundled one required for CAPI URL, headers, and error handling. Explicit config still wins. Advertised supported_endpoints metadata still takes precedence. --- .../src/plugin/github-copilot/models.ts | 11 ++++++++ packages/opencode/src/provider/provider.ts | 18 +++++++------ .../test/plugin/github-copilot-models.test.ts | 11 ++++++++ .../opencode/test/provider/provider.test.ts | 25 +++++++++++++++++++ 4 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 8cafa567150..61631826704 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -79,6 +79,17 @@ type CopilotModel = Omit & { const decodeModels = Schema.decodeUnknownSync(schema) const decodeItem = Schema.decodeUnknownOption(item) +// Copilot serves these families only on /responses, so route them there when the +// live model metadata does not advertise supported_endpoints. Older chat-only +// families (gpt-5-mini, gpt-4*, Claude) stay on chat. +export function fallbackEndpoint(modelID: string): "responses" | undefined { + const match = /^gpt-(\d+)/.exec(modelID) + if (match && Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini")) return "responses" + if (modelID.startsWith("grok-") || modelID.startsWith("gemini-") || modelID.startsWith("mai-code-")) + return "responses" + return undefined +} + function build(key: string, remote: SelectableItem, url: string, prev?: Model): Model { const reasoning = !!remote.capabilities.supports.adaptive_thinking || diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 867ef2ca2fa..da6481ea078 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -8,6 +8,7 @@ import { NoSuchModelError, type Provider as SDK } from "ai" import { Npm } from "@opencode-ai/core/npm" import { Hash } from "@opencode-ai/core/util/hash" import { Plugin } from "../plugin" +import { CopilotModels } from "../plugin/github-copilot/models" import { serviceUse } from "@opencode-ai/core/effect/service-use" import { type LanguageModelV3 } from "@ai-sdk/provider" import { ModelsDev } from "@opencode-ai/core/models-dev" @@ -237,8 +238,7 @@ function custom(dep: CustomDep): Record { 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) + if (CopilotModels.fallbackEndpoint(modelID) === "responses") return sdk.responses(modelID) return sdk.chat(modelID) }, options: {}, @@ -1497,11 +1497,15 @@ const layer = Layer.effect( model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? - // Config-defined gateway models bypass fromModelsDevModel, so resolve the - // native passthrough npm here before falling back to the catalog default. - cloudflareGatewayNpm(providerID, apiID) ?? - modelsDev[providerID]?.npm ?? - "@ai-sdk/openai-compatible" + // Provider-specific SDKs that must not inherit the models.dev catalog + // default: GitHub Copilot needs its bundled SDK, and gateway models + // bypass fromModelsDevModel so they resolve their native passthrough npm + // before falling back to the catalog default. + (providerID === ProviderV2.ID.githubCopilot + ? "@ai-sdk/github-copilot" + : (cloudflareGatewayNpm(providerID, apiID) ?? + modelsDev[providerID]?.npm ?? + "@ai-sdk/openai-compatible")) const name = iife(() => { if (model.name) return model.name if (model.id && model.id !== modelID) return modelID diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index d968402e3b8..ad2938b91dd 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -490,3 +490,14 @@ test("remaps fallback oauth model urls to the enterprise host", async () => { expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com") expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot") }) + +test("falls back to responses for Copilot families that require it", () => { + expect(CopilotModels.fallbackEndpoint("gpt-5.6-sol")).toBe("responses") + expect(CopilotModels.fallbackEndpoint("grok-4.5")).toBe("responses") + expect(CopilotModels.fallbackEndpoint("grok-4.6")).toBe("responses") + expect(CopilotModels.fallbackEndpoint("gemini-3.5-flash")).toBe("responses") + expect(CopilotModels.fallbackEndpoint("mai-code-1.1-flash")).toBe("responses") + expect(CopilotModels.fallbackEndpoint("gpt-5-mini")).toBeUndefined() + expect(CopilotModels.fallbackEndpoint("gpt-4o")).toBeUndefined() + expect(CopilotModels.fallbackEndpoint("claude-sonnet-4.6")).toBeUndefined() +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 32d4e3a39b1..c9156e71b72 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1348,6 +1348,31 @@ it.instance( }, ) +it.instance( + "custom github-copilot models use the bundled Copilot SDK", + Effect.gen(function* () { + yield* set("GITHUB_TOKEN", "test-token") + const providers = yield* list + const model = providers[ProviderV2.ID.githubCopilot].models["copilot-grok-4.6"] + expect(model).toBeDefined() + expect(model.api.npm).toBe("@ai-sdk/github-copilot") + }), + { + config: { + provider: { + "github-copilot": { + models: { + "copilot-grok-4.6": { + name: "Copilot Grok 4.6", + limit: { context: 128000, output: 64000 }, + }, + }, + }, + }, + }, + }, +) + it.instance( "custom model inherits api.url from models.dev provider", Effect.gen(function* () {