diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 38ac4881ccdf..83410aeaa7ff 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -4,6 +4,7 @@ import { ModelsDev } from "@opencode-ai/core/models-dev" import { effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" import { ProviderV2 } from "@opencode-ai/core/provider" +import type { Provider } from "@/provider/provider" export const ModelsCommand = effectCmd({ command: "models [provider]", @@ -33,26 +34,6 @@ export const ModelsCommand = effectCmd({ const provider = yield* Provider.Service const providers = yield* provider.list() - const print = (providerID: ProviderV2.ID, verbose?: boolean) => { - const p = providers[providerID] - const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) - for (const [modelID, model] of sorted) { - process.stdout.write(`${providerID}/${modelID}`) - process.stdout.write(EOL) - if (verbose) { - process.stdout.write(JSON.stringify(model, null, 2)) - process.stdout.write(EOL) - } - } - } - - if (args.provider) { - const providerID = ProviderV2.ID.make(args.provider) - if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) - print(providerID, args.verbose) - return - } - const ids = Object.keys(providers).sort((a, b) => { const aIsOpencode = a.startsWith("opencode") const bIsOpencode = b.startsWith("opencode") @@ -61,6 +42,97 @@ export const ModelsCommand = effectCmd({ return a.localeCompare(b) }) - for (const providerID of ids) print(ProviderV2.ID.make(providerID), args.verbose) + let selected = ids + if (args.provider) { + const providerID = ProviderV2.ID.make(args.provider) + if (!providers[providerID]) return yield* fail(`Provider not found: ${args.provider}`) + selected = [providerID] + } + + const print = (providerID: ProviderV2.ID) => { + const p = providers[providerID] + const sorted = Object.entries(p.models).sort(([a], [b]) => a.localeCompare(b)) + if (args.verbose) { + process.stdout.write(formatProviderTable(providerID, p.name, sorted) + EOL + EOL) + return + } + for (const [modelID] of sorted) { + process.stdout.write(`${providerID}/${modelID}`) + process.stdout.write(EOL) + } + } + + for (const providerID of selected) print(ProviderV2.ID.make(providerID)) }), }) + +const HEADERS = ["Model", "Name", "Cost ($/1M in/out)", "Context", "Output", "Capabilities"] + +// One table per provider, so a single long model ID cannot widen every other provider's columns. +export function formatProviderTable( + providerID: string, + providerName: string, + models: [string, Provider.Model][], +): string { + const cells = models.map(([modelID, model]) => + [ + `${providerID}/${modelID}`, + model.name, + formatCost(providerID, model.cost), + formatTokens(model.limit.context), + formatTokens(model.limit.output), + formatCapabilities(model.capabilities), + ].map(singleLine), + ) + + // Widths are measured in terminal columns, so wide characters such as CJK still line up. + const widths = HEADERS.map((header, column) => + Math.max(Bun.stringWidth(header), ...cells.map((cell) => Bun.stringWidth(cell[column]))), + ) + // The last column is left unpadded so no line carries trailing whitespace. + const row = (cell: string[]) => + cell + .map((value, column) => + column === cell.length - 1 ? value : value + " ".repeat(widths[column] - Bun.stringWidth(value)), + ) + .join(" ") + + const header = row(HEADERS) + const lines = cells.map(row) + // The rules span the widest line, which can be a row whose last column outgrows its header. + const rule = "─".repeat(Math.max(...[header, ...lines].map((line) => Bun.stringWidth(line)))) + return [singleLine(providerName), rule, header, rule, ...lines].join(EOL) +} + +// Catalogue names can carry tabs or newlines (some models.dev names end in a tab), +// which would shift a row or split it in two. +function singleLine(value: string): string { + return value.replace(/\s+/g, " ").trim() +} + +// A model prices a base rate plus optional context tiers; the table shows the base rate. +// A model whose config declares no cost is stored as 0, so a zero cost only means free +// for opencode's own models, matching the "Free" label in the TUI model picker. +function formatCost(providerID: string, cost: Provider.Model["cost"]): string { + if (cost.input === 0 && cost.output === 0) return providerID === "opencode" ? "free" : "-" + return `${cost.input} / ${cost.output}` +} + +function formatTokens(count: number): string { + if (!count) return "-" + if (count >= 999_500) return `${trimDecimal(count / 1_000_000)}M` + if (count >= 1_000) return `${Math.round(count / 1_000)}K` + return String(count) +} + +function trimDecimal(value: number): string { + return value.toFixed(1).replace(/\.0$/, "") +} + +function formatCapabilities(capabilities: Provider.Model["capabilities"]): string { + const flags: string[] = [] + if (capabilities.reasoning) flags.push("reasoning") + if (capabilities.toolcall) flags.push("tools") + if (capabilities.attachment) flags.push("attachments") + return flags.length ? flags.join(", ") : "-" +} diff --git a/packages/opencode/test/cli/cmd/models.test.ts b/packages/opencode/test/cli/cmd/models.test.ts new file mode 100644 index 000000000000..75adb61ae491 --- /dev/null +++ b/packages/opencode/test/cli/cmd/models.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test" +import { EOL } from "os" +import { formatProviderTable } from "../../../src/cli/cmd/models" +import type { Provider } from "../../../src/provider/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" + +const MODALITIES = { text: true, audio: false, image: false, video: false, pdf: false } + +function model(overrides: Partial = {}): Provider.Model { + return { + id: ModelV2.ID.make("model-id"), + providerID: ProviderV2.ID.make("provider-id"), + api: { id: "api", url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: false, + input: MODALITIES, + output: MODALITIES, + interleaved: false, + }, + cost: { input: 3, output: 15, cache: { read: 0, write: 0 } }, + limit: { context: 200_000, output: 64_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", + ...overrides, + } +} + +// Returns the cells of the row for a model ID. Columns are separated by at least +// two spaces, and no fixture value contains two consecutive spaces. +function cells(table: string, id: string): string[] { + const line = table.split(EOL).find((row) => row.startsWith(id + " ")) + if (!line) throw new Error(`no row for ${id}`) + return line.split(/ {2,}/) +} + +describe("cli.models", () => { + test("aligns every column and leaves no trailing whitespace", () => { + const table = formatProviderTable("acme", "Acme", [ + ["short", model({ name: "Short" })], + ["a/much/longer/model/id", model({ name: "Longer" })], + ]) + const lines = table.split(EOL) + + for (const line of lines) expect(line).toBe(line.trimEnd()) + + // Header, rule, and every row start the Name column at the same offset. + const header = lines[2] + const nameColumn = header.indexOf("Name") + for (const line of [lines[4], lines[5]]) { + expect(line.slice(nameColumn).startsWith("Short") || line.slice(nameColumn).startsWith("Longer")).toBe(true) + } + expect(lines[0]).toBe("Acme") + expect(lines[1]).toBe("─".repeat(header.length)) + expect(lines[3]).toBe("─".repeat(header.length)) + }) + + test("extends the rules to rows whose last column is wider than its header", () => { + const table = formatProviderTable("acme", "Acme", [ + [ + "capable", + model({ capabilities: { ...model().capabilities, reasoning: true, attachment: true, toolcall: true } }), + ], + ]) + const lines = table.split(EOL) + const widest = Math.max(...lines.slice(2).map((line) => line.length)) + + // "reasoning, tools, attachments" is longer than "Capabilities". + expect(widest).toBeGreaterThan(lines[2].length) + expect(lines[1]).toBe("─".repeat(widest)) + expect(lines[3]).toBe("─".repeat(widest)) + }) + + test("prefixes model ids with the provider id", () => { + const table = formatProviderTable("acme", "Acme", [["gpt-9", model()]]) + expect(table).toContain("acme/gpt-9") + }) + + test("formats cost, limits and capabilities", () => { + const table = formatProviderTable("acme", "Acme", [ + ["priced", model({ cost: { input: 3, output: 15, cache: { read: 0, write: 0 } } })], + ["large", model({ limit: { context: 1_048_576, output: 900 } })], + [ + "capable", + model({ capabilities: { ...model().capabilities, reasoning: true, attachment: true, toolcall: true } }), + ], + ["plain", model()], + ]) + + expect(cells(table, "acme/priced")).toEqual(["acme/priced", "Model", "3 / 15", "200K", "64K", "-"]) + expect(cells(table, "acme/large").slice(3, 5)).toEqual(["1M", "900"]) + expect(cells(table, "acme/capable")[5]).toBe("reasoning, tools, attachments") + expect(cells(table, "acme/plain")[5]).toBe("-") + }) + + test("only calls a zero cost free for opencode's own models", () => { + // A config model that declares no cost is stored as 0, which means unknown, not free. + const zero = model({ cost: { input: 0, output: 0, cache: { read: 0, write: 0 } } }) + + expect(cells(formatProviderTable("opencode", "OpenCode Zen", [["zen", zero]]), "opencode/zen")[2]).toBe("free") + expect(cells(formatProviderTable("acme", "Acme", [["custom", zero]]), "acme/custom")[2]).toBe("-") + }) + + test("rounds token limits without overstating them", () => { + const limits = (context: number) => + cells(formatProviderTable("acme", "Acme", [["m", model({ limit: { context, output: 0 } })]]), "acme/m")[3] + + expect(limits(1_500_000)).toBe("1.5M") + expect(limits(2_000_000)).toBe("2M") + // Rounds up into the next unit rather than printing "1000K". + expect(limits(999_600)).toBe("1M") + expect(limits(131_072)).toBe("131K") + expect(limits(512)).toBe("512") + expect(limits(0)).toBe("-") + }) + + test("keeps rows aligned when names carry tabs, newlines or wide characters", () => { + const table = formatProviderTable("acme", "Acme", [ + // Verbatim from models.dev: this name ends in a tab. + ["tabbed", model({ name: "DeepSeek V3 (Turbo)\t" })], + ["split", model({ name: "two\nlines" })], + ["wide", model({ name: "通义千问" })], + ["plain", model({ name: "Plain" })], + ]) + const lines = table.split(EOL) + + expect(table).not.toMatch(/\t/) + // Two header rules, the header, the provider name, and one line per model. + expect(lines).toHaveLength(8) + expect(cells(table, "acme/tabbed")[1]).toBe("DeepSeek V3 (Turbo)") + expect(cells(table, "acme/split")[1]).toBe("two lines") + + // The cost column starts at the same terminal column on every row, CJK included. + const costColumn = (id: string) => { + const line = lines.find((row) => row.startsWith(id + " "))! + return Bun.stringWidth(line.slice(0, line.indexOf("3 / 15"))) + } + expect(new Set(["acme/tabbed", "acme/split", "acme/wide", "acme/plain"].map(costColumn)).size).toBe(1) + }) + + test("renders a header-only table for a provider with no models", () => { + const table = formatProviderTable("acme", "Acme", []) + const lines = table.split(EOL) + expect(lines).toHaveLength(4) + expect(lines[2]).toContain("Capabilities") + }) +}) diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx index 4e4fea2b46ac..1e4b24aad96c 100644 --- a/packages/web/src/content/docs/cli.mdx +++ b/packages/web/src/content/docs/cli.mdx @@ -334,6 +334,23 @@ Use the `--refresh` flag to update the cached model list. This is useful when ne opencode models --refresh ``` +Use the `--verbose` flag to see what each model is, as a table per provider. + +```bash +opencode models --verbose +``` + +``` +Anthropic +───────────────────────────────────────────────────────────────────────────────────────────────── +Model Name Cost ($/1M in/out) Context Output Capabilities +───────────────────────────────────────────────────────────────────────────────────────────────── +anthropic/claude-opus-4-5 Claude Opus 4.5 5 / 25 200K 64K reasoning, tools, attachments +anthropic/claude-sonnet-4-5 Claude Sonnet 4.5 3 / 15 200K 64K reasoning, tools, attachments +``` + +Costs are the base rate per 1M tokens, before any context-tier pricing. A `-` means the value is not known — for example, a custom provider's model whose config declares no cost. The default output stays one plain `provider/model` per line, so it can still be piped into other commands. + --- ### run