@pipedog/openai 0.0.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1550 @@
1
+ // Copyright (c) 2024-2026, Daily
2
+ // Copyright (c) 2026, pipedog contributors
3
+ // SPDX-License-Identifier: BSD-2-Clause
4
+ //
5
+ // Ported from pipecat: src/pipecat/services/openai/realtime/llm.py
6
+ /**
7
+ * OpenAI Realtime LLM service implementation with WebSocket support.
8
+ *
9
+ * Porting notes:
10
+ * - The deprecated `model` and `sessionProperties` constructor options are
11
+ * not ported; use `settings: new OpenAIRealtimeLLMService.Settings({ model,
12
+ * sessionProperties })` instead.
13
+ * - The websocket is `websocketConnect()` from core (the global `WebSocket`).
14
+ * - Video frames are encoded with core's `encodeImage()` (upstream: Pillow).
15
+ * - The tracing decorators are applied in a static block (decorator syntax is
16
+ * not erasable).
17
+ */
18
+ import { Future, rethrowIfCancelled, time } from "@pipedog/asyncio";
19
+ import { createStreamResampler } from "@pipedog/core/audio/utils";
20
+ import { BotStoppedSpeakingFrame, FunctionCallCancelFrame, FunctionCallFromLLM, FunctionCallResultFrame, InputAudioRawFrame, InputImageRawFrame, InterimTranscriptionFrame, InterruptionFrame, LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMMessagesAppendFrame, LLMServiceMetadataFrame, LLMSetToolsFrame, LLMTextFrame, ProposedUserStartedSpeakingFrame, ProposedUserStoppedSpeakingFrame, SpeechControlParamsFrame, TextType, TranscriptionFrame, TTSAudioRawFrame, TTSStartedFrame, TTSStoppedFrame, TTSTextFrame, UserStartedSpeakingFrame, UserStoppedSpeakingFrame, } from "@pipedog/core/frames/frames";
21
+ import { LLMTokenUsage } from "@pipedog/core/metrics/metrics";
22
+ import * as asyncToolMessages from "@pipedog/core/processors/aggregators/async_tool_messages";
23
+ import { LLMSpecificMessage, standardMessageText, } from "@pipedog/core/processors/aggregators/llm_context";
24
+ import { FrameDirection } from "@pipedog/core/processors/frame_processor";
25
+ import { LLMService, } from "@pipedog/core/services/llm_service";
26
+ import { LLMSettings, } from "@pipedog/core/services/settings";
27
+ import { websocketConnect, } from "@pipedog/core/services/websocket_service";
28
+ import { Language } from "@pipedog/core/transcriptions/language";
29
+ import { ExternalUserTurnStrategies } from "@pipedog/core/turns/user_turn_strategies";
30
+ import { base64ToBytes, bytesToBase64 } from "@pipedog/core/utils/base64";
31
+ import { encodeImage } from "@pipedog/core/utils/image";
32
+ import { logger } from "@pipedog/core/utils/logger";
33
+ import { pyJsonDumps, pyJsonLoads } from "@pipedog/core/utils/py_json";
34
+ import { timeNowIso8601 } from "@pipedog/core/utils/time";
35
+ import { installTracedStt, tracedOpenaiRealtime, } from "@pipedog/core/utils/tracing/service_decorators";
36
+ import { assertGiven, isGiven, NOT_GIVEN } from "@pipedog/core/utils/types";
37
+ import { OPENAI_REALTIME_WHISPER_MODEL, OPENAI_SAMPLE_RATE } from "../_constants.js";
38
+ import { OpenAIRealtimeLLMAdapter } from "../open_ai_realtime_adapter.js";
39
+ import * as events from "./events.js";
40
+ // Pre-roll cushion added on top of the auto-sized duration (start_secs), to
41
+ // absorb small timing slop between start_secs and the audio actually clipped,
42
+ // and to give a bit of extra audio context for the model.
43
+ // Not applied to an explicit userAudioPrerollSecs override.
44
+ export const AUTOSIZED_USER_AUDIO_PREROLL_MARGIN_SECS = 0.3;
45
+ // Pre-roll used before start_secs is known (no SpeechControlParamsFrame yet, or
46
+ // no upstream VAD) and no override is given.
47
+ export const DEFAULT_USER_AUDIO_PREROLL_SECS = 0.5;
48
+ /** The values of `events.ImageDetail` (upstream reads them with `get_args`). */
49
+ const IMAGE_DETAILS = ["auto", "low", "high"];
50
+ function errorMessage(e) {
51
+ return e instanceof Error ? e.message : String(e);
52
+ }
53
+ /** Convert a snake_case key to camelCase (`output_modalities` → `outputModalities`). */
54
+ function snakeToCamel(key) {
55
+ return key.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
56
+ }
57
+ /** Python truthiness, for `x if value else y`. */
58
+ function pyTruthy(value) {
59
+ if (value === null || value === undefined || value === false)
60
+ return false;
61
+ if (value === 0 || value === "")
62
+ return false;
63
+ if (Array.isArray(value))
64
+ return value.length > 0;
65
+ if (value instanceof Map || value instanceof Set)
66
+ return value.size > 0;
67
+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
68
+ return Object.keys(value).length > 0;
69
+ }
70
+ return true;
71
+ }
72
+ /** A string as Python's `repr()` prints it. */
73
+ function pyRepr(value) {
74
+ return typeof value === "string" ? `'${value}'` : String(value);
75
+ }
76
+ /**
77
+ * Tracks the current audio response from the assistant.
78
+ *
79
+ * - `itemId`: unique identifier for the audio response item.
80
+ * - `contentIndex`: index of the audio content within the item.
81
+ * - `startTimeMs`: timestamp when the audio response started in milliseconds.
82
+ * - `totalSize`: total size of audio data received in bytes. Defaults to 0.
83
+ * - `responseId`: ID of the server response the item belongs to. Defaults to "".
84
+ */
85
+ export class CurrentAudioResponse {
86
+ itemId;
87
+ contentIndex;
88
+ startTimeMs;
89
+ totalSize;
90
+ responseId;
91
+ constructor(options) {
92
+ this.itemId = options.itemId;
93
+ this.contentIndex = options.contentIndex;
94
+ this.startTimeMs = options.startTimeMs;
95
+ this.totalSize = options.totalSize ?? 0;
96
+ this.responseId = options.responseId ?? "";
97
+ }
98
+ }
99
+ /**
100
+ * Settings for OpenAIRealtimeLLMService.
101
+ *
102
+ * - `sessionProperties`: OpenAI Realtime session properties (modalities,
103
+ * audio config, tools, etc.). `model` and `instructions` are synced
104
+ * bidirectionally with the top-level `model` and `systemInstruction` fields.
105
+ */
106
+ export class OpenAIRealtimeLLMSettings extends LLMSettings {
107
+ sessionProperties = NOT_GIVEN;
108
+ constructor(options = {}) {
109
+ super(options);
110
+ this._assignOptions(options);
111
+ }
112
+ // -- Bidirectional sync helpers ------------------------------------------
113
+ /** Push top-level `model`/`systemInstruction` into `sessionProperties`. */
114
+ static _syncTopLevelToSp(settings) {
115
+ if (!isGiven(settings.sessionProperties)) {
116
+ return;
117
+ }
118
+ const sp = settings.sessionProperties;
119
+ if (isGiven(settings.model) && settings.model !== null) {
120
+ sp.model = settings.model;
121
+ }
122
+ if (isGiven(settings.systemInstruction)) {
123
+ sp.instructions = settings.systemInstruction;
124
+ }
125
+ }
126
+ // -- applyUpdate override -------------------------------------------------
127
+ /**
128
+ * Merge a delta, keeping `model`/`systemInstruction` in sync with SP.
129
+ *
130
+ * When the delta contains `sessionProperties`, it **replaces** the stored
131
+ * SP wholesale (matching legacy behaviour). Top-level field values always
132
+ * take precedence over conflicting SP values.
133
+ */
134
+ applyUpdate(delta) {
135
+ // 1. Let the base class handle all fields including sessionProperties
136
+ // (wholesale replacement when given).
137
+ const changed = super.applyUpdate(delta);
138
+ // 2. SP → top-level: if the SP was just replaced and carries
139
+ // model/instructions that the delta didn't set at top level,
140
+ // pull them up.
141
+ if ("sessionProperties" in changed && isGiven(this.sessionProperties)) {
142
+ const sp = this.sessionProperties;
143
+ if (!("model" in changed) && sp.model !== null && sp.model !== undefined) {
144
+ const oldModel = this.model;
145
+ this.model = sp.model;
146
+ if (oldModel !== this.model) {
147
+ changed.model = oldModel;
148
+ }
149
+ }
150
+ if (!("systemInstruction" in changed) &&
151
+ sp.instructions !== null &&
152
+ sp.instructions !== undefined) {
153
+ const oldSi = this.systemInstruction;
154
+ this.systemInstruction = sp.instructions;
155
+ if (oldSi !== this.systemInstruction) {
156
+ changed.systemInstruction = oldSi;
157
+ }
158
+ }
159
+ }
160
+ // 3. Top-level → SP: ensure SP mirrors the authoritative top-level
161
+ // values. Covers all cases: top-level-only delta, SP-only delta,
162
+ // and mixed deltas where top-level takes precedence.
163
+ OpenAIRealtimeLLMSettings._syncTopLevelToSp(this);
164
+ return changed;
165
+ }
166
+ // -- fromMapping override -------------------------------------------------
167
+ /**
168
+ * Build a delta from a plain dict, routing SP keys into `sessionProperties`.
169
+ *
170
+ * Keys that correspond to `SessionProperties` fields (except `model`) are
171
+ * collected into a nested `sessionProperties` value. `model` is always
172
+ * routed to the top-level field. Unknown keys go to `extra`. Keys may use
173
+ * upstream's snake_case spelling or the camelCase field names.
174
+ */
175
+ static fromMapping(settings) {
176
+ const cls = this;
177
+ // Determine which keys belong to our own dataclass fields.
178
+ const ownFieldNames = new Set(new cls()._fieldNames());
179
+ const top = {};
180
+ const spDict = {};
181
+ const extra = {};
182
+ // Build the SP field set without instantiating (avoid constructor
183
+ // cost for every fromMapping call). `modelFields` maps camelCase field
184
+ // names to wire keys; both spellings are accepted.
185
+ const spKeys = new Map();
186
+ for (const [field, wire] of Object.entries(events.SessionProperties.modelFields)) {
187
+ if (field === "model")
188
+ continue;
189
+ spKeys.set(field, field);
190
+ spKeys.set(wire, field);
191
+ }
192
+ for (const [key, value] of Object.entries(settings)) {
193
+ // Resolve aliases first
194
+ const canonical = cls._aliases[key] ?? key;
195
+ if (ownFieldNames.has(canonical)) {
196
+ top[canonical] = value;
197
+ }
198
+ else if (ownFieldNames.has(snakeToCamel(canonical))) {
199
+ top[snakeToCamel(canonical)] = value;
200
+ }
201
+ else if (spKeys.has(canonical)) {
202
+ spDict[spKeys.get(canonical)] = value;
203
+ }
204
+ else {
205
+ extra[key] = value;
206
+ }
207
+ }
208
+ if (Object.keys(spDict).length > 0) {
209
+ top.sessionProperties = new events.SessionProperties(spDict);
210
+ }
211
+ const instance = new cls(top);
212
+ instance.extra = extra;
213
+ return instance;
214
+ }
215
+ }
216
+ /**
217
+ * OpenAI Realtime LLM service providing real-time audio and text communication.
218
+ *
219
+ * Implements the OpenAI Realtime API with WebSocket communication for
220
+ * low-latency bidirectional audio and text interactions. Supports function
221
+ * calling, conversation management, and real-time transcription.
222
+ *
223
+ * Proposes turn boundaries from OpenAI's server-side VAD events, which the
224
+ * recommended external user turn strategies resolve into
225
+ * `UserStartedSpeakingFrame` / `UserStoppedSpeakingFrame`, so pipeline
226
+ * processors that depend on those frames (RTVI client speech events,
227
+ * `TurnTrackingObserver`, `AudioBufferProcessor` turn recording,
228
+ * `UserIdleController`, user mute strategies, voicemail detector) work out of
229
+ * the box. `LLMContextAggregatorPair` auto-detects this realtime service and
230
+ * decouples context writes from those frames; see the
231
+ * `examples/realtime/realtime-openai.py` example.
232
+ *
233
+ * If you wire local VAD (`LLMUserAggregatorParams.vadAnalyzer`) on top of this
234
+ * service, disable OpenAI's server-side turn detection first
235
+ * (`turn_detection=False`); otherwise both sources broadcast duplicate
236
+ * user-turn frames. See
237
+ * `examples/realtime/realtime-openai-locally-driven-turns.py`.
238
+ */
239
+ export class OpenAIRealtimeLLMService extends LLMService {
240
+ static Settings = OpenAIRealtimeLLMSettings;
241
+ // Overriding the default adapter to use the OpenAIRealtimeLLMAdapter one.
242
+ static adapterClass = OpenAIRealtimeLLMAdapter;
243
+ // Substrings used to recognize reasoning-capable Realtime models. Substring
244
+ // match (rather than exact equality) so date-versioned variants of the same
245
+ // base model also match without code changes. Extend this tuple as OpenAI
246
+ // ships more reasoning-capable Realtime models.
247
+ static _REASONING_CAPABLE_MODEL_SUBSTRINGS = [
248
+ "gpt-realtime-2",
249
+ "gpt-realtime-2.1",
250
+ ];
251
+ static {
252
+ const proto = OpenAIRealtimeLLMService.prototype;
253
+ proto._handleEvtSessionCreated = tracedOpenaiRealtime("llm_setup")(proto._handleEvtSessionCreated);
254
+ proto._handleEvtResponseDone = tracedOpenaiRealtime("llm_response")(proto._handleEvtResponseDone);
255
+ proto._createResponse = tracedOpenaiRealtime("llm_request")(proto._createResponse);
256
+ installTracedStt(OpenAIRealtimeLLMService.prototype, "_handleUserTranscription");
257
+ }
258
+ apiKey;
259
+ baseUrl;
260
+ _audioInputPaused;
261
+ _videoInputPaused;
262
+ _videoFrameDetail;
263
+ _lastSentTime = 0;
264
+ _websocket = null;
265
+ _receiveTask = null;
266
+ _context = null;
267
+ _llmNeedsConversationSetup = true;
268
+ _disconnecting = false;
269
+ _apiSessionReady = false;
270
+ _runLlmWhenApiSessionReady = false;
271
+ _currentAssistantResponse = null;
272
+ _currentAudioResponse = null;
273
+ _resampler = createStreamResampler();
274
+ _userAudioPrerollBuffer = new Uint8Array(0);
275
+ // When set, pins the pre-roll duration; otherwise auto-sized from the
276
+ // upstream VAD's start_secs (see _handleSpeechControlParams).
277
+ _userAudioPrerollSecsOverride;
278
+ _userAudioPrerollSecs;
279
+ _messagesAddedManually = new Map();
280
+ // Track function calls by call_id
281
+ _pendingFunctionCalls = new Map();
282
+ _completedToolCalls = new Set();
283
+ // Calls the API is waiting on, by call_id: only these can be answered
284
+ // with a function_call_output. Cleared on disconnect, since a reconnect
285
+ // replays the conversation as text and the API no longer knows the ids.
286
+ _openFunctionCalls = new Set();
287
+ // Something was delivered that no response has taken in yet. A response
288
+ // starting clears it; an inference request while it stands creates one.
289
+ _resultsAwaitingResponse = false;
290
+ // A response was asked for while one was already running, and is owed
291
+ // once that one is done.
292
+ _responseCreateOwed = false;
293
+ _responseActive = false;
294
+ // Whether we've already emitted the "stripping `reasoning`" warning for
295
+ // this service instance. The Realtime API doesn't allow swapping the model
296
+ // mid-session, so once is enough.
297
+ _reasoningStripWarned = false;
298
+ _retrieveConversationItemFutures = new Map();
299
+ /**
300
+ * Initialize the OpenAI Realtime LLM service.
301
+ *
302
+ * @param options.apiKey OpenAI API key for authentication.
303
+ * @param options.baseUrl WebSocket base URL for the realtime API.
304
+ * @param options.settings Runtime-updatable settings for this service.
305
+ * @param options.startAudioPaused Whether to start with audio input paused.
306
+ * @param options.startVideoPaused Whether to start with video input paused.
307
+ * @param options.videoFrameDetail Detail level for video processing.
308
+ * @param options.userAudioPrerollSecs Pre-roll replayed after an
309
+ * interruption clears the input audio buffer, in manual turn-detection
310
+ * mode. See `OpenAIRealtimeLLMServiceOptions.userAudioPrerollSecs`.
311
+ * Other options are passed to the parent LLMService.
312
+ */
313
+ constructor(options) {
314
+ const cls = new.target;
315
+ const baseUrl = options.baseUrl ?? "wss://api.openai.com/v1/realtime";
316
+ // 1. Initialize default_settings with hardcoded defaults
317
+ const defaultSettings = new cls.Settings({
318
+ model: "gpt-realtime-2.1",
319
+ systemInstruction: null,
320
+ temperature: null,
321
+ maxTokens: null,
322
+ topP: null,
323
+ topK: null,
324
+ frequencyPenalty: null,
325
+ presencePenalty: null,
326
+ seed: null,
327
+ filterIncompleteUserTurns: false,
328
+ userTurnCompletionConfig: null,
329
+ sessionProperties: new events.SessionProperties({}),
330
+ });
331
+ // Sync top-level model back into session_properties
332
+ cls.Settings._syncTopLevelToSp(defaultSettings);
333
+ // 2. Apply settings delta (canonical API, always wins)
334
+ if (options.settings !== null && options.settings !== undefined) {
335
+ defaultSettings.applyUpdate(options.settings);
336
+ }
337
+ cls._omitUnsupportedInputAudioTranscriptionPrompt(assertGiven(defaultSettings.sessionProperties));
338
+ // Build WebSocket URL with model query parameter
339
+ // Source: https://platform.openai.com/docs/guides/realtime-websocket
340
+ const fullUrl = `${baseUrl}?model=${defaultSettings.model}`;
341
+ super({ ...options, settings: defaultSettings });
342
+ this.apiKey = options.apiKey;
343
+ this.baseUrl = fullUrl;
344
+ this._audioInputPaused = options.startAudioPaused ?? false;
345
+ this._videoInputPaused = options.startVideoPaused ?? false;
346
+ this._videoFrameDetail = options.videoFrameDetail ?? "auto";
347
+ const userAudioPrerollSecs = options.userAudioPrerollSecs ?? null;
348
+ this._userAudioPrerollSecsOverride = userAudioPrerollSecs;
349
+ this._userAudioPrerollSecs =
350
+ userAudioPrerollSecs !== null ? userAudioPrerollSecs : DEFAULT_USER_AUDIO_PREROLL_SECS;
351
+ this._registerEventHandler("on_conversation_item_created");
352
+ this._registerEventHandler("on_conversation_item_updated");
353
+ }
354
+ /** Drop input transcription prompt settings unsupported by the selected model. */
355
+ static _omitUnsupportedInputAudioTranscriptionPrompt(sessionProperties) {
356
+ const transcription = sessionProperties.audio?.input?.transcription ?? null;
357
+ if (transcription && transcription.model === OPENAI_REALTIME_WHISPER_MODEL) {
358
+ if (transcription.prompt) {
359
+ transcription.prompt = null;
360
+ logger.warning(`${OPENAI_REALTIME_WHISPER_MODEL} does not support the prompt ` +
361
+ "parameter; omitting prompt from OpenAI Realtime input audio " +
362
+ "transcription settings.");
363
+ return true;
364
+ }
365
+ }
366
+ return false;
367
+ }
368
+ /**
369
+ * Check if the service can generate usage metrics.
370
+ *
371
+ * @returns True if metrics generation is supported.
372
+ */
373
+ canGenerateMetrics() {
374
+ return true;
375
+ }
376
+ /**
377
+ * Set whether audio input is paused.
378
+ *
379
+ * @param paused True to pause audio input, false to resume.
380
+ */
381
+ setAudioInputPaused(paused) {
382
+ this._audioInputPaused = paused;
383
+ }
384
+ /**
385
+ * Set whether video input is paused.
386
+ *
387
+ * @param paused True to pause video input, false to resume.
388
+ */
389
+ setVideoInputPaused(paused) {
390
+ this._videoInputPaused = paused;
391
+ }
392
+ /**
393
+ * Set the detail level for video processing.
394
+ *
395
+ * @param detail Detail level - "auto", "low", or "high".
396
+ */
397
+ setVideoFrameDetail(detail) {
398
+ if (!IMAGE_DETAILS.includes(detail)) {
399
+ logger.warning(`Invalid video detail '${detail}', must be one of ('${IMAGE_DETAILS.join("', '")}')`);
400
+ return;
401
+ }
402
+ this._videoFrameDetail = detail;
403
+ }
404
+ /** Check if a specific modality is enabled, "text" or "audio". */
405
+ _isModalityEnabled(modality) {
406
+ const modalities = pyTruthy(assertGiven(this._settings.sessionProperties).outputModalities)
407
+ ? assertGiven(this._settings.sessionProperties).outputModalities
408
+ : ["audio", "text"];
409
+ return modalities.includes(modality);
410
+ }
411
+ /** Get the list of enabled modalities. */
412
+ _getEnabledModalities() {
413
+ const modalities = pyTruthy(assertGiven(this._settings.sessionProperties).outputModalities)
414
+ ? assertGiven(this._settings.sessionProperties).outputModalities
415
+ : ["audio", "text"];
416
+ // API only supports single modality responses: either ["text"] or ["audio"]
417
+ if (modalities.includes("audio")) {
418
+ return ["audio"];
419
+ }
420
+ return ["text"];
421
+ }
422
+ /**
423
+ * Retrieve a conversation item by ID from the server.
424
+ *
425
+ * @param itemId The ID of the conversation item to retrieve.
426
+ * @returns The retrieved conversation item.
427
+ */
428
+ async retrieveConversationItem(itemId) {
429
+ const future = new Future();
430
+ let retrievalInFlight = false;
431
+ const pending = this._retrieveConversationItemFutures.get(itemId);
432
+ if (!pending || pending.length === 0) {
433
+ this._retrieveConversationItemFutures.set(itemId, []);
434
+ }
435
+ else {
436
+ retrievalInFlight = true;
437
+ }
438
+ this._retrieveConversationItemFutures.get(itemId).push(future);
439
+ if (!retrievalInFlight) {
440
+ await this.sendClientEvent(
441
+ // Set event_id to "rci_{item_id}" so that we can identify an
442
+ // error later if the retrieval fails. We don't need a UUID
443
+ // suffix to the event_id because we're ensuring only one
444
+ // in-flight retrieval per item_id. (Note: "rci" = "retrieve
445
+ // conversation item")
446
+ new events.ConversationItemRetrieveEvent({ itemId, eventId: `rci_${itemId}` }));
447
+ }
448
+ return await future;
449
+ }
450
+ //
451
+ // standard AIService frame handling
452
+ //
453
+ /**
454
+ * Set up the service and connect.
455
+ *
456
+ * @param setup Configuration object containing setup parameters.
457
+ */
458
+ async setup(setup) {
459
+ await super.setup(setup);
460
+ await this._connect();
461
+ }
462
+ /** Release realtime LLM resources at teardown. */
463
+ async cleanup() {
464
+ await super.cleanup();
465
+ await this._disconnect();
466
+ }
467
+ /**
468
+ * Stop the service and close WebSocket connection.
469
+ *
470
+ * @param frame The end frame triggering service shutdown.
471
+ */
472
+ async stop(frame) {
473
+ await super.stop(frame);
474
+ await this._disconnect();
475
+ }
476
+ /**
477
+ * Cancel the service and close WebSocket connection.
478
+ *
479
+ * @param frame The cancel frame triggering service cancellation.
480
+ */
481
+ async cancel(frame) {
482
+ await super.cancel(frame);
483
+ await this._disconnect();
484
+ }
485
+ //
486
+ // speech and interruption handling
487
+ //
488
+ /**
489
+ * Whether server-side turn detection is explicitly disabled.
490
+ *
491
+ * `null` and `false` are different. `null` means we're using OpenAI's
492
+ * built-in turn detection defaults (so server VAD is active); `false` is
493
+ * the opt-out that puts the service in manual mode.
494
+ */
495
+ _isTurnDetectionDisabled() {
496
+ const sessionProperties = assertGiven(this._settings.sessionProperties);
497
+ return Boolean(sessionProperties.audio &&
498
+ sessionProperties.audio.input &&
499
+ sessionProperties.audio.input.turnDetection === false);
500
+ }
501
+ /**
502
+ * Intermediate results reach the model as conversation items.
503
+ *
504
+ * The API takes one output per function call, so an intermediate result
505
+ * goes in beside the call instead of through it (see `pushFrame`).
506
+ */
507
+ get acceptsIntermediateFunctionCallResults() {
508
+ return true;
509
+ }
510
+ /** Realtime service; recommends external turn strategies when server-side VAD is active. */
511
+ serviceMetadataFrame() {
512
+ // When turn_detection is disabled the server doesn't emit VAD events,
513
+ // so there are no UserStarted/StoppedSpeakingFrame to drive external strategies.
514
+ const emitsTurnFrames = !this._isTurnDetectionDisabled();
515
+ this._warnIfRealtimeServiceEmitsNoTurnFrames(emitsTurnFrames);
516
+ return new LLMServiceMetadataFrame({
517
+ serviceName: this.name,
518
+ isRealtimeService: true,
519
+ userTurnStrategies: emitsTurnFrames ? new ExternalUserTurnStrategies() : null,
520
+ });
521
+ }
522
+ async _handleInterruption() {
523
+ if (this._isTurnDetectionDisabled()) {
524
+ await this.sendClientEvent(new events.InputAudioBufferClearEvent({}));
525
+ // The clear wipes the speech onset appended before local turn
526
+ // detection fired this interruption; replay it so it isn't lost.
527
+ await this._replayUserAudioPreroll();
528
+ await this.sendClientEvent(new events.ResponseCancelEvent({}));
529
+ }
530
+ await this._truncateCurrentAudioResponse();
531
+ await this.stopAllMetrics();
532
+ // Whatever the interrupted response was going to say is moot, and so is
533
+ // a response owed behind it: the user's turn makes one of its own.
534
+ this._responseCreateOwed = false;
535
+ if (this._currentAssistantResponse) {
536
+ await this.pushFrame(new LLMFullResponseEndFrame());
537
+ // Only push TTSStoppedFrame if audio modality is enabled
538
+ if (this._isModalityEnabled("audio")) {
539
+ await this.pushFrame(new TTSStoppedFrame());
540
+ }
541
+ }
542
+ }
543
+ async _handleUserStartedSpeaking(_frame) { }
544
+ async _handleUserStoppedSpeaking(_frame) {
545
+ if (this._isTurnDetectionDisabled()) {
546
+ await this.sendClientEvent(new events.InputAudioBufferCommitEvent({}));
547
+ await this.sendClientEvent(new events.ResponseCreateEvent({}));
548
+ }
549
+ }
550
+ async _handleBotStoppedSpeaking() {
551
+ this._currentAudioResponse = null;
552
+ }
553
+ /** Calculate audio duration in milliseconds based on PCM audio parameters. */
554
+ _calculateAudioDurationMs(totalBytes, sampleRate = OPENAI_SAMPLE_RATE, bytesPerSample = 2) {
555
+ const samples = totalBytes / bytesPerSample;
556
+ const durationSeconds = samples / sampleRate;
557
+ return Math.trunc(durationSeconds * 1000);
558
+ }
559
+ /**
560
+ * Truncates the current audio response at the appropriate duration.
561
+ *
562
+ * Calculates the actual duration of the audio content and truncates at the
563
+ * shorter of either the wall clock time or the actual audio duration to
564
+ * prevent invalid truncation requests.
565
+ */
566
+ async _truncateCurrentAudioResponse() {
567
+ if (!this._currentAudioResponse) {
568
+ return;
569
+ }
570
+ // if the bot is still speaking, truncate the last message
571
+ try {
572
+ const current = this._currentAudioResponse;
573
+ this._currentAudioResponse = null;
574
+ // Calculate actual audio duration instead of using wall clock time
575
+ const audioDurationMs = this._calculateAudioDurationMs(current.totalSize);
576
+ // Use the shorter of wall clock time or actual audio duration
577
+ const elapsedMs = Math.trunc(time() * 1000 - current.startTimeMs);
578
+ const truncateMs = Math.min(elapsedMs, audioDurationMs);
579
+ logger.trace(`Truncating audio: duration=${audioDurationMs}ms, ` +
580
+ `elapsed=${elapsedMs}ms, truncate=${truncateMs}ms`);
581
+ await this.sendClientEvent(new events.ConversationItemTruncateEvent({
582
+ itemId: current.itemId,
583
+ contentIndex: current.contentIndex,
584
+ audioEndMs: truncateMs,
585
+ }));
586
+ }
587
+ catch (e) {
588
+ rethrowIfCancelled(e);
589
+ // Log warning and don't re-raise - allow session to continue
590
+ logger.warning(`Audio truncation failed (non-fatal): ${errorMessage(e)}`);
591
+ }
592
+ }
593
+ //
594
+ // frame processing
595
+ //
596
+ // StartFrame, StopFrame, CancelFrame implemented in base class
597
+ //
598
+ /** Return the tools configured on `sessionProperties`, if any. */
599
+ _serviceTools() {
600
+ return (assertGiven(this._settings.sessionProperties).tools ?? null);
601
+ }
602
+ /**
603
+ * Process incoming frames from the pipeline.
604
+ *
605
+ * @param frame The frame to process.
606
+ * @param direction The direction of frame flow in the pipeline.
607
+ */
608
+ async processFrame(frame, direction) {
609
+ await super.processFrame(frame, direction);
610
+ if (frame instanceof TranscriptionFrame) {
611
+ // pass
612
+ }
613
+ else if (frame instanceof LLMContextFrame) {
614
+ await this._handleContext(frame.context, direction);
615
+ }
616
+ else if (frame instanceof InputAudioRawFrame) {
617
+ if (!this._audioInputPaused) {
618
+ await this._sendUserAudio(frame);
619
+ }
620
+ }
621
+ else if (frame instanceof InputImageRawFrame) {
622
+ if (!this._videoInputPaused) {
623
+ await this._sendUserVideo(frame);
624
+ }
625
+ }
626
+ else if (frame instanceof InterruptionFrame) {
627
+ await this._handleInterruption();
628
+ }
629
+ else if (frame instanceof UserStartedSpeakingFrame) {
630
+ await this._handleUserStartedSpeaking(frame);
631
+ }
632
+ else if (frame instanceof UserStoppedSpeakingFrame) {
633
+ await this._handleUserStoppedSpeaking(frame);
634
+ }
635
+ else if (frame instanceof BotStoppedSpeakingFrame) {
636
+ await this._handleBotStoppedSpeaking();
637
+ }
638
+ else if (frame instanceof SpeechControlParamsFrame) {
639
+ this._handleSpeechControlParams(frame);
640
+ }
641
+ else if (frame instanceof LLMMessagesAppendFrame) {
642
+ await this._handleMessagesAppend(frame);
643
+ }
644
+ else if (frame instanceof LLMSetToolsFrame) {
645
+ // Continuous session: no fresh context frame per turn, so sync the
646
+ // registered tool handlers to the new tool set here (the base service
647
+ // only does this on LLMContextFrame).
648
+ this._syncRegisteredToolHandlers(frame.tools);
649
+ await this._sendSessionUpdate();
650
+ }
651
+ await this.pushFrame(frame, direction);
652
+ }
653
+ /**
654
+ * Push a frame, delivering function call outcomes to the API on the way.
655
+ *
656
+ * Function call results and cancellations are broadcast by the base
657
+ * service; the downstream copy is observed here and sent to the API as it
658
+ * is produced, intermediate results included. The frames travel on to the
659
+ * assistant aggregator, which records them in the context and decides when
660
+ * inference should run.
661
+ *
662
+ * @param frame The frame to push.
663
+ * @param direction The direction of frame pushing.
664
+ */
665
+ async pushFrame(frame, direction = FrameDirection.DOWNSTREAM) {
666
+ if (direction === FrameDirection.DOWNSTREAM) {
667
+ if (frame instanceof FunctionCallResultFrame) {
668
+ await this._handleFunctionCallResult(frame);
669
+ }
670
+ else if (frame instanceof FunctionCallCancelFrame) {
671
+ await this._handleFunctionCallCancel(frame);
672
+ }
673
+ }
674
+ await super.pushFrame(frame, direction);
675
+ }
676
+ async _handleContext(context, direction) {
677
+ if (!this._context) {
678
+ // We got our initial context
679
+ this._context = context;
680
+ // Initialize our bookkeeping of already-completed tool calls in
681
+ // the context
682
+ await this._processCompletedFunctionCalls(false);
683
+ // Run the LLM at next opportunity
684
+ await this._createResponse();
685
+ }
686
+ else {
687
+ // We got an updated context.
688
+ // This may contain a new user message or tool call result.
689
+ this._context = context;
690
+ // Send results for newly-completed function calls, if any (results
691
+ // this service pushed are already sent; this catches any written
692
+ // into the context some other way).
693
+ await this._processCompletedFunctionCalls(true);
694
+ // A context frame travelling upstream comes from the assistant
695
+ // aggregator, which is asking the model to respond to what was
696
+ // delivered since its last response.
697
+ if (direction === FrameDirection.UPSTREAM && this._resultsAwaitingResponse) {
698
+ await this._createResponse();
699
+ }
700
+ }
701
+ }
702
+ /**
703
+ * Put appended messages into the conversation as items, for the model to
704
+ * take in.
705
+ *
706
+ * Whether the model answers them is the aggregator's call: an append that
707
+ * asks to run comes back to this service as the context frame the assistant
708
+ * aggregator pushes upstream, which creates the response (see
709
+ * `_handleContext`). Before the session's conversation is set up, nothing
710
+ * is sent: the setup sends the context, which the aggregator records the
711
+ * messages in.
712
+ */
713
+ async _handleMessagesAppend(frame) {
714
+ if (!this._apiSessionReady || this._llmNeedsConversationSetup) {
715
+ return;
716
+ }
717
+ for (const message of frame.messages) {
718
+ const text = standardMessageText(message);
719
+ if (!text) {
720
+ continue;
721
+ }
722
+ const role = message instanceof LLMSpecificMessage
723
+ ? null
724
+ : message.role;
725
+ await this._sendMessageItem(text, role === "assistant" ? "assistant" : "system");
726
+ }
727
+ this._resultsAwaitingResponse = true;
728
+ }
729
+ //
730
+ // websocket communication
731
+ //
732
+ /**
733
+ * Send a client event to the OpenAI Realtime API.
734
+ *
735
+ * @param event The client event to send.
736
+ */
737
+ async sendClientEvent(event) {
738
+ await this._wsSend(event.modelDump({ excludeNone: true }));
739
+ }
740
+ async _connect() {
741
+ try {
742
+ if (this._websocket) {
743
+ // Here we assume that if we have a websocket, we are connected. We
744
+ // handle disconnections in the send/recv code paths.
745
+ return;
746
+ }
747
+ this._websocket = await websocketConnect(this.baseUrl, {
748
+ additionalHeaders: {
749
+ Authorization: `Bearer ${this.apiKey}`,
750
+ },
751
+ });
752
+ this._receiveTask = this.createTask(() => this._receiveTaskHandler(), "_receive_task_handler");
753
+ }
754
+ catch (e) {
755
+ rethrowIfCancelled(e);
756
+ await this.pushError(`Error connecting: ${errorMessage(e)}`, { exception: e });
757
+ this._websocket = null;
758
+ }
759
+ }
760
+ async _disconnect() {
761
+ try {
762
+ this._disconnecting = true;
763
+ this._apiSessionReady = false;
764
+ await this.stopAllMetrics();
765
+ if (this._websocket) {
766
+ await this._websocket.close();
767
+ this._websocket = null;
768
+ }
769
+ if (this._receiveTask) {
770
+ await this.cancelTask(this._receiveTask, 1.0);
771
+ this._receiveTask = null;
772
+ }
773
+ this._completedToolCalls = new Set();
774
+ // A new session knows none of the old call ids, and no response is
775
+ // running in it.
776
+ this._openFunctionCalls = new Set();
777
+ this._responseActive = false;
778
+ this._responseCreateOwed = false;
779
+ this._disconnecting = false;
780
+ }
781
+ catch (e) {
782
+ rethrowIfCancelled(e);
783
+ await this.pushError(`Error disconnecting: ${errorMessage(e)}`, { exception: e });
784
+ }
785
+ }
786
+ async _wsSend(realtimeMessage) {
787
+ try {
788
+ if (!this._disconnecting && this._websocket) {
789
+ await this._websocket.send(pyJsonDumps(realtimeMessage));
790
+ }
791
+ }
792
+ catch (e) {
793
+ rethrowIfCancelled(e);
794
+ if (this._disconnecting || !this._websocket) {
795
+ // We're in the process of disconnecting.
796
+ // (If not this._websocket, that could indicate that we
797
+ // somehow *started* the websocket send attempt while we still
798
+ // had a connection)
799
+ return;
800
+ }
801
+ // In server-to-server contexts, a WebSocket error should be quite rare.
802
+ // Given how hard it is to recover from a send-side error with proper
803
+ // state management, and that exponential backoff for retries can have
804
+ // cost/stability implications for a service cluster, let's just treat
805
+ // a send-side error as fatal.
806
+ await this.pushError(`Error sending client event: ${errorMessage(e)}`, { exception: e });
807
+ }
808
+ }
809
+ /** Apply a settings delta, sending a session update when needed. */
810
+ async _updateSettings(delta) {
811
+ const changed = await super._updateSettings(delta);
812
+ const promptOmitted = this.constructor._omitUnsupportedInputAudioTranscriptionPrompt(assertGiven(this._settings.sessionProperties));
813
+ const handled = new Set(["sessionProperties", "systemInstruction"]);
814
+ const changedKeys = Object.keys(changed);
815
+ const handledSettingsChanged = changedKeys.some((key) => handled.has(key));
816
+ if (handledSettingsChanged || promptOmitted) {
817
+ await this._sendSessionUpdate();
818
+ }
819
+ this._warnUnhandledUpdatedSettings(changedKeys.filter((key) => !handled.has(key)));
820
+ return changed;
821
+ }
822
+ /**
823
+ * Drop `reasoning` from an outgoing session.update if the model can't use it.
824
+ *
825
+ * The server otherwise rejects the whole update and kills the session.
826
+ * Returns a copy when stripping; the user's stored config is preserved.
827
+ */
828
+ _stripUnsupportedReasoning(settings) {
829
+ if (settings.reasoning === null || settings.reasoning === undefined || !settings.model) {
830
+ return settings;
831
+ }
832
+ const model = settings.model;
833
+ const substrings = this.constructor
834
+ ._REASONING_CAPABLE_MODEL_SUBSTRINGS;
835
+ if (substrings.some((s) => model.includes(s))) {
836
+ return settings;
837
+ }
838
+ if (!this._reasoningStripWarned) {
839
+ logger.warning(`${this} stripping \`reasoning\` from session.update: model=${pyRepr(model)} ` +
840
+ "isn't a known reasoning-capable Realtime model.");
841
+ this._reasoningStripWarned = true;
842
+ }
843
+ return settings.modelCopy({ update: { reasoning: null } });
844
+ }
845
+ async _sendSessionUpdate() {
846
+ // Mutate a copy: the stored session_properties is read elsewhere (e.g.
847
+ // _serviceTools) and must stay intact.
848
+ const settings = assertGiven(this._settings.sessionProperties).modelCopy();
849
+ const adapter = this.getLlmAdapter();
850
+ if (this._context) {
851
+ const llmInvocationParams = await adapter.getLlmInvocationParams(this._context, {
852
+ systemInstruction: assertGiven(this._settings.systemInstruction),
853
+ serviceTools: settings.tools,
854
+ });
855
+ // The adapter resolves conflicts between init-provided and
856
+ // context-provided system instructions (preferring init-provided).
857
+ if (llmInvocationParams.system_instruction) {
858
+ settings.instructions = llmInvocationParams.system_instruction;
859
+ }
860
+ // The adapter settles the tools too: the context's own when it has
861
+ // any, else the init-provided ones; built-in tools ride along either way.
862
+ settings.tools = llmInvocationParams.tools;
863
+ }
864
+ else {
865
+ // No context yet: the init-provided tools; built-in tools ride along.
866
+ settings.tools = adapter.fromStandardTools(settings.tools);
867
+ }
868
+ const outgoing = this._stripUnsupportedReasoning(settings);
869
+ await this.sendClientEvent(new events.SessionUpdateEvent({ session: outgoing }));
870
+ }
871
+ //
872
+ // inbound server event handling
873
+ // https://platform.openai.com/docs/api-reference/realtime-server-events
874
+ //
875
+ async _receiveTaskHandler() {
876
+ if (this._websocket === null) {
877
+ throw new Error("AssertionError: _websocket is None");
878
+ }
879
+ for await (const message of this._websocket) {
880
+ const evt = events.parseServerEvent(typeof message === "string" ? message : new TextDecoder().decode(message));
881
+ if (evt.type === "session.created") {
882
+ await this._handleEvtSessionCreated(evt);
883
+ }
884
+ else if (evt.type === "session.updated") {
885
+ await this._handleEvtSessionUpdated(evt);
886
+ }
887
+ else if (evt.type === "response.created") {
888
+ await this._handleEvtResponseCreated(evt);
889
+ }
890
+ else if (evt.type === "response.output_audio.delta") {
891
+ await this._handleEvtAudioDelta(evt);
892
+ }
893
+ else if (evt.type === "conversation.item.added") {
894
+ await this._handleEvtConversationItemAdded(evt);
895
+ }
896
+ else if (evt.type === "conversation.item.done") {
897
+ await this._handleEvtConversationItemDone(evt);
898
+ }
899
+ else if (evt.type === "conversation.item.input_audio_transcription.delta") {
900
+ await this._handleEvtInputAudioTranscriptionDelta(evt);
901
+ }
902
+ else if (evt.type === "conversation.item.input_audio_transcription.completed") {
903
+ await this.handleEvtInputAudioTranscriptionCompleted(evt);
904
+ }
905
+ else if (evt.type === "conversation.item.retrieved") {
906
+ await this._handleConversationItemRetrieved(evt);
907
+ }
908
+ else if (evt.type === "response.done") {
909
+ await this._handleEvtResponseDone(evt);
910
+ }
911
+ else if (evt.type === "input_audio_buffer.speech_started") {
912
+ await this._handleEvtSpeechStarted(evt);
913
+ }
914
+ else if (evt.type === "input_audio_buffer.speech_stopped") {
915
+ await this._handleEvtSpeechStopped(evt);
916
+ }
917
+ else if (evt.type === "response.output_text.delta") {
918
+ await this._handleEvtTextDelta(evt);
919
+ }
920
+ else if (evt.type === "response.output_audio_transcript.delta") {
921
+ await this._handleEvtAudioTranscriptDelta(evt);
922
+ }
923
+ else if (evt.type === "response.function_call_arguments.done") {
924
+ await this._handleEvtFunctionCallArgumentsDone(evt);
925
+ }
926
+ else if (evt.type === "error") {
927
+ if (!(await this._maybeHandleEvtRetrieveConversationItemError(evt))) {
928
+ if (evt.error.code === "response_cancel_not_active" ||
929
+ evt.error.code === "conversation_already_has_active_response") {
930
+ logger.debug(`${this} ${evt.error.message}`);
931
+ }
932
+ else {
933
+ await this._handleEvtError(evt);
934
+ // errors are fatal, so exit the receive loop
935
+ return;
936
+ }
937
+ }
938
+ }
939
+ }
940
+ }
941
+ // Traced with tracedOpenaiRealtime("llm_setup") (see the static block).
942
+ async _handleEvtSessionCreated(_evt) {
943
+ // session.created is received right after connecting. Send a message
944
+ // to configure the session properties.
945
+ await this._sendSessionUpdate();
946
+ }
947
+ async _handleEvtSessionUpdated(_evt) {
948
+ // If this is our first context frame, run the LLM
949
+ this._apiSessionReady = true;
950
+ // Now that we've configured the session, we can run the LLM if we need to.
951
+ if (this._runLlmWhenApiSessionReady) {
952
+ this._runLlmWhenApiSessionReady = false;
953
+ await this._createResponse();
954
+ }
955
+ }
956
+ async _handleEvtAudioDelta(evt) {
957
+ // note: ttfb is faster by 1/2 RTT than ttfb as measured for other
958
+ // services, since we're getting this event from the server
959
+ await this.stopTtfbMetrics();
960
+ if (!this._currentAudioResponse) {
961
+ // First delta of a new assistant turn.
962
+ this._currentAudioResponse = new CurrentAudioResponse({
963
+ itemId: evt.itemId,
964
+ contentIndex: evt.contentIndex,
965
+ startTimeMs: Math.trunc(time() * 1000),
966
+ responseId: evt.responseId,
967
+ });
968
+ await this.pushFrame(new TTSStartedFrame());
969
+ }
970
+ else if (this._currentAudioResponse.itemId !== evt.itemId) {
971
+ // A response can contain multiple output items (observed with
972
+ // gpt-realtime-2 on long replies). Deltas arrive in playback order
973
+ // across items, so we treat them as one continuous TTS turn:
974
+ // advance the tracked item in place without emitting another
975
+ // TTSStartedFrame. Reset totalSize so truncation math reflects the
976
+ // current item only (last-item-wins, matching openai-agents-python).
977
+ logger.debug(`${this} advancing to next audio output item ` +
978
+ `(prev item_id=${this._currentAudioResponse.itemId} -> ` +
979
+ `new item_id=${evt.itemId} response_id=${evt.responseId} ` +
980
+ `output_index=${evt.outputIndex} content_index=${evt.contentIndex})`);
981
+ this._currentAudioResponse.itemId = evt.itemId;
982
+ this._currentAudioResponse.contentIndex = evt.contentIndex;
983
+ this._currentAudioResponse.responseId = evt.responseId;
984
+ this._currentAudioResponse.startTimeMs = Math.trunc(time() * 1000);
985
+ this._currentAudioResponse.totalSize = 0;
986
+ }
987
+ const audio = base64ToBytes(evt.delta);
988
+ this._currentAudioResponse.totalSize += audio.length;
989
+ const frame = new TTSAudioRawFrame({
990
+ audio,
991
+ sampleRate: OPENAI_SAMPLE_RATE,
992
+ numChannels: 1,
993
+ });
994
+ await this.pushFrame(frame);
995
+ }
996
+ /** Handle conversation.item.added event - item is added but may still be processing. */
997
+ async _handleEvtConversationItemAdded(evt) {
998
+ if (evt.item.type === "function_call") {
999
+ // Track this function call for when arguments are completed
1000
+ // Only add if not already tracked (prevent duplicates)
1001
+ const callId = evt.item.callId;
1002
+ if (!this._pendingFunctionCalls.has(callId)) {
1003
+ this._pendingFunctionCalls.set(callId, evt.item);
1004
+ }
1005
+ else {
1006
+ logger.warning(`Function call ${callId} already tracked, skipping`);
1007
+ }
1008
+ }
1009
+ await this._callEventHandler("on_conversation_item_created", evt.item.id, evt.item);
1010
+ // This will get sent from the server every time a new "message" is added
1011
+ // to the server's conversation state, whether we create it via the API
1012
+ // or the server creates it from LLM output.
1013
+ if (this._messagesAddedManually.get(evt.item.id)) {
1014
+ this._messagesAddedManually.delete(evt.item.id);
1015
+ return;
1016
+ }
1017
+ if (evt.item.role === "assistant") {
1018
+ this._currentAssistantResponse = evt.item;
1019
+ await this.pushFrame(new LLMFullResponseStartFrame());
1020
+ }
1021
+ }
1022
+ /** Handle conversation.item.done event - item is fully completed. */
1023
+ async _handleEvtConversationItemDone(evt) {
1024
+ await this._callEventHandler("on_conversation_item_updated", evt.item.id, evt.item);
1025
+ // The item is now fully processed and ready
1026
+ // For now, no additional logic needed beyond the event handler call
1027
+ }
1028
+ async _handleEvtInputAudioTranscriptionDelta(evt) {
1029
+ await this.pushFrame(
1030
+ // no way to get a language code?
1031
+ new InterimTranscriptionFrame({
1032
+ text: evt.delta,
1033
+ userId: "",
1034
+ timestamp: timeNowIso8601(),
1035
+ result: evt,
1036
+ }), FrameDirection.UPSTREAM);
1037
+ }
1038
+ /** Handle a transcription result with tracing. */
1039
+ async _handleUserTranscription(_transcript, _isFinal, _language = null) { }
1040
+ /**
1041
+ * Handle completion of input audio transcription.
1042
+ *
1043
+ * @param evt The transcription completed event.
1044
+ */
1045
+ async handleEvtInputAudioTranscriptionCompleted(evt) {
1046
+ await this._callEventHandler("on_conversation_item_updated", evt.itemId, null);
1047
+ await this.pushFrame(
1048
+ // no way to get a language code?
1049
+ new TranscriptionFrame({
1050
+ text: evt.transcript,
1051
+ userId: "",
1052
+ timestamp: timeNowIso8601(),
1053
+ result: evt,
1054
+ }), FrameDirection.UPSTREAM);
1055
+ await this._handleUserTranscription(evt.transcript, true, Language.EN);
1056
+ }
1057
+ async _handleConversationItemRetrieved(evt) {
1058
+ const futures = this._retrieveConversationItemFutures.get(evt.item.id);
1059
+ this._retrieveConversationItemFutures.delete(evt.item.id);
1060
+ if (futures && futures.length > 0) {
1061
+ for (const future of futures) {
1062
+ future.setResult(evt.item);
1063
+ }
1064
+ }
1065
+ }
1066
+ /** Note the response, which takes in everything delivered before it. */
1067
+ async _handleEvtResponseCreated(_evt) {
1068
+ this._responseActive = true;
1069
+ this._resultsAwaitingResponse = false;
1070
+ }
1071
+ // Traced with tracedOpenaiRealtime("llm_response") (see the static block).
1072
+ async _handleEvtResponseDone(evt) {
1073
+ // todo: figure out whether there's anything we need to do for "cancelled" events
1074
+ // usage metrics
1075
+ const usage = evt.response.usage;
1076
+ const inputDetails = usage?.inputTokenDetails ?? null;
1077
+ const outputDetails = usage?.outputTokenDetails ?? null;
1078
+ const cachedDetails = inputDetails ? (inputDetails.cachedTokensDetails ?? null) : null;
1079
+ const tokens = new LLMTokenUsage({
1080
+ promptTokens: usage.inputTokens,
1081
+ completionTokens: usage.outputTokens,
1082
+ totalTokens: usage.totalTokens,
1083
+ cacheReadInputTokens: inputDetails ? inputDetails.cachedTokens : null,
1084
+ inputAudioTokens: inputDetails ? inputDetails.audioTokens : null,
1085
+ outputAudioTokens: outputDetails ? outputDetails.audioTokens : null,
1086
+ cacheReadInputAudioTokens: cachedDetails ? cachedDetails.audioTokens : null,
1087
+ });
1088
+ await this.startLlmUsageMetrics(tokens);
1089
+ await this.stopProcessingMetrics();
1090
+ // Push TTSStoppedFrame here (rather than on each per-item
1091
+ // response.output_audio.done) so that a response containing multiple
1092
+ // output items still emits exactly one bracketing TTSStartedFrame /
1093
+ // TTSStoppedFrame pair around the audio. In practice gpt-realtime-2's
1094
+ // audio.done events arrive batched within a few milliseconds of
1095
+ // response.done, so this is effectively coincident with end-of-audio.
1096
+ // The strictly-sequential variant (audio.done A before item B begins)
1097
+ // is theoretical — we haven't observed it — but this placement handles
1098
+ // it too: per-item gating would otherwise emit Stopped/Started/Stopped.
1099
+ if (this._currentAudioResponse !== null) {
1100
+ await this.pushFrame(new TTSStoppedFrame());
1101
+ }
1102
+ await this.pushFrame(new LLMFullResponseEndFrame());
1103
+ this._currentAssistantResponse = null;
1104
+ this._responseActive = false;
1105
+ // A response asked for while this one was running is owed now, unless
1106
+ // this one took in what it was for.
1107
+ if (this._responseCreateOwed) {
1108
+ this._responseCreateOwed = false;
1109
+ if (this._resultsAwaitingResponse) {
1110
+ await this._createResponse();
1111
+ }
1112
+ }
1113
+ // error handling
1114
+ if (evt.response.status === "failed") {
1115
+ const statusDetails = evt.response.statusDetails;
1116
+ await this.pushError(statusDetails.error.message);
1117
+ return;
1118
+ }
1119
+ // response content
1120
+ for (const item of evt.response.output) {
1121
+ await this._callEventHandler("on_conversation_item_updated", item.id, item);
1122
+ }
1123
+ }
1124
+ async _handleEvtTextDelta(evt) {
1125
+ // We receive text deltas (as opposed to audio transcript deltas) when
1126
+ // the output modality is "text"
1127
+ if (evt.delta) {
1128
+ const frame = new LLMTextFrame({ text: evt.delta });
1129
+ await this.pushFrame(frame);
1130
+ }
1131
+ }
1132
+ async _handleEvtAudioTranscriptDelta(evt) {
1133
+ // We receive audio transcript deltas (as opposed to text deltas) when
1134
+ // the output modality is "audio" (the default)
1135
+ if (evt.delta) {
1136
+ await this._pushOutputTranscriptTextFrames(evt.delta);
1137
+ }
1138
+ }
1139
+ async _pushOutputTranscriptTextFrames(text) {
1140
+ // In a typical "cascade" LLM + TTS setup, LLMTextFrames would not
1141
+ // proceed beyond the TTS service. Therefore, since a speech-to-speech
1142
+ // service like OpenAI Realtime combines both LLM and TTS functionality,
1143
+ // you might think we wouldn't need to push LLMTextFrames at all.
1144
+ // However, RTVI relies on LLMTextFrames being pushed to trigger its
1145
+ // "bot-llm-text" event. So here we push an LLMTextFrame, too, but avoid
1146
+ // appending it to context to avoid context message duplication.
1147
+ // Push LLMTextFrame
1148
+ const llmTextFrame = new LLMTextFrame({ text });
1149
+ llmTextFrame.appendToContext = false;
1150
+ await this.pushFrame(llmTextFrame);
1151
+ // Push TTSTextFrame
1152
+ const ttsTextFrame = new TTSTextFrame({ text, textType: TextType.SENTENCE });
1153
+ ttsTextFrame.includesInterFrameSpaces = true;
1154
+ await this.pushFrame(ttsTextFrame);
1155
+ }
1156
+ /**
1157
+ * Handle completion of function call arguments.
1158
+ *
1159
+ * @param evt The response.function_call_arguments.done event.
1160
+ */
1161
+ async _handleEvtFunctionCallArgumentsDone(evt) {
1162
+ // Process the function call immediately when arguments are complete
1163
+ // This is needed because function calls might not trigger response.done
1164
+ try {
1165
+ // Parse the arguments
1166
+ const args = pyJsonLoads(evt.arguments);
1167
+ // Get the function call item we tracked earlier
1168
+ const functionCallItem = this._pendingFunctionCalls.get(evt.callId);
1169
+ if (functionCallItem) {
1170
+ // Remove from pending calls FIRST to prevent duplicate processing
1171
+ this._pendingFunctionCalls.delete(evt.callId);
1172
+ // The API is waiting on this call now, so its id can be answered
1173
+ // with a function_call_output.
1174
+ this._openFunctionCalls.add(evt.callId);
1175
+ // Create the function call and process it
1176
+ const functionCalls = [
1177
+ new FunctionCallFromLLM({
1178
+ context: this._context,
1179
+ toolCallId: evt.callId,
1180
+ functionName: functionCallItem.name,
1181
+ arguments: args,
1182
+ }),
1183
+ ];
1184
+ await this.runFunctionCalls(functionCalls);
1185
+ logger.debug(`Processed function call: ${functionCallItem.name}`);
1186
+ }
1187
+ else {
1188
+ logger.warning(`No tracked function call found for call_id: ${evt.callId}`);
1189
+ logger.warning(`Available pending calls: [${[...this._pendingFunctionCalls.keys()]
1190
+ .map((k) => pyRepr(k))
1191
+ .join(", ")}]`);
1192
+ }
1193
+ }
1194
+ catch (e) {
1195
+ rethrowIfCancelled(e);
1196
+ logger.error(`Failed to process function call arguments: ${errorMessage(e)}`);
1197
+ }
1198
+ }
1199
+ async _handleEvtSpeechStarted(_evt) {
1200
+ // Note: this event is not received when turn detection is disabled,
1201
+ // which is good: in that case, local turn detection is responsible for
1202
+ // this work.
1203
+ this._responseCreateOwed = false;
1204
+ await this._truncateCurrentAudioResponse();
1205
+ await this.broadcastFrame(ProposedUserStartedSpeakingFrame);
1206
+ }
1207
+ async _handleEvtSpeechStopped(_evt) {
1208
+ // Note: this event is not received when turn detection is disabled,
1209
+ // which is good: in that case, local turn detection is responsible for
1210
+ // this work.
1211
+ await this.startTtfbMetrics();
1212
+ await this.startProcessingMetrics();
1213
+ await this.broadcastFrame(ProposedUserStoppedSpeakingFrame);
1214
+ }
1215
+ /**
1216
+ * Maybe handle an error event related to retrieving a conversation item.
1217
+ *
1218
+ * If the given error event is an error retrieving a conversation item:
1219
+ *
1220
+ * - set an exception on the future that `retrieveConversationItem()` is
1221
+ * waiting on
1222
+ * - return true
1223
+ *
1224
+ * Otherwise:
1225
+ *
1226
+ * - return false
1227
+ */
1228
+ async _maybeHandleEvtRetrieveConversationItemError(evt) {
1229
+ if (evt.error.code === "item_retrieve_invalid_item_id") {
1230
+ const eventId = evt.error.eventId;
1231
+ if (eventId === null || eventId === undefined) {
1232
+ throw new Error("AssertionError: error.event_id is None");
1233
+ }
1234
+ // event_id is of the form "rci_{item_id}"
1235
+ const itemId = eventId.slice(eventId.indexOf("_") + 1);
1236
+ const futures = this._retrieveConversationItemFutures.get(itemId);
1237
+ this._retrieveConversationItemFutures.delete(itemId);
1238
+ if (futures && futures.length > 0) {
1239
+ for (const future of futures) {
1240
+ future.setException(new Error(evt.error.message));
1241
+ }
1242
+ }
1243
+ return true;
1244
+ }
1245
+ return false;
1246
+ }
1247
+ async _handleEvtError(evt) {
1248
+ // Errors are fatal to this connection. Send an ErrorFrame.
1249
+ await this.pushError(`Error: ${JSON.stringify(evt)}`);
1250
+ }
1251
+ //
1252
+ // state and client events for the current conversation
1253
+ // https://platform.openai.com/docs/api-reference/realtime-client-events
1254
+ //
1255
+ /**
1256
+ * Reset the conversation by disconnecting and reconnecting.
1257
+ *
1258
+ * This is the safest way to start a new conversation. Note that this will
1259
+ * fail if called from the receive task.
1260
+ */
1261
+ async resetConversation() {
1262
+ logger.debug("Resetting conversation");
1263
+ await this._disconnect();
1264
+ // Prepare to setup server-side conversation from local context again
1265
+ this._llmNeedsConversationSetup = true;
1266
+ await this._processCompletedFunctionCalls(false);
1267
+ await this._connect();
1268
+ }
1269
+ // Traced with tracedOpenaiRealtime("llm_request") (see the static block).
1270
+ async _createResponse() {
1271
+ if (!this._apiSessionReady) {
1272
+ this._runLlmWhenApiSessionReady = true;
1273
+ return;
1274
+ }
1275
+ if (this._responseActive) {
1276
+ // The API refuses a second response while one is running. Hold this
1277
+ // one for response.done rather than losing it to that error.
1278
+ logger.debug("Response already active — holding the next one until it is done");
1279
+ this._responseCreateOwed = true;
1280
+ return;
1281
+ }
1282
+ if (this._context === null) {
1283
+ throw new Error("AssertionError: _context is None");
1284
+ }
1285
+ const context = this._context;
1286
+ const adapter = this.getLlmAdapter();
1287
+ // Configure the LLM for this session if needed
1288
+ if (this._llmNeedsConversationSetup) {
1289
+ this._logLlmConversationSetup(context);
1290
+ // Send initial messages
1291
+ const llmInvocationParams = await adapter.getLlmInvocationParams(context);
1292
+ const messages = llmInvocationParams.messages;
1293
+ for (const item of messages) {
1294
+ const evt = new events.ConversationItemCreateEvent({ item });
1295
+ this._messagesAddedManually.set(evt.item.id, true);
1296
+ await this.sendClientEvent(evt);
1297
+ }
1298
+ // Send new settings if needed
1299
+ await this._sendSessionUpdate();
1300
+ // We're done configuring the LLM for this session
1301
+ this._llmNeedsConversationSetup = false;
1302
+ }
1303
+ logger.debug("Creating response");
1304
+ // Active from the moment the create is sent, not from response.created: a
1305
+ // second create in between would be refused by the API.
1306
+ this._responseActive = true;
1307
+ await this.pushFrame(new LLMFullResponseStartFrame());
1308
+ await this.startProcessingMetrics();
1309
+ await this.startTtfbMetrics();
1310
+ await this.sendClientEvent(new events.ResponseCreateEvent({
1311
+ response: new events.ResponseProperties({
1312
+ outputModalities: this._getEnabledModalities(),
1313
+ }),
1314
+ }));
1315
+ }
1316
+ /**
1317
+ * Send any tool result the context holds that hasn't reached the API yet.
1318
+ *
1319
+ * Results this service produced are already sent, as they are produced (see
1320
+ * `pushFrame`), so what this finds is a result written into the context
1321
+ * some other way, plus, with `sendNewResults` false, the results a context
1322
+ * brings with it at the start of a session.
1323
+ */
1324
+ async _processCompletedFunctionCalls(sendNewResults) {
1325
+ if (this._context === null) {
1326
+ throw new Error("AssertionError: _context is None");
1327
+ }
1328
+ for (const message of this._context.getMessages()) {
1329
+ // LLMSpecificMessages are opaque provider-specific payloads, not
1330
+ // standard tool-result messages — skip them.
1331
+ if (message instanceof LLMSpecificMessage) {
1332
+ continue;
1333
+ }
1334
+ // Async-tool messages live alongside regular tool messages in the
1335
+ // context; detect and route them before the regular logic so we
1336
+ // don't try to send the async-tool envelope JSON as a tool result.
1337
+ const asyncPayload = asyncToolMessages.parseMessage(message);
1338
+ if (asyncPayload !== null) {
1339
+ if (this._completedToolCalls.has(asyncPayload.toolCallId)) {
1340
+ continue;
1341
+ }
1342
+ if (asyncPayload.kind === "started") {
1343
+ // The provider already issued the tool call and natively
1344
+ // awaits a result; nothing to send for the started marker.
1345
+ continue;
1346
+ }
1347
+ if (asyncPayload.kind === "intermediate") {
1348
+ // Sent as a message when it was produced; the call stays
1349
+ // open for the final result.
1350
+ continue;
1351
+ }
1352
+ if (asyncPayload.kind === "final") {
1353
+ // Deliver via the formal tool-result channel — same path
1354
+ // as a synchronous tool result, just delayed.
1355
+ if (sendNewResults) {
1356
+ this._resultsAwaitingResponse = true;
1357
+ await this._sendToolResult(asyncPayload.toolCallId, asyncPayload.result);
1358
+ }
1359
+ this._completedToolCalls.add(asyncPayload.toolCallId);
1360
+ continue;
1361
+ }
1362
+ // Defensive: any async-tool message must not fall through
1363
+ // to the regular tool-result block below, even if it
1364
+ // carries a kind we don't recognize.
1365
+ continue;
1366
+ }
1367
+ // Look for newly-completed "regular" (as opposed to async-tool) results
1368
+ const msg = message;
1369
+ if (pyTruthy(msg.role) && msg.content !== "IN_PROGRESS") {
1370
+ const toolCallId = msg.tool_call_id;
1371
+ if (toolCallId && !this._completedToolCalls.has(toolCallId)) {
1372
+ // Found a newly-completed function call - send the result to the service
1373
+ if (sendNewResults) {
1374
+ this._resultsAwaitingResponse = true;
1375
+ await this._sendToolResult(toolCallId, msg.content ?? null);
1376
+ }
1377
+ this._completedToolCalls.add(toolCallId);
1378
+ }
1379
+ }
1380
+ }
1381
+ }
1382
+ /**
1383
+ * Auto-size the pre-roll from the upstream VAD's startSecs.
1384
+ *
1385
+ * `VADController` broadcasts the current `VADParams` at pipeline start (and
1386
+ * on any runtime change), so the pre-roll tracks `startSecs` — the leading
1387
+ * speech local turn detection consumes before confirming the turn — plus a
1388
+ * margin.
1389
+ *
1390
+ * This assumes VAD drives turn starts (the default
1391
+ * `VADUserTurnStartStrategy`). With a non-VAD turn-start strategy the
1392
+ * onset-to-turn-start gap isn't governed by `startSecs`; in that case the
1393
+ * developer should pin a pre-roll duration via `userAudioPrerollSecs`
1394
+ * (which short-circuits this).
1395
+ *
1396
+ * This mechanism is harmless when server-side turn detection is enabled
1397
+ * (the buffer is simply unused).
1398
+ */
1399
+ _handleSpeechControlParams(frame) {
1400
+ if (this._userAudioPrerollSecsOverride !== null) {
1401
+ return;
1402
+ }
1403
+ if (frame.vadParams !== null) {
1404
+ this._userAudioPrerollSecs =
1405
+ frame.vadParams.startSecs + AUTOSIZED_USER_AUDIO_PREROLL_MARGIN_SECS;
1406
+ }
1407
+ }
1408
+ async _sendUserAudio(frame) {
1409
+ // The session's input format is PCM at a fixed 24 kHz.
1410
+ let audio = frame.audio;
1411
+ if (frame.sampleRate !== OPENAI_SAMPLE_RATE) {
1412
+ audio = await this._resampler.resample(audio, frame.sampleRate, OPENAI_SAMPLE_RATE);
1413
+ }
1414
+ if (audio.length === 0) {
1415
+ return;
1416
+ }
1417
+ const payload = bytesToBase64(audio);
1418
+ await this.sendClientEvent(new events.InputAudioBufferAppendEvent({ audio: payload }));
1419
+ // In manual turn-detection mode, keep a rolling pre-roll buffer of the
1420
+ // most recent audio so _handleInterruption can replay the speech onset
1421
+ // after clearing the input buffer. (Server-side turn detection never
1422
+ // clears it, so no buffer is needed.)
1423
+ if (this._isTurnDetectionDisabled()) {
1424
+ const buffer = new Uint8Array(this._userAudioPrerollBuffer.length + audio.length);
1425
+ buffer.set(this._userAudioPrerollBuffer, 0);
1426
+ buffer.set(audio, this._userAudioPrerollBuffer.length);
1427
+ const prerollLen = Math.trunc(OPENAI_SAMPLE_RATE * frame.numChannels * 2 * this._userAudioPrerollSecs);
1428
+ // Python's `buffer[-0:]` keeps the whole buffer.
1429
+ this._userAudioPrerollBuffer =
1430
+ prerollLen > 0 && prerollLen < buffer.length
1431
+ ? buffer.slice(buffer.length - prerollLen)
1432
+ : buffer;
1433
+ }
1434
+ }
1435
+ /**
1436
+ * Re-append the buffered pre-roll audio to the input buffer.
1437
+ *
1438
+ * Called right after `InputAudioBufferClearEvent` so the speech onset
1439
+ * (appended before local turn detection fired the interruption) survives
1440
+ * the clear. The buffer is left intact — it's a rolling window, so a later
1441
+ * interruption can restore the onset again.
1442
+ */
1443
+ async _replayUserAudioPreroll() {
1444
+ if (this._userAudioPrerollBuffer.length === 0) {
1445
+ return;
1446
+ }
1447
+ const payload = bytesToBase64(this._userAudioPrerollBuffer);
1448
+ await this.sendClientEvent(new events.InputAudioBufferAppendEvent({ audio: payload }));
1449
+ }
1450
+ /**
1451
+ * Send user video frame to OpenAI Realtime API.
1452
+ *
1453
+ * @param frame The InputImageRawFrame to send.
1454
+ */
1455
+ async _sendUserVideo(frame) {
1456
+ if (this._videoInputPaused || this._disconnecting || !this._websocket) {
1457
+ return;
1458
+ }
1459
+ const now = time();
1460
+ if (now - this._lastSentTime < 1) {
1461
+ return; // Ignore if less than 1 second has passed
1462
+ }
1463
+ this._lastSentTime = now; // Update last sent time
1464
+ logger.trace(`Sending video frame to OpenAI Realtime: ${frame}`);
1465
+ // Convert video frame to JPEG format and encode as base64
1466
+ if (frame.format === null) {
1467
+ throw new Error("AssertionError: frame.format is None");
1468
+ }
1469
+ const [encoded, mimeType] = await encodeImage(frame.image, frame.size, frame.format);
1470
+ const data = bytesToBase64(encoded);
1471
+ // Create data URI for the video frame
1472
+ const dataUri = `data:${mimeType};base64,${data}`;
1473
+ // Create a conversation item with the video frame
1474
+ const item = new events.ConversationItem({
1475
+ type: "message",
1476
+ role: "user",
1477
+ content: [
1478
+ new events.ItemContent({
1479
+ type: "input_image",
1480
+ imageUrl: dataUri,
1481
+ detail: this._videoFrameDetail,
1482
+ }),
1483
+ ],
1484
+ });
1485
+ // Send the conversation item
1486
+ try {
1487
+ await this.sendClientEvent(new events.ConversationItemCreateEvent({ item }));
1488
+ }
1489
+ catch (e) {
1490
+ rethrowIfCancelled(e);
1491
+ await this.pushError(`Send error: ${errorMessage(e)}`);
1492
+ }
1493
+ }
1494
+ async _sendToolResult(toolCallId, result) {
1495
+ logger.debug(`Sending tool result to OpenAI Realtime for tool_call_id=${toolCallId}`);
1496
+ this._openFunctionCalls.delete(toolCallId);
1497
+ const item = new events.ConversationItem({
1498
+ type: "function_call_output",
1499
+ callId: toolCallId,
1500
+ output: result,
1501
+ });
1502
+ await this.sendClientEvent(new events.ConversationItemCreateEvent({ item }));
1503
+ }
1504
+ /** Put text into the conversation for the model to take in, without answering it. */
1505
+ async _sendMessageItem(text, role = "system") {
1506
+ const item = new events.ConversationItem({
1507
+ type: "message",
1508
+ role: role,
1509
+ content: [new events.ItemContent({ type: "input_text", text })],
1510
+ });
1511
+ await this.sendClientEvent(new events.ConversationItemCreateEvent({ item }));
1512
+ }
1513
+ /**
1514
+ * Send one function call result to the API as it is produced.
1515
+ *
1516
+ * An intermediate result can't go through the tool-result channel — the
1517
+ * API takes one output per call — so it goes in as a message carrying the
1518
+ * same envelope a text LLM would read from the context. A final result
1519
+ * answers the call, unless a reconnect has left the API without the id to
1520
+ * answer, in which case it goes in as a message too.
1521
+ */
1522
+ async _handleFunctionCallResult(frame) {
1523
+ const result = pyTruthy(frame.result)
1524
+ ? pyJsonDumps(frame.result, { ensureAscii: false })
1525
+ : "COMPLETED";
1526
+ const isFinal = frame.properties ? frame.properties.isFinal : true;
1527
+ if (!isFinal) {
1528
+ const message = asyncToolMessages.buildIntermediateResultMessage(frame.toolCallId, result);
1529
+ await this._sendMessageItem(message.content ?? "");
1530
+ }
1531
+ else if (this._openFunctionCalls.has(frame.toolCallId)) {
1532
+ await this._sendToolResult(frame.toolCallId, result);
1533
+ }
1534
+ else {
1535
+ const message = asyncToolMessages.buildFinalResultMessage(frame.toolCallId, result);
1536
+ await this._sendMessageItem(message.content ?? "");
1537
+ }
1538
+ this._completedToolCalls.add(frame.toolCallId);
1539
+ this._resultsAwaitingResponse = true;
1540
+ }
1541
+ /** Settle a cancelled call, so the API isn't left waiting on it. */
1542
+ async _handleFunctionCallCancel(frame) {
1543
+ if (!this._openFunctionCalls.has(frame.toolCallId)) {
1544
+ return;
1545
+ }
1546
+ await this._sendToolResult(frame.toolCallId, "CANCELLED");
1547
+ this._completedToolCalls.add(frame.toolCallId);
1548
+ }
1549
+ }
1550
+ //# sourceMappingURL=llm.js.map