// Openai tests cover speech provider plugin behavior. import { afterEach, describe, expect, it, vi } from "vitest"; import { buildOpenAISpeechProvider } from "./speech-provider.js"; vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: async ({ url, init, }: { url: string; init?: RequestInit; }): Promise<{ response: Response; release: () => Promise }> => ({ response: await globalThis.fetch(url, init), release: vi.fn(async () => {}), }), ssrfPolicyFromHttpBaseUrlAllowedHostname: () => undefined, })); function isSpeechRequestBody(value: unknown): value is { [key: string]: unknown; model?: string; voice?: string; speed?: number; response_format?: string; } { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function parseRequestBody(init: RequestInit | undefined): { [key: string]: unknown; model?: string; voice?: string; speed?: number; response_format?: string; } { if (typeof init?.body !== "string") { throw new Error("expected string request body"); } const body: unknown = JSON.parse(init.body); if (!isSpeechRequestBody(body)) { throw new Error("expected OpenAI speech request body"); } return body; } function mockSpeechFetchExpectingFormat(responseFormat: string) { const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { const body = parseRequestBody(init); expect(body.response_format).toBe(responseFormat); return new Response(new Uint8Array([1, 2, 3]), { status: 200 }); }); globalThis.fetch = fetchMock as unknown as typeof fetch; return fetchMock; } describe("buildOpenAISpeechProvider", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; vi.restoreAllMocks(); }); it("normalizes provider-owned speech config from raw provider config", () => { const provider = buildOpenAISpeechProvider(); const resolved = provider.resolveConfig?.({ cfg: {} as never, timeoutMs: 30_000, rawConfig: { providers: { openai: { apiKey: "sk-test", baseUrl: "https://example.com/v1/", model: "tts-1", voice: "alloy", speed: 1.25, instructions: " Speak warmly ", responseFormat: " WAV ", extraBody: { lang: "en-US", }, }, }, }, }); expect(resolved).toEqual({ apiKey: "sk-test", baseUrl: "https://example.com/v1", model: "tts-1", voice: "alloy", speed: 1.25, instructions: "Speak warmly", responseFormat: "wav", extraBody: { lang: "en-US", }, }); }); it("drops malformed speech speed values", () => { const provider = buildOpenAISpeechProvider(); const resolved = provider.resolveConfig?.({ cfg: {} as never, timeoutMs: 30_000, rawConfig: { providers: { openai: { speed: 4.5, }, }, }, }); expect(resolved?.speed).toBeUndefined(); }); it("passes custom endpoint speech speeds through", () => { const provider = buildOpenAISpeechProvider(); const resolved = provider.resolveConfig?.({ cfg: {} as never, timeoutMs: 30_000, rawConfig: { providers: { openai: { baseUrl: "https://tts.example.com/v1", speed: 4.5, }, }, }, }); expect(resolved?.speed).toBe(4.5); }); it("uses talk base url overrides when validating speech speed", () => { const provider = buildOpenAISpeechProvider(); const resolvedConfig = provider.resolveTalkConfig?.({ cfg: {} as never, timeoutMs: 30_000, baseTtsConfig: { providers: { openai: { apiKey: "sk-base", }, }, }, talkProviderConfig: { baseUrl: "https://tts.example.com/v1", speed: 4.5, }, }); expect(resolvedConfig?.baseUrl).toBe("https://tts.example.com/v1"); expect(resolvedConfig?.speed).toBe(4.5); }); it("parses OpenAI directive tokens against the resolved base url", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "voice", value: "alloy", policy: { allowVoice: true, allowModelId: true, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: true, overrides: { voice: "alloy" }, }); expect( provider.parseDirectiveToken?.({ key: "model", value: "kokoro-custom-model", policy: { allowVoice: true, allowModelId: true, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: false, }); }); it("parses preferred-OpenAI speed directive within the supported range", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "speed", value: "1.5", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: true, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: true, overrides: { speed: 1.5 }, }); }); it("parses explicit openai_speed alias", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "openai_speed", value: "0.75", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: true, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: true, overrides: { speed: 0.75 }, }); }); it("ignores OpenAI speed directives when allowVoiceSettings is disabled", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "speed", value: "1.5", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: false, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: true, }); }); it("warns on non-numeric OpenAI speed values", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "speed", value: "fast", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: true, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: true, warnings: ['invalid OpenAI speed "fast" (0.25-4.0)'], }); }); it("warns on partial OpenAI speed values", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "speed", value: "1.5abc", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: true, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: true, warnings: ['invalid OpenAI speed "1.5abc" (0.25-4.0)'], }); }); it("warns on OpenAI speed values outside the supported 0.25..4 range", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "speed", value: "5", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: true, }, providerConfig: { baseUrl: "https://api.openai.com/v1/", }, } as never), ).toEqual({ handled: true, warnings: ['invalid OpenAI speed "5" (0.25-4.0)'], }); }); it("passes custom endpoint OpenAI-compatible speed directives through", () => { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "speed", value: "4.5", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: true, }, providerConfig: { baseUrl: "https://tts.example.com/v1", }, } as never), ).toEqual({ handled: true, overrides: { speed: 4.5 }, }); }); it("uses OPENAI_TTS_BASE_URL when parsing OpenAI-compatible speed directives", () => { const previousBaseUrl = process.env.OPENAI_TTS_BASE_URL; process.env.OPENAI_TTS_BASE_URL = "https://tts.example.com/v1"; try { const provider = buildOpenAISpeechProvider(); expect( provider.parseDirectiveToken?.({ key: "speed", value: "4.5", policy: { allowVoice: true, allowModelId: true, allowVoiceSettings: true, }, providerConfig: {}, } as never), ).toEqual({ handled: true, overrides: { speed: 4.5 }, }); } finally { if (previousBaseUrl === undefined) { delete process.env.OPENAI_TTS_BASE_URL; } else { process.env.OPENAI_TTS_BASE_URL = previousBaseUrl; } } }); it("preserves talk responseFormat overrides", () => { const provider = buildOpenAISpeechProvider(); const resolvedConfig = provider.resolveTalkConfig?.({ cfg: {} as never, timeoutMs: 30_000, baseTtsConfig: { providers: { openai: { apiKey: "sk-base", responseFormat: "mp3", }, }, }, talkProviderConfig: { apiKey: "sk-talk", responseFormat: " WAV ", }, }); expect(resolvedConfig?.apiKey).toBe("sk-talk"); expect(resolvedConfig?.responseFormat).toBe("wav"); }); it("maps Talk speak params onto OpenAI speech overrides", () => { const provider = buildOpenAISpeechProvider(); expect( provider.resolveTalkOverrides?.({ talkProviderConfig: {}, params: { text: "Hello from talk mode.", voiceId: "nova", modelId: "tts-1", speed: 218 / 175, }, }), ).toEqual({ voice: "nova", model: "tts-1", speed: 218 / 175, }); }); it("maps persona prompt fields to instructions when instructions are unset", async () => { const provider = buildOpenAISpeechProvider(); const prepared = await provider.prepareSynthesis?.({ text: "hello", cfg: {} as never, providerConfig: { apiKey: "sk-test", model: "gpt-4o-mini-tts", voice: "cedar", }, persona: { id: "alfred", label: "Alfred", prompt: { profile: "A brilliant British butler.", scene: "A quiet late-night study.", sampleContext: "The speaker is answering a trusted operator.", style: "Refined and lightly amused.", accent: "British English.", pacing: "Measured.", constraints: ["Do not read configuration values aloud."], }, }, target: "audio-file", timeoutMs: 1_000, }); expect(prepared?.providerConfig?.instructions).toContain("Persona: Alfred"); expect(prepared?.providerConfig?.instructions).toContain( "Constraint: Do not read configuration values aloud.", ); }); it("uses wav for Groq-compatible OpenAI TTS endpoints", async () => { const provider = buildOpenAISpeechProvider(); mockSpeechFetchExpectingFormat("wav"); const result = await provider.synthesize({ text: "hello", cfg: {} as never, providerConfig: { apiKey: "sk-test", baseUrl: "https://api.groq.com/openai/v1", model: "canopylabs/orpheus-v1-english", voice: "daniel", }, target: "audio-file", timeoutMs: 1_000, }); expect(result.outputFormat).toBe("wav"); expect(result.fileExtension).toBe(".wav"); expect(result.voiceCompatible).toBe(false); }); it("applies the configured media byte cap to synthesized audio", async () => { const provider = buildOpenAISpeechProvider(); globalThis.fetch = vi.fn( async () => new Response(new Uint8Array(2048), { status: 200 }), ) as unknown as typeof fetch; await expect( provider.synthesize({ text: "hello", cfg: { agents: { defaults: { mediaMaxMb: 0.001, }, }, } as never, providerConfig: { apiKey: "sk-test", model: "gpt-4o-mini-tts", voice: "alloy", }, target: "audio-file", timeoutMs: 1_000, }), ).rejects.toThrow("OpenAI TTS audio response exceeds"); }); it("applies provider overrides to telephony synthesis", async () => { const provider = buildOpenAISpeechProvider(); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { const body = parseRequestBody(init); expect(body.model).toBe("tts-1"); expect(body.voice).toBe("nova"); expect(body.speed).toBe(1.25); expect(body.response_format).toBe("pcm"); return new Response(new Uint8Array([1, 2, 3]), { status: 200 }); }); globalThis.fetch = fetchMock as unknown as typeof fetch; const result = await provider.synthesizeTelephony?.({ text: "hello", cfg: {} as never, providerConfig: { apiKey: "sk-test", model: "gpt-4o-mini-tts", voice: "alloy", speed: 1, }, providerOverrides: { model: "tts-1", voice: "nova", speed: 1.25, }, timeoutMs: 1_000, }); expect(result?.outputFormat).toBe("pcm"); expect(fetchMock).toHaveBeenCalledTimes(1); }); it.each([ { responseFormat: "wav" as const, voiceCompatible: false }, { responseFormat: "mp3" as const, voiceCompatible: true }, ])( "marks configured $responseFormat voice-note compatibility as $voiceCompatible", async ({ responseFormat, voiceCompatible }) => { const provider = buildOpenAISpeechProvider(); mockSpeechFetchExpectingFormat(responseFormat); const result = await provider.synthesize({ text: "hello", cfg: {} as never, providerConfig: { apiKey: "sk-test", model: "gpt-4o-mini-tts", voice: "alloy", responseFormat, }, target: "voice-note", timeoutMs: 1_000, }); expect(result.outputFormat).toBe(responseFormat); expect(result.fileExtension).toBe(`.${responseFormat}`); expect(result.voiceCompatible).toBe(voiceCompatible); }, ); it("passes extra_body config through to OpenAI-compatible speech requests", async () => { const provider = buildOpenAISpeechProvider(); const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => { const body = parseRequestBody(init); expect(body.model).toBe("custom-tts"); expect(body.voice).toBe("custom-voice"); expect(body.lang).toBe("en-US"); expect(body.response_format).toBe("mp3"); return new Response(new Uint8Array([1, 2, 3]), { status: 200 }); }); globalThis.fetch = fetchMock as unknown as typeof fetch; const result = await provider.synthesize({ text: "hello", cfg: {} as never, providerConfig: { apiKey: "sk-test", baseUrl: "https://proxy.example.com/openai/v1", model: "custom-tts", voice: "custom-voice", responseFormat: "mp3", extra_body: { lang: "en-US", }, }, target: "audio-file", timeoutMs: 1_000, }); expect(result.outputFormat).toBe("mp3"); expect(fetchMock).toHaveBeenCalledTimes(1); }); });