@pipedog/openai 0.0.0-stage → 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.
- package/LICENSE +25 -0
- package/NOTICE +9 -0
- package/dist/_constants.d.ts +4 -0
- package/dist/_constants.js +9 -0
- package/dist/base_llm.d.ts +272 -0
- package/dist/base_llm.js +619 -0
- package/dist/image.d.ts +116 -0
- package/dist/image.js +179 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +12 -0
- package/dist/live/events.d.ts +768 -0
- package/dist/live/events.js +1020 -0
- package/dist/live/index.d.ts +2 -0
- package/dist/live/index.js +8 -0
- package/dist/live/llm.d.ts +566 -0
- package/dist/live/llm.js +1652 -0
- package/dist/llm.d.ts +30 -0
- package/dist/llm.js +50 -0
- package/dist/open_ai_live_adapter.d.ts +94 -0
- package/dist/open_ai_live_adapter.js +192 -0
- package/dist/open_ai_realtime_adapter.d.ts +95 -0
- package/dist/open_ai_realtime_adapter.js +221 -0
- package/dist/open_ai_responses_adapter.d.ts +95 -0
- package/dist/open_ai_responses_adapter.js +358 -0
- package/dist/realtime/events.d.ts +1401 -0
- package/dist/realtime/events.js +1639 -0
- package/dist/realtime/index.d.ts +2 -0
- package/dist/realtime/index.js +8 -0
- package/dist/realtime/llm.d.ts +457 -0
- package/dist/realtime/llm.js +1550 -0
- package/dist/responses/index.d.ts +2 -0
- package/dist/responses/index.js +8 -0
- package/dist/responses/llm.d.ts +448 -0
- package/dist/responses/llm.js +1517 -0
- package/dist/stt.d.ts +409 -0
- package/dist/stt.js +741 -0
- package/dist/tts.d.ts +86 -0
- package/dist/tts.js +238 -0
- package/package.json +56 -4
- package/README.md +0 -3
package/dist/live/llm.js
ADDED
|
@@ -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
|