|
2 | 2 | import { afterEach, describe, expect, it, vi } from "vitest"; |
3 | 3 | import { buildXaiSpeechProvider } from "./speech-provider.js"; |
4 | 4 |
|
5 | | -const { xaiTTSMock, isProviderAuthProfileConfiguredMock, resolveApiKeyForProviderMock } = |
6 | | - vi.hoisted(() => ({ |
7 | | - xaiTTSMock: vi.fn(async () => Buffer.from("audio-bytes")), |
8 | | - isProviderAuthProfileConfiguredMock: vi.fn(() => false), |
9 | | - resolveApiKeyForProviderMock: vi.fn( |
10 | | - async (): Promise<{ apiKey: string | undefined }> => ({ apiKey: undefined }), |
11 | | - ), |
12 | | - })); |
| 5 | +const { |
| 6 | + xaiTTSMock, |
| 7 | + listXaiTtsVoicesMock, |
| 8 | + isProviderAuthProfileConfiguredMock, |
| 9 | + resolveApiKeyForProviderMock, |
| 10 | +} = vi.hoisted(() => ({ |
| 11 | + xaiTTSMock: vi.fn(async () => Buffer.from("audio-bytes")), |
| 12 | + listXaiTtsVoicesMock: vi.fn(async () => [{ id: "altair", name: "Altair" }]), |
| 13 | + isProviderAuthProfileConfiguredMock: vi.fn(() => false), |
| 14 | + resolveApiKeyForProviderMock: vi.fn( |
| 15 | + async (): Promise<{ apiKey: string | undefined }> => ({ apiKey: undefined }), |
| 16 | + ), |
| 17 | +})); |
13 | 18 |
|
14 | 19 | vi.mock("./tts.js", () => ({ |
15 | 20 | XAI_BASE_URL: "https://api.x.ai/v1", |
16 | | - XAI_TTS_VOICES: ["eve", "ara", "rex", "sal", "leo", "una"], |
17 | | - isValidXaiTtsVoice: (voice: string) => ["eve", "ara", "rex", "sal", "leo", "una"].includes(voice), |
| 21 | + XAI_TTS_FALLBACK_VOICES: ["ara", "eve", "leo", "rex", "sal"], |
| 22 | + isValidXaiTtsVoice: (voice: string) => voice.trim().length > 0, |
| 23 | + listXaiTtsVoices: listXaiTtsVoicesMock, |
18 | 24 | normalizeXaiLanguageCode: (value: unknown) => |
19 | 25 | typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined, |
20 | 26 | normalizeXaiTtsBaseUrl: (baseUrl?: string) => |
@@ -64,7 +70,10 @@ describe("xai speech provider", () => { |
64 | 70 | isProviderAuthProfileConfiguredMock.mockReturnValue(false); |
65 | 71 | resolveApiKeyForProviderMock.mockReset(); |
66 | 72 | resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: undefined }); |
| 73 | + listXaiTtsVoicesMock.mockReset(); |
| 74 | + listXaiTtsVoicesMock.mockResolvedValue([{ id: "altair", name: "Altair" }]); |
67 | 75 | delete process.env.XAI_API_KEY; |
| 76 | + delete process.env.XAI_BASE_URL; |
68 | 77 | }); |
69 | 78 |
|
70 | 79 | it("synthesizes mp3 audio and does not claim native voice-note compatibility", async () => { |
@@ -196,6 +205,112 @@ describe("xai speech provider", () => { |
196 | 205 | ).toBe(false); |
197 | 206 | }); |
198 | 207 |
|
| 208 | + it("uses direct voice-list auth and URL overrides before configured sources", async () => { |
| 209 | + process.env.XAI_API_KEY = "env-key"; |
| 210 | + resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "oauth-bearer" }); |
| 211 | + const provider = buildXaiSpeechProvider(); |
| 212 | + |
| 213 | + await provider.listVoices?.({ |
| 214 | + apiKey: "request-key", |
| 215 | + baseUrl: "https://api.x.ai/v1/", |
| 216 | + providerConfig: { |
| 217 | + apiKey: "config-key", |
| 218 | + baseUrl: "https://config.example/v1", |
| 219 | + }, |
| 220 | + cfg: {}, |
| 221 | + }); |
| 222 | + |
| 223 | + expect(listXaiTtsVoicesMock).toHaveBeenCalledWith({ |
| 224 | + apiKey: "request-key", |
| 225 | + baseUrl: "https://api.x.ai/v1", |
| 226 | + }); |
| 227 | + expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled(); |
| 228 | + }); |
| 229 | + |
| 230 | + it("uses provider config auth before environment and profiles", async () => { |
| 231 | + process.env.XAI_API_KEY = "env-key"; |
| 232 | + resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "oauth-bearer" }); |
| 233 | + const provider = buildXaiSpeechProvider(); |
| 234 | + |
| 235 | + await provider.listVoices?.({ |
| 236 | + providerConfig: { |
| 237 | + apiKey: "config-key", |
| 238 | + baseUrl: "https://config.example/v1/", |
| 239 | + }, |
| 240 | + cfg: {}, |
| 241 | + }); |
| 242 | + |
| 243 | + expect(listXaiTtsVoicesMock).toHaveBeenCalledWith({ |
| 244 | + apiKey: "config-key", |
| 245 | + baseUrl: "https://config.example/v1", |
| 246 | + }); |
| 247 | + expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled(); |
| 248 | + }); |
| 249 | + |
| 250 | + it("uses environment auth before profiles for voice discovery", async () => { |
| 251 | + process.env.XAI_API_KEY = "env-key"; |
| 252 | + resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "oauth-bearer" }); |
| 253 | + const provider = buildXaiSpeechProvider(); |
| 254 | + |
| 255 | + await provider.listVoices?.({ providerConfig: {}, cfg: {} }); |
| 256 | + |
| 257 | + expect(listXaiTtsVoicesMock).toHaveBeenCalledWith({ |
| 258 | + apiKey: "env-key", |
| 259 | + baseUrl: "https://api.x.ai/v1", |
| 260 | + }); |
| 261 | + expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled(); |
| 262 | + }); |
| 263 | + |
| 264 | + it("uses the environment-only base URL for voice discovery", async () => { |
| 265 | + process.env.XAI_API_KEY = "env-key"; |
| 266 | + process.env.XAI_BASE_URL = "https://env.example/v1/"; |
| 267 | + const provider = buildXaiSpeechProvider(); |
| 268 | + |
| 269 | + await provider.listVoices?.({ providerConfig: {}, cfg: {} }); |
| 270 | + |
| 271 | + expect(listXaiTtsVoicesMock).toHaveBeenCalledWith({ |
| 272 | + apiKey: "env-key", |
| 273 | + baseUrl: "https://env.example/v1", |
| 274 | + }); |
| 275 | + }); |
| 276 | + |
| 277 | + it("uses cfg-scoped profile auth for voice discovery", async () => { |
| 278 | + resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: "oauth-bearer" }); |
| 279 | + const provider = buildXaiSpeechProvider(); |
| 280 | + const cfg = { agents: { defaults: {} } }; |
| 281 | + |
| 282 | + await provider.listVoices?.({ providerConfig: {}, cfg }); |
| 283 | + |
| 284 | + expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ provider: "xai", cfg }); |
| 285 | + expect(listXaiTtsVoicesMock).toHaveBeenCalledWith({ |
| 286 | + apiKey: "oauth-bearer", |
| 287 | + baseUrl: "https://api.x.ai/v1", |
| 288 | + }); |
| 289 | + }); |
| 290 | + |
| 291 | + it("returns the five offline fallback voices only when auth is absent", async () => { |
| 292 | + const provider = buildXaiSpeechProvider(); |
| 293 | + |
| 294 | + await expect(provider.listVoices?.({ providerConfig: {} })).resolves.toEqual([ |
| 295 | + { id: "ara", name: "ara" }, |
| 296 | + { id: "eve", name: "eve" }, |
| 297 | + { id: "leo", name: "leo" }, |
| 298 | + { id: "rex", name: "rex" }, |
| 299 | + { id: "sal", name: "sal" }, |
| 300 | + ]); |
| 301 | + expect(listXaiTtsVoicesMock).not.toHaveBeenCalled(); |
| 302 | + expect(resolveApiKeyForProviderMock).not.toHaveBeenCalled(); |
| 303 | + }); |
| 304 | + |
| 305 | + it("does not mask authenticated catalog failures with offline voices", async () => { |
| 306 | + listXaiTtsVoicesMock.mockRejectedValueOnce(new Error("catalog unavailable")); |
| 307 | + const provider = buildXaiSpeechProvider(); |
| 308 | + |
| 309 | + await expect( |
| 310 | + provider.listVoices?.({ providerConfig: { apiKey: "bad-key" }, cfg: {} }), |
| 311 | + ).rejects.toThrow("catalog unavailable"); |
| 312 | + }); |
| 313 | + |
199 | 314 | it("threads cfg into the OAuth fallback resolver when no direct apiKey is available", async () => { |
200 | 315 | resolveApiKeyForProviderMock.mockResolvedValueOnce({ apiKey: "oauth-bearer" }); |
201 | 316 | const provider = buildXaiSpeechProvider(); |
|
0 commit comments