From 521d05f290c2f86d3237db7b219de231a9975c10 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 28 Jul 2026 04:05:30 -0400 Subject: [PATCH] fix(cli): harden infer provider workflows (#115054) --- docs/cli/infer.md | 2 + .../embedded-agent-runner/model.test.ts | 5 + src/agents/embedded-agent-runner/model.ts | 1 + src/cli/capability-cli.test.ts | 132 ++++++++++++++++++ src/cli/capability-cli/metadata.ts | 1 + src/cli/capability-cli/model.ts | 34 ++++- src/cli/capability-cli/tts.ts | 15 +- 7 files changed, 184 insertions(+), 6 deletions(-) diff --git a/docs/cli/infer.md b/docs/cli/infer.md index 691431910cb9..5fe50f625e13 100644 --- a/docs/cli/infer.md +++ b/docs/cli/infer.md @@ -214,6 +214,7 @@ Speech synthesis and TTS provider/persona state. ```bash openclaw infer tts convert --text "hello from openclaw" --output ./hello.mp3 --json openclaw infer tts convert --text "Your build is complete" --output ./build-complete.mp3 --json +openclaw infer tts convert --provider xiaomi --text "Provider-only selection" --output ./xiaomi.mp3 --json openclaw infer tts providers --json openclaw infer tts personas --json openclaw infer tts status --json @@ -222,6 +223,7 @@ openclaw infer tts status --json Notes: - `tts status` only supports `--gateway` (it reflects gateway-managed TTS state). +- Use `tts convert --provider ` when selecting a provider without overriding its model. - Use `tts providers`, `tts voices`, `tts personas`, `tts set-provider`, and `tts set-persona` to inspect and configure TTS behavior. ## Video diff --git a/src/agents/embedded-agent-runner/model.test.ts b/src/agents/embedded-agent-runner/model.test.ts index e68bb5ea680e..be12edf9d564 100644 --- a/src/agents/embedded-agent-runner/model.test.ts +++ b/src/agents/embedded-agent-runner/model.test.ts @@ -834,6 +834,7 @@ describe("resolveModel", () => { modelId: "mistral-medium-3-5", cfg: undefined, workspaceDir: undefined, + includeRuntimeDiscovery: true, }); expect(resolveBundledProviderStaticCatalogModelMock).not.toHaveBeenCalled(); expect(discoverAuthStorage).not.toHaveBeenCalled(); @@ -880,6 +881,7 @@ describe("resolveModel", () => { modelId: "gemini-3.1-pro-preview", cfg: undefined, workspaceDir: undefined, + includeRuntimeDiscovery: true, }); expect(resolveBundledProviderStaticCatalogModelMock).toHaveBeenCalledWith({ provider: "google", @@ -1180,12 +1182,14 @@ describe("resolveModel", () => { modelId: "claude-haiku-4-5", cfg: undefined, workspaceDir: undefined, + includeRuntimeDiscovery: true, }); expect(resolveBundledStaticCatalogModelMock).toHaveBeenCalledWith({ provider: "openai", modelId: "gpt-4o", cfg: undefined, workspaceDir: undefined, + includeRuntimeDiscovery: true, }); expect(resolveBundledStaticCatalogModelMock).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -1305,6 +1309,7 @@ describe("resolveModel", () => { modelId: "gpt-5.5-pro", cfg: undefined, workspaceDir: undefined, + includeRuntimeDiscovery: true, }); }); diff --git a/src/agents/embedded-agent-runner/model.ts b/src/agents/embedded-agent-runner/model.ts index f7dd5b8ced63..53d2763debdf 100644 --- a/src/agents/embedded-agent-runner/model.ts +++ b/src/agents/embedded-agent-runner/model.ts @@ -305,6 +305,7 @@ export async function resolveModelAsync( modelId: normalizedRef.model, cfg, workspaceDir, + includeRuntimeDiscovery: true, }); if (manifestModel) { return manifestModel; diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 3a2494d82bd1..935e1e13963a 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -35,6 +35,10 @@ const mocks = vi.hoisted(() => ({ loadAuthProfileStoreForRuntime: vi.fn(() => ({ profiles: {}, order: {} })), listProfilesForProvider: vi.fn(() => []), resolveApiKeyForProvider: vi.fn(), + loadManifestMetadataSnapshot: vi.fn(() => ({ manifestRegistry: { plugins: [] } })), + planEffectiveModelCatalogRows: vi.fn< + typeof import("../model-catalog/index.js").planEffectiveModelCatalogRows + >(() => ({ rows: [], entries: [], conflicts: [] })), resolveAgentDir: vi.fn((_cfg: unknown, agentId: string) => `/tmp/agent-${agentId}`), updateAuthProfileStoreWithLock: vi.fn( async ({ updater }: { updater: (store: any) => boolean }) => { @@ -233,6 +237,14 @@ vi.mock("../config/config.js", () => ({ mocks.setRuntimeConfigSnapshot as typeof import("../config/config.js").setRuntimeConfigSnapshot, })); +vi.mock("../model-catalog/index.js", () => ({ + planEffectiveModelCatalogRows: mocks.planEffectiveModelCatalogRows, +})); + +vi.mock("../plugins/manifest-contract-eligibility.js", () => ({ + loadManifestMetadataSnapshot: mocks.loadManifestMetadataSnapshot, +})); + vi.mock("./command-config-resolution.js", () => ({ resolveCommandConfigWithSecrets: mocks.resolveCommandConfigWithSecrets, })); @@ -385,6 +397,9 @@ vi.mock("../tts/tts.js", () => ({ vi.mock("../tts/provider-registry.js", () => ({ canonicalizeSpeechProviderId: vi.fn((provider: string) => provider), listSpeechProviders: vi.fn(() => []), + normalizeSpeechProviderId: vi.fn( + (provider: string | undefined) => provider?.trim().toLowerCase() || undefined, + ), })); vi.mock("../web-search/runtime.js", () => ({ @@ -494,6 +509,12 @@ describe("capability cli", () => { mocks.loadAuthProfileStoreForRuntime.mockReset().mockReturnValue({ profiles: {}, order: {} }); mocks.listProfilesForProvider.mockReset().mockReturnValue([]); mocks.resolveApiKeyForProvider.mockReset().mockRejectedValue(new Error("no auth profile")); + mocks.loadManifestMetadataSnapshot + .mockReset() + .mockReturnValue({ manifestRegistry: { plugins: [] } }); + mocks.planEffectiveModelCatalogRows + .mockReset() + .mockReturnValue({ rows: [], entries: [], conflicts: [] }); mocks.resolveAgentDir.mockClear(); mocks.resolveTtsConfig.mockReset().mockReturnValue({}); mocks.getRuntimeConfigSourceSnapshot.mockReset().mockReturnValue(null); @@ -752,6 +773,61 @@ describe("capability cli", () => { expect(ids).toContain("image.describe"); }); + it.each([ + ["list", []], + ["inspect", ["--model", "openai/gpt-5.4"]], + ["providers", []], + ] as const)("keeps model %s catalog inspection read-only", async (command, args) => { + await runCap("capability", "model", command, ...args, "--json"); + + expect(mocks.loadModelCatalog).toHaveBeenCalledWith({ + config: mocks.loadConfig(), + readOnly: true, + }); + }); + + it("reports model providers configured through their environment key", async () => { + vi.stubEnv("OPENAI_API_KEY", "test-openai-key"); + + await runCap("capability", "model", "providers", "--json"); + + const providers = firstJsonOutput() as unknown as Array<{ + configured?: boolean; + provider?: string; + }>; + expect(providers).toContainEqual( + expect.objectContaining({ provider: "openai", configured: true }), + ); + expect(mocks.getProviderEnvVars).toHaveBeenCalledWith("openai"); + }); + + it("inspects runtime-declared manifest models without live discovery", async () => { + mocks.loadModelCatalog.mockResolvedValueOnce([] as never); + mocks.planEffectiveModelCatalogRows.mockReturnValueOnce({ + rows: [ + { + provider: "openai", + id: "gpt-5.6-sol", + name: "GPT-5.6 Sol", + ref: "openai/gpt-5.6-sol", + mergeKey: "openai/gpt-5.6-sol", + source: "manifest", + input: ["text"], + reasoning: true, + status: "available", + }, + ], + entries: [], + conflicts: [], + }); + + await runCap("capability", "model", "inspect", "--model", "openai/gpt-5.6-sol", "--json"); + + expect(firstJsonOutput()).toEqual( + expect.objectContaining({ provider: "openai", id: "gpt-5.6-sol" }), + ); + }); + it("defaults model run to local transport", async () => { await runCap("capability", "model", "run", "--prompt", "hello", "--json"); @@ -2674,6 +2750,62 @@ describe("capability cli", () => { expect(firstTextToSpeechCall()?.disableFallback).toBe(true); }); + it("selects a TTS provider without inventing a model override", async () => { + await runCap( + "capability", + "tts", + "convert", + "--text", + "hello", + "--provider", + "xiaomi", + "--json", + ); + + expect(mocks.resolveExplicitTtsOverrides).toHaveBeenCalledWith( + expect.objectContaining({ provider: "xiaomi", modelId: undefined }), + ); + expect(firstTextToSpeechCall()?.disableFallback).toBe(true); + }); + + it("rejects conflicting TTS provider and model selections", async () => { + await expect( + runCap( + "capability", + "tts", + "convert", + "--text", + "hello", + "--provider", + "xiaomi", + "--model", + "openai/gpt-4o-mini-tts", + "--json", + ), + ).rejects.toThrow("exit 1"); + + expectRuntimeErrorContains("TTS --provider must match the provider in --model."); + }); + + it("accepts equivalent TTS provider casing with a model selection", async () => { + await runCap( + "capability", + "tts", + "convert", + "--text", + "hello", + "--provider", + "OpenAI", + "--model", + "openai/gpt-4o-mini-tts", + "--json", + ); + + expect(mocks.resolveExplicitTtsOverrides).toHaveBeenCalledWith( + expect.objectContaining({ provider: "openai", modelId: "gpt-4o-mini-tts" }), + ); + }); + it("does not infer and forward a local provider guess for gateway TTS overrides", async () => { await runCap( "capability", diff --git a/src/cli/capability-cli/metadata.ts b/src/cli/capability-cli/metadata.ts index 8abc01fc506c..384a1bb637bf 100644 --- a/src/cli/capability-cli/metadata.ts +++ b/src/cli/capability-cli/metadata.ts @@ -159,6 +159,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ "--text", "--channel", "--voice", + "--provider", "--model", "--output", "--local", diff --git a/src/cli/capability-cli/model.ts b/src/cli/capability-cli/model.ts index 2747d770a141..1355b7fc7548 100644 --- a/src/cli/capability-cli/model.ts +++ b/src/cli/capability-cli/model.ts @@ -32,7 +32,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { callGateway, randomIdempotencyKey } from "../../gateway/call.js"; import { ADMIN_SCOPE } from "../../gateway/operator-scopes.js"; import { convertHeicToJpeg } from "../../media/media-services.js"; +import { planEffectiveModelCatalogRows } from "../../model-catalog/index.js"; +import { loadManifestMetadataSnapshot } from "../../plugins/manifest-contract-eligibility.js"; import { defaultRuntime } from "../../runtime.js"; +import { getProviderEnvVars } from "../../secrets/provider-env-vars.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { getModelsCommandSecretTargetIds } from "../command-secret-targets.js"; import { collectOption } from "../program/helpers.js"; @@ -51,6 +54,25 @@ import { const LOCAL_MODEL_RUN_SYSTEM_PROMPT = "You are a personal assistant running inside OpenClaw."; const HEIC_MODEL_RUN_MIMES = new Set(["image/heic", "image/heif"]); +async function loadModelCatalogForInspection(cfg: OpenClawConfig) { + const prepared = await loadPreparedModelCatalog({ config: cfg, readOnly: true }); + const metadataSnapshot = loadManifestMetadataSnapshot({ config: cfg, env: process.env }); + const manifest = planEffectiveModelCatalogRows({ + registry: metadataSnapshot.manifestRegistry, + config: cfg, + }).rows; + const entries = new Map(); + for (const entry of manifest) { + entries.set(`${entry.provider}\0${entry.id}`, entry); + } + for (const entry of prepared) { + entries.set(`${entry.provider}\0${entry.id}`, entry); + } + return [...entries.values()].toSorted( + (a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id), + ); +} + async function canonicalizeModelRunRef(params: { raw: string | undefined; cfg: OpenClawConfig; @@ -324,7 +346,7 @@ async function runModelRun(params: { async function buildModelProviders() { const cfg = getRuntimeConfig(); - const catalog = await loadPreparedModelCatalog({ config: cfg }); + const catalog = await loadModelCatalogForInspection(cfg); const selectedProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.model), ); @@ -345,7 +367,11 @@ async function buildModelProviders() { count: 0, defaults: [], available: true, - configured: providerHasGenericConfig({ cfg, providerId: entry.provider }), + configured: providerHasGenericConfig({ + cfg, + providerId: entry.provider, + envVars: getProviderEnvVars(entry.provider), + }), selected: selectedProvider === entry.provider, }; current.count += 1; @@ -458,7 +484,7 @@ export function registerModelCapabilityCommands(capability: Command): void { .option("--json", "Output JSON", false) .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { - const result = await loadPreparedModelCatalog({ config: getRuntimeConfig() }); + const result = await loadModelCatalogForInspection(getRuntimeConfig()); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, providerSummaryText); }); }); @@ -471,7 +497,7 @@ export function registerModelCapabilityCommands(capability: Command): void { .action(async (opts) => { await runCommandWithRuntime(defaultRuntime, async () => { const target = normalizeStringifiedOptionalString(opts.model) ?? ""; - const catalog = await loadPreparedModelCatalog({ config: getRuntimeConfig() }); + const catalog = await loadModelCatalogForInspection(getRuntimeConfig()); const entry = catalog.find((candidate) => `${candidate.provider}/${candidate.id}` === target) ?? catalog.find((candidate) => candidate.id === target); diff --git a/src/cli/capability-cli/tts.ts b/src/cli/capability-cli/tts.ts index 934965412ef4..3207fdf85b46 100644 --- a/src/cli/capability-cli/tts.ts +++ b/src/cli/capability-cli/tts.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { callGateway } from "../../gateway/call.js"; import { defaultRuntime } from "../../runtime.js"; +import { normalizeSpeechProviderId } from "../../tts/provider-registry.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { emitJsonOrText, @@ -26,6 +27,7 @@ export function registerTtsCapabilityCommands(capability: Command): void { .requiredOption("--text ", "Input text") .option("--channel ", "Channel hint") .option("--voice ", "Voice hint") + .option("--provider ", "Speech provider id") .option("--model ", "Model override") .option("--output ", "Output path") .option("--local", "Force local execution", false) @@ -43,11 +45,20 @@ export function registerTtsCapabilityCommands(capability: Command): void { if (opts.model && !modelRef.provider) { throw new Error("TTS model overrides must use the form ."); } + const provider = normalizeSpeechProviderId( + typeof opts.provider === "string" && opts.provider.trim() + ? opts.provider.trim() + : modelRef.provider, + ); + const modelProvider = normalizeSpeechProviderId(modelRef.provider); + if (provider && modelProvider && provider !== modelProvider) { + throw new Error("TTS --provider must match the provider in --model."); + } const result = await runTtsConvert({ text: String(opts.text), channel: opts.channel as string | undefined, - provider: modelRef.provider, - modelId: modelRef.provider ? modelRef.model : undefined, + provider, + modelId: modelProvider ? modelRef.model : undefined, voiceId: opts.voice as string | undefined, output: opts.output as string | undefined, transport,