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
4 changes: 2 additions & 2 deletions plugins/opencode/agents/opencode-rescue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <value>` and `--model <value>` 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 <value>`, `--model <value>`, 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`.
Expand Down
5 changes: 3 additions & 2 deletions plugins/opencode/commands/adversarial-review.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Run a steerable adversarial OpenCode review that challenges implementation and design decisions
argument-hint: '[--wait|--background] [--base <ref>] [--model <id>] [--pr <number>] [focus area or custom review instructions]'
argument-hint: '[--wait|--background] [--base <ref>] [--model <id> | --free] [--pr <number>] [focus area or custom review instructions]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion
---
Expand Down Expand Up @@ -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 <id>` 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 <number>` 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):
Expand Down
4 changes: 2 additions & 2 deletions plugins/opencode/commands/rescue.md
Original file line number Diff line number Diff line change
@@ -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 <provider/model>] [--agent <build|plan>] [what OpenCode should investigate, solve, or continue]"
argument-hint: "[--background|--wait] [--resume|--fresh] [--model <provider/model> | --free] [--agent <build|plan>] [what OpenCode should investigate, solve, or continue]"
context: fork
allowed-tools: Bash(node:*)
---
Expand All @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion plugins/opencode/commands/review.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
description: Run an OpenCode code review against local git state or a GitHub PR
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [--model <id>] [--pr <number>]'
argument-hint: '[--wait|--background] [--base <ref>] [--scope auto|working-tree|branch] [--model <id> | --free] [--pr <number>]'
disable-model-invocation: true
allowed-tools: Read, Glob, Grep, Bash(node:*), Bash(git:*), Bash(gh:*), AskUserQuestion
---
Expand Down Expand Up @@ -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 <id>` 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 <number>` 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`.

Expand Down
119 changes: 106 additions & 13 deletions plugins/opencode/scripts/lib/model.mjs
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
// Model-string parsing for OpenCode's HTTP API.
// Model helpers for OpenCode's HTTP API.
//
// The CLI accepts `--model <provider>/<model-id>` 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
// <provider>/<model-id>` 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
Expand All @@ -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 <id>`.
*/
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<string[]>}
*/
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<any>} [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 <id>` 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 };
}
Loading