Skip to content

Commit 7f5dce8

Browse files
authored
fix(xai): discover TTS voices dynamically (#103446)
1 parent 5b916cb commit 7f5dce8

7 files changed

Lines changed: 355 additions & 53 deletions

File tree

docs/providers/xai.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,8 +315,12 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
315315
The bundled `xai` plugin registers text-to-speech through the shared `tts`
316316
provider surface.
317317

318-
- Voices: `eve`, `ara`, `rex`, `sal`, `leo`, `una`
318+
- Voices: authenticated live catalog from xAI; list it with
319+
`openclaw infer tts voices --provider xai`
320+
- Offline fallback voices: `ara`, `eve`, `leo`, `rex`, `sal`
319321
- Default voice: `eve`
322+
- Account custom voice IDs are forwarded even when they are absent from the
323+
built-in catalog response
320324
- Formats: `mp3`, `wav`, `pcm`, `mulaw`, `alaw`
321325
- Language: BCP-47 code or `auto`
322326
- Speed: provider-native speed override
@@ -340,9 +344,9 @@ stale context metadata on active 4.20 rows. It does not pin active 4.20
340344
```
341345

342346
<Note>
343-
OpenClaw uses xAI's batch `/v1/tts` endpoint. xAI also offers streaming
344-
TTS over WebSocket, but the bundled xAI provider does not implement that
345-
streaming hook yet.
347+
OpenClaw uses xAI's batch `/v1/tts` endpoint and authenticated
348+
`/v1/tts/voices` catalog. xAI also offers streaming TTS over WebSocket, but
349+
the bundled xAI provider does not implement that streaming hook yet.
346350
</Note>
347351

348352
</Accordion>

docs/tools/tts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -929,7 +929,7 @@ Reply -> TTS enabled?
929929
<Accordion title="xAI">
930930
<ParamField path="apiKey" type="string">Env: `XAI_API_KEY`.</ParamField>
931931
<ParamField path="baseUrl" type="string">Default `https://api.x.ai/v1`. Env: `XAI_BASE_URL`.</ParamField>
932-
<ParamField path="speakerVoiceId" type="string">Default `eve`. Live voices: `ara`, `eve`, `leo`, `rex`, `sal`, `una`. Legacy alias: `voiceId`.</ParamField>
932+
<ParamField path="speakerVoiceId" type="string">Default `eve`. With auth, `openclaw infer tts voices --provider xai` fetches the current built-in catalog; without auth it lists offline fallbacks `ara`, `eve`, `leo`, `rex`, and `sal`. Account custom voice IDs are forwarded even when absent from the built-in list. Legacy alias: `voiceId`.</ParamField>
933933
<ParamField path="language" type="string">BCP-47 language code or `auto`. Default `en`.</ParamField>
934934
<ParamField path="responseFormat" type='"mp3" | "wav" | "pcm" | "mulaw" | "alaw"'>Default `mp3`.</ParamField>
935935
<ParamField path="speed" type="number">Provider-native speed override, `0.7..1.5`.</ParamField>

extensions/xai/speech-provider.test.ts

Lines changed: 125 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,25 @@
22
import { afterEach, describe, expect, it, vi } from "vitest";
33
import { buildXaiSpeechProvider } from "./speech-provider.js";
44

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+
}));
1318

1419
vi.mock("./tts.js", () => ({
1520
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,
1824
normalizeXaiLanguageCode: (value: unknown) =>
1925
typeof value === "string" && value.trim() ? value.trim().toLowerCase() : undefined,
2026
normalizeXaiTtsBaseUrl: (baseUrl?: string) =>
@@ -64,7 +70,10 @@ describe("xai speech provider", () => {
6470
isProviderAuthProfileConfiguredMock.mockReturnValue(false);
6571
resolveApiKeyForProviderMock.mockReset();
6672
resolveApiKeyForProviderMock.mockResolvedValue({ apiKey: undefined });
73+
listXaiTtsVoicesMock.mockReset();
74+
listXaiTtsVoicesMock.mockResolvedValue([{ id: "altair", name: "Altair" }]);
6775
delete process.env.XAI_API_KEY;
76+
delete process.env.XAI_BASE_URL;
6877
});
6978

7079
it("synthesizes mp3 audio and does not claim native voice-note compatibility", async () => {
@@ -196,6 +205,112 @@ describe("xai speech provider", () => {
196205
).toBe(false);
197206
});
198207

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+
199314
it("threads cfg into the OAuth fallback resolver when no direct apiKey is available", async () => {
200315
resolveApiKeyForProviderMock.mockResolvedValueOnce({ apiKey: "oauth-bearer" });
201316
const provider = buildXaiSpeechProvider();

extensions/xai/speech-provider.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,11 @@ import {
1919
} from "openclaw/plugin-sdk/string-coerce-runtime";
2020
import {
2121
isValidXaiTtsVoice,
22+
listXaiTtsVoices,
2223
normalizeXaiLanguageCode,
2324
normalizeXaiTtsBaseUrl,
2425
XAI_BASE_URL,
25-
XAI_TTS_VOICES,
26+
XAI_TTS_FALLBACK_VOICES,
2627
xaiTTS,
2728
} from "./tts.js";
2829

@@ -147,8 +148,6 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
147148
overrides?: SpeechProviderOverrides;
148149
warnings?: string[];
149150
} {
150-
const providerConfig = ctx.providerConfig as Record<string, unknown> | undefined;
151-
const baseUrl = trimToUndefined(providerConfig?.baseUrl);
152151
switch (ctx.key) {
153152
case "voice":
154153
case "voice_id":
@@ -158,7 +157,7 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
158157
if (!ctx.policy.allowVoice) {
159158
return { handled: true };
160159
}
161-
if (!isValidXaiTtsVoice(ctx.value, baseUrl)) {
160+
if (!isValidXaiTtsVoice(ctx.value)) {
162161
return { handled: true, warnings: [`invalid xAI voice "${ctx.value}"`] };
163162
}
164163
return { handled: true, overrides: { voiceId: ctx.value } };
@@ -173,7 +172,7 @@ export function buildXaiSpeechProvider(): SpeechProviderPlugin {
173172
label: "xAI",
174173
autoSelectOrder: 25,
175174
models: [],
176-
voices: XAI_TTS_VOICES,
175+
voices: XAI_TTS_FALLBACK_VOICES,
177176
resolveConfig: ({ rawConfig }) => normalizeXaiProviderConfig(rawConfig),
178177
parseDirectiveToken,
179178
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
@@ -225,7 +224,18 @@ export function buildXaiSpeechProvider(): SpeechProviderPlugin {
225224
? {}
226225
: { speed: normalizeXaiSpeechSpeed(params.speed) }),
227226
}),
228-
listVoices: async () => XAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })),
227+
listVoices: async (req) => {
228+
const config = readXaiProviderConfig(req.providerConfig ?? {});
229+
const directApiKey = trimToUndefined(req.apiKey) ?? config.apiKey;
230+
const apiKey = await resolveOptionalXaiAudioApiKey(directApiKey, req.cfg);
231+
if (!apiKey) {
232+
return XAI_TTS_FALLBACK_VOICES.map((voice) => ({ id: voice, name: voice }));
233+
}
234+
return await listXaiTtsVoices({
235+
apiKey,
236+
baseUrl: normalizeXaiTtsBaseUrl(trimToUndefined(req.baseUrl) ?? config.baseUrl),
237+
});
238+
},
229239
isConfigured: ({ providerConfig, cfg }) =>
230240
Boolean(readXaiProviderConfig(providerConfig).apiKey || process.env.XAI_API_KEY) ||
231241
isProviderAuthProfileConfigured({ provider: "xai", cfg }),
@@ -278,18 +288,28 @@ export function buildXaiSpeechProvider(): SpeechProviderPlugin {
278288
// 1. Configured `messages.tts.providers.xai.apiKey` (or talk equivalent)
279289
// 2. `XAI_API_KEY` env var
280290
// 3. xAI OAuth auth profile (cfg-scoped)
281-
async function resolveXaiAudioApiKey(
291+
async function resolveOptionalXaiAudioApiKey(
282292
configApiKey: string | undefined,
283-
cfg: OpenClawConfig,
284-
): Promise<string> {
293+
cfg?: OpenClawConfig,
294+
): Promise<string | undefined> {
285295
const direct = trimToUndefined(configApiKey) ?? trimToUndefined(process.env.XAI_API_KEY);
286296
if (direct) {
287297
return direct;
288298
}
299+
if (!cfg) {
300+
return undefined;
301+
}
289302
const auth = await resolveApiKeyForProvider({ provider: "xai", cfg });
290-
const oauthKey = trimToUndefined(auth?.apiKey);
291-
if (oauthKey) {
292-
return oauthKey;
303+
return trimToUndefined(auth?.apiKey);
304+
}
305+
306+
async function resolveXaiAudioApiKey(
307+
configApiKey: string | undefined,
308+
cfg: OpenClawConfig,
309+
): Promise<string> {
310+
const apiKey = await resolveOptionalXaiAudioApiKey(configApiKey, cfg);
311+
if (apiKey) {
312+
return apiKey;
293313
}
294314
throw new Error(
295315
"xAI credentials missing for TTS. Sign in with `openclaw onboard --auth-choice xai-oauth`, or run `openclaw onboard --auth-choice xai-api-key`, or set XAI_API_KEY.",

0 commit comments

Comments
 (0)