fix: validate openai speech speeds

This commit is contained in:
Peter Steinberger
2026-05-28 16:56:01 -04:00
parent 3533297cd9
commit ec4a00beae
2 changed files with 89 additions and 16 deletions

View File

@@ -97,6 +97,64 @@ describe("buildOpenAISpeechProvider", () => {
});
});
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();

View File

@@ -105,24 +105,36 @@ function readExtraBody(value: unknown): Record<string, unknown> | undefined {
return body;
}
function normalizeOpenAISpeechSpeed(value: unknown, baseUrl?: string): number | undefined {
const speed = asFiniteNumber(value);
if (speed === undefined) {
return undefined;
}
if (baseUrl !== undefined && normalizeOpenAITtsBaseUrl(baseUrl) !== DEFAULT_OPENAI_BASE_URL) {
return speed;
}
return speed >= 0.25 && speed <= 4 ? speed : undefined;
}
function normalizeOpenAIProviderConfig(
rawConfig: Record<string, unknown>,
): OpenAITtsProviderConfig {
const raw = resolveOpenAIProviderConfigRecord(rawConfig);
const extraBody = readExtraBody(raw?.extraBody) ?? readExtraBody(raw?.extra_body);
const baseUrl = normalizeOpenAITtsBaseUrl(
trimToUndefined(raw?.baseUrl) ??
trimToUndefined(process.env.OPENAI_TTS_BASE_URL) ??
DEFAULT_OPENAI_BASE_URL,
);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw?.apiKey,
path: "messages.tts.providers.openai.apiKey",
}),
baseUrl: normalizeOpenAITtsBaseUrl(
trimToUndefined(raw?.baseUrl) ??
trimToUndefined(process.env.OPENAI_TTS_BASE_URL) ??
DEFAULT_OPENAI_BASE_URL,
),
baseUrl,
model: trimToUndefined(raw?.model) ?? "gpt-4o-mini-tts",
voice: trimToUndefined(raw?.voice) ?? "coral",
speed: asFiniteNumber(raw?.speed),
speed: normalizeOpenAISpeechSpeed(raw?.speed, baseUrl),
instructions: trimToUndefined(raw?.instructions),
responseFormat: normalizeOpenAISpeechResponseFormat(raw?.responseFormat),
extraBody,
@@ -136,7 +148,11 @@ function readOpenAIProviderConfig(config: SpeechProviderConfig): OpenAITtsProvid
baseUrl: trimToUndefined(config.baseUrl) ?? normalized.baseUrl,
model: trimToUndefined(config.model) ?? normalized.model,
voice: trimToUndefined(config.voice) ?? normalized.voice,
speed: asFiniteNumber(config.speed) ?? normalized.speed,
speed:
normalizeOpenAISpeechSpeed(
config.speed,
trimToUndefined(config.baseUrl) ?? normalized.baseUrl,
) ?? normalized.speed,
instructions: trimToUndefined(config.instructions) ?? normalized.instructions,
responseFormat:
normalizeOpenAISpeechResponseFormat(config.responseFormat) ?? normalized.responseFormat,
@@ -146,6 +162,7 @@ function readOpenAIProviderConfig(config: SpeechProviderConfig): OpenAITtsProvid
function readOpenAIOverrides(
overrides: SpeechProviderOverrides | undefined,
baseUrl: string,
): OpenAITtsProviderOverrides {
if (!overrides) {
return {};
@@ -153,7 +170,7 @@ function readOpenAIOverrides(
return {
model: trimToUndefined(overrides.model),
voice: trimToUndefined(overrides.voice),
speed: asFiniteNumber(overrides.speed),
speed: normalizeOpenAISpeechSpeed(overrides.speed, baseUrl),
};
}
@@ -232,6 +249,8 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
resolveTalkConfig: ({ baseTtsConfig, talkProviderConfig }) => {
const base = normalizeOpenAIProviderConfig(baseTtsConfig);
const responseFormat = normalizeOpenAISpeechResponseFormat(talkProviderConfig.responseFormat);
const baseUrl = trimToUndefined(talkProviderConfig.baseUrl) ?? base.baseUrl;
const speed = normalizeOpenAISpeechSpeed(talkProviderConfig.speed, baseUrl);
return {
...base,
...(talkProviderConfig.apiKey === undefined
@@ -242,18 +261,14 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
path: "talk.providers.openai.apiKey",
}),
}),
...(trimToUndefined(talkProviderConfig.baseUrl) == null
? {}
: { baseUrl: trimToUndefined(talkProviderConfig.baseUrl) }),
...(trimToUndefined(talkProviderConfig.baseUrl) == null ? {} : { baseUrl }),
...(trimToUndefined(talkProviderConfig.modelId) == null
? {}
: { model: trimToUndefined(talkProviderConfig.modelId) }),
...(trimToUndefined(talkProviderConfig.voiceId) == null
? {}
: { voice: trimToUndefined(talkProviderConfig.voiceId) }),
...(asFiniteNumber(talkProviderConfig.speed) == null
? {}
: { speed: asFiniteNumber(talkProviderConfig.speed) }),
...(speed == null ? {} : { speed }),
...(trimToUndefined(talkProviderConfig.instructions) == null
? {}
: { instructions: trimToUndefined(talkProviderConfig.instructions) }),
@@ -291,7 +306,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
},
synthesize: async (req) => {
const config = readOpenAIProviderConfig(req.providerConfig);
const overrides = readOpenAIOverrides(req.providerOverrides);
const overrides = readOpenAIOverrides(req.providerOverrides, config.baseUrl);
const apiKey = config.apiKey || process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OpenAI API key missing");
@@ -322,7 +337,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
},
synthesizeTelephony: async (req) => {
const config = readOpenAIProviderConfig(req.providerConfig);
const overrides = readOpenAIOverrides(req.providerOverrides);
const overrides = readOpenAIOverrides(req.providerOverrides, config.baseUrl);
const apiKey = config.apiKey || process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error("OpenAI API key missing");