Files
openclaw/extensions/openrouter/speech-provider.test.ts
2026-05-17 02:32:14 +08:00

219 lines
6.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import { buildOpenRouterSpeechProvider } from "./speech-provider.js";
const {
assertOkOrThrowHttpErrorMock,
postJsonRequestMock,
readProviderBinaryResponseMock,
resolveProviderHttpRequestConfigMock,
} = vi.hoisted(() => ({
assertOkOrThrowHttpErrorMock: vi.fn(async () => {}),
postJsonRequestMock: vi.fn(),
readProviderBinaryResponseMock: vi.fn(async (response: Response) => {
return new Uint8Array(await response.arrayBuffer());
}),
resolveProviderHttpRequestConfigMock: vi.fn((params: Record<string, unknown>) => ({
baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://openrouter.ai/api/v1",
allowPrivateNetwork: false,
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
dispatcherPolicy: undefined,
})),
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock,
postJsonRequest: postJsonRequestMock,
readProviderBinaryResponse: readProviderBinaryResponseMock,
resolveProviderHttpRequestConfig: resolveProviderHttpRequestConfigMock,
}));
function requireOpenRouterConfigRequest(): Record<string, unknown> {
const [call] = resolveProviderHttpRequestConfigMock.mock.calls;
if (!call) {
throw new Error("expected OpenRouter speech config request");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter speech config request");
}
return request;
}
function requireOpenRouterPostRequest(): Record<string, unknown> {
const [call] = postJsonRequestMock.mock.calls;
if (!call) {
throw new Error("expected OpenRouter speech request");
}
const [request] = call;
if (!request || typeof request !== "object" || Array.isArray(request)) {
throw new Error("expected OpenRouter speech request");
}
return request as Record<string, unknown>;
}
function requireHeaders(value: unknown): Headers {
if (!(value instanceof Headers)) {
throw new Error("expected OpenRouter speech request headers");
}
return value;
}
describe("openrouter speech provider", () => {
afterEach(() => {
assertOkOrThrowHttpErrorMock.mockClear();
postJsonRequestMock.mockReset();
readProviderBinaryResponseMock.mockClear();
resolveProviderHttpRequestConfigMock.mockClear();
vi.unstubAllEnvs();
});
it("normalizes provider-owned speech config", () => {
const provider = buildOpenRouterSpeechProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as never,
timeoutMs: 30_000,
rawConfig: {
providers: {
openrouter: {
apiKey: "sk-test",
baseUrl: "https://openrouter.ai/v1/",
modelId: "google/gemini-3.1-flash-tts-preview",
voiceId: "Kore",
speed: 1.1,
responseFormat: " MP3 ",
provider: {
options: {
openai: {
instructions: "Speak warmly.",
},
},
},
},
},
},
});
expect(resolved).toEqual({
apiKey: "sk-test",
baseUrl: "https://openrouter.ai/api/v1",
model: "google/gemini-3.1-flash-tts-preview",
voice: "Kore",
speed: 1.1,
responseFormat: "mp3",
provider: {
options: {
openai: {
instructions: "Speak warmly.",
},
},
},
});
});
it("synthesizes OpenAI-compatible speech through OpenRouter", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
response: new Response(new Uint8Array([1, 2, 3]), { status: 200 }),
release,
});
const provider = buildOpenRouterSpeechProvider();
const result = await provider.synthesize({
text: "hello",
cfg: {
models: {
providers: {
openrouter: {
apiKey: "sk-openrouter",
baseUrl: "https://openrouter.ai/v1/",
},
},
},
} as never,
providerConfig: {
model: "openai/gpt-4o-mini-tts-2025-12-15",
voice: "nova",
speed: 1.2,
},
target: "voice-note",
timeoutMs: 12_345,
});
expect(resolveProviderHttpRequestConfigMock).toHaveBeenCalledOnce();
expect(requireOpenRouterConfigRequest()).toEqual({
baseUrl: "https://openrouter.ai/api/v1",
defaultBaseUrl: "https://openrouter.ai/api/v1",
allowPrivateNetwork: false,
defaultHeaders: {
Authorization: "Bearer sk-openrouter",
"Content-Type": "application/json",
"HTTP-Referer": "https://openclaw.ai",
"X-OpenRouter-Title": "OpenClaw",
},
provider: "openrouter",
capability: "audio",
transport: "http",
});
expect(postJsonRequestMock).toHaveBeenCalledOnce();
const request = requireOpenRouterPostRequest();
const headers = requireHeaders(request.headers);
expect(Object.fromEntries(headers.entries())).toEqual({
authorization: "Bearer sk-openrouter",
"content-type": "application/json",
"http-referer": "https://openclaw.ai",
"x-openrouter-title": "OpenClaw",
});
expect(request).toEqual({
url: "https://openrouter.ai/api/v1/audio/speech",
headers,
body: {
model: "openai/gpt-4o-mini-tts-2025-12-15",
input: "hello",
voice: "nova",
response_format: "mp3",
speed: 1.2,
},
timeoutMs: 12_345,
fetchFn: fetch,
allowPrivateNetwork: false,
dispatcherPolicy: undefined,
});
expect(result.audioBuffer).toEqual(Buffer.from([1, 2, 3]));
expect(readProviderBinaryResponseMock).toHaveBeenCalledWith(
expect.any(Response),
"OpenRouter TTS API error",
"audio",
);
expect(result.outputFormat).toBe("mp3");
expect(result.fileExtension).toBe(".mp3");
expect(result.voiceCompatible).toBe(true);
expect(release).toHaveBeenCalledOnce();
});
it("defaults to a live-proven OpenRouter TTS model", () => {
const provider = buildOpenRouterSpeechProvider();
expect(
provider.resolveConfig?.({ cfg: {} as never, rawConfig: {}, timeoutMs: 30_000 }),
).toEqual({
model: "hexgrad/kokoro-82m",
voice: "af_alloy",
responseFormat: undefined,
provider: undefined,
});
});
it("uses OPENROUTER_API_KEY when provider config omits apiKey", () => {
vi.stubEnv("OPENROUTER_API_KEY", "sk-env");
const provider = buildOpenRouterSpeechProvider();
expect(
provider.isConfigured({
cfg: {} as never,
providerConfig: {},
timeoutMs: 30_000,
}),
).toBe(true);
});
});