refactor: dedupe openai speech provider helpers

This commit is contained in:
Peter Steinberger
2026-04-06 18:54:05 +01:00
parent 8fdaa5da49
commit cae4538a86
2 changed files with 83 additions and 23 deletions

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { buildOpenAISpeechProvider } from "./speech-provider.js";
describe("buildOpenAISpeechProvider", () => {
it("normalizes provider-owned speech config from raw provider config", () => {
const provider = buildOpenAISpeechProvider();
const resolved = provider.resolveConfig?.({
cfg: {} as never,
rawConfig: {
providers: {
openai: {
apiKey: "sk-test",
baseUrl: "https://example.com/v1/",
model: "tts-1",
voice: "alloy",
speed: 1.25,
instructions: " Speak warmly ",
},
},
},
});
expect(resolved).toEqual({
apiKey: "sk-test",
baseUrl: "https://example.com/v1",
model: "tts-1",
voice: "alloy",
speed: 1.25,
instructions: "Speak warmly",
});
});
it("parses OpenAI directive tokens against the resolved base url", () => {
const provider = buildOpenAISpeechProvider();
expect(
provider.parseDirectiveToken?.({
key: "voice",
value: "alloy",
policy: {
allowVoice: true,
allowModelId: true,
},
providerConfig: {
baseUrl: "https://api.openai.com/v1/",
},
} as never),
).toEqual({
handled: true,
overrides: { voice: "alloy" },
});
expect(
provider.parseDirectiveToken?.({
key: "model",
value: "kokoro-custom-model",
policy: {
allowVoice: true,
allowModelId: true,
},
providerConfig: {
baseUrl: "https://api.openai.com/v1/",
},
} as never),
).toEqual({
handled: false,
});
});
});

View File

@@ -5,6 +5,12 @@ import type {
SpeechProviderOverrides,
SpeechProviderPlugin,
} from "openclaw/plugin-sdk/speech";
import {
asFiniteNumber,
asObjectRecord,
resolveOpenAIProviderConfigRecord,
trimToUndefined,
} from "./realtime-provider-shared.js";
import {
DEFAULT_OPENAI_BASE_URL,
isValidOpenAIModel,
@@ -30,25 +36,10 @@ type OpenAITtsProviderOverrides = {
speed?: number;
};
function trimToUndefined(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
function normalizeOpenAIProviderConfig(
rawConfig: Record<string, unknown>,
): OpenAITtsProviderConfig {
const providers = asObject(rawConfig.providers);
const raw = asObject(providers?.openai) ?? asObject(rawConfig.openai);
const raw = resolveOpenAIProviderConfigRecord(rawConfig);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw?.apiKey,
@@ -61,7 +52,7 @@ function normalizeOpenAIProviderConfig(
),
model: trimToUndefined(raw?.model) ?? "gpt-4o-mini-tts",
voice: trimToUndefined(raw?.voice) ?? "coral",
speed: asNumber(raw?.speed),
speed: asFiniteNumber(raw?.speed),
instructions: trimToUndefined(raw?.instructions),
};
}
@@ -73,7 +64,7 @@ function readOpenAIProviderConfig(config: SpeechProviderConfig): OpenAITtsProvid
baseUrl: trimToUndefined(config.baseUrl) ?? normalized.baseUrl,
model: trimToUndefined(config.model) ?? normalized.model,
voice: trimToUndefined(config.voice) ?? normalized.voice,
speed: asNumber(config.speed) ?? normalized.speed,
speed: asFiniteNumber(config.speed) ?? normalized.speed,
instructions: trimToUndefined(config.instructions) ?? normalized.instructions,
};
}
@@ -87,7 +78,7 @@ function readOpenAIOverrides(
return {
model: trimToUndefined(overrides.model),
voice: trimToUndefined(overrides.voice),
speed: asNumber(overrides.speed),
speed: asFiniteNumber(overrides.speed),
};
}
@@ -96,7 +87,7 @@ function parseDirectiveToken(ctx: SpeechDirectiveTokenParseContext): {
overrides?: SpeechProviderOverrides;
warnings?: string[];
} {
const baseUrl = trimToUndefined(ctx.providerConfig?.baseUrl);
const baseUrl = trimToUndefined(asObjectRecord(ctx.providerConfig)?.baseUrl);
switch (ctx.key) {
case "voice":
case "openai_voice":
@@ -153,9 +144,9 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
...(trimToUndefined(talkProviderConfig.voiceId) == null
? {}
: { voice: trimToUndefined(talkProviderConfig.voiceId) }),
...(asNumber(talkProviderConfig.speed) == null
...(asFiniteNumber(talkProviderConfig.speed) == null
? {}
: { speed: asNumber(talkProviderConfig.speed) }),
: { speed: asFiniteNumber(talkProviderConfig.speed) }),
...(trimToUndefined(talkProviderConfig.instructions) == null
? {}
: { instructions: trimToUndefined(talkProviderConfig.instructions) }),
@@ -168,7 +159,7 @@ export function buildOpenAISpeechProvider(): SpeechProviderPlugin {
...(trimToUndefined(params.modelId) == null
? {}
: { model: trimToUndefined(params.modelId) }),
...(asNumber(params.speed) == null ? {} : { speed: asNumber(params.speed) }),
...(asFiniteNumber(params.speed) == null ? {} : { speed: asFiniteNumber(params.speed) }),
}),
listVoices: async () => OPENAI_TTS_VOICES.map((voice) => ({ id: voice, name: voice })),
isConfigured: ({ providerConfig }) =>