Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions packages/opencode/src/cli/cmd/run/footer.command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1062,3 +1063,14 @@ export function RunModelSelectBody(props: {
</PanelShell>
)
}

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(/\.$/, "")
}
55 changes: 45 additions & 10 deletions packages/opencode/src/plugin/github-copilot/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
),
}),
),
}),
Expand Down Expand Up @@ -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 ||
Expand All @@ -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,
Expand All @@ -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: {
Expand All @@ -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 ?? {},
Expand Down Expand Up @@ -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 {
Expand Down
169 changes: 169 additions & 0 deletions packages/opencode/test/plugin/github-copilot-models.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
26 changes: 23 additions & 3 deletions packages/tui/src/component/dialog-model.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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)
},
Expand Down Expand Up @@ -187,7 +187,27 @@ export function sortModelOptions<T extends { footer?: string; releaseDate: strin
if (newestFirst) return sortBy(options, [(option) => 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(/\.$/, "")
}
17 changes: 14 additions & 3 deletions packages/tui/test/cli/cmd/tui/model-options.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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")
})
})
Loading