Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 0 additions & 2 deletions opencode.json
Original file line number Diff line number Diff line change
@@ -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"]
}
18 changes: 17 additions & 1 deletion src/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -829,7 +845,7 @@ export function generateOpencodeModels(entries: ModelEntry[]): Record<string, un

const model: Record<string, unknown> = {
id: entry.id,
name: entry.name,
name: modelDisplayName(entry),
reasoning: entry.reasoning,
tool_call: entry.tool_call,
attachment: entry.attachment ?? false,
Expand Down
4 changes: 2 additions & 2 deletions src/v2models.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
40 changes: 39 additions & 1 deletion tests/unit/v2models.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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));
Expand Down
Loading