mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-30 02:22:25 +00:00
feat: Add first-class infer CLI for inference workflows (#62129)
* refresh infer branch onto latest main * flatten infer media commands * fix tts runtime facade export * validate explicit web search providers * fix infer auth logout persistence
This commit is contained in:
903
src/cli/capability-cli.test.ts
Normal file
903
src/cli/capability-cli.test.ts
Normal file
@@ -0,0 +1,903 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { Command } from "commander";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runRegisteredCli } from "../test-utils/command-runner.js";
|
||||
import { registerCapabilityCli } from "./capability-cli.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}),
|
||||
writeJson: vi.fn(),
|
||||
writeStdout: vi.fn(),
|
||||
},
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
loadAuthProfileStoreForRuntime: vi.fn(() => ({ profiles: {}, order: {} })),
|
||||
listProfilesForProvider: vi.fn(() => []),
|
||||
updateAuthProfileStoreWithLock: vi.fn(
|
||||
async ({ updater }: { updater: (store: any) => boolean }) => {
|
||||
const store = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
order: {},
|
||||
lastGood: {},
|
||||
usageStats: {},
|
||||
};
|
||||
updater(store);
|
||||
return store;
|
||||
},
|
||||
),
|
||||
resolveMemorySearchConfig: vi.fn(() => null),
|
||||
loadModelCatalog: vi.fn(async () => []),
|
||||
agentCommand: vi.fn(async () => ({
|
||||
payloads: [{ text: "local reply" }],
|
||||
meta: { agentMeta: { provider: "openai", model: "gpt-5.4" } },
|
||||
})),
|
||||
callGateway: vi.fn(async ({ method }: { method: string }) => {
|
||||
if (method === "tts.status") {
|
||||
return { enabled: true, provider: "openai" };
|
||||
}
|
||||
if (method === "agent") {
|
||||
return {
|
||||
result: {
|
||||
payloads: [{ text: "gateway reply" }],
|
||||
meta: { agentMeta: { provider: "anthropic", model: "claude-sonnet-4-6" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
describeImageFile: vi.fn(async () => ({
|
||||
text: "friendly lobster",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
})),
|
||||
generateImage: vi.fn(),
|
||||
transcribeAudioFile: vi.fn(async () => ({ text: "meeting notes" })),
|
||||
textToSpeech: vi.fn(async () => ({
|
||||
success: true,
|
||||
audioPath: "/tmp/tts-source.mp3",
|
||||
provider: "openai",
|
||||
outputFormat: "mp3",
|
||||
voiceCompatible: false,
|
||||
attempts: [],
|
||||
})),
|
||||
setTtsProvider: vi.fn(),
|
||||
resolveExplicitTtsOverrides: vi.fn(
|
||||
({
|
||||
provider,
|
||||
modelId,
|
||||
voiceId,
|
||||
}: {
|
||||
provider?: string;
|
||||
modelId?: string;
|
||||
voiceId?: string;
|
||||
}) => ({
|
||||
...(provider ? { provider } : {}),
|
||||
...(modelId || voiceId
|
||||
? {
|
||||
providerOverrides: {
|
||||
[provider ?? "openai"]: {
|
||||
...(modelId ? { modelId } : {}),
|
||||
...(voiceId ? { voiceId } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
createEmbeddingProvider: vi.fn(async () => ({
|
||||
provider: {
|
||||
id: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
embedQuery: async () => [0.1, 0.2],
|
||||
embedBatch: async (texts: string[]) => texts.map(() => [0.1, 0.2]),
|
||||
},
|
||||
})),
|
||||
registerMemoryEmbeddingProvider: vi.fn(),
|
||||
listMemoryEmbeddingProviders: vi.fn(() => [
|
||||
{ id: "openai", defaultModel: "text-embedding-3-small", transport: "remote" },
|
||||
]),
|
||||
registerBuiltInMemoryEmbeddingProviders: vi.fn(),
|
||||
isWebSearchProviderConfigured: vi.fn(() => false),
|
||||
isWebFetchProviderConfigured: vi.fn(() => false),
|
||||
modelsStatusCommand: vi.fn(
|
||||
async (_opts: unknown, runtime: { log: (...args: unknown[]) => void }) => {
|
||||
runtime.log(JSON.stringify({ ok: true, providers: [{ id: "openai" }] }));
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime.js", () => ({
|
||||
defaultRuntime: mocks.runtime,
|
||||
writeRuntimeJson: (runtime: { writeJson: (value: unknown) => void }, value: unknown) =>
|
||||
runtime.writeJson(value),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: (...args: unknown[]) => mocks.loadConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-command.js", () => ({
|
||||
agentCommand: (...args: unknown[]) => mocks.agentCommand(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/agent-scope.js", () => ({
|
||||
resolveDefaultAgentId: () => "main",
|
||||
resolveAgentDir: () => "/tmp/agent",
|
||||
}));
|
||||
|
||||
vi.mock("../agents/model-catalog.js", () => ({
|
||||
loadModelCatalog: (...args: unknown[]) => mocks.loadModelCatalog(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles.js", () => ({
|
||||
loadAuthProfileStoreForRuntime: (...args: unknown[]) =>
|
||||
mocks.loadAuthProfileStoreForRuntime(...args),
|
||||
listProfilesForProvider: (...args: unknown[]) => mocks.listProfilesForProvider(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/auth-profiles/store.js", () => ({
|
||||
updateAuthProfileStoreWithLock: (...args: unknown[]) =>
|
||||
mocks.updateAuthProfileStoreWithLock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../agents/memory-search.js", () => ({
|
||||
resolveMemorySearchConfig: (...args: unknown[]) => mocks.resolveMemorySearchConfig(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/models.js", () => ({
|
||||
modelsAuthLoginCommand: vi.fn(),
|
||||
modelsStatusCommand: (...args: unknown[]) => mocks.modelsStatusCommand(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (...args: unknown[]) => mocks.callGateway(...args),
|
||||
randomIdempotencyKey: () => "run-1",
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/connection-details.js", () => ({
|
||||
buildGatewayConnectionDetailsWithResolvers: vi.fn(() => ({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
urlSource: "local loopback",
|
||||
message: "Gateway target: ws://127.0.0.1:18789",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("../media-understanding/runtime.js", () => ({
|
||||
describeImageFile: (...args: unknown[]) => mocks.describeImageFile(...args),
|
||||
describeVideoFile: vi.fn(),
|
||||
transcribeAudioFile: (...args: unknown[]) => mocks.transcribeAudioFile(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/memory-embedding-providers.js", () => ({
|
||||
listMemoryEmbeddingProviders: (...args: unknown[]) => mocks.listMemoryEmbeddingProviders(...args),
|
||||
registerMemoryEmbeddingProvider: (...args: unknown[]) =>
|
||||
mocks.registerMemoryEmbeddingProvider(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../extensions/memory-core/runtime-api.js", () => ({
|
||||
createEmbeddingProvider: (...args: unknown[]) => mocks.createEmbeddingProvider(...args),
|
||||
registerBuiltInMemoryEmbeddingProviders: (...args: unknown[]) =>
|
||||
mocks.registerBuiltInMemoryEmbeddingProviders(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../image-generation/runtime.js", () => ({
|
||||
generateImage: (...args: unknown[]) => mocks.generateImage(...args),
|
||||
listRuntimeImageGenerationProviders: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../video-generation/runtime.js", () => ({
|
||||
generateVideo: vi.fn(),
|
||||
listRuntimeVideoGenerationProviders: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../tts/tts.js", () => ({
|
||||
getTtsProvider: vi.fn(() => "openai"),
|
||||
listSpeechVoices: vi.fn(async () => []),
|
||||
resolveTtsConfig: vi.fn(() => ({})),
|
||||
resolveTtsPrefsPath: vi.fn(() => "/tmp/tts.json"),
|
||||
setTtsEnabled: vi.fn(),
|
||||
setTtsProvider: (...args: unknown[]) => mocks.setTtsProvider(...args),
|
||||
resolveExplicitTtsOverrides: (...args: unknown[]) => mocks.resolveExplicitTtsOverrides(...args),
|
||||
textToSpeech: (...args: unknown[]) => mocks.textToSpeech(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../tts/provider-registry.js", () => ({
|
||||
canonicalizeSpeechProviderId: vi.fn((provider: string) => provider),
|
||||
listSpeechProviders: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
vi.mock("../web-search/runtime.js", () => ({
|
||||
listWebSearchProviders: vi.fn(() => []),
|
||||
isWebSearchProviderConfigured: (...args: unknown[]) =>
|
||||
mocks.isWebSearchProviderConfigured(...args),
|
||||
runWebSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../web-fetch/runtime.js", () => ({
|
||||
listWebFetchProviders: vi.fn(() => []),
|
||||
isWebFetchProviderConfigured: (...args: unknown[]) => mocks.isWebFetchProviderConfigured(...args),
|
||||
resolveWebFetchDefinition: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("capability cli", () => {
|
||||
beforeEach(() => {
|
||||
mocks.runtime.log.mockClear();
|
||||
mocks.runtime.error.mockClear();
|
||||
mocks.runtime.writeJson.mockClear();
|
||||
mocks.loadModelCatalog
|
||||
.mockReset()
|
||||
.mockResolvedValue([{ id: "gpt-5.4", provider: "openai", name: "GPT-5.4" }]);
|
||||
mocks.loadAuthProfileStoreForRuntime.mockReset().mockReturnValue({ profiles: {}, order: {} });
|
||||
mocks.listProfilesForProvider.mockReset().mockReturnValue([]);
|
||||
mocks.updateAuthProfileStoreWithLock
|
||||
.mockReset()
|
||||
.mockImplementation(async ({ updater }: { updater: (store: any) => boolean }) => {
|
||||
const store = {
|
||||
version: 1,
|
||||
profiles: {},
|
||||
order: {},
|
||||
lastGood: {},
|
||||
usageStats: {},
|
||||
};
|
||||
updater(store);
|
||||
return store;
|
||||
});
|
||||
mocks.resolveMemorySearchConfig.mockReset().mockReturnValue(null);
|
||||
mocks.agentCommand.mockClear();
|
||||
mocks.callGateway.mockClear().mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "tts.status") {
|
||||
return { enabled: true, provider: "openai" };
|
||||
}
|
||||
if (method === "agent") {
|
||||
return {
|
||||
result: {
|
||||
payloads: [{ text: "gateway reply" }],
|
||||
meta: { agentMeta: { provider: "anthropic", model: "claude-sonnet-4-6" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
mocks.describeImageFile.mockClear();
|
||||
mocks.generateImage.mockReset();
|
||||
mocks.transcribeAudioFile.mockClear();
|
||||
mocks.textToSpeech.mockClear();
|
||||
mocks.setTtsProvider.mockClear();
|
||||
mocks.resolveExplicitTtsOverrides.mockClear();
|
||||
mocks.createEmbeddingProvider.mockClear();
|
||||
mocks.registerMemoryEmbeddingProvider.mockClear();
|
||||
mocks.registerBuiltInMemoryEmbeddingProviders.mockClear();
|
||||
mocks.isWebSearchProviderConfigured.mockReset().mockReturnValue(false);
|
||||
mocks.isWebFetchProviderConfigured.mockReset().mockReturnValue(false);
|
||||
mocks.modelsStatusCommand.mockClear();
|
||||
mocks.callGateway.mockImplementation(async ({ method }: { method: string }) => {
|
||||
if (method === "tts.status") {
|
||||
return { enabled: true, provider: "openai" };
|
||||
}
|
||||
if (method === "tts.convert") {
|
||||
return {
|
||||
audioPath: "/tmp/gateway-tts.mp3",
|
||||
provider: "openai",
|
||||
outputFormat: "mp3",
|
||||
voiceCompatible: false,
|
||||
};
|
||||
}
|
||||
if (method === "agent") {
|
||||
return {
|
||||
result: {
|
||||
payloads: [{ text: "gateway reply" }],
|
||||
meta: { agentMeta: { provider: "anthropic", model: "claude-sonnet-4-6" } },
|
||||
},
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
});
|
||||
|
||||
it("lists canonical capabilities", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "list", "--json"],
|
||||
});
|
||||
|
||||
const payload = mocks.runtime.writeJson.mock.calls[0]?.[0] as Array<{ id: string }>;
|
||||
expect(payload.some((entry) => entry.id === "model.run")).toBe(true);
|
||||
expect(payload.some((entry) => entry.id === "image.describe")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults model run to local transport", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "model", "run", "--prompt", "hello", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.agentCommand).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.callGateway).not.toHaveBeenCalled();
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: "model.run",
|
||||
transport: "local",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults tts status to gateway transport", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "tts", "status", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ method: "tts.status" }),
|
||||
);
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ transport: "gateway" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes image describe through media understanding, not generation", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "image", "describe", "--file", "photo.jpg", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.describeImageFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: expect.stringMatching(/photo\.jpg$/) }),
|
||||
);
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: "image.describe",
|
||||
outputs: [expect.objectContaining({ kind: "image.description" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails image describe when no description text is returned", async () => {
|
||||
mocks.describeImageFile.mockResolvedValueOnce({
|
||||
text: undefined,
|
||||
provider: undefined,
|
||||
model: undefined,
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "image", "describe", "--file", "photo.jpg", "--json"],
|
||||
}),
|
||||
).rejects.toThrow("exit 1");
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/No description returned for image/),
|
||||
);
|
||||
});
|
||||
|
||||
it("rewrites mismatched explicit image output extensions to the detected file type", async () => {
|
||||
const jpegBase64 =
|
||||
"/9j/4AAQSkZJRgABAQAAAQABAAD/2wCEAAkGBxAQEBUQEBAVFRUVFRUVFRUVFRUVFRUVFRUXFhUVFRUYHSggGBolHRUVITEhJSkrLi4uFx8zODMsNygtLisBCgoKDg0OGhAQGi0fHyUtLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLf/AABEIAAEAAQMBIgACEQEDEQH/xAAXAAEBAQEAAAAAAAAAAAAAAAAAAQID/8QAFhEBAQEAAAAAAAAAAAAAAAAAAAER/9oADAMBAAIQAxAAAAH2AP/EABgQAQEAAwAAAAAAAAAAAAAAAAEAEQIS/9oACAEBAAEFAk1o7//EABYRAQEBAAAAAAAAAAAAAAAAAAABEf/aAAgBAwEBPwGn/8QAFhEBAQEAAAAAAAAAAAAAAAAAABEB/9oACAECAQE/AYf/xAAaEAACAgMAAAAAAAAAAAAAAAABEQAhMUFh/9oACAEBAAY/AjK9cY2f/8QAGhABAQACAwAAAAAAAAAAAAAAAAERITFBUf/aAAgBAQABPyGQk7W5jVYkA//Z";
|
||||
mocks.generateImage.mockResolvedValue({
|
||||
provider: "openai",
|
||||
model: "gpt-image-1",
|
||||
attempts: [],
|
||||
images: [
|
||||
{
|
||||
buffer: Buffer.from(jpegBase64, "base64"),
|
||||
mimeType: "image/png",
|
||||
fileName: "provider-output.png",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const tempOutput = path.join(os.tmpdir(), `openclaw-image-mismatch-${Date.now()}.png`);
|
||||
await fs.rm(tempOutput, { force: true });
|
||||
await fs.rm(tempOutput.replace(/\.png$/, ".jpg"), { force: true });
|
||||
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"image",
|
||||
"generate",
|
||||
"--prompt",
|
||||
"friendly lobster",
|
||||
"--output",
|
||||
tempOutput,
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
outputs: [
|
||||
expect.objectContaining({
|
||||
path: tempOutput.replace(/\.png$/, ".jpg"),
|
||||
mimeType: "image/jpeg",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes audio transcribe through transcription, not realtime", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "audio", "transcribe", "--file", "memo.m4a", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.transcribeAudioFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ filePath: expect.stringMatching(/memo\.m4a$/) }),
|
||||
);
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: "audio.transcribe",
|
||||
outputs: [expect.objectContaining({ kind: "audio.transcription" })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails audio transcribe when no transcript text is returned", async () => {
|
||||
mocks.transcribeAudioFile.mockResolvedValueOnce({ text: undefined });
|
||||
|
||||
await expect(
|
||||
runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "audio", "transcribe", "--file", "memo.m4a", "--json"],
|
||||
}),
|
||||
).rejects.toThrow("exit 1");
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/No transcript returned for audio/),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards transcription prompt and language hints", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"audio",
|
||||
"transcribe",
|
||||
"--file",
|
||||
"memo.m4a",
|
||||
"--language",
|
||||
"en",
|
||||
"--prompt",
|
||||
"Focus on names",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.transcribeAudioFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
filePath: expect.stringMatching(/memo\.m4a$/),
|
||||
language: "en",
|
||||
prompt: "Focus on names",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses request-scoped TTS overrides without mutating prefs", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"tts",
|
||||
"convert",
|
||||
"--text",
|
||||
"hello",
|
||||
"--model",
|
||||
"openai/gpt-4o-mini-tts",
|
||||
"--voice",
|
||||
"alloy",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.textToSpeech).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
overrides: expect.objectContaining({
|
||||
provider: "openai",
|
||||
providerOverrides: expect.objectContaining({
|
||||
openai: expect.objectContaining({
|
||||
modelId: "gpt-4o-mini-tts",
|
||||
voiceId: "alloy",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mocks.setTtsProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables TTS fallback when explicit provider or voice/model selection is requested", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"tts",
|
||||
"convert",
|
||||
"--text",
|
||||
"hello",
|
||||
"--model",
|
||||
"openai/gpt-4o-mini-tts",
|
||||
"--voice",
|
||||
"alloy",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.textToSpeech).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
disableFallback: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not infer and forward a local provider guess for gateway TTS overrides", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"tts",
|
||||
"convert",
|
||||
"--gateway",
|
||||
"--text",
|
||||
"hello",
|
||||
"--voice",
|
||||
"alloy",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.callGateway).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: "tts.convert",
|
||||
params: expect.objectContaining({
|
||||
provider: undefined,
|
||||
voiceId: "alloy",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails clearly when gateway TTS output is requested against a remote gateway", async () => {
|
||||
const gatewayConnection = await import("../gateway/connection-details.js");
|
||||
vi.mocked(gatewayConnection.buildGatewayConnectionDetailsWithResolvers).mockReturnValueOnce({
|
||||
url: "wss://gateway.example.com",
|
||||
urlSource: "config gateway.remote.url",
|
||||
message: "Gateway target: wss://gateway.example.com",
|
||||
});
|
||||
|
||||
await expect(
|
||||
runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"tts",
|
||||
"convert",
|
||||
"--gateway",
|
||||
"--text",
|
||||
"hello",
|
||||
"--output",
|
||||
"hello.mp3",
|
||||
"--json",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("exit 1");
|
||||
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("--output is not supported for remote gateway TTS yet"),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses only embedding providers for embedding creation", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "embedding", "create", "--text", "hello", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.createEmbeddingProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "auto",
|
||||
fallback: "none",
|
||||
}),
|
||||
);
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
capability: "embedding.create",
|
||||
provider: "openai",
|
||||
model: "text-embedding-3-small",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("derives the embedding provider from a provider/model override", async () => {
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"embedding",
|
||||
"create",
|
||||
"--text",
|
||||
"hello",
|
||||
"--model",
|
||||
"openai/text-embedding-3-large",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
|
||||
expect(mocks.createEmbeddingProvider).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "openai",
|
||||
fallback: "none",
|
||||
model: "text-embedding-3-large",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans provider auth profiles and usage stats on logout", async () => {
|
||||
mocks.loadAuthProfileStoreForRuntime.mockReturnValue({
|
||||
profiles: {
|
||||
"openai:default": { id: "openai:default" },
|
||||
"openai:secondary": { id: "openai:secondary" },
|
||||
"anthropic:default": { id: "anthropic:default" },
|
||||
},
|
||||
order: { openai: ["openai:default", "openai:secondary"] },
|
||||
lastGood: { openai: "openai:secondary" },
|
||||
usageStats: {
|
||||
"openai:default": { errorCount: 2 },
|
||||
"openai:secondary": { errorCount: 1 },
|
||||
"anthropic:default": { errorCount: 3 },
|
||||
},
|
||||
});
|
||||
mocks.listProfilesForProvider.mockReturnValue(["openai:default", "openai:secondary"]);
|
||||
|
||||
let updatedStore: Record<string, any> | null = null;
|
||||
mocks.updateAuthProfileStoreWithLock.mockImplementationOnce(
|
||||
async ({ updater }: { updater: (store: any) => boolean }) => {
|
||||
const store = {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"openai:default": { id: "openai:default" },
|
||||
"openai:secondary": { id: "openai:secondary" },
|
||||
"anthropic:default": { id: "anthropic:default" },
|
||||
},
|
||||
order: { openai: ["openai:default", "openai:secondary"] },
|
||||
lastGood: { openai: "openai:secondary" },
|
||||
usageStats: {
|
||||
"openai:default": { errorCount: 2 },
|
||||
"openai:secondary": { errorCount: 1 },
|
||||
"anthropic:default": { errorCount: 3 },
|
||||
},
|
||||
};
|
||||
updater(store);
|
||||
updatedStore = store;
|
||||
return store;
|
||||
},
|
||||
);
|
||||
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "model", "auth", "logout", "--provider", "openai", "--json"],
|
||||
});
|
||||
|
||||
expect(updatedStore).toMatchObject({
|
||||
profiles: {
|
||||
"anthropic:default": { id: "anthropic:default" },
|
||||
},
|
||||
order: {},
|
||||
lastGood: {},
|
||||
usageStats: {
|
||||
"anthropic:default": { errorCount: 3 },
|
||||
},
|
||||
});
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
|
||||
provider: "openai",
|
||||
removedProfiles: ["openai:default", "openai:secondary"],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails logout if the auth store update does not complete", async () => {
|
||||
mocks.listProfilesForProvider.mockReturnValue(["openai:default"]);
|
||||
mocks.updateAuthProfileStoreWithLock.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "model", "auth", "logout", "--provider", "openai", "--json"],
|
||||
}),
|
||||
).rejects.toThrow("exit 1");
|
||||
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to remove saved auth profiles for provider openai."),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects providerless audio model overrides", async () => {
|
||||
await expect(
|
||||
runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"audio",
|
||||
"transcribe",
|
||||
"--file",
|
||||
"memo.m4a",
|
||||
"--model",
|
||||
"whisper-1",
|
||||
"--json",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("exit 1");
|
||||
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Model overrides must use the form <provider/model>."),
|
||||
);
|
||||
expect(mocks.transcribeAudioFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects providerless image describe model overrides", async () => {
|
||||
await expect(
|
||||
runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"image",
|
||||
"describe",
|
||||
"--file",
|
||||
"photo.jpg",
|
||||
"--model",
|
||||
"gpt-4.1-mini",
|
||||
"--json",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("exit 1");
|
||||
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Model overrides must use the form <provider/model>."),
|
||||
);
|
||||
expect(mocks.describeImageFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects providerless video describe model overrides", async () => {
|
||||
const mediaRuntime = await import("../media-understanding/runtime.js");
|
||||
vi.mocked(mediaRuntime.describeVideoFile).mockResolvedValue({
|
||||
text: "friendly lobster",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
} as never);
|
||||
|
||||
await expect(
|
||||
runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: [
|
||||
"capability",
|
||||
"video",
|
||||
"describe",
|
||||
"--file",
|
||||
"clip.mp4",
|
||||
"--model",
|
||||
"gpt-4.1-mini",
|
||||
"--json",
|
||||
],
|
||||
}),
|
||||
).rejects.toThrow("exit 1");
|
||||
|
||||
expect(mocks.runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Model overrides must use the form <provider/model>."),
|
||||
);
|
||||
expect(vi.mocked(mediaRuntime.describeVideoFile)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("bootstraps built-in embedding providers when the registry is empty", async () => {
|
||||
mocks.listMemoryEmbeddingProviders.mockReturnValueOnce([]);
|
||||
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "embedding", "providers", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.registerBuiltInMemoryEmbeddingProviders).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
registerMemoryEmbeddingProvider: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces available, configured, and selected for web providers", async () => {
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
tools: {
|
||||
web: {
|
||||
search: { provider: "gemini" },
|
||||
fetch: { provider: "firecrawl" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const webSearchRuntime = await import("../web-search/runtime.js");
|
||||
const webFetchRuntime = await import("../web-fetch/runtime.js");
|
||||
vi.mocked(webSearchRuntime.listWebSearchProviders).mockReturnValue([
|
||||
{ id: "brave", envVars: ["BRAVE_API_KEY"] } as never,
|
||||
{ id: "gemini", envVars: ["GEMINI_API_KEY"] } as never,
|
||||
]);
|
||||
vi.mocked(webFetchRuntime.listWebFetchProviders).mockReturnValue([
|
||||
{ id: "firecrawl", envVars: ["FIRECRAWL_API_KEY"] } as never,
|
||||
]);
|
||||
mocks.isWebSearchProviderConfigured.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
mocks.isWebFetchProviderConfigured.mockReturnValueOnce(true);
|
||||
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "web", "providers", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith({
|
||||
search: [
|
||||
{
|
||||
available: true,
|
||||
configured: false,
|
||||
selected: false,
|
||||
id: "brave",
|
||||
envVars: ["BRAVE_API_KEY"],
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
configured: true,
|
||||
selected: true,
|
||||
id: "gemini",
|
||||
envVars: ["GEMINI_API_KEY"],
|
||||
},
|
||||
],
|
||||
fetch: [
|
||||
{
|
||||
available: true,
|
||||
configured: true,
|
||||
selected: true,
|
||||
id: "firecrawl",
|
||||
envVars: ["FIRECRAWL_API_KEY"],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces selected and configured embedding provider state", async () => {
|
||||
mocks.loadConfig.mockReturnValue({});
|
||||
mocks.resolveMemorySearchConfig.mockReturnValue({
|
||||
provider: "gemini",
|
||||
model: "gemini-embedding-001",
|
||||
});
|
||||
mocks.listMemoryEmbeddingProviders.mockReturnValue([
|
||||
{ id: "openai", defaultModel: "text-embedding-3-small", transport: "remote" },
|
||||
{ id: "gemini", defaultModel: "gemini-embedding-001", transport: "remote" },
|
||||
]);
|
||||
|
||||
await runRegisteredCli({
|
||||
register: registerCapabilityCli as (program: Command) => void,
|
||||
argv: ["capability", "embedding", "providers", "--json"],
|
||||
});
|
||||
|
||||
expect(mocks.runtime.writeJson).toHaveBeenCalledWith([
|
||||
{
|
||||
available: true,
|
||||
configured: false,
|
||||
selected: false,
|
||||
id: "openai",
|
||||
defaultModel: "text-embedding-3-small",
|
||||
transport: "remote",
|
||||
autoSelectPriority: undefined,
|
||||
},
|
||||
{
|
||||
available: true,
|
||||
configured: true,
|
||||
selected: true,
|
||||
id: "gemini",
|
||||
defaultModel: "gemini-embedding-001",
|
||||
transport: "remote",
|
||||
autoSelectPriority: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
1822
src/cli/capability-cli.ts
Normal file
1822
src/cli/capability-cli.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,9 +26,18 @@ const { registerQaCli } = vi.hoisted(() => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const { inferAction, registerCapabilityCli } = vi.hoisted(() => {
|
||||
const action = vi.fn();
|
||||
const register = vi.fn((program: Command) => {
|
||||
program.command("infer").alias("capability").action(action);
|
||||
});
|
||||
return { inferAction: action, registerCapabilityCli: register };
|
||||
});
|
||||
|
||||
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
|
||||
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
|
||||
vi.mock("../qa-cli.js", () => ({ registerQaCli }));
|
||||
vi.mock("../capability-cli.js", () => ({ registerCapabilityCli }));
|
||||
|
||||
describe("registerSubCliCommands", () => {
|
||||
const originalArgv = process.argv;
|
||||
@@ -54,6 +63,8 @@ describe("registerSubCliCommands", () => {
|
||||
acpAction.mockClear();
|
||||
registerNodesCli.mockClear();
|
||||
nodesAction.mockClear();
|
||||
registerCapabilityCli.mockClear();
|
||||
inferAction.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -98,6 +109,17 @@ describe("registerSubCliCommands", () => {
|
||||
expect(nodesAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("registers the infer placeholder and dispatches through the capability registrar", async () => {
|
||||
const program = createRegisteredProgram(["node", "openclaw", "infer"], "openclaw");
|
||||
|
||||
expect(program.commands.map((cmd) => cmd.name())).toEqual(["infer"]);
|
||||
|
||||
await program.parseAsync(["infer"], { from: "user" });
|
||||
|
||||
expect(registerCapabilityCli).toHaveBeenCalledTimes(1);
|
||||
expect(inferAction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces placeholder when registering a subcommand by name", async () => {
|
||||
const program = createRegisteredProgram(["node", "openclaw", "acp", "--help"], "openclaw");
|
||||
|
||||
|
||||
@@ -74,6 +74,11 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
|
||||
loadModule: () => import("../models-cli.js"),
|
||||
exportName: "registerModelsCli",
|
||||
},
|
||||
{
|
||||
commandNames: ["infer", "capability"],
|
||||
loadModule: () => import("../capability-cli.js"),
|
||||
exportName: "registerCapabilityCli",
|
||||
},
|
||||
{
|
||||
commandNames: ["approvals"],
|
||||
loadModule: () => import("../exec-approvals-cli.js"),
|
||||
|
||||
@@ -22,6 +22,16 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
|
||||
description: "Discover, scan, and configure models",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "infer",
|
||||
description: "Run provider-backed inference commands",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "capability",
|
||||
description: "Run provider-backed inference commands (fallback alias: infer)",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
{
|
||||
name: "approvals",
|
||||
description: "Manage exec approvals (gateway or node host)",
|
||||
|
||||
Reference in New Issue
Block a user