diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index c9c1d2c1670f..4c7ba5e5d94e 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -1,4 +1,5 @@ import { cmd } from "./cmd" +import { modelOptionWithAlias } from "@/cli/model-option" import * as prompts from "@clack/prompts" import { UI } from "../ui" import { Global } from "@opencode-ai/core/global" @@ -53,11 +54,7 @@ const AgentCreateCommand = effectCmd({ alias: ["tools"], describe: `comma-separated list of permissions to allow (default: all). Available: "${AVAILABLE_PERMISSIONS.join(", ")}"`, }) - .option("model", { - type: "string", - alias: ["m"], - describe: "model to use in the format of provider/model", - }), + .option("model", modelOptionWithAlias), handler: Effect.fn("Cli.agent.create")(function* (args) { const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) const { Agent } = yield* Effect.promise(() => import("../../agent/agent")) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 944aec17abd7..53b5722fac6f 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,4 +1,5 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" +import { modelOptionWithAlias } from "@/cli/model-option" import { FSUtil } from "@opencode-ai/core/fs-util" // CLI entry point for `opencode run` and `opencode --mini`. // @@ -163,11 +164,7 @@ export const RunCommand = effectCmd({ describe: "deprecated: session sharing was discontinued", hidden: true, }) - .option("model", { - type: "string", - alias: ["m"], - describe: "model to use in the format of provider/model", - }) + .option("model", modelOptionWithAlias) .option("agent", { type: "string", describe: "agent to use", diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 3a837ba31bb5..0c43b2e8358f 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -1,4 +1,5 @@ import { cmd } from "@/cli/cmd/cmd" +import { modelOptionWithAlias } from "@/cli/model-option" import { Rpc } from "@/util/rpc" import { type rpc } from "../tui/worker" import path from "path" @@ -117,11 +118,7 @@ export const TuiThreadCommand = cmd({ type: "string", describe: "path to start opencode in", }) - .option("model", { - type: "string", - alias: ["m"], - describe: "model to use in the format of provider/model", - }) + .option("model", modelOptionWithAlias) .option("continue", { alias: ["c"], describe: "continue the last session", diff --git a/packages/opencode/src/cli/model-option.ts b/packages/opencode/src/cli/model-option.ts new file mode 100644 index 000000000000..5d0d4f99f2be --- /dev/null +++ b/packages/opencode/src/cli/model-option.ts @@ -0,0 +1,9 @@ +// Utilities already use -m for other options, so only the long flag is inherited. +export const modelOption = { + type: "string" as const, + describe: "model to use in the format of provider/model", + // A later explicit selection overrides a launcher's model prefix, including across aliases. + coerce: (value: string | string[]) => (Array.isArray(value) ? value.at(-1) : value), +} + +export const modelOptionWithAlias = { ...modelOption, alias: ["m"] } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 13540a73a36f..cef670572a40 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -29,6 +29,7 @@ import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" +import { modelOption } from "./cli/model-option" const args = hideBin(process.argv) @@ -50,6 +51,7 @@ const cli = yargs(args) .alias("help", "h") .version("version", "show version number", InstallationVersion) .alias("version", "v") + .option("model", modelOption) .option("print-logs", { describe: "print logs to stderr", type: "boolean", diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index f9d9ea70ba68..0ad7c1a5b4c1 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -8,6 +8,7 @@ start ACP (Agent Client Protocol) server Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -36,6 +37,7 @@ Commands: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -52,6 +54,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -78,6 +81,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + -m, --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -85,7 +89,6 @@ Options: -c, --continue continue the last session [boolean] -s, --session session id to continue [string] --fork fork the session before continuing (requires --continue or --session) [boolean] - -m, --model model to use in the format of provider/model [string] --agent agent to use [string] --format format: default (formatted) or json (raw JSON events) [string] [choices: "default", "json"] [default: "default"] @@ -129,6 +132,7 @@ Commands: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -147,6 +151,7 @@ Commands: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -164,6 +169,7 @@ Commands: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -180,6 +186,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -196,6 +203,7 @@ uninstall opencode and remove all related files Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -213,6 +221,7 @@ starts a headless opencode server Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -233,6 +242,7 @@ start opencode server and open web interface Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -256,6 +266,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -271,6 +282,7 @@ show token usage and cost statistics Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -292,6 +304,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -309,6 +322,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -326,6 +340,7 @@ Commands: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -342,6 +357,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -359,6 +375,7 @@ Commands: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -375,6 +392,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -397,6 +415,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -411,6 +430,7 @@ list MCP servers and their status Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -427,6 +447,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -449,6 +470,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -465,6 +487,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -478,6 +501,7 @@ list providers and credentials Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -494,6 +518,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -512,6 +537,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -525,6 +551,7 @@ create a new agent Options: -h, --help show help [boolean] -v, --version show version number [boolean] + -m, --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -533,8 +560,7 @@ Options: --mode agent mode [string] [choices: "all", "primary", "subagent"] --permissions, --tools comma-separated list of permissions to allow (default: all). Available: "bash, read, edit, glob, grep, webfetch, task, todowrite, - websearch, lsp, skill" [string] - -m, --model model to use in the format of provider/model [string]" + websearch, lsp, skill" [string]" `; exports[`opencode CLI help-text snapshots every documented command emits stable help text: opencode agent list --help 1`] = ` @@ -545,6 +571,7 @@ list all available agents Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -558,6 +585,7 @@ list sessions Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -576,6 +604,7 @@ Positionals: Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -589,6 +618,7 @@ install the GitHub agent Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" @@ -602,6 +632,7 @@ run the GitHub agent Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean] @@ -617,6 +648,7 @@ print the database path Options: -h, --help show help [boolean] -v, --version show version number [boolean] + --model model to use in the format of provider/model [string] --print-logs print logs to stderr [boolean] --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] --pure run without external plugins [boolean]" diff --git a/packages/opencode/test/cli/model-option.test.ts b/packages/opencode/test/cli/model-option.test.ts new file mode 100644 index 000000000000..294a6aa21fa7 --- /dev/null +++ b/packages/opencode/test/cli/model-option.test.ts @@ -0,0 +1,111 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { cliIt } from "../lib/cli-process" +import { testProviderConfig } from "../lib/test-provider" +import path from "node:path" + +describe("global model option", () => { + cliIt.live("utilities accept a model prefix without resolving it", ({ opencode }) => + Effect.gen(function* () { + for (const prefix of [["--model", "missing/unused"], ["--model=missing/unused"]]) { + const result = yield* opencode.spawn([...prefix, "session", "list", "--format", "json"]) + opencode.expectExit(result, 0) + expect(result.stdout).toBe("") + } + const help = yield* opencode.spawn(["--model", "missing/unused", "upgrade", "--help"]) + opencode.expectExit(help, 0) + expect(help.stderr).toMatch(/-m, --method/) + expect(help.stderr).not.toMatch(/-m, --model/) + }), + ) + + cliIt.live( + "run, resume and fork retain explicit model selection and export accepts the same prefix", + ({ llm, opencode, home }) => + Effect.gen(function* () { + const config = testProviderConfig(llm.url) + const env = { + // The suite preload uses :memory:; independent CLI processes need one owned durable store. + OPENCODE_DB: path.join(home, "model-option.sqlite"), + OPENCODE_CONFIG_CONTENT: JSON.stringify({ + ...config, + model: "test/test-model", + provider: { + test: { + ...config.provider.test, + models: { + ...config.provider.test.models, + other: { ...config.provider.test.models["test-model"], id: "other", name: "Other Model" }, + }, + }, + }, + }), + } + yield* llm.textMatch((hit) => hit.body.model === "other", "first") + const first = yield* opencode.spawn(["--model", "test/other", "run", "--format", "json", "export"], { env }) + opencode.expectExit(first, 0) + expect(opencode.parseJsonEvents(first.stdout)).toContainEqual( + expect.objectContaining({ + type: "text", + part: expect.objectContaining({ text: "first" }), + }), + ) + const session = opencode.parseJsonEvents(first.stdout)[0].sessionID + if (typeof session !== "string") throw new Error("run must produce a session ID") + + for (const fork of [true, false]) { + yield* llm.textMatch((hit) => hit.body.model === "test-model", "next") + const resumed = yield* opencode.spawn( + [ + "--pure", + "--log-level", + "ERROR", + "--model", + "test/test-model", + "run", + "--session", + session, + ...(fork ? ["--fork"] : []), + "--agent", + "build", + "--format", + "json", + "--", + "literal", + "--model", + "test/other", + ], + { env }, + ) + opencode.expectExit(resumed, 0) + expect(opencode.parseJsonEvents(resumed.stdout)).toContainEqual( + expect.objectContaining({ + type: "text", + part: expect.objectContaining({ text: "next" }), + }), + ) + const id = opencode.parseJsonEvents(resumed.stdout)[0].sessionID + if (typeof id !== "string") throw new Error("resumed run must produce a session ID") + expect(id === session).toBe(!fork) + const exported = yield* opencode.spawn(["--model", "missing/unused", "export", id], { env }) + opencode.expectExit(exported, 0) + const messages = JSON.parse(exported.stdout).messages + const users = messages.filter((message: { info: { role: string } }) => message.info.role === "user") + expect(users.at(-1).info.model).toEqual({ providerID: "test", modelID: "test-model" }) + expect( + users.at(-1).parts.some((part: { text?: string }) => part.text?.includes("literal --model test/other")), + ).toBe(true) + } + for (const flag of ["--model", "-m"]) { + yield* llm.textMatch((hit) => hit.body.model === "other", "override") + const override = yield* opencode.spawn( + ["--model", "test/test-model", "run", flag, "test/other", "explicit override"], + { env }, + ) + opencode.expectExit(override, 0) + expect(override.stdout).toBe("override\n") + } + }), + 120_000, + ) +})