diff --git a/packages/cli/src/commands/commands.ts b/packages/cli/src/commands/commands.ts index 2d87705a59..fea2480274 100644 --- a/packages/cli/src/commands/commands.ts +++ b/packages/cli/src/commands/commands.ts @@ -171,7 +171,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO ), model: Flag.string("model").pipe( Flag.withAlias("m"), - Flag.withDescription("Model to use in the format provider/model"), + Flag.withDescription("Model to use in the format provider/model#variant"), Flag.optional, ), agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), Flag.optional), @@ -185,7 +185,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.atMost(100), ), title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), - variant: Flag.string("variant").pipe(Flag.withDescription("Model variant"), Flag.optional), thinking: Flag.boolean("thinking").pipe( Flag.withDescription("Show thinking blocks"), Flag.withDefault(false), @@ -195,10 +194,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO Flag.withDefault(false), ), yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), - dangerouslySkipPermissions: Flag.boolean("dangerously-skip-permissions").pipe( - Flag.withDefault(false), - Flag.withHidden, - ), }, }), Spec.make("service", { diff --git a/packages/cli/src/commands/handlers/run.ts b/packages/cli/src/commands/handlers/run.ts index 280a50002c..e8abf0cb1e 100644 --- a/packages/cli/src/commands/handlers/run.ts +++ b/packages/cli/src/commands/handlers/run.ts @@ -23,9 +23,8 @@ export default Runtime.handler(Commands.commands.run, (input) => format: input.format, file: [...input.file], title: Option.getOrUndefined(input.title), - variant: Option.getOrUndefined(input.variant), thinking: input.thinking, - dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions, + auto: input.auto || input.yolo, }), ) }), diff --git a/packages/cli/src/mini/index.ts b/packages/cli/src/mini/index.ts index ce411906de..4a6ef986fa 100644 --- a/packages/cli/src/mini/index.ts +++ b/packages/cli/src/mini/index.ts @@ -3,5 +3,6 @@ export { runNonInteractive, mergeInput as mergeNonInteractiveInput, pickRunModel, + parseRunModel, type RunCommandInput, } from "./run" diff --git a/packages/cli/src/mini/noninteractive.ts b/packages/cli/src/mini/noninteractive.ts index 8defc7415b..978bacacc5 100644 --- a/packages/cli/src/mini/noninteractive.ts +++ b/packages/cli/src/mini/noninteractive.ts @@ -25,7 +25,7 @@ type Input = { variant?: string thinking: boolean format: "default" | "json" - dangerouslySkipPermissions: boolean + auto: boolean /** True when the client is attached to a shared server rather than an exclusive in-process one. */ attached: boolean renderTool: (part: ToolPart) => Promise @@ -91,7 +91,7 @@ export async function runNonInteractivePrompt(input: Input) { } const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray }) => { - if (!input.dangerouslySkipPermissions) { + if (!input.auto) { permissionRejected = true UI.println( UI.Style.TEXT_WARNING_BOLD + "!", @@ -103,10 +103,10 @@ export async function runNonInteractivePrompt(input: Input) { .reply({ sessionID: input.sessionID, requestID: request.id, - reply: input.dangerouslySkipPermissions ? "once" : "reject", + reply: input.auto ? "once" : "reject", }) .catch(() => {}) - if (!input.dangerouslySkipPermissions) { + if (!input.auto) { await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) } } diff --git a/packages/cli/src/mini/run.ts b/packages/cli/src/mini/run.ts index 3780c201c8..6f7e461a8c 100644 --- a/packages/cli/src/mini/run.ts +++ b/packages/cli/src/mini/run.ts @@ -1,6 +1,7 @@ import { Service } from "@opencode-ai/client/effect" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Model } from "@opencode-ai/schema/model" import type { ToolPart } from "@opencode-ai/sdk/v2" import { open } from "node:fs/promises" import path from "node:path" @@ -21,9 +22,8 @@ export type RunCommandInput = { format: "default" | "json" file: string[] title?: string - variant?: string thinking?: boolean - dangerouslySkipPermissions?: boolean + auto?: boolean } type FilePart = { @@ -62,7 +62,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser const session = await selectSession(client, requestedDirectory, input) const cwd = session?.location.directory ?? requestedDirectory const workspace = session?.location.workspaceID - const explicitModel = parseModel(input.model) + const explicit = parseRunModel(input.model) + const explicitModel = explicit?.model + const variant = explicit?.variant const sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined const defaultModel = !explicitModel && !sessionModel @@ -70,8 +72,8 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser .default({ location: { directory: cwd, workspace } }) .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) : undefined - const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) - if (input.variant && !model) + const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel) + if (variant && !model) return reportError(input, "Cannot select a variant before selecting a model", session?.id) if (model) { await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) @@ -84,7 +86,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser session ?? (await client.session.create({ agent, - model: model ? { providerID: model.providerID, id: model.modelID, variant: input.variant } : undefined, + model: model ? { providerID: model.providerID, id: model.modelID, variant } : undefined, location: { directory: cwd }, })) if (!session && input.title !== undefined) { @@ -101,10 +103,10 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser files: prepared.files, agent, model, - variant: input.variant, + variant, thinking: input.thinking ?? false, format: input.format, - dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false, + auto: input.auto ?? false, attached: true, renderTool, renderToolError, @@ -142,12 +144,13 @@ function localDirectory(root: string) { } } -function parseModel(value?: string) { +export function parseRunModel(value?: string) { if (!value) return - const [providerID, ...rest] = value.split("/") - const modelID = rest.join("/") - if (!providerID || !modelID) fail("--model must use the format provider/model") - return { providerID, modelID } + const ref = Model.Ref.parse(value) + return { + model: { providerID: ref.providerID, modelID: ref.id }, + variant: ref.variant, + } } async function validateAgent(client: OpenCodeClient, directory: string, name?: string) { diff --git a/packages/cli/test/mini.test.ts b/packages/cli/test/mini.test.ts index 3c5ddfe146..17ff0177c7 100644 --- a/packages/cli/test/mini.test.ts +++ b/packages/cli/test/mini.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { InstallationVersion } from "@opencode-ai/core/installation/version" import path from "node:path" -import { mergeInteractiveInput, mergeNonInteractiveInput, pickRunModel } from "../src/mini" +import { mergeInteractiveInput, mergeNonInteractiveInput, parseRunModel, pickRunModel } from "../src/mini" async function cli(args: string[]) { const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { @@ -39,6 +39,12 @@ describe("mini command", () => { ).toEqual({ providerID: "session-provider", modelID: "session-model" }) }) + test("parses model variants from the model reference", () => { + expect(JSON.stringify(parseRunModel("openrouter/openai/gpt-5#high"))).toBe( + JSON.stringify({ model: { providerID: "openrouter", modelID: "openai/gpt-5" }, variant: "high" }), + ) + }) + test("is registered in the preview CLI", async () => { const result = await cli(["--help"]) @@ -52,6 +58,7 @@ describe("mini command", () => { expect(result.exitCode).toBe(0) expect(result.stdout).toContain("--server string") + expect(result.stdout).not.toContain("--variant") expect(result.stdout).not.toContain("--attach") expect(result.stdout).not.toContain("--command") }) diff --git a/packages/core/src/config/model.ts b/packages/core/src/config/model.ts index a1ee46a5f1..e3e1d94e84 100644 --- a/packages/core/src/config/model.ts +++ b/packages/core/src/config/model.ts @@ -27,12 +27,10 @@ export const Selection = Schema.Union([Short, Explicit]) .annotate({ identifier: "Config.ModelSelection" }) function parse(input: string): Selection { - const providerEnd = input.indexOf("/") - const variantStart = input.lastIndexOf("#") - const hasVariant = variantStart > providerEnd + const ref = Model.Ref.parse(input) return { - providerID: Provider.ID.make(input.slice(0, providerEnd)), - model: Model.ID.make(input.slice(providerEnd + 1, hasVariant ? variantStart : undefined)), - ...(hasVariant ? { variant: Model.VariantID.make(input.slice(variantStart + 1)) } : {}), + providerID: ref.providerID, + model: ref.id, + ...(ref.variant ? { variant: ref.variant } : {}), } } diff --git a/packages/opencode/test/cli/run/noninteractive.test.ts b/packages/opencode/test/cli/run/noninteractive.test.ts index e593140bc4..599d9daced 100644 --- a/packages/opencode/test/cli/run/noninteractive.test.ts +++ b/packages/opencode/test/cli/run/noninteractive.test.ts @@ -84,7 +84,7 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?: files: [], thinking: false, format: "default", - dangerouslySkipPermissions: false, + auto: false, attached: input.attached ?? false, renderTool: () => Promise.resolve(), renderToolError: () => Promise.resolve(), diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index 3539c87f77..c54a22d613 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -15,7 +15,27 @@ export const Ref = Schema.Struct({ id: ID, providerID: Provider.ID, variant: VariantID.pipe(optional), -}).annotate({ identifier: "Model.Ref" }) +}) + .annotate({ identifier: "Model.Ref" }) + .pipe( + statics((schema) => ({ + parse: (input: string) => { + const providerEnd = input.indexOf("/") + if (providerEnd <= 0) throw new Error(`Invalid model reference: ${input}`) + const providerID = input.slice(0, providerEnd) + const variantStart = input.indexOf("#", providerEnd + 1) + const id = input.slice(providerEnd + 1, variantStart === -1 ? undefined : variantStart) + const variant = variantStart === -1 ? undefined : input.slice(variantStart + 1) + if (!id || providerID.includes("#") || (variant !== undefined && (!variant || variant.includes("#")))) + throw new Error(`Invalid model reference: ${input}`) + return schema.make({ + providerID: Provider.ID.make(providerID), + id: ID.make(id), + ...(variant ? { variant: VariantID.make(variant) } : {}), + }) + }, + })), + ) export interface Ref extends Schema.Schema.Type {} export const Family = Schema.String.pipe(Schema.brand("Model.Family")) diff --git a/packages/schema/test/model.test.ts b/packages/schema/test/model.test.ts new file mode 100644 index 0000000000..1dedf08698 --- /dev/null +++ b/packages/schema/test/model.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test" +import { Model } from "../src/model.js" + +describe("Model.Ref", () => { + test("parses model references with optional variants", () => { + const variant = Model.Ref.parse("openrouter/openai/gpt-5#high") + expect(String(variant.providerID)).toBe("openrouter") + expect(String(variant.id)).toBe("openai/gpt-5") + expect(String(variant.variant)).toBe("high") + + const standard = Model.Ref.parse("anthropic/claude-sonnet") + expect(String(standard.providerID)).toBe("anthropic") + expect(String(standard.id)).toBe("claude-sonnet") + expect(standard.variant).toBeUndefined() + }) + + test("rejects malformed model references", () => { + expect(() => Model.Ref.parse("gpt-5")).toThrow() + expect(() => Model.Ref.parse("openai/gpt-5#")).toThrow() + expect(() => Model.Ref.parse("openai/gpt-5#high#extra")).toThrow() + }) +})