openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
972 lines (971 loc) • 34.4 kB
JavaScript
import { s as asFiniteNumber } from "./number-coercion-CJQ8TR--.js";
import { d as normalizeSecretInputString, u as normalizeResolvedSecretInputString } from "./types.secrets-_0JOMGE5.js";
import { c as resolveProviderRequestHeaders } from "./provider-request-config-0Zg8QIAI.js";
import { n as createDebugProxyWebSocketAgent, r as resolveDebugProxySettings } from "./env-ckWue_wY.js";
import { n as captureWsEvent } from "./runtime-DrwkIUiy.js";
import "./proxy-capture-x_2F2qw1.js";
import { i as isProviderAuthProfileConfigured, s as resolveProviderAuthProfileApiKey } from "./provider-auth-DAOC_qI9.js";
import "./secret-input-DVCzFGxN.js";
import "./provider-http-BXCBCi4E.js";
import { Q as REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, Z as REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ } from "./session-log-runtime-GkK49CJb.js";
import "./realtime-voice-BRCFRGg7.js";
import { a as resolveOpenAIProviderConfigRecord, i as readRealtimeErrorDetail, n as createOpenAIRealtimeClientSecret, o as trimToUndefined, t as captureOpenAIRealtimeWsClose } from "./realtime-provider-shared-C_he3i03.js";
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import WebSocket from "ws";
//#region extensions/openai/realtime-voice-provider.ts
const OPENAI_REALTIME_DEFAULT_MODEL = "gpt-realtime-2";
const OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL = "gpt-4o-mini-transcribe";
const OPENAI_REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX = "Conversation already has an active response in progress:";
const OPENAI_REALTIME_NO_ACTIVE_RESPONSE_CANCEL_ERROR = "Cancellation failed: no active response found";
const OPENAI_REALTIME_MAX_SESSION_DURATION_FRAGMENT = "maximum duration";
const OPENAI_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS = 250;
const OPENAI_REALTIME_VOICES = [
"alloy",
"ash",
"ballad",
"coral",
"echo",
"sage",
"shimmer",
"verse",
"marin",
"cedar"
];
function normalizeOpenAIRealtimeVoice(value) {
if (typeof value !== "string") return;
const normalized = value.trim().toLowerCase();
return OPENAI_REALTIME_VOICES.includes(normalized) ? normalized : void 0;
}
function normalizeProviderConfig(config) {
const raw = resolveOpenAIProviderConfigRecord(config);
return {
apiKey: normalizeResolvedSecretInputString({
value: raw?.apiKey,
path: "plugins.entries.voice-call.config.realtime.providers.openai.apiKey"
}),
model: trimToUndefined(raw?.model),
voice: normalizeOpenAIRealtimeVoice(raw?.speakerVoice ?? raw?.voice),
temperature: asFiniteNumber(raw?.temperature),
vadThreshold: asUnitInterval(raw?.vadThreshold),
silenceDurationMs: asNonNegativeInteger(raw?.silenceDurationMs),
prefixPaddingMs: asNonNegativeInteger(raw?.prefixPaddingMs),
interruptResponseOnInputAudio: typeof raw?.interruptResponseOnInputAudio === "boolean" ? raw.interruptResponseOnInputAudio : void 0,
minBargeInAudioEndMs: asNonNegativeInteger(raw?.minBargeInAudioEndMs),
reasoningEffort: trimToUndefined(raw?.reasoningEffort),
azureEndpoint: trimToUndefined(raw?.azureEndpoint),
azureDeployment: trimToUndefined(raw?.azureDeployment),
azureApiVersion: trimToUndefined(raw?.azureApiVersion)
};
}
function asNonNegativeInteger(value) {
const number = asFiniteNumber(value);
return number !== void 0 && Number.isSafeInteger(number) && number >= 0 ? number : void 0;
}
function asUnitInterval(value) {
const number = asFiniteNumber(value);
return number !== void 0 && number >= 0 && number <= 1 ? number : void 0;
}
const OPENAI_REALTIME_PLATFORM_API_KEY_REQUIRED = "OpenAI Realtime voice requires an OpenAI Platform API key";
const OPENAI_REALTIME_API_KEY_REQUIRED = "OpenAI Realtime voice requires an API key";
const KEYCHAIN_SECRET_REF_RE = /^keychain:([^:]+):([^:]+)$/;
const KEYCHAIN_LOOKUP_TIMEOUT_MS = 5e3;
const resolvedKeychainSecretRefCache = /* @__PURE__ */ new Map();
function resolveKeychainSecretRef(value) {
const trimmed = value.trim();
const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed);
if (!match) return trimmed || void 0;
const cached = resolvedKeychainSecretRefCache.get(trimmed);
if (cached) return cached;
const [, service, account] = match;
try {
const resolved = execFileSync("/usr/bin/security", [
"find-generic-password",
"-s",
service,
"-a",
account,
"-w"
], {
encoding: "utf8",
stdio: [
"ignore",
"pipe",
"pipe"
],
timeout: KEYCHAIN_LOOKUP_TIMEOUT_MS
}).trim() || void 0;
if (resolved) resolvedKeychainSecretRefCache.set(trimmed, resolved);
return resolved;
} catch {
return;
}
}
function resolveOpenAIRealtimeSecretInput(configuredApiKey) {
const configured = normalizeSecretInputString(configuredApiKey);
if (configured) {
const value = resolveKeychainSecretRef(configured);
return value ? {
status: "available",
value
} : { status: "missing" };
}
return { status: "missing" };
}
function resolveOpenAIRealtimeEnvApiKey() {
const envValue = normalizeSecretInputString(process.env.OPENAI_API_KEY);
if (!envValue) return { status: "missing" };
const value = resolveKeychainSecretRef(envValue);
return value ? {
status: "available",
value
} : { status: "missing" };
}
function resolveOpenAIRealtimeApiKey(configuredApiKey) {
const configured = resolveOpenAIRealtimeSecretInput(configuredApiKey);
if (configured.status === "available" || hasOpenAIRealtimeConfiguredApiKeyInput(configuredApiKey)) return configured;
return resolveOpenAIRealtimeEnvApiKey();
}
function requireOpenAIRealtimeApiKey(configuredApiKey, errorMessage = OPENAI_REALTIME_API_KEY_REQUIRED) {
const resolved = resolveOpenAIRealtimeApiKey(configuredApiKey);
if (resolved.status === "available") return resolved.value;
throw new Error(errorMessage);
}
function hasOpenAIRealtimeConfiguredApiKeyInput(configuredApiKey) {
return Boolean(normalizeSecretInputString(configuredApiKey));
}
function hasOpenAIRealtimeApiKeyInput(configuredApiKey) {
return Boolean(normalizeSecretInputString(configuredApiKey) ?? normalizeSecretInputString(process.env.OPENAI_API_KEY));
}
async function resolveOpenAIRealtimePlatformApiKey(params) {
const configured = resolveOpenAIRealtimeSecretInput(params.configuredApiKey);
if (configured.status === "available" || hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)) return configured;
const profileApiKey = await resolveProviderAuthProfileApiKey({
provider: "openai",
cfg: params.cfg,
profileTypes: ["api_key"]
});
if (profileApiKey) return {
status: "available",
value: profileApiKey
};
return resolveOpenAIRealtimeEnvApiKey();
}
async function requireOpenAIRealtimePlatformApiKey(params) {
const resolved = await resolveOpenAIRealtimePlatformApiKey(params);
if (resolved.status === "available") return resolved.value;
throw new Error(OPENAI_REALTIME_PLATFORM_API_KEY_REQUIRED);
}
function hasOpenAIRealtimePlatformApiKeyInput(params) {
if (hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)) return true;
if (isProviderAuthProfileConfigured({
provider: "openai",
cfg: params.cfg,
profileTypes: ["api_key"]
})) return true;
return hasOpenAIRealtimeApiKeyInput(void 0);
}
function isOpenAIRealtimeMaxSessionDurationError(detail) {
const normalized = detail.toLowerCase();
return normalized.includes("session") && normalized.includes(OPENAI_REALTIME_MAX_SESSION_DURATION_FRAGMENT);
}
function base64ToBuffer(b64) {
return Buffer.from(b64, "base64");
}
var OpenAIRealtimeVoiceBridge = class OpenAIRealtimeVoiceBridge {
static {
this.DEFAULT_MODEL = OPENAI_REALTIME_DEFAULT_MODEL;
}
static {
this.MAX_RECONNECT_ATTEMPTS = 5;
}
static {
this.BASE_RECONNECT_DELAY_MS = 1e3;
}
static {
this.CONNECT_TIMEOUT_MS = 1e4;
}
constructor(config) {
this.config = config;
this.supportsToolResultContinuation = true;
this.ws = null;
this.connected = false;
this.sessionConfigured = false;
this.intentionallyClosed = false;
this.reconnectAttempts = 0;
this.pendingAudio = [];
this.markQueue = [];
this.responseStartTimestamp = null;
this.responseActive = false;
this.responseCreateInFlight = false;
this.responseCancelInFlight = false;
this.responseCreatePending = false;
this.continuingToolCallIds = /* @__PURE__ */ new Set();
this.latestMediaTimestamp = 0;
this.lastAssistantItemId = null;
this.connectionUrl = "";
this.toolCallBuffers = /* @__PURE__ */ new Map();
this.deliveredToolCallKeys = /* @__PURE__ */ new Set();
this.flowId = randomUUID();
this.sessionReadyFired = false;
this.audioFormat = config.audioFormat ?? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ;
}
async connect() {
this.intentionallyClosed = false;
this.reconnectAttempts = 0;
await this.doConnect();
}
sendAudio(audio) {
if (!this.connected || !this.sessionConfigured || this.ws?.readyState !== WebSocket.OPEN) {
if (this.pendingAudio.length < 320) this.pendingAudio.push(audio);
return;
}
this.sendEvent({
type: "input_audio_buffer.append",
audio: audio.toString("base64")
});
}
setMediaTimestamp(ts) {
this.latestMediaTimestamp = ts;
}
sendUserMessage(text) {
this.sendEvent({
type: "conversation.item.create",
item: {
type: "message",
role: "user",
content: [{
type: "input_text",
text
}]
}
});
this.requestResponseCreate();
}
triggerGreeting(instructions) {
if (!this.isConnected() || !this.ws) return;
this.sendUserMessage(instructions ?? this.config.instructions ?? "Greet the meeting.");
}
submitToolResult(callId, result, options) {
this.sendEvent({
type: "conversation.item.create",
item: {
type: "function_call_output",
call_id: callId,
output: JSON.stringify(result)
}
});
if (options?.willContinue === true) {
this.continuingToolCallIds.add(callId);
return;
}
this.continuingToolCallIds.delete(callId);
if (options?.suppressResponse === true) return;
this.requestResponseCreate();
}
acknowledgeMark() {
if (this.markQueue.length === 0) return;
this.markQueue.shift();
}
close() {
this.intentionallyClosed = true;
this.connected = false;
this.sessionConfigured = false;
if (this.ws) {
this.ws.close(1e3, "Bridge closed");
this.ws = null;
}
}
isConnected() {
return this.connected && this.sessionConfigured;
}
async doConnect() {
await new Promise((resolve, reject) => {
let settled = false;
const settleResolve = () => {
if (settled) return;
settled = true;
clearTimeout(connectTimeout);
resolve();
};
const settleReject = (error) => {
if (settled) return;
settled = true;
clearTimeout(connectTimeout);
reject(error);
};
const connectTimeout = setTimeout(() => {
if (!this.sessionConfigured && !this.intentionallyClosed) {
this.ws?.terminate();
settleReject(/* @__PURE__ */ new Error("OpenAI realtime connection timeout"));
}
}, OpenAIRealtimeVoiceBridge.CONNECT_TIMEOUT_MS);
const openWebSocket = (connection) => {
if (settled) return;
if (this.intentionallyClosed) {
settleResolve();
return;
}
const url = connection.url;
this.connectionUrl = connection.url;
const proxyAgent = createDebugProxyWebSocketAgent(resolveDebugProxySettings());
const ws = new WebSocket(connection.url, {
headers: connection.headers,
...proxyAgent ? { agent: proxyAgent } : {}
});
this.ws = ws;
const rejectStartup = (error) => {
settleReject(error);
if (ws.readyState !== WebSocket.CLOSED) {
this.intentionallyClosed = true;
ws.close(1e3, "startup failed");
}
};
ws.on("open", () => {
this.resetRealtimeSessionState();
this.connected = true;
this.sessionConfigured = false;
this.reconnectAttempts = 0;
captureWsEvent({
url,
direction: "local",
kind: "ws-open",
flowId: this.flowId,
meta: {
provider: "openai",
capability: "realtime-voice"
}
});
this.sendSessionUpdate();
});
ws.on("message", (data) => {
if (settled && !this.sessionConfigured) return;
captureWsEvent({
url,
direction: "inbound",
kind: "ws-frame",
flowId: this.flowId,
payload: data,
meta: {
provider: "openai",
capability: "realtime-voice"
}
});
try {
const event = JSON.parse(data.toString());
if (event.type === "error" && !this.sessionConfigured) {
rejectStartup(new Error(readRealtimeErrorDetail(event.error)));
return;
}
this.handleEvent(event);
if (event.type === "session.updated") settleResolve();
} catch (error) {
console.error("[openai] realtime event parse failed:", error);
}
});
ws.on("error", (error) => {
captureWsEvent({
url,
direction: "local",
kind: "error",
flowId: this.flowId,
errorText: error instanceof Error ? error.message : String(error),
meta: {
provider: "openai",
capability: "realtime-voice"
}
});
if (!this.sessionConfigured) {
rejectStartup(error instanceof Error ? error : new Error(String(error)));
return;
}
this.config.onError?.(error instanceof Error ? error : new Error(String(error)));
});
ws.on("close", (code, reasonBuffer) => {
captureOpenAIRealtimeWsClose({
url,
flowId: this.flowId,
capability: "realtime-voice",
code,
reasonBuffer
});
this.connected = false;
this.sessionConfigured = false;
if (this.intentionallyClosed) {
settleResolve();
this.config.onClose?.("completed");
return;
}
if (!this.sessionConfigured && !settled) {
settleReject(/* @__PURE__ */ new Error("OpenAI realtime connection closed before ready"));
return;
}
const reason = this.reconnectReason ?? "websocket-close";
this.reconnectReason = void 0;
this.attemptReconnect(reason);
});
};
const connectionOrPromise = this.resolveConnectionParams();
if (connectionOrPromise instanceof Promise) {
connectionOrPromise.then(openWebSocket).catch((error) => {
settleReject(error instanceof Error ? error : new Error(String(error)));
});
return;
}
openWebSocket(connectionOrPromise);
});
}
resolveConnectionParams() {
const cfg = this.config;
const model = cfg.model ?? OpenAIRealtimeVoiceBridge.DEFAULT_MODEL;
if (cfg.azureEndpoint && cfg.azureDeployment) {
const apiKey = requireOpenAIRealtimeApiKey(cfg.apiKey);
const url = `${cfg.azureEndpoint.replace(/\/$/, "").replace(/^http(s?):/, (_, secure) => `ws${secure}:`)}/openai/realtime?api-version=${cfg.azureApiVersion ?? "2024-10-01-preview"}&deployment=${encodeURIComponent(cfg.azureDeployment)}`;
return {
url,
headers: resolveProviderRequestHeaders({
provider: "openai",
baseUrl: url,
capability: "audio",
transport: "websocket",
defaultHeaders: { "api-key": apiKey }
}) ?? { "api-key": apiKey }
};
}
if (hasOpenAIRealtimeConfiguredApiKeyInput(cfg.apiKey)) {
const directApiKey = resolveOpenAIRealtimeSecretInput(cfg.apiKey);
if (directApiKey.status === "missing") throw new Error(OPENAI_REALTIME_PLATFORM_API_KEY_REQUIRED);
return this.resolveApiKeyConnectionParams(directApiKey.value, model);
}
if (cfg.azureEndpoint) {
const directApiKey = resolveOpenAIRealtimeEnvApiKey();
if (directApiKey.status === "missing") throw new Error(OPENAI_REALTIME_API_KEY_REQUIRED);
return this.resolveApiKeyConnectionParams(directApiKey.value, model);
}
return this.resolveDefaultConnectionParams(model);
}
async resolveDefaultConnectionParams(model) {
const apiKey = await requireOpenAIRealtimePlatformApiKey({
configuredApiKey: this.config.apiKey,
cfg: this.config.cfg
});
return this.resolveApiKeyConnectionParams(apiKey, model);
}
resolveApiKeyConnectionParams(apiKey, model) {
const cfg = this.config;
if (cfg.azureEndpoint) {
const url = `${cfg.azureEndpoint.replace(/\/$/, "").replace(/^http(s?):/, (_, secure) => `ws${secure}:`)}/v1/realtime?model=${encodeURIComponent(model)}`;
return {
url,
headers: resolveProviderRequestHeaders({
provider: "openai",
baseUrl: url,
capability: "audio",
transport: "websocket",
defaultHeaders: { Authorization: `Bearer ${apiKey}` }
}) ?? { Authorization: `Bearer ${apiKey}` }
};
}
const url = `wss://api.openai.com/v1/realtime?model=${encodeURIComponent(model)}`;
return {
url,
headers: resolveProviderRequestHeaders({
provider: "openai",
baseUrl: url,
capability: "audio",
transport: "websocket",
defaultHeaders: { Authorization: `Bearer ${apiKey}` }
}) ?? { Authorization: `Bearer ${apiKey}` }
};
}
async attemptReconnect(reason) {
if (this.intentionallyClosed) return;
if (this.reconnectAttempts >= OpenAIRealtimeVoiceBridge.MAX_RECONNECT_ATTEMPTS) {
this.config.onEvent?.({
direction: "client",
type: "session.reconnect.exhausted",
detail: `reason=${reason} attempts=${this.reconnectAttempts}`
});
this.config.onClose?.("error");
return;
}
this.reconnectAttempts += 1;
const attempt = this.reconnectAttempts;
const delay = OpenAIRealtimeVoiceBridge.BASE_RECONNECT_DELAY_MS * 2 ** (attempt - 1);
this.config.onEvent?.({
direction: "client",
type: "session.reconnect.scheduled",
detail: `reason=${reason} attempt=${attempt} delayMs=${delay}`
});
await new Promise((resolve) => {
setTimeout(resolve, delay);
});
if (this.intentionallyClosed) return;
try {
await this.doConnect();
this.config.onEvent?.({
direction: "client",
type: "session.reconnect.ready",
detail: `reason=${reason} attempt=${attempt}`
});
} catch (error) {
this.config.onError?.(error instanceof Error ? error : new Error(String(error)));
await this.attemptReconnect(reason);
}
}
sendSessionUpdate() {
if (this.usesAzureDeploymentRealtimeApi()) {
this.sendEvent(this.buildAzureDeploymentSessionUpdate());
return;
}
this.sendEvent(this.buildGaSessionUpdate());
}
buildGaSessionUpdate() {
const cfg = this.config;
const autoRespondToAudio = cfg.autoRespondToAudio ?? true;
const interruptResponseOnInputAudio = cfg.interruptResponseOnInputAudio ?? autoRespondToAudio;
return {
type: "session.update",
session: {
type: "realtime",
model: cfg.model ?? OpenAIRealtimeVoiceBridge.DEFAULT_MODEL,
instructions: cfg.instructions,
output_modalities: ["audio"],
audio: {
input: {
format: this.resolveRealtimeAudioFormat(),
noise_reduction: null,
transcription: { model: OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL },
turn_detection: {
type: "server_vad",
threshold: cfg.vadThreshold ?? .5,
prefix_padding_ms: cfg.prefixPaddingMs ?? 300,
silence_duration_ms: cfg.silenceDurationMs ?? 500,
create_response: autoRespondToAudio,
interrupt_response: interruptResponseOnInputAudio
}
},
output: {
format: this.resolveRealtimeAudioFormat(),
voice: cfg.voice ?? "alloy"
}
},
...cfg.reasoningEffort ? { reasoning: { effort: cfg.reasoningEffort } } : {},
...cfg.tools && cfg.tools.length > 0 ? {
tools: cfg.tools,
tool_choice: "auto"
} : {}
}
};
}
usesAzureDeploymentRealtimeApi() {
return Boolean(this.config.azureEndpoint && this.config.azureDeployment);
}
buildAzureDeploymentSessionUpdate() {
const cfg = this.config;
const format = this.resolveLegacyRealtimeAudioFormat();
return {
type: "session.update",
session: {
modalities: ["text", "audio"],
instructions: cfg.instructions,
voice: cfg.voice ?? "alloy",
input_audio_format: format,
output_audio_format: format,
input_audio_transcription: { model: "whisper-1" },
turn_detection: {
type: "server_vad",
threshold: cfg.vadThreshold ?? .5,
prefix_padding_ms: cfg.prefixPaddingMs ?? 300,
silence_duration_ms: cfg.silenceDurationMs ?? 500,
create_response: cfg.autoRespondToAudio ?? true
},
temperature: cfg.temperature ?? .8,
...cfg.tools && cfg.tools.length > 0 ? {
tools: cfg.tools,
tool_choice: "auto"
} : {}
}
};
}
resolveRealtimeAudioFormat() {
return this.audioFormat.encoding === "pcm16" ? {
type: "audio/pcm",
rate: 24e3
} : { type: "audio/pcmu" };
}
resolveLegacyRealtimeAudioFormat() {
return this.audioFormat.encoding === "pcm16" ? "pcm16" : "g711_ulaw";
}
handleEvent(event) {
const emitServerEvent = () => this.config.onEvent?.({
direction: "server",
type: event.type,
detail: this.describeServerEvent(event),
...event.item_id ? { itemId: event.item_id } : {},
...event.response_id ?? event.response?.id ? { responseId: event.response_id ?? event.response?.id } : {}
});
if (event.type === "error" && isOpenAIRealtimeMaxSessionDurationError(readRealtimeErrorDetail(event.error))) {
this.reconnectReason = "max-duration";
this.activeConnectionReason = "max-duration";
this.config.onEvent?.({
direction: "server",
type: "session.rotation",
detail: "reason=max-duration"
});
this.ws?.close(1e3, "max-duration rotation");
return;
}
emitServerEvent();
switch (event.type) {
case "session.created": return;
case "session.updated":
this.sessionConfigured = true;
for (const chunk of this.pendingAudio.splice(0)) this.sendAudio(chunk);
if (this.activeConnectionReason) {
this.config.onEvent?.({
direction: "server",
type: "session.rotation.ready",
detail: `reason=${this.activeConnectionReason}`
});
this.activeConnectionReason = void 0;
}
if (!this.sessionReadyFired) {
this.sessionReadyFired = true;
this.config.onReady?.();
}
return;
case "response.created":
this.responseActive = true;
this.responseCreateInFlight = false;
return;
case "conversation.output_audio.delta":
case "response.audio.delta":
case "response.output_audio.delta": {
const audioDelta = event.delta ?? event.data;
if (!audioDelta) return;
const audio = base64ToBuffer(audioDelta);
this.config.onAudio(audio);
if (event.item_id && event.item_id !== this.lastAssistantItemId) {
this.lastAssistantItemId = event.item_id;
this.responseStartTimestamp = this.latestMediaTimestamp;
} else if (this.responseStartTimestamp === null) this.responseStartTimestamp = this.latestMediaTimestamp;
this.responseActive = true;
this.sendMark();
return;
}
case "input_audio_buffer.speech_started":
if (this.config.interruptResponseOnInputAudio ?? this.config.autoRespondToAudio ?? true) this.handleBargeIn();
return;
case "conversation.output_transcript.delta":
case "response.output_text.delta":
case "response.audio_transcript.delta":
case "response.output_audio_transcript.delta":
if (event.delta) this.config.onTranscript?.("assistant", event.delta, false);
return;
case "response.output_text.done":
case "response.audio_transcript.done":
case "response.output_audio_transcript.done":
{
const transcript = event.transcript ?? event.text;
if (transcript) this.config.onTranscript?.("assistant", transcript, true);
}
return;
case "conversation.input_transcript.delta":
case "conversation.item.input_audio_transcription.delta":
if (event.delta) this.config.onTranscript?.("user", event.delta, false);
return;
case "conversation.item.input_audio_transcription.completed":
if (event.transcript) this.config.onTranscript?.("user", event.transcript, true);
return;
case "response.cancelled":
case "response.done":
this.responseActive = false;
this.responseCreateInFlight = false;
this.responseCancelInFlight = false;
this.flushPendingResponseCreate();
return;
case "response.function_call_arguments.delta": {
const key = event.item_id ?? "unknown";
const existing = this.toolCallBuffers.get(key);
if (existing && event.delta) existing.args += event.delta;
else if (event.item_id) this.toolCallBuffers.set(event.item_id, {
name: event.name ?? "",
callId: event.call_id ?? "",
args: event.delta ?? ""
});
return;
}
case "response.function_call_arguments.done": {
const key = event.item_id ?? "unknown";
const buffered = this.toolCallBuffers.get(key);
this.emitToolCallOnce({
itemId: event.item_id,
callId: buffered?.callId || event.call_id,
name: buffered?.name || event.name,
rawArgs: buffered?.args || event.arguments
});
this.toolCallBuffers.delete(key);
return;
}
case "conversation.item.done":
if (event.item?.type !== "function_call") return;
this.emitToolCallOnce({
itemId: event.item.id ?? event.item_id,
callId: event.item.call_id ?? event.call_id ?? event.item.id ?? event.item_id,
name: event.item.name ?? event.name,
rawArgs: event.item.arguments ?? event.arguments
});
return;
case "error": {
const detail = readRealtimeErrorDetail(event.error);
if (detail.startsWith(OPENAI_REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX)) {
this.responseActive = true;
this.responseCreateInFlight = false;
this.responseCreatePending = true;
return;
}
if (detail === OPENAI_REALTIME_NO_ACTIVE_RESPONSE_CANCEL_ERROR) {
this.responseActive = false;
this.responseCancelInFlight = false;
this.flushPendingResponseCreate();
return;
}
this.config.onError?.(new Error(detail));
}
default:
}
}
handleBargeIn(options) {
const assistantItemId = this.lastAssistantItemId;
const responseStartTimestamp = this.responseStartTimestamp;
const force = options?.force === true;
const shouldInterruptProvider = assistantItemId !== null && (responseStartTimestamp !== null && (this.markQueue.length > 0 || options?.audioPlaybackActive === true) || force);
const audioEndMs = shouldInterruptProvider ? Math.max(0, responseStartTimestamp === null ? this.latestMediaTimestamp : this.latestMediaTimestamp - responseStartTimestamp) : null;
const minBargeInAudioEndMs = this.config.minBargeInAudioEndMs ?? OPENAI_REALTIME_DEFAULT_MIN_BARGE_IN_AUDIO_END_MS;
if (!force && audioEndMs !== null && audioEndMs < minBargeInAudioEndMs) {
this.config.onEvent?.({
direction: "client",
type: "conversation.item.truncate.skipped",
detail: `reason=barge-in audioEndMs=${audioEndMs} minAudioEndMs=${minBargeInAudioEndMs}`
});
return;
}
if (options?.audioPlaybackActive === true && this.responseActive && !this.responseCancelInFlight) {
this.sendEvent({ type: "response.cancel" }, "reason=barge-in");
this.responseCancelInFlight = true;
}
if (shouldInterruptProvider) {
this.sendEvent({
type: "conversation.item.truncate",
item_id: assistantItemId,
content_index: 0,
audio_end_ms: audioEndMs
}, `reason=barge-in audioEndMs=${audioEndMs}`);
this.config.onClearAudio();
this.markQueue = [];
this.lastAssistantItemId = null;
this.responseStartTimestamp = null;
return;
}
this.config.onClearAudio();
}
emitToolCallOnce(fields) {
if (!this.config.onToolCall) return;
const itemId = fields.itemId || fields.callId || "unknown";
const callId = fields.callId || itemId;
const name = fields.name || "";
const dedupeKey = fields.itemId || fields.callId || `${name}:${fields.rawArgs ?? ""}`;
if (this.deliveredToolCallKeys.has(dedupeKey)) return;
this.deliveredToolCallKeys.add(dedupeKey);
let args = {};
try {
args = JSON.parse(fields.rawArgs || "{}");
} catch {}
this.config.onToolCall({
itemId,
callId,
name,
args
});
}
requestResponseCreate() {
if (this.responseActive || this.responseCreateInFlight || this.responseCancelInFlight || this.continuingToolCallIds.size > 0) {
this.responseCreatePending = true;
return;
}
this.responseCreatePending = false;
this.responseCreateInFlight = true;
this.sendEvent({ type: "response.create" });
}
flushPendingResponseCreate() {
if (!this.responseCreatePending) return;
this.responseCreatePending = false;
this.requestResponseCreate();
}
resetRealtimeSessionState() {
this.markQueue = [];
this.responseStartTimestamp = null;
this.responseActive = false;
this.responseCreateInFlight = false;
this.responseCancelInFlight = false;
this.responseCreatePending = false;
this.continuingToolCallIds.clear();
this.lastAssistantItemId = null;
this.toolCallBuffers.clear();
this.deliveredToolCallKeys.clear();
}
sendMark() {
const markName = `audio-${Date.now()}`;
this.markQueue.push(markName);
this.config.onMark?.(markName);
}
sendEvent(event, detail) {
if (this.ws?.readyState === WebSocket.OPEN) {
const type = event && typeof event === "object" && typeof event.type === "string" ? event.type : "unknown";
this.config.onEvent?.({
direction: "client",
type,
...detail ? { detail } : {}
});
const payload = JSON.stringify(event);
captureWsEvent({
url: this.connectionUrl,
direction: "outbound",
kind: "ws-frame",
flowId: this.flowId,
payload,
meta: {
provider: "openai",
capability: "realtime-voice"
}
});
this.ws.send(payload);
}
}
describeServerEvent(event) {
if (event.type === "error") return readRealtimeErrorDetail(event.error);
if (event.type === "response.done") {
const status = event.response?.status;
const details = event.response?.status_details === void 0 ? void 0 : JSON.stringify(event.response.status_details);
return [status ? `status=${status}` : void 0, details].filter(Boolean).join(" ") || void 0;
}
if (event.type === "response.cancelled") return "cancelled";
if (event.type === "conversation.item.done" && event.item?.type) return [event.item.type, event.item.name ? `name=${event.item.name}` : void 0].filter(Boolean).join(" ");
}
};
function resolveOpenAIRealtimeBrowserOfferHeaders() {
const headers = resolveProviderRequestHeaders({
provider: "openai",
baseUrl: "https://api.openai.com/v1/realtime/calls",
capability: "audio",
transport: "http",
defaultHeaders: {}
});
const SERVER_ONLY_HEADERS = new Set([
"user-agent",
"originator",
"version"
]);
const browserHeaders = Object.fromEntries(Object.entries(headers ?? {}).filter(([key]) => !SERVER_ONLY_HEADERS.has(key.toLowerCase())));
return Object.keys(browserHeaders).length > 0 ? browserHeaders : void 0;
}
async function createOpenAIRealtimeBrowserSession(req) {
const config = normalizeProviderConfig(req.providerConfig);
if (config.azureEndpoint || config.azureDeployment) throw new Error("OpenAI Realtime browser sessions do not support Azure endpoints yet");
const model = req.model ?? config.model ?? OPENAI_REALTIME_DEFAULT_MODEL;
const authToken = await requireOpenAIRealtimePlatformApiKey({
configuredApiKey: config.apiKey,
cfg: req.cfg
});
const voice = normalizeOpenAIRealtimeVoice(req.voice) ?? config.voice ?? "alloy";
const session = {
type: "realtime",
model,
instructions: req.instructions,
audio: {
input: {
noise_reduction: { type: "near_field" },
turn_detection: {
type: "server_vad",
create_response: true,
interrupt_response: true,
...typeof (req.vadThreshold ?? config.vadThreshold) === "number" ? { threshold: req.vadThreshold ?? config.vadThreshold } : {},
...typeof (req.prefixPaddingMs ?? config.prefixPaddingMs) === "number" ? { prefix_padding_ms: req.prefixPaddingMs ?? config.prefixPaddingMs } : {},
...typeof (req.silenceDurationMs ?? config.silenceDurationMs) === "number" ? { silence_duration_ms: req.silenceDurationMs ?? config.silenceDurationMs } : {}
},
transcription: { model: OPENAI_REALTIME_INPUT_TRANSCRIPTION_MODEL }
},
output: { voice }
}
};
if (req.tools && req.tools.length > 0) {
session.tools = req.tools;
session.tool_choice = "auto";
}
const reasoningEffort = trimToUndefined(req.reasoningEffort) ?? config.reasoningEffort;
if (reasoningEffort) session.reasoning = { effort: reasoningEffort };
const clientSecret = await createOpenAIRealtimeClientSecret({
authToken,
auditContext: "openai-realtime-browser-session",
session
});
const offerHeaders = resolveOpenAIRealtimeBrowserOfferHeaders();
return {
provider: "openai",
transport: "webrtc",
clientSecret: clientSecret.value,
offerUrl: "https://api.openai.com/v1/realtime/calls",
...offerHeaders ? { offerHeaders } : {},
model,
voice,
...typeof clientSecret.expiresAt === "number" ? { expiresAt: clientSecret.expiresAt } : {}
};
}
function buildOpenAIRealtimeVoiceProvider() {
return {
id: "openai",
label: "OpenAI Realtime Voice",
defaultModel: OPENAI_REALTIME_DEFAULT_MODEL,
autoSelectOrder: 10,
capabilities: {
transports: ["webrtc", "gateway-relay"],
inputAudioFormats: [REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ],
outputAudioFormats: [REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ],
supportsBrowserSession: true,
supportsBargeIn: true,
supportsToolCalls: true
},
resolveConfig: ({ rawConfig }) => normalizeProviderConfig(rawConfig),
isConfigured: ({ cfg, providerConfig }) => {
const config = normalizeProviderConfig(providerConfig);
if (config.azureEndpoint || config.azureDeployment) return hasOpenAIRealtimeApiKeyInput(config.apiKey);
return hasOpenAIRealtimePlatformApiKeyInput({
configuredApiKey: config.apiKey,
cfg
});
},
createBridge: (req) => {
const config = normalizeProviderConfig(req.providerConfig);
return new OpenAIRealtimeVoiceBridge({
...req,
apiKey: config.apiKey,
model: config.model,
voice: config.voice,
temperature: config.temperature,
vadThreshold: config.vadThreshold,
silenceDurationMs: config.silenceDurationMs,
prefixPaddingMs: config.prefixPaddingMs,
interruptResponseOnInputAudio: req.interruptResponseOnInputAudio ?? config.interruptResponseOnInputAudio,
minBargeInAudioEndMs: config.minBargeInAudioEndMs,
reasoningEffort: config.reasoningEffort,
azureEndpoint: config.azureEndpoint,
azureDeployment: config.azureDeployment,
azureApiVersion: config.azureApiVersion
});
},
createBrowserSession: createOpenAIRealtimeBrowserSession
};
}
//#endregion
export { buildOpenAIRealtimeVoiceProvider as t };