@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,1652 @@
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/live/llm.py
6
+ /**
7
+ * OpenAI Live LLM service: full-duplex speech-to-speech over WebSocket.
8
+ *
9
+ * Porting notes:
10
+ * - The WebSocket is opened with `websocketConnect` from core, which sends
11
+ * the `Authorization` header on Bun, Node and Deno. Runtimes whose
12
+ * `WebSocket` can't send headers (Cloudflare Workers) pass a
13
+ * `webSocketFactory` (an addition to the upstream options).
14
+ * - `parse_server_event` raises only `ValueError`, which the receive loop
15
+ * skips; here any (non-cancellation) error from `parseServerEvent` is
16
+ * treated as that.
17
+ * - `OpenAIResponsesReasoningConfig` is sent as its set fields (`effort`,
18
+ * `summary`, `mode`), which is what upstream's `model_dump(exclude_none=True)`
19
+ * produces.
20
+ */
21
+ import { CancelledError, currentTask, Event, Lock, rethrowIfCancelled, sleep, TimeoutError, timeout, waitFor, } from "@pipedog/asyncio";
22
+ import { OPENAI_NOT_GIVEN } from "@pipedog/core/adapters/services/open_ai_adapter";
23
+ import { createStreamResampler } from "@pipedog/core/audio/utils";
24
+ import { FunctionCallCancelFrame, FunctionCallFromLLM, FunctionCallResultFrame, InputAudioRawFrame, InterimTranscriptionFrame, LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMServiceMetadataFrame, LLMSetToolsFrame, LLMTextFrame, ProposedUserStartedSpeakingFrame, ProposedUserStoppedSpeakingFrame, SpeechOutputAudioRawFrame, TextType, TranscriptionFrame, TTSStartedFrame, TTSStoppedFrame, TTSTextFrame, } from "@pipedog/core/frames/frames";
25
+ import { LLMTokenUsage } from "@pipedog/core/metrics/metrics";
26
+ import { FrameDirection } from "@pipedog/core/processors/frame_processor";
27
+ import { LLMService, } from "@pipedog/core/services/llm_service";
28
+ import { LLMSettings } from "@pipedog/core/services/settings";
29
+ import { ConnectionClosed, websocketConnect, } from "@pipedog/core/services/websocket_service";
30
+ import { ExternalUserTurnStrategies } from "@pipedog/core/turns/user_turn_strategies";
31
+ import { base64ToBytes, bytesToBase64 } from "@pipedog/core/utils/base64";
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 { assertGiven, isGiven, NOT_GIVEN } from "@pipedog/core/utils/types";
36
+ import { _BackendSession, _renderTranscriptRequest, BackendError, BackendIdle, BackendLLMWorker, BackendOutput, BackendToolCall, } from "@pipedog/core/workers/llm/backend_llm_worker";
37
+ import { OPENAI_SAMPLE_RATE } from "../_constants.js";
38
+ import { OpenAILiveLLMAdapter, } from "../open_ai_live_adapter.js";
39
+ import { OpenAIResponsesReasoningConfig, } from "../responses/llm.js";
40
+ import * as events from "./events.js";
41
+ export const DEFAULT_MODEL = "gpt-live-1";
42
+ // Quiet time that ends a speaker's turn. The API emits transcript fragments on
43
+ // 200 ms frame boundaries, so this has to clear ordinary gaps within speech.
44
+ export const TURN_GAP_SECS = 0.8;
45
+ // The server drains delegation and output work for at most 10 seconds after
46
+ // `session.close` before emitting `session.closed`.
47
+ export const SESSION_CLOSE_TIMEOUT_SECS = 10.0;
48
+ /** How long a delegation waits for the session with the backend to open. */
49
+ export const BACKEND_ATTACH_TIMEOUT_SECS = 5.0;
50
+ // Each context append takes one text part of at most 500 tokens. Chunks are
51
+ // measured against a lower budget, since what counts them is an estimate.
52
+ export const MAX_CONTEXT_APPEND_TOKENS = 450;
53
+ // Correlation key for a wrapped event whose envelope carries no delegation id.
54
+ const _UNCORRELATED_DELEGATION = "uncorrelated";
55
+ function errorMessage(e) {
56
+ // Python's `str(TimeoutError())` is empty; asyncio's timeouts raise it bare.
57
+ if (e instanceof TimeoutError && e.message === "Timed out")
58
+ return "";
59
+ return e instanceof Error ? e.message : String(e);
60
+ }
61
+ /** Python's `repr()` of a JSON-like value, for log lines. */
62
+ function pyRepr(value) {
63
+ if (value === null || value === undefined)
64
+ return "None";
65
+ if (value === true)
66
+ return "True";
67
+ if (value === false)
68
+ return "False";
69
+ if (typeof value === "string")
70
+ return reprStr(value);
71
+ if (Array.isArray(value))
72
+ return `[${value.map(pyRepr).join(", ")}]`;
73
+ if (typeof value === "object") {
74
+ const entries = Object.entries(value);
75
+ return `{${entries.map(([k, v]) => `${reprStr(k)}: ${pyRepr(v)}`).join(", ")}}`;
76
+ }
77
+ return String(value);
78
+ }
79
+ /** A pydantic model's `str()`: its fields as `name=repr(value)`, space-separated. */
80
+ function pydanticStr(model) {
81
+ return Object.entries(model.modelDump())
82
+ .map(([k, v]) => `${k}=${pyRepr(v)}`)
83
+ .join(" ");
84
+ }
85
+ /** Python truthiness, for values from JSON or function results. */
86
+ function pyTruthy(value) {
87
+ if (value === null || value === undefined || value === false)
88
+ return false;
89
+ if (value === 0 || value === "" || Number.isNaN(value))
90
+ return false;
91
+ if (Array.isArray(value))
92
+ return value.length > 0;
93
+ if (value instanceof Map || value instanceof Set)
94
+ return value.size > 0;
95
+ if (typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype) {
96
+ return Object.keys(value).length > 0;
97
+ }
98
+ return true;
99
+ }
100
+ /** Python's `repr()` of a string, for log lines. */
101
+ function reprStr(s) {
102
+ const quote = s.includes("'") && !s.includes('"') ? '"' : "'";
103
+ const body = s
104
+ .replace(/\\/g, "\\\\")
105
+ .replace(/\n/g, "\\n")
106
+ .replace(/\r/g, "\\r")
107
+ .replace(/\t/g, "\\t")
108
+ .replaceAll(quote, `\\${quote}`);
109
+ return `${quote}${body}${quote}`;
110
+ }
111
+ /** A copy of a JSON-like value with object keys sorted (`json.dumps(sort_keys=True)`). */
112
+ function sortKeys(value) {
113
+ if (Array.isArray(value))
114
+ return value.map(sortKeys);
115
+ if (typeof value === "object" && value !== null) {
116
+ const proto = Object.getPrototypeOf(value);
117
+ if (proto === Object.prototype || proto === null) {
118
+ const out = {};
119
+ for (const key of Object.keys(value).sort()) {
120
+ out[key] = sortKeys(value[key]);
121
+ }
122
+ return out;
123
+ }
124
+ }
125
+ return value;
126
+ }
127
+ /**
128
+ * Settings for OpenAILiveLLMService.
129
+ *
130
+ * - `voice`: output voice name (for example `marin` or `cedar`). `null`
131
+ * leaves the choice to the API. Cannot be changed once the session has
132
+ * started.
133
+ * - `store`: whether the API keeps the session, making it available for 30
134
+ * days as a stereo recording from `GET /v1/live/sessions/{id}/content` and
135
+ * as a source a later session can fork from. `false` by default at the API,
136
+ * and requires a project whose data policy permits persistence. Cannot be
137
+ * changed once the session has started.
138
+ */
139
+ export class OpenAILiveLLMSettings extends LLMSettings {
140
+ voice = NOT_GIVEN;
141
+ store = NOT_GIVEN;
142
+ constructor(options = {}) {
143
+ super(options);
144
+ this._assignOptions(options);
145
+ }
146
+ }
147
+ /**
148
+ * One speaker's in-progress turn, assembled from timed transcript fragments.
149
+ *
150
+ * - `gapSecs`: quiet time that ends the turn.
151
+ * - `text`: fragments accumulated so far.
152
+ * - `open`: whether a turn is currently open.
153
+ * - `timer`: the task that closes the turn once `gapSecs` elapses.
154
+ * - `lock`: held while the turn opens or closes. The timer runs in its own
155
+ * task, so this is what keeps one turn's end frames from landing among the
156
+ * next turn's start frames.
157
+ */
158
+ export class _TurnGrouper {
159
+ gapSecs;
160
+ text = "";
161
+ open = false;
162
+ timer = null;
163
+ lock = new Lock();
164
+ constructor(options) {
165
+ this.gapSecs = options.gapSecs;
166
+ }
167
+ }
168
+ /**
169
+ * A client delegation in progress: what to correlate the backend's outputs with.
170
+ *
171
+ * - `id`: the delegation's id, which the appends carry.
172
+ * - `answered`: whether the backend has sent a spoken output for it yet; its
173
+ * notes do not count.
174
+ * - `timeoutTask`: the task that abandons it if the backend takes too long.
175
+ */
176
+ export class _LiveDelegation {
177
+ id;
178
+ answered = false;
179
+ timeoutTask = null;
180
+ constructor(options) {
181
+ this.id = options.id;
182
+ }
183
+ }
184
+ /**
185
+ * Responses delegation: OpenAI hosts the backend model the live model delegates to.
186
+ *
187
+ * Hosted tools run server-side; function calls are executed by this service
188
+ * with the handlers registered for the tools in the pipeline's `LLMContext`
189
+ * (which is also where the backend model's `tools` and `tool_choice` come
190
+ * from).
191
+ *
192
+ * - `settings`: request settings for the backend model — the same object
193
+ * `OpenAIResponsesLLMService` takes. `model` is required.
194
+ * `systemInstruction` becomes the backend's `instructions`,
195
+ * `maxCompletionTokens` its `max_output_tokens`, `reasoning` is sent when
196
+ * configured (the server default applies otherwise) and `extra` is merged
197
+ * into the delegation configuration. Fields the Live API doesn't accept for
198
+ * a delegated model (`temperature`, `topP`, the penalties, `seed`, `topK`,
199
+ * `maxTokens`, and the user-turn-completion fields) are dropped with a
200
+ * warning.
201
+ * - `serviceTier`: Responses API service tier for delegated requests
202
+ * (`auto`, `default`, `flex` or `priority`).
203
+ */
204
+ export class ResponsesDelegation {
205
+ settings;
206
+ serviceTier;
207
+ constructor(options) {
208
+ this.settings = options.settings;
209
+ this.serviceTier = options.serviceTier ?? null;
210
+ }
211
+ }
212
+ /**
213
+ * Client delegation: a Pipecat worker is the backend the live model delegates to.
214
+ *
215
+ * A delegation names no task: the live model signals only that it is handing
216
+ * work over. Each one is put to the backend as a message carrying the
217
+ * transcript fragments since the previous delegation, and the backend works
218
+ * out the request from them. The service attaches to the backend for its
219
+ * life and takes delegations one at a time: the next waits until the backend
220
+ * reports it has nothing left to do, since they share one backend
221
+ * conversation.
222
+ *
223
+ * What comes back is appended to the live session according to each output's
224
+ * `prefersSpoken` flag: what it wants heard goes as commentary, for the model
225
+ * to relay in its own words, and the rest as thinking — silent context it can
226
+ * draw on if the conversation turns that way.
227
+ *
228
+ * - `backend`: the worker that runs delegated work — normally a
229
+ * `BackendLLMWorker` wrapping any LLM service, or the name of one
230
+ * registered elsewhere (in the app, or in another process on a shared
231
+ * bus). A worker given here is registered as a child of the pipeline worker
232
+ * at setup, so the pipeline must run under a `WorkerRunner`.
233
+ * - `timeoutSecs`: how long a delegation may take before it is abandoned.
234
+ */
235
+ export class ClientDelegation {
236
+ backend;
237
+ timeoutSecs;
238
+ constructor(options) {
239
+ this.backend = options.backend;
240
+ this.timeoutSecs = options.timeoutSecs ?? 120.0;
241
+ }
242
+ }
243
+ /**
244
+ * A delegated Responses run whose function calls we are collecting.
245
+ *
246
+ * - `callIds`: calls still awaiting an output.
247
+ * - `hadCalls`: whether the response asked for any function call at all.
248
+ * - `finished`: whether the response emitted all of its output items.
249
+ */
250
+ export class _PendingResponse {
251
+ callIds = new Set();
252
+ hadCalls = false;
253
+ finished = false;
254
+ }
255
+ /**
256
+ * OpenAI Live LLM service: full-duplex speech-to-speech over WebSocket.
257
+ *
258
+ * The live model (`gpt-live-1`) listens and speaks at the same time. It
259
+ * decides on its own when to answer, when to stop talking when the user
260
+ * speaks over it, and when to *delegate* work — search, reasoning, tool use —
261
+ * to a backend text model while the conversation continues. In the
262
+ * frontend/backend terms used throughout, the live model is the *frontend*
263
+ * (the conversational model) and the delegated-to model is the *backend*.
264
+ * There is no client-side turn detection or response triggering: the
265
+ * pipeline streams audio in and plays audio out.
266
+ *
267
+ * Delegation modes, selected with `delegation`:
268
+ *
269
+ * - `ResponsesDelegation` — OpenAI hosts the backend (Responses API) model.
270
+ * Function calls it makes are executed here with the handlers registered
271
+ * for the pipeline context's tools; results go back to the API as soon as
272
+ * they are available.
273
+ * - `ClientDelegation` — a `BackendLLMWorker` running any Pipecat LLM service
274
+ * is the backend. The model does not share its conversation with the
275
+ * backend, so the service ships the transcript turns since the previous
276
+ * delegation along with each request.
277
+ * - `null` — client delegation mode with no backend configured; delegated
278
+ * requests are declined.
279
+ *
280
+ * Turn frames: the service proposes user turn boundaries from the API's
281
+ * projected transcript turns, and the recommended
282
+ * `new ExternalUserTurnStrategies({ enableInterruptions: false })` resolve
283
+ * them into `UserStartedSpeakingFrame` / `UserStoppedSpeakingFrame` **without
284
+ * broadcasting interruptions**: the model handles being talked over itself,
285
+ * and a delegation keeps running when that happens (the model is expected to
286
+ * disregard results the conversation has moved past). As a consequence every
287
+ * tool behaves as `cancelOnInterruption: false`; use `cancellableByLlm: true`
288
+ * for tools the model should be able to cancel on request.
289
+ *
290
+ * The context aggregators record both sides of the conversation from the
291
+ * transcript frames. Unlike the Realtime services, this one is not flagged as
292
+ * a realtime service for the aggregators: the service closes a user turn
293
+ * itself once the speaker falls quiet and pushes its final transcript then,
294
+ * so the user aggregator writes each user turn to the context as it ends —
295
+ * before any tool calls it triggers — rather than when the assistant starts
296
+ * to respond.
297
+ *
298
+ * The session starts on the first `LLMContextFrame` (typically queued as an
299
+ * `LLMRunFrame`): the context's leading system message (or
300
+ * `Settings.systemInstruction`) becomes the model's instructions and the
301
+ * remaining text messages seed the session as prior conversation.
302
+ *
303
+ * Event handlers available:
304
+ *
305
+ * - on_session_started: called with the session resource once the session is
306
+ * ready for audio.
307
+ * - on_delegation_created: called with the delegation item when the model
308
+ * delegates work.
309
+ *
310
+ * @example
311
+ * const llm = new OpenAILiveLLMService({
312
+ * apiKey: process.env.OPENAI_API_KEY!,
313
+ * settings: new OpenAILiveLLMService.Settings({ systemInstruction: "..." }),
314
+ * delegation: new OpenAILiveLLMService.ResponsesDelegation({
315
+ * settings: new OpenAIResponsesLLMService.Settings({ model: "gpt-5.4-mini" }),
316
+ * }),
317
+ * });
318
+ */
319
+ export class OpenAILiveLLMService extends LLMService {
320
+ static Settings = OpenAILiveLLMSettings;
321
+ static ResponsesDelegation = ResponsesDelegation;
322
+ static ClientDelegation = ClientDelegation;
323
+ static adapterClass = OpenAILiveLLMAdapter;
324
+ apiKey;
325
+ baseUrl;
326
+ _sessionModel;
327
+ _delegation;
328
+ _webSocketFactory;
329
+ _websocket = null;
330
+ _receiveTask = null;
331
+ _disconnecting = false;
332
+ _context = null;
333
+ _needsSessionConfig = true;
334
+ _sessionStarted = false;
335
+ _sessionClosedEvent = new Event();
336
+ _sentToolsSnapshot = null;
337
+ // Whether a session has started on the current connection. Until one has,
338
+ // an error indicates that the session failed to start. Unlike
339
+ // _sessionStarted, this is not cleared on session close (which helps us
340
+ // avoid treating errors during shutdown as session start failures). It is
341
+ // cleared on disconnect, so the next connection is judged afresh.
342
+ _sessionStartedOnConnection = false;
343
+ _resampler = createStreamResampler();
344
+ _warnedAudioDropped = false;
345
+ // Turns assembled from transcript fragments, one per direction: in full
346
+ // duplex the user and the model can be speaking at once.
347
+ _userTurn = new _TurnGrouper({ gapSecs: TURN_GAP_SECS });
348
+ _assistantTurn = new _TurnGrouper({ gapSecs: TURN_GAP_SECS });
349
+ // A trailing developer message asking the model to open the conversation.
350
+ _openingInstruction = null;
351
+ // Responses delegation: calls awaiting an output, by call id, and the
352
+ // responses they belong to, by response id.
353
+ _openFunctionCalls = new Map();
354
+ _pendingResponses = new Map();
355
+ // Client delegation: the conversation not yet sent to the backend, whether
356
+ // the backend has run before, the session with it, the delegation it is
357
+ // working on and the ones waiting their turn.
358
+ _transcriptFragments = [];
359
+ _delegatedBefore = false;
360
+ _backendSession = null;
361
+ _backendSessionTask = null;
362
+ // Set while the session with the backend is open, so a delegation that
363
+ // starts just after attaching waits for it.
364
+ _backendAttached = new Event();
365
+ _currentDelegation = null;
366
+ _queuedDelegations = [];
367
+ /**
368
+ * Initialize the OpenAI Live LLM service.
369
+ *
370
+ * @param options.apiKey OpenAI project API key.
371
+ * @param options.baseUrl WebSocket base URL of the Live API.
372
+ * @param options.settings Runtime-updatable settings. `model`, `voice`,
373
+ * `store` and `systemInstruction` are fixed once the session has started.
374
+ * @param options.delegation Where the model's delegated work runs. `null`
375
+ * selects client delegation with no backend configured.
376
+ * Other options are passed to the parent LLMService.
377
+ */
378
+ constructor(options) {
379
+ const settingsClass = new.target.Settings;
380
+ const defaultSettings = new settingsClass({
381
+ model: DEFAULT_MODEL,
382
+ systemInstruction: null,
383
+ temperature: null,
384
+ maxTokens: null,
385
+ topP: null,
386
+ topK: null,
387
+ frequencyPenalty: null,
388
+ presencePenalty: null,
389
+ seed: null,
390
+ filterIncompleteUserTurns: false,
391
+ userTurnCompletionConfig: null,
392
+ voice: null,
393
+ store: null,
394
+ });
395
+ if (options.settings !== undefined && options.settings !== null) {
396
+ defaultSettings.applyUpdate(options.settings);
397
+ }
398
+ const delegation = options.delegation ?? null;
399
+ if (delegation instanceof ResponsesDelegation) {
400
+ const backendModel = delegation.settings.model;
401
+ if (!isGiven(backendModel) || !backendModel) {
402
+ throw new Error("ResponsesDelegation.settings.model is required");
403
+ }
404
+ }
405
+ // The live model is fixed for the session's lifetime, so it is resolved
406
+ // once, here.
407
+ const sessionModel = assertGiven(defaultSettings.model);
408
+ if (!sessionModel) {
409
+ throw new Error("settings.model is required");
410
+ }
411
+ super({ ...options, settings: defaultSettings });
412
+ this.apiKey = options.apiKey;
413
+ this.baseUrl = options.baseUrl ?? "wss://api.openai.com/v1/live/sessions";
414
+ this._sessionModel = sessionModel;
415
+ this._delegation = delegation;
416
+ this._webSocketFactory = options.webSocketFactory ?? null;
417
+ this._registerEventHandler("on_session_started");
418
+ this._registerEventHandler("on_delegation_created");
419
+ }
420
+ /**
421
+ * Check if the service can generate usage metrics.
422
+ *
423
+ * @returns True if metrics generation is supported.
424
+ */
425
+ canGenerateMetrics() {
426
+ return true;
427
+ }
428
+ /** The Live API takes one output per function call, so intermediate results are dropped. */
429
+ get acceptsIntermediateFunctionCallResults() {
430
+ return false;
431
+ }
432
+ /**
433
+ * Decline both roles: this service is a frontend with a backend of its own.
434
+ *
435
+ * @param role The role the service is being asked to take.
436
+ * @returns The reason, naming what to use instead.
437
+ */
438
+ llmWithBackendRoleObjection(role) {
439
+ return (`${this.constructor.name} cannot be an LLMWithBackend ${role}: it is already a ` +
440
+ "frontend with a backend of its own. Give it a delegation instead — " +
441
+ "OpenAILiveLLMService.ClientDelegation for a backend worker, or " +
442
+ "OpenAILiveLLMService.ResponsesDelegation for OpenAI's own.");
443
+ }
444
+ /**
445
+ * Recommend external turn strategies, resolved from the API's projected turns.
446
+ *
447
+ * `isRealtimeService` is left off on purpose: the aggregators' realtime
448
+ * mode defers the user-message write until the assistant responds, to
449
+ * absorb late transcripts, and here that would place a delegation's tool
450
+ * calls ahead of the user message that caused them.
451
+ */
452
+ serviceMetadataFrame() {
453
+ return new LLMServiceMetadataFrame({
454
+ serviceName: this.name,
455
+ userTurnStrategies: new ExternalUserTurnStrategies({ enableInterruptions: false }),
456
+ });
457
+ }
458
+ //
459
+ // lifecycle
460
+ //
461
+ /**
462
+ * Set up the service, register the client-delegation backend, and connect.
463
+ *
464
+ * @param setup Configuration object containing setup parameters.
465
+ */
466
+ async setup(setup) {
467
+ await super.setup(setup);
468
+ if (this._delegation instanceof ClientDelegation) {
469
+ if (this._delegation.backend instanceof BackendLLMWorker) {
470
+ // A child of the pipeline worker: ended and cancelled with it.
471
+ await this.pipelineWorker.addWorkers(this._delegation.backend);
472
+ }
473
+ }
474
+ await this._connect();
475
+ }
476
+ /** Release resources at teardown. */
477
+ async cleanup() {
478
+ await super.cleanup();
479
+ await this._disconnect();
480
+ await this._detachBackend();
481
+ }
482
+ /**
483
+ * Close the session gracefully and disconnect.
484
+ *
485
+ * @param frame The end frame triggering service shutdown.
486
+ */
487
+ async stop(frame) {
488
+ await super.stop(frame);
489
+ await this._closeOpenTurns();
490
+ await this._closeSession();
491
+ await this._disconnect();
492
+ }
493
+ /**
494
+ * Disconnect immediately.
495
+ *
496
+ * @param frame The cancel frame triggering service cancellation.
497
+ */
498
+ async cancel(frame) {
499
+ await super.cancel(frame);
500
+ await this._disconnect();
501
+ }
502
+ /**
503
+ * Apply a settings delta.
504
+ *
505
+ * The API fixes the session's settings when it starts, so a change is
506
+ * stored and reaches the API on the next session (see `resetConversation`).
507
+ */
508
+ async _updateSettings(delta) {
509
+ const changed = await super._updateSettings(delta);
510
+ this._warnUnhandledUpdatedSettings(changed);
511
+ return changed;
512
+ }
513
+ /**
514
+ * Start a new session seeded from the current context.
515
+ *
516
+ * The Live API only takes conversation history at session start, so
517
+ * replacing the context (for example to restore a saved conversation) means
518
+ * dropping the session and opening a new one configured from the context as
519
+ * it is now. The old session is abandoned rather than closed gracefully: a
520
+ * graceful close waits for its in-flight delegations to drain, and their
521
+ * results belong to the conversation being replaced. Must not be called
522
+ * from the receive task.
523
+ */
524
+ async resetConversation() {
525
+ logger.debug(`${this}: resetting conversation`);
526
+ // Close out turns the old session was in the middle of, so the
527
+ // aggregator records what was said and the response frames stay balanced
528
+ // for the new session's turns.
529
+ await this._closeOpenTurns();
530
+ await this._disconnect();
531
+ this._needsSessionConfig = true;
532
+ await this._connect();
533
+ if (this._context !== null) {
534
+ await this._sendSessionConfig();
535
+ }
536
+ }
537
+ //
538
+ // frame processing
539
+ //
540
+ /**
541
+ * Process incoming frames from the pipeline.
542
+ *
543
+ * @param frame The frame to process.
544
+ * @param direction The direction of frame flow in the pipeline.
545
+ */
546
+ async processFrame(frame, direction) {
547
+ await super.processFrame(frame, direction);
548
+ if (frame instanceof LLMContextFrame) {
549
+ await this._handleContext(frame.context);
550
+ }
551
+ else if (frame instanceof InputAudioRawFrame) {
552
+ await this._sendUserAudio(frame);
553
+ }
554
+ else if (frame instanceof LLMSetToolsFrame) {
555
+ // Continuous session: no fresh context frame per turn, so sync the
556
+ // registered tool handlers to the new tool set here (the base service
557
+ // only does this on LLMContextFrame).
558
+ this._syncRegisteredToolHandlers(frame.tools);
559
+ await this._maybeSendToolsUpdate();
560
+ }
561
+ await this.pushFrame(frame, direction);
562
+ }
563
+ /**
564
+ * Push a frame, delivering function call outcomes to the API on the way.
565
+ *
566
+ * Function call results and cancellations are broadcast by the base
567
+ * service; the downstream copy is observed here and answered to the backend
568
+ * model immediately. The frames continue to the assistant aggregator, which
569
+ * records them in the context.
570
+ *
571
+ * @param frame The frame to push.
572
+ * @param direction The direction of frame pushing.
573
+ */
574
+ async pushFrame(frame, direction = FrameDirection.DOWNSTREAM) {
575
+ if (direction === FrameDirection.DOWNSTREAM) {
576
+ if (frame instanceof FunctionCallResultFrame) {
577
+ await this._handleFunctionCallResult(frame);
578
+ }
579
+ else if (frame instanceof FunctionCallCancelFrame) {
580
+ await this._handleFunctionCallCancel(frame);
581
+ }
582
+ }
583
+ await super.pushFrame(frame, direction);
584
+ }
585
+ /**
586
+ * Take the context, configuring the session from the first one.
587
+ *
588
+ * A later context carries messages the aggregators recorded from this
589
+ * session's own transcripts, tool results (delivered to the API as they are
590
+ * produced, see `pushFrame`), or messages the app appended. None of it
591
+ * reaches the API: user and assistant messages can't be told apart from the
592
+ * transcript recordings. Newly appended system/developer messages can only
593
+ * come from the app (the aggregators add user, assistant and tool messages,
594
+ * and async-tool envelopes that `async_tool_messages.parseMessage`
595
+ * identifies), so a future diff could forward those as
596
+ * `session.context.append(channel="developer")` unambiguously.
597
+ */
598
+ async _handleContext(context) {
599
+ this._context = context;
600
+ if (this._needsSessionConfig) {
601
+ await this._sendSessionConfig();
602
+ }
603
+ }
604
+ //
605
+ // session configuration
606
+ //
607
+ async _invocationParams() {
608
+ if (this._context === null)
609
+ throw new Error("assertion failed: no context");
610
+ return await this.getLlmAdapter().getLlmInvocationParams(this._context, {
611
+ systemInstruction: assertGiven(this._settings.systemInstruction),
612
+ });
613
+ }
614
+ /** Send `session.start`, the first message on the socket. */
615
+ async _sendSessionConfig() {
616
+ const params = await this._invocationParams();
617
+ let history = params.input;
618
+ // A trailing developer message is the app asking the bot to open the
619
+ // conversation. The startup history is not the place for it: it is
620
+ // delivered as commentary once the session starts, which is how the API
621
+ // asks the model to speak first.
622
+ this._openingInstruction = _trailingDeveloperText(history);
623
+ if (this._openingInstruction) {
624
+ history = history.slice(0, -1);
625
+ }
626
+ // The API fixes the model at session start, so it is read here: a change
627
+ // applied mid-session takes effect on the next one.
628
+ this._sessionModel = assertGiven(this._settings.model) || this._sessionModel;
629
+ const voice = assertGiven(this._settings.voice);
630
+ const session = new events.SessionConfig({
631
+ model: this._sessionModel,
632
+ instructions: params.instructions,
633
+ audio: voice
634
+ ? new events.AudioConfig({ output: new events.AudioOutputConfig({ voice }) })
635
+ : null,
636
+ delegation: this._delegationConfig(params.tools, params.tool_choice),
637
+ input: history.length > 0 ? history : null,
638
+ store: assertGiven(this._settings.store),
639
+ });
640
+ this._sentToolsSnapshot = OpenAILiveLLMService._toolsSnapshot(params);
641
+ this._needsSessionConfig = false;
642
+ logger.debug(`${this}: starting session with ${history.length} prior messages ` +
643
+ `and ${session.delegation ? session.delegation.type : "client"} delegation`);
644
+ await this.sendClientEvent(new events.SessionStartEvent({ session }));
645
+ }
646
+ _delegationConfig(tools, toolChoice) {
647
+ if (this._delegation instanceof ResponsesDelegation) {
648
+ const responses = _responsesDelegationConfig(this._delegation);
649
+ if (tools.length > 0) {
650
+ responses.tools = tools;
651
+ }
652
+ if (toolChoice !== null && toolChoice !== undefined) {
653
+ responses.tool_choice = toolChoice;
654
+ }
655
+ return new events.ResponsesDelegationConfig({ responses });
656
+ }
657
+ return new events.ClientDelegationConfig();
658
+ }
659
+ /** Send a sparse `session.update` when the backend model's tools changed. */
660
+ async _maybeSendToolsUpdate() {
661
+ if (!(this._sessionStarted &&
662
+ this._context !== null &&
663
+ this._delegation instanceof ResponsesDelegation)) {
664
+ return;
665
+ }
666
+ const params = await this._invocationParams();
667
+ const snapshot = OpenAILiveLLMService._toolsSnapshot(params);
668
+ if (snapshot === this._sentToolsSnapshot) {
669
+ return;
670
+ }
671
+ this._sentToolsSnapshot = snapshot;
672
+ const responses = { tools: params.tools };
673
+ if (params.tool_choice !== null && params.tool_choice !== undefined) {
674
+ responses.tool_choice = params.tool_choice;
675
+ }
676
+ await this.sendClientEvent(new events.SessionUpdateEvent({
677
+ session: new events.SessionUpdateConfig({
678
+ delegation: new events.ResponsesDelegationConfig({ responses }),
679
+ }),
680
+ }));
681
+ }
682
+ static _toolsSnapshot(params) {
683
+ return pyJsonDumps(sortKeys({ tools: params.tools, tool_choice: params.tool_choice }), {
684
+ default: String,
685
+ });
686
+ }
687
+ //
688
+ // websocket communication
689
+ //
690
+ /**
691
+ * Send a client event to the Live API.
692
+ *
693
+ * @param event The client event to send.
694
+ */
695
+ async sendClientEvent(event) {
696
+ await this._wsSend(event.toPayload());
697
+ }
698
+ async _connect() {
699
+ await this._attachBackend();
700
+ try {
701
+ if (this._websocket) {
702
+ return;
703
+ }
704
+ this._websocket = await websocketConnect(this.baseUrl, {
705
+ additionalHeaders: { Authorization: `Bearer ${this.apiKey}` },
706
+ webSocketFactory: this._webSocketFactory,
707
+ });
708
+ this._receiveTask = this.createTask(() => this._receiveTaskHandler(), "receiveTaskHandler");
709
+ }
710
+ catch (e) {
711
+ rethrowIfCancelled(e);
712
+ this._websocket = null;
713
+ await this.pushError(`Error connecting: ${errorMessage(e)}`, {
714
+ exception: e,
715
+ forceTreatAsPermanent: true,
716
+ });
717
+ }
718
+ }
719
+ async _disconnect() {
720
+ // `_disconnecting` gates every outgoing event, so it is cleared in a
721
+ // finally: a teardown that fails part-way still leaves the service able
722
+ // to speak on its next session.
723
+ try {
724
+ this._disconnecting = true;
725
+ this._sessionStarted = false;
726
+ this._sessionStartedOnConnection = false;
727
+ await this.stopAllMetrics();
728
+ if (this._websocket) {
729
+ await this._websocket.close();
730
+ this._websocket = null;
731
+ }
732
+ if (this._receiveTask) {
733
+ await this.cancelTask(this._receiveTask, 1.0);
734
+ this._receiveTask = null;
735
+ }
736
+ await this._abandonDelegations();
737
+ this._transcriptFragments.length = 0;
738
+ this._delegatedBefore = false;
739
+ this._sentToolsSnapshot = null;
740
+ this._openFunctionCalls.clear();
741
+ this._pendingResponses.clear();
742
+ }
743
+ catch (e) {
744
+ rethrowIfCancelled(e);
745
+ await this.pushError(`Error disconnecting: ${errorMessage(e)}`, { exception: e });
746
+ }
747
+ finally {
748
+ this._disconnecting = false;
749
+ }
750
+ }
751
+ /** Ask the server to shut down gracefully and wait for `session.closed`. */
752
+ async _closeSession() {
753
+ if (!this._websocket || !this._sessionStarted) {
754
+ return;
755
+ }
756
+ this._sessionClosedEvent.clear();
757
+ await this.sendClientEvent(new events.SessionCloseEvent());
758
+ try {
759
+ await waitFor(() => this._sessionClosedEvent.wait(), SESSION_CLOSE_TIMEOUT_SECS);
760
+ }
761
+ catch (e) {
762
+ if (!(e instanceof TimeoutError))
763
+ throw e;
764
+ logger.warning(`${this}: timed out waiting for session.closed`);
765
+ }
766
+ }
767
+ async _wsSend(message) {
768
+ try {
769
+ if (!this._disconnecting && this._websocket) {
770
+ await this._websocket.send(pyJsonDumps(message));
771
+ }
772
+ }
773
+ catch (e) {
774
+ rethrowIfCancelled(e);
775
+ if (this._disconnecting || !this._websocket) {
776
+ return;
777
+ }
778
+ await this.pushError(`Error sending client event: ${errorMessage(e)}`, {
779
+ exception: e,
780
+ forceTreatAsPermanent: true,
781
+ });
782
+ }
783
+ }
784
+ //
785
+ // inbound server event handling
786
+ //
787
+ async _receiveTaskHandler() {
788
+ const websocket = this._websocket;
789
+ if (websocket === null)
790
+ throw new Error("assertion failed: no websocket");
791
+ try {
792
+ for await (const message of websocket) {
793
+ let evt;
794
+ try {
795
+ evt = events.parseServerEvent(message);
796
+ }
797
+ catch (e) {
798
+ rethrowIfCancelled(e);
799
+ logger.warning(`${this}: ignoring unparseable server event: ${errorMessage(e)}`);
800
+ continue;
801
+ }
802
+ await this._handleServerEvent(evt);
803
+ }
804
+ }
805
+ catch (e) {
806
+ if (!(e instanceof ConnectionClosed))
807
+ throw e;
808
+ if (!this._disconnecting) {
809
+ await this.pushError(`Connection closed: ${errorMessage(e)}`, {
810
+ exception: e,
811
+ forceTreatAsPermanent: true,
812
+ });
813
+ }
814
+ }
815
+ }
816
+ async _handleServerEvent(evt) {
817
+ if (evt instanceof events.SessionStartedEvent) {
818
+ await this._handleEvtSessionStarted(evt);
819
+ }
820
+ else if (evt instanceof events.OutputAudioDeltaEvent) {
821
+ await this._handleEvtAudioDelta(evt);
822
+ }
823
+ else if (evt instanceof events.TranscriptDeltaEvent) {
824
+ await this._handleEvtTranscriptDelta(evt);
825
+ }
826
+ else if (evt instanceof events.SessionDelegationCreatedEvent) {
827
+ await this._handleEvtDelegationCreated(evt);
828
+ }
829
+ else if (evt instanceof events.ResponseEventEnvelope) {
830
+ await this._handleEvtResponse(evt);
831
+ }
832
+ else if (evt instanceof events.SessionUsageUpdatedEvent) {
833
+ await this._reportUsage(evt.usage);
834
+ }
835
+ else if (evt instanceof events.SessionClosedEvent) {
836
+ await this._handleEvtSessionClosed(evt);
837
+ }
838
+ else if (evt instanceof events.ErrorEvent) {
839
+ await this._handleEvtError(evt);
840
+ }
841
+ else if (evt instanceof events.SessionUpdatedEvent) {
842
+ logger.debug(`${this}: session updated`);
843
+ }
844
+ else if (evt instanceof events.UnknownServerEvent) {
845
+ logger.debug(`${this}: ignoring unknown server event ${evt.type}`);
846
+ }
847
+ else {
848
+ logger.debug(`${this}: ${evt.type}`);
849
+ }
850
+ }
851
+ async _handleEvtSessionStarted(evt) {
852
+ this._sessionStarted = true;
853
+ this._sessionStartedOnConnection = true;
854
+ logger.info(`${this}: session ${evt.session.id} started. The live model handles being ` +
855
+ "interrupted by itself; no InterruptionFrame is broadcast, so tools run to " +
856
+ "completion regardless of cancel_on_interruption.");
857
+ // The API takes a session update only once the session has started, so
858
+ // tools that changed while it was starting are sent now.
859
+ await this._maybeSendToolsUpdate();
860
+ await this._callEventHandler("on_session_started", evt.session);
861
+ if (this._openingInstruction) {
862
+ // Speakable context is how the API asks the model to open the
863
+ // conversation; the model paraphrases rather than reads it out.
864
+ const instruction = this._openingInstruction;
865
+ this._openingInstruction = null;
866
+ await this._sendContextAppend(null, instruction, { spoken: true });
867
+ }
868
+ }
869
+ async _handleEvtSessionClosed(evt) {
870
+ logger.debug(`${this}: session closed (${evt.reason ?? "None"})`);
871
+ if (evt.usage) {
872
+ await this._reportUsage(evt.usage);
873
+ }
874
+ this._sessionStarted = false;
875
+ this._sessionClosedEvent.set();
876
+ }
877
+ async _handleEvtError(evt) {
878
+ const error = evt.error;
879
+ let details = `${error.type || "error"}/${error.code || "unknown"}: ${error.message ?? "None"}`;
880
+ if (error.param) {
881
+ details += ` (param: ${error.param})`;
882
+ }
883
+ if (!this._sessionStartedOnConnection) {
884
+ // No session has started on this connection, so this one will not.
885
+ await this.pushError(`Session startup failed: ${details}`, { forceTreatAsPermanent: true });
886
+ }
887
+ else {
888
+ await this.pushError(details);
889
+ }
890
+ }
891
+ //
892
+ // audio
893
+ //
894
+ async _sendUserAudio(frame) {
895
+ if (!this._sessionStarted) {
896
+ if (!this._warnedAudioDropped) {
897
+ this._warnedAudioDropped = true;
898
+ logger.debug(`${this}: dropping input audio until the session has started`);
899
+ }
900
+ return;
901
+ }
902
+ let audio = frame.audio;
903
+ if (frame.sampleRate !== OPENAI_SAMPLE_RATE) {
904
+ audio = await this._resampler.resample(audio, frame.sampleRate, OPENAI_SAMPLE_RATE);
905
+ }
906
+ if (audio.length === 0) {
907
+ return;
908
+ }
909
+ const payload = bytesToBase64(audio);
910
+ await this.sendClientEvent(new events.InputAudioAppendEvent({ audio: payload }));
911
+ }
912
+ async _handleEvtAudioDelta(evt) {
913
+ // The model streams output continuously at real-time pace, silence
914
+ // included, so this is a speech stream rather than TTS output: the output
915
+ // transport derives BotStarted/StoppedSpeakingFrame from the audio itself
916
+ // instead of from TTSStarted/StoppedFrame.
917
+ await this.pushFrame(new SpeechOutputAudioRawFrame({
918
+ audio: base64ToBytes(evt.delta),
919
+ sampleRate: OPENAI_SAMPLE_RATE,
920
+ numChannels: 1,
921
+ }));
922
+ }
923
+ //
924
+ // transcript turns
925
+ //
926
+ async _handleEvtTranscriptDelta(evt) {
927
+ if (!evt.delta) {
928
+ return;
929
+ }
930
+ const role = evt.role;
931
+ // The delegation ledger keeps fragments as they arrive: the backend is
932
+ // better served by the recent role-labelled sequence than by waiting for
933
+ // a turn that may never be declared final.
934
+ this._rememberFragment(role, evt.delta);
935
+ const turn = role === "user" ? this._userTurn : this._assistantTurn;
936
+ await turn.lock.run(async () => {
937
+ if (!turn.open) {
938
+ turn.open = true;
939
+ turn.text = "";
940
+ await this._openTurn(role);
941
+ }
942
+ turn.text += evt.delta;
943
+ await this._appendTurn(role, evt.delta, turn.text, evt);
944
+ await this._restartTurnTimer(role, turn);
945
+ });
946
+ }
947
+ async _restartTurnTimer(role, turn) {
948
+ if (turn.timer !== null) {
949
+ await this.cancelTask(turn.timer);
950
+ }
951
+ turn.timer = this.createTask(() => this._closeTurnAfterGap(role, turn), `turn-gap:${role}`);
952
+ }
953
+ async _closeTurnAfterGap(role, turn) {
954
+ await sleep(turn.gapSecs);
955
+ await turn.lock.run(async () => {
956
+ // Running inside the turn's own timer, so the timer must not cancel itself.
957
+ turn.timer = null;
958
+ await this._endTurn(role);
959
+ });
960
+ }
961
+ /** Close an open turn, if any, emitting its end frames. */
962
+ async _closeTurn(role) {
963
+ const turn = role === "user" ? this._userTurn : this._assistantTurn;
964
+ await turn.lock.run(async () => {
965
+ if (turn.timer !== null) {
966
+ await this.cancelTask(turn.timer);
967
+ turn.timer = null;
968
+ }
969
+ await this._endTurn(role);
970
+ });
971
+ }
972
+ /** Emit an open turn's end frames, with the turn's lock held. */
973
+ async _endTurn(role) {
974
+ const turn = role === "user" ? this._userTurn : this._assistantTurn;
975
+ if (!turn.open) {
976
+ return;
977
+ }
978
+ turn.open = false;
979
+ const text = turn.text;
980
+ turn.text = "";
981
+ if (role === "user") {
982
+ if (text.trim()) {
983
+ await this.pushFrame(new TranscriptionFrame({ text, userId: "", timestamp: timeNowIso8601() }), FrameDirection.UPSTREAM);
984
+ }
985
+ await this.broadcastFrame(ProposedUserStoppedSpeakingFrame);
986
+ }
987
+ else {
988
+ await this.pushFrame(new TTSStoppedFrame());
989
+ await this.pushFrame(new LLMFullResponseEndFrame());
990
+ }
991
+ }
992
+ /** Close both directions, so response frames stay balanced across sessions. */
993
+ async _closeOpenTurns() {
994
+ await this._closeTurn("user");
995
+ await this._closeTurn("assistant");
996
+ }
997
+ async _openTurn(role) {
998
+ if (role === "user") {
999
+ await this.broadcastFrame(ProposedUserStartedSpeakingFrame);
1000
+ }
1001
+ else {
1002
+ await this.pushFrame(new LLMFullResponseStartFrame());
1003
+ await this.pushFrame(new TTSStartedFrame());
1004
+ }
1005
+ }
1006
+ async _appendTurn(role, delta, accumulated, evt) {
1007
+ if (role === "user") {
1008
+ await this._pushInterimTranscription(accumulated, evt);
1009
+ }
1010
+ else {
1011
+ await this._pushAssistantText(delta);
1012
+ }
1013
+ }
1014
+ /**
1015
+ * Keep a transcript fragment for the next client delegation.
1016
+ *
1017
+ * Fragments land on frame boundaries, so consecutive ones from the same
1018
+ * speaker are joined back up as they arrive, giving the backend whole
1019
+ * utterances to read. Deltas carry their own leading spaces, so they
1020
+ * concatenate into the original text unaltered.
1021
+ */
1022
+ _rememberFragment(role, delta) {
1023
+ if (!(this._delegation instanceof ClientDelegation)) {
1024
+ return;
1025
+ }
1026
+ const fragments = this._transcriptFragments;
1027
+ const last = fragments[fragments.length - 1];
1028
+ if (last !== undefined && last.role === role) {
1029
+ last.content += delta;
1030
+ }
1031
+ else if (delta.trim()) {
1032
+ fragments.push({ role, content: delta.trimStart() });
1033
+ }
1034
+ }
1035
+ /** Take the conversation recorded since the previous delegation. */
1036
+ _takeTranscript() {
1037
+ const taken = this._transcriptFragments;
1038
+ this._transcriptFragments = [];
1039
+ return taken
1040
+ .filter((f) => f.content.trim())
1041
+ .map((f) => ({ role: f.role, content: f.content.trim() }));
1042
+ }
1043
+ async _pushAssistantText(text) {
1044
+ if (!text) {
1045
+ return;
1046
+ }
1047
+ // RTVI relies on LLMTextFrames for its "bot-llm-text" event, but the
1048
+ // assistant aggregator records the TTSTextFrame, so the LLMTextFrame must
1049
+ // not also be appended to the context.
1050
+ const llmTextFrame = new LLMTextFrame({ text });
1051
+ llmTextFrame.appendToContext = false;
1052
+ await this.pushFrame(llmTextFrame);
1053
+ const ttsTextFrame = new TTSTextFrame({ text, textType: TextType.SENTENCE });
1054
+ ttsTextFrame.includesInterFrameSpaces = true;
1055
+ await this.pushFrame(ttsTextFrame);
1056
+ }
1057
+ async _pushInterimTranscription(transcript, evt) {
1058
+ if (!transcript.trim()) {
1059
+ return;
1060
+ }
1061
+ await this.pushFrame(new InterimTranscriptionFrame({
1062
+ text: transcript,
1063
+ userId: "",
1064
+ timestamp: timeNowIso8601(),
1065
+ result: evt,
1066
+ }), FrameDirection.UPSTREAM);
1067
+ }
1068
+ //
1069
+ // delegation
1070
+ //
1071
+ async _handleEvtDelegationCreated(evt) {
1072
+ const delegation = evt.delegation;
1073
+ logger.debug(`${this}: delegation ${delegation.id} created (target=${delegation.target})`);
1074
+ await this._callEventHandler("on_delegation_created", delegation);
1075
+ if (delegation.target === "client") {
1076
+ await this._handleClientDelegation(delegation);
1077
+ }
1078
+ }
1079
+ async _handleClientDelegation(delegation) {
1080
+ if (!(this._delegation instanceof ClientDelegation)) {
1081
+ logger.warning(`${this}: the model delegated work but no backend is configured to handle ` +
1082
+ "client delegations; declining");
1083
+ await this._sendContextAppend(delegation.id, "No backend is available to handle delegated work in this session.", { spoken: true });
1084
+ return;
1085
+ }
1086
+ // The backend is handed the conversation since the last delegation and
1087
+ // works out the request from it. The transcript is taken now, so a
1088
+ // delegation that waits its turn still reads what prompted it.
1089
+ const request = _renderTranscriptRequest(this._takeTranscript(), {
1090
+ first: !this._delegatedBefore,
1091
+ });
1092
+ this._delegatedBefore = true;
1093
+ if (this._currentDelegation === null) {
1094
+ await this._startDelegation(delegation, request);
1095
+ }
1096
+ else {
1097
+ logger.debug(`${this}: delegation ${delegation.id} waits for the backend to finish`);
1098
+ this._queuedDelegations.push([delegation, request]);
1099
+ }
1100
+ }
1101
+ /** Hold the session with the backend, appending what it produces to the live session. */
1102
+ async _runBackendSession() {
1103
+ const config = this._delegation;
1104
+ if (!(config instanceof ClientDelegation)) {
1105
+ throw new Error("assertion failed: client delegation is not configured");
1106
+ }
1107
+ const backendName = typeof config.backend === "string" ? config.backend : config.backend.name;
1108
+ try {
1109
+ const backendSession = new _BackendSession(this.pipelineWorker, backendName, {
1110
+ timeoutSecs: config.timeoutSecs,
1111
+ });
1112
+ await backendSession.run(async (session) => {
1113
+ this._backendSession = session;
1114
+ this._backendAttached.set();
1115
+ for await (const event of session) {
1116
+ await this._onBackendEvent(event);
1117
+ }
1118
+ if (!session.detached) {
1119
+ logger.warning(`${this}: backend '${backendName}' went away`);
1120
+ }
1121
+ });
1122
+ }
1123
+ catch (e) {
1124
+ if (e instanceof CancelledError)
1125
+ throw e;
1126
+ logger.error(`${this}: the session with backend '${backendName}' failed: ${errorMessage(e)}`);
1127
+ }
1128
+ finally {
1129
+ this._backendAttached.clear();
1130
+ this._backendSession = null;
1131
+ }
1132
+ }
1133
+ async _onBackendEvent(event) {
1134
+ const current = this._currentDelegation;
1135
+ if (event instanceof BackendToolCall) {
1136
+ // Reported to clients as ExternalFunctionCall frames, as the
1137
+ // Responses-delegation backend's calls are.
1138
+ await this.pushFrame(event.toFrame());
1139
+ }
1140
+ else if (event instanceof BackendOutput) {
1141
+ if (!event.text) {
1142
+ return;
1143
+ }
1144
+ if (current !== null && event.prefersSpoken) {
1145
+ current.answered = true;
1146
+ }
1147
+ await this._sendContextAppend(current ? current.id : null, event.text, {
1148
+ spoken: event.prefersSpoken,
1149
+ });
1150
+ }
1151
+ else if (event instanceof BackendError) {
1152
+ if (current !== null) {
1153
+ logger.warning(`${this}: delegation ${current.id} failed: ${event.error}`);
1154
+ await this._sendContextAppend(current.id, "The delegated work could not be completed.", {
1155
+ spoken: true,
1156
+ });
1157
+ await this.pushError(`Delegation ${current.id} failed: ${event.error}`);
1158
+ await this._finishDelegation();
1159
+ }
1160
+ }
1161
+ else if (event instanceof BackendIdle) {
1162
+ if (current !== null) {
1163
+ if (!current.answered) {
1164
+ // The live model holds the conversation until the delegation says
1165
+ // something, so a run that produced nothing spoken still gets a
1166
+ // word back.
1167
+ logger.warning(`${this}: delegation ${current.id} produced no spoken output`);
1168
+ await this._sendContextAppend(current.id, "The delegated work finished without an answer.", { spoken: true });
1169
+ }
1170
+ await this._finishDelegation();
1171
+ }
1172
+ }
1173
+ }
1174
+ async _startDelegation(delegation, request) {
1175
+ const config = this._delegation;
1176
+ if (!(config instanceof ClientDelegation)) {
1177
+ throw new Error("assertion failed: client delegation is not configured");
1178
+ }
1179
+ const current = new _LiveDelegation({ id: delegation.id });
1180
+ this._currentDelegation = current;
1181
+ current.timeoutTask = this.createTask(() => this._delegationTimeout(current, config.timeoutSecs), `${this}::delegation_timeout:${delegation.id}`);
1182
+ try {
1183
+ await timeout(BACKEND_ATTACH_TIMEOUT_SECS, () => this._backendAttached.wait());
1184
+ const session = this._backendSession;
1185
+ if (session === null) {
1186
+ throw new Error("the backend is not attached");
1187
+ }
1188
+ await session.send(request);
1189
+ }
1190
+ catch (e) {
1191
+ rethrowIfCancelled(e);
1192
+ logger.warning(`${this}: delegation ${delegation.id} failed: ${errorMessage(e)}`);
1193
+ await this._sendContextAppend(delegation.id, "The delegated work could not be completed.", {
1194
+ spoken: true,
1195
+ });
1196
+ await this.pushError(`Delegation ${delegation.id} failed: ${errorMessage(e)}`, {
1197
+ exception: e,
1198
+ });
1199
+ await this._finishDelegation();
1200
+ }
1201
+ }
1202
+ /** Close the delegation in progress and start the next one waiting, if any. */
1203
+ async _finishDelegation() {
1204
+ const current = this._currentDelegation;
1205
+ this._currentDelegation = null;
1206
+ if (current !== null && current.timeoutTask !== null) {
1207
+ if (current.timeoutTask !== currentTask()) {
1208
+ await this.cancelTask(current.timeoutTask);
1209
+ }
1210
+ current.timeoutTask = null;
1211
+ }
1212
+ const next = this._queuedDelegations.shift();
1213
+ if (next !== undefined) {
1214
+ const [delegation, request] = next;
1215
+ await this._startDelegation(delegation, request);
1216
+ }
1217
+ }
1218
+ async _delegationTimeout(current, timeoutSecs) {
1219
+ await sleep(timeoutSecs);
1220
+ if (this._currentDelegation !== current) {
1221
+ return;
1222
+ }
1223
+ logger.warning(`${this}: delegation ${current.id} timed out; abandoning it`);
1224
+ current.timeoutTask = null;
1225
+ // The backend may still be at work on it. Detaching stops that work, so
1226
+ // none of it is reported under a later delegation.
1227
+ await this._detachBackend();
1228
+ await this._attachBackend();
1229
+ await this._sendContextAppend(current.id, "The delegated work could not be completed.", {
1230
+ spoken: true,
1231
+ });
1232
+ await this._finishDelegation();
1233
+ }
1234
+ /**
1235
+ * Drop the delegation in progress and the ones waiting: the session they belong to is over.
1236
+ *
1237
+ * The backend is detached, which stops its work; the next connection
1238
+ * attaches again.
1239
+ */
1240
+ async _abandonDelegations() {
1241
+ this._queuedDelegations.length = 0;
1242
+ const current = this._currentDelegation;
1243
+ this._currentDelegation = null;
1244
+ if (current !== null && current.timeoutTask !== null) {
1245
+ await this.cancelTask(current.timeoutTask);
1246
+ current.timeoutTask = null;
1247
+ }
1248
+ await this._detachBackend();
1249
+ }
1250
+ /** Open the session with the backend, when client delegation is configured and none is open. */
1251
+ async _attachBackend() {
1252
+ if (this._delegation instanceof ClientDelegation && this._backendSessionTask === null) {
1253
+ this._backendSessionTask = this.createTask(() => this._runBackendSession(), `${this}::backend_session`);
1254
+ }
1255
+ }
1256
+ /** Close the session with the backend, which stops its work. */
1257
+ async _detachBackend() {
1258
+ if (this._backendSessionTask !== null) {
1259
+ await this.cancelTask(this._backendSessionTask);
1260
+ this._backendSessionTask = null;
1261
+ }
1262
+ }
1263
+ /**
1264
+ * Append text to the live session, for it to speak or to keep to itself.
1265
+ *
1266
+ * The API's two channels are named for what the live model does with the
1267
+ * text: commentary is paraphrased aloud, thinking joins its private
1268
+ * reasoning. That vantage is the model's own, which is why what arrives
1269
+ * from a backend is flagged `prefersSpoken` instead — ordinary prose can go
1270
+ * unspoken without being anything like a thought.
1271
+ */
1272
+ async _sendContextAppend(delegationId, text, options) {
1273
+ const spoken = options.spoken;
1274
+ const channel = spoken ? "commentary" : "thinking";
1275
+ for (const chunk of _chunkText(text, MAX_CONTEXT_APPEND_TOKENS)) {
1276
+ logger.debug(`${this}: delegation ${delegationId ?? "None"} ${channel} context: ${reprStr(chunk)}`);
1277
+ const eventClass = spoken
1278
+ ? events.SessionCommentaryAppendEvent
1279
+ : events.SessionThinkingAppendEvent;
1280
+ await this.sendClientEvent(new eventClass({ delegationId, content: chunk }));
1281
+ }
1282
+ }
1283
+ //
1284
+ // Responses delegation
1285
+ //
1286
+ /** Dispatch a wrapped Responses lifecycle event by its own type. */
1287
+ async _handleEvtResponse(evt) {
1288
+ const innerType = evt.innerType;
1289
+ if (innerType === "response.created") {
1290
+ this._trackResponse(evt);
1291
+ }
1292
+ else if (innerType === "response.output_item.done") {
1293
+ await this._handleResponseOutputItemDone(evt);
1294
+ }
1295
+ else if (innerType === "response.completed" ||
1296
+ innerType === "response.incomplete" ||
1297
+ innerType === "response.failed") {
1298
+ await this._handleResponseFinished(evt);
1299
+ }
1300
+ else {
1301
+ logger.trace(`${this}: ${innerType || evt.type}`);
1302
+ }
1303
+ }
1304
+ /**
1305
+ * Remember a delegated response so its function calls can be collected.
1306
+ *
1307
+ * @returns The response's correlation key.
1308
+ */
1309
+ _trackResponse(evt) {
1310
+ const key = _correlationKey(evt);
1311
+ if (!this._pendingResponses.has(key)) {
1312
+ this._pendingResponses.set(key, new _PendingResponse());
1313
+ }
1314
+ return key;
1315
+ }
1316
+ async _handleResponseOutputItemDone(evt) {
1317
+ const rawItem = evt.event.item;
1318
+ const item = events.ResponseOutputItem.modelValidate(pyTruthy(rawItem) ? rawItem : {});
1319
+ if (item.type !== "function_call") {
1320
+ return;
1321
+ }
1322
+ if (item.status !== "completed") {
1323
+ logger.debug(`${this}: ignoring ${item.status ?? "None"} function call item`);
1324
+ return;
1325
+ }
1326
+ if (!item.callId || !item.name) {
1327
+ logger.warning(`${this}: function call item without call_id or name: ${pydanticStr(item)}`);
1328
+ return;
1329
+ }
1330
+ if (this._openFunctionCalls.has(item.callId)) {
1331
+ logger.warning(`${this}: function call ${item.callId} already in progress, skipping`);
1332
+ return;
1333
+ }
1334
+ let args;
1335
+ try {
1336
+ args = item.arguments ? pyJsonLoads(item.arguments) : {};
1337
+ }
1338
+ catch (e) {
1339
+ if (!(e instanceof SyntaxError))
1340
+ throw e;
1341
+ await this.pushError(`Invalid arguments for function call ${item.name}: ${e.message}`, {
1342
+ exception: e,
1343
+ });
1344
+ return;
1345
+ }
1346
+ const key = this._trackResponse(evt);
1347
+ const pending = this._pendingResponses.get(key);
1348
+ pending.callIds.add(item.callId);
1349
+ pending.hadCalls = true;
1350
+ this._openFunctionCalls.set(item.callId, key);
1351
+ await this.runFunctionCalls([
1352
+ new FunctionCallFromLLM({
1353
+ context: this._context,
1354
+ toolCallId: item.callId,
1355
+ functionName: item.name,
1356
+ arguments: args,
1357
+ }),
1358
+ ]);
1359
+ }
1360
+ /**
1361
+ * Note that a response emitted all of its output items.
1362
+ *
1363
+ * The lifecycle snapshot's `output` is empty even here, so the calls
1364
+ * collected from the individual item events are what has to be answered.
1365
+ */
1366
+ async _handleResponseFinished(evt) {
1367
+ const innerType = evt.innerType;
1368
+ if (innerType === "response.completed") {
1369
+ const response = evt.event.response;
1370
+ await this._reportBackendUsage((pyTruthy(response) ? response : {}));
1371
+ }
1372
+ else if (innerType === "response.failed" || innerType === "response.incomplete") {
1373
+ const rawResponse = evt.event.response;
1374
+ const response = (pyTruthy(rawResponse) ? rawResponse : {});
1375
+ const rawError = pyTruthy(response.error)
1376
+ ? response.error
1377
+ : pyTruthy(response.incomplete_details)
1378
+ ? response.incomplete_details
1379
+ : {};
1380
+ let message = null;
1381
+ if (typeof rawError === "object" && rawError !== null && !Array.isArray(rawError)) {
1382
+ const error = rawError;
1383
+ message = pyTruthy(error.message) ? error.message : error.reason;
1384
+ }
1385
+ let status = "unknown";
1386
+ if ("status" in response)
1387
+ status = response.status ?? "None";
1388
+ await this.pushError(`Delegated response ${innerType.replace(/^response\./, "")}: ` +
1389
+ `${pyTruthy(message) ? String(message) : String(status)}`);
1390
+ }
1391
+ const key = _correlationKey(evt);
1392
+ const pending = this._pendingResponses.get(key);
1393
+ if (pending === undefined) {
1394
+ return;
1395
+ }
1396
+ pending.finished = true;
1397
+ await this._maybeContinueResponse(key);
1398
+ }
1399
+ /** Continue a response once every function call it made has been answered. */
1400
+ async _maybeContinueResponse(key) {
1401
+ const pending = this._pendingResponses.get(key);
1402
+ if (pending === undefined ||
1403
+ !pending.finished ||
1404
+ !pending.hadCalls ||
1405
+ pending.callIds.size > 0) {
1406
+ return;
1407
+ }
1408
+ this._pendingResponses.delete(key);
1409
+ logger.debug(`${this}: continuing delegated work for ${key}`);
1410
+ await this.sendClientEvent(new events.ResponseCreateEvent());
1411
+ }
1412
+ async _handleFunctionCallResult(frame) {
1413
+ if (!this._openFunctionCalls.has(frame.toolCallId)) {
1414
+ return;
1415
+ }
1416
+ const isFinal = frame.properties ? frame.properties.isFinal : true;
1417
+ if (!isFinal) {
1418
+ logger.warning(`${this}: the Live API accepts one output per function call; dropping ` +
1419
+ `intermediate result for ${frame.functionName}:${frame.toolCallId}`);
1420
+ return;
1421
+ }
1422
+ // Same encoding the assistant aggregator records in the context.
1423
+ const output = pyTruthy(frame.result)
1424
+ ? pyJsonDumps(frame.result, { ensureAscii: false })
1425
+ : "COMPLETED";
1426
+ await this._sendFunctionCallOutput(frame.toolCallId, output);
1427
+ }
1428
+ async _handleFunctionCallCancel(frame) {
1429
+ if (!this._openFunctionCalls.has(frame.toolCallId)) {
1430
+ return;
1431
+ }
1432
+ await this._sendFunctionCallOutput(frame.toolCallId, pyJsonDumps({ error: "The function call was cancelled before it produced a result." }));
1433
+ }
1434
+ /** Queue one function result, and continue the response once all are in. */
1435
+ async _sendFunctionCallOutput(callId, output) {
1436
+ const key = this._openFunctionCalls.get(callId) ?? "";
1437
+ this._openFunctionCalls.delete(callId);
1438
+ logger.debug(`${this}: sending function call output for ${callId}`);
1439
+ await this.sendClientEvent(new events.ResponseItemCreateEvent({
1440
+ item: new events.FunctionCallOutputItem({ callId, output }),
1441
+ }));
1442
+ const pending = this._pendingResponses.get(key);
1443
+ if (pending !== undefined) {
1444
+ pending.callIds.delete(callId);
1445
+ }
1446
+ await this._maybeContinueResponse(key);
1447
+ }
1448
+ //
1449
+ // usage metrics
1450
+ //
1451
+ /**
1452
+ * Log the session's cumulative live audio duration.
1453
+ *
1454
+ * The live model's usage is reported as seconds rather than tokens. Token
1455
+ * metrics come from the backend model instead, on the nested Responses
1456
+ * completion events.
1457
+ */
1458
+ async _reportUsage(usage) {
1459
+ if (usage.seconds !== null && usage.seconds !== undefined) {
1460
+ logger.debug(`${this}: live audio usage: ${usage.seconds.toFixed(1)}s (cumulative)`);
1461
+ }
1462
+ }
1463
+ /** Report the backend model's token usage from a completed delegated response. */
1464
+ async _reportBackendUsage(response) {
1465
+ const usage = response.usage;
1466
+ if (typeof usage !== "object" || usage === null || Array.isArray(usage)) {
1467
+ return;
1468
+ }
1469
+ const u = usage;
1470
+ const details = (pyTruthy(u.input_tokens_details) ? u.input_tokens_details : {});
1471
+ const outputDetails = (pyTruthy(u.output_tokens_details) ? u.output_tokens_details : {});
1472
+ const tokens = new LLMTokenUsage({
1473
+ promptTokens: u.input_tokens || 0,
1474
+ completionTokens: u.output_tokens || 0,
1475
+ totalTokens: u.total_tokens || 0,
1476
+ cacheReadInputTokens: details.cached_tokens || 0,
1477
+ reasoningTokens: outputDetails.reasoning_tokens || 0,
1478
+ });
1479
+ if (tokens.totalTokens > 0) {
1480
+ await this.startLlmUsageMetrics(tokens);
1481
+ }
1482
+ }
1483
+ }
1484
+ /**
1485
+ * Identify the delegated work a wrapped Responses event belongs to.
1486
+ *
1487
+ * The envelope's `delegationId` is the correlator every wrapped event
1488
+ * carries; the Responses events inside do not all name their response. An
1489
+ * envelope without one falls back to a shared key, which keeps a single
1490
+ * delegated response's events together.
1491
+ */
1492
+ function _correlationKey(evt) {
1493
+ return evt.delegationId || _UNCORRELATED_DELEGATION;
1494
+ }
1495
+ /** Return the text of a trailing developer message, if the history ends with one. */
1496
+ export function _trailingDeveloperText(items) {
1497
+ const last = items[items.length - 1];
1498
+ if (last === undefined || last.role !== "developer") {
1499
+ return null;
1500
+ }
1501
+ return (last.content
1502
+ .filter((part) => part.text)
1503
+ .map((part) => part.text)
1504
+ .join("\n")
1505
+ .trim() || null);
1506
+ }
1507
+ /**
1508
+ * Build `delegation.responses` from the nested Responses settings.
1509
+ *
1510
+ * Mirrors how `OpenAIResponsesLLMService` builds a request from the same
1511
+ * settings, minus what the Live API owns for a delegated model.
1512
+ */
1513
+ export function _responsesDelegationConfig(delegation) {
1514
+ const settings = delegation.settings;
1515
+ const isSet = (value) => isGiven(value) && value !== null && value !== OPENAI_NOT_GIVEN;
1516
+ const config = { model: settings.model };
1517
+ if (isSet(settings.systemInstruction)) {
1518
+ config.instructions = settings.systemInstruction;
1519
+ }
1520
+ if (typeof settings.maxCompletionTokens === "number" &&
1521
+ Number.isInteger(settings.maxCompletionTokens)) {
1522
+ config.max_output_tokens = settings.maxCompletionTokens;
1523
+ }
1524
+ if (settings.reasoning instanceof OpenAIResponsesReasoningConfig) {
1525
+ const reasoning = {};
1526
+ for (const [field, value] of [
1527
+ ["effort", settings.reasoning.effort],
1528
+ ["summary", settings.reasoning.summary],
1529
+ ["mode", settings.reasoning.mode],
1530
+ ]) {
1531
+ if (value !== null && value !== undefined)
1532
+ reasoning[field] = value;
1533
+ }
1534
+ config.reasoning = reasoning;
1535
+ }
1536
+ if (delegation.serviceTier !== null) {
1537
+ config.service_tier = delegation.serviceTier;
1538
+ }
1539
+ const unsupportedFields = [
1540
+ ["temperature", "temperature"],
1541
+ ["topP", "top_p"],
1542
+ ["frequencyPenalty", "frequency_penalty"],
1543
+ ["presencePenalty", "presence_penalty"],
1544
+ ["seed", "seed"],
1545
+ ["topK", "top_k"],
1546
+ ["maxTokens", "max_tokens"],
1547
+ ["userTurnCompletionConfig", "user_turn_completion_config"],
1548
+ ];
1549
+ const unsupported = unsupportedFields
1550
+ .filter(([field]) => isSet(settings[field]))
1551
+ .map(([, name]) => name);
1552
+ if (isSet(settings.filterIncompleteUserTurns) && settings.filterIncompleteUserTurns) {
1553
+ unsupported.push("filter_incomplete_user_turns");
1554
+ }
1555
+ if (unsupported.length > 0) {
1556
+ logger.warning("Dropping Responses settings the OpenAI Live API doesn't accept for a delegated " +
1557
+ `model: [${unsupported.map((n) => `'${n}'`).join(", ")}]`);
1558
+ }
1559
+ Object.assign(config, settings.extra);
1560
+ return config;
1561
+ }
1562
+ const _SENTENCE_BOUNDARY = /(?<=[.!?])\s+|\n+/;
1563
+ /**
1564
+ * Estimate how many tokens a piece of text costs.
1565
+ *
1566
+ * A byte-pair encoding packs about four characters of ASCII into a token.
1567
+ * Other scripts cost more per character — CJK runs close to one token each —
1568
+ * so anything outside ASCII is counted whole.
1569
+ */
1570
+ export function _estimatedTokens(text) {
1571
+ return Math.floor((_tokenQuarters(text) + 3) / 4);
1572
+ }
1573
+ /** Return a text's cost in quarter-tokens: one for an ASCII character, four for any other. */
1574
+ export function _tokenQuarters(text) {
1575
+ let cost = 0;
1576
+ for (const char of text) {
1577
+ cost += char.codePointAt(0) < 128 ? 1 : 4;
1578
+ }
1579
+ return cost;
1580
+ }
1581
+ /**
1582
+ * Split off the longest prefix of `text` that fits `tokenLimit`.
1583
+ *
1584
+ * The cut lands on the last space within the limit, so words stay whole.
1585
+ */
1586
+ export function _splitAtTokenLimit(text, tokenLimit) {
1587
+ // Indexed by code point, as Python indexes a str.
1588
+ const chars = Array.from(text);
1589
+ const budget = tokenLimit * 4;
1590
+ let end = chars.length;
1591
+ let cost = 0;
1592
+ for (let index = 0; index < chars.length; index++) {
1593
+ cost += _tokenQuarters(chars[index]);
1594
+ if (cost > budget) {
1595
+ end = Math.max(index, 1);
1596
+ break;
1597
+ }
1598
+ }
1599
+ let space = -1;
1600
+ for (let i = end - 1; i >= 0; i--) {
1601
+ if (chars[i] === " ") {
1602
+ space = i;
1603
+ break;
1604
+ }
1605
+ }
1606
+ if (space > 0) {
1607
+ end = space;
1608
+ }
1609
+ return [chars.slice(0, end).join("").trim(), chars.slice(end).join("").trim()];
1610
+ }
1611
+ /**
1612
+ * Split text into chunks of at most `tokenLimit` estimated tokens.
1613
+ *
1614
+ * Chunks break on sentence boundaries; a sentence longer than the limit is
1615
+ * split at a space.
1616
+ */
1617
+ export function _chunkText(text, tokenLimit) {
1618
+ text = text.trim();
1619
+ if (!text) {
1620
+ return [];
1621
+ }
1622
+ if (_estimatedTokens(text) <= tokenLimit) {
1623
+ return [text];
1624
+ }
1625
+ const chunks = [];
1626
+ let current = "";
1627
+ const flush = () => {
1628
+ if (current) {
1629
+ chunks.push(current);
1630
+ current = "";
1631
+ }
1632
+ };
1633
+ for (let piece of text.split(_SENTENCE_BOUNDARY)) {
1634
+ piece = piece.trim();
1635
+ while (_estimatedTokens(piece) > tokenLimit) {
1636
+ const [head, rest] = _splitAtTokenLimit(piece, tokenLimit);
1637
+ piece = rest;
1638
+ flush();
1639
+ chunks.push(head);
1640
+ }
1641
+ if (!piece) {
1642
+ continue;
1643
+ }
1644
+ if (current && _estimatedTokens(`${current} ${piece}`) > tokenLimit) {
1645
+ flush();
1646
+ }
1647
+ current = `${current} ${piece}`.trim();
1648
+ }
1649
+ flush();
1650
+ return chunks;
1651
+ }
1652
+ //# sourceMappingURL=llm.js.map