@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.
@@ -0,0 +1,1517 @@
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/responses/llm.py
6
+ /**
7
+ * OpenAI Responses API LLM service implementations (WebSocket and HTTP).
8
+ *
9
+ * Porting notes:
10
+ * - Upstream's `_BaseOpenAIResponsesLLMService` is mixed into two different
11
+ * bases (`LLMService` for HTTP, `WebsocketLLMService` for WebSocket), so it
12
+ * is a class-factory mixin here (`BaseOpenAIResponsesLLMServiceMixin`),
13
+ * with `_BaseOpenAIResponsesLLMService` as its plain-class form.
14
+ * `instanceof _BaseOpenAIResponsesLLMService` holds for both services.
15
+ * The mixin's members are public (a mixin can't export protected members).
16
+ * - The HTTP client is the `openai` JS SDK, which runs over `fetch`. Its
17
+ * streamed events are plain objects, so upstream's `isinstance` checks on
18
+ * the SDK's event classes are checks of the event's `type`. Upstream's
19
+ * `DefaultAsyncHttpxClient(limits=connection_limits(...))` has no `fetch`
20
+ * counterpart; connection reuse is left to the runtime. The SDK's
21
+ * `APITimeoutError` is `APIConnectionTimeoutError`.
22
+ * - The SDK's `NOT_GIVEN` sentinel is `undefined` (`OPENAI_NOT_GIVEN`). It is
23
+ * stored in the `temperature`, `topP` and `maxCompletionTokens` settings
24
+ * after the defaults are built, since an `undefined` option means "not
25
+ * given" to Pipecat.
26
+ * - The tracing decorator is applied in a static block (decorator syntax is
27
+ * not erasable).
28
+ */
29
+ import { abortable, CancelledError, currentSignal, monotonic, rethrowIfCancelled, TimeoutError, waitFor, } from "@pipedog/asyncio";
30
+ import { OPENAI_NOT_GIVEN, } from "@pipedog/core/adapters/services/open_ai_adapter";
31
+ import { FunctionCallFromLLM, LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMMessagesAppendFrame, LLMThoughtEndFrame, LLMThoughtStartFrame, LLMThoughtTextFrame, } from "@pipedog/core/frames/frames";
32
+ import { LLMTokenUsage } from "@pipedog/core/metrics/metrics";
33
+ import { LLMService, WebsocketLLMService, WebsocketReconnectedError, } from "@pipedog/core/services/llm_service";
34
+ import { LLMSettings } from "@pipedog/core/services/settings";
35
+ import { ConnectionClosed } from "@pipedog/core/services/websocket_service";
36
+ import { getEnv } from "@pipedog/core/utils/env";
37
+ import { isTimeoutException } from "@pipedog/core/utils/http";
38
+ import { logger } from "@pipedog/core/utils/logger";
39
+ import { addVirtualBase, defineMixin } from "@pipedog/core/utils/mixin";
40
+ import { tracedLlm } from "@pipedog/core/utils/tracing/service_decorators";
41
+ import { assertGiven, NOT_GIVEN } from "@pipedog/core/utils/types";
42
+ import OpenAI, { APIConnectionTimeoutError } from "openai";
43
+ import { OPENAI_MODEL_WITHOUT_RESPONSE_SCHEMA } from "../base_llm.js";
44
+ import { OpenAIResponsesLLMAdapter, } from "../open_ai_responses_adapter.js";
45
+ function errorMessage(e) {
46
+ return e instanceof Error ? e.message : String(e);
47
+ }
48
+ /** `json.dumps(value, indent=2, default=str)`, for log lines. */
49
+ function jsonIndent(value) {
50
+ return JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? String(v) : v), 2);
51
+ }
52
+ /** `json.dumps(value, sort_keys=True)`: keys sorted at every level. */
53
+ function jsonSortKeys(value) {
54
+ return JSON.stringify(value, (_k, v) => {
55
+ if (v !== null && typeof v === "object" && !Array.isArray(v)) {
56
+ return Object.fromEntries(Object.keys(v)
57
+ .sort()
58
+ .map((key) => [key, v[key]]));
59
+ }
60
+ return v;
61
+ });
62
+ }
63
+ /** Hex SHA-256 digest of a string (`hashlib.sha256(s.encode()).hexdigest()`). */
64
+ async function sha256Hex(text) {
65
+ const digest = await abortable(crypto.subtle.digest("SHA-256", new TextEncoder().encode(text)));
66
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
67
+ }
68
+ // ---------------------------------------------------------------------------
69
+ // Private retry exception classes
70
+ // ---------------------------------------------------------------------------
71
+ /** Base for errors that should trigger a retry in `_processContext`. */
72
+ export class _RetryableError extends Error {
73
+ name = "_RetryableError";
74
+ }
75
+ /** Server could not find the previous response (connection-local cache miss). */
76
+ export class _PreviousResponseNotFoundError extends _RetryableError {
77
+ name = "_PreviousResponseNotFoundError";
78
+ }
79
+ /** WebSocket connection hit the 60-minute server-side limit. */
80
+ export class _ConnectionLimitReachedError extends _RetryableError {
81
+ name = "_ConnectionLimitReachedError";
82
+ }
83
+ /** Response did not begin producing output within the retry timeout. */
84
+ export class _ResponseTimeoutError extends _RetryableError {
85
+ name = "_ResponseTimeoutError";
86
+ }
87
+ /**
88
+ * Reasoning configuration for reasoning-capable OpenAI Responses models.
89
+ *
90
+ * Only reasoning-capable models use this — the mainline gpt series from gpt-5
91
+ * onward and the o-series. The service's default model, `gpt-4.1`, does not
92
+ * reason, so this config has no effect there; select a reasoning-capable model
93
+ * to use it. See OpenAI's reasoning guide
94
+ * (https://platform.openai.com/docs/guides/reasoning) and model list
95
+ * (https://platform.openai.com/docs/models) to choose one and to check which
96
+ * effort levels it accepts.
97
+ *
98
+ * Reasoning models use internal reasoning tokens before producing a response.
99
+ * This controls how much reasoning they do and whether a human-readable
100
+ * summary of it is returned.
101
+ *
102
+ * - `effort`: how much reasoning effort the model applies. `null` (the
103
+ * default) leaves the field unset, so the model's own default applies;
104
+ * `"none"` disables reasoning for latency-sensitive use and `"max"` applies
105
+ * the model's deepest reasoning. At lower efforts (e.g. `"low"`) the model
106
+ * reasons only when a turn calls for it, so simple prompts may produce no
107
+ * summary at all.
108
+ * - `summary`: verbosity of the reasoning summary to return. `null` (the
109
+ * default) requests no summary. Any summary is surfaced via thought frames
110
+ * (the `on_assistant_thought` event); the encrypted reasoning itself is
111
+ * preserved across turns regardless of this setting.
112
+ * - `mode`: reasoning mode for models that offer one, such as the gpt-5.6
113
+ * series: `"standard"` or the slower, more thorough `"pro"`. `null` (the
114
+ * default) leaves the field unset, so the model's own default applies.
115
+ * `effort` selects the reasoning intensity within the chosen mode.
116
+ */
117
+ export class OpenAIResponsesReasoningConfig {
118
+ // `| string` for forward compatibility: if OpenAI adds new levels, users can
119
+ // pass the new string without waiting for a Pipecat release.
120
+ effort;
121
+ summary;
122
+ mode;
123
+ constructor(options = {}) {
124
+ this.effort = options.effort ?? null;
125
+ this.summary = options.summary ?? null;
126
+ this.mode = options.mode ?? null;
127
+ }
128
+ /**
129
+ * The config as a dict of its wire fields (pydantic's `model_dump`).
130
+ *
131
+ * @param options.excludeNone Leave out fields whose value is `null`.
132
+ */
133
+ modelDump(options = {}) {
134
+ const result = {
135
+ effort: this.effort,
136
+ summary: this.summary,
137
+ mode: this.mode,
138
+ };
139
+ if (options.excludeNone) {
140
+ for (const [key, value] of Object.entries(result)) {
141
+ if (value === null)
142
+ delete result[key];
143
+ }
144
+ }
145
+ return result;
146
+ }
147
+ }
148
+ /**
149
+ * Settings for OpenAI Responses API LLM services.
150
+ *
151
+ * - `maxCompletionTokens`: maximum completion tokens to generate.
152
+ * - `reasoning`: reasoning configuration for reasoning-capable models. `null`
153
+ * (the default) leaves reasoning unconfigured: the service then sends the
154
+ * lowest effort OpenAI documents for the model, for the fastest responses,
155
+ * and logs what it sent. Models Pipecat doesn't list, and any model when
156
+ * `useProviderThinkingDefault` is set, run at OpenAI's default. The default
157
+ * model, `gpt-4.1`, does not reason.
158
+ *
159
+ * The inherited `temperature` and `topP` fields also hold the OpenAI SDK's
160
+ * sentinel (`OPENAI_NOT_GIVEN`), which the service stores so these fields
161
+ * can be passed through unchanged to the client.
162
+ */
163
+ export class OpenAIResponsesLLMSettings extends LLMSettings {
164
+ maxCompletionTokens = NOT_GIVEN;
165
+ reasoning = NOT_GIVEN;
166
+ constructor(options = {}) {
167
+ super(options);
168
+ this._assignOptions(options);
169
+ }
170
+ }
171
+ // ---------------------------------------------------------------------------
172
+ // Model classification
173
+ // ---------------------------------------------------------------------------
174
+ /** Whether the model is an o-series reasoning model (o1, o3, o4-mini, ...). */
175
+ export function _isOSeries(model) {
176
+ return /^o\d/.test(model.toLowerCase());
177
+ }
178
+ // The lowest reasoning effort OpenAI documents for each model, sent when the
179
+ // caller doesn't configure `reasoning`, keyed by model name. The pro models
180
+ // accept nothing below "medium", and gpt-5-pro only "high".
181
+ export const _THINKING_DEFAULTS = {
182
+ "gpt-5": "minimal",
183
+ "gpt-5-mini": "minimal",
184
+ "gpt-5-nano": "minimal",
185
+ "gpt-5-pro": "high",
186
+ "gpt-5.1": "none",
187
+ "gpt-5.2": "none",
188
+ "gpt-5.2-pro": "medium",
189
+ "gpt-5.4": "none",
190
+ "gpt-5.4-mini": "none",
191
+ "gpt-5.4-nano": "none",
192
+ "gpt-5.4-pro": "medium",
193
+ "gpt-5.5": "none",
194
+ "gpt-5.5-pro": "medium",
195
+ "gpt-5.6-luna": "none",
196
+ "gpt-5.6-sol": "none",
197
+ "gpt-5.6-terra": "none",
198
+ "gpt-6-astra": "low",
199
+ "gpt-6-luna": "none",
200
+ "gpt-6-sol": "none",
201
+ "gpt-6.1-sol": "low",
202
+ o1: "low",
203
+ "o1-pro": "low",
204
+ o3: "low",
205
+ "o3-mini": "low",
206
+ "o3-pro": "low",
207
+ "o4-mini": "low",
208
+ };
209
+ /**
210
+ * The name a model id refers to, the key it's looked up by in
211
+ * `_THINKING_DEFAULTS`.
212
+ *
213
+ * A dated snapshot (`gpt-5-2025-08-07`) gets its model's entry.
214
+ */
215
+ export function _modelName(model) {
216
+ return model.toLowerCase().replace(/-\d{4}-\d{2}-\d{2}$/, "");
217
+ }
218
+ /**
219
+ * Classify whether an OpenAI model supports reasoning.
220
+ *
221
+ * Assumes that future (beyond gpt-5.x) mainline gpt series models will also
222
+ * support reasoning. This can be revisited if OpenAI changes their model
223
+ * lineup or reasoning support in the future.
224
+ *
225
+ * @param model The model name (e.g. `"gpt-5.4"`, `"o3"`, `"gpt-4.1"`).
226
+ * @returns `true` for reasoning-capable models — the o-series and the
227
+ * mainline gpt series from gpt-5 onward. `false` for models known *not* to
228
+ * reason: gpt-4.x and earlier, and the `gpt-5-chat` non-reasoning variant.
229
+ * `null` when the model is unrecognized and we can't tell either way.
230
+ */
231
+ export function _modelSupportsReasoning(model) {
232
+ const lower = model.toLowerCase();
233
+ if (_isOSeries(lower)) {
234
+ return true;
235
+ }
236
+ // Mainline gpt series: reasons from gpt-5 onward. `gpt-5-chat` is the
237
+ // non-reasoning variant of the series.
238
+ const match = /^gpt-(\d+)/.exec(lower);
239
+ if (match) {
240
+ return Number.parseInt(match[1], 10) >= 5 && !lower.includes("chat");
241
+ }
242
+ return null;
243
+ }
244
+ /**
245
+ * Shared base for HTTP and WebSocket OpenAI Responses API services.
246
+ *
247
+ * Contains settings, adapter reference, HTTP client creation, parameter
248
+ * building, `runInference`, and metrics support. Subclasses implement
249
+ * `processFrame` and `_processContext` for their transport.
250
+ *
251
+ * A mixin over an `LLMService` base: upstream's
252
+ * `class OpenAIResponsesLLMService(_BaseOpenAIResponsesLLMService, WebsocketLLMService)`
253
+ * is `class OpenAIResponsesLLMService extends BaseOpenAIResponsesLLMServiceMixin(WebsocketLLMService)`.
254
+ */
255
+ export const BaseOpenAIResponsesLLMServiceMixin = defineMixin("_BaseOpenAIResponsesLLMService", (Base) => {
256
+ // The base must be an LLMService (upstream's base subclasses it).
257
+ const LLMBase = Base;
258
+ class BaseOpenAIResponsesLLMServiceImpl extends LLMBase {
259
+ static Settings = OpenAIResponsesLLMSettings;
260
+ static ReasoningConfig = OpenAIResponsesReasoningConfig;
261
+ static adapterClass = OpenAIResponsesLLMAdapter;
262
+ static supportsResponseSchema = true;
263
+ _apiKey;
264
+ _serviceTier;
265
+ _retryTimeoutSecs;
266
+ _retryOnTimeout;
267
+ // Tracks the model we've already warned about (reasoning configured on a
268
+ // non-reasoning model) so we log it once per model rather than per turn.
269
+ _reasoningUnsupportedWarnedFor = null;
270
+ _client;
271
+ // Used by tracedLlm for a more detailed model name in tracing spans.
272
+ _fullModelName = null;
273
+ /**
274
+ * Initialize the OpenAI Responses API LLM service.
275
+ *
276
+ * @param options.apiKey OpenAI API key. If null, uses environment variable.
277
+ * @param options.baseUrl Custom base URL for OpenAI API. If null, uses default.
278
+ * @param options.organization OpenAI organization ID.
279
+ * @param options.project OpenAI project ID.
280
+ * @param options.defaultHeaders Additional HTTP headers to include in requests.
281
+ * @param options.serviceTier Service tier to use (see
282
+ * `BaseOpenAIResponsesLLMServiceOptions.serviceTier`).
283
+ * @param options.settings Runtime-updatable settings.
284
+ * @param options.retryTimeoutSecs How long an inference may go without
285
+ * producing output before it is abandoned and re-issued, when
286
+ * `retryOnTimeout` is set. Defaults to 5.0 seconds.
287
+ * @param options.retryOnTimeout Whether to re-issue the request once if the
288
+ * first attempt produces no output within `retryTimeoutSecs`. The retry
289
+ * is unbounded.
290
+ * Other options are passed to the parent LLMService.
291
+ */
292
+ constructor(...args) {
293
+ const options = (args[0] ?? {});
294
+ const settingsClass = new.target.Settings ??
295
+ OpenAIResponsesLLMSettings;
296
+ const defaultSettings = new settingsClass({
297
+ model: "gpt-4.1",
298
+ systemInstruction: null,
299
+ frequencyPenalty: null,
300
+ presencePenalty: null,
301
+ seed: null,
302
+ topK: null,
303
+ maxTokens: null,
304
+ reasoning: null,
305
+ filterIncompleteUserTurns: false,
306
+ userTurnCompletionConfig: null,
307
+ extra: {},
308
+ });
309
+ // The OpenAI SDK's sentinel can't travel in an options object (an
310
+ // `undefined` option means "not given" to Pipecat), so it is stored
311
+ // here.
312
+ const raw = defaultSettings;
313
+ raw.temperature = OPENAI_NOT_GIVEN;
314
+ raw.topP = OPENAI_NOT_GIVEN;
315
+ raw.maxCompletionTokens = OPENAI_NOT_GIVEN;
316
+ if (options.settings !== null && options.settings !== undefined) {
317
+ defaultSettings.applyUpdate(options.settings);
318
+ }
319
+ super({ ...options, settings: defaultSettings });
320
+ // Resolve the API key from the environment if not provided. The
321
+ // AsyncOpenAI HTTP client does this automatically, but the WebSocket
322
+ // variant connects via raw websockets and needs the key explicitly.
323
+ this._apiKey = options.apiKey || getEnv("OPENAI_API_KEY") || null;
324
+ this._serviceTier = options.serviceTier ?? null;
325
+ this._retryTimeoutSecs =
326
+ options.retryTimeoutSecs === undefined ? 5.0 : options.retryTimeoutSecs;
327
+ this._retryOnTimeout = options.retryOnTimeout === undefined ? false : options.retryOnTimeout;
328
+ this._client = this._createClient({
329
+ apiKey: options.apiKey ?? null,
330
+ baseUrl: options.baseUrl ?? null,
331
+ organization: options.organization ?? null,
332
+ project: options.project ?? null,
333
+ defaultHeaders: options.defaultHeaders ?? null,
334
+ });
335
+ if (this._settings.systemInstruction) {
336
+ logger.debug(`${this}: Using system instruction: ${this._settings.systemInstruction}`);
337
+ }
338
+ }
339
+ /**
340
+ * Create an AsyncOpenAI client instance.
341
+ *
342
+ * @param options.apiKey OpenAI API key.
343
+ * @param options.baseUrl Custom base URL for the API.
344
+ * @param options.organization OpenAI organization ID.
345
+ * @param options.project OpenAI project ID.
346
+ * @param options.defaultHeaders Additional HTTP headers.
347
+ * @returns Configured AsyncOpenAI client instance.
348
+ */
349
+ _createClient(options = {}) {
350
+ return new OpenAI({
351
+ apiKey: options.apiKey ?? undefined,
352
+ baseURL: options.baseUrl ?? undefined,
353
+ organization: options.organization ?? null,
354
+ project: options.project ?? null,
355
+ defaultHeaders: options.defaultHeaders ?? undefined,
356
+ });
357
+ }
358
+ /** Check if this service can generate processing metrics. */
359
+ canGenerateMetrics() {
360
+ return true;
361
+ }
362
+ /**
363
+ * Build parameters for a Responses API call.
364
+ *
365
+ * @param invocationParams Parameters derived from the LLM context.
366
+ * @returns Dictionary of parameters for the Responses API call.
367
+ */
368
+ _buildResponseParams(invocationParams) {
369
+ const params = {
370
+ model: this._settings.model,
371
+ stream: true,
372
+ // store=false avoids OpenAI-side 30-day conversation storage.
373
+ // The WebSocket variant's previous_response_id optimization
374
+ // still works with store=false because it uses a connection-local
375
+ // in-memory cache. See the class docstrings for details.
376
+ store: false,
377
+ input: invocationParams.input,
378
+ };
379
+ // instructions (set by the adapter when input is non-empty)
380
+ if ("instructions" in invocationParams) {
381
+ params.instructions = invocationParams.instructions;
382
+ }
383
+ // Optional parameters - only include if given
384
+ if (typeof this._settings.temperature === "number") {
385
+ params.temperature = this._settings.temperature;
386
+ }
387
+ if (typeof this._settings.topP === "number") {
388
+ params.top_p = this._settings.topP;
389
+ }
390
+ if (typeof this._settings.maxCompletionTokens === "number" &&
391
+ Number.isInteger(this._settings.maxCompletionTokens)) {
392
+ params.max_output_tokens = this._settings.maxCompletionTokens;
393
+ }
394
+ if (this._serviceTier !== null) {
395
+ params.service_tier = this._serviceTier;
396
+ }
397
+ // Tools
398
+ const tools = invocationParams.tools;
399
+ if (tools !== null && tools !== OPENAI_NOT_GIVEN) {
400
+ params.tools = tools;
401
+ }
402
+ // Reasoning
403
+ const reasoning = assertGiven(this._settings.reasoning);
404
+ const reasoningParams = reasoning ? reasoning.modelDump({ excludeNone: true }) : {};
405
+ if (Object.keys(reasoningParams).length > 0) {
406
+ params.reasoning = reasoningParams;
407
+ // Ask for the encrypted reasoning so it can be sent back on later
408
+ // turns, preserving reasoning context across the conversation.
409
+ params.include = ["reasoning.encrypted_content"];
410
+ this._warnIfReasoningUnsupported();
411
+ }
412
+ // Extra settings
413
+ Object.assign(params, this._settings.extra);
414
+ // Applied last, so reasoning configured in the settings or in extra wins
415
+ // over the low-latency default.
416
+ this._maybeApplyThinkingDefault(params);
417
+ return params;
418
+ }
419
+ /**
420
+ * Whether a model can enforce a response schema.
421
+ *
422
+ * OpenAI models before gpt-4o-mini and gpt-4o-2024-08-06 cannot.
423
+ *
424
+ * @param model The model name.
425
+ */
426
+ static modelSupportsResponseSchema(model) {
427
+ return !OPENAI_MODEL_WITHOUT_RESPONSE_SCHEMA.test(model);
428
+ }
429
+ /**
430
+ * Run a one-shot, out-of-band inference with the given LLM context.
431
+ *
432
+ * Always uses the HTTP client regardless of transport variant.
433
+ *
434
+ * @param context The LLM context containing conversation history.
435
+ * @param options.maxTokens Optional maximum number of tokens to generate.
436
+ * @param options.systemInstruction Optional system instruction for this
437
+ * inference.
438
+ * @param options.responseSchema Optional JSON schema the reply must
439
+ * follow. The service asks the provider to enforce it, so the reply is
440
+ * JSON text matching the schema.
441
+ * @returns The LLM's response as a string, or null if no response is
442
+ * generated.
443
+ */
444
+ async runInference(context, options = {}) {
445
+ const adapter = this.getLlmAdapter();
446
+ const effectiveInstruction = options.systemInstruction || assertGiven(this._settings.systemInstruction);
447
+ const invocationParams = await adapter.getLlmInvocationParams(context, {
448
+ systemInstruction: effectiveInstruction,
449
+ });
450
+ const params = this._buildResponseParams(invocationParams);
451
+ // Override for non-streaming
452
+ params.stream = false;
453
+ if (options.maxTokens !== null && options.maxTokens !== undefined) {
454
+ params.max_output_tokens = options.maxTokens;
455
+ }
456
+ const responseSchema = this._checkResponseSchema(options.responseSchema);
457
+ if (responseSchema !== null) {
458
+ params.text = {
459
+ format: {
460
+ type: "json_schema",
461
+ name: "response",
462
+ schema: responseSchema,
463
+ strict: true,
464
+ },
465
+ };
466
+ }
467
+ const response = (await abortable(this._client.responses.create(params, { signal: currentSignal() })));
468
+ return response.output_text;
469
+ }
470
+ /**
471
+ * Convert accumulated function call data into FunctionCallFromLLM list.
472
+ *
473
+ * @param context The LLM context for the current inference.
474
+ * @param functionCalls Map of item_id to {name, call_id, arguments}.
475
+ * @returns List of parsed function call objects.
476
+ */
477
+ _processFunctionCalls(context, functionCalls) {
478
+ const fcList = [];
479
+ for (const fc of functionCalls.values()) {
480
+ let args;
481
+ try {
482
+ args = fc.arguments ? JSON.parse(fc.arguments) : {};
483
+ }
484
+ catch (e) {
485
+ if (!(e instanceof SyntaxError))
486
+ throw e;
487
+ logger.warning(`${this}: Failed to parse function call arguments: ${fc.arguments}`);
488
+ args = {};
489
+ }
490
+ fcList.push(new FunctionCallFromLLM({
491
+ context,
492
+ toolCallId: fc.call_id,
493
+ functionName: fc.name,
494
+ arguments: args,
495
+ }));
496
+ }
497
+ return fcList;
498
+ }
499
+ // -- reasoning ------------------------------------------------------------
500
+ /**
501
+ * Send the model's lowest reasoning effort when no reasoning is configured.
502
+ *
503
+ * Applies the model's entry in `_THINKING_DEFAULTS`, if it has one. A
504
+ * request that already sets `reasoning`, from the settings or from
505
+ * `extra`, is left as it is. So is every request when
506
+ * `useProviderThinkingDefault` is set. The first request for each model
507
+ * logs the effort sent, or that the model runs at OpenAI's default,
508
+ * warning for an unlisted reasoning model.
509
+ *
510
+ * @param params The response params dict (modified in place).
511
+ */
512
+ _maybeApplyThinkingDefault(params) {
513
+ if ("reasoning" in params) {
514
+ return;
515
+ }
516
+ const model = assertGiven(this._settings.model) || "";
517
+ const name = _modelName(model);
518
+ const effort = Object.hasOwn(_THINKING_DEFAULTS, name) ? _THINKING_DEFAULTS[name] : undefined;
519
+ this._applyThinkingDefault(params, model, effort !== undefined ? { reasoning: { effort } } : null, {
520
+ canThink: Boolean(_modelSupportsReasoning(model)),
521
+ provider: "OpenAI",
522
+ settingsFields: "`reasoning`",
523
+ });
524
+ }
525
+ /**
526
+ * Log a clear error when reasoning is configured on a model that can't use
527
+ * it.
528
+ *
529
+ * The raw API error in this case ("Encrypted content is not supported with
530
+ * this model") is cryptic, so surface an actionable one instead. Only
531
+ * fires for models *known* not to reason; unrecognized models are left
532
+ * alone. Logs once per model rather than on every turn.
533
+ */
534
+ _warnIfReasoningUnsupported() {
535
+ const model = assertGiven(this._settings.model);
536
+ if (!model || _modelSupportsReasoning(model) !== false) {
537
+ // No model, reasoning-capable, or unrecognized — say nothing.
538
+ return;
539
+ }
540
+ if (model === this._reasoningUnsupportedWarnedFor) {
541
+ return;
542
+ }
543
+ this._reasoningUnsupportedWarnedFor = model;
544
+ logger.error(`${this}: \`reasoning\` is configured but model '${model}' does not support ` +
545
+ "reasoning, so requests will fail. Reasoning is supported only by " +
546
+ "reasoning-capable models — the mainline gpt series from gpt-5 onward " +
547
+ "and the o-series; see " +
548
+ "OpenAI's reasoning guide (https://platform.openai.com/docs/guides/reasoning). " +
549
+ "Remove the `reasoning` setting or select a reasoning-capable model.");
550
+ }
551
+ /**
552
+ * Persist a reasoning item so it round-trips on the next request.
553
+ *
554
+ * The encrypted reasoning is sent back on later requests to preserve
555
+ * reasoning context. Store it as an LLM-specific message the adapter
556
+ * re-emits as a Responses reasoning input item — positioned, by append
557
+ * order, before the assistant message or function call it produced.
558
+ *
559
+ * @param itemId The reasoning item id.
560
+ * @param summary The reasoning summary parts (`summary_text` dicts).
561
+ * @param encryptedContent The encrypted reasoning payload.
562
+ */
563
+ async _appendReasoningMessage(itemId, summary, encryptedContent) {
564
+ if (!encryptedContent) {
565
+ // Nothing to round-trip (e.g. reasoning disabled / effort="none").
566
+ return;
567
+ }
568
+ const message = {
569
+ type: "reasoning",
570
+ id: itemId,
571
+ summary,
572
+ encrypted_content: encryptedContent,
573
+ };
574
+ await this.pushFrame(new LLMMessagesAppendFrame({
575
+ messages: [this.getLlmAdapter().createLlmSpecificMessage(message)],
576
+ }));
577
+ }
578
+ }
579
+ return BaseOpenAIResponsesLLMServiceImpl;
580
+ });
581
+ /**
582
+ * Shared base for HTTP and WebSocket OpenAI Responses API services, as a
583
+ * plain class over `LLMService` (see `BaseOpenAIResponsesLLMServiceMixin`).
584
+ */
585
+ export class _BaseOpenAIResponsesLLMService extends BaseOpenAIResponsesLLMServiceMixin(LLMService) {
586
+ constructor(options = {}) {
587
+ super(options);
588
+ }
589
+ }
590
+ /**
591
+ * OpenAI Responses API LLM service using WebSocket transport.
592
+ *
593
+ * Maintains a persistent WebSocket connection to
594
+ * `wss://api.openai.com/v1/responses` for lower-latency inference, especially
595
+ * beneficial for tool-call-heavy workflows. Automatically uses
596
+ * `previous_response_id` to send only incremental context when possible, and
597
+ * falls back to full context on reconnection or cache miss.
598
+ *
599
+ * The `previous_response_id` optimization works with `store=false` (the
600
+ * default) because WebSocket mode uses a connection-local in-memory cache —
601
+ * no conversations are stored on OpenAI's servers. This is why the HTTP
602
+ * variant (`OpenAIResponsesHttpLLMService`) does not offer this optimization
603
+ * by default (or at all, yet): over HTTP, `previous_response_id` requires
604
+ * `store=true`, which enables OpenAI-side 30-day conversation storage.
605
+ *
606
+ * This is the recommended variant for real-time / conversational use.
607
+ *
608
+ * @example
609
+ * const llm = new OpenAIResponsesLLMService({
610
+ * apiKey: getEnv("OPENAI_API_KEY"),
611
+ * settings: new OpenAIResponsesLLMService.Settings({
612
+ * systemInstruction: "You are a helpful assistant.",
613
+ * }),
614
+ * });
615
+ */
616
+ export class OpenAIResponsesLLMService extends BaseOpenAIResponsesLLMServiceMixin(WebsocketLLMService) {
617
+ static {
618
+ const proto = OpenAIResponsesLLMService.prototype;
619
+ proto._processContext = tracedLlm(proto._processContext);
620
+ }
621
+ _wsUrl;
622
+ // State for previous_response_id optimization
623
+ _previousResponseId = null;
624
+ _previousInputHash = null;
625
+ _previousInputLength = null;
626
+ _previousResponseOutput = null;
627
+ // Response cancellation state
628
+ _currentResponseId = null; // ID of current non-cancelled response
629
+ _cancelPendingResponse = false;
630
+ _needsDrain = false;
631
+ /**
632
+ * Initialize the WebSocket-based OpenAI Responses API LLM service.
633
+ *
634
+ * @param options.wsUrl WebSocket endpoint URL. Defaults to
635
+ * `wss://api.openai.com/v1/responses`.
636
+ * Other options are passed to the base class (apiKey, baseUrl,
637
+ * organization, project, defaultHeaders, serviceTier, settings, etc.).
638
+ */
639
+ constructor(options = {}) {
640
+ super(options);
641
+ this._wsUrl = options.wsUrl ?? "wss://api.openai.com/v1/responses";
642
+ }
643
+ // -- WebsocketLLMService interface ----------------------------------------
644
+ /** Establish the WebSocket connection. */
645
+ async _connectWebsocket() {
646
+ try {
647
+ if (this._websocket) {
648
+ return;
649
+ }
650
+ this._websocket = await this._websocketConnect(this._wsUrl, {
651
+ additionalHeaders: {
652
+ Authorization: `Bearer ${this._apiKey}`,
653
+ },
654
+ });
655
+ }
656
+ catch (e) {
657
+ rethrowIfCancelled(e);
658
+ this._websocket = null;
659
+ await this.pushError(`Error connecting to WebSocket: ${errorMessage(e)}`, {
660
+ exception: e,
661
+ });
662
+ }
663
+ }
664
+ /** Close the WebSocket connection and clear state. */
665
+ async _disconnectWebsocket() {
666
+ try {
667
+ await this.stopAllMetrics();
668
+ if (this._websocket) {
669
+ await this._websocket.close();
670
+ }
671
+ }
672
+ catch (e) {
673
+ rethrowIfCancelled(e);
674
+ await this.pushError(`Error disconnecting from WebSocket: ${errorMessage(e)}`, {
675
+ exception: e,
676
+ });
677
+ }
678
+ finally {
679
+ this._websocket = null;
680
+ this._clearPreviousResponseState();
681
+ this._clearCancellationState();
682
+ }
683
+ }
684
+ /** Release resources at teardown. */
685
+ async cleanup() {
686
+ await super.cleanup();
687
+ await this._disconnect();
688
+ }
689
+ // -- previous_response_id optimization ------------------------------------
690
+ /**
691
+ * Compute a deterministic hash of input items for comparison.
692
+ *
693
+ * @param items List of Responses API input items.
694
+ * @returns Hex digest of the SHA-256 hash.
695
+ */
696
+ static async _hashInputItems(items) {
697
+ return await sha256Hex(jsonSortKeys(items));
698
+ }
699
+ /**
700
+ * Try to use previous_response_id to send only new input items.
701
+ *
702
+ * If the prefix of `fullInput` matches the stored hash from the previous
703
+ * inference call, only new items are sent along with
704
+ * `previous_response_id`. Otherwise the full input is sent.
705
+ *
706
+ * @param params The response params dict (modified in place).
707
+ * @param fullInput The complete input items list from the adapter.
708
+ * @returns The (possibly modified) params dict.
709
+ */
710
+ async _applyPreviousResponseOptimization(params, fullInput) {
711
+ if (this._previousResponseId === null) {
712
+ logger.debug(`${this}: Sending full context (${fullInput.length} items)`);
713
+ logger.trace(`${this}: Reason: no previous response`);
714
+ return params;
715
+ }
716
+ if (this._previousInputLength === null ||
717
+ this._previousInputHash === null ||
718
+ fullInput.length <= this._previousInputLength) {
719
+ logger.debug(`${this}: Sending full context (${fullInput.length} items)`);
720
+ logger.trace(`${this}: Reason: input not longer than previous (${this._previousInputLength ?? "None"})`);
721
+ return params;
722
+ }
723
+ const prefix = fullInput.slice(0, this._previousInputLength);
724
+ const prefixHash = await OpenAIResponsesLLMService._hashInputItems(prefix);
725
+ if (prefixHash !== this._previousInputHash) {
726
+ logger.debug(`${this}: Sending full context (${fullInput.length} items)`);
727
+ logger.trace(`${this}: Reason: input prefix hash mismatch ` +
728
+ `(previous input: ${jsonIndent(prefix)}, ` +
729
+ `expected hash: ${this._previousInputHash}, ` +
730
+ `actual hash: ${prefixHash})`);
731
+ return params;
732
+ }
733
+ const itemsAfterPrefix = fullInput.slice(this._previousInputLength);
734
+ const responseOutput = this._previousResponseOutput ?? [];
735
+ if (!OpenAIResponsesLLMService._startsWithResponseOutput(itemsAfterPrefix, responseOutput)) {
736
+ logger.debug(`${this}: Sending full context (${fullInput.length} items)`);
737
+ logger.trace(`${this}: Reason: response output mismatch after prefix ` +
738
+ `(previous response output: ${jsonIndent(responseOutput)}, ` +
739
+ `items after prefix: ${jsonIndent(itemsAfterPrefix)})`);
740
+ return params;
741
+ }
742
+ // The server already knows its own output — skip those items
743
+ const itemsToSend = itemsAfterPrefix.slice(responseOutput.length);
744
+ const cached = this._previousInputLength + responseOutput.length;
745
+ params.input = itemsToSend;
746
+ params.previous_response_id = this._previousResponseId;
747
+ logger.debug(`${this}: Sending incremental context via previous_response_id ` +
748
+ `(${itemsToSend.length} new items, ${cached} cached)`);
749
+ return params;
750
+ }
751
+ /**
752
+ * Check whether `items` begins with entries that match `responseOutput`.
753
+ *
754
+ * When using `previous_response_id`, the server already knows its own
755
+ * output. After confirming that the input prefix matches what we
756
+ * previously sent, this method checks whether the items immediately
757
+ * following that prefix correspond to the server's response output. If
758
+ * they do, those items can be skipped so we send only the truly new items
759
+ * (user messages, tool results, etc.).
760
+ *
761
+ * For messages, the comparison checks role and text content (extracting
762
+ * text from the output's `output_text` content parts and comparing against
763
+ * the input's content). For function calls, it matches by `call_id`. This
764
+ * avoids requiring exact format equality while still confirming the items
765
+ * represent the same data. If the match fails for any reason, the caller
766
+ * falls back to sending the full context.
767
+ *
768
+ * @param items The input items following the matched prefix.
769
+ * @param responseOutput Raw `output` array from the previous
770
+ * `response.completed` event.
771
+ * @returns True if the leading items correspond to the response output.
772
+ */
773
+ static _startsWithResponseOutput(items, responseOutput) {
774
+ if (items.length < responseOutput.length) {
775
+ return false;
776
+ }
777
+ for (let i = 0; i < responseOutput.length; i++) {
778
+ const outputItem = responseOutput[i];
779
+ const inputItem = items[i];
780
+ const outputType = outputItem.type;
781
+ if (outputType === "message") {
782
+ if (inputItem.role !== (outputItem.role ?? "assistant")) {
783
+ return false;
784
+ }
785
+ // Extract text from the output's content array and compare against
786
+ // the input's content (which the adapter stores as a plain string
787
+ // for simple text responses).
788
+ const outputContent = outputItem.content ?? [];
789
+ let outputText;
790
+ if (Array.isArray(outputContent)) {
791
+ outputText = outputContent
792
+ .filter((p) => p.type === "output_text")
793
+ .map((p) => p.text ?? "")
794
+ .join("");
795
+ }
796
+ else {
797
+ outputText = String(outputContent);
798
+ }
799
+ const inputContent = inputItem.content ?? "";
800
+ let inputText;
801
+ if (Array.isArray(inputContent)) {
802
+ // Adapter may produce multimodal content parts
803
+ inputText = inputContent
804
+ .filter((p) => p.type === "input_text")
805
+ .map((p) => p.text ?? "")
806
+ .join("");
807
+ }
808
+ else {
809
+ inputText = String(inputContent);
810
+ }
811
+ if (outputText !== inputText) {
812
+ return false;
813
+ }
814
+ }
815
+ else if (outputType === "function_call") {
816
+ if (inputItem.type !== "function_call" || inputItem.call_id !== outputItem.call_id) {
817
+ return false;
818
+ }
819
+ }
820
+ else if (outputType === "reasoning") {
821
+ // Reasoning items round-trip via an LLMSpecificMessage; match by the
822
+ // server-assigned id so the optimization can skip them too.
823
+ if (inputItem.type !== "reasoning" || inputItem.id !== outputItem.id) {
824
+ return false;
825
+ }
826
+ }
827
+ else {
828
+ // Unknown output type — can't confirm match
829
+ return false;
830
+ }
831
+ }
832
+ return true;
833
+ }
834
+ /**
835
+ * Store state for the next call's previous_response_id optimization.
836
+ *
837
+ * @param responseId The response ID returned by the server.
838
+ * @param fullInput The complete input items list that was sent.
839
+ * @param responseOutput Raw `output` array from the `response.completed`
840
+ * event, stored for loose comparison on the next call.
841
+ */
842
+ async _storePreviousResponseState(responseId, fullInput, responseOutput) {
843
+ const inputHash = await OpenAIResponsesLLMService._hashInputItems(fullInput);
844
+ this._previousResponseId = responseId;
845
+ this._previousInputLength = fullInput.length;
846
+ this._previousInputHash = inputHash;
847
+ this._previousResponseOutput = responseOutput;
848
+ }
849
+ /** Clear stored previous_response_id state. */
850
+ _clearPreviousResponseState() {
851
+ this._previousResponseId = null;
852
+ this._previousInputLength = null;
853
+ this._previousInputHash = null;
854
+ this._previousResponseOutput = null;
855
+ }
856
+ // -- response cancellation ------------------------------------------------
857
+ /** Clear response cancellation tracking state. */
858
+ _clearCancellationState() {
859
+ this._currentResponseId = null;
860
+ this._cancelPendingResponse = false;
861
+ this._needsDrain = false;
862
+ }
863
+ /**
864
+ * Drain events from a cancelled response before starting a new one.
865
+ *
866
+ * After a cancellation, the WebSocket may still have in-flight events from
867
+ * the cancelled response. We must drain them before sending a new
868
+ * `response.create` — we can't simply filter them inline because the API
869
+ * doesn't provide a reliable way to correlate events to a specific
870
+ * response (e.g. delta events carry neither a `response_id` nor any
871
+ * intermediary identifier that could be traced back to one).
872
+ *
873
+ * This method reads and discards events until a terminal event
874
+ * (`response.completed`, `response.failed`, or `response.incomplete`)
875
+ * arrives, ensuring the connection is clean. If draining times out or the
876
+ * connection drops, clears cancellation state and returns —
877
+ * `_ensureConnected` will handle reconnection before the next inference.
878
+ */
879
+ async _drainCancelledResponse() {
880
+ if (!this._websocket) {
881
+ this._clearCancellationState();
882
+ return;
883
+ }
884
+ logger.debug(`${this}: Draining cancelled response events`);
885
+ try {
886
+ while (true) {
887
+ const websocket = this._websocket;
888
+ const raw = await waitFor(() => websocket.recv(), 5.0);
889
+ const event = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw));
890
+ const eventType = event.type;
891
+ // If we were cancelled before response.created, the first event here
892
+ // will be response.created for the cancelled request — send cancel
893
+ // now that we have the id.
894
+ if (eventType === "response.created" && this._cancelPendingResponse) {
895
+ const responseId = (event.response ?? {}).id ?? null;
896
+ logger.debug(`${this}: Received response.created for pending-cancel ` +
897
+ `response ${responseId} — sending response.cancel`);
898
+ this._cancelPendingResponse = false;
899
+ if (responseId) {
900
+ try {
901
+ await this._wsSend({ type: "response.cancel", response_id: responseId });
902
+ }
903
+ catch (e) {
904
+ rethrowIfCancelled(e);
905
+ }
906
+ }
907
+ continue;
908
+ }
909
+ if (eventType === "response.completed" ||
910
+ eventType === "response.failed" ||
911
+ eventType === "response.incomplete") {
912
+ logger.debug(`${this}: Cancelled response terminated with ${eventType} — connection is clean`);
913
+ this._clearCancellationState();
914
+ return;
915
+ }
916
+ }
917
+ }
918
+ catch (e) {
919
+ if (e instanceof TimeoutError ||
920
+ e instanceof WebsocketReconnectedError ||
921
+ e instanceof ConnectionClosed) {
922
+ logger.warning(`${this}: Error draining cancelled response: ${errorMessage(e)}`);
923
+ this._clearCancellationState();
924
+ return;
925
+ }
926
+ throw e;
927
+ }
928
+ }
929
+ // -- frame processing -----------------------------------------------------
930
+ /**
931
+ * Process frames for LLM completion requests.
932
+ *
933
+ * @param frame The frame to process.
934
+ * @param direction The direction of frame processing.
935
+ */
936
+ async processFrame(frame, direction) {
937
+ await super.processFrame(frame, direction);
938
+ if (frame instanceof LLMContextFrame) {
939
+ try {
940
+ await this.pushFrame(new LLMFullResponseStartFrame());
941
+ await this.startProcessingMetrics();
942
+ await this._processContext(frame.context);
943
+ }
944
+ catch (e) {
945
+ if (e instanceof CancelledError) {
946
+ // The pipeline cancelled us (e.g. due to an interruption). Ask the
947
+ // server to stop generating and flag that we need to drain stale
948
+ // events before the next inference. We can't just send a new
949
+ // response.create and filter stale events inline — the API doesn't
950
+ // provide a reliable way to correlate events to a specific response.
951
+ if (this._currentResponseId) {
952
+ logger.debug(`${this}: Cancelled during response ${this._currentResponseId} ` +
953
+ "— sending response.cancel");
954
+ try {
955
+ await this._wsSend({
956
+ type: "response.cancel",
957
+ response_id: this._currentResponseId,
958
+ });
959
+ }
960
+ catch (sendError) {
961
+ rethrowIfCancelled(sendError);
962
+ }
963
+ }
964
+ else {
965
+ logger.debug(`${this}: Cancelled before response.created ` +
966
+ "— will cancel on next response.created");
967
+ this._cancelPendingResponse = true;
968
+ }
969
+ this._currentResponseId = null;
970
+ this._needsDrain = true;
971
+ throw e;
972
+ }
973
+ await this.pushError(`Error during inference: ${errorMessage(e)}`, { exception: e });
974
+ }
975
+ finally {
976
+ await this.stopProcessingMetrics();
977
+ await this.pushFrame(new LLMFullResponseEndFrame());
978
+ }
979
+ }
980
+ else {
981
+ await this.pushFrame(frame, direction);
982
+ }
983
+ }
984
+ // -- core inference -------------------------------------------------------
985
+ /**
986
+ * Run inference over WebSocket with retry and previous_response_id.
987
+ *
988
+ * Tries once with the `previous_response_id` optimization. On a retriable
989
+ * error (cache miss, connection limit, connection drop, or — when
990
+ * `retryOnTimeout` is set — a response that produces no output in time),
991
+ * clears state and retries once with the full context and no timeout.
992
+ * Transport-level `ConnectionClosed` errors are handled transparently by
993
+ * `_wsSend`/`_wsRecv` (auto-reconnect → `WebsocketReconnectedError`).
994
+ *
995
+ * Traced with `tracedLlm` (see the static block).
996
+ *
997
+ * @param context The LLM context containing conversation history.
998
+ */
999
+ async _processContext(context) {
1000
+ // If a previous response was cancelled, drain its remaining events
1001
+ // before starting a new one.
1002
+ if (this._needsDrain) {
1003
+ await this._drainCancelledResponse();
1004
+ }
1005
+ const adapter = this.getLlmAdapter();
1006
+ this._logLlmResponse(context);
1007
+ const invocationParams = await adapter.getLlmInvocationParams(context, {
1008
+ systemInstruction: assertGiven(this._settings.systemInstruction),
1009
+ });
1010
+ const fullInput = invocationParams.input;
1011
+ const buildParams = async (options) => {
1012
+ let params = this._buildResponseParams(invocationParams);
1013
+ // WebSocket mode does not use the "stream" parameter.
1014
+ delete params.stream;
1015
+ if (options.applyOptimization) {
1016
+ params = await this._applyPreviousResponseOptimization(params, fullInput);
1017
+ }
1018
+ return params;
1019
+ };
1020
+ const sendAndReceive = async (params, outputTimeoutSecs = null) => {
1021
+ await this._ensureConnected();
1022
+ await this.startTtfbMetrics();
1023
+ await this._wsSend({ type: "response.create", ...params });
1024
+ await this._receiveResponseEvents(context, fullInput, outputTimeoutSecs);
1025
+ };
1026
+ const cleanup = async () => {
1027
+ this._clearPreviousResponseState();
1028
+ await this.stopTtfbMetrics();
1029
+ };
1030
+ // -- first attempt (with previous_response_id optimization) -----------
1031
+ try {
1032
+ await sendAndReceive(await buildParams({ applyOptimization: true }), this._retryOnTimeout ? this._retryTimeoutSecs : null);
1033
+ return; // Success
1034
+ }
1035
+ catch (e) {
1036
+ if (e instanceof _ResponseTimeoutError) {
1037
+ // Dropping the connection discards the abandoned response's events,
1038
+ // so the retry starts on a clean socket with no draining needed.
1039
+ logger.warning(`${this}: No output within ${this._retryTimeoutSecs}s — reconnecting ` +
1040
+ `and retrying with full context (${fullInput.length} items)`);
1041
+ await cleanup();
1042
+ await this._tryReconnect({
1043
+ reportError: (error, options) => this._reportError(error, options),
1044
+ });
1045
+ }
1046
+ else if (e instanceof _PreviousResponseNotFoundError) {
1047
+ logger.warning(`${this}: previous_response_not_found — ` +
1048
+ `retrying with full context (${fullInput.length} items)`);
1049
+ await cleanup();
1050
+ }
1051
+ else if (e instanceof _ConnectionLimitReachedError) {
1052
+ logger.warning(`${this}: WebSocket connection limit reached — ` +
1053
+ `reconnecting and retrying with full context (${fullInput.length} items)`);
1054
+ await cleanup();
1055
+ await this._tryReconnect({
1056
+ reportError: (error, options) => this._reportError(error, options),
1057
+ });
1058
+ }
1059
+ else if (e instanceof WebsocketReconnectedError) {
1060
+ // ConnectionClosed was handled by the base class — connection is
1061
+ // fresh, so any connection-local server state is gone.
1062
+ logger.warning(`${this}: Connection lost and recovered — ` +
1063
+ `retrying with full context (${fullInput.length} items)`);
1064
+ await cleanup();
1065
+ }
1066
+ else {
1067
+ rethrowIfCancelled(e);
1068
+ await cleanup();
1069
+ throw e;
1070
+ }
1071
+ }
1072
+ // -- retry with full context (no optimization) ------------------------
1073
+ try {
1074
+ await sendAndReceive(await buildParams({ applyOptimization: false }));
1075
+ }
1076
+ catch (e) {
1077
+ rethrowIfCancelled(e);
1078
+ await cleanup();
1079
+ throw e;
1080
+ }
1081
+ }
1082
+ /** Report the token usage of a response received over the WebSocket. */
1083
+ async _reportEventUsage(usage) {
1084
+ if (!usage || Object.keys(usage).length === 0) {
1085
+ return;
1086
+ }
1087
+ const inputDetails = usage.input_tokens_details || {};
1088
+ const outputDetails = usage.output_tokens_details || {};
1089
+ const tokens = new LLMTokenUsage({
1090
+ promptTokens: usage.input_tokens ?? 0,
1091
+ completionTokens: usage.output_tokens ?? 0,
1092
+ totalTokens: usage.total_tokens ?? 0,
1093
+ cacheReadInputTokens: inputDetails.cached_tokens ?? 0,
1094
+ cacheCreationInputTokens: inputDetails.cache_write_tokens ?? 0,
1095
+ reasoningTokens: outputDetails.reasoning_tokens ?? 0,
1096
+ });
1097
+ await this.startLlmUsageMetrics(tokens);
1098
+ }
1099
+ /**
1100
+ * Receive and process WebSocket events until the response completes.
1101
+ *
1102
+ * @param context The LLM context for the current inference.
1103
+ * @param fullInput The complete input items list (for storing state on
1104
+ * success).
1105
+ * @param outputTimeoutSecs How long to wait for the response to start
1106
+ * producing output before raising `_ResponseTimeoutError`. Once the first
1107
+ * output event arrives the wait becomes unbounded, since by then content
1108
+ * is already on its way downstream and re-issuing would duplicate it.
1109
+ * null waits indefinitely throughout.
1110
+ * @throws _PreviousResponseNotFoundError Server couldn't find previous response.
1111
+ * @throws _ConnectionLimitReachedError 60-minute connection limit reached.
1112
+ * @throws _ResponseTimeoutError Response produced no output in time.
1113
+ * @throws WebsocketReconnectedError Connection was lost and auto-recovered.
1114
+ * @throws ConnectionClosed Connection was lost and could not be recovered.
1115
+ */
1116
+ async _receiveResponseEvents(context, fullInput, outputTimeoutSecs = null) {
1117
+ const functionCalls = new Map();
1118
+ const currentArguments = new Map();
1119
+ let reasoningSummaryOpen = false;
1120
+ let deadline = outputTimeoutSecs ? monotonic() + outputTimeoutSecs : null;
1121
+ while (true) {
1122
+ let event;
1123
+ if (deadline === null) {
1124
+ event = await this._wsRecv();
1125
+ }
1126
+ else {
1127
+ try {
1128
+ event = await waitFor(() => this._wsRecv(), Math.max(deadline - monotonic(), 0));
1129
+ }
1130
+ catch (e) {
1131
+ if (e instanceof TimeoutError) {
1132
+ throw new _ResponseTimeoutError(`No output within ${outputTimeoutSecs}s`);
1133
+ }
1134
+ throw e;
1135
+ }
1136
+ }
1137
+ const eventType = event.type;
1138
+ if (eventType === "response.created") {
1139
+ this._currentResponseId = (event.response ?? {}).id ?? null;
1140
+ logger.debug(`${this}: Response started: ${this._currentResponseId}`);
1141
+ continue;
1142
+ }
1143
+ // Anything past response.created means the response is under way, so
1144
+ // the window for abandoning and re-issuing it has closed.
1145
+ deadline = null;
1146
+ if (eventType === "response.output_text.delta") {
1147
+ await this.stopTtfbMetrics();
1148
+ await this._pushLlmText(event.delta ?? "");
1149
+ }
1150
+ else if (eventType === "response.reasoning_summary_text.delta") {
1151
+ await this.stopTtfbMetrics();
1152
+ if (!reasoningSummaryOpen) {
1153
+ await this.pushFrame(new LLMThoughtStartFrame());
1154
+ reasoningSummaryOpen = true;
1155
+ }
1156
+ await this.pushFrame(new LLMThoughtTextFrame({ text: event.delta ?? "" }));
1157
+ }
1158
+ else if (eventType === "response.output_item.added") {
1159
+ await this.stopTtfbMetrics();
1160
+ const item = event.item ?? {};
1161
+ if (item.type === "function_call") {
1162
+ // A turn that only calls tools produces no answer text, so the call
1163
+ // itself is what the caller gets and TTFAT ends here rather than
1164
+ // going unmeasured.
1165
+ await this.stopTtfatMetrics();
1166
+ const itemId = item.id ?? "";
1167
+ functionCalls.set(itemId, {
1168
+ name: item.name ?? "",
1169
+ call_id: item.call_id ?? "",
1170
+ arguments: "",
1171
+ });
1172
+ currentArguments.set(itemId, "");
1173
+ }
1174
+ }
1175
+ else if (eventType === "response.function_call_arguments.delta") {
1176
+ const itemId = event.item_id ?? "";
1177
+ if (currentArguments.has(itemId)) {
1178
+ currentArguments.set(itemId, currentArguments.get(itemId) + (event.delta ?? ""));
1179
+ }
1180
+ }
1181
+ else if (eventType === "response.function_call_arguments.done") {
1182
+ const itemId = event.item_id ?? "";
1183
+ const call = functionCalls.get(itemId);
1184
+ if (call) {
1185
+ call.arguments = event.arguments ?? "";
1186
+ }
1187
+ }
1188
+ else if (eventType === "response.output_item.done") {
1189
+ const item = event.item ?? {};
1190
+ if (item.type === "function_call") {
1191
+ const itemId = item.id ?? "";
1192
+ const call = functionCalls.get(itemId);
1193
+ if (call) {
1194
+ call.name = item.name ?? "";
1195
+ call.call_id = item.call_id ?? "";
1196
+ call.arguments = item.arguments ?? "";
1197
+ }
1198
+ }
1199
+ else if (item.type === "reasoning") {
1200
+ if (reasoningSummaryOpen) {
1201
+ await this.pushFrame(new LLMThoughtEndFrame());
1202
+ reasoningSummaryOpen = false;
1203
+ }
1204
+ await this._appendReasoningMessage(item.id ?? null, (item.summary || []).map((s) => ({
1205
+ type: "summary_text",
1206
+ text: s.text ?? "",
1207
+ })), item.encrypted_content ?? null);
1208
+ }
1209
+ }
1210
+ else if (eventType === "response.completed") {
1211
+ const response = event.response ?? {};
1212
+ await this._reportEventUsage(response.usage);
1213
+ this._fullModelName = response.model ?? null;
1214
+ // Store state for next call's previous_response_id optimization.
1215
+ // Include the response output so the hash covers the assistant's
1216
+ // reply — the server already knows it, so we won't resend it.
1217
+ const responseId = response.id;
1218
+ if (responseId) {
1219
+ const responseOutput = response.output || [];
1220
+ await this._storePreviousResponseState(responseId, fullInput, responseOutput);
1221
+ }
1222
+ break; // Response complete
1223
+ }
1224
+ else if (eventType === "response.failed" || eventType === "response.incomplete") {
1225
+ const response = event.response ?? {};
1226
+ // A response cut short or failed is still billed for its tokens.
1227
+ await this._reportEventUsage(response.usage);
1228
+ const errorMsg = (response.error || {}).message ||
1229
+ (response.incomplete_details || {}).reason ||
1230
+ `Response ${eventType.split(".").at(-1)}`;
1231
+ await this.pushError(`LLM response error: ${errorMsg}`);
1232
+ break;
1233
+ }
1234
+ else if (eventType === "error") {
1235
+ const error = event.error ?? {};
1236
+ const code = error.code ?? "";
1237
+ const message = error.message ?? "Unknown error";
1238
+ if (code === "previous_response_not_found") {
1239
+ throw new _PreviousResponseNotFoundError(message);
1240
+ }
1241
+ else if (code === "websocket_connection_limit_reached") {
1242
+ throw new _ConnectionLimitReachedError(message);
1243
+ }
1244
+ else {
1245
+ await this.pushError(`WebSocket API error: ${message}`);
1246
+ break;
1247
+ }
1248
+ }
1249
+ }
1250
+ // Process any function calls
1251
+ if (functionCalls.size > 0) {
1252
+ const fcList = this._processFunctionCalls(context, functionCalls);
1253
+ await this.runFunctionCalls(fcList);
1254
+ }
1255
+ }
1256
+ }
1257
+ addVirtualBase(OpenAIResponsesLLMService, _BaseOpenAIResponsesLLMService);
1258
+ /**
1259
+ * OpenAI Responses API LLM service using HTTP streaming transport.
1260
+ *
1261
+ * Uses server-sent events (SSE) via the OpenAI SDK for streaming inference.
1262
+ * Each `_processContext` call opens a new HTTP connection.
1263
+ *
1264
+ * Unlike the WebSocket variant, this service does not use
1265
+ * `previous_response_id` for incremental context delivery by default (or at
1266
+ * all, yet). Over HTTP, `previous_response_id` requires `store=true`, which
1267
+ * enables OpenAI-side 30-day conversation storage — a privacy/compliance
1268
+ * tradeoff that many users won't want. The WebSocket variant avoids this
1269
+ * because its `previous_response_id` uses a connection-local in-memory cache
1270
+ * that works with `store=false` (nothing is stored long-term).
1271
+ *
1272
+ * @example
1273
+ * const llm = new OpenAIResponsesHttpLLMService({
1274
+ * apiKey: getEnv("OPENAI_API_KEY"),
1275
+ * settings: new OpenAIResponsesHttpLLMService.Settings({
1276
+ * systemInstruction: "You are a helpful assistant.",
1277
+ * }),
1278
+ * });
1279
+ */
1280
+ export class OpenAIResponsesHttpLLMService extends _BaseOpenAIResponsesLLMService {
1281
+ static {
1282
+ const proto = OpenAIResponsesHttpLLMService.prototype;
1283
+ proto._processContext = tracedLlm(proto._processContext);
1284
+ }
1285
+ /**
1286
+ * Process frames for LLM completion requests.
1287
+ *
1288
+ * @param frame The frame to process.
1289
+ * @param direction The direction of frame processing.
1290
+ */
1291
+ async processFrame(frame, direction) {
1292
+ await super.processFrame(frame, direction);
1293
+ if (frame instanceof LLMContextFrame) {
1294
+ try {
1295
+ await this.pushFrame(new LLMFullResponseStartFrame());
1296
+ await this.startProcessingMetrics();
1297
+ await this._processContext(frame.context);
1298
+ }
1299
+ catch (e) {
1300
+ rethrowIfCancelled(e);
1301
+ if (isTimeoutException(e)) {
1302
+ await this._callEventHandler("on_completion_timeout");
1303
+ await this.pushError("LLM completion timeout", { exception: e });
1304
+ }
1305
+ else {
1306
+ await this.pushError(`Error during inference: ${errorMessage(e)}`, { exception: e });
1307
+ }
1308
+ }
1309
+ finally {
1310
+ await this.stopProcessingMetrics();
1311
+ await this.pushFrame(new LLMFullResponseEndFrame());
1312
+ }
1313
+ }
1314
+ else {
1315
+ await this.pushFrame(frame, direction);
1316
+ }
1317
+ }
1318
+ /** Report a response's token usage, if it carries any. */
1319
+ async _reportUsage(usage) {
1320
+ if (!usage) {
1321
+ return;
1322
+ }
1323
+ // Third-party Responses API servers may omit fields the OpenAI SDK treats
1324
+ // as required. Anything omitted is left undefined — at the top level
1325
+ // (token counts), as a missing detail sub-object, or as a missing field
1326
+ // inside one. Coalesce each to 0 so a partial usage payload can't raise
1327
+ // or leak null into metrics.
1328
+ const inputDetails = usage.input_tokens_details;
1329
+ const outputDetails = usage.output_tokens_details;
1330
+ const tokens = new LLMTokenUsage({
1331
+ promptTokens: usage.input_tokens || 0,
1332
+ completionTokens: usage.output_tokens || 0,
1333
+ totalTokens: usage.total_tokens || 0,
1334
+ cacheReadInputTokens: inputDetails ? inputDetails.cached_tokens || 0 : 0,
1335
+ cacheCreationInputTokens: inputDetails ? inputDetails.cache_write_tokens || 0 : 0,
1336
+ reasoningTokens: outputDetails ? outputDetails.reasoning_tokens || 0 : 0,
1337
+ });
1338
+ await this.startLlmUsageMetrics(tokens);
1339
+ }
1340
+ /**
1341
+ * Open a streaming Responses API request, retrying once on timeout.
1342
+ *
1343
+ * @param params Parameters for the Responses API call.
1344
+ * @returns Async stream of response events.
1345
+ */
1346
+ async _createStream(params) {
1347
+ const create = () => abortable(this._client.responses.create(params, {
1348
+ signal: currentSignal(),
1349
+ }));
1350
+ if (!this._retryOnTimeout) {
1351
+ return await create();
1352
+ }
1353
+ try {
1354
+ return await waitFor(() => create(), this._retryTimeoutSecs);
1355
+ }
1356
+ catch (e) {
1357
+ if (e instanceof TimeoutError || e instanceof APIConnectionTimeoutError) {
1358
+ // Retry, this time without a timeout so we get a response
1359
+ logger.debug(`${this}: Retrying response creation due to timeout`);
1360
+ return await create();
1361
+ }
1362
+ throw e;
1363
+ }
1364
+ }
1365
+ /** Traced with `tracedLlm` (see the static block). */
1366
+ async _processContext(context) {
1367
+ const adapter = this.getLlmAdapter();
1368
+ this._logLlmResponse(context);
1369
+ const invocationParams = await adapter.getLlmInvocationParams(context, {
1370
+ systemInstruction: assertGiven(this._settings.systemInstruction),
1371
+ });
1372
+ const params = this._buildResponseParams(invocationParams);
1373
+ await this.startTtfbMetrics();
1374
+ const stream = await this._createStream(params);
1375
+ // Track function calls across stream events
1376
+ // item_id -> {name, call_id, arguments}
1377
+ let functionCalls = new Map();
1378
+ // item_id -> accumulated arguments
1379
+ const currentArguments = new Map();
1380
+ let reasoningSummaryOpen = false;
1381
+ let streamErrored = false;
1382
+ // Ensure the stream and its async iterator are closed on
1383
+ // cancellation/exception to release the HTTP connection. Each event is
1384
+ // awaited as a cancellation point, since the SDK's iterator is a foreign
1385
+ // await.
1386
+ const eventIter = stream[Symbol.asyncIterator]();
1387
+ try {
1388
+ while (true) {
1389
+ const next = await abortable(eventIter.next());
1390
+ if (next.done)
1391
+ break;
1392
+ const event = next.value;
1393
+ if (event.type === "response.output_text.delta") {
1394
+ await this.stopTtfbMetrics();
1395
+ await this._pushLlmText(event.delta);
1396
+ }
1397
+ else if (event.type === "response.reasoning_summary_text.delta") {
1398
+ await this.stopTtfbMetrics();
1399
+ if (!reasoningSummaryOpen) {
1400
+ await this.pushFrame(new LLMThoughtStartFrame());
1401
+ reasoningSummaryOpen = true;
1402
+ }
1403
+ await this.pushFrame(new LLMThoughtTextFrame({ text: event.delta }));
1404
+ }
1405
+ else if (event.type === "response.output_item.added") {
1406
+ await this.stopTtfbMetrics();
1407
+ const item = event.item;
1408
+ if (item?.type === "function_call") {
1409
+ // A turn that only calls tools produces no answer text, so the
1410
+ // call itself is what the caller gets and TTFAT ends here rather
1411
+ // than going unmeasured.
1412
+ await this.stopTtfatMetrics();
1413
+ const itemId = item.id || "";
1414
+ functionCalls.set(itemId, {
1415
+ name: item.name,
1416
+ call_id: item.call_id,
1417
+ arguments: "",
1418
+ });
1419
+ currentArguments.set(itemId, "");
1420
+ }
1421
+ }
1422
+ else if (event.type === "response.function_call_arguments.delta") {
1423
+ const itemId = event.item_id;
1424
+ if (currentArguments.has(itemId)) {
1425
+ currentArguments.set(itemId, currentArguments.get(itemId) + event.delta);
1426
+ }
1427
+ }
1428
+ else if (event.type === "response.function_call_arguments.done") {
1429
+ const itemId = event.item_id;
1430
+ const call = functionCalls.get(itemId);
1431
+ if (call) {
1432
+ call.arguments = event.arguments;
1433
+ }
1434
+ }
1435
+ else if (event.type === "response.output_item.done") {
1436
+ const item = event.item;
1437
+ if (item?.type === "function_call") {
1438
+ const itemId = item.id || "";
1439
+ const call = functionCalls.get(itemId);
1440
+ if (call) {
1441
+ call.name = item.name;
1442
+ call.call_id = item.call_id;
1443
+ call.arguments = item.arguments;
1444
+ }
1445
+ }
1446
+ else if (item?.type === "reasoning") {
1447
+ if (reasoningSummaryOpen) {
1448
+ await this.pushFrame(new LLMThoughtEndFrame());
1449
+ reasoningSummaryOpen = false;
1450
+ }
1451
+ await this._appendReasoningMessage(item.id ?? null, (item.summary || []).map((s) => ({
1452
+ type: "summary_text",
1453
+ text: s.text,
1454
+ })), item.encrypted_content ?? null);
1455
+ }
1456
+ }
1457
+ else if (event.type === "response.completed") {
1458
+ const response = event.response;
1459
+ await this._reportUsage(response.usage);
1460
+ // This field is used by tracedLlm for more detailed model name in
1461
+ // tracing spans
1462
+ this._fullModelName = response.model;
1463
+ }
1464
+ else if (event.type === "response.failed") {
1465
+ await this._reportUsage(event.response.usage);
1466
+ // As with usage, the detail objects and their fields are only as
1467
+ // reliable as the server; coalesce to a generic message so a sparse
1468
+ // payload still reports something.
1469
+ const error = event.response.error;
1470
+ const message = error ? error.message : null;
1471
+ await this.pushError(`LLM response error: ${message || "Response failed"}`);
1472
+ streamErrored = true;
1473
+ break;
1474
+ }
1475
+ else if (event.type === "response.incomplete") {
1476
+ await this._reportUsage(event.response.usage);
1477
+ const details = event.response.incomplete_details;
1478
+ const reason = details ? details.reason : null;
1479
+ await this.pushError(`LLM response error: ${reason || "Response incomplete"}`);
1480
+ streamErrored = true;
1481
+ break;
1482
+ }
1483
+ else if (event.type === "error") {
1484
+ await this.pushError(`Responses API error: ${event.message}`);
1485
+ streamErrored = true;
1486
+ break;
1487
+ }
1488
+ }
1489
+ }
1490
+ finally {
1491
+ // Close the iterator first, then abort the request to release HTTP
1492
+ // resources.
1493
+ try {
1494
+ await eventIter.return?.();
1495
+ }
1496
+ catch (e) {
1497
+ rethrowIfCancelled(e);
1498
+ }
1499
+ stream.controller?.abort();
1500
+ }
1501
+ // A stream that ended in a terminal error may have announced a function
1502
+ // call whose arguments never finished streaming — drop those rather than
1503
+ // run them with fabricated empty arguments. `arguments` is only written
1504
+ // by the Done events, so a non-empty string means the call completed
1505
+ // (e.g. parallel tool calls finished before a later item was truncated)
1506
+ // and it still runs.
1507
+ if (streamErrored) {
1508
+ functionCalls = new Map([...functionCalls].filter(([, call]) => call.arguments));
1509
+ }
1510
+ // Process any function calls
1511
+ if (functionCalls.size > 0) {
1512
+ const fcList = this._processFunctionCalls(context, functionCalls);
1513
+ await this.runFunctionCalls(fcList);
1514
+ }
1515
+ }
1516
+ }
1517
+ //# sourceMappingURL=llm.js.map