From e69299609cfc8f8724f92fe1c04cc5b2dc8944d1 Mon Sep 17 00:00:00 2001 From: Kevin Gatera Date: Tue, 6 Oct 2026 06:41:46 -0400 Subject: [PATCH] fix: support per-prompt headless model selection --- README.md | 10 +++++++++ scripts/check-runtime.ts | 1 + scripts/sync-runtime.ts | 15 +++++++++++++ src/launcher.ts | 27 +++++++++++++++++++---- src/prompt-model.ts | 20 +++++++++++++++++ test/prompt-model.test.ts | 41 +++++++++++++++++++++++++++++++++++ test/prompt-preflight.test.ts | 6 +++++ 7 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 src/prompt-model.ts create mode 100644 test/prompt-model.test.ts diff --git a/README.md b/README.md index 934860f..867fa1f 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,16 @@ not available on `PATH`. ## Configuration +Select a model for one headless prompt without changing the saved default: + +```bash +zcode --model account:zai-individual-coding-plan/GLM-5.3-Flash --prompt 'Reply with exactly: ok' +``` + +`--model` requires a full provider/model ID and `--prompt` (or `-p`). It also +overrides a resumed session's model for that run. Unknown or unavailable models +fail before a prompt is sent, rather than falling back to the default. + ZCode reads MCP servers, hooks, plugins, permissions, network, storage and display settings from `~/.zcode/cli/setting.json` (or `%USERPROFILE%\.zcode\cli\setting.json` on Windows). Project settings use diff --git a/scripts/check-runtime.ts b/scripts/check-runtime.ts index 26bebcb..8ae7ee5 100755 --- a/scripts/check-runtime.ts +++ b/scripts/check-runtime.ts @@ -106,6 +106,7 @@ if (patchRuntimeLoginModelDefaults(runtimeSource) !== runtimeSource && runtimeSource.includes('"OAuth response is not valid JSON",{httpStatus:void 0}')) || !runtimeSource.includes('ZCODE_CLI_OAUTH_CALLBACK_STDIN==="1"') || !runtimeSource.includes("ZCODE_CLI_CREDENTIALS_FILE") + || !runtimeSource.includes("ZCODE_CLI_PROMPT_MODEL") || !runtimeSource.includes(".loadSessionTranscript=async()=>await(await") || !runtimeSource.includes('"loadSessionContextMessages"') || !runtimeSource.includes(".readGoal=async()=>await(await") diff --git a/scripts/sync-runtime.ts b/scripts/sync-runtime.ts index 7089341..588de6d 100755 --- a/scripts/sync-runtime.ts +++ b/scripts/sync-runtime.ts @@ -1377,6 +1377,15 @@ export function patchRuntimeStreamEofFinishGuard(runtime: string): string { return patched; } +export function patchRuntimePromptModel(runtime: string): string { + if (runtime.includes("ZCODE_CLI_PROMPT_MODEL")) return runtime; + const anchor = /if\([A-Za-z_$][\w$]*=([A-Za-z_$][\w$]*)\.traceId,[A-Za-z_$][\w$]*\.memoryBench/u.exec(runtime); + if (!anchor) throw new Error("ZCode runtime is incompatible with the prompt model patch."); + return runtime.slice(0, anchor.index) + + `if(process.env.ZCODE_CLI_PROMPT_MODEL)await ${anchor[1]}.setModel(process.env.ZCODE_CLI_PROMPT_MODEL,{transient:true});` + + runtime.slice(anchor.index); +} + export function patchRuntimeCliCredentials(runtime: string): string { const marker = 'ZCODE_CLI_CREDENTIALS_FILE'; if (runtime.includes(marker)) return runtime; @@ -1663,6 +1672,12 @@ export const runtimePatchPlan: readonly RuntimePatchDefinition[] = [ apply: patchRuntimeCliCredentials, verify: (runtime) => runtime.includes("ZCODE_CLI_CREDENTIALS_FILE") }, + { + id: "prompt-model", + requirement: "required", + apply: patchRuntimePromptModel, + verify: (runtime) => runtime.includes("ZCODE_CLI_PROMPT_MODEL") + }, { id: "desktop-oauth", requirement: "required", diff --git a/src/launcher.ts b/src/launcher.ts index ea1e51c..05dda57 100644 --- a/src/launcher.ts +++ b/src/launcher.ts @@ -34,6 +34,7 @@ import { import { requestAppServer } from "./app-server-client.ts"; import { runPluginCommand } from "./plugin-cli.ts"; import { missingCodingPlanKey } from "./prompt-preflight.ts"; +import { extractPromptModel } from "./prompt-model.ts"; import { capabilitiesFromExtractionMetadata, type RuntimeCliOptionType @@ -240,13 +241,13 @@ function inspectRuntimeInvocation( } export async function promptPreflight( - args: string[], env: NodeJS.ProcessEnv = process.env + args: string[], env: NodeJS.ProcessEnv = process.env, model?: string ): Promise { const invocation = inspectRuntimeInvocation(args, readRuntimeCliOptionTypes()); if (!invocation.agentInvocation || invocation.invalid || invocation.passthrough || invocation.resume) { return undefined; } - return missingCodingPlanKey({ env, workingDirectory: invocation.workingDirectory }); + return missingCodingPlanKey({ env, workingDirectory: invocation.workingDirectory, model }); } export function withDefaultBrowserUse( @@ -288,6 +289,7 @@ function runtimeEnvironment(extra: NodeJS.ProcessEnv = {}, inspectConfiguration const env: NodeJS.ProcessEnv = { ...process.env }; delete env.ZCODE_CLI_OAUTH_CALLBACK_STDIN; delete env.ZCODE_CLI_MIGRATE_CONFIG; + delete env.ZCODE_CLI_PROMPT_MODEL; const distributionVersion = readDistributionVersion(); const appVersion = readJsonVersion(extractionMetadataPath, "appVersion"); const inherited: NodeJS.ProcessEnv = { @@ -476,6 +478,16 @@ export async function main(args: string[]): Promise { return 0; } + let promptModel: string | undefined; + try { + const parsed = extractPromptModel(args, readRuntimeCliOptionTypes()); + args = parsed.args; + promptModel = parsed.model; + } catch (error) { + console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); + return 1; + } + let setupPending = false; try { // doctor 必须能检查损坏的配置,不能先被普通启动校验或 provider 迁移拦住。 @@ -601,13 +613,20 @@ export async function main(args: string[]): Promise { } try { - const diagnostic = await readConfiguredModelAccess() ? undefined : await promptPreflight(login.args); + const diagnostic = await readConfiguredModelAccess() ? undefined : await promptPreflight(login.args, process.env, promptModel); if (diagnostic) { console.error(diagnostic); return 1; } const runtimeArgs = withDefaultBrowserUse(login.args); - return await runRuntime(node, runtimeArgs, firstRunSetupEnv(setupPending, runtimeArgs)); + const code = await runRuntime(node, runtimeArgs, { + ...firstRunSetupEnv(setupPending, runtimeArgs), + ...(promptModel ? { ZCODE_CLI_PROMPT_MODEL: promptModel } : {}) + }); + if (code === 0 && (args.includes("--help") || args.includes("-h"))) { + console.log("\n --model Select a model for this --prompt run without changing the saved default."); + } + return code; } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : String(error)}`); return 1; diff --git a/src/prompt-model.ts b/src/prompt-model.ts new file mode 100644 index 0000000..b7b1f14 --- /dev/null +++ b/src/prompt-model.ts @@ -0,0 +1,20 @@ +import { parseArgs } from "node:util"; +import type { RuntimeCliOptionType } from "./runtime-capabilities.ts"; + +export function extractPromptModel( + args: string[], optionTypes: Readonly> +): { args: string[]; model?: string } { + const options = Object.fromEntries(Object.entries(optionTypes).map(([name, type]) => [name, { type }])); + const parsed = parseArgs({ args, allowPositionals: true, strict: false, tokens: true, + options: { ...options, prompt: { type: "string", short: "p" }, model: { type: "string" } } }); + const tokens = parsed.tokens.filter(token => token.kind === "option" && token.name === "model"); + if (tokens.length === 0) return { args }; + if (tokens.length !== 1) throw new Error("Specify --model only once."); + if (typeof parsed.values.prompt !== "string") throw new Error("--model requires --prompt or -p."); + const model = String(parsed.values.model).trim(); + if (!/^[^/\s]+\/\S+$/u.test(model)) throw new Error("--model must be a provider/model ID."); + const token = tokens[0]!; + const removed = new Set([token.index]); + if (token.kind === "option" && !token.inlineValue) removed.add(token.index + 1); + return { args: args.filter((_, index) => !removed.has(index)), model }; +} diff --git a/test/prompt-model.test.ts b/test/prompt-model.test.ts new file mode 100644 index 0000000..1c818d4 --- /dev/null +++ b/test/prompt-model.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test"; +import { extractPromptModel } from "../src/prompt-model.ts"; +import { patchRuntimePromptModel } from "../scripts/sync-runtime.ts"; + +const model = "account:zai-start-plan/GLM-5.3-Flash"; +const options = { prompt: "string", resume: "string" } as const; + +test("extracts a per-prompt model while preserving prompt text and resume arguments", () => { + expect(extractPromptModel(["--model", model, "-p", "ok", "--resume", "session"], options)) + .toEqual({ args: ["-p", "ok", "--resume", "session"], model }); + expect(extractPromptModel([`--model=${model}`, "--prompt=ok"], options)) + .toEqual({ args: ["--prompt=ok"], model }); + expect(extractPromptModel(["--prompt", "--model"], options)) + .toEqual({ args: ["--prompt", "--model"] }); + expect(() => extractPromptModel(["--model", "glm", "-p", "ok"], options)).toThrow(/provider\/model/); + expect(() => extractPromptModel(["--model", model], options)).toThrow(/requires --prompt/); + expect(() => extractPromptModel(["--model", model, "--model", model, "-p", "ok"], options)).toThrow(/only once/); +}); + +test("switches the restored app transiently before submitting a prompt and propagates invalid models", async () => { + const source = 'async function run(app,options){let trace;if(trace=app.traceId,options.memoryBench)throw Error("unused");return app.submitPrompt("ok")}'; + const patched = patchRuntimePromptModel(source); + expect(patchRuntimePromptModel(patched)).toBe(patched); + expect(() => patchRuntimePromptModel("unsupported")).toThrow(/incompatible/); + const load = (env: Record) => new Function("process", `${patched};return run`)({ env }); + const events: unknown[] = []; + const app = { + traceId: "fixture", setModel: async (...args: unknown[]) => { events.push(args); }, + submitPrompt: async () => { events.push("prompt"); return "ok"; } + }; + expect(await load({ ZCODE_CLI_PROMPT_MODEL: model })(app, {})).toBe("ok"); + expect(events).toEqual([[model, { transient: true }], "prompt"]); + events.length = 0; + await load({})(app, {}); + expect(events).toEqual(["prompt"]); + events.length = 0; + await expect(load({ ZCODE_CLI_PROMPT_MODEL: model })({ ...app, + setModel: async () => { throw new Error("Unknown model"); } + }, {})).rejects.toThrow("Unknown model"); + expect(events).toEqual([]); +}); diff --git a/test/prompt-preflight.test.ts b/test/prompt-preflight.test.ts index 4526823..31ad453 100644 --- a/test/prompt-preflight.test.ts +++ b/test/prompt-preflight.test.ts @@ -81,6 +81,12 @@ describe("prompt credential preflight (offline)", () => { } }); + test("checks an explicit prompt model instead of the saved default", async () => { + const f = await fixture(); + expect(await promptPreflight(["--cwd", f.home, "-p", "hello"], f.env, + "account:zai-individual-coding-plan/GLM-5.3-Flash")).toBeUndefined(); + }); + test("covers headless prompt forms while preserving help, login and resume", async () => { const f = await fixture(); for (const args of [["--prompt", "hello"], ["--prompt=hello"], ["--print", "hello"], ["-p", "hello"], ["--target", "hello"]]) {