diff --git a/README.md b/README.md index 7de3664..dd5b8f9 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This package is based on **[FanFan4204/opencode-commandcode-provider](https://gi - Release date, family, input limits, model status, vendor context limits, and matched context-price tiers flow through the bundled catalog. V2 gets native release, family, input, status, and tier fields; V1 keeps its supported fields and base prices, with `context_over_200k` where representable. - The provider's `supported_endpoints` metadata chooses each model's API route. Messages is preferred when advertised; otherwise Chat Completions is preferred, and Responses is used only when it is the sole advertised route. This avoids the malformed Responses function-call start event observed on DeepSeek V4.1 Flash `#max` in OpenCode V2.0.18: `response.output_item.added` contained a `function_call` item without its required `arguments` string. That exact tool-call flow now passes through Chat Completions on V1 and V2. Responses mappings remain available for response-only models, but the current catalog has none. Claude Sonnet 4.6 returned `MODEL_NOT_IN_PLAN` with the message “available in Pro and above plans or extra on-demand usage”; entitled Claude access has not been live-verified. If a model fails on an account entitled to use it, please [open an issue](https://github.com/BrainerVirus/opencode-commandcode/issues) with the model ID and OpenCode version, or submit a PR with a reproducible fix. - Quiet OpenCode startup (diagnostics go to `startup.json`, not stdout). +- Free models show `(Free)` after their display name on V1 and V2 when all catalog prices are zero. Model IDs and routing stay unchanged; this is a name suffix, not a native desktop badge. Names that already end in `Free` or `(Free)` keep their existing label. ## How it works diff --git a/opencode.json b/opencode.json index 34f2c01..ee930ed 100644 --- a/opencode.json +++ b/opencode.json @@ -1,6 +1,4 @@ { "$schema": "https://opencode.ai/config.json", - "model": "commandcode/deepseek-v4-flash", - "small_model": "commandcode/deepseek-v4-flash", "plugins": ["@brainervirus/opencode-commandcode"] } diff --git a/src/catalog.ts b/src/catalog.ts index 7d4de5c..6613893 100644 --- a/src/catalog.ts +++ b/src/catalog.ts @@ -783,6 +783,22 @@ export function toConfigKey(id: string): string { return short.toLowerCase(); } +/** Display name with a `(Free)` suffix when every catalog price is $0. Wire id untouched. */ +export function modelDisplayName(entry: ModelEntry): string { + const prices = [entry.cost, ...(entry.cost.tiers ?? [])]; + if (entry.cost.context_over_200k) prices.push(entry.cost.context_over_200k); + const free = prices.every( + (price) => + price.input === 0 && + price.output === 0 && + (price.cache_read ?? 0) === 0 && + (price.cache_write ?? 0) === 0, + ); + // Sync appends both "(Free)" (models.dev SKUs) and " Free" (disambiguateModelNames). + // The leading space/parens keep it a suffix, so "Carefree" still gets the label. + return free && !/(?:\sfree|\(free\))$/i.test(entry.name) ? `${entry.name} (Free)` : entry.name; +} + export function usesAnthropicMessagesApi(id: string): boolean { return /(?:^|[/:])claude-/i.test(id); } @@ -829,7 +845,7 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record = { id: entry.id, - name: entry.name, + name: modelDisplayName(entry), reasoning: entry.reasoning, tool_call: entry.tool_call, attachment: entry.attachment ?? false, diff --git a/src/v2models.ts b/src/v2models.ts index 503c177..8f5cfa5 100644 --- a/src/v2models.ts +++ b/src/v2models.ts @@ -1,4 +1,4 @@ -import { modelApi, toConfigKey, type ModelEntry } from "./catalog.js"; +import { modelApi, modelDisplayName, toConfigKey, type ModelEntry } from "./catalog.js"; /** Minimal V2 model shape (structural subset of Model.Info). */ export interface V2Model { @@ -53,7 +53,7 @@ export function toV2Model(entry: ModelEntry): V2Model { id, modelID: entry.id, providerID: "commandcode", - name: entry.name, + name: modelDisplayName(entry), ...(entry.family !== undefined ? { family: entry.family } : {}), capabilities: { tools: entry.tool_call, diff --git a/tests/unit/v2models.test.ts b/tests/unit/v2models.test.ts index 831ab31..1074e9b 100644 --- a/tests/unit/v2models.test.ts +++ b/tests/unit/v2models.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { generateV2Models, toV2Model } from "@/src/v2models.ts"; -import { toConfigKey, type ModelEntry } from "@/src/catalog.ts"; +import { generateOpencodeModels, toConfigKey, type ModelEntry } from "@/src/catalog.ts"; const base: ModelEntry = { id: "anthropic/claude-sonnet-4-6", @@ -15,6 +15,44 @@ const base: ModelEntry = { modalities: { input: ["text", "image"], output: ["text"] }, }; +test.each([ + { name: "Cat Jam", cost: { input: 0, output: 0 }, expected: "Cat Jam (Free)" }, + { name: "Cat Jam (Free)", cost: { input: 0, output: 0 }, expected: "Cat Jam (Free)" }, + { name: "Cat Jam Free", cost: { input: 0, output: 0 }, expected: "Cat Jam Free" }, + { name: "Carefree", cost: { input: 0, output: 0 }, expected: "Carefree (Free)" }, + { name: "Paid", cost: { input: 0, output: 1 }, expected: "Paid" }, + { name: "Paid", cost: { input: 1, output: 0 }, expected: "Paid" }, + { name: "Cached", cost: { input: 0, output: 0, cache_write: 1 }, expected: "Cached" }, + { name: "Read", cost: { input: 0, output: 0, cache_read: 1 }, expected: "Read" }, + { + name: "Tiered", + cost: { + input: 0, + output: 0, + tiers: [{ input: 0, output: 1, tier: { type: "context" as const, size: 100000 } }], + }, + expected: "Tiered", + }, + { + name: "Tiered", + cost: { input: 0, output: 0, context_over_200k: { input: 1, output: 2 } }, + expected: "Tiered", + }, +])( + "Given $name pricing, both generations show $expected without changing IDs", + ({ name, cost, expected }) => { + const entry = { ...base, id: "stealth/cat-jam:free", name, cost }; + const v1 = generateOpencodeModels([entry])["cat-jam:free"] as { name: string; id: string }; + const v2 = toV2Model(entry); + expect(v1.name).toBe(expected); + expect(v2.name).toBe(expected); + expect(v1.id).toBe("stealth/cat-jam:free"); + expect(v2.modelID).toBe("stealth/cat-jam:free"); + expect(v2.id).toBe("cat-jam:free"); + expect(entry.name).toBe(name); + }, +); + test("toV2Model maps V1 entry to V2 capabilities/cost/limit", () => { const m = toV2Model(base); expect(m.id).toBe(toConfigKey(base.id));