Skip to content

Commit 302c2b2

Browse files
author
kidding
committed
fix(tts/xiaomi): support token plan endpoint
1 parent 3620568 commit 302c2b2

5 files changed

Lines changed: 110 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ Docs: https://docs.openclaw.ai
167167

168168
### Fixes
169169

170+
- Xiaomi MiMo: send TTS requests with Bearer auth and reuse the Xiaomi model provider base URL for TTS when no speech-specific base URL is configured, supporting Token Plan regional endpoints. Fixes #77692. Thanks @kidding1412 and @ismael-81.
170171
- Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested.
171172
- Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs.
172173
- CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive.

docs/providers/xiaomi.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ an `assistant` message and optional style guidance as a `user` message.
7272
| Default | `mimo-v2.5-tts`, voice `mimo_default` |
7373
| Output | MP3 by default; WAV when configured |
7474

75+
Token Plan keys require the matching Xiaomi regional Token Plan endpoint, such
76+
as `https://token-plan-cn.xiaomimimo.com/v1` or
77+
`https://token-plan-ams.xiaomimimo.com/v1`. When
78+
`messages.tts.providers.xiaomi.baseUrl` is not set, Xiaomi TTS reuses
79+
`models.providers.xiaomi.baseUrl`; set the TTS base URL only when speech should
80+
use a different endpoint from chat completions.
81+
7582
```json5
7683
{
7784
messages: {
@@ -81,6 +88,8 @@ an `assistant` message and optional style guidance as a `user` message.
8188
providers: {
8289
xiaomi: {
8390
apiKey: "xiaomi_api_key",
91+
// For Token Plan keys, set the matching regional endpoint here or in models.providers.xiaomi.baseUrl.
92+
// baseUrl: "https://token-plan-cn.xiaomimimo.com/v1",
8493
model: "mimo-v2.5-tts",
8594
voice: "mimo_default",
8695
format: "mp3",

docs/tools/tts.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -943,7 +943,7 @@ OpenAI and ElevenLabs output formats are fixed per channel as listed above.
943943

944944
<Accordion title="Xiaomi MiMo">
945945
<ParamField path="apiKey" type="string">Env: `XIAOMI_API_KEY`.</ParamField>
946-
<ParamField path="baseUrl" type="string">Default `https://api.xiaomimimo.com/v1`. Env: `XIAOMI_BASE_URL`.</ParamField>
946+
<ParamField path="baseUrl" type="string">Default `https://api.xiaomimimo.com/v1`. Env: `XIAOMI_BASE_URL`. If omitted, TTS reuses `models.providers.xiaomi.baseUrl`, including Token Plan regional endpoints such as `https://token-plan-cn.xiaomimimo.com/v1` or `https://token-plan-ams.xiaomimimo.com/v1`.</ParamField>
947947
<ParamField path="model" type="string">Default `mimo-v2.5-tts`. Env: `XIAOMI_TTS_MODEL`. Also supports `mimo-v2-tts`.</ParamField>
948948
<ParamField path="voice" type="string">Default `mimo_default`. Env: `XIAOMI_TTS_VOICE`.</ParamField>
949949
<ParamField path="format" type='"mp3" | "wav"'>Default `mp3`. Env: `XIAOMI_TTS_FORMAT`.</ParamField>

extensions/xiaomi/speech-provider.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,10 @@ describe("buildXiaomiSpeechProvider", () => {
163163
expect(mockFetch).toHaveBeenCalledOnce();
164164
const [url, init] = mockFetch.mock.calls[0];
165165
expect(url).toBe("https://api.xiaomimimo.com/v1/chat/completions");
166-
expect(init?.headers).toMatchObject({ "api-key": "sk-test" });
166+
expect(init?.headers).toMatchObject({
167+
Authorization: "Bearer sk-test",
168+
"Content-Type": "application/json",
169+
});
167170
const body = JSON.parse(init!.body as string);
168171
expect(body.model).toBe("mimo-v2-tts");
169172
expect(body.messages).toEqual([
@@ -174,6 +177,72 @@ describe("buildXiaomiSpeechProvider", () => {
174177
expect(transcodeAudioBufferToOpusMock).not.toHaveBeenCalled();
175178
});
176179

180+
it("inherits the Xiaomi model provider baseUrl when TTS baseUrl is not configured", async () => {
181+
const audio = Buffer.from("fake-mp3-audio").toString("base64");
182+
const mockFetch = vi.mocked(globalThis.fetch);
183+
mockFetch.mockResolvedValueOnce(
184+
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
185+
status: 200,
186+
headers: { "Content-Type": "application/json" },
187+
}),
188+
);
189+
190+
await provider.synthesize({
191+
text: "Hello from Token Plan.",
192+
cfg: {
193+
models: {
194+
providers: {
195+
xiaomi: {
196+
baseUrl: "https://token-plan-ams.xiaomimimo.com/v1/",
197+
},
198+
},
199+
},
200+
} as never,
201+
providerConfig: { apiKey: "tp-test" },
202+
target: "audio-file",
203+
timeoutMs: 30000,
204+
});
205+
206+
expect(mockFetch).toHaveBeenCalledOnce();
207+
const [url, init] = mockFetch.mock.calls[0];
208+
expect(url).toBe("https://token-plan-ams.xiaomimimo.com/v1/chat/completions");
209+
expect(init?.headers).toMatchObject({ Authorization: "Bearer tp-test" });
210+
});
211+
212+
it("prefers the TTS provider baseUrl over the Xiaomi model provider baseUrl", async () => {
213+
const audio = Buffer.from("fake-mp3-audio").toString("base64");
214+
const mockFetch = vi.mocked(globalThis.fetch);
215+
mockFetch.mockResolvedValueOnce(
216+
new Response(JSON.stringify({ choices: [{ message: { audio: { data: audio } } }] }), {
217+
status: 200,
218+
headers: { "Content-Type": "application/json" },
219+
}),
220+
);
221+
222+
await provider.synthesize({
223+
text: "Hello from explicit TTS endpoint.",
224+
cfg: {
225+
models: {
226+
providers: {
227+
xiaomi: {
228+
baseUrl: "https://token-plan-ams.xiaomimimo.com/v1",
229+
},
230+
},
231+
},
232+
} as never,
233+
providerConfig: {
234+
apiKey: "tp-test",
235+
baseUrl: "https://token-plan-cn.xiaomimimo.com/v1/",
236+
},
237+
target: "audio-file",
238+
timeoutMs: 30000,
239+
});
240+
241+
expect(mockFetch).toHaveBeenCalledOnce();
242+
const [url] = mockFetch.mock.calls[0];
243+
expect(url).toBe("https://token-plan-cn.xiaomimimo.com/v1/chat/completions");
244+
});
245+
177246
it("transcodes Xiaomi output to Opus for voice-note targets", async () => {
178247
const audio = Buffer.from("fake-mp3-audio").toString("base64");
179248
vi.mocked(globalThis.fetch).mockResolvedValueOnce(

extensions/xiaomi/speech-provider.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ type XiaomiTtsFormat = (typeof XIAOMI_TTS_FORMATS)[number];
3636

3737
type XiaomiTtsProviderConfig = {
3838
apiKey?: string;
39-
baseUrl: string;
39+
baseUrl?: string;
4040
model: string;
4141
voice: string;
4242
format: XiaomiTtsFormat;
@@ -54,6 +54,11 @@ function normalizeXiaomiTtsBaseUrl(baseUrl?: string): string {
5454
return (baseUrl?.trim() || DEFAULT_XIAOMI_TTS_BASE_URL).replace(/\/+$/, "");
5555
}
5656

57+
function normalizeOptionalXiaomiTtsBaseUrl(baseUrl: unknown): string | undefined {
58+
const trimmed = trimToUndefined(baseUrl);
59+
return trimmed ? normalizeXiaomiTtsBaseUrl(trimmed) : undefined;
60+
}
61+
5762
function normalizeXiaomiTtsFormat(value: unknown): XiaomiTtsFormat | undefined {
5863
const normalized = trimToUndefined(value)?.toLowerCase();
5964
return XIAOMI_TTS_FORMATS.includes(normalized as XiaomiTtsFormat)
@@ -68,6 +73,13 @@ function resolveXiaomiTtsConfigRecord(
6873
return asObject(providers?.xiaomi) ?? asObject(providers?.mimo) ?? asObject(rawConfig.xiaomi);
6974
}
7075

76+
function readXiaomiModelProviderConfig(cfg: unknown): Record<string, unknown> | undefined {
77+
const root = asObject(cfg);
78+
const models = asObject(root?.models);
79+
const providers = asObject(models?.providers);
80+
return asObject(providers?.xiaomi);
81+
}
82+
7183
function normalizeXiaomiTtsProviderConfig(
7284
rawConfig: Record<string, unknown>,
7385
): XiaomiTtsProviderConfig {
@@ -77,9 +89,9 @@ function normalizeXiaomiTtsProviderConfig(
7789
value: raw?.apiKey,
7890
path: "messages.tts.providers.xiaomi.apiKey",
7991
}),
80-
baseUrl: normalizeXiaomiTtsBaseUrl(
81-
trimToUndefined(raw?.baseUrl) ?? trimToUndefined(process.env.XIAOMI_BASE_URL),
82-
),
92+
baseUrl:
93+
normalizeOptionalXiaomiTtsBaseUrl(raw?.baseUrl) ??
94+
normalizeOptionalXiaomiTtsBaseUrl(process.env.XIAOMI_BASE_URL),
8395
model:
8496
trimToUndefined(raw?.model) ??
8597
trimToUndefined(process.env.XIAOMI_TTS_MODEL) ??
@@ -105,14 +117,24 @@ function readXiaomiTtsProviderConfig(config: SpeechProviderConfig): XiaomiTtsPro
105117
value: config.apiKey,
106118
path: "messages.tts.providers.xiaomi.apiKey",
107119
}) ?? normalized.apiKey,
108-
baseUrl: normalizeXiaomiTtsBaseUrl(trimToUndefined(config.baseUrl) ?? normalized.baseUrl),
120+
baseUrl: normalizeOptionalXiaomiTtsBaseUrl(config.baseUrl) ?? normalized.baseUrl,
109121
model: trimToUndefined(config.model) ?? normalized.model,
110122
voice: trimToUndefined(config.voice) ?? trimToUndefined(config.voiceId) ?? normalized.voice,
111123
format: normalizeXiaomiTtsFormat(config.format) ?? normalized.format,
112124
style: trimToUndefined(config.style) ?? normalized.style,
113125
};
114126
}
115127

128+
function resolveXiaomiTtsBaseUrl(params: {
129+
cfg?: unknown;
130+
providerConfig: XiaomiTtsProviderConfig;
131+
}): string {
132+
return normalizeXiaomiTtsBaseUrl(
133+
params.providerConfig.baseUrl ??
134+
normalizeOptionalXiaomiTtsBaseUrl(readXiaomiModelProviderConfig(params.cfg)?.baseUrl),
135+
);
136+
}
137+
116138
function readXiaomiTtsOverrides(
117139
overrides: SpeechProviderOverrides | undefined,
118140
): XiaomiTtsOverrides {
@@ -214,7 +236,7 @@ async function xiaomiTTS(params: {
214236
init: {
215237
method: "POST",
216238
headers: {
217-
"api-key": apiKey,
239+
Authorization: `Bearer ${apiKey}`,
218240
"Content-Type": "application/json",
219241
},
220242
body: JSON.stringify({
@@ -263,7 +285,7 @@ export function buildXiaomiSpeechProvider(): SpeechProviderPlugin {
263285
const audioBuffer = await xiaomiTTS({
264286
text: req.text,
265287
apiKey,
266-
baseUrl: config.baseUrl,
288+
baseUrl: resolveXiaomiTtsBaseUrl({ cfg: req.cfg, providerConfig: config }),
267289
model: overrides.model ?? config.model,
268290
voice: overrides.voice ?? config.voice,
269291
format: outputFormat,

0 commit comments

Comments
 (0)