Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
1417 lines
44 KiB
TypeScript
1417 lines
44 KiB
TypeScript
// Openai provider module implements model/runtime integration.
|
|
import { execFileSync } from "node:child_process";
|
|
import { randomUUID } from "node:crypto";
|
|
import {
|
|
isProviderAuthProfileConfigured,
|
|
resolveProviderAuthProfileApiKey,
|
|
} from "openclaw/plugin-sdk/provider-auth";
|
|
import { resolveProviderRequestHeaders } from "openclaw/plugin-sdk/provider-http";
|
|
import {
|
|
captureWsEvent,
|
|
createDebugProxyWebSocketAgent,
|
|
resolveDebugProxySettings,
|
|
} from "openclaw/plugin-sdk/proxy-capture";
|
|
import type {
|
|
RealtimeVoiceAudioFormat,
|
|
RealtimeVoiceBargeInOptions,
|
|
RealtimeVoiceBridge,
|
|
RealtimeVoiceBrowserSession,
|
|
RealtimeVoiceBrowserSessionCreateRequest,
|
|
RealtimeVoiceBridgeCreateRequest,
|
|
RealtimeVoiceProviderConfig,
|
|
RealtimeVoiceProviderPlugin,
|
|
RealtimeVoiceTool,
|
|
RealtimeVoiceToolResultOptions,
|
|
} from "openclaw/plugin-sdk/realtime-voice";
|
|
import {
|
|
REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ,
|
|
REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ,
|
|
} from "openclaw/plugin-sdk/realtime-voice";
|
|
import {
|
|
normalizeResolvedSecretInputString,
|
|
normalizeSecretInputString,
|
|
} from "openclaw/plugin-sdk/secret-input";
|
|
import WebSocket from "ws";
|
|
import {
|
|
asFiniteNumber,
|
|
captureOpenAIRealtimeWsClose,
|
|
createOpenAIRealtimeClientSecret,
|
|
readRealtimeErrorDetail,
|
|
resolveOpenAIProviderConfigRecord,
|
|
trimToUndefined,
|
|
} from "./realtime-provider-shared.js";
|
|
|
|
type OpenAIRealtimeVoice =
|
|
| "alloy"
|
|
| "ash"
|
|
| "ballad"
|
|
| "cedar"
|
|
| "coral"
|
|
| "echo"
|
|
| "marin"
|
|
| "sage"
|
|
| "shimmer"
|
|
| "verse";
|
|
|
|
type OpenAIRealtimeVoiceProviderConfig = {
|
|
apiKey?: string;
|
|
model?: string;
|
|
voice?: OpenAIRealtimeVoice;
|
|
temperature?: number;
|
|
vadThreshold?: number;
|
|
silenceDurationMs?: number;
|
|
prefixPaddingMs?: number;
|
|
interruptResponseOnInputAudio?: boolean;
|
|
minBargeInAudioEndMs?: number;
|
|
reasoningEffort?: string;
|
|
azureEndpoint?: string;
|
|
azureDeployment?: string;
|
|
azureApiVersion?: string;
|
|
};
|
|
|
|
type OpenAIRealtimeVoiceBridgeConfig = RealtimeVoiceBridgeCreateRequest & {
|
|
apiKey?: string;
|
|
model?: string;
|
|
voice?: OpenAIRealtimeVoice;
|
|
temperature?: number;
|
|
vadThreshold?: number;
|
|
silenceDurationMs?: number;
|
|
prefixPaddingMs?: number;
|
|
interruptResponseOnInputAudio?: boolean;
|
|
minBargeInAudioEndMs?: number;
|
|
reasoningEffort?: string;
|
|
azureEndpoint?: string;
|
|
azureDeployment?: string;
|
|
azureApiVersion?: string;
|
|
};
|
|
|
|
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",
|
|
] as const satisfies readonly OpenAIRealtimeVoice[];
|
|
|
|
function normalizeOpenAIRealtimeVoice(value: unknown): OpenAIRealtimeVoice | undefined {
|
|
if (typeof value !== "string") {
|
|
return undefined;
|
|
}
|
|
const normalized = value.trim().toLowerCase();
|
|
return OPENAI_REALTIME_VOICES.includes(normalized as OpenAIRealtimeVoice)
|
|
? (normalized as OpenAIRealtimeVoice)
|
|
: undefined;
|
|
}
|
|
|
|
type RealtimeEvent = {
|
|
type: string;
|
|
delta?: string;
|
|
data?: string;
|
|
text?: string;
|
|
transcript?: string;
|
|
item_id?: string;
|
|
response_id?: string;
|
|
call_id?: string;
|
|
name?: string;
|
|
arguments?: string;
|
|
item?: {
|
|
id?: string;
|
|
type?: string;
|
|
name?: string;
|
|
call_id?: string;
|
|
arguments?: string;
|
|
};
|
|
response?: {
|
|
id?: string;
|
|
status?: string;
|
|
status_details?: unknown;
|
|
};
|
|
error?: unknown;
|
|
};
|
|
|
|
type RealtimeTurnDetectionConfig = {
|
|
type: "server_vad";
|
|
threshold: number;
|
|
prefix_padding_ms: number;
|
|
silence_duration_ms: number;
|
|
create_response: boolean;
|
|
interrupt_response?: boolean;
|
|
};
|
|
|
|
type RealtimeGaSessionUpdate = {
|
|
type: "session.update";
|
|
session: {
|
|
type: "realtime";
|
|
model?: string;
|
|
instructions?: string;
|
|
output_modalities: string[];
|
|
audio: {
|
|
input: {
|
|
format: OpenAIRealtimeAudioFormatConfig;
|
|
turn_detection: RealtimeTurnDetectionConfig;
|
|
noise_reduction?: { type: "near_field" } | null;
|
|
transcription?: { model: string };
|
|
};
|
|
output: {
|
|
format: OpenAIRealtimeAudioFormatConfig;
|
|
voice: OpenAIRealtimeVoice;
|
|
};
|
|
};
|
|
reasoning?: { effort: string };
|
|
tools?: RealtimeVoiceTool[];
|
|
tool_choice?: string;
|
|
};
|
|
};
|
|
|
|
type RealtimeAzureDeploymentSessionUpdate = {
|
|
type: "session.update";
|
|
session: {
|
|
modalities: string[];
|
|
instructions?: string;
|
|
voice: OpenAIRealtimeVoice;
|
|
input_audio_format: "g711_ulaw" | "pcm16";
|
|
output_audio_format: "g711_ulaw" | "pcm16";
|
|
input_audio_transcription?: { model: string };
|
|
turn_detection: RealtimeTurnDetectionConfig;
|
|
temperature: number;
|
|
tools?: RealtimeVoiceTool[];
|
|
tool_choice?: string;
|
|
};
|
|
};
|
|
|
|
type OpenAIRealtimeAudioFormatConfig =
|
|
| {
|
|
type: "audio/pcm";
|
|
rate: 24000;
|
|
}
|
|
| {
|
|
type: "audio/pcmu";
|
|
};
|
|
|
|
function normalizeProviderConfig(
|
|
config: RealtimeVoiceProviderConfig,
|
|
): OpenAIRealtimeVoiceProviderConfig {
|
|
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
|
|
: undefined,
|
|
minBargeInAudioEndMs: asNonNegativeInteger(raw?.minBargeInAudioEndMs),
|
|
reasoningEffort: trimToUndefined(raw?.reasoningEffort),
|
|
azureEndpoint: trimToUndefined(raw?.azureEndpoint),
|
|
azureDeployment: trimToUndefined(raw?.azureDeployment),
|
|
azureApiVersion: trimToUndefined(raw?.azureApiVersion),
|
|
};
|
|
}
|
|
|
|
function asNonNegativeInteger(value: unknown): number | undefined {
|
|
const number = asFiniteNumber(value);
|
|
return number !== undefined && Number.isSafeInteger(number) && number >= 0 ? number : undefined;
|
|
}
|
|
|
|
function asUnitInterval(value: unknown): number | undefined {
|
|
const number = asFiniteNumber(value);
|
|
return number !== undefined && number >= 0 && number <= 1 ? number : undefined;
|
|
}
|
|
|
|
type OpenAIRealtimeApiKeyResolution =
|
|
| { status: "available"; value: string }
|
|
| { status: "missing" };
|
|
|
|
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 = 5000;
|
|
const resolvedKeychainSecretRefCache = new Map<string, string>();
|
|
|
|
function resolveKeychainSecretRef(value: string): string | undefined {
|
|
const trimmed = value.trim();
|
|
const match = KEYCHAIN_SECRET_REF_RE.exec(trimmed);
|
|
if (!match) {
|
|
return trimmed || undefined;
|
|
}
|
|
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() || undefined;
|
|
if (resolved) {
|
|
resolvedKeychainSecretRefCache.set(trimmed, resolved);
|
|
}
|
|
return resolved;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function resolveOpenAIRealtimeSecretInput(
|
|
configuredApiKey: string | undefined,
|
|
): OpenAIRealtimeApiKeyResolution {
|
|
const configured = normalizeSecretInputString(configuredApiKey);
|
|
if (configured) {
|
|
const value = resolveKeychainSecretRef(configured);
|
|
return value ? { status: "available", value } : { status: "missing" };
|
|
}
|
|
|
|
return { status: "missing" };
|
|
}
|
|
|
|
function resolveOpenAIRealtimeEnvApiKey(): OpenAIRealtimeApiKeyResolution {
|
|
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: string | undefined,
|
|
): OpenAIRealtimeApiKeyResolution {
|
|
const configured = resolveOpenAIRealtimeSecretInput(configuredApiKey);
|
|
if (
|
|
configured.status === "available" ||
|
|
hasOpenAIRealtimeConfiguredApiKeyInput(configuredApiKey)
|
|
) {
|
|
return configured;
|
|
}
|
|
return resolveOpenAIRealtimeEnvApiKey();
|
|
}
|
|
|
|
function requireOpenAIRealtimeApiKey(
|
|
configuredApiKey: string | undefined,
|
|
errorMessage = OPENAI_REALTIME_API_KEY_REQUIRED,
|
|
): string {
|
|
const resolved = resolveOpenAIRealtimeApiKey(configuredApiKey);
|
|
if (resolved.status === "available") {
|
|
return resolved.value;
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
function hasOpenAIRealtimeConfiguredApiKeyInput(configuredApiKey: string | undefined): boolean {
|
|
return Boolean(normalizeSecretInputString(configuredApiKey));
|
|
}
|
|
|
|
function hasOpenAIRealtimeApiKeyInput(configuredApiKey: string | undefined): boolean {
|
|
return Boolean(
|
|
normalizeSecretInputString(configuredApiKey) ??
|
|
normalizeSecretInputString(process.env.OPENAI_API_KEY),
|
|
);
|
|
}
|
|
|
|
async function resolveOpenAIRealtimePlatformApiKey(params: {
|
|
configuredApiKey: string | undefined;
|
|
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
|
|
}): Promise<OpenAIRealtimeApiKeyResolution> {
|
|
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: {
|
|
configuredApiKey: string | undefined;
|
|
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
|
|
}): Promise<string> {
|
|
const resolved = await resolveOpenAIRealtimePlatformApiKey(params);
|
|
if (resolved.status === "available") {
|
|
return resolved.value;
|
|
}
|
|
throw new Error(OPENAI_REALTIME_PLATFORM_API_KEY_REQUIRED);
|
|
}
|
|
|
|
function hasOpenAIRealtimePlatformApiKeyInput(params: {
|
|
configuredApiKey: string | undefined;
|
|
cfg: RealtimeVoiceBrowserSessionCreateRequest["cfg"] | undefined;
|
|
}): boolean {
|
|
if (hasOpenAIRealtimeConfiguredApiKeyInput(params.configuredApiKey)) {
|
|
return true;
|
|
}
|
|
if (
|
|
isProviderAuthProfileConfigured({
|
|
provider: "openai",
|
|
cfg: params.cfg,
|
|
profileTypes: ["api_key"],
|
|
})
|
|
) {
|
|
return true;
|
|
}
|
|
return hasOpenAIRealtimeApiKeyInput(undefined);
|
|
}
|
|
|
|
function isOpenAIRealtimeMaxSessionDurationError(detail: string): boolean {
|
|
const normalized = detail.toLowerCase();
|
|
return (
|
|
normalized.includes("session") &&
|
|
normalized.includes(OPENAI_REALTIME_MAX_SESSION_DURATION_FRAGMENT)
|
|
);
|
|
}
|
|
|
|
function base64ToBuffer(b64: string): Buffer {
|
|
return Buffer.from(b64, "base64");
|
|
}
|
|
|
|
class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
|
private static readonly DEFAULT_MODEL = OPENAI_REALTIME_DEFAULT_MODEL;
|
|
private static readonly MAX_RECONNECT_ATTEMPTS = 5;
|
|
private static readonly BASE_RECONNECT_DELAY_MS = 1000;
|
|
private static readonly CONNECT_TIMEOUT_MS = 10_000;
|
|
readonly supportsToolResultContinuation = true;
|
|
|
|
private ws: WebSocket | null = null;
|
|
private connected = false;
|
|
private sessionConfigured = false;
|
|
private intentionallyClosed = false;
|
|
private reconnectAttempts = 0;
|
|
private pendingAudio: Buffer[] = [];
|
|
private markQueue: string[] = [];
|
|
private responseStartTimestamp: number | null = null;
|
|
private responseActive = false;
|
|
private responseCreateInFlight = false;
|
|
private responseCancelInFlight = false;
|
|
private responseCreatePending = false;
|
|
private continuingToolCallIds = new Set<string>();
|
|
private latestMediaTimestamp = 0;
|
|
private lastAssistantItemId: string | null = null;
|
|
private connectionUrl = "";
|
|
private toolCallBuffers = new Map<string, { name: string; callId: string; args: string }>();
|
|
private deliveredToolCallKeys = new Set<string>();
|
|
private readonly flowId = randomUUID();
|
|
private sessionReadyFired = false;
|
|
private reconnectReason: string | undefined;
|
|
private activeConnectionReason: string | undefined;
|
|
private readonly audioFormat: RealtimeVoiceAudioFormat;
|
|
|
|
constructor(private readonly config: OpenAIRealtimeVoiceBridgeConfig) {
|
|
this.audioFormat = config.audioFormat ?? REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ;
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
this.intentionallyClosed = false;
|
|
this.reconnectAttempts = 0;
|
|
await this.doConnect();
|
|
}
|
|
|
|
sendAudio(audio: Buffer): void {
|
|
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: number): void {
|
|
this.latestMediaTimestamp = ts;
|
|
}
|
|
|
|
sendUserMessage(text: string): void {
|
|
this.sendEvent({
|
|
type: "conversation.item.create",
|
|
item: {
|
|
type: "message",
|
|
role: "user",
|
|
content: [{ type: "input_text", text }],
|
|
},
|
|
});
|
|
this.requestResponseCreate();
|
|
}
|
|
|
|
triggerGreeting(instructions?: string): void {
|
|
if (!this.isConnected() || !this.ws) {
|
|
return;
|
|
}
|
|
this.sendUserMessage(instructions ?? this.config.instructions ?? "Greet the meeting.");
|
|
}
|
|
|
|
submitToolResult(
|
|
callId: string,
|
|
result: unknown,
|
|
options?: RealtimeVoiceToolResultOptions,
|
|
): void {
|
|
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(): void {
|
|
if (this.markQueue.length === 0) {
|
|
return;
|
|
}
|
|
this.markQueue.shift();
|
|
}
|
|
|
|
close(): void {
|
|
this.intentionallyClosed = true;
|
|
this.connected = false;
|
|
this.sessionConfigured = false;
|
|
if (this.ws) {
|
|
this.ws.close(1000, "Bridge closed");
|
|
this.ws = null;
|
|
}
|
|
}
|
|
|
|
isConnected(): boolean {
|
|
return this.connected && this.sessionConfigured;
|
|
}
|
|
|
|
private async doConnect(): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
let settled = false;
|
|
let startupFailureClosing = false;
|
|
const settleResolve = () => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(connectTimeout);
|
|
resolve();
|
|
};
|
|
const settleReject = (error: Error) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(connectTimeout);
|
|
reject(error);
|
|
};
|
|
const connectTimeout: ReturnType<typeof setTimeout> = setTimeout(() => {
|
|
if (!this.sessionConfigured && !this.intentionallyClosed) {
|
|
startupFailureClosing = true;
|
|
this.ws?.terminate();
|
|
settleReject(new Error("OpenAI realtime connection timeout"));
|
|
}
|
|
}, OpenAIRealtimeVoiceBridge.CONNECT_TIMEOUT_MS);
|
|
|
|
const openWebSocket = (connection: { url: string; headers: Record<string, string> }) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
if (this.intentionallyClosed) {
|
|
settleResolve();
|
|
return;
|
|
}
|
|
const url = connection.url;
|
|
this.connectionUrl = connection.url;
|
|
const debugProxy = resolveDebugProxySettings();
|
|
const proxyAgent = createDebugProxyWebSocketAgent(debugProxy);
|
|
const ws = new WebSocket(connection.url, {
|
|
headers: connection.headers,
|
|
...(proxyAgent ? { agent: proxyAgent } : {}),
|
|
});
|
|
this.ws = ws;
|
|
|
|
const rejectStartup = (error: Error) => {
|
|
startupFailureClosing = true;
|
|
settleReject(error);
|
|
if (ws.readyState !== WebSocket.CLOSED) {
|
|
ws.close(1000, "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: Buffer) => {
|
|
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()) as RealtimeEvent;
|
|
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,
|
|
});
|
|
if (startupFailureClosing) {
|
|
if (this.ws === ws) {
|
|
this.connected = false;
|
|
this.sessionConfigured = false;
|
|
}
|
|
return;
|
|
}
|
|
const wasSessionConfigured = this.sessionConfigured;
|
|
this.connected = false;
|
|
this.sessionConfigured = false;
|
|
if (this.intentionallyClosed) {
|
|
settleResolve();
|
|
this.config.onClose?.("completed");
|
|
return;
|
|
}
|
|
if (!wasSessionConfigured && !settled) {
|
|
settleReject(new Error("OpenAI realtime connection closed before ready"));
|
|
return;
|
|
}
|
|
const reason = this.reconnectReason ?? "websocket-close";
|
|
this.reconnectReason = undefined;
|
|
void this.attemptReconnect(reason);
|
|
});
|
|
};
|
|
|
|
const connectionOrPromise = this.resolveConnectionParams();
|
|
if (connectionOrPromise instanceof Promise) {
|
|
void connectionOrPromise.then(openWebSocket).catch((error: unknown) => {
|
|
settleReject(error instanceof Error ? error : new Error(String(error)));
|
|
});
|
|
return;
|
|
}
|
|
openWebSocket(connectionOrPromise);
|
|
});
|
|
}
|
|
|
|
private resolveConnectionParams():
|
|
| { url: string; headers: Record<string, string> }
|
|
| Promise<{ url: string; headers: Record<string, string> }> {
|
|
const cfg = this.config;
|
|
const model = cfg.model ?? OpenAIRealtimeVoiceBridge.DEFAULT_MODEL;
|
|
if (cfg.azureEndpoint && cfg.azureDeployment) {
|
|
const apiKey = requireOpenAIRealtimeApiKey(cfg.apiKey);
|
|
const base = cfg.azureEndpoint
|
|
.replace(/\/$/, "")
|
|
.replace(/^http(s?):/, (_, secure: string) => `ws${secure}:`);
|
|
const apiVersion = cfg.azureApiVersion ?? "2024-10-01-preview";
|
|
const url = `${base}/openai/realtime?api-version=${apiVersion}&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);
|
|
}
|
|
|
|
private async resolveDefaultConnectionParams(model: string): Promise<{
|
|
url: string;
|
|
headers: Record<string, string>;
|
|
}> {
|
|
const apiKey = await requireOpenAIRealtimePlatformApiKey({
|
|
configuredApiKey: this.config.apiKey,
|
|
cfg: this.config.cfg,
|
|
});
|
|
return this.resolveApiKeyConnectionParams(apiKey, model);
|
|
}
|
|
|
|
private resolveApiKeyConnectionParams(
|
|
apiKey: string,
|
|
model: string,
|
|
): { url: string; headers: Record<string, string> } {
|
|
const cfg = this.config;
|
|
if (cfg.azureEndpoint) {
|
|
const base = cfg.azureEndpoint
|
|
.replace(/\/$/, "")
|
|
.replace(/^http(s?):/, (_, secure: string) => `ws${secure}:`);
|
|
const url = `${base}/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}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
private async attemptReconnect(reason: string): Promise<void> {
|
|
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);
|
|
}
|
|
}
|
|
|
|
private sendSessionUpdate(): void {
|
|
if (this.usesAzureDeploymentRealtimeApi()) {
|
|
this.sendEvent(this.buildAzureDeploymentSessionUpdate());
|
|
return;
|
|
}
|
|
|
|
this.sendEvent(this.buildGaSessionUpdate());
|
|
}
|
|
|
|
private buildGaSessionUpdate(): RealtimeGaSessionUpdate {
|
|
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 ?? 0.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",
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
private usesAzureDeploymentRealtimeApi(): boolean {
|
|
return Boolean(this.config.azureEndpoint && this.config.azureDeployment);
|
|
}
|
|
|
|
private buildAzureDeploymentSessionUpdate(): RealtimeAzureDeploymentSessionUpdate {
|
|
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 ?? 0.5,
|
|
prefix_padding_ms: cfg.prefixPaddingMs ?? 300,
|
|
silence_duration_ms: cfg.silenceDurationMs ?? 500,
|
|
create_response: cfg.autoRespondToAudio ?? true,
|
|
},
|
|
temperature: cfg.temperature ?? 0.8,
|
|
...(cfg.tools && cfg.tools.length > 0
|
|
? {
|
|
tools: cfg.tools,
|
|
tool_choice: "auto",
|
|
}
|
|
: {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
private resolveRealtimeAudioFormat(): OpenAIRealtimeAudioFormatConfig {
|
|
return this.audioFormat.encoding === "pcm16"
|
|
? { type: "audio/pcm", rate: 24000 }
|
|
: { type: "audio/pcmu" };
|
|
}
|
|
|
|
private resolveLegacyRealtimeAudioFormat(): "g711_ulaw" | "pcm16" {
|
|
return this.audioFormat.encoding === "pcm16" ? "pcm16" : "g711_ulaw";
|
|
}
|
|
|
|
private handleEvent(event: RealtimeEvent): void {
|
|
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(1000, "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 = undefined;
|
|
}
|
|
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?: RealtimeVoiceBargeInOptions): void {
|
|
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();
|
|
}
|
|
|
|
private emitToolCallOnce(fields: {
|
|
itemId?: string;
|
|
callId?: string;
|
|
name?: string;
|
|
rawArgs?: string;
|
|
}): void {
|
|
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: unknown = {};
|
|
try {
|
|
args = JSON.parse(fields.rawArgs || "{}");
|
|
} catch {}
|
|
this.config.onToolCall({
|
|
itemId,
|
|
callId,
|
|
name,
|
|
args,
|
|
});
|
|
}
|
|
|
|
private requestResponseCreate(): void {
|
|
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" });
|
|
}
|
|
|
|
private flushPendingResponseCreate(): void {
|
|
if (!this.responseCreatePending) {
|
|
return;
|
|
}
|
|
this.responseCreatePending = false;
|
|
this.requestResponseCreate();
|
|
}
|
|
|
|
private resetRealtimeSessionState(): void {
|
|
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();
|
|
}
|
|
|
|
private sendMark(): void {
|
|
const markName = `audio-${Date.now()}`;
|
|
this.markQueue.push(markName);
|
|
this.config.onMark?.(markName);
|
|
}
|
|
|
|
private sendEvent(event: unknown, detail?: string): void {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
const type =
|
|
event && typeof event === "object" && typeof (event as { type?: unknown }).type === "string"
|
|
? (event as { type: string }).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);
|
|
}
|
|
}
|
|
|
|
private describeServerEvent(event: RealtimeEvent): string | undefined {
|
|
if (event.type === "error") {
|
|
return readRealtimeErrorDetail(event.error);
|
|
}
|
|
if (event.type === "response.done") {
|
|
const status = event.response?.status;
|
|
const details =
|
|
event.response?.status_details === undefined
|
|
? undefined
|
|
: JSON.stringify(event.response.status_details);
|
|
return (
|
|
[status ? `status=${status}` : undefined, details].filter(Boolean).join(" ") || undefined
|
|
);
|
|
}
|
|
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}` : undefined]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
}
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function resolveOpenAIRealtimeBrowserOfferHeaders(): Record<string, string> | undefined {
|
|
const headers = resolveProviderRequestHeaders({
|
|
provider: "openai",
|
|
baseUrl: "https://api.openai.com/v1/realtime/calls",
|
|
capability: "audio",
|
|
transport: "http",
|
|
defaultHeaders: {},
|
|
});
|
|
// Strip server-side-only attribution headers: browser direct fetches to
|
|
// api.openai.com fail CORS preflight when these are present (only
|
|
// authorization,content-type are allowed by the endpoint's CORS policy).
|
|
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 : undefined;
|
|
}
|
|
|
|
async function createOpenAIRealtimeBrowserSession(
|
|
req: RealtimeVoiceBrowserSessionCreateRequest,
|
|
): Promise<RealtimeVoiceBrowserSession> {
|
|
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: Record<string, unknown> = {
|
|
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 } : {}),
|
|
};
|
|
}
|
|
|
|
export function buildOpenAIRealtimeVoiceProvider(): RealtimeVoiceProviderPlugin {
|
|
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,
|
|
};
|
|
}
|