Skip to content

Commit 6ea3cdd

Browse files
stainluobviyus
andauthored
fix: register bundled TTS providers and route overrides correctly (#62846) (thanks @stainlu)
* fix(microsoft,elevenlabs): add enabledByDefault so speech providers register at runtime * fix(tts): route generic directive tokens to the explicitly declared provider Addresses the P2 Codex review on #62846 that flagged auto-enabling ElevenLabs as a product regression for MiniMax users. Both providers claim the generic `speed` token, and parseTtsDirectives walked providers in autoSelectOrder with first-match-wins, so inputs like `[[tts:provider=minimax speed=1.2]]` silently routed speed to providerOverrides.elevenlabs once elevenlabs participated in every parse pass. The parser now pre-scans for `provider=` (honoring legacy last-wins semantics) and routes generic tokens with the declared provider tried first, falling back to autoSelectOrder when it doesn't handle the key. Token order inside the directive no longer matters: `speed=1.2` before or after `provider=minimax` both resolve to MiniMax. Adds a regression test suite covering the exact ElevenLabs/MiniMax speed collision plus fallback, mixed-token, last-wins, and allowProvider-disabled cases. parseTtsDirectives had no prior test coverage. * fix(tts): prefer active provider for generic directives * fix: register bundled TTS providers safely (#62846) (thanks @stainlu) * fix: use exported TTS SDK seam (#62846) (thanks @stainlu) --------- Co-authored-by: Ayaan Zaidi <[email protected]>
1 parent ecfaf64 commit 6ea3cdd

7 files changed

Lines changed: 210 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ Docs: https://docs.openclaw.ai
3333
- Ollama/chat: strip the `ollama/` provider prefix from Ollama chat request model ids so configured refs like `ollama/qwen3:14b-q8_0` stop 404ing against the Ollama API. (#67457) Thanks @suboss87.
3434
- QA/Matrix: split the private QA lab runtime into smaller tested modules, add Matrix media contract coverage for image understanding and generated-image delivery, and update the memory-dreaming QA sweep to assert the separate phase-report layout. (#67430) Thanks @gumadeiras.
3535
- Agents/tools: resolve non-workspace host tilde paths against the OS home directory and keep edit recovery aligned with that same path target, so `~/...` host edit/write operations stop failing or reading back the wrong file when `OPENCLAW_HOME` differs. (#62804) Thanks @stainlu.
36+
- Speech/TTS: auto-enable the bundled Microsoft and ElevenLabs speech providers, and route generic TTS directive tokens through the explicit or active provider first so overrides like `[[tts:speed=1.2]]` stop silently landing on the wrong provider. (#62846) Thanks @stainlu.
3637

3738
## 2026.4.15-beta.1
3839

extensions/discord/src/voice/manager.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,14 @@ import path from "node:path";
55
import type { Readable } from "node:stream";
66
import { ChannelType, type Client, ReadyListener } from "@buape/carbon";
77
import type { VoicePlugin } from "@buape/carbon/voice";
8-
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
9-
import { agentCommandFromIngress } from "openclaw/plugin-sdk/agent-runtime";
10-
import { resolveTtsConfig, type ResolvedTtsConfig } from "openclaw/plugin-sdk/agent-runtime";
8+
import {
9+
agentCommandFromIngress,
10+
getTtsProvider,
11+
resolveAgentDir,
12+
resolveTtsConfig,
13+
resolveTtsPrefsPath,
14+
type ResolvedTtsConfig,
15+
} from "openclaw/plugin-sdk/agent-runtime";
1116
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
1217
import type { DiscordAccountConfig, TtsConfig } from "openclaw/plugin-sdk/config-runtime";
1318
import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
@@ -809,6 +814,7 @@ export class DiscordVoiceManager {
809814
const directive = parseTtsDirectives(replyText, ttsConfig.modelOverrides, {
810815
cfg: ttsCfg,
811816
providerConfigs: ttsConfig.providerConfigs,
817+
preferredProviderId: getTtsProvider(ttsConfig, resolveTtsPrefsPath(ttsConfig)),
812818
});
813819
const rawSpeakText = directive.overrides.ttsText ?? directive.cleanedText.trim();
814820
const speakText = sanitizeVoiceReplyTextForSpeech(rawSpeakText, speaker.label);

extensions/elevenlabs/openclaw.plugin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"id": "elevenlabs",
3+
"enabledByDefault": true,
34
"contracts": {
45
"speechProviders": ["elevenlabs"]
56
},

extensions/microsoft/openclaw.plugin.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"id": "microsoft",
3+
"enabledByDefault": true,
34
"contracts": {
45
"speechProviders": ["microsoft"]
56
},

extensions/speech-core/src/tts.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,22 +1036,23 @@ export async function maybeApplyTtsToPayload(params: {
10361036
return params.payload;
10371037
}
10381038
const config = resolveTtsConfig(params.cfg);
1039+
const activeProvider = getTtsProvider(config, prefsPath);
10391040

10401041
const reply = resolveSendableOutboundReplyParts(params.payload);
10411042
const text = reply.text;
10421043
const directives = parseTtsDirectives(text, config.modelOverrides, {
10431044
cfg: params.cfg,
10441045
providerConfigs: config.providerConfigs,
1046+
preferredProviderId: activeProvider,
10451047
});
10461048
if (directives.warnings.length > 0) {
10471049
logVerbose(`TTS: ignored directive overrides (${directives.warnings.join("; ")})`);
10481050
}
10491051

10501052
if (isVerbose()) {
10511053
const effectiveProvider = directives.overrides?.provider
1052-
? (canonicalizeSpeechProviderId(directives.overrides.provider, params.cfg) ??
1053-
getTtsProvider(config, prefsPath))
1054-
: getTtsProvider(config, prefsPath);
1054+
? (canonicalizeSpeechProviderId(directives.overrides.provider, params.cfg) ?? activeProvider)
1055+
: activeProvider;
10551056
logVerbose(
10561057
`TTS: auto mode enabled (${autoMode}), channel=${params.channel}, selected provider=${effectiveProvider}, config.provider=${config.provider}, config.providerSource=${config.providerSource}`,
10571058
);

src/tts/directives.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { SpeechProviderPlugin } from "../plugins/types.js";
3+
import { parseTtsDirectives } from "./directives.js";
4+
import type {
5+
SpeechDirectiveTokenParseContext,
6+
SpeechDirectiveTokenParseResult,
7+
SpeechModelOverridePolicy,
8+
} from "./provider-types.js";
9+
10+
function makeProvider(
11+
id: string,
12+
order: number,
13+
parse: (ctx: SpeechDirectiveTokenParseContext) => SpeechDirectiveTokenParseResult | undefined,
14+
): SpeechProviderPlugin {
15+
return {
16+
id,
17+
label: id,
18+
autoSelectOrder: order,
19+
parseDirectiveToken: parse,
20+
isConfigured: () => true,
21+
synthesize: async () => ({
22+
audioBuffer: Buffer.alloc(0),
23+
outputFormat: "mp3",
24+
fileExtension: ".mp3",
25+
voiceCompatible: false,
26+
}),
27+
} as SpeechProviderPlugin;
28+
}
29+
30+
const elevenlabs = makeProvider("elevenlabs", 10, ({ key, value }) => {
31+
if (key === "speed") {
32+
return { handled: true, overrides: { speed: Number(value) } };
33+
}
34+
if (key === "style") {
35+
return { handled: true, overrides: { style: Number(value) } };
36+
}
37+
return undefined;
38+
});
39+
40+
const minimax = makeProvider("minimax", 20, ({ key, value }) => {
41+
if (key === "speed") {
42+
return { handled: true, overrides: { speed: Number(value) } };
43+
}
44+
return undefined;
45+
});
46+
47+
const fullPolicy: SpeechModelOverridePolicy = {
48+
enabled: true,
49+
allowText: true,
50+
allowProvider: true,
51+
allowVoice: true,
52+
allowModelId: true,
53+
allowVoiceSettings: true,
54+
allowNormalization: true,
55+
allowSeed: true,
56+
};
57+
58+
describe("parseTtsDirectives provider-aware routing", () => {
59+
it("routes generic speed to the explicitly declared provider", () => {
60+
const result = parseTtsDirectives(
61+
"hello [[tts:provider=minimax speed=1.2]] world",
62+
fullPolicy,
63+
{
64+
providers: [elevenlabs, minimax],
65+
},
66+
);
67+
68+
expect(result.overrides.provider).toBe("minimax");
69+
expect(result.overrides.providerOverrides?.minimax).toEqual({ speed: 1.2 });
70+
expect(result.overrides.providerOverrides?.elevenlabs).toBeUndefined();
71+
});
72+
73+
it("routes correctly when provider appears after the generic token", () => {
74+
const result = parseTtsDirectives("[[tts:speed=1.2 provider=minimax]] hi", fullPolicy, {
75+
providers: [elevenlabs, minimax],
76+
});
77+
78+
expect(result.overrides.provider).toBe("minimax");
79+
expect(result.overrides.providerOverrides?.minimax).toEqual({ speed: 1.2 });
80+
expect(result.overrides.providerOverrides?.elevenlabs).toBeUndefined();
81+
});
82+
83+
it("routes to the preferred provider when no provider token is declared", () => {
84+
const result = parseTtsDirectives("[[tts:speed=1.5]]", fullPolicy, {
85+
providers: [elevenlabs, minimax],
86+
preferredProviderId: "minimax",
87+
});
88+
89+
expect(result.overrides.provider).toBeUndefined();
90+
expect(result.overrides.providerOverrides?.minimax).toEqual({ speed: 1.5 });
91+
expect(result.overrides.providerOverrides?.elevenlabs).toBeUndefined();
92+
});
93+
94+
it("falls back to autoSelectOrder when no provider hint is available", () => {
95+
const result = parseTtsDirectives("[[tts:speed=1.5]]", fullPolicy, {
96+
providers: [elevenlabs, minimax],
97+
});
98+
99+
expect(result.overrides.provider).toBeUndefined();
100+
expect(result.overrides.providerOverrides?.elevenlabs).toEqual({ speed: 1.5 });
101+
expect(result.overrides.providerOverrides?.minimax).toBeUndefined();
102+
});
103+
104+
it("falls through when the preferred provider does not handle the key", () => {
105+
const result = parseTtsDirectives("[[tts:provider=minimax style=0.4]]", fullPolicy, {
106+
providers: [elevenlabs, minimax],
107+
});
108+
109+
expect(result.overrides.provider).toBe("minimax");
110+
expect(result.overrides.providerOverrides?.elevenlabs).toEqual({ style: 0.4 });
111+
expect(result.overrides.providerOverrides?.minimax).toBeUndefined();
112+
});
113+
114+
it("routes mixed tokens independently in the same directive", () => {
115+
const result = parseTtsDirectives("[[tts:provider=minimax style=0.4 speed=1.2]]", fullPolicy, {
116+
providers: [elevenlabs, minimax],
117+
});
118+
119+
expect(result.overrides.provider).toBe("minimax");
120+
expect(result.overrides.providerOverrides?.minimax).toEqual({ speed: 1.2 });
121+
expect(result.overrides.providerOverrides?.elevenlabs).toEqual({ style: 0.4 });
122+
});
123+
124+
it("keeps last-wins provider semantics", () => {
125+
const result = parseTtsDirectives(
126+
"[[tts:provider=elevenlabs provider=minimax speed=1.1]]",
127+
fullPolicy,
128+
{ providers: [elevenlabs, minimax] },
129+
);
130+
131+
expect(result.overrides.provider).toBe("minimax");
132+
expect(result.overrides.providerOverrides?.minimax).toEqual({ speed: 1.1 });
133+
expect(result.overrides.providerOverrides?.elevenlabs).toBeUndefined();
134+
});
135+
136+
it("ignores provider tokens when provider overrides are disabled", () => {
137+
const policy: SpeechModelOverridePolicy = { ...fullPolicy, allowProvider: false };
138+
const result = parseTtsDirectives("[[tts:provider=elevenlabs speed=1.2]]", policy, {
139+
providers: [elevenlabs, minimax],
140+
preferredProviderId: "minimax",
141+
});
142+
143+
expect(result.overrides.provider).toBeUndefined();
144+
expect(result.overrides.providerOverrides?.minimax).toEqual({ speed: 1.2 });
145+
expect(result.overrides.providerOverrides?.elevenlabs).toBeUndefined();
146+
});
147+
});

src/tts/directives.ts

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ type ParseTtsDirectiveOptions = {
1313
cfg?: OpenClawConfig;
1414
providers?: readonly SpeechProviderPlugin[];
1515
providerConfigs?: Record<string, SpeechProviderConfig>;
16+
preferredProviderId?: string;
1617
};
1718

1819
function buildProviderOrder(left: SpeechProviderPlugin, right: SpeechProviderPlugin): number {
@@ -38,6 +39,20 @@ function resolveDirectiveProviderConfig(
3839
return options?.providerConfigs?.[provider.id];
3940
}
4041

42+
function prioritizeProvider(
43+
providers: readonly SpeechProviderPlugin[],
44+
providerId: string | undefined,
45+
): SpeechProviderPlugin[] {
46+
if (!providerId) {
47+
return [...providers];
48+
}
49+
const preferredProvider = providers.find((provider) => provider.id === providerId);
50+
if (!preferredProvider) {
51+
return [...providers];
52+
}
53+
return [preferredProvider, ...providers.filter((provider) => provider.id !== providerId)];
54+
}
55+
4156
export function parseTtsDirectives(
4257
text: string,
4358
policy: SpeechModelOverridePolicy,
@@ -66,6 +81,37 @@ export function parseTtsDirectives(
6681
cleanedText = cleanedText.replace(directiveRegex, (_match, body: string) => {
6782
hasDirective = true;
6883
const tokens = body.split(/\s+/).filter(Boolean);
84+
85+
let declaredProviderId: string | undefined;
86+
if (policy.allowProvider) {
87+
for (const token of tokens) {
88+
const eqIndex = token.indexOf("=");
89+
if (eqIndex === -1) {
90+
continue;
91+
}
92+
const rawKey = token.slice(0, eqIndex).trim();
93+
if (!rawKey || normalizeLowercaseStringOrEmpty(rawKey) !== "provider") {
94+
continue;
95+
}
96+
const rawValue = token.slice(eqIndex + 1).trim();
97+
if (!rawValue) {
98+
continue;
99+
}
100+
const providerId = normalizeLowercaseStringOrEmpty(rawValue);
101+
if (!providerId) {
102+
warnings.push("invalid provider id");
103+
continue;
104+
}
105+
declaredProviderId = providerId;
106+
overrides.provider = providerId;
107+
}
108+
}
109+
110+
const orderedProviders = prioritizeProvider(
111+
providers,
112+
declaredProviderId ?? normalizeLowercaseStringOrEmpty(options?.preferredProviderId),
113+
);
114+
69115
for (const token of tokens) {
70116
const eqIndex = token.indexOf("=");
71117
if (eqIndex === -1) {
@@ -78,19 +124,10 @@ export function parseTtsDirectives(
78124
}
79125
const key = normalizeLowercaseStringOrEmpty(rawKey);
80126
if (key === "provider") {
81-
if (policy.allowProvider) {
82-
const providerId = normalizeLowercaseStringOrEmpty(rawValue);
83-
if (providerId) {
84-
overrides.provider = providerId;
85-
} else {
86-
warnings.push("invalid provider id");
87-
}
88-
}
89127
continue;
90128
}
91129

92-
let handled = false;
93-
for (const provider of providers) {
130+
for (const provider of orderedProviders) {
94131
const parsed = provider.parseDirectiveToken?.({
95132
key,
96133
value: rawValue,
@@ -101,7 +138,6 @@ export function parseTtsDirectives(
101138
if (!parsed?.handled) {
102139
continue;
103140
}
104-
handled = true;
105141
if (parsed.overrides) {
106142
overrides.providerOverrides = {
107143
...overrides.providerOverrides,
@@ -116,10 +152,6 @@ export function parseTtsDirectives(
116152
}
117153
break;
118154
}
119-
120-
if (!handled) {
121-
continue;
122-
}
123155
}
124156
return "";
125157
});

0 commit comments

Comments
 (0)