Compare commits

..
Author SHA1 Message Date
Aiden Cline c8397a9891 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.
2026-09-23 23:05:09 -05:00
9 changed files with 59 additions and 26 deletions
@@ -36,7 +36,6 @@ export const goModels = [
fresh: true,
},
{ id: "longcat-2.0", name: "LongCat-2.0", requests: 11400, allowance: 60 },
{ id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", requests: 13000, allowance: 30 },
{
id: "mimo-v2.6-flash",
name: "MiMo-V2.6-Flash",
@@ -79,6 +79,17 @@ type CopilotModel = Omit<Model, "api"> & {
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 ||
+11 -7
View File
@@ -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<string, CustomLoader> {
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
@@ -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()
})
@@ -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* () {
+1 -5
View File
@@ -207,11 +207,7 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
inArray(geoStat.dataset, scope.datasets),
inArray(geoStat.client, scope.clients),
inArray(geoStat.source, scope.sources),
or(
inArray(geoStat.provider, RETIRED_STAT_PROVIDERS),
inArray(geoStat.model, RETIRED_STAT_MODELS),
and(eq(geoStat.provider, "unknown"), eq(geoStat.model, "hy4-preview")),
),
or(inArray(geoStat.provider, RETIRED_STAT_PROVIDERS), inArray(geoStat.model, RETIRED_STAT_MODELS)),
),
),
catch: (cause) => DatabaseError.make({ cause }),
@@ -57,17 +57,6 @@ describe("inference stat normalization", () => {
expect(statProvider("unknown", "", "custom-provider")).toBe("custom-provider")
})
test("attributes hy4 preview traffic to Tencent instead of the unknown provider", () => {
expect(modelAuthor("hy4-preview")).toBe("tencent")
expect(toModelAggregate(aggregate("hy4-preview", "opencode"))).toMatchObject([
{ model: "hy4-preview", provider: "tencent" },
])
expect(toProviderAggregate(aggregate("hy4-preview", "opencode"))).toMatchObject([{ provider: "tencent" }])
expect(toGeoAggregate({ ...aggregate("hy4-preview", "opencode"), country: "US" })).toMatchObject([
{ model: "hy4-preview", provider: "tencent" },
])
})
test("maps oversized model ids to unknown before aggregation", () => {
expect(statModel("x".repeat(256), "")).toBe("x".repeat(256))
expect(statModel("x".repeat(257), "")).toBe("unknown")
@@ -6,7 +6,6 @@ export const MODEL_AUTHOR_RULES = [
{ match: "gpt", author: "openai" },
{ match: "grok", author: "xai" },
{ match: "hy3", author: "tencent" },
{ match: "hy4", author: "tencent" },
{ match: "kimi", author: "moonshot" },
{ match: "mimo", author: "xiaomi" },
{ match: "minimax", author: "minimax" },
-1
View File
@@ -195,7 +195,6 @@ export class ModelStatRepo extends Context.Service<ModelStatRepo, ModelStatRepo.
or(
inArray(modelStat.provider, RETIRED_STAT_PROVIDERS),
inArray(modelStat.model, RETIRED_STAT_MODELS),
and(eq(modelStat.provider, "unknown"), eq(modelStat.model, "hy4-preview")),
),
),
),