Skip to content
Open
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
114 changes: 93 additions & 21 deletions packages/opencode/src/cli/cmd/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]",
Expand Down Expand Up @@ -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")
Expand All @@ -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(", ") : "-"
}
153 changes: 153 additions & 0 deletions packages/opencode/test/cli/cmd/models.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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")
})
})
17 changes: 17 additions & 0 deletions packages/web/src/content/docs/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading