refactor(cli): parse variants from model refs

This commit is contained in:
Dax Raad 2026-07-08 23:25:37 -04:00
parent cccd013801
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(
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", {

View file

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

View file

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

View file

@ -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<void>
@ -91,7 +91,7 @@ export async function runNonInteractivePrompt(input: Input) {
}
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
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(() => {})
}
}

View file

@ -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) {

View file

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

View file

@ -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 } : {}),
}
}

View file

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

View file

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