Jelajahi Sumber

refactor(cli): parse variants from model refs

Dax Raad 1 bulan lalu
induk
melakukan
67f2393f83

+ 1 - 6
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", {

+ 1 - 2
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,
       }),
     )
   }),

+ 1 - 0
packages/cli/src/mini/index.ts

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

+ 4 - 4
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<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(() => {})
     }
   }

+ 16 - 13
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) {

+ 8 - 1
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")
   })

+ 4 - 6
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 } : {}),
   }
 }

+ 1 - 1
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(),

+ 21 - 1
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<typeof Ref> {}
 
 export const Family = Schema.String.pipe(Schema.brand("Model.Family"))

+ 22 - 0
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()
+  })
+})