From 4dc4b85f0464370f795519cb09e15db4bd349c3a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 9 Jul 2026 12:56:11 -0500 Subject: [PATCH 1/4] fix(copilot): handle zero billing batch size --- .../src/plugin/github-copilot/models.ts | 2 +- .../test/plugin/github-copilot-models.test.ts | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 4e571103a5b5..f3f32a0b0b92 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -99,7 +99,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): : 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 usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0 const model: CopilotModel = { id: key, diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 35bfaa6cb417..e6d89fe01cc7 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -187,6 +187,60 @@ test("converts Copilot AIC token prices to USD per million tokens", async () => expect(models["ignored-non-chat-record"]).toBeUndefined() }) +test("uses zero cost when Copilot reports a zero billing batch size", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "mercury-alpha", + name: "Mercury Alpha", + version: "mercury-alpha-2026-07-09", + billing: { + token_prices: { + batch_size: 0, + default: { + input_price: 0, + output_price: 0, + cache_price: 0, + }, + }, + }, + capabilities: { + family: "mercury", + limits: { + max_context_window_tokens: 128000, + max_output_tokens: 16384, + max_prompt_tokens: 128000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mercury-alpha"] + + expect(model.cost).toEqual({ + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }) + expect(JSON.stringify(model)).not.toContain("null") +}) + test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => { globalThis.fetch = mock(() => Promise.resolve( From 6404db996c8ea748cebeaa50fadab8c947bfcba3 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 9 Jul 2026 13:00:17 -0500 Subject: [PATCH 2/4] fix(copilot): sanitize synced model metadata --- .../src/plugin/github-copilot/models.ts | 65 ++++++++++++------- .../test/plugin/github-copilot-models.test.ts | 38 ++++++++++- 2 files changed, 76 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index f3f32a0b0b92..538fbab815ec 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -1,6 +1,8 @@ import type { Model } from "@opencode-ai/sdk/v2" import { Option, Schema } from "effect" +const billingNumber = Schema.optional(Schema.NullOr(Schema.Finite)) + const item = Schema.Struct({ model_picker_enabled: Schema.Boolean, id: Schema.String, @@ -14,30 +16,38 @@ const item = Schema.Struct({ }), ), billing: Schema.optional( - Schema.Struct({ - token_prices: Schema.optional( - Schema.Struct({ - batch_size: Schema.Number, - default: Schema.Struct({ - cache_price: Schema.Number, - input_price: Schema.Number, - output_price: Schema.Number, - }), - }), - ), - }), + Schema.NullOr( + Schema.Struct({ + token_prices: Schema.optional( + Schema.NullOr( + Schema.Struct({ + batch_size: billingNumber, + default: Schema.optional( + Schema.NullOr( + Schema.Struct({ + cache_price: billingNumber, + input_price: billingNumber, + output_price: billingNumber, + }), + ), + ), + }), + ), + ), + }), + ), ), capabilities: Schema.Struct({ family: Schema.String, limits: Schema.optional( Schema.Struct({ - max_context_window_tokens: Schema.optional(Schema.Number), - max_output_tokens: Schema.optional(Schema.Number), - max_prompt_tokens: Schema.optional(Schema.Number), + max_context_window_tokens: Schema.optional(Schema.Finite), + max_output_tokens: Schema.optional(Schema.Finite), + max_prompt_tokens: Schema.optional(Schema.Finite), vision: Schema.optional( Schema.Struct({ - max_prompt_image_size: Schema.Number, - max_prompt_images: Schema.Number, + max_prompt_image_size: Schema.Finite, + max_prompt_images: Schema.Finite, supported_media_types: Schema.Array(Schema.String), }), ), @@ -45,8 +55,8 @@ const item = Schema.Struct({ ), supports: Schema.Struct({ adaptive_thinking: Schema.optional(Schema.Boolean), - max_thinking_budget: Schema.optional(Schema.Number), - min_thinking_budget: Schema.optional(Schema.Number), + max_thinking_budget: Schema.optional(Schema.Finite), + min_thinking_budget: Schema.optional(Schema.Finite), reasoning_effort: Schema.optional(Schema.Array(Schema.String)), streaming: Schema.optional(Schema.Boolean), structured_outputs: Schema.optional(Schema.Boolean), @@ -98,8 +108,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): ? "chat" : undefined const prices = remote.billing?.token_prices - // Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens. - const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0 + const batch = prices?.batch_size const model: CopilotModel = { id: key, @@ -142,10 +151,10 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): family: prev?.family ?? remote.capabilities.family, name: prev?.name ?? remote.name, cost: { - input: (prices?.default.input_price ?? 0) * usdPerMillion, - output: (prices?.default.output_price ?? 0) * usdPerMillion, + input: convertCost(prices?.default?.input_price, batch), + output: convertCost(prices?.default?.output_price, batch), cache: { - read: (prices?.default.cache_price ?? 0) * usdPerMillion, + read: convertCost(prices?.default?.cache_price, batch), // `/models` exposes cached-input reads only; per-request billing accounts for cache writes. write: 0, }, @@ -201,6 +210,14 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): return model } +// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens. +function convertCost(value: number | null | undefined, batch: number | null | undefined) { + if (value === null || value === undefined || value < 0 || batch === null || batch === undefined || batch <= 0) + return 0 + const result = (value / batch) * 10_000 + return Number.isFinite(result) ? result : 0 +} + function usable(item: Item): item is SelectableItem { return ( item.policy?.state !== "disabled" && diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index e6d89fe01cc7..3889fc396d03 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -187,7 +187,7 @@ test("converts Copilot AIC token prices to USD per million tokens", async () => expect(models["ignored-non-chat-record"]).toBeUndefined() }) -test("uses zero cost when Copilot reports a zero billing batch size", async () => { +test("uses finite zero costs when Copilot reports invalid billing metadata", async () => { globalThis.fetch = mock(() => Promise.resolve( new Response( @@ -204,7 +204,7 @@ test("uses zero cost when Copilot reports a zero billing batch size", async () = default: { input_price: 0, output_price: 0, - cache_price: 0, + cache_price: null, }, }, }, @@ -221,6 +221,34 @@ test("uses zero cost when Copilot reports a zero billing batch size", async () = }, }, }, + { + model_picker_enabled: true, + id: "overflow-model", + name: "Overflow Model", + version: "overflow-model-2026-07-09", + billing: { + token_prices: { + batch_size: Number.MIN_VALUE, + default: { + input_price: Number.MAX_VALUE, + output_price: -1, + cache_price: 1, + }, + }, + }, + capabilities: { + family: "test", + limits: { + max_context_window_tokens: 128000, + max_output_tokens: 16384, + max_prompt_tokens: 128000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, ], }), { status: 200 }, @@ -228,7 +256,9 @@ test("uses zero cost when Copilot reports a zero billing batch size", async () = ), ) as unknown as typeof fetch - const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mercury-alpha"] + const models = (await CopilotModels.get("https://api.githubcopilot.com")).models + const model = models["mercury-alpha"] + const overflow = models["overflow-model"] expect(model.cost).toEqual({ input: 0, @@ -238,7 +268,9 @@ test("uses zero cost when Copilot reports a zero billing batch size", async () = write: 0, }, }) + expect(overflow.cost).toEqual(model.cost) expect(JSON.stringify(model)).not.toContain("null") + expect(JSON.stringify(overflow)).not.toContain("null") }) test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => { From d8c43bcc14153705dfb1852e80d5ee1537868502 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 9 Jul 2026 13:10:36 -0500 Subject: [PATCH 3/4] Revert "fix(copilot): sanitize synced model metadata" This reverts commit 6404db996c8ea748cebeaa50fadab8c947bfcba3. --- .../src/plugin/github-copilot/models.ts | 65 +++++++------------ .../test/plugin/github-copilot-models.test.ts | 38 +---------- 2 files changed, 27 insertions(+), 76 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 538fbab815ec..f3f32a0b0b92 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -1,8 +1,6 @@ import type { Model } from "@opencode-ai/sdk/v2" import { Option, Schema } from "effect" -const billingNumber = Schema.optional(Schema.NullOr(Schema.Finite)) - const item = Schema.Struct({ model_picker_enabled: Schema.Boolean, id: Schema.String, @@ -16,38 +14,30 @@ const item = Schema.Struct({ }), ), billing: Schema.optional( - Schema.NullOr( - Schema.Struct({ - token_prices: Schema.optional( - Schema.NullOr( - Schema.Struct({ - batch_size: billingNumber, - default: Schema.optional( - Schema.NullOr( - Schema.Struct({ - cache_price: billingNumber, - input_price: billingNumber, - output_price: billingNumber, - }), - ), - ), - }), - ), - ), - }), - ), + Schema.Struct({ + token_prices: Schema.optional( + Schema.Struct({ + batch_size: Schema.Number, + default: Schema.Struct({ + cache_price: Schema.Number, + input_price: Schema.Number, + output_price: Schema.Number, + }), + }), + ), + }), ), capabilities: Schema.Struct({ family: Schema.String, limits: Schema.optional( Schema.Struct({ - max_context_window_tokens: Schema.optional(Schema.Finite), - max_output_tokens: Schema.optional(Schema.Finite), - max_prompt_tokens: Schema.optional(Schema.Finite), + max_context_window_tokens: Schema.optional(Schema.Number), + max_output_tokens: Schema.optional(Schema.Number), + max_prompt_tokens: Schema.optional(Schema.Number), vision: Schema.optional( Schema.Struct({ - max_prompt_image_size: Schema.Finite, - max_prompt_images: Schema.Finite, + max_prompt_image_size: Schema.Number, + max_prompt_images: Schema.Number, supported_media_types: Schema.Array(Schema.String), }), ), @@ -55,8 +45,8 @@ const item = Schema.Struct({ ), supports: Schema.Struct({ adaptive_thinking: Schema.optional(Schema.Boolean), - max_thinking_budget: Schema.optional(Schema.Finite), - min_thinking_budget: Schema.optional(Schema.Finite), + max_thinking_budget: Schema.optional(Schema.Number), + min_thinking_budget: Schema.optional(Schema.Number), reasoning_effort: Schema.optional(Schema.Array(Schema.String)), streaming: Schema.optional(Schema.Boolean), structured_outputs: Schema.optional(Schema.Boolean), @@ -108,7 +98,8 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): ? "chat" : undefined const prices = remote.billing?.token_prices - const batch = prices?.batch_size + // Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens. + const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0 const model: CopilotModel = { id: key, @@ -151,10 +142,10 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): family: prev?.family ?? remote.capabilities.family, name: prev?.name ?? remote.name, cost: { - input: convertCost(prices?.default?.input_price, batch), - output: convertCost(prices?.default?.output_price, batch), + input: (prices?.default.input_price ?? 0) * usdPerMillion, + output: (prices?.default.output_price ?? 0) * usdPerMillion, cache: { - read: convertCost(prices?.default?.cache_price, batch), + read: (prices?.default.cache_price ?? 0) * usdPerMillion, // `/models` exposes cached-input reads only; per-request billing accounts for cache writes. write: 0, }, @@ -210,14 +201,6 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): return model } -// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens. -function convertCost(value: number | null | undefined, batch: number | null | undefined) { - if (value === null || value === undefined || value < 0 || batch === null || batch === undefined || batch <= 0) - return 0 - const result = (value / batch) * 10_000 - return Number.isFinite(result) ? result : 0 -} - function usable(item: Item): item is SelectableItem { return ( item.policy?.state !== "disabled" && diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 3889fc396d03..e6d89fe01cc7 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -187,7 +187,7 @@ test("converts Copilot AIC token prices to USD per million tokens", async () => expect(models["ignored-non-chat-record"]).toBeUndefined() }) -test("uses finite zero costs when Copilot reports invalid billing metadata", async () => { +test("uses zero cost when Copilot reports a zero billing batch size", async () => { globalThis.fetch = mock(() => Promise.resolve( new Response( @@ -204,7 +204,7 @@ test("uses finite zero costs when Copilot reports invalid billing metadata", asy default: { input_price: 0, output_price: 0, - cache_price: null, + cache_price: 0, }, }, }, @@ -221,34 +221,6 @@ test("uses finite zero costs when Copilot reports invalid billing metadata", asy }, }, }, - { - model_picker_enabled: true, - id: "overflow-model", - name: "Overflow Model", - version: "overflow-model-2026-07-09", - billing: { - token_prices: { - batch_size: Number.MIN_VALUE, - default: { - input_price: Number.MAX_VALUE, - output_price: -1, - cache_price: 1, - }, - }, - }, - capabilities: { - family: "test", - limits: { - max_context_window_tokens: 128000, - max_output_tokens: 16384, - max_prompt_tokens: 128000, - }, - supports: { - streaming: true, - tool_calls: true, - }, - }, - }, ], }), { status: 200 }, @@ -256,9 +228,7 @@ test("uses finite zero costs when Copilot reports invalid billing metadata", asy ), ) as unknown as typeof fetch - const models = (await CopilotModels.get("https://api.githubcopilot.com")).models - const model = models["mercury-alpha"] - const overflow = models["overflow-model"] + const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mercury-alpha"] expect(model.cost).toEqual({ input: 0, @@ -268,9 +238,7 @@ test("uses finite zero costs when Copilot reports invalid billing metadata", asy write: 0, }, }) - expect(overflow.cost).toEqual(model.cost) expect(JSON.stringify(model)).not.toContain("null") - expect(JSON.stringify(overflow)).not.toContain("null") }) test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => { From 45361d8056218bb34943ccaa38c16caed224c3d9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Thu, 9 Jul 2026 13:11:45 -0500 Subject: [PATCH 4/4] fix(provider): isolate invalid models --- packages/opencode/src/provider/provider.ts | 16 ++++++++---- .../opencode/test/provider/provider.test.ts | 26 +++++++++++++++++++ 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b1c5c935e9ac..c0a222d649bb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1071,11 +1071,17 @@ export type ConfigProvidersResult = Types.DeepMutable { - if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined - if (typeof value === "bigint") return value.toString() - return value - }), + JSON.stringify( + { + ...provider, + models: Object.fromEntries(Object.entries(provider.models).filter(([, model]) => Schema.is(Model)(model))), + }, + (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }, + ), ) } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 18ec8f9fbeeb..c27877c1f7be 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1426,6 +1426,32 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) +test("public provider info omits invalid models", () => { + const provider = Provider.fromModelsDevProvider({ + id: "test", + name: "Test", + env: [], + models: { + valid: { + id: "valid", + name: "Valid", + cost: { input: 1, output: 1 }, + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + provider.models.invalid = { + ...provider.models.valid, + id: ModelV2.ID.make("invalid"), + cost: { ...provider.models.valid.cost, input: Number.NaN }, + } + + const result = Provider.toPublicInfo(provider) + + expect(result.models.valid).toBeDefined() + expect(result.models.invalid).toBeUndefined() +}) + it.instance("model variants are generated for reasoning models", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key")