fix(cli): harden infer provider workflows (#115054)

This commit is contained in:
Peter Steinberger
2026-07-28 04:05:30 -04:00
committed by GitHub
parent 97c19a98e3
commit 521d05f290
7 changed files with 184 additions and 6 deletions
+2
View File
@@ -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 <id>` 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
@@ -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,
});
});
@@ -305,6 +305,7 @@ export async function resolveModelAsync(
modelId: normalizedRef.model,
cfg,
workspaceDir,
includeRuntimeDiscovery: true,
});
if (manifestModel) {
return manifestModel;
+132
View File
@@ -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",
+1
View File
@@ -159,6 +159,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [
"--text",
"--channel",
"--voice",
"--provider",
"--model",
"--output",
"--local",
+30 -4
View File
@@ -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<string, (typeof prepared)[number] | (typeof manifest)[number]>();
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);
+13 -2
View File
@@ -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 <text>", "Input text")
.option("--channel <id>", "Channel hint")
.option("--voice <id>", "Voice hint")
.option("--provider <id>", "Speech provider id")
.option("--model <provider/model>", "Model override")
.option("--output <path>", "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 <provider/model>.");
}
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,