@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
|
@@ -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
|