diff --git a/plugins/opencode/agents/opencode-rescue.md b/plugins/opencode/agents/opencode-rescue.md index dd50e66..2e8ccc3 100644 --- a/plugins/opencode/agents/opencode-rescue.md +++ b/plugins/opencode/agents/opencode-rescue.md @@ -26,8 +26,8 @@ Forwarding rules: - Do not inspect the repository, read files, grep, monitor progress, poll status, fetch results, cancel jobs, summarize output, or do any follow-up work of your own. - Do not call `review`, `adversarial-review`, `status`, `result`, or `cancel`. This subagent only forwards to `task`. - Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan). -- Leave model unset by default. Only add `--model` when the user explicitly asks for a specific model. -- Treat `--agent ` and `--model ` as runtime controls and do not include them in the task text you pass through. +- Leave model unset by default. Only add `--model` or `--free` when the user explicitly asks for a specific model or a free-tier pick. `--free` and `--model` are mutually exclusive. +- Treat `--agent `, `--model `, and `--free` as runtime controls and do not include them in the task text you pass through. - Default to a write-capable OpenCode run by adding `--write` unless the user explicitly asks for read-only behavior or only wants review, diagnosis, or research without edits. - Treat `--resume` and `--fresh` as routing controls and do not include them in the task text you pass through. - `--resume` means add `--resume-last`. diff --git a/plugins/opencode/commands/adversarial-review.md b/plugins/opencode/commands/adversarial-review.md index 503e628..5d4513e 100644 --- a/plugins/opencode/commands/adversarial-review.md +++ b/plugins/opencode/commands/adversarial-review.md @@ -1,6 +1,6 @@ --- description: Run a steerable adversarial OpenCode review that challenges implementation and design decisions -argument-hint: '[--wait|--background] [--base ] [--model ] [--pr ] [focus area or custom review instructions]' +argument-hint: '[--wait|--background] [--base ] [--model | --free] [--pr ] [focus area or custom review instructions]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion --- @@ -34,10 +34,11 @@ Execution mode rules: Argument handling: - Preserve the user's arguments exactly. -- Do not strip `--wait`, `--background`, `--model`, or `--pr` yourself. +- Do not strip `--wait`, `--background`, `--model`, `--free`, or `--pr` yourself. - Adversarial reviews support custom focus text. Any text after flags is treated as a focus area. - The companion script handles `--adversarial` internally. - `--model ` overrides OpenCode's default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it. +- `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given. - `--pr ` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated. PR reference extraction (REQUIRED — read this carefully): diff --git a/plugins/opencode/commands/rescue.md b/plugins/opencode/commands/rescue.md index bb7d676..1a74d7f 100644 --- a/plugins/opencode/commands/rescue.md +++ b/plugins/opencode/commands/rescue.md @@ -1,6 +1,6 @@ --- description: Delegate investigation, an explicit fix request, or follow-up rescue work to the OpenCode rescue subagent -argument-hint: "[--background|--wait] [--resume|--fresh] [--model ] [--agent ] [what OpenCode should investigate, solve, or continue]" +argument-hint: "[--background|--wait] [--resume|--fresh] [--model | --free] [--agent ] [what OpenCode should investigate, solve, or continue]" context: fork allowed-tools: Bash(node:*) --- @@ -17,7 +17,7 @@ Execution mode: - If the request includes `--wait`, run the `opencode:opencode-rescue` subagent in the foreground. - If neither flag is present, default to foreground. - `--background` and `--wait` are execution flags for Claude Code. Do not forward them to `task`, and do not treat them as part of the natural-language task text. -- `--model` and `--agent` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. +- `--model`, `--free`, and `--agent` are runtime-selection flags. Preserve them for the forwarded `task` call, but do not treat them as part of the natural-language task text. `--free` tells the companion to pick a random first-party `opencode/*` free-tier model from `opencode models`; it is restricted to `opencode/*` because OpenRouter free models have inconsistent tool-use support. `--free` is mutually exclusive with `--model`. - If the request includes `--resume`, do not ask whether to continue. The user already chose. - If the request includes `--fresh`, do not ask whether to continue. The user already chose. - Otherwise, before starting OpenCode, check for a resumable rescue session from this Claude session by running: diff --git a/plugins/opencode/commands/review.md b/plugins/opencode/commands/review.md index 94889af..7ae6a12 100644 --- a/plugins/opencode/commands/review.md +++ b/plugins/opencode/commands/review.md @@ -1,6 +1,6 @@ --- description: Run an OpenCode code review against local git state or a GitHub PR -argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model ] [--pr ]' +argument-hint: '[--wait|--background] [--base ] [--scope auto|working-tree|branch] [--model | --free] [--pr ]' disable-model-invocation: true allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion --- @@ -40,6 +40,7 @@ Argument handling: - `/opencode:review` is native-review only. It does not support staged-only review, unstaged-only review, or extra focus text. - If the user needs custom review instructions or more adversarial framing, they should use `/opencode:adversarial-review`. - `--model ` overrides OpenCode's default model for this single review (e.g. `--model openrouter/anthropic/claude-opus-4-6`). Pass it through verbatim if the user supplied it. +- `--free` tells the companion script to shell out to `opencode models`, filter for first-party `opencode/*` free-tier models (those ending in `:free` or `-free`), and pick one at random for this review. Restricted to the `opencode/*` provider because OpenRouter free-tier models have inconsistent tool-use support, and the review agent needs `read`/`grep`/`glob`/`list`. Pass it through verbatim if the user supplied it. `--free` and `--model` are mutually exclusive — the companion will error if both are given. - `--pr ` reviews a GitHub pull request via `gh pr diff` instead of the local working tree. The cwd must be a git repo whose remote points at the PR's repository, and `gh` must be installed and authenticated. Pass it through verbatim if the user supplied it. - **PR reference extraction (REQUIRED)**: if the user's input contains a PR reference like `PR #390`, `pr #390`, `PR 390`, or `pr 390` (e.g. `/opencode:review on PR #390`), you MUST extract the number yourself and pass it as `--pr 390`. Do not pass `PR #390` literally to bash — bash strips unquoted `#NNN` tokens as comments before they reach the companion script. Example: `node ... review --pr 390`, NOT `node ... review on PR #390`. diff --git a/plugins/opencode/scripts/lib/model.mjs b/plugins/opencode/scripts/lib/model.mjs index 45287ea..9ec54f7 100644 --- a/plugins/opencode/scripts/lib/model.mjs +++ b/plugins/opencode/scripts/lib/model.mjs @@ -1,22 +1,31 @@ -// Model-string parsing for OpenCode's HTTP API. +// Model helpers for OpenCode's HTTP API. // -// The CLI accepts `--model /` as a plain string -// (e.g. `openrouter/anthropic/claude-haiku-4.5`), but OpenCode's -// `POST /session/:id/message` endpoint rejects a string in the `model` -// field with HTTP 400: +// Two concerns live here: // -// {"error":[{"expected":"object","path":["model"], -// "message":"Invalid input: expected object, received string"}]} +// 1. CLI model-string parsing. The CLI accepts `--model +// /` as a plain string (e.g. +// `openrouter/anthropic/claude-haiku-4.5`), but OpenCode's +// `POST /session/:id/message` endpoint rejects a string in the +// `model` field with HTTP 400: // -// It expects `{ providerID, modelID }` instead. This helper parses the -// CLI string into that shape. The first `/` splits provider from model -// id, so `openrouter/anthropic/claude-haiku-4.5` → providerID -// "openrouter", modelID "anthropic/claude-haiku-4.5". Any remaining -// slashes belong to the model id because providers frequently namespace -// their models (e.g. `anthropic/...`). +// {"error":[{"expected":"object","path":["model"], +// "message":"Invalid input: expected object, received string"}]} +// +// It expects `{ providerID, modelID }` instead. `parseModelString` +// converts at the CLI boundary. The first `/` splits provider from +// model id, so `openrouter/anthropic/claude-haiku-4.5` → providerID +// "openrouter", modelID "anthropic/claude-haiku-4.5". +// +// 2. `--free` flag support. `listOpencodeModels` shells out to +// `opencode models` (one `provider/model-id` per line) and +// `selectFreeModel` filters for the `:free` or `-free` suffix and +// picks one at random. Both helpers take dependency-injected +// hooks (`run`, `rng`) so tests don't need a real opencode binary. // // (Apache License 2.0 §4(b) modification notice — see NOTICE.) +import { runCommand } from "./process.mjs"; + /** * Parse a `"provider/model-id"` CLI string into OpenCode's expected * `{providerID, modelID}` shape. Returns null for empty/invalid input @@ -43,3 +52,87 @@ export function parseModelString(input) { return { providerID, modelID }; } + +/** + * Regex matching OpenCode's free-tier model suffixes. `:free` is the + * OpenRouter convention, `-free` is the opencode/* convention. + */ +const FREE_MODEL_SUFFIX = /(?::free|-free)$/i; + +/** + * Regex restricting `--free` picks to the first-party `opencode/*` + * provider. OpenRouter's `:free` models have highly variable tool-use + * support (many route to endpoints that return + * `No endpoints found that support tool use`), which breaks the + * review agent since it needs read/grep/glob/list. First-party + * `opencode/*` models always support tool use, so restricting `--free` + * to that provider makes the flag reliable by default. Users who need + * a specific non-opencode model can still use `--model `. + */ +const OPENCODE_PROVIDER = /^opencode\//i; + +/** + * Shell out to `opencode models` and return the raw newline-separated + * list of `provider/model-id` strings. Blank lines and whitespace are + * discarded; no filtering is applied here. + * + * @param {object} [opts] + * @param {(cmd: string, args: string[]) => Promise<{ stdout: string, stderr: string, exitCode: number }>} [opts.run] + * Dependency-injected command runner — defaults to `runCommand` from + * lib/process.mjs. Tests override this to avoid needing a real + * opencode binary. + * @returns {Promise} + */ +export async function listOpencodeModels({ run = runCommand } = {}) { + const result = await run("opencode", ["models"]); + if (result.exitCode !== 0) { + const detail = result.stderr?.trim() || `exit code ${result.exitCode}`; + throw new Error(`\`opencode models\` failed: ${detail}`); + } + return result.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +/** + * Pick a free-tier model at random from `opencode models`. Restricts + * the pool to the first-party `opencode/*` provider so the chosen + * model always supports tool use (see `OPENCODE_PROVIDER` above for + * the rationale). Returns the usual `{providerID, modelID, raw}` + * triple so callers can pass it to `sendPrompt` and log the raw form. + * + * Throws a descriptive error if the user has no `opencode/*` free + * models available, so `--free` invocations fail loudly instead of + * silently falling back to a paid default. + * + * @param {object} [opts] + * @param {() => number} [opts.rng] - deterministic rng for tests, default Math.random + * @param {(cmd: string, args: string[]) => Promise} [opts.run] - runCommand override for tests + * @returns {Promise<{ providerID: string, modelID: string, raw: string }>} + */ +export async function selectFreeModel({ rng = Math.random, run } = {}) { + const models = await listOpencodeModels(run ? { run } : {}); + const free = models.filter( + (m) => FREE_MODEL_SUFFIX.test(m) && OPENCODE_PROVIDER.test(m), + ); + + if (free.length === 0) { + throw new Error( + "`--free` was requested, but `opencode models` returned no first-party " + + "`opencode/*` free-tier models. `--free` is restricted to opencode-native " + + "models because OpenRouter free-tier models have inconsistent tool-use " + + "support. Use `--model ` to target a specific free model on another " + + "provider, or run `/opencode:setup` to configure the opencode provider." + ); + } + + const idx = Math.floor(rng() * free.length); + const raw = free[Math.min(idx, free.length - 1)]; + const parsed = parseModelString(raw); + if (!parsed) { + // Shouldn't happen — `opencode models` output is always provider/... + throw new Error(`Unable to parse free model string: ${raw}`); + } + return { ...parsed, raw }; +} diff --git a/plugins/opencode/scripts/opencode-companion.mjs b/plugins/opencode/scripts/opencode-companion.mjs index 137d04e..4ff2215 100644 --- a/plugins/opencode/scripts/opencode-companion.mjs +++ b/plugins/opencode/scripts/opencode-companion.mjs @@ -32,7 +32,18 @@ // `{providerID, modelID}` object before sending. Passing the raw // CLI string caused HTTP 400 ("expected object, received string") // on every `--model` invocation — the original threading commit -// wired the argument through but never adapted the shape. +// wired the argument through but never adapted the shape; +// - add a `--free` flag to review, adversarial-review, and task +// handlers that shells out to `opencode models`, filters for the +// `:free` / `-free` suffix, and picks one at random so callers +// can cheaply fire off reviews/tasks against whichever free-tier +// model is available without hand-picking. Mutually exclusive +// with `--model`. For background tasks the free model is locked +// in at dispatch so the worker can't drift; +// - fix `handleTask` silently dropping `--model`: the foreground +// path passed `{agent}` to sendPrompt with no model, and the +// background worker never parsed or forwarded one. Both paths +// now honor `--model` / `--free` end-to-end. // (Apache License 2.0 §4(b) modification notice.) import path from "node:path"; @@ -58,7 +69,7 @@ import { buildReviewPrompt, buildTaskPrompt } from "./lib/prompts.mjs"; import { getDiff, getStatus as getGitStatus, detectPrReference } from "./lib/git.mjs"; import { readJson } from "./lib/fs.mjs"; import { resolveReviewAgent } from "./lib/review-agent.mjs"; -import { parseModelString } from "./lib/model.mjs"; +import { parseModelString, selectFreeModel } from "./lib/model.mjs"; const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || path.resolve(import.meta.dirname, ".."); @@ -153,10 +164,42 @@ async function handleSetup(argv) { // Review // ------------------------------------------------------------------ +/** + * Resolve the model the user requested. Handles three cases: + * - `--free` : pick a random free model via `opencode models` + * - `--model X` : parse X into {providerID, modelID} + * - neither : return null (use the user's configured default) + * + * `--free` and `--model` are mutually exclusive. Errors bubble up to the + * handler, which prints them and exits non-zero. + * + * @param {{ free?: boolean, model?: string }} options + * @returns {Promise<{providerID: string, modelID: string, raw?: string} | null>} + */ +async function resolveRequestedModel(options) { + if (options.free && options.model) { + throw new Error("--free and --model are mutually exclusive; pick one."); + } + if (options.free) { + return selectFreeModel(); + } + if (options.model) { + const parsed = parseModelString(options.model); + if (!parsed) { + throw new Error( + `Invalid --model value: ${options.model} (expected "provider/model-id", ` + + `e.g. openrouter/anthropic/claude-haiku-4.5)` + ); + } + return { ...parsed, raw: options.model }; + } + return null; +} + async function handleReview(argv) { const { options } = parseArgs(argv, { valueOptions: ["base", "scope", "model", "pr"], - booleanOptions: ["wait", "background"], + booleanOptions: ["wait", "background", "free"], }); const prNumber = options.pr ? Number(options.pr) : null; @@ -165,6 +208,14 @@ async function handleReview(argv) { process.exit(1); } + let requestedModel; + try { + requestedModel = await resolveRequestedModel(options); + } catch (err) { + console.error(err.message); + process.exit(1); + } + const workspace = await resolveWorkspace(); const job = createJobRecord(workspace, "review", { base: options.base, @@ -188,14 +239,14 @@ async function handleReview(argv) { }, PLUGIN_ROOT); const reviewAgent = await resolveReviewAgent(client, log); - const model = parseModelString(options.model); + const modelLabel = requestedModel?.raw ?? requestedModel ?? null; report("reviewing", "Running review..."); - log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}${options.model ? `, model: ${options.model}` : ""}${prNumber ? `, pr: #${prNumber}` : ""}`); + log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}${modelLabel ? `, model: ${modelLabel}${options.free ? " (--free picked)" : ""}` : ""}${prNumber ? `, pr: #${prNumber}` : ""}`); const response = await client.sendPrompt(session.id, prompt, { agent: reviewAgent.agent, - model, + model: requestedModel ? { providerID: requestedModel.providerID, modelID: requestedModel.modelID } : null, tools: reviewAgent.tools, }); @@ -224,9 +275,17 @@ async function handleReview(argv) { async function handleAdversarialReview(argv) { const { options, positional } = parseArgs(argv, { valueOptions: ["base", "scope", "model", "pr"], - booleanOptions: ["wait", "background"], + booleanOptions: ["wait", "background", "free"], }); + let requestedModel; + try { + requestedModel = await resolveRequestedModel(options); + } catch (err) { + console.error(err.message); + process.exit(1); + } + let focus = positional.join(" ").trim(); // --pr takes precedence; otherwise look for "PR #N" / "PR N" inside the @@ -272,14 +331,14 @@ async function handleAdversarialReview(argv) { }, PLUGIN_ROOT); const reviewAgent = await resolveReviewAgent(client, log); - const model = parseModelString(options.model); + const modelLabel = requestedModel?.raw ?? null; report("reviewing", "Running adversarial review..."); - log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}, focus: ${focus || "(none)"}${options.model ? `, model: ${options.model}` : ""}${prNumber ? `, pr: #${prNumber}` : ""}`); + log(`Prompt length: ${prompt.length} chars, agent: ${reviewAgent.agent}, focus: ${focus || "(none)"}${modelLabel ? `, model: ${modelLabel}${options.free ? " (--free picked)" : ""}` : ""}${prNumber ? `, pr: #${prNumber}` : ""}`); const response = await client.sendPrompt(session.id, prompt, { agent: reviewAgent.agent, - model, + model: requestedModel ? { providerID: requestedModel.providerID, modelID: requestedModel.modelID } : null, tools: reviewAgent.tools, }); @@ -311,11 +370,11 @@ async function handleAdversarialReview(argv) { async function handleTask(argv) { const { options, positional } = parseArgs(argv, { valueOptions: ["model", "agent"], - booleanOptions: ["write", "background", "wait", "resume-last", "fresh"], + booleanOptions: ["write", "background", "wait", "resume-last", "fresh", "free"], }); const taskText = extractTaskText(argv, ["model", "agent"], [ - "write", "background", "wait", "resume-last", "fresh", + "write", "background", "wait", "resume-last", "fresh", "free", ]); if (!taskText) { @@ -323,6 +382,17 @@ async function handleTask(argv) { process.exit(1); } + // Resolve --free / --model once here so background workers inherit a + // concrete model string and can't drift if `opencode models` changes + // between dispatch and execution. + let requestedModel; + try { + requestedModel = await resolveRequestedModel(options); + } catch (err) { + console.error(err.message); + process.exit(1); + } + const workspace = await resolveWorkspace(); const isWrite = options.write !== undefined ? options.write : true; const agentName = options.agent ?? (isWrite ? "build" : "plan"); @@ -359,10 +429,20 @@ async function handleTask(argv) { ]; if (isWrite) workerArgs.push("--write"); if (resumeSessionId) workerArgs.push("--resume-session", resumeSessionId); - if (options.model) workerArgs.push("--model", options.model); + // Pass the resolved model (from --model or --free) as a concrete + // "provider/model-id" string so the worker doesn't need to re-run + // `opencode models`. + if (requestedModel?.raw) { + workerArgs.push("--model", requestedModel.raw); + } else if (requestedModel) { + workerArgs.push("--model", `${requestedModel.providerID}/${requestedModel.modelID}`); + } spawnDetached("node", workerArgs, { cwd: workspace }); console.log(`OpenCode task started in background: ${job.id}`); + if (options.free && requestedModel) { + console.log(`--free picked: ${requestedModel.raw}`); + } console.log("Check `/opencode:status` for progress."); return; } @@ -387,10 +467,11 @@ async function handleTask(argv) { const prompt = buildTaskPrompt(taskText, { write: isWrite }); report("investigating", "Sending task to OpenCode..."); - log(`Agent: ${agentName}, Write: ${isWrite}, Prompt: ${prompt.length} chars`); + log(`Agent: ${agentName}, Write: ${isWrite}, Prompt: ${prompt.length} chars${requestedModel?.raw ? `, model: ${requestedModel.raw}${options.free ? " (--free picked)" : ""}` : ""}`); const response = await client.sendPrompt(sessionId, prompt, { agent: agentName, + model: requestedModel ? { providerID: requestedModel.providerID, modelID: requestedModel.modelID } : null, }); report("finalizing", "Processing task output..."); @@ -437,6 +518,10 @@ async function handleTaskWorker(argv) { const agentName = options.agent ?? "build"; const isWrite = !!options.write; const resumeSessionId = options["resume-session"]; + // Parent `handleTask` resolves --free/--model into a concrete + // provider/model-id string before spawning us, so the worker just + // needs to parse and forward it. + const workerModel = parseModelString(options.model); if (!workspace || !jobId || !taskText) { process.exit(1); @@ -460,9 +545,11 @@ async function handleTaskWorker(argv) { const prompt = buildTaskPrompt(taskText, { write: isWrite }); report("investigating", "Running task..."); + if (workerModel) log(`Worker model: ${options.model}`); const response = await client.sendPrompt(sessionId, prompt, { agent: agentName, + model: workerModel, }); const text = extractResponseText(response); diff --git a/plugins/opencode/skills/opencode-runtime/SKILL.md b/plugins/opencode/skills/opencode-runtime/SKILL.md index 7d99a5d..ea41db0 100644 --- a/plugins/opencode/skills/opencode-runtime/SKILL.md +++ b/plugins/opencode/skills/opencode-runtime/SKILL.md @@ -19,12 +19,14 @@ Execution rules: - You may use the `opencode-prompting` skill to rewrite the user's request into a tighter OpenCode prompt before the single `task` call. - That prompt drafting is the only Claude-side work allowed. Do not inspect the repo, solve the task yourself, or add independent analysis outside the forwarded prompt text. - Leave `--agent` unset unless the user explicitly requests a specific agent (build or plan). -- Leave model unset by default. Add `--model` only when the user explicitly asks for one. +- Leave model unset by default. Add `--model` only when the user explicitly asks for one, or `--free` when they explicitly ask for a free-tier pick. `--free` and `--model` are mutually exclusive. Command selection: - Use exactly one `task` invocation per rescue handoff. - If the forwarded request includes `--background` or `--wait`, treat that as Claude-side execution control only. Strip it before calling `task`, and do not treat it as part of the natural-language task text. - If the forwarded request includes `--model`, pass it through to `task`. +- If the forwarded request includes `--free`, pass it through to `task`. The companion will shell out to `opencode models`, filter for first-party `opencode/*` free-tier entries (`:free` or `-free`), and pick one at random. `--free` is restricted to `opencode/*` because OpenRouter free models have inconsistent tool-use support. +- If the forwarded request includes both `--free` and `--model`, do not invoke `task` — return nothing, because the companion will reject the combination. - If the forwarded request includes `--agent`, pass it through to `task`. - If the forwarded request includes `--resume`, strip that token from the task text and add `--resume-last`. - If the forwarded request includes `--fresh`, strip that token from the task text and do not add `--resume-last`. diff --git a/tests/model.test.mjs b/tests/model.test.mjs index c6454c7..1da4435 100644 --- a/tests/model.test.mjs +++ b/tests/model.test.mjs @@ -1,12 +1,18 @@ // Unit tests for scripts/lib/model.mjs. // -// These assert that the CLI `--model /` string is -// transformed into the `{providerID, modelID}` object shape that -// OpenCode's `POST /session/:id/message` endpoint expects. +// Covers both model-string parsing (used for `--model`) and the +// `opencode models` shell-out + free-model selection (used for +// `--free`). The shell-out helpers take injected `run` and `rng` +// callbacks, so tests don't need a real opencode binary or non- +// deterministic randomness. import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { parseModelString } from "../plugins/opencode/scripts/lib/model.mjs"; +import { + parseModelString, + listOpencodeModels, + selectFreeModel, +} from "../plugins/opencode/scripts/lib/model.mjs"; describe("parseModelString", () => { it("splits on the first slash so nested model ids stay intact", () => { @@ -61,3 +67,151 @@ describe("parseModelString", () => { assert.equal(parseModelString({ providerID: "x", modelID: "y" }), null); }); }); + +// Fake `opencode models` stdout fixture, modeled on the real output +// that the current user's opencode binary produced — 31 free models +// scattered among ~600 total. +const FAKE_OPENCODE_MODELS_STDOUT = [ + "opencode/big-pickle", + "opencode/gpt-5-nano", + "opencode/minimax-m2.5-free", + "opencode/nemotron-3-super-free", + "openrouter/anthropic/claude-haiku-4.5", + "openrouter/anthropic/claude-opus-4.6", + "openrouter/anthropic/claude-sonnet-4.6", + "openrouter/google/gemma-3-27b-it", + "openrouter/google/gemma-3-27b-it:free", + "openrouter/google/gemma-3-4b-it:free", + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + "openrouter/minimax/minimax-m2.5:free", + "openrouter/moonshotai/kimi-k2:free", + "openrouter/qwen/qwen-max", + "", // blank line — should be ignored + " ", // whitespace-only — should be ignored +].join("\n"); + +function makeFakeRun(stdout, { exitCode = 0, stderr = "" } = {}) { + return async (cmd, args) => { + assert.equal(cmd, "opencode"); + assert.deepEqual(args, ["models"]); + return { stdout, stderr, exitCode }; + }; +} + +describe("listOpencodeModels", () => { + it("parses the opencode models output into one model per line", async () => { + const run = makeFakeRun(FAKE_OPENCODE_MODELS_STDOUT); + const models = await listOpencodeModels({ run }); + assert.equal(models.length, 14); + assert.equal(models[0], "opencode/big-pickle"); + assert.ok(models.includes("openrouter/minimax/minimax-m2.5:free")); + }); + + it("filters out blank and whitespace-only lines", async () => { + const run = makeFakeRun("foo/bar\n\n \nbaz/qux\n"); + const models = await listOpencodeModels({ run }); + assert.deepEqual(models, ["foo/bar", "baz/qux"]); + }); + + it("throws a helpful error when opencode models exits non-zero", async () => { + const run = makeFakeRun("", { exitCode: 1, stderr: "opencode: command not found" }); + await assert.rejects( + () => listOpencodeModels({ run }), + /opencode models.*command not found/i, + ); + }); + + it("throws with the exit code when stderr is empty", async () => { + const run = makeFakeRun("", { exitCode: 127 }); + await assert.rejects( + () => listOpencodeModels({ run }), + /exit code 127/, + ); + }); +}); + +describe("selectFreeModel", () => { + it("picks the first opencode/* free model when rng=0", async () => { + const run = makeFakeRun(FAKE_OPENCODE_MODELS_STDOUT); + const result = await selectFreeModel({ run, rng: () => 0 }); + assert.equal(result.raw, "opencode/minimax-m2.5-free"); + assert.equal(result.providerID, "opencode"); + assert.equal(result.modelID, "minimax-m2.5-free"); + }); + + it("picks the last opencode/* free model when rng returns just under 1", async () => { + const run = makeFakeRun(FAKE_OPENCODE_MODELS_STDOUT); + const result = await selectFreeModel({ run, rng: () => 0.999999 }); + // Fixture has two opencode/* free models; last is opencode/nemotron-3-super-free. + assert.equal(result.raw, "opencode/nemotron-3-super-free"); + }); + + it("honors the injected rng deterministically", async () => { + const run = makeFakeRun(FAKE_OPENCODE_MODELS_STDOUT); + // 2 opencode/* free models; rng=0.5 → idx=1. + const result = await selectFreeModel({ run, rng: () => 0.5 }); + assert.equal(result.raw, "opencode/nemotron-3-super-free"); + }); + + it("excludes openrouter/* free models even though they match the suffix regex", async () => { + const run = makeFakeRun(FAKE_OPENCODE_MODELS_STDOUT); + const seen = new Set(); + for (let i = 0; i < 20; i += 1) { + const r = await selectFreeModel({ run, rng: () => i / 20 }); + seen.add(r.raw); + } + // Only the two opencode/* free entries should ever be picked, + // never any of the openrouter/* ":free" entries in the fixture. + assert.deepEqual( + [...seen].sort(), + ["opencode/minimax-m2.5-free", "opencode/nemotron-3-super-free"], + ); + }); + + it("only picks models whose suffix is :free or -free (not substring 'free' elsewhere)", async () => { + const run = makeFakeRun([ + "opencode/freedom-v1", // contains free but doesn't end with the suffix + "opencode/model-free", // -free suffix ✓ + "opencode/other:free", // :free suffix ✓ + ].join("\n")); + const seen = new Set(); + for (let i = 0; i < 20; i += 1) { + const r = await selectFreeModel({ run, rng: () => i / 20 }); + seen.add(r.raw); + } + assert.equal(seen.size, 2); + assert.ok(seen.has("opencode/model-free")); + assert.ok(seen.has("opencode/other:free")); + assert.ok(!seen.has("opencode/freedom-v1")); + }); + + it("throws a descriptive error when no opencode/* free models are available", async () => { + // Has openrouter/* free models (which we now reject) but no opencode/* ones. + const run = makeFakeRun([ + "opencode/big-pickle", // opencode but not free + "openrouter/anthropic/claude-haiku-4.5", // neither + "openrouter/google/gemma-3-27b-it:free", // free but wrong provider + "openrouter/meta-llama/llama-3.3-70b-instruct:free", + ].join("\n")); + await assert.rejects( + () => selectFreeModel({ run, rng: () => 0.5 }), + /no first-party `opencode\/\*` free-tier models/i, + ); + }); + + it("throws when there are no free models at all", async () => { + const run = makeFakeRun("opencode/big-pickle\nopencode/gpt-5-nano\n"); + await assert.rejects( + () => selectFreeModel({ run, rng: () => 0.5 }), + /no first-party `opencode\/\*` free-tier models/i, + ); + }); + + it("propagates listOpencodeModels errors unchanged", async () => { + const run = makeFakeRun("", { exitCode: 127 }); + await assert.rejects( + () => selectFreeModel({ run, rng: () => 0 }), + /exit code 127/, + ); + }); +});