Skip to content
Closed
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
7 changes: 2 additions & 5 deletions packages/opencode/src/cli/cmd/agent.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"))
Expand Down
7 changes: 2 additions & 5 deletions packages/opencode/src/cli/cmd/run.ts
Original file line number Diff line number Diff line change
@@ -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`.
//
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 2 additions & 5 deletions packages/opencode/src/cli/cmd/tui.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/model-option.ts
Original file line number Diff line number Diff line change
@@ -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"] }
2 changes: 2 additions & 0 deletions packages/opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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",
Expand Down

Large diffs are not rendered by default.

111 changes: 111 additions & 0 deletions packages/opencode/test/cli/model-option.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
Loading