diff --git a/packages/opencode/src/cli/cmd/run/footer.command.tsx b/packages/opencode/src/cli/cmd/run/footer.command.tsx index c97af0ce09a5..ccdf7dc124d1 100644 --- a/packages/opencode/src/cli/cmd/run/footer.command.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.command.tsx @@ -958,13 +958,14 @@ export function RunModelSelectBody(props: { .map(([modelID, model]) => { const title = model.name ?? modelID const current = props.current()?.providerID === provider.id && props.current()?.modelID === modelID + const context = formatContext(model.limit?.input ?? model.limit?.context) const footer = current ? "current" : model.cost?.input === 0 && provider.id === "opencode" - ? "Free" + ? [context, "Free"].filter((part): part is string => part !== undefined).join(" · ") : title !== modelID - ? modelID - : undefined + ? [context, modelID].filter((part): part is string => part !== undefined).join(" · ") + : context return { providerID: provider.id, modelID, @@ -1062,3 +1063,14 @@ export function RunModelSelectBody(props: { ) } + +function formatContext(tokens?: number) { + if (!tokens) return undefined + if (tokens >= 1_000_000) return `${formatNumber(tokens / 1_000_000)}M ctx` + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K ctx` + return `${tokens} ctx` +} + +function formatNumber(value: number) { + return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "") +} diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 7e6608e6036e..d41065cea58b 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -20,9 +20,20 @@ const item = Schema.Struct({ batch_size: Schema.Number, default: Schema.Struct({ cache_price: Schema.Number, + cache_write_price: Schema.optional(Schema.Number), + context_max: Schema.optional(Schema.Number), input_price: Schema.Number, output_price: Schema.Number, }), + long_context: Schema.optional( + Schema.Struct({ + cache_price: Schema.Number, + cache_write_price: Schema.optional(Schema.Number), + context_max: Schema.Number, + input_price: Schema.Number, + output_price: Schema.Number, + }), + ), }), ), }), @@ -75,7 +86,13 @@ type SelectableItem = Item & { const decodeModels = Schema.decodeUnknownSync(schema) const decodeItem = Schema.decodeUnknownOption(item) -function build(key: string, remote: SelectableItem, url: string, prev?: Model): Model { +function build( + key: string, + remote: SelectableItem, + url: string, + prev?: Model, + context?: "default" | "long", +): Model { const reasoning = !!remote.capabilities.supports.adaptive_thinking || !!remote.capabilities.supports.reasoning_effort?.length || @@ -87,8 +104,13 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): const isMsgApi = remote.supported_endpoints?.includes("/v1/messages") const prices = remote.billing?.token_prices + const price = context === "long" ? (prices?.long_context ?? prices?.default) : prices?.default // Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens. const usdPerMillion = prices ? 10_000 / prices.batch_size : 0 + const inputLimit = + context === "default" && prices?.long_context + ? (price?.context_max ?? remote.capabilities.limits.max_prompt_tokens) + : remote.capabilities.limits.max_prompt_tokens const model: Model = { id: key, @@ -101,8 +123,11 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): // API response wins status: "active", limit: { - context: remote.capabilities.limits.max_context_window_tokens ?? remote.capabilities.limits.max_prompt_tokens, - input: remote.capabilities.limits.max_prompt_tokens, + context: + context === "default" && prices?.long_context + ? inputLimit + : remote.capabilities.limits.max_context_window_tokens ?? remote.capabilities.limits.max_prompt_tokens, + input: inputLimit, output: remote.capabilities.limits.max_output_tokens, }, capabilities: { @@ -128,14 +153,14 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): }, // existing wins family: prev?.family ?? remote.capabilities.family, - name: prev?.name ?? remote.name, + name: prev?.name ?? (context === "long" ? `${remote.name} Long` : remote.name), cost: { - input: (prices?.default.input_price ?? 0) * usdPerMillion, - output: (prices?.default.output_price ?? 0) * usdPerMillion, + input: (price?.input_price ?? 0) * usdPerMillion, + output: (price?.output_price ?? 0) * usdPerMillion, cache: { - read: (prices?.default.cache_price ?? 0) * usdPerMillion, + read: (price?.cache_price ?? 0) * usdPerMillion, // `/models` exposes cached-input reads only; per-request billing accounts for cache writes. - write: 0, + write: (price?.cache_write_price ?? 0) * usdPerMillion, }, }, options: prev?.options ?? {}, @@ -228,13 +253,23 @@ export async function get( delete result[key] continue } - result[key] = build(key, m, baseURL, model) + result[key] = build( + key, + m, + baseURL, + model, + key.endsWith("-long-context") ? "long" : "default", + ) } // add new endpoint models not already keyed in result for (const [id, m] of remote) { + if (m.billing?.token_prices?.long_context && m.billing.token_prices.default.context_max) { + const longID = `${id}-long-context` + if (!(longID in result)) result[longID] = build(longID, m, baseURL, undefined, "long") + } if (id in result) continue - result[id] = build(id, m, baseURL) + result[id] = build(id, m, baseURL, undefined, "default") } return { diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 1a63f3cb92f4..6711fd0d940b 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -187,6 +187,175 @@ test("converts Copilot AIC token prices to USD per million tokens", async () => expect(models["ignored-non-chat-record"]).toBeUndefined() }) +test("uses Copilot API limits for each model", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "gpt-4o", + name: "GPT-4o", + version: "gpt-4o-2024-05-13", + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 64000, + max_output_tokens: 16384, + max_prompt_tokens: 64000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + { + model_picker_enabled: true, + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + version: "claude-sonnet-4.6-2026-02-24", + capabilities: { + family: "claude-sonnet", + limits: { + max_context_window_tokens: 200000, + max_output_tokens: 32000, + max_prompt_tokens: 160000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + { + model_picker_enabled: true, + id: "gpt-5.4-extended", + name: "GPT-5.4 Extended", + version: "gpt-5.4-extended-2026-04-01", + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 1000000, + max_output_tokens: 128000, + max_prompt_tokens: 872000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const models = (await CopilotModels.get("https://api.githubcopilot.com")).models + + expect(models["gpt-4o"].limit).toEqual({ + context: 64000, + input: 64000, + output: 16384, + }) + expect(models["claude-sonnet-4.6"].limit).toEqual({ + context: 200000, + input: 160000, + output: 32000, + }) + expect(models["gpt-5.4-extended"].limit).toEqual({ + context: 1000000, + input: 872000, + output: 128000, + }) +}) + +test("adds long context choices for Copilot tiered-context models", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "gpt-5.4", + name: "GPT-5.4", + version: "gpt-5.4-2026-03-05", + billing: { + token_prices: { + batch_size: 1000000, + default: { + cache_price: 25, + cache_write_price: 0, + context_max: 272000, + input_price: 250, + output_price: 1500, + }, + long_context: { + cache_price: 50, + cache_write_price: 0, + context_max: 922000, + input_price: 500, + output_price: 2250, + }, + }, + }, + capabilities: { + family: "gpt", + limits: { + max_context_window_tokens: 1050000, + max_output_tokens: 128000, + max_prompt_tokens: 922000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const models = (await CopilotModels.get("https://api.githubcopilot.com")).models + + expect(models["gpt-5.4"].api.id).toBe("gpt-5.4") + expect(models["gpt-5.4"].limit).toEqual({ + context: 272000, + input: 272000, + output: 128000, + }) + expect(models["gpt-5.4"].cost).toEqual({ + input: 2.5, + output: 15, + cache: { + read: 0.25, + write: 0, + }, + }) + expect(models["gpt-5.4-long-context"].api.id).toBe("gpt-5.4") + expect(models["gpt-5.4-long-context"].name).toBe("GPT-5.4 Long") + expect(models["gpt-5.4-long-context"].limit).toEqual({ + context: 1050000, + input: 922000, + output: 128000, + }) + expect(models["gpt-5.4-long-context"].cost).toEqual({ + input: 5, + output: 22.5, + cache: { + read: 0.5, + write: 0, + }, + }) +}) + test("clears existing variants so refreshed models calculate provider-specific variants", async () => { globalThis.fetch = mock(() => Promise.resolve( diff --git a/packages/tui/src/component/dialog-model.tsx b/packages/tui/src/component/dialog-model.tsx index ff8715ef85f3..60b59dc74dec 100644 --- a/packages/tui/src/component/dialog-model.tsx +++ b/packages/tui/src/component/dialog-model.tsx @@ -41,7 +41,7 @@ export function DialogModel(props: { providerID?: string }) { description: provider.name, category, disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + footer: modelFooter(model, provider.id), onSelect: () => { onSelect(provider.id, model.id) }, @@ -79,7 +79,7 @@ export function DialogModel(props: { providerID?: string }) { : undefined, category: connected() ? provider.name : undefined, disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + footer: modelFooter(info, provider.id), onSelect() { onSelect(provider.id, model) }, @@ -187,7 +187,27 @@ export function sortModelOptions option.releaseDate, "desc"], (option) => option.title) return sortBy( options, - (option) => option.footer !== "Free", + (option) => !option.footer?.includes("Free"), (option) => option.title, ) } + +export function modelFooter( + model: { cost?: { input?: number }; limit?: { context?: number; input?: number } }, + providerID: string, +) { + const parts = [formatContext(model.limit?.input ?? model.limit?.context)] + if (model.cost?.input === 0 && providerID === "opencode") parts.push("Free") + return parts.filter((part): part is string => part !== undefined).join(" · ") || undefined +} + +export function formatContext(tokens?: number) { + if (!tokens) return undefined + if (tokens >= 1_000_000) return `${formatNumber(tokens / 1_000_000)}M ctx` + if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K ctx` + return `${tokens} ctx` +} + +function formatNumber(value: number) { + return Number.isInteger(value) ? value.toString() : value.toFixed(2).replace(/0+$/, "").replace(/\.$/, "") +} diff --git a/packages/tui/test/cli/cmd/tui/model-options.test.ts b/packages/tui/test/cli/cmd/tui/model-options.test.ts index e0b4fc5ab714..f073dad995a0 100644 --- a/packages/tui/test/cli/cmd/tui/model-options.test.ts +++ b/packages/tui/test/cli/cmd/tui/model-options.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { sortModelOptions } from "../../../../src/component/dialog-model" +import { formatContext, modelFooter, sortModelOptions } from "../../../../src/component/dialog-model" describe("sortModelOptions", () => { test("orders provider-scoped model choices by newest release first", () => { @@ -19,12 +19,23 @@ describe("sortModelOptions", () => { const sorted = sortModelOptions( [ { title: "Beta", releaseDate: "2026-01-01" }, - { title: "Alpha", releaseDate: "2025-01-01", footer: "Free" }, - { title: "Gamma", releaseDate: "2024-01-01", footer: "Free" }, + { title: "Alpha", releaseDate: "2025-01-01", footer: "4K ctx · Free" }, + { title: "Gamma", releaseDate: "2024-01-01", footer: "8K ctx · Free" }, ], false, ) expect(sorted.map((model) => model.title)).toEqual(["Alpha", "Gamma", "Beta"]) }) + + test("formats model context footer", () => { + expect(formatContext(272000)).toBe("272K ctx") + expect(formatContext(936000)).toBe("936K ctx") + expect(formatContext(1000000)).toBe("1M ctx") + expect(formatContext(1050000)).toBe("1.05M ctx") + expect(modelFooter({ limit: { input: 272000, context: 1050000 }, cost: { input: 2.5 } }, "github-copilot")).toBe( + "272K ctx", + ) + expect(modelFooter({ limit: { context: 4000 }, cost: { input: 0 } }, "opencode")).toBe("4K ctx · Free") + }) })