refactor(cli): parse variants from model refs

This commit is contained in:
Dax Raad 2026-07-08 23:25:37 -04:00
commit 67f2393f83
10 changed files with 79 additions and 34 deletions

View file

@ -171,7 +171,7 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
), ),
model: Flag.string("model").pipe( model: Flag.string("model").pipe(
Flag.withAlias("m"), 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, Flag.optional,
), ),
agent: Flag.string("agent").pipe(Flag.withDescription("Agent to use"), 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), Flag.atMost(100),
), ),
title: Flag.string("title").pipe(Flag.withDescription("Session title"), Flag.optional), 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( thinking: Flag.boolean("thinking").pipe(
Flag.withDescription("Show thinking blocks"), Flag.withDescription("Show thinking blocks"),
Flag.withDefault(false), Flag.withDefault(false),
@ -195,10 +194,6 @@ export const Commands = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCO
Flag.withDefault(false), Flag.withDefault(false),
), ),
yolo: Flag.boolean("yolo").pipe(Flag.withDefault(false), Flag.withHidden), 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", { Spec.make("service", {

View file

@ -23,9 +23,8 @@ export default Runtime.handler(Commands.commands.run, (input) =>
format: input.format, format: input.format,
file: [...input.file], file: [...input.file],
title: Option.getOrUndefined(input.title), title: Option.getOrUndefined(input.title),
variant: Option.getOrUndefined(input.variant),
thinking: input.thinking, thinking: input.thinking,
dangerouslySkipPermissions: input.auto || input.yolo || input.dangerouslySkipPermissions, auto: input.auto || input.yolo,
}), }),
) )
}), }),

View file

@ -3,5 +3,6 @@ export {
runNonInteractive, runNonInteractive,
mergeInput as mergeNonInteractiveInput, mergeInput as mergeNonInteractiveInput,
pickRunModel, pickRunModel,
parseRunModel,
type RunCommandInput, type RunCommandInput,
} from "./run" } from "./run"

View file

@ -25,7 +25,7 @@ type Input = {
variant?: string variant?: string
thinking: boolean thinking: boolean
format: "default" | "json" format: "default" | "json"
dangerouslySkipPermissions: boolean auto: boolean
/** True when the client is attached to a shared server rather than an exclusive in-process one. */ /** True when the client is attached to a shared server rather than an exclusive in-process one. */
attached: boolean attached: boolean
renderTool: (part: ToolPart) => Promise<void> renderTool: (part: ToolPart) => Promise<void>
@ -91,7 +91,7 @@ export async function runNonInteractivePrompt(input: Input) {
} }
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => { const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.dangerouslySkipPermissions) { if (!input.auto) {
permissionRejected = true permissionRejected = true
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
@ -103,10 +103,10 @@ export async function runNonInteractivePrompt(input: Input) {
.reply({ .reply({
sessionID: input.sessionID, sessionID: input.sessionID,
requestID: request.id, requestID: request.id,
reply: input.dangerouslySkipPermissions ? "once" : "reject", reply: input.auto ? "once" : "reject",
}) })
.catch(() => {}) .catch(() => {})
if (!input.dangerouslySkipPermissions) { if (!input.auto) {
await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {}) await input.client.session.interrupt({ sessionID: input.sessionID }).catch(() => {})
} }
} }

View file

@ -1,6 +1,7 @@
import { Service } from "@opencode-ai/client/effect" import { Service } from "@opencode-ai/client/effect"
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import { FSUtil } from "@opencode-ai/core/fs-util" import { FSUtil } from "@opencode-ai/core/fs-util"
import { Model } from "@opencode-ai/schema/model"
import type { ToolPart } from "@opencode-ai/sdk/v2" import type { ToolPart } from "@opencode-ai/sdk/v2"
import { open } from "node:fs/promises" import { open } from "node:fs/promises"
import path from "node:path" import path from "node:path"
@ -21,9 +22,8 @@ export type RunCommandInput = {
format: "default" | "json" format: "default" | "json"
file: string[] file: string[]
title?: string title?: string
variant?: string
thinking?: boolean thinking?: boolean
dangerouslySkipPermissions?: boolean auto?: boolean
} }
type FilePart = { type FilePart = {
@ -62,7 +62,9 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
const session = await selectSession(client, requestedDirectory, input) const session = await selectSession(client, requestedDirectory, input)
const cwd = session?.location.directory ?? requestedDirectory const cwd = session?.location.directory ?? requestedDirectory
const workspace = session?.location.workspaceID 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 sessionModel = session?.model ? { providerID: session.model.providerID, modelID: session.model.id } : undefined
const defaultModel = const defaultModel =
!explicitModel && !sessionModel !explicitModel && !sessionModel
@ -70,8 +72,8 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
.default({ location: { directory: cwd, workspace } }) .default({ location: { directory: cwd, workspace } })
.then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined)) .then((result) => (result.data ? { providerID: result.data.providerID, modelID: result.data.id } : undefined))
: undefined : undefined
const model = pickRunModel(explicitModel, input.variant, sessionModel, defaultModel) const model = pickRunModel(explicitModel, variant, sessionModel, defaultModel)
if (input.variant && !model) if (variant && !model)
return reportError(input, "Cannot select a variant before selecting a model", session?.id) return reportError(input, "Cannot select a variant before selecting a model", session?.id)
if (model) { if (model) {
await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model }) await waitForCatalogReady({ sdk: client, directory: cwd, workspace, model })
@ -84,7 +86,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
session ?? session ??
(await client.session.create({ (await client.session.create({
agent, 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 }, location: { directory: cwd },
})) }))
if (!session && input.title !== undefined) { if (!session && input.title !== undefined) {
@ -101,10 +103,10 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: Ser
files: prepared.files, files: prepared.files,
agent, agent,
model, model,
variant: input.variant, variant,
thinking: input.thinking ?? false, thinking: input.thinking ?? false,
format: input.format, format: input.format,
dangerouslySkipPermissions: input.dangerouslySkipPermissions ?? false, auto: input.auto ?? false,
attached: true, attached: true,
renderTool, renderTool,
renderToolError, renderToolError,
@ -142,12 +144,13 @@ function localDirectory(root: string) {
} }
} }
function parseModel(value?: string) { export function parseRunModel(value?: string) {
if (!value) return if (!value) return
const [providerID, ...rest] = value.split("/") const ref = Model.Ref.parse(value)
const modelID = rest.join("/") return {
if (!providerID || !modelID) fail("--model must use the format provider/model") model: { providerID: ref.providerID, modelID: ref.id },
return { providerID, modelID } variant: ref.variant,
}
} }
async function validateAgent(client: OpenCodeClient, directory: string, name?: string) { async function validateAgent(client: OpenCodeClient, directory: string, name?: string) {

View file

@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { InstallationVersion } from "@opencode-ai/core/installation/version" import { InstallationVersion } from "@opencode-ai/core/installation/version"
import path from "node:path" 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[]) { async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], { 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" }) ).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 () => { test("is registered in the preview CLI", async () => {
const result = await cli(["--help"]) const result = await cli(["--help"])
@ -52,6 +58,7 @@ describe("mini command", () => {
expect(result.exitCode).toBe(0) expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("--server string") expect(result.stdout).toContain("--server string")
expect(result.stdout).not.toContain("--variant")
expect(result.stdout).not.toContain("--attach") expect(result.stdout).not.toContain("--attach")
expect(result.stdout).not.toContain("--command") expect(result.stdout).not.toContain("--command")
}) })

View file

@ -27,12 +27,10 @@ export const Selection = Schema.Union([Short, Explicit])
.annotate({ identifier: "Config.ModelSelection" }) .annotate({ identifier: "Config.ModelSelection" })
function parse(input: string): Selection { function parse(input: string): Selection {
const providerEnd = input.indexOf("/") const ref = Model.Ref.parse(input)
const variantStart = input.lastIndexOf("#")
const hasVariant = variantStart > providerEnd
return { return {
providerID: Provider.ID.make(input.slice(0, providerEnd)), providerID: ref.providerID,
model: Model.ID.make(input.slice(providerEnd + 1, hasVariant ? variantStart : undefined)), model: ref.id,
...(hasVariant ? { variant: Model.VariantID.make(input.slice(variantStart + 1)) } : {}), ...(ref.variant ? { variant: ref.variant } : {}),
} }
} }

View file

@ -84,7 +84,7 @@ async function run(input: { turn: (inputID: string) => V2Event[]; pendingForms?:
files: [], files: [],
thinking: false, thinking: false,
format: "default", format: "default",
dangerouslySkipPermissions: false, auto: false,
attached: input.attached ?? false, attached: input.attached ?? false,
renderTool: () => Promise.resolve(), renderTool: () => Promise.resolve(),
renderToolError: () => Promise.resolve(), renderToolError: () => Promise.resolve(),

View file

@ -15,7 +15,27 @@ export const Ref = Schema.Struct({
id: ID, id: ID,
providerID: Provider.ID, providerID: Provider.ID,
variant: VariantID.pipe(optional), 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<typeof Ref> {} export interface Ref extends Schema.Schema.Type<typeof Ref> {}
export const Family = Schema.String.pipe(Schema.brand("Model.Family")) export const Family = Schema.String.pipe(Schema.brand("Model.Family"))

View file

@ -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()
})
})