@pipedog/openai 0.0.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
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/base_llm.py
6
+ /**
7
+ * Base LLM service implementation for services that use the AsyncOpenAI client.
8
+ *
9
+ * Porting notes:
10
+ * - The client is `OpenAI` from the official `openai` JS SDK, which talks
11
+ * through `fetch` and runs on every runtime. Subclasses for
12
+ * OpenAI-compatible providers override `createClient()`.
13
+ * - The SDK's `NOT_GIVEN` sentinel is `undefined` (`OPENAI_NOT_GIVEN`), which
14
+ * leaves a field out of the request body. The store-mode settings hold it
15
+ * for the sampling fields that are unset, as upstream does.
16
+ * - Upstream builds the client with an httpx client carrying connection limits
17
+ * (`DefaultAsyncHttpxClient(limits=connection_limits(...))`). `fetch` has no
18
+ * such object, so the SDK's own fetch is used.
19
+ * - The JS SDK has no `extra_body` request option: it sends unknown params
20
+ * as-is. `extra_body` (from `_mergeExtraBody()` or `Settings.extra`) is
21
+ * merged into the request JSON just before sending, like the Python client.
22
+ * - `APIStatusError` / `APITimeoutError` are `APIError` (with `status`) and
23
+ * `APIConnectionTimeoutError` in the JS SDK.
24
+ * - Deprecated APIs are not ported: `BaseOpenAILLMService.InputParams` and the
25
+ * `model` / `params` constructor options.
26
+ */
27
+ import { abortable, currentSignal, rethrowIfCancelled, TimeoutError, waitFor, } from "@pipedog/asyncio";
28
+ import { LLMContextConversionError, } from "@pipedog/core/adapters/base_llm_adapter";
29
+ import { OPENAI_NOT_GIVEN, } from "@pipedog/core/adapters/services/open_ai_adapter";
30
+ import { FunctionCallFromLLM, LLMContextFrame, LLMFullResponseEndFrame, LLMFullResponseStartFrame, LLMTextFrame, } from "@pipedog/core/frames/frames";
31
+ import { LLMTokenUsage } from "@pipedog/core/metrics/metrics";
32
+ import { LLMService } from "@pipedog/core/services/llm_service";
33
+ import { LLMSettings } from "@pipedog/core/services/settings";
34
+ import { ErrorCategory } from "@pipedog/core/utils/errors";
35
+ import { isTimeoutException } from "@pipedog/core/utils/http";
36
+ import { logger } from "@pipedog/core/utils/logger";
37
+ import { pyJsonLoads } from "@pipedog/core/utils/py_json";
38
+ import { tracedLlm } from "@pipedog/core/utils/tracing/service_decorators";
39
+ import { assertGiven, isGiven, NOT_GIVEN } from "@pipedog/core/utils/types";
40
+ import OpenAI, { APIConnectionTimeoutError, APIError, BadRequestError, UnprocessableEntityError, } from "openai";
41
+ function errorMessage(e) {
42
+ return e instanceof Error ? e.message : String(e);
43
+ }
44
+ /**
45
+ * Settings for BaseOpenAILLMService.
46
+ *
47
+ * - `maxCompletionTokens`: maximum completion tokens to generate.
48
+ *
49
+ * Upstream overrides the inherited sampling fields (`frequencyPenalty`,
50
+ * `presencePenalty`, `seed`, `temperature`, `topP`, `maxTokens`) to also
51
+ * accept the OpenAI SDK's sentinel, which the service stores there so those
52
+ * fields can be passed through unchanged to the client. The SDK's sentinel is
53
+ * `undefined` here, which TypeScript can't add to an inherited field's type;
54
+ * the service writes it with `setOpenAINotGiven()`.
55
+ */
56
+ export class OpenAILLMSettings extends LLMSettings {
57
+ maxCompletionTokens = NOT_GIVEN;
58
+ constructor(options = {}) {
59
+ super(options);
60
+ this._assignOptions(options);
61
+ }
62
+ }
63
+ /** The settings fields that hold the OpenAI SDK's sentinel when unset. */
64
+ const OPENAI_SENTINEL_FIELDS = [
65
+ "frequencyPenalty",
66
+ "presencePenalty",
67
+ "seed",
68
+ "temperature",
69
+ "topP",
70
+ "maxTokens",
71
+ "maxCompletionTokens",
72
+ ];
73
+ /**
74
+ * Store the OpenAI SDK's "not given" sentinel (`OPENAI_NOT_GIVEN`) in the
75
+ * given settings fields, so they are left out of requests.
76
+ *
77
+ * Options objects can't carry it (an `undefined` option means "not given" to
78
+ * Pipecat), so services set it after building their default settings.
79
+ *
80
+ * @param settings Store-mode settings to update in place.
81
+ * @param fields The fields to set. Defaults to every OpenAI sampling field.
82
+ */
83
+ export function setOpenAINotGiven(settings, fields = OPENAI_SENTINEL_FIELDS) {
84
+ const self = settings;
85
+ for (const name of fields) {
86
+ self[name] = OPENAI_NOT_GIVEN;
87
+ }
88
+ }
89
+ /**
90
+ * OpenAI models that predate strict JSON schema replies: gpt-3.5, gpt-4 and
91
+ * gpt-4-turbo, the first gpt-4o snapshot, chatgpt-4o and the o1 previews.
92
+ */
93
+ export const OPENAI_MODEL_WITHOUT_RESPONSE_SCHEMA = /^(gpt-3\.5|gpt-4($|-)|gpt-4o-2024-05-13|chatgpt-4o|o1-mini|o1-preview)/;
94
+ export class BaseOpenAILLMService extends LLMService {
95
+ static Settings = OpenAILLMSettings;
96
+ /**
97
+ * Set by services for providers other than OpenAI. Without a key the OpenAI
98
+ * client falls back to OPENAI_API_KEY and would send it to that provider.
99
+ */
100
+ static _requiresApiKey = false;
101
+ /**
102
+ * Whether this service's API supports the "developer" message role.
103
+ *
104
+ * OpenAI's native API supports it, but some OpenAI-compatible services
105
+ * (e.g. Cerebras) do not. Subclasses that don't support it should set this
106
+ * to `false`, which causes the adapter to convert "developer" messages to
107
+ * "user" messages before sending them to the API.
108
+ */
109
+ static supportsDeveloperRole = true;
110
+ /**
111
+ * Whether the API can enforce a response schema. OpenAI-compatible services
112
+ * whose API cannot should set this to `false`.
113
+ */
114
+ static supportsResponseSchema = true;
115
+ _serviceTier;
116
+ _retryTimeoutSecs;
117
+ _retryOnTimeout;
118
+ _fullModelName = "";
119
+ _client;
120
+ /**
121
+ * Initialize the BaseOpenAILLMService.
122
+ *
123
+ * @param options.apiKey OpenAI API key. If null, uses environment variable.
124
+ * @param options.baseUrl Custom base URL for OpenAI API. If null, uses default.
125
+ * @param options.organization OpenAI organization ID.
126
+ * @param options.project OpenAI project ID.
127
+ * @param options.defaultHeaders Additional HTTP headers to include in requests.
128
+ * @param options.serviceTier Service tier to use (e.g., "auto", "flex", "priority").
129
+ * @param options.settings Runtime-updatable settings.
130
+ * @param options.retryTimeoutSecs How long an inference may go without
131
+ * producing output before it is abandoned and re-issued, when
132
+ * `retryOnTimeout` is set. Defaults to 5.0 seconds.
133
+ * @param options.retryOnTimeout Whether to re-issue the request once if the
134
+ * first attempt produces no output within `retryTimeoutSecs`. The retry
135
+ * is unbounded.
136
+ * Other options are passed to the parent LLMService.
137
+ * @throws Error If the service is for a provider other than OpenAI and
138
+ * `apiKey` is empty (upstream: ValueError).
139
+ */
140
+ constructor(options = {}) {
141
+ const cls = new.target;
142
+ if (cls._requiresApiKey && !options.apiKey) {
143
+ throw new Error(`${cls.name} requires an api_key.`);
144
+ }
145
+ // 1. Initialize default_settings with hardcoded defaults
146
+ const defaultSettings = new cls.Settings({
147
+ model: "gpt-4.1",
148
+ systemInstruction: null,
149
+ topK: null,
150
+ filterIncompleteUserTurns: false,
151
+ userTurnCompletionConfig: null,
152
+ extra: {},
153
+ });
154
+ setOpenAINotGiven(defaultSettings);
155
+ // 2. Apply settings delta (canonical API, always wins)
156
+ if (options.settings !== undefined && options.settings !== null) {
157
+ defaultSettings.applyUpdate(options.settings);
158
+ }
159
+ super({ ...options, settings: defaultSettings });
160
+ this._serviceTier = options.serviceTier ?? null;
161
+ this._retryTimeoutSecs =
162
+ options.retryTimeoutSecs === undefined ? 5.0 : options.retryTimeoutSecs;
163
+ this._retryOnTimeout = options.retryOnTimeout === undefined ? false : options.retryOnTimeout;
164
+ this._client = this.createClient(options);
165
+ if (this._settings.systemInstruction) {
166
+ logger.debug(`${this}: Using system instruction: ${this._settings.systemInstruction}`);
167
+ }
168
+ }
169
+ /**
170
+ * Create an AsyncOpenAI client instance.
171
+ *
172
+ * @param options.apiKey OpenAI API key.
173
+ * @param options.baseUrl Custom base URL for the API.
174
+ * @param options.organization OpenAI organization ID.
175
+ * @param options.project OpenAI project ID.
176
+ * @param options.defaultHeaders Additional HTTP headers.
177
+ * Other options are the service's constructor options, which subclasses
178
+ * may read.
179
+ * @returns Configured AsyncOpenAI client instance.
180
+ */
181
+ createClient(options = {}) {
182
+ return new OpenAI({
183
+ apiKey: options.apiKey ?? undefined,
184
+ baseURL: options.baseUrl ?? undefined,
185
+ organization: options.organization ?? null,
186
+ project: options.project ?? null,
187
+ defaultHeaders: options.defaultHeaders ?? undefined,
188
+ });
189
+ }
190
+ /**
191
+ * Check if this service can generate processing metrics.
192
+ *
193
+ * @returns True, as OpenAI service supports metrics generation.
194
+ */
195
+ canGenerateMetrics() {
196
+ return true;
197
+ }
198
+ /**
199
+ * Set the full AI model name.
200
+ *
201
+ * @param fullModelName The full name of the AI model to use.
202
+ */
203
+ setFullModelName(fullModelName) {
204
+ this._fullModelName = fullModelName;
205
+ }
206
+ /**
207
+ * Get the current full model name.
208
+ *
209
+ * @returns The full name of the AI model being used.
210
+ */
211
+ getFullModelName() {
212
+ return this._fullModelName;
213
+ }
214
+ /** Whether this service's API supports the "developer" message role. */
215
+ get supportsDeveloperRole() {
216
+ return this.constructor.supportsDeveloperRole;
217
+ }
218
+ /**
219
+ * Turn request parameters into the JSON body to send: `extra_body` is
220
+ * merged in over the other fields, as the Python client does.
221
+ */
222
+ _requestBody(params) {
223
+ const { extra_body: extraBody, ...rest } = params;
224
+ return { ...rest, ...(extraBody ?? {}) };
225
+ }
226
+ /**
227
+ * Get streaming chat completions from OpenAI API with optional timeout and retry.
228
+ *
229
+ * @param context Context to use for the chat completion. Contains messages,
230
+ * tools, and tool choice.
231
+ * @returns Async stream of chat completion chunks.
232
+ */
233
+ async getChatCompletions(context) {
234
+ const adapter = this.getLlmAdapter();
235
+ this._logLlmResponse(context);
236
+ const paramsFromContext = await adapter.getLlmInvocationParams(context, {
237
+ systemInstruction: assertGiven(this._settings.systemInstruction),
238
+ convertDeveloperToUser: !this.supportsDeveloperRole,
239
+ });
240
+ const params = this.buildChatCompletionParams(paramsFromContext);
241
+ const create = () => abortable(this._client.chat.completions.create(this._requestBody(params), {
242
+ signal: currentSignal(),
243
+ }));
244
+ if (this._retryOnTimeout) {
245
+ try {
246
+ const chunks = await waitFor(() => create(), this._retryTimeoutSecs);
247
+ return chunks;
248
+ }
249
+ catch (e) {
250
+ if (e instanceof TimeoutError || e instanceof APIConnectionTimeoutError) {
251
+ // Retry, this time without a timeout so we get a response
252
+ logger.debug(`${this}: Retrying chat completion due to timeout`);
253
+ const chunks = await create();
254
+ return chunks;
255
+ }
256
+ throw e;
257
+ }
258
+ }
259
+ else {
260
+ const chunks = await create();
261
+ return chunks;
262
+ }
263
+ }
264
+ /**
265
+ * Build parameters for chat completion request.
266
+ *
267
+ * Subclasses can override this to customize parameters for different
268
+ * providers.
269
+ *
270
+ * @param paramsFromContext Parameters, derived from the LLM context, to use
271
+ * for the chat completion. Contains messages, tools, and tool choice.
272
+ * @returns Dictionary of parameters for the chat completion request.
273
+ */
274
+ buildChatCompletionParams(paramsFromContext) {
275
+ const params = {
276
+ model: this._settings.model,
277
+ stream: true,
278
+ stream_options: { include_usage: true },
279
+ frequency_penalty: this._settings.frequencyPenalty,
280
+ presence_penalty: this._settings.presencePenalty,
281
+ seed: this._settings.seed,
282
+ temperature: this._settings.temperature,
283
+ top_p: this._settings.topP,
284
+ max_tokens: this._settings.maxTokens,
285
+ max_completion_tokens: this._settings.maxCompletionTokens,
286
+ service_tier: this._serviceTier !== null ? this._serviceTier : OPENAI_NOT_GIVEN,
287
+ };
288
+ // Messages, tools, tool_choice
289
+ Object.assign(params, paramsFromContext);
290
+ Object.assign(params, this._settings.extra);
291
+ return params;
292
+ }
293
+ /**
294
+ * Add provider-specific fields to a request's `extra_body`.
295
+ *
296
+ * The OpenAI client rejects keyword arguments it doesn't know, so fields
297
+ * specific to an OpenAI-compatible provider travel in `extra_body`, which
298
+ * the client merges into the request JSON as-is.
299
+ *
300
+ * @param params Request parameters, updated in place. An `extra_body`
301
+ * already there, supplied through `Settings.extra`, wins key by key,
302
+ * matching how `extra` overrides every other request parameter. It is
303
+ * copied rather than modified.
304
+ * @param fields Fields to add. Unset and null values are omitted.
305
+ */
306
+ static _mergeExtraBody(params, fields) {
307
+ const extraBody = {};
308
+ for (const [name, value] of Object.entries(fields)) {
309
+ if (isGiven(value) && value !== null && value !== undefined) {
310
+ extraBody[name] = value;
311
+ }
312
+ }
313
+ Object.assign(extraBody, params.extra_body || {});
314
+ if (Object.keys(extraBody).length > 0) {
315
+ params.extra_body = extraBody;
316
+ }
317
+ }
318
+ /**
319
+ * Whether a model can enforce a response schema.
320
+ *
321
+ * OpenAI models before gpt-4o-mini and gpt-4o-2024-08-06 cannot.
322
+ *
323
+ * @param model The model name.
324
+ */
325
+ static modelSupportsResponseSchema(model) {
326
+ return !OPENAI_MODEL_WITHOUT_RESPONSE_SCHEMA.test(model);
327
+ }
328
+ /**
329
+ * Run a one-shot, out-of-band (i.e. out-of-pipeline) inference with the
330
+ * given LLM context.
331
+ *
332
+ * @param context The LLM context containing conversation history.
333
+ * @param options.maxTokens Optional maximum number of tokens to generate. If
334
+ * provided, overrides the service's default max_tokens /
335
+ * max_completion_tokens setting.
336
+ * @param options.systemInstruction Optional system instruction to use for
337
+ * this inference. If provided, overrides any system instruction in the
338
+ * context.
339
+ * @param options.responseSchema Optional JSON schema the reply must follow.
340
+ * The service asks the provider to enforce it, so the reply is JSON text
341
+ * matching the schema.
342
+ * @returns The LLM's response as a string, or null if no response is
343
+ * generated.
344
+ */
345
+ async runInference(context, options = {}) {
346
+ const maxTokens = options.maxTokens ?? null;
347
+ const effectiveInstruction = options.systemInstruction || assertGiven(this._settings.systemInstruction);
348
+ const adapter = this.getLlmAdapter();
349
+ const invocationParams = await adapter.getLlmInvocationParams(context, {
350
+ systemInstruction: effectiveInstruction,
351
+ convertDeveloperToUser: !this.supportsDeveloperRole,
352
+ });
353
+ // Build params using the same method as streaming completions
354
+ const params = this.buildChatCompletionParams(invocationParams);
355
+ // Override for non-streaming
356
+ params.stream = false;
357
+ delete params.stream_options;
358
+ // Override max_tokens if provided
359
+ if (maxTokens !== null) {
360
+ // Use max_completion_tokens for newer models, fallback to max_tokens
361
+ if ("max_completion_tokens" in params) {
362
+ params.max_completion_tokens = maxTokens;
363
+ }
364
+ else {
365
+ params.max_tokens = maxTokens;
366
+ }
367
+ }
368
+ const responseSchema = this._checkResponseSchema(options.responseSchema);
369
+ if (responseSchema !== null) {
370
+ params.response_format = {
371
+ type: "json_schema",
372
+ json_schema: { name: "response", schema: responseSchema, strict: true },
373
+ };
374
+ }
375
+ // LLM completion
376
+ const response = (await abortable(this._client.chat.completions.create(this._requestBody(params), {
377
+ signal: currentSignal(),
378
+ })));
379
+ return response.choices[0]?.message.content ?? null;
380
+ }
381
+ /**
382
+ * Convert a chat completion's usage into Pipecat's token usage.
383
+ *
384
+ * Follows OpenAI's convention, where `completion_tokens` includes the
385
+ * reasoning tokens reported in `completion_tokens_details`. Providers that
386
+ * report reasoning elsewhere, or apart from `completion_tokens`, override
387
+ * this.
388
+ *
389
+ * @param usage The usage reported with the completion.
390
+ * @returns The token usage to report.
391
+ */
392
+ _tokenUsage(usage) {
393
+ const promptDetails = usage.prompt_tokens_details;
394
+ const cachedTokens = promptDetails ? (promptDetails.cached_tokens ?? null) : null;
395
+ // Tokens written into the prompt cache, billed above the input rate.
396
+ // Providers without prompt caching omit the field, which reads as "not
397
+ // reported" rather than zero.
398
+ const cacheWriteTokens = promptDetails
399
+ ? (promptDetails.cache_write_tokens ?? null)
400
+ : null;
401
+ const reasoningTokens = usage.completion_tokens_details
402
+ ? (usage.completion_tokens_details.reasoning_tokens ?? null)
403
+ : null;
404
+ return new LLMTokenUsage({
405
+ promptTokens: usage.prompt_tokens,
406
+ completionTokens: usage.completion_tokens,
407
+ totalTokens: usage.total_tokens,
408
+ cacheReadInputTokens: cachedTokens,
409
+ cacheCreationInputTokens: cacheWriteTokens,
410
+ reasoningTokens: reasoningTokens,
411
+ });
412
+ }
413
+ async _processContext(context) {
414
+ const functionsList = [];
415
+ const argumentsList = [];
416
+ const toolIdList = [];
417
+ let funcIdx = 0;
418
+ let functionName = "";
419
+ let args = "";
420
+ let toolCallId = "";
421
+ await this.startTtfbMetrics();
422
+ // Generate chat completions from LLMContext
423
+ const chunkStream = await this.getChatCompletions(context);
424
+ // Ensure the stream and its async iterator are closed on
425
+ // cancellation/exception to release the HTTP connection. Each chunk is
426
+ // awaited as a cancellation point, since the SDK's iterator is a foreign
427
+ // await.
428
+ const chunkIter = chunkStream[Symbol.asyncIterator]();
429
+ // Providers differ in how often they send usage: some once at the end,
430
+ // others a cumulative snapshot on every chunk. Holding the latest and
431
+ // reporting it after the stream keeps that to one report per completion.
432
+ let tokenUsage = null;
433
+ try {
434
+ try {
435
+ while (true) {
436
+ const next = await abortable(chunkIter.next());
437
+ if (next.done)
438
+ break;
439
+ const chunk = next.value;
440
+ if (chunk.usage) {
441
+ tokenUsage = this._tokenUsage(chunk.usage);
442
+ }
443
+ if (chunk.model && this.getFullModelName() !== chunk.model) {
444
+ this.setFullModelName(chunk.model);
445
+ }
446
+ if (chunk.choices === null || chunk.choices === undefined || chunk.choices.length === 0) {
447
+ continue;
448
+ }
449
+ await this.stopTtfbMetrics();
450
+ const delta = chunk.choices[0]?.delta;
451
+ if (!delta) {
452
+ continue;
453
+ }
454
+ if (delta.tool_calls && delta.tool_calls.length > 0) {
455
+ // A turn that only calls tools produces no answer text, so the
456
+ // call itself is what the caller gets and TTFAT ends here rather
457
+ // than going unmeasured.
458
+ await this.stopTtfatMetrics();
459
+ // We're streaming the LLM response to enable the fastest response
460
+ // times. For text, we just yield each chunk as we receive it and
461
+ // count on consumers to do whatever coalescing they need (eg. to
462
+ // pass full sentences to TTS)
463
+ //
464
+ // If the LLM is a function call, we'll do some coalescing here. If
465
+ // the response contains a function name, we'll yield a frame to
466
+ // tell consumers that they can start preparing to call the
467
+ // function with that name. We accumulate all the arguments for the
468
+ // rest of the streamed response, then when the response is done,
469
+ // we package up all the arguments and the function name and yield
470
+ // a frame containing the function name and the arguments.
471
+ const toolCall = delta.tool_calls[0];
472
+ if (toolCall.index !== funcIdx) {
473
+ functionsList.push(functionName);
474
+ argumentsList.push(args || "{}");
475
+ toolIdList.push(toolCallId);
476
+ functionName = "";
477
+ args = "";
478
+ toolCallId = "";
479
+ funcIdx += 1;
480
+ }
481
+ if (toolCall.function?.name) {
482
+ functionName += toolCall.function.name;
483
+ toolCallId = toolCall.id;
484
+ }
485
+ if (toolCall.function?.arguments) {
486
+ // Keep iterating through the response to collect all the argument fragments
487
+ args += toolCall.function.arguments;
488
+ }
489
+ }
490
+ else if (delta.content) {
491
+ await this._pushLlmText(delta.content);
492
+ }
493
+ // When gpt-4o-audio / gpt-4o-mini-audio is used for llm or stt+llm
494
+ // we need to get LLMTextFrame for the transcript
495
+ else if (delta.audio?.transcript) {
496
+ await this.pushFrame(new LLMTextFrame({ text: delta.audio.transcript }));
497
+ }
498
+ }
499
+ }
500
+ finally {
501
+ // Close the iterator first, then abort the request to release HTTP
502
+ // resources.
503
+ try {
504
+ await chunkIter.return?.();
505
+ }
506
+ catch (e) {
507
+ rethrowIfCancelled(e);
508
+ }
509
+ chunkStream.controller?.abort();
510
+ }
511
+ }
512
+ finally {
513
+ // Report even if the response is interrupted or cancelled mid-stream.
514
+ if (tokenUsage) {
515
+ await this.startLlmUsageMetrics(tokenUsage);
516
+ }
517
+ }
518
+ // if we got a function name and arguments, check to see if it's a function
519
+ // with a registered handler. If so, run the registered callback, save the
520
+ // result to the context, and re-prompt to get a chat answer. If we don't
521
+ // have a registered handler, raise an exception.
522
+ if (functionName) {
523
+ // added to the list as last function name and arguments not added to the list
524
+ functionsList.push(functionName);
525
+ argumentsList.push(args || "{}");
526
+ toolIdList.push(toolCallId);
527
+ const functionCalls = [];
528
+ for (let i = 0; i < functionsList.length; i++) {
529
+ const name = functionsList[i];
530
+ const rawArguments = argumentsList[i];
531
+ const toolId = toolIdList[i];
532
+ let parsedArguments;
533
+ try {
534
+ parsedArguments = pyJsonLoads(rawArguments);
535
+ }
536
+ catch (e) {
537
+ rethrowIfCancelled(e);
538
+ logger.warning(`${this}: Failed to parse function call arguments: ${rawArguments}`);
539
+ continue;
540
+ }
541
+ functionCalls.push(new FunctionCallFromLLM({
542
+ context,
543
+ toolCallId: toolId,
544
+ functionName: name,
545
+ arguments: parsedArguments,
546
+ }));
547
+ }
548
+ await this.runFunctionCalls(functionCalls);
549
+ }
550
+ }
551
+ /**
552
+ * Process frames for LLM completion requests.
553
+ *
554
+ * Handles LLMContextFrame to trigger LLM completions.
555
+ *
556
+ * @param frame The frame to process.
557
+ * @param direction The direction of frame processing.
558
+ */
559
+ async processFrame(frame, direction) {
560
+ await super.processFrame(frame, direction);
561
+ if (frame instanceof LLMContextFrame) {
562
+ try {
563
+ await this.pushFrame(new LLMFullResponseStartFrame());
564
+ await this.startProcessingMetrics();
565
+ await this._processContext(frame.context);
566
+ }
567
+ catch (e) {
568
+ rethrowIfCancelled(e);
569
+ if (isTimeoutException(e)) {
570
+ await this._callEventHandler("on_completion_timeout");
571
+ await this.pushError("LLM completion timeout", { exception: e });
572
+ }
573
+ else if (e instanceof LLMContextConversionError) {
574
+ await this.pushError(e.message, { exception: e });
575
+ // A conversion failure (e.g. an unsupported file MIME type, corrupt
576
+ // base64 data) can't reach the API at all, but is just as much
577
+ // evidence of an invalid file as a rejection from OpenAI itself, so
578
+ // it gets the same best-effort cleanup.
579
+ frame.context.removeInvalidFileMessage();
580
+ }
581
+ else {
582
+ // Only the payload-shaped errors (bad request, unprocessable
583
+ // content, payload too large — the latter has no dedicated OpenAI
584
+ // exception class, so match its status code) are grounds to remove
585
+ // a pending file message on a best-effort basis. The rest of the
586
+ // 4xx range — auth, permissions, not-found, rate limiting — says
587
+ // nothing about whether our request (or its file) was bad, and
588
+ // removing the file there would discard it for no benefit: the file
589
+ // isn't what needs fixing before the next retry can succeed. When a
590
+ // message is removed as a result of this error, the fault lay in
591
+ // application-supplied content and the context is repaired, so the
592
+ // error is pushed as APPLICATION instead of letting the rejection
593
+ // classify as permanent and cost the service its usability.
594
+ const removed = (e instanceof BadRequestError ||
595
+ e instanceof UnprocessableEntityError ||
596
+ (e instanceof APIError && e.status === 413)) &&
597
+ frame.context.removeInvalidFileMessage();
598
+ await this.pushError(`Error during completion: ${errorMessage(e)}`, {
599
+ exception: e,
600
+ category: removed ? ErrorCategory.APPLICATION : null,
601
+ });
602
+ }
603
+ }
604
+ finally {
605
+ await this.stopProcessingMetrics();
606
+ await this.pushFrame(new LLMFullResponseEndFrame());
607
+ }
608
+ }
609
+ else {
610
+ await this.pushFrame(frame, direction);
611
+ }
612
+ }
613
+ }
614
+ // @traced_llm on `_process_context`.
615
+ {
616
+ const proto = BaseOpenAILLMService.prototype;
617
+ proto._processContext = tracedLlm(proto._processContext);
618
+ }
619
+ //# sourceMappingURL=base_llm.js.map