mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(cli): harden infer provider workflows (#115054)
This commit is contained in:
committed by
GitHub
parent
97c19a98e3
commit
521d05f290
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -159,6 +159,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [
|
||||
"--text",
|
||||
"--channel",
|
||||
"--voice",
|
||||
"--provider",
|
||||
"--model",
|
||||
"--output",
|
||||
"--local",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user