// Openrouter tests cover speech provider plugin behavior. 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) => ({ 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 { 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 { 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; } 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); }); });