@pipedog/openai 0.0.0 → 0.4.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 +49 -3
- package/README.md +0 -5
package/dist/base_llm.js
ADDED
|
@@ -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
|