fix(voice): scope OpenAI voice credentials

Resolve OpenAI voice credentials and endpoints from voice-specific config before broader OpenAI fallbacks, and use the same REST STT provider for dictation and voice mode.
This commit is contained in:
Mohamed Boudra
2026-06-25 18:41:57 +07:00
parent 2692211ef9
commit f12c9e9cfa
16 changed files with 397 additions and 319 deletions
@@ -75,6 +75,24 @@ describe("PersistedConfigSchema worktrees config", () => {
});
});
describe("PersistedConfigSchema provider credentials", () => {
test("accepts OpenAI voice credentials", () => {
const parsed = PersistedConfigSchema.parse({
providers: {
openai: {
voice: {
apiKey: " voice-secret ",
baseUrl: " https://voice.example.com/v1 ",
},
},
},
});
expect(parsed.providers?.openai?.voice?.apiKey).toBe("voice-secret");
expect(parsed.providers?.openai?.voice?.baseUrl).toBe("https://voice.example.com/v1");
});
});
describe("PersistedConfigSchema daemon append system prompt", () => {
test("accepts optional append system prompt", () => {
const parsed = PersistedConfigSchema.parse({
+11 -2
View File
@@ -45,9 +45,18 @@ const LogConfigSchema = z
})
.strict();
const ProviderCredentialsSchema = z
const OpenAiVoiceProviderSchema = z
.object({
apiKey: z.string().trim().min(1).optional(),
baseUrl: z.string().trim().min(1).optional(),
})
.strict();
const OpenAiProviderSchema = z
.object({
apiKey: z.string().min(1).optional(),
voice: OpenAiVoiceProviderSchema.optional(),
baseUrl: z.string().trim().min(1).optional(),
})
.strict();
@@ -59,7 +68,7 @@ const LocalSpeechProviderSchema = z
const ProvidersSchema = z
.object({
openai: ProviderCredentialsSchema.optional(),
openai: OpenAiProviderSchema.optional(),
local: LocalSpeechProviderSchema.optional(),
})
.strict();
@@ -43,4 +43,118 @@ describe("resolveOpenAiSpeechConfig", () => {
expect(resolved?.stt?.apiKey).toBe("sk-test");
expect(resolved?.tts?.apiKey).toBe("sk-test");
});
test("uses nested voice config before env and non-voice fallbacks", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
voice: {
apiKey: "voice-config-key",
baseUrl: " https://voice.example.com/v1 ",
},
baseUrl: "https://legacy-config.example.com/v1",
},
},
});
const env = {
OPENAI_API_KEY: "env-key",
OPENAI_VOICE_API_KEY: "voice-env-key",
OPENAI_VOICE_BASE_URL: "https://voice-env.example.com/v1",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
expect(resolved?.apiKey).toBe("voice-config-key");
expect(resolved?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.stt?.apiKey).toBe("voice-config-key");
expect(resolved?.stt?.baseUrl).toBe("https://voice.example.com/v1");
expect(resolved?.tts?.apiKey).toBe("voice-config-key");
expect(resolved?.tts?.baseUrl).toBe("https://voice.example.com/v1");
});
test("uses voice env config when nested voice config is unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_VOICE_API_KEY: "voice-env-key",
OPENAI_VOICE_BASE_URL: " https://voice-env.example.com/v1 ",
OPENAI_BASE_URL: "https://env.example.com/v1",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
expect(resolved?.apiKey).toBe("voice-env-key");
expect(resolved?.stt?.apiKey).toBe("voice-env-key");
expect(resolved?.tts?.apiKey).toBe("voice-env-key");
expect(resolved?.baseUrl).toBe("https://voice-env.example.com/v1");
expect(resolved?.stt?.baseUrl).toBe("https://voice-env.example.com/v1");
expect(resolved?.tts?.baseUrl).toBe("https://voice-env.example.com/v1");
});
test("falls back to non-voice OpenAI config", () => {
const persisted = PersistedConfigSchema.parse({
providers: {
openai: {
apiKey: "fallback-config-key",
baseUrl: " https://legacy-config.example.com/v1 ",
},
},
});
const env = {
OPENAI_API_KEY: "sk-test",
OPENAI_BASE_URL: " https://env.example.com/v1 ",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
expect(resolved?.apiKey).toBe("fallback-config-key");
expect(resolved?.baseUrl).toBe("https://legacy-config.example.com/v1");
});
test("falls back to global OpenAI env config when voice-specific inputs are unset", () => {
const persisted = PersistedConfigSchema.parse({});
const env = {
OPENAI_API_KEY: "env-key",
OPENAI_BASE_URL: " https://env.example.com/v1 ",
} as NodeJS.ProcessEnv;
const resolved = resolveOpenAiSpeechConfig({
env,
persisted,
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
});
expect(resolved?.apiKey).toBe("env-key");
expect(resolved?.baseUrl).toBe("https://env.example.com/v1");
});
});
@@ -5,14 +5,13 @@ import type { RequestedSpeechProviders } from "../../speech-types.js";
import type { STTConfig } from "./stt.js";
import type { TTSConfig } from "./tts.js";
export const DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL = "gpt-4o-transcribe";
export const DEFAULT_OPENAI_TTS_MODEL = "tts-1";
export interface OpenAiSpeechProviderConfig {
apiKey?: string;
baseUrl?: string;
stt?: Partial<STTConfig> & { apiKey?: string };
tts?: Partial<TTSConfig> & { apiKey?: string };
realtimeTranscriptionModel?: string;
}
const OpenAiTtsVoiceSchema = z.enum(["alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
@@ -33,6 +32,7 @@ const OptionalTrimmedStringSchema = z
const OpenAiSpeechResolutionSchema = z.object({
apiKey: OptionalTrimmedStringSchema,
baseUrl: OptionalTrimmedStringSchema,
sttConfidenceThreshold: OptionalFiniteNumberSchema,
sttModel: OptionalTrimmedStringSchema,
ttsVoice: z.string().trim().toLowerCase().pipe(OpenAiTtsVoiceSchema).default("alloy"),
@@ -42,9 +42,6 @@ const OpenAiSpeechResolutionSchema = z.object({
.toLowerCase()
.pipe(OpenAiTtsModelSchema)
.default(DEFAULT_OPENAI_TTS_MODEL),
realtimeTranscriptionModel: OptionalTrimmedStringSchema.default(
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
),
});
function isOpenAiProviderActive(provider: { enabled?: boolean; provider: string }): boolean {
@@ -83,11 +80,6 @@ function buildOpenAiSttInput(params: {
pickIfOpenAi(providers.voiceStt, persisted.features?.voiceMode?.stt?.model),
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
]),
realtimeTranscriptionModel: firstDefined<string>([
env.OPENAI_REALTIME_TRANSCRIPTION_MODEL,
pickIfOpenAi(providers.dictationStt, persisted.features?.dictation?.stt?.model),
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
]),
};
}
@@ -118,8 +110,16 @@ function buildOpenAiResolutionInput(params: {
}): Record<string, unknown> {
return {
apiKey: firstDefined<string>([
params.env.OPENAI_API_KEY,
params.persisted.providers?.openai?.voice?.apiKey,
params.env.OPENAI_VOICE_API_KEY,
params.persisted.providers?.openai?.apiKey,
params.env.OPENAI_API_KEY,
]),
baseUrl: firstDefined<string>([
params.persisted.providers?.openai?.voice?.baseUrl,
params.env.OPENAI_VOICE_BASE_URL,
params.persisted.providers?.openai?.baseUrl,
params.env.OPENAI_BASE_URL,
]),
...buildOpenAiSttInput(params),
...buildOpenAiTtsInput(params),
@@ -139,8 +139,10 @@ export function resolveOpenAiSpeechConfig(params: {
return {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
stt: {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
...(parsed.sttConfidenceThreshold !== undefined
? { confidenceThreshold: parsed.sttConfidenceThreshold }
: {}),
@@ -148,10 +150,10 @@ export function resolveOpenAiSpeechConfig(params: {
},
tts: {
apiKey: parsed.apiKey,
...(parsed.baseUrl ? { baseUrl: parsed.baseUrl } : {}),
voice: parsed.ttsVoice,
model: parsed.ttsModel,
responseFormat: "pcm",
},
realtimeTranscriptionModel: parsed.realtimeTranscriptionModel,
};
}
@@ -1,264 +0,0 @@
import type pino from "pino";
import { WebSocket } from "ws";
import { EventEmitter } from "node:events";
import type { StreamingTranscriptionSession } from "../../speech-provider.js";
type OpenAITurnDetection =
| null
| {
type: "server_vad";
create_response?: boolean;
threshold?: number;
prefix_padding_ms?: number;
silence_duration_ms?: number;
}
| {
type: "semantic_vad";
create_response?: boolean;
eagerness?: "low" | "medium" | "high";
};
type OpenAIClientEvent =
| {
type: "session.update";
session: {
type: "transcription";
audio: {
input: {
format: { type: "audio/pcm"; rate: 24000 };
transcription: {
model: string;
language?: string;
prompt?: string;
};
turn_detection: OpenAITurnDetection;
};
};
};
}
| { type: "input_audio_buffer.append"; audio: string }
| { type: "input_audio_buffer.commit" }
| { type: "input_audio_buffer.clear" };
type OpenAIServerEvent =
| { type: "session.created" | "session.updated" }
| {
type: "input_audio_buffer.committed";
item_id: string;
previous_item_id: string | null;
}
| { type: "input_audio_buffer.speech_started" }
| { type: "input_audio_buffer.speech_stopped" }
| {
type: "conversation.item.input_audio_transcription.delta";
item_id: string;
delta: string;
}
| {
type: "conversation.item.input_audio_transcription.completed";
item_id: string;
transcript: string;
}
| { type: "error"; error?: { message?: string } };
export class OpenAIRealtimeTranscriptionSession
extends EventEmitter
implements StreamingTranscriptionSession
{
public readonly requiredSampleRate = 24000;
private readonly apiKey: string;
private readonly logger: pino.Logger;
private readonly transcriptionModel: string;
private readonly language?: string;
private readonly prompt?: string;
private readonly turnDetection: OpenAITurnDetection;
private ws: WebSocket | null = null;
private ready: Promise<void> | null = null;
private closing = false;
private partialByItemId = new Map<string, string>();
constructor(params: {
apiKey: string;
logger: pino.Logger;
transcriptionModel: string;
language?: string;
prompt?: string;
turnDetection?: OpenAITurnDetection;
}) {
super();
this.apiKey = params.apiKey;
this.logger = params.logger.child({ provider: "openai", component: "realtime-transcription" });
this.transcriptionModel = params.transcriptionModel;
this.language = params.language;
this.prompt = params.prompt;
this.turnDetection = params.turnDetection ?? null;
}
public async connect(): Promise<void> {
if (this.ready) {
return this.ready;
}
this.closing = false;
this.ready = new Promise<void>((resolve, reject) => {
const url =
process.env.OPENAI_REALTIME_URL ?? "wss://api.openai.com/v1/realtime?intent=transcription";
const ws = new WebSocket(url, {
headers: {
Authorization: `Bearer ${this.apiKey}`,
},
});
this.ws = ws;
let resolved = false;
const fail = (error: Error) => {
if (resolved) {
this.emit("error", error);
return;
}
resolved = true;
reject(error);
};
ws.on("open", () => {
this.logger.debug("OpenAI realtime transcription websocket connected");
const update: OpenAIClientEvent = {
type: "session.update",
session: {
type: "transcription",
audio: {
input: {
format: { type: "audio/pcm", rate: 24000 },
transcription: {
model: this.transcriptionModel,
...(this.language ? { language: this.language } : {}),
...(this.prompt ? { prompt: this.prompt } : {}),
},
turn_detection: this.turnDetection,
},
},
},
};
ws.send(JSON.stringify(update));
});
ws.on("message", (data) => {
const text = typeof data === "string" ? data : data.toString("utf-8");
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return;
}
const event = parsed as OpenAIServerEvent;
if (event.type === "session.created" || event.type === "session.updated") {
if (!resolved) {
resolved = true;
resolve();
}
return;
}
if (event.type === "input_audio_buffer.committed") {
this.emit("committed", {
segmentId: event.item_id,
previousSegmentId: event.previous_item_id,
});
return;
}
if (event.type === "input_audio_buffer.speech_started") {
this.emit("speech_started");
return;
}
if (event.type === "input_audio_buffer.speech_stopped") {
this.emit("speech_stopped");
return;
}
if (event.type === "conversation.item.input_audio_transcription.delta") {
const replaceDelta = this.transcriptionModel === "whisper-1";
const prev = this.partialByItemId.get(event.item_id) ?? "";
const next = replaceDelta ? event.delta : prev + event.delta;
this.partialByItemId.set(event.item_id, next);
this.emit("transcript", { segmentId: event.item_id, transcript: next, isFinal: false });
return;
}
if (event.type === "conversation.item.input_audio_transcription.completed") {
this.partialByItemId.set(event.item_id, event.transcript);
this.emit("transcript", {
segmentId: event.item_id,
transcript: event.transcript,
isFinal: true,
});
return;
}
if (event.type === "error") {
const message = event.error?.message ?? "OpenAI realtime error";
fail(new Error(message));
}
});
ws.on("error", (err) => {
fail(err instanceof Error ? err : new Error(String(err)));
});
ws.on("close", () => {
this.logger.debug("OpenAI realtime websocket closed");
if (this.closing) {
return;
}
if (!resolved) {
fail(new Error("OpenAI realtime websocket closed before ready"));
return;
}
fail(new Error("OpenAI realtime websocket closed"));
});
});
return this.ready;
}
public appendPcm16(pcm16le: Buffer): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("OpenAI realtime websocket not connected");
}
const base64Audio = pcm16le.toString("base64");
const event: OpenAIClientEvent = { type: "input_audio_buffer.append", audio: base64Audio };
this.ws.send(JSON.stringify(event));
}
public commit(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error("OpenAI realtime websocket not connected");
}
const event: OpenAIClientEvent = { type: "input_audio_buffer.commit" };
this.ws.send(JSON.stringify(event));
}
public clear(): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
return;
}
const event: OpenAIClientEvent = { type: "input_audio_buffer.clear" };
this.ws.send(JSON.stringify(event));
}
public close(): void {
try {
this.closing = true;
this.ws?.close();
} catch {
// no-op
} finally {
this.ws = null;
this.ready = null;
}
}
}
@@ -0,0 +1,35 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { initializeOpenAiSpeechServices } from "./runtime.js";
import { OpenAISTT } from "./stt.js";
import { OpenAITTS } from "./tts.js";
describe("initializeOpenAiSpeechServices", () => {
test("uses REST OpenAI STT for voice and dictation", () => {
const services = initializeOpenAiSpeechServices({
providers: {
dictationStt: { provider: "openai", explicit: true },
voiceTurnDetection: { provider: "local", explicit: false, enabled: false },
voiceStt: { provider: "openai", explicit: true },
voiceTts: { provider: "openai", explicit: true },
},
openaiConfig: {
apiKey: "sk-test",
stt: { apiKey: "sk-test" },
tts: { apiKey: "sk-test" },
},
existing: {
turnDetectionService: null,
sttService: null,
ttsService: null,
dictationSttService: null,
},
logger: pino({ level: "silent" }),
});
expect(services.sttService).toBeInstanceOf(OpenAISTT);
expect(services.dictationSttService).toBeInstanceOf(OpenAISTT);
expect(services.ttsService).toBeInstanceOf(OpenAITTS);
});
});
@@ -3,12 +3,7 @@ import type { Logger } from "pino";
import type { SpeechToTextProvider, TextToSpeechProvider } from "../../speech-provider.js";
import type { RequestedSpeechProviders } from "../../speech-types.js";
import type { TurnDetectionProvider } from "../../turn-detection-provider.js";
import {
DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
DEFAULT_OPENAI_TTS_MODEL,
type OpenAiSpeechProviderConfig,
} from "./config.js";
import { OpenAIRealtimeTranscriptionSession } from "./realtime-transcription-session.js";
import { DEFAULT_OPENAI_TTS_MODEL, type OpenAiSpeechProviderConfig } from "./config.js";
import { OpenAISTT } from "./stt.js";
import { OpenAITTS } from "./tts.js";
@@ -126,25 +121,6 @@ function createOpenAiTts(
);
}
function createOpenAiDictationService(
apiKey: string,
openaiConfig: OpenAiSpeechProviderConfig | undefined,
): SpeechToTextProvider {
return {
id: "openai",
createSession: ({ logger: sessionLogger, language, prompt }) =>
new OpenAIRealtimeTranscriptionSession({
apiKey,
logger: sessionLogger,
transcriptionModel:
openaiConfig?.realtimeTranscriptionModel ?? DEFAULT_OPENAI_REALTIME_TRANSCRIPTION_MODEL,
...(language ? { language } : {}),
...(prompt ? { prompt } : {}),
turnDetection: null,
}),
};
}
export function initializeOpenAiSpeechServices(params: {
providers: RequestedSpeechProviders;
openaiConfig: OpenAiSpeechProviderConfig | undefined;
@@ -186,9 +162,10 @@ export function initializeOpenAiSpeechServices(params: {
}
if (needsOpenAiDictation && openAiCredentials.openaiDictationApiKey) {
dictationSttService = createOpenAiDictationService(
dictationSttService = createOpenAiStt(
openAiCredentials.openaiDictationApiKey,
openaiConfig,
logger,
);
}
} else if (needsAnyOpenAi) {
@@ -0,0 +1,90 @@
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
const { openAiConstructorOptionsMock, transcriptionsCreateMock } = vi.hoisted(() => ({
openAiConstructorOptionsMock: vi.fn(),
transcriptionsCreateMock: vi.fn(),
}));
vi.mock("openai", () => ({
OpenAI: vi.fn(function OpenAI(options: unknown) {
openAiConstructorOptionsMock(options);
return {
audio: {
transcriptions: {
create: transcriptionsCreateMock,
},
},
};
}),
}));
import { OpenAISTT } from "./stt.js";
describe("OpenAISTT", () => {
afterEach(() => {
openAiConstructorOptionsMock.mockReset();
transcriptionsCreateMock.mockReset();
});
test("passes configured baseUrl to the OpenAI client", () => {
const provider = new OpenAISTT(
{ apiKey: "sk-test", baseUrl: "https://speech.example.com/v1" },
pino({ level: "silent" }),
);
expect(provider.id).toBe("openai");
expect(openAiConstructorOptionsMock).toHaveBeenCalledWith({
apiKey: "sk-test",
baseURL: "https://speech.example.com/v1",
});
});
test("passes transcription prompt to OpenAI REST STT", async () => {
transcriptionsCreateMock.mockImplementation(
async (request: { file: NodeJS.ReadableStream }) => {
await new Promise<void>((resolve, reject) => {
request.file.once("error", reject);
request.file.once("end", resolve);
request.file.resume();
});
return { text: "hello" };
},
);
const provider = new OpenAISTT(
{ apiKey: "sk-test", model: "gpt-4o-transcribe" },
pino({ level: "silent" }),
);
const session = provider.createSession({
logger: pino({ level: "silent" }),
language: "en",
prompt: "Only transcribe the speaker.",
});
const transcript = new Promise<string>((resolve, reject) => {
session.on("transcript", (event) => {
if (event.isFinal) {
resolve(event.transcript);
}
});
session.on("error", (error) => {
reject(error instanceof Error ? error : new Error(String(error)));
});
});
await session.connect();
session.appendPcm16(Buffer.from([0, 0, 0, 0]));
session.commit();
await expect(transcript).resolves.toBe("hello");
expect(transcriptionsCreateMock).toHaveBeenCalledWith(
expect.objectContaining({
language: "en",
model: "gpt-4o-transcribe",
prompt: "Only transcribe the speaker.",
response_format: "json",
}),
);
});
});
@@ -17,6 +17,7 @@ export type { LogprobToken, TranscriptionResult };
export interface STTConfig {
apiKey: string;
baseUrl?: string;
model?: "whisper-1" | "gpt-4o-transcribe" | "gpt-4o-mini-transcribe" | (string & {});
confidenceThreshold?: number; // Default: -3.0
}
@@ -56,6 +57,7 @@ export class OpenAISTT implements SpeechToTextProvider {
this.logger = parentLogger.child({ module: "agent", provider: "openai", component: "stt" });
this.openaiClient = new OpenAI({
apiKey: sttConfig.apiKey,
...(sttConfig.baseUrl ? { baseURL: sttConfig.baseUrl } : {}),
});
this.logger.info({ model: sttConfig.model || "whisper-1" }, "STT (OpenAI Whisper) initialized");
}
@@ -138,7 +140,13 @@ export class OpenAISTT implements SpeechToTextProvider {
}
const wav = convertPCMToWavBuffer(pcm16);
const result = await transcribeAudio(wav, "audio/wav", params.language ?? "en", logger);
const result = await transcribeAudio(
wav,
"audio/wav",
params.language ?? "en",
logger,
params.prompt,
);
emitter.emit("transcript", {
segmentId: committedId,
@@ -178,6 +186,7 @@ export class OpenAISTT implements SpeechToTextProvider {
format: string,
language: string,
logger: pino.Logger,
prompt?: string,
): Promise<TranscriptionResult> {
const startTime = Date.now();
let tempFilePath: string | null = null;
@@ -198,6 +207,7 @@ export class OpenAISTT implements SpeechToTextProvider {
file: await import("fs").then((fs) => fs.createReadStream(tempFilePath!)),
language,
model: modelToUse,
...(prompt ? { prompt } : {}),
...(supportsLogprobs ? { include: includeLogprobs } : {}),
response_format: "json",
});
@@ -0,0 +1,42 @@
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
const { openAiConstructorOptionsMock, speechCreateMock } = vi.hoisted(() => ({
openAiConstructorOptionsMock: vi.fn(),
speechCreateMock: vi.fn(),
}));
vi.mock("openai", () => ({
OpenAI: vi.fn(function OpenAI(options: unknown) {
openAiConstructorOptionsMock(options);
return {
audio: {
speech: {
create: speechCreateMock,
},
},
};
}),
}));
import { OpenAITTS } from "./tts.js";
describe("OpenAITTS", () => {
afterEach(() => {
openAiConstructorOptionsMock.mockReset();
speechCreateMock.mockReset();
});
test("passes configured baseUrl to the OpenAI client", () => {
const provider = new OpenAITTS(
{ apiKey: "sk-test", baseUrl: "https://speech.example.com/v1" },
pino({ level: "silent" }),
);
expect(provider.getConfig().baseUrl).toBe("https://speech.example.com/v1");
expect(openAiConstructorOptionsMock).toHaveBeenCalledWith({
apiKey: "sk-test",
baseURL: "https://speech.example.com/v1",
});
});
});
@@ -7,6 +7,7 @@ export type { SpeechStreamResult };
export interface TTSConfig {
apiKey: string;
baseUrl?: string;
model?: "tts-1" | "tts-1-hd";
voice?: "alloy" | "echo" | "fable" | "onyx" | "nova" | "shimmer";
responseFormat?: "mp3" | "opus" | "aac" | "flac" | "wav" | "pcm";
@@ -27,6 +28,7 @@ export class OpenAITTS implements TextToSpeechProvider {
this.logger = parentLogger.child({ module: "agent", provider: "openai", component: "tts" });
this.openaiClient = new OpenAI({
apiKey: ttsConfig.apiKey,
...(ttsConfig.baseUrl ? { baseURL: ttsConfig.baseUrl } : {}),
});
this.logger.info(
@@ -129,7 +129,7 @@ describe("resolveSpeechConfig", () => {
dictation: "es",
voice: "pt",
});
expect(result.openai?.apiKey).toBe("env-key");
expect(result.openai?.apiKey).toBe("persisted-key");
expect(result.openai?.stt?.model).toBe("gpt-4o-transcribe");
});