@framers/agentos 0.10.24 → 0.10.26
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/dist/api/model.d.ts.map +1 -1
- package/dist/api/model.js +5 -1
- package/dist/api/model.js.map +1 -1
- package/dist/api/streamText.d.ts.map +1 -1
- package/dist/api/streamText.js +21 -15
- package/dist/api/streamText.js.map +1 -1
- package/dist/cognition/emergent/SandboxedToolForge.d.ts +30 -1
- package/dist/cognition/emergent/SandboxedToolForge.d.ts.map +1 -1
- package/dist/cognition/emergent/SandboxedToolForge.js +87 -6
- package/dist/cognition/emergent/SandboxedToolForge.js.map +1 -1
- package/dist/core/llm/providers/implementations/OpenAIProvider.d.ts +202 -68
- package/dist/core/llm/providers/implementations/OpenAIProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/OpenAIProvider.js +866 -204
- package/dist/core/llm/providers/implementations/OpenAIProvider.js.map +1 -1
- package/dist/core/llm/providers/implementations/openai-response-format-guard.d.ts +17 -0
- package/dist/core/llm/providers/implementations/openai-response-format-guard.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/openai-response-format-guard.js +31 -0
- package/dist/core/llm/providers/implementations/openai-response-format-guard.js.map +1 -1
- package/dist/core/llm/providers/model-effort.d.ts +7 -0
- package/dist/core/llm/providers/model-effort.d.ts.map +1 -1
- package/dist/core/llm/providers/model-effort.js +16 -2
- package/dist/core/llm/providers/model-effort.js.map +1 -1
- package/package.json +1 -1
|
@@ -3,7 +3,7 @@ import { stripForeignVendorParams } from '../openrouter-only-params.js';
|
|
|
3
3
|
import { resolveOpenAiCacheRetentionParams, resolvePromptCacheKey } from '../openai-cache-params.js';
|
|
4
4
|
import { OpenAIProviderError } from '../errors/OpenAIProviderError.js';
|
|
5
5
|
import { ApiKeyPool } from '../../../providers/ApiKeyPool.js';
|
|
6
|
-
import { toOpenAiResponseFormat } from './openai-response-format-guard.js';
|
|
6
|
+
import { toOpenAiResponseFormat, toOpenAiResponsesTextFormat } from './openai-response-format-guard.js';
|
|
7
7
|
import { clampMaxOutputTokens } from '../model-output-limits.js';
|
|
8
8
|
import { mapEffortToOpenAiReasoningEffort, mapEffortToOpenAiReasoningEffortForModel, mapEffortToOpenAiResponsesEffort } from '../model-effort.js';
|
|
9
9
|
import { computeRetryBackoffMs } from './retry-backoff.js';
|
|
@@ -115,27 +115,54 @@ export function openAiHasLongContextPricing(modelId) {
|
|
|
115
115
|
/**
|
|
116
116
|
* Whether OpenAI serves `modelId` only through the Responses API. OpenAI lists
|
|
117
117
|
* the `-pro` tiers, the codex models, `gpt-5.6-cyber` and the deep-research
|
|
118
|
-
* models as Responses-only
|
|
119
|
-
*
|
|
120
|
-
*
|
|
118
|
+
* models as Responses-only; on /v1/chat/completions they fail (gpt-5.3-codex
|
|
119
|
+
* answers 404). OpenAIProvider sends every call to these ids to /v1/responses
|
|
120
|
+
* (see {@link openAiRequiresResponsesApi}).
|
|
121
|
+
*
|
|
122
|
+
* Only OpenAI's own families (`gpt-`, the o-series, `codex-`) qualify. Groq,
|
|
123
|
+
* xAI, Together and Mistral delegate to this class with their own model ids,
|
|
124
|
+
* and an id such as `grok-4-pro` on one of those endpoints is served by its
|
|
125
|
+
* Chat Completions route.
|
|
121
126
|
*
|
|
122
127
|
* @param modelId Provider-side model identifier.
|
|
123
128
|
* @returns `true` when the model has no Chat Completions endpoint.
|
|
124
129
|
*/
|
|
125
130
|
export function isOpenAIResponsesOnlyModel(modelId) {
|
|
126
|
-
return
|
|
131
|
+
return /^(gpt-|o\d|codex-)/i.test(modelId)
|
|
132
|
+
&& /-pro(-\d{4}-\d{2}-\d{2})?$|codex|^gpt-5\.6-cyber|deep-research/i.test(modelId);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether a call to `modelId` can be served only by `/v1/responses`:
|
|
136
|
+
*
|
|
137
|
+
* - any call to a Responses-only model ({@link isOpenAIResponsesOnlyModel});
|
|
138
|
+
* - a GPT-6 call carrying function tools. Chat Completions does not serve
|
|
139
|
+
* it: `gpt-6-astra` rejects tool calls there with or without a
|
|
140
|
+
* `reasoning_effort` (probed 2026-09-10 and 2026-09-30), the `gpt-6.1-sol` model page lists
|
|
141
|
+
* tool calling on Responses only, and the `gpt-6-sol` / `gpt-6-luna`
|
|
142
|
+
* pages allow chat tool calls only with `reasoning_effort: 'none'`. A
|
|
143
|
+
* 2026-09-30 probe of a GPT-6 tool call with no `reasoning_effort` on
|
|
144
|
+
* /v1/chat/completions returned HTTP 400.
|
|
145
|
+
*
|
|
146
|
+
* {@link shouldRouteToOpenAiResponsesApi} applies this rule to streamed and
|
|
147
|
+
* non-streamed calls alike.
|
|
148
|
+
*
|
|
149
|
+
* @param modelId Provider-side model identifier.
|
|
150
|
+
* @param options The request's tools.
|
|
151
|
+
* @returns `true` when the call has no Chat Completions route.
|
|
152
|
+
*/
|
|
153
|
+
export function openAiRequiresResponsesApi(modelId, options) {
|
|
154
|
+
return (isOpenAIResponsesOnlyModel(modelId) ||
|
|
155
|
+
(/^gpt-6/i.test(modelId) && requestHasFunctionTools(options.tools)));
|
|
127
156
|
}
|
|
128
157
|
/**
|
|
129
158
|
* ModelInfo capabilities for a GPT-5, GPT-6 or o-series id, limited to what
|
|
130
159
|
* OpenAIProvider can serve.
|
|
131
160
|
*
|
|
132
161
|
* - Responses-only models ({@link isOpenAIResponsesOnlyModel}) list as
|
|
133
|
-
* `chat` only.
|
|
134
|
-
*
|
|
135
|
-
* - GPT-6
|
|
136
|
-
*
|
|
137
|
-
* calls on Chat Completions, and this provider routes a tool call to
|
|
138
|
-
* Responses only when it carries an effort and is not streamed.
|
|
162
|
+
* `chat` only. Tool and streaming support vary from model to model on
|
|
163
|
+
* that tier, and the refresh leaves the Responses-only o-series ids out.
|
|
164
|
+
* - GPT-6 lists `tool_use`: every GPT-6 tool call, streamed or not, goes to
|
|
165
|
+
* `/v1/responses` ({@link openAiRequiresResponsesApi}).
|
|
139
166
|
* - `o1-mini` and `o1-preview` have no function calling or structured
|
|
140
167
|
* outputs. They and `o3-mini` take text only, and every other id takes
|
|
141
168
|
* images (per model input modalities, checked 2026-09-29).
|
|
@@ -147,9 +174,7 @@ export function openAiReasoningCapabilities(modelId) {
|
|
|
147
174
|
const id = modelId.toLowerCase();
|
|
148
175
|
if (isOpenAIResponsesOnlyModel(id) || /^o1-(mini|preview)/.test(id))
|
|
149
176
|
return ['chat'];
|
|
150
|
-
const caps = ['chat', 'json_mode'];
|
|
151
|
-
if (!/^gpt-6/.test(id))
|
|
152
|
-
caps.push('tool_use');
|
|
177
|
+
const caps = ['chat', 'json_mode', 'tool_use'];
|
|
153
178
|
if (!/^o3-mini/.test(id))
|
|
154
179
|
caps.push('vision_input');
|
|
155
180
|
return caps;
|
|
@@ -183,32 +208,30 @@ export function isOpenAIReasoningModel(modelId) {
|
|
|
183
208
|
return /^(o\d|gpt-5|gpt-6)/i.test(modelId);
|
|
184
209
|
}
|
|
185
210
|
/**
|
|
186
|
-
* Whether OpenAI rejects `reasoning_effort` sent
|
|
187
|
-
* the `/v1/chat/completions` endpoint for `modelId`. The GPT-5 family
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* 2026-07-08 when the codegen frontier fallback chain
|
|
191
|
-
* effort:xhigh orchestrator turn to gpt-5.5
|
|
211
|
+
* Whether OpenAI rejects `reasoning_effort` sent alongside function tools on
|
|
212
|
+
* the `/v1/chat/completions` endpoint for `modelId`. The GPT-5 family does:
|
|
213
|
+
* "Function tools with reasoning_effort are not supported for gpt-5.5 in
|
|
214
|
+
* /v1/chat/completions. Please use /v1/responses instead." (live-observed
|
|
215
|
+
* 2026-07-08, when the codegen frontier fallback chain sent a tool-carrying,
|
|
216
|
+
* effort:xhigh orchestrator turn to gpt-5.5 and the whole build failed).
|
|
192
217
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
218
|
+
* GPT-6 does as well. Live-probed 2026-09-10 (chat.completions,
|
|
219
|
+
* `gpt-6-astra`, `reasoning_effort: 'xhigh'` and one function tool): HTTP 400
|
|
220
|
+
* `invalid_request_error`, "Function tools with reasoning_effort are not
|
|
221
|
+
* supported for gpt-6-astra in /v1/chat/completions. To use function tools,
|
|
222
|
+
* use /v1/responses or set reasoning_effort to 'none'." The 2026-09-30 probe
|
|
223
|
+
* found that `gpt-6-sol` and `gpt-6-luna` accept `'none'` there while
|
|
224
|
+
* `gpt-6-astra` rejects it, and that a GPT-6 tool call with no
|
|
225
|
+
* `reasoning_effort` also returns 400, so every GPT-6 tool call goes to
|
|
226
|
+
* `/v1/responses` ({@link openAiRequiresResponsesApi}).
|
|
201
227
|
*
|
|
202
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
209
|
-
* ("Supported values are: 'low', 'medium', 'high', and 'xhigh'"). Dropping the
|
|
210
|
-
* param entirely (or routing to `/v1/responses`) is therefore the only viable
|
|
211
|
-
* path, which is exactly what the two call sites below already do.
|
|
228
|
+
* {@link shouldRouteToOpenAiResponsesApi} sends a GPT-5 tool call that
|
|
229
|
+
* carries an effort to `/v1/responses`, which keeps both the effort and the
|
|
230
|
+
* tools. A call whose message content Responses cannot carry stays on
|
|
231
|
+
* chat/completions, where the chat builder drops `reasoning_effort` so the
|
|
232
|
+
* call succeeds at the model's default depth. The o-series (o1/o3/o4) accepts
|
|
233
|
+
* `reasoning_effort` with tools on chat/completions, so it stays outside this
|
|
234
|
+
* predicate; widen it if a future o-series model enforces the same rule.
|
|
212
235
|
*
|
|
213
236
|
* @param modelId Provider-side model identifier (e.g. `'gpt-5.5'`, `'gpt-6-astra'`).
|
|
214
237
|
*/
|
|
@@ -275,35 +298,196 @@ export function flattenResponsesTextContent(content) {
|
|
|
275
298
|
return '';
|
|
276
299
|
return content.filter(isTextPart).map((p) => p.text).join('\n');
|
|
277
300
|
}
|
|
301
|
+
/** Whether one content part is an image URL block, which `/v1/responses` takes as `input_image`. */
|
|
302
|
+
function isImageUrlPart(part) {
|
|
303
|
+
if (part == null || typeof part !== 'object')
|
|
304
|
+
return false;
|
|
305
|
+
const p = part;
|
|
306
|
+
return (p.type === 'image_url' &&
|
|
307
|
+
p.image_url != null &&
|
|
308
|
+
typeof p.image_url === 'object' &&
|
|
309
|
+
typeof p.image_url.url === 'string');
|
|
310
|
+
}
|
|
278
311
|
/**
|
|
279
|
-
* Whether
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
* message content the input mapper cannot carry (see
|
|
288
|
-
* {@link isResponsesMappableContent}). Every excluded case stays on
|
|
289
|
-
* chat/completions; R4's effort-drop remains the defense-in-depth floor there.
|
|
312
|
+
* Whether the `/v1/responses` builder can carry message `m` without losing
|
|
313
|
+
* content. Text-only content ({@link isResponsesMappableContent}) maps on
|
|
314
|
+
* every role. User and tool messages may also carry image parts: a user turn
|
|
315
|
+
* sends them as `input_image`, and a tool result becomes a
|
|
316
|
+
* `function_call_output` whose output is an `input_text` / `input_image`
|
|
317
|
+
* array (the form OpenAI's function-calling reference gives for it). Any other
|
|
318
|
+
* part, such as audio, a file or an Anthropic `tool_result` block, has no
|
|
319
|
+
* mapping.
|
|
290
320
|
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
321
|
+
* @param m A request message.
|
|
322
|
+
* @returns `true` when the message maps onto Responses input losslessly.
|
|
323
|
+
*/
|
|
324
|
+
export function isResponsesMappableMessage(m) {
|
|
325
|
+
if (isResponsesMappableContent(m.content))
|
|
326
|
+
return true;
|
|
327
|
+
return ((m.role === 'user' || m.role === 'tool') &&
|
|
328
|
+
Array.isArray(m.content) &&
|
|
329
|
+
m.content.every((part) => isTextPart(part) || isImageUrlPart(part)));
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The `/v1/responses` form of a user or tool message's content. Text-only
|
|
333
|
+
* content stays the flattened string {@link flattenResponsesTextContent}
|
|
334
|
+
* builds; content with images becomes `input_text` and `input_image` parts.
|
|
335
|
+
* Every image carries a `detail`, `'auto'` (the API's default) when the source
|
|
336
|
+
* part set none. Callers gate on {@link isResponsesMappableMessage} first.
|
|
337
|
+
*/
|
|
338
|
+
function toResponsesInputContent(content) {
|
|
339
|
+
if (!Array.isArray(content) || isResponsesMappableContent(content)) {
|
|
340
|
+
return flattenResponsesTextContent(content);
|
|
341
|
+
}
|
|
342
|
+
return content.map((part) => isImageUrlPart(part)
|
|
343
|
+
? { type: 'input_image', image_url: part.image_url.url, detail: part.image_url.detail ?? 'auto' }
|
|
344
|
+
: { type: 'input_text', text: isTextPart(part) ? part.text : '' });
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Whether a call goes to `/v1/responses` instead of `/v1/chat/completions`.
|
|
348
|
+
* Streamed and non-streamed calls follow this one rule.
|
|
349
|
+
*
|
|
350
|
+
* - Every call {@link openAiRequiresResponsesApi} names: Responses-only
|
|
351
|
+
* models and GPT-6 tool calls.
|
|
352
|
+
* - A GPT-5 or GPT-6 call that carries function tools and an effort, when
|
|
353
|
+
* every message maps ({@link isResponsesMappableMessage}). Chat
|
|
354
|
+
* Completions rejects `reasoning_effort` alongside function tools for
|
|
355
|
+
* these families ("Function tools with reasoning_effort are not supported
|
|
356
|
+
* ... Please use /v1/responses instead"); Responses keeps both, and a
|
|
357
|
+
* `responseFormat` travels as `text.format`.
|
|
358
|
+
*
|
|
359
|
+
* Content gating goes through {@link isResponsesMappableMessage}, which
|
|
360
|
+
* accepts Anthropic-style cache-marked system blocks (`systemBlocks` reach the
|
|
361
|
+
* provider as an all-text content array) and flattens them without loss. A
|
|
362
|
+
* GPT-5 tool call whose content does not map stays on chat/completions, where
|
|
363
|
+
* the chat builder drops the effort (see
|
|
364
|
+
* {@link openAiRejectsReasoningEffortWithTools}).
|
|
365
|
+
*
|
|
366
|
+
* @param modelId Provider-side model identifier.
|
|
367
|
+
* @param messages The request messages.
|
|
368
|
+
* @param options The request's tools, effort and response format.
|
|
369
|
+
* @returns `true` when the call goes to `/v1/responses`.
|
|
300
370
|
*/
|
|
301
371
|
export function shouldRouteToOpenAiResponsesApi(modelId, messages, options) {
|
|
372
|
+
if (openAiRequiresResponsesApi(modelId, options))
|
|
373
|
+
return true;
|
|
302
374
|
return (openAiRejectsReasoningEffortWithTools(modelId) &&
|
|
303
375
|
requestHasFunctionTools(options.tools) &&
|
|
304
376
|
mapEffortToOpenAiReasoningEffort(options.effort) !== undefined &&
|
|
305
|
-
|
|
306
|
-
|
|
377
|
+
messages.every(isResponsesMappableMessage));
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Reads a `/v1/responses` `output` array: assistant text from the
|
|
381
|
+
* `output_text` parts of message items and tool calls from `function_call`
|
|
382
|
+
* items (`call_id` becomes the call id). Reasoning and other item types count
|
|
383
|
+
* toward `itemCount` only.
|
|
384
|
+
*/
|
|
385
|
+
function summarizeResponsesOutput(output) {
|
|
386
|
+
const items = output ?? [];
|
|
387
|
+
let text = '';
|
|
388
|
+
const toolCalls = [];
|
|
389
|
+
for (const item of items) {
|
|
390
|
+
if (item.type === 'message') {
|
|
391
|
+
for (const part of item.content ?? []) {
|
|
392
|
+
if (part.type === 'output_text' && typeof part.text === 'string')
|
|
393
|
+
text += part.text;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
else if (item.type === 'function_call') {
|
|
397
|
+
toolCalls.push({
|
|
398
|
+
id: item.call_id ?? '',
|
|
399
|
+
type: 'function',
|
|
400
|
+
function: { name: item.name ?? '', arguments: item.arguments ?? '' },
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return { text, toolCalls, itemCount: items.length };
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* HTTP-equivalent statuses of the `/v1/responses` error codes that mean the
|
|
408
|
+
* request may succeed later or elsewhere. A stream that fails with one of them
|
|
409
|
+
* before any output throws with this status, so the fallback walker treats it
|
|
410
|
+
* like the HTTP error a failed request returns.
|
|
411
|
+
*/
|
|
412
|
+
const RESPONSES_RETRYABLE_ERROR_STATUS = {
|
|
413
|
+
server_error: 500,
|
|
414
|
+
rate_limit_exceeded: 429,
|
|
415
|
+
vector_store_timeout: 504,
|
|
416
|
+
};
|
|
417
|
+
/** The HTTP-equivalent status of a `/v1/responses` error code, when it is a retryable one. */
|
|
418
|
+
function responsesErrorStatus(code) {
|
|
419
|
+
return typeof code === 'string' ? RESPONSES_RETRYABLE_ERROR_STATUS[code] : undefined;
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* The chat-style finish reason of a `/v1/responses` result, shared by the
|
|
423
|
+
* streamed and non-streamed mappings. An incomplete response reports why it
|
|
424
|
+
* stopped, as Chat Completions does, even when it holds a function call (the
|
|
425
|
+
* call may be cut off): `length` when the output budget ran out and
|
|
426
|
+
* `content_filter` when a filter stopped it. Otherwise `tool_calls` when the
|
|
427
|
+
* turn called tools, else `stop`.
|
|
428
|
+
*/
|
|
429
|
+
function responsesFinishReason(status, incompleteReason, hasToolCalls) {
|
|
430
|
+
if (status === 'incomplete') {
|
|
431
|
+
if (incompleteReason === 'max_output_tokens' || incompleteReason === 'max_tokens')
|
|
432
|
+
return 'length';
|
|
433
|
+
if (incompleteReason === 'content_filter')
|
|
434
|
+
return 'content_filter';
|
|
435
|
+
}
|
|
436
|
+
return hasToolCalls ? 'tool_calls' : 'stop';
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Brings a streamed call in line with its authoritative form (the item on
|
|
440
|
+
* `response.output_item.done`, the text on
|
|
441
|
+
* `response.function_call_arguments.done`, or the call in the terminal
|
|
442
|
+
* response) and returns the delta that carries the difference, or
|
|
443
|
+
* `undefined` when the emitted deltas already match.
|
|
444
|
+
*
|
|
445
|
+
* Argument deltas only append, so when the authoritative arguments extend
|
|
446
|
+
* the streamed text the missing suffix goes out as `arguments_delta`, and a
|
|
447
|
+
* consumer that rebuilds calls from deltas (StreamingReconstructor) ends with
|
|
448
|
+
* the same arguments as the final chunk. Arguments that do not extend the
|
|
449
|
+
* streamed text cannot be corrected by appending; they are kept in
|
|
450
|
+
* `finalArguments` for the final chunk and logged.
|
|
451
|
+
*
|
|
452
|
+
* @param call The streamed call, updated in place.
|
|
453
|
+
* @param authoritative The call's final id, name and arguments; absent
|
|
454
|
+
* fields are left as streamed.
|
|
455
|
+
* @returns The correcting delta, or `undefined`.
|
|
456
|
+
*/
|
|
457
|
+
function reconcileStreamedCall(call, authoritative) {
|
|
458
|
+
const delta = { index: call.index };
|
|
459
|
+
const fn = {};
|
|
460
|
+
let changed = false;
|
|
461
|
+
if (authoritative.id && authoritative.id !== call.id) {
|
|
462
|
+
call.id = authoritative.id;
|
|
463
|
+
delta.id = authoritative.id;
|
|
464
|
+
delta.type = 'function';
|
|
465
|
+
changed = true;
|
|
466
|
+
}
|
|
467
|
+
if (authoritative.name && authoritative.name !== call.name) {
|
|
468
|
+
call.name = authoritative.name;
|
|
469
|
+
fn.name = authoritative.name;
|
|
470
|
+
changed = true;
|
|
471
|
+
}
|
|
472
|
+
const args = authoritative.arguments;
|
|
473
|
+
if (typeof args === 'string' && args !== call.arguments) {
|
|
474
|
+
if (args.startsWith(call.arguments)) {
|
|
475
|
+
fn.arguments_delta = args.slice(call.arguments.length);
|
|
476
|
+
call.arguments = args;
|
|
477
|
+
call.finalArguments = undefined;
|
|
478
|
+
changed = true;
|
|
479
|
+
}
|
|
480
|
+
else if (call.finalArguments !== args) {
|
|
481
|
+
call.finalArguments = args;
|
|
482
|
+
console.warn(`OpenAIProvider: streamed arguments for tool call ${call.id || `#${call.index}`} ` +
|
|
483
|
+
'do not match the final function_call item; the final chunk carries the final arguments.');
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (!changed)
|
|
487
|
+
return undefined;
|
|
488
|
+
if (fn.name !== undefined || fn.arguments_delta !== undefined)
|
|
489
|
+
delta.function = fn;
|
|
490
|
+
return delta;
|
|
307
491
|
}
|
|
308
492
|
export class OpenAIProvider {
|
|
309
493
|
/**
|
|
@@ -596,9 +780,9 @@ export class OpenAIProvider {
|
|
|
596
780
|
async generateCompletion(modelId, messages, options) {
|
|
597
781
|
this.ensureInitialized();
|
|
598
782
|
const apiKey = await this.getApiKey(options.apiKeyOverride);
|
|
599
|
-
// GPT-
|
|
600
|
-
//
|
|
601
|
-
//
|
|
783
|
+
// Responses-only models, GPT-6 tool calls and GPT-5/6 tool calls that
|
|
784
|
+
// carry an effort go to /v1/responses; every other call takes the chat
|
|
785
|
+
// path. generateCompletionStream applies the same rule.
|
|
602
786
|
if (shouldRouteToOpenAiResponsesApi(modelId, messages, options)) {
|
|
603
787
|
const responsesBody = this.buildResponsesPayload(modelId, messages, options);
|
|
604
788
|
const responsesApiResponse = await this.makeApiRequest('/responses', 'POST', apiKey, responsesBody, false, options.requestTimeout);
|
|
@@ -611,61 +795,373 @@ export class OpenAIProvider {
|
|
|
611
795
|
/** @inheritdoc */
|
|
612
796
|
async *generateCompletionStream(modelId, messages, options) {
|
|
613
797
|
this.ensureInitialized();
|
|
614
|
-
const apiKey = await this.getApiKey(options.apiKeyOverride);
|
|
615
|
-
const requestBody = this.buildChatCompletionPayload(modelId, messages, options, true);
|
|
616
|
-
const stream = (await this.makeApiRequest('/chat/completions', 'POST', apiKey, requestBody, true, // Indicate streaming response is expected
|
|
617
|
-
options.requestTimeout));
|
|
618
|
-
// Accumulators for streaming tool calls
|
|
619
|
-
const accumulatedToolCalls = new Map();
|
|
620
798
|
const abortSignal = options.abortSignal;
|
|
799
|
+
// Checked before any network work, so an aborted call never reaches the
|
|
800
|
+
// OAuth token fetch or the POST.
|
|
621
801
|
if (abortSignal?.aborted) {
|
|
622
|
-
yield
|
|
623
|
-
id: `openai-abort-${Date.now()}`,
|
|
624
|
-
object: 'chat.completion.chunk',
|
|
625
|
-
created: Math.floor(Date.now() / 1000),
|
|
626
|
-
modelId,
|
|
627
|
-
choices: [],
|
|
628
|
-
error: { message: 'Stream aborted prior to first chunk', type: 'abort' },
|
|
629
|
-
isFinal: true,
|
|
630
|
-
};
|
|
802
|
+
yield this.abortedStreamChunk(modelId, 'Stream aborted prior to first chunk');
|
|
631
803
|
return;
|
|
632
804
|
}
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
805
|
+
const apiKey = await this.getApiKey(options.apiKeyOverride);
|
|
806
|
+
// The routing rule generateCompletion uses, so a streamed call reaches the
|
|
807
|
+
// same endpoint with the same effort as its non-streamed twin.
|
|
808
|
+
const viaResponses = shouldRouteToOpenAiResponsesApi(modelId, messages, options);
|
|
809
|
+
const endpoint = viaResponses ? '/responses' : '/chat/completions';
|
|
810
|
+
const requestBody = viaResponses
|
|
811
|
+
? this.buildResponsesPayload(modelId, messages, options, true)
|
|
812
|
+
: this.buildChatCompletionPayload(modelId, messages, options, true);
|
|
813
|
+
let stream;
|
|
814
|
+
try {
|
|
815
|
+
stream = await this.makeApiRequest(endpoint, 'POST', apiKey, requestBody, true, options.requestTimeout, abortSignal);
|
|
816
|
+
}
|
|
817
|
+
catch (error) {
|
|
818
|
+
// An abort during the request surfaces as the abort chunk, not a throw.
|
|
641
819
|
if (abortSignal?.aborted) {
|
|
642
|
-
yield
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
820
|
+
yield this.abortedStreamChunk(modelId, 'Stream aborted by caller');
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
throw error;
|
|
824
|
+
}
|
|
825
|
+
if (viaResponses) {
|
|
826
|
+
yield* this.mapResponsesStream(stream, modelId, abortSignal);
|
|
827
|
+
return;
|
|
828
|
+
}
|
|
829
|
+
yield* this.mapChatStream(stream, modelId, abortSignal);
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* The terminal chunk a stream yields when the caller's signal aborts it.
|
|
833
|
+
* @private
|
|
834
|
+
*/
|
|
835
|
+
abortedStreamChunk(modelId, message) {
|
|
836
|
+
return {
|
|
837
|
+
id: `openai-abort-${Date.now()}`,
|
|
838
|
+
object: 'chat.completion.chunk',
|
|
839
|
+
created: Math.floor(Date.now() / 1000),
|
|
840
|
+
modelId,
|
|
841
|
+
choices: [],
|
|
842
|
+
error: { message, type: 'abort' },
|
|
843
|
+
isFinal: true,
|
|
844
|
+
};
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Maps a `/v1/chat/completions` SSE stream onto
|
|
848
|
+
* {@link ModelCompletionResponse} chunks, accumulating streamed tool calls
|
|
849
|
+
* by index. A caller abort ends the stream with the abort chunk.
|
|
850
|
+
* @private
|
|
851
|
+
*/
|
|
852
|
+
async *mapChatStream(stream, modelId, abortSignal) {
|
|
853
|
+
const accumulatedToolCalls = new Map();
|
|
854
|
+
for await (const data of this.parseSseStream(stream, abortSignal)) {
|
|
855
|
+
if (abortSignal?.aborted)
|
|
651
856
|
break;
|
|
857
|
+
// OpenAI ends the stream with [DONE]; the finish and usage chunks come before it.
|
|
858
|
+
if (data === '[DONE]')
|
|
859
|
+
return;
|
|
860
|
+
let mapped;
|
|
861
|
+
try {
|
|
862
|
+
const apiChunk = JSON.parse(data);
|
|
863
|
+
mapped = this.mapApiToStreamChunkResponse(apiChunk, accumulatedToolCalls);
|
|
864
|
+
}
|
|
865
|
+
catch (error) {
|
|
866
|
+
console.warn('OpenAIProvider: Failed to parse stream chunk JSON:', data, error);
|
|
867
|
+
continue;
|
|
652
868
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
869
|
+
yield mapped;
|
|
870
|
+
}
|
|
871
|
+
if (abortSignal?.aborted) {
|
|
872
|
+
yield this.abortedStreamChunk(modelId, 'Stream aborted by caller');
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* Maps a `/v1/responses` SSE stream onto the {@link ModelCompletionResponse}
|
|
877
|
+
* chunks the chat stream produces.
|
|
878
|
+
*
|
|
879
|
+
* - `response.output_text.delta` yields a text chunk.
|
|
880
|
+
* - A `function_call` item yields a tool-call delta with its id and name
|
|
881
|
+
* (`response.output_item.added`), then its argument fragments
|
|
882
|
+
* (`response.function_call_arguments.delta`). Tool-call indexes are
|
|
883
|
+
* dense in order of appearance, so a reasoning item ahead of the calls
|
|
884
|
+
* leaves no gap.
|
|
885
|
+
* - The item's final form (`response.function_call_arguments.done`,
|
|
886
|
+
* `response.output_item.done`) and the terminal response are
|
|
887
|
+
* authoritative. Text, a call id or name, or an argument suffix that the
|
|
888
|
+
* deltas did not carry goes out as one more delta, exactly once, before
|
|
889
|
+
* the final chunk. A consumer that rebuilds the turn from deltas
|
|
890
|
+
* (StreamingReconstructor) and one that reads the final chunk
|
|
891
|
+
* (streamText, GMI) therefore see the same text and calls, including
|
|
892
|
+
* when a proxy strips the delta events.
|
|
893
|
+
* - `response.completed` and `response.incomplete` yield exactly one final
|
|
894
|
+
* chunk with the finish reason, tool calls and usage.
|
|
895
|
+
* - Before any text or tool call has gone out, `response.failed`, an
|
|
896
|
+
* `error` event, and a body that ends or fails to read before a terminal
|
|
897
|
+
* event throw an {@link OpenAIProviderError}, as a failed HTTP request
|
|
898
|
+
* does: the status of a retryable error code
|
|
899
|
+
* ({@link RESPONSES_RETRYABLE_ERROR_STATUS}) or `NETWORK_ERROR` for the
|
|
900
|
+
* cut-off or failed body, and a failed response's billed usage in
|
|
901
|
+
* `details.usage`. Callers can then fail over and meter the attempt.
|
|
902
|
+
* - After output, those failures and a caller abort each yield one final
|
|
903
|
+
* chunk carrying an error instead, so the partial answer stays with the
|
|
904
|
+
* caller.
|
|
905
|
+
*
|
|
906
|
+
* Reasoning summaries, content-part events and refusals carry nothing this
|
|
907
|
+
* mapping surfaces and are skipped.
|
|
908
|
+
* @private
|
|
909
|
+
*/
|
|
910
|
+
async *mapResponsesStream(stream, modelId, abortSignal) {
|
|
911
|
+
let responseId = `openai-resp-${Date.now()}`;
|
|
912
|
+
let created = Math.floor(Date.now() / 1000);
|
|
913
|
+
let responseModel = modelId;
|
|
914
|
+
let emittedText = '';
|
|
915
|
+
const calls = [];
|
|
916
|
+
const callByOutputIndex = new Map();
|
|
917
|
+
const callByItemId = new Map();
|
|
918
|
+
// Whether any text or tool call has gone out to the caller.
|
|
919
|
+
const deliveredOutput = () => emittedText.length > 0 || calls.length > 0;
|
|
920
|
+
// A read that failed mid-body (a dropped connection) ends the event loop
|
|
921
|
+
// like a body cut off before its terminal event.
|
|
922
|
+
const reading = {};
|
|
923
|
+
const noteResponse = (response) => {
|
|
924
|
+
if (!response)
|
|
657
925
|
return;
|
|
926
|
+
if (typeof response.id === 'string' && response.id)
|
|
927
|
+
responseId = response.id;
|
|
928
|
+
if (typeof response.created_at === 'number')
|
|
929
|
+
created = response.created_at;
|
|
930
|
+
if (typeof response.model === 'string' && response.model)
|
|
931
|
+
responseModel = response.model;
|
|
932
|
+
};
|
|
933
|
+
const chunk = (fields) => ({
|
|
934
|
+
id: responseId,
|
|
935
|
+
object: 'chat.completion.chunk',
|
|
936
|
+
created,
|
|
937
|
+
modelId: responseModel,
|
|
938
|
+
choices: [{ index: 0, message: { role: 'assistant', content: null }, finishReason: null }],
|
|
939
|
+
...fields,
|
|
940
|
+
});
|
|
941
|
+
const textChunk = (delta) => chunk({
|
|
942
|
+
choices: [{ index: 0, message: { role: 'assistant', content: delta }, finishReason: null }],
|
|
943
|
+
responseTextDelta: delta,
|
|
944
|
+
});
|
|
945
|
+
// Finds the call an event names, by call id first, then output position,
|
|
946
|
+
// then item id, and starts a new one when none matches.
|
|
947
|
+
const trackCall = (outputIndex, itemId, callId) => {
|
|
948
|
+
let call = (callId ? calls.find((c) => c.id === callId) : undefined) ??
|
|
949
|
+
(outputIndex !== undefined ? callByOutputIndex.get(outputIndex) : undefined) ??
|
|
950
|
+
(itemId ? callByItemId.get(itemId) : undefined);
|
|
951
|
+
if (!call) {
|
|
952
|
+
call = { index: calls.length, id: '', name: '', arguments: '' };
|
|
953
|
+
calls.push(call);
|
|
658
954
|
}
|
|
955
|
+
if (outputIndex !== undefined)
|
|
956
|
+
callByOutputIndex.set(outputIndex, call);
|
|
957
|
+
if (itemId)
|
|
958
|
+
callByItemId.set(itemId, call);
|
|
959
|
+
return call;
|
|
960
|
+
};
|
|
961
|
+
for await (const data of this.readResponsesSse(stream, abortSignal, reading)) {
|
|
962
|
+
if (abortSignal?.aborted)
|
|
963
|
+
break;
|
|
964
|
+
// /v1/responses sends no [DONE]; treat one as the end of the body.
|
|
965
|
+
if (data === '[DONE]')
|
|
966
|
+
break;
|
|
967
|
+
let event;
|
|
659
968
|
try {
|
|
660
|
-
|
|
661
|
-
yield this.mapApiToStreamChunkResponse(apiChunk, accumulatedToolCalls);
|
|
969
|
+
event = JSON.parse(data);
|
|
662
970
|
}
|
|
663
971
|
catch (error) {
|
|
664
|
-
console.warn('OpenAIProvider: Failed to parse stream
|
|
665
|
-
|
|
972
|
+
console.warn('OpenAIProvider: Failed to parse /responses stream event JSON:', data, error);
|
|
973
|
+
continue;
|
|
974
|
+
}
|
|
975
|
+
switch (event.type) {
|
|
976
|
+
case 'response.created':
|
|
977
|
+
case 'response.in_progress':
|
|
978
|
+
noteResponse(event.response);
|
|
979
|
+
break;
|
|
980
|
+
case 'response.output_text.delta':
|
|
981
|
+
if (typeof event.delta === 'string' && event.delta) {
|
|
982
|
+
emittedText += event.delta;
|
|
983
|
+
yield textChunk(event.delta);
|
|
984
|
+
}
|
|
985
|
+
break;
|
|
986
|
+
case 'response.output_item.added':
|
|
987
|
+
case 'response.output_item.done': {
|
|
988
|
+
const item = event.item;
|
|
989
|
+
if (item?.type !== 'function_call')
|
|
990
|
+
break;
|
|
991
|
+
const call = trackCall(event.output_index, item.id, item.call_id);
|
|
992
|
+
const delta = reconcileStreamedCall(call, {
|
|
993
|
+
id: item.call_id,
|
|
994
|
+
name: item.name,
|
|
995
|
+
// An added item's arguments are normally '' and carry nothing yet.
|
|
996
|
+
arguments: event.type === 'response.output_item.done' ? item.arguments : item.arguments || undefined,
|
|
997
|
+
});
|
|
998
|
+
if (delta)
|
|
999
|
+
yield chunk({ toolCallsDeltas: [delta] });
|
|
1000
|
+
break;
|
|
1001
|
+
}
|
|
1002
|
+
case 'response.function_call_arguments.delta': {
|
|
1003
|
+
if (typeof event.delta !== 'string' || !event.delta)
|
|
1004
|
+
break;
|
|
1005
|
+
const call = trackCall(event.output_index, event.item_id);
|
|
1006
|
+
if (call.finalArguments !== undefined)
|
|
1007
|
+
break;
|
|
1008
|
+
call.arguments += event.delta;
|
|
1009
|
+
yield chunk({ toolCallsDeltas: [{ index: call.index, function: { arguments_delta: event.delta } }] });
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
case 'response.function_call_arguments.done': {
|
|
1013
|
+
if (typeof event.arguments !== 'string')
|
|
1014
|
+
break;
|
|
1015
|
+
const delta = reconcileStreamedCall(trackCall(event.output_index, event.item_id), {
|
|
1016
|
+
arguments: event.arguments,
|
|
1017
|
+
});
|
|
1018
|
+
if (delta)
|
|
1019
|
+
yield chunk({ toolCallsDeltas: [delta] });
|
|
1020
|
+
break;
|
|
1021
|
+
}
|
|
1022
|
+
case 'response.completed':
|
|
1023
|
+
case 'response.incomplete': {
|
|
1024
|
+
const response = event.response;
|
|
1025
|
+
noteResponse(response);
|
|
1026
|
+
const output = response?.output ?? [];
|
|
1027
|
+
const summary = summarizeResponsesOutput(output);
|
|
1028
|
+
// The terminal output is authoritative: send whatever the deltas left out.
|
|
1029
|
+
const corrections = [];
|
|
1030
|
+
output.forEach((item, outputIndex) => {
|
|
1031
|
+
if (item.type !== 'function_call')
|
|
1032
|
+
return;
|
|
1033
|
+
const delta = reconcileStreamedCall(trackCall(outputIndex, item.id, item.call_id), {
|
|
1034
|
+
id: item.call_id,
|
|
1035
|
+
name: item.name,
|
|
1036
|
+
arguments: item.arguments,
|
|
1037
|
+
});
|
|
1038
|
+
if (delta)
|
|
1039
|
+
corrections.push(delta);
|
|
1040
|
+
});
|
|
1041
|
+
let missingText = '';
|
|
1042
|
+
if (summary.text && summary.text !== emittedText) {
|
|
1043
|
+
if (summary.text.startsWith(emittedText)) {
|
|
1044
|
+
missingText = summary.text.slice(emittedText.length);
|
|
1045
|
+
}
|
|
1046
|
+
else {
|
|
1047
|
+
console.warn('OpenAIProvider: streamed /responses text does not match the final output; keeping the streamed text.');
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
if (missingText || corrections.length > 0) {
|
|
1051
|
+
emittedText += missingText;
|
|
1052
|
+
const correction = missingText ? textChunk(missingText) : chunk({});
|
|
1053
|
+
if (corrections.length > 0)
|
|
1054
|
+
correction.toolCallsDeltas = corrections;
|
|
1055
|
+
yield correction;
|
|
1056
|
+
}
|
|
1057
|
+
const usage = response?.usage
|
|
1058
|
+
? this.mapResponsesUsage(response.usage, response.model ?? modelId)
|
|
1059
|
+
: undefined;
|
|
1060
|
+
const status = response?.status ?? (event.type === 'response.incomplete' ? 'incomplete' : 'completed');
|
|
1061
|
+
if (summary.itemCount === 0 && emittedText.length === 0 && calls.length === 0) {
|
|
1062
|
+
// Nothing streamed and nothing in the output: the malformed body
|
|
1063
|
+
// the non-streamed mapping throws INVALID_RESPONSE for.
|
|
1064
|
+
yield chunk({
|
|
1065
|
+
choices: [],
|
|
1066
|
+
error: {
|
|
1067
|
+
message: `OpenAI /responses returned no usable output (status: ${status})`,
|
|
1068
|
+
type: 'invalid_response',
|
|
1069
|
+
},
|
|
1070
|
+
...(usage ? { usage } : {}),
|
|
1071
|
+
isFinal: true,
|
|
1072
|
+
});
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const toolCalls = calls
|
|
1076
|
+
.filter((c) => c.id && c.name)
|
|
1077
|
+
.map((c) => ({
|
|
1078
|
+
id: c.id,
|
|
1079
|
+
type: 'function',
|
|
1080
|
+
function: { name: c.name, arguments: c.finalArguments ?? c.arguments },
|
|
1081
|
+
}));
|
|
1082
|
+
const finishReason = responsesFinishReason(status, response?.incomplete_details?.reason, toolCalls.length > 0);
|
|
1083
|
+
if (emittedText.length === 0 && toolCalls.length === 0) {
|
|
1084
|
+
this.warnReasoningOnlyOutput(response ?? { id: responseId, status }, modelId, finishReason);
|
|
1085
|
+
}
|
|
1086
|
+
yield chunk({
|
|
1087
|
+
...(typeof response?.service_tier === 'string' ? { serviceTier: response.service_tier } : {}),
|
|
1088
|
+
choices: [{
|
|
1089
|
+
index: 0,
|
|
1090
|
+
message: {
|
|
1091
|
+
role: 'assistant',
|
|
1092
|
+
content: null,
|
|
1093
|
+
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
1094
|
+
},
|
|
1095
|
+
finishReason,
|
|
1096
|
+
logprobs: null,
|
|
1097
|
+
}],
|
|
1098
|
+
usage,
|
|
1099
|
+
isFinal: true,
|
|
1100
|
+
});
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
case 'response.failed': {
|
|
1104
|
+
const response = event.response;
|
|
1105
|
+
noteResponse(response);
|
|
1106
|
+
const message = response?.error?.message ?? 'OpenAI /responses reported the response as failed';
|
|
1107
|
+
const usage = response?.usage ? this.mapResponsesUsage(response.usage, response.model ?? modelId) : undefined;
|
|
1108
|
+
if (!deliveredOutput()) {
|
|
1109
|
+
throw new OpenAIProviderError(message, 'RESPONSES_FAILED', response?.error?.code ?? undefined, 'response_failed', responsesErrorStatus(response?.error?.code), { error: response?.error, ...(usage ? { usage } : {}) });
|
|
1110
|
+
}
|
|
1111
|
+
yield chunk({
|
|
1112
|
+
choices: [],
|
|
1113
|
+
error: {
|
|
1114
|
+
message,
|
|
1115
|
+
type: 'response_failed',
|
|
1116
|
+
...(response?.error?.code ? { code: response.error.code } : {}),
|
|
1117
|
+
...(response?.error ? { details: response.error } : {}),
|
|
1118
|
+
},
|
|
1119
|
+
...(usage ? { usage } : {}),
|
|
1120
|
+
isFinal: true,
|
|
1121
|
+
});
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
case 'error': {
|
|
1125
|
+
const message = event.message ?? 'OpenAI /responses stream error';
|
|
1126
|
+
if (!deliveredOutput()) {
|
|
1127
|
+
throw new OpenAIProviderError(message, 'RESPONSES_STREAM_ERROR', event.code ?? undefined, 'stream_error', responsesErrorStatus(event.code));
|
|
1128
|
+
}
|
|
1129
|
+
yield chunk({
|
|
1130
|
+
choices: [],
|
|
1131
|
+
error: {
|
|
1132
|
+
message,
|
|
1133
|
+
type: 'stream_error',
|
|
1134
|
+
...(event.code ? { code: event.code } : {}),
|
|
1135
|
+
},
|
|
1136
|
+
isFinal: true,
|
|
1137
|
+
});
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
default:
|
|
1141
|
+
// Reasoning summaries, content parts, output_text.done, refusals.
|
|
1142
|
+
break;
|
|
666
1143
|
}
|
|
667
1144
|
}
|
|
668
|
-
abortSignal?.
|
|
1145
|
+
if (abortSignal?.aborted) {
|
|
1146
|
+
yield this.abortedStreamChunk(modelId, 'Stream aborted by caller');
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
const endMessage = reading.failure
|
|
1150
|
+
? `OpenAI /responses stream failed before response.completed: ${reading.failure.message}`
|
|
1151
|
+
: 'OpenAI /responses stream ended before response.completed';
|
|
1152
|
+
if (!deliveredOutput()) {
|
|
1153
|
+
// A connection cut before anything arrived, which a retry or another
|
|
1154
|
+
// provider can serve.
|
|
1155
|
+
throw new OpenAIProviderError(endMessage, 'NETWORK_ERROR', undefined, 'stream_truncated', undefined, reading.failure);
|
|
1156
|
+
}
|
|
1157
|
+
yield chunk({
|
|
1158
|
+
choices: [],
|
|
1159
|
+
error: {
|
|
1160
|
+
message: endMessage,
|
|
1161
|
+
type: 'stream_truncated',
|
|
1162
|
+
},
|
|
1163
|
+
isFinal: true,
|
|
1164
|
+
});
|
|
669
1165
|
}
|
|
670
1166
|
/** @inheritdoc */
|
|
671
1167
|
async generateEmbeddings(modelId, texts, options) {
|
|
@@ -787,15 +1283,16 @@ export class OpenAIProvider {
|
|
|
787
1283
|
payload.top_p = options.topP;
|
|
788
1284
|
}
|
|
789
1285
|
else {
|
|
790
|
-
// Reasoning models (o-series, GPT-5) take `reasoning_effort`
|
|
1286
|
+
// Reasoning models (o-series, GPT-5, GPT-6) take `reasoning_effort`
|
|
791
1287
|
// (none|low|medium|high|xhigh) in place of the sampling params they
|
|
792
|
-
// reject. Map the agentos effort scale onto it so `effort: 'max'`
|
|
793
|
-
//
|
|
794
|
-
//
|
|
795
|
-
// GPT-5
|
|
796
|
-
//
|
|
797
|
-
//
|
|
798
|
-
// the model reasons at its default
|
|
1288
|
+
// reject. Map the agentos effort scale onto it so `effort: 'max'` drives
|
|
1289
|
+
// them at their xhigh ceiling. customModelParams (below) can still
|
|
1290
|
+
// override.
|
|
1291
|
+
// GPT-5 and GPT-6 reject `reasoning_effort` with function tools on
|
|
1292
|
+
// chat/completions. shouldRouteToOpenAiResponsesApi sends those calls to
|
|
1293
|
+
// /v1/responses; one that still arrives here (its content cannot go to
|
|
1294
|
+
// Responses) drops the effort, so the model reasons at its default
|
|
1295
|
+
// depth and the tool call succeeds.
|
|
799
1296
|
const reasoningEffort = mapEffortToOpenAiReasoningEffortForModel(options.effort, modelId);
|
|
800
1297
|
const dropEffortForTools = requestHasFunctionTools(options.tools) &&
|
|
801
1298
|
openAiRejectsReasoningEffortWithTools(modelId);
|
|
@@ -909,28 +1406,54 @@ export class OpenAIProvider {
|
|
|
909
1406
|
payload.service_tier = options.serviceTier;
|
|
910
1407
|
}
|
|
911
1408
|
/**
|
|
912
|
-
* Build a `/v1/responses` request body for a
|
|
913
|
-
*
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
*
|
|
920
|
-
*
|
|
921
|
-
*
|
|
1409
|
+
* Build a `/v1/responses` request body for a call
|
|
1410
|
+
* {@link shouldRouteToOpenAiResponsesApi} sends there: a Responses-only
|
|
1411
|
+
* model, a GPT-6 tool call, or a GPT-5/GPT-6 tool call that carries an
|
|
1412
|
+
* effort.
|
|
1413
|
+
*
|
|
1414
|
+
* - System, assistant and text-only turns carry flattened text
|
|
1415
|
+
* ({@link flattenResponsesTextContent}), so cache-marked `systemBlocks`
|
|
1416
|
+
* arrive joined instead of empty. A user turn with images carries
|
|
1417
|
+
* `input_text` / `input_image` parts, and a tool result with images
|
|
1418
|
+
* becomes a `function_call_output` whose output is that part array.
|
|
1419
|
+
* - `tools`, `reasoning` and `text.format` appear only when the call has
|
|
1420
|
+
* function tools, an effort the model maps, or an OpenAI-shaped
|
|
1421
|
+
* `responseFormat`. `stream: true` marks a streamed call; Responses has
|
|
1422
|
+
* no `stream_options.include_usage` and reports usage on the terminal
|
|
1423
|
+
* event.
|
|
1424
|
+
* - A chat-style `reasoning_effort` in `customModelParams` moves to
|
|
1425
|
+
* `reasoning.effort`, because Responses rejects the top-level key.
|
|
1426
|
+
*
|
|
922
1427
|
* Live-probed 2026-07-08; block-content path live-probed 2026-09-12.
|
|
1428
|
+
*
|
|
1429
|
+
* @throws {OpenAIProviderError} `RESPONSES_UNMAPPABLE_CONTENT` when a
|
|
1430
|
+
* message carries a part Responses cannot represent (audio, a file, an
|
|
1431
|
+
* Anthropic `tool_result` block). Only a call with no Chat Completions
|
|
1432
|
+
* route reaches here with such content; the error is not retryable, like
|
|
1433
|
+
* the 400 or 404 Chat Completions would return for it.
|
|
923
1434
|
* @private
|
|
924
1435
|
*/
|
|
925
|
-
buildResponsesPayload(modelId, messages, options) {
|
|
1436
|
+
buildResponsesPayload(modelId, messages, options, stream = false) {
|
|
1437
|
+
const unmappable = messages.find((m) => !isResponsesMappableMessage(m));
|
|
1438
|
+
if (unmappable) {
|
|
1439
|
+
const partTypes = Array.isArray(unmappable.content)
|
|
1440
|
+
? Array.from(new Set(unmappable.content.map((part) => String(part?.type))))
|
|
1441
|
+
: [typeof unmappable.content];
|
|
1442
|
+
throw new OpenAIProviderError(`OpenAI serves ${modelId} for this call only on /v1/responses, which cannot carry a ` +
|
|
1443
|
+
`'${unmappable.role}' message with content parts [${partTypes.join(', ')}]`, 'RESPONSES_UNMAPPABLE_CONTENT');
|
|
1444
|
+
}
|
|
926
1445
|
const input = [];
|
|
927
1446
|
for (const m of messages) {
|
|
928
|
-
const text = flattenResponsesTextContent(m.content);
|
|
929
1447
|
if (m.role === 'tool') {
|
|
930
1448
|
// Tool result → function_call_output paired by call_id.
|
|
931
|
-
input.push({ type: 'function_call_output', call_id: m.tool_call_id, output:
|
|
1449
|
+
input.push({ type: 'function_call_output', call_id: m.tool_call_id, output: toResponsesInputContent(m.content) });
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
if (m.role === 'user') {
|
|
1453
|
+
input.push({ role: 'user', content: toResponsesInputContent(m.content) });
|
|
932
1454
|
continue;
|
|
933
1455
|
}
|
|
1456
|
+
const text = flattenResponsesTextContent(m.content);
|
|
934
1457
|
if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
|
|
935
1458
|
// Optional assistant text first, then one function_call item per call.
|
|
936
1459
|
if (text.length > 0)
|
|
@@ -945,19 +1468,25 @@ export class OpenAIProvider {
|
|
|
945
1468
|
}
|
|
946
1469
|
continue;
|
|
947
1470
|
}
|
|
948
|
-
// system /
|
|
949
|
-
//
|
|
1471
|
+
// system / plain assistant. Drop an empty assistant turn (no text, no
|
|
1472
|
+
// tool_calls carries nothing); keep an empty system turn defensively.
|
|
950
1473
|
if (m.role === 'assistant' && text.length === 0)
|
|
951
1474
|
continue;
|
|
952
1475
|
input.push({ role: m.role, content: text });
|
|
953
1476
|
}
|
|
954
|
-
const payload = {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1477
|
+
const payload = { model: modelId, input, store: false };
|
|
1478
|
+
// A Responses-only model can be called without tools, and a
|
|
1479
|
+
// `reasoning: {}` or `tools: undefined` key has no meaning on the wire.
|
|
1480
|
+
if (requestHasFunctionTools(options.tools))
|
|
1481
|
+
payload.tools = this.toResponsesTools(options.tools);
|
|
1482
|
+
const effort = mapEffortToOpenAiResponsesEffort(modelId, options.effort);
|
|
1483
|
+
if (effort !== undefined)
|
|
1484
|
+
payload.reasoning = { effort };
|
|
1485
|
+
const textFormat = toOpenAiResponsesTextFormat(options.responseFormat);
|
|
1486
|
+
if (textFormat)
|
|
1487
|
+
payload.text = { format: textFormat };
|
|
1488
|
+
if (stream)
|
|
1489
|
+
payload.stream = true;
|
|
961
1490
|
if (options.toolChoice !== undefined) {
|
|
962
1491
|
payload.tool_choice = this.toResponsesToolChoice(options.toolChoice);
|
|
963
1492
|
}
|
|
@@ -981,6 +1510,60 @@ export class OpenAIProvider {
|
|
|
981
1510
|
Object.assign(payload, passthrough);
|
|
982
1511
|
}
|
|
983
1512
|
}
|
|
1513
|
+
// customModelParams is shared with the chat path, whose effort key is the
|
|
1514
|
+
// top-level `reasoning_effort`. Responses rejects that key and takes the
|
|
1515
|
+
// value as `reasoning.effort`, so move it there.
|
|
1516
|
+
if ('reasoning_effort' in payload) {
|
|
1517
|
+
const passedEffort = payload.reasoning_effort;
|
|
1518
|
+
delete payload.reasoning_effort;
|
|
1519
|
+
if (passedEffort !== undefined && passedEffort !== null) {
|
|
1520
|
+
const reasoning = payload.reasoning && typeof payload.reasoning === 'object'
|
|
1521
|
+
? payload.reasoning
|
|
1522
|
+
: {};
|
|
1523
|
+
payload.reasoning = { ...reasoning, effort: passedEffort };
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
// The chat path's output limits (`max_completion_tokens`, `max_tokens`)
|
|
1527
|
+
// are `max_output_tokens` here; Responses rejects the chat names. An
|
|
1528
|
+
// explicit `max_output_tokens` in customModelParams wins.
|
|
1529
|
+
for (const chatLimit of ['max_completion_tokens', 'max_tokens']) {
|
|
1530
|
+
if (!(chatLimit in payload))
|
|
1531
|
+
continue;
|
|
1532
|
+
const limit = payload[chatLimit];
|
|
1533
|
+
delete payload[chatLimit];
|
|
1534
|
+
const passedOutputLimit = options.customModelParams?.max_output_tokens;
|
|
1535
|
+
if (typeof limit === 'number' && passedOutputLimit === undefined) {
|
|
1536
|
+
payload.max_output_tokens = limit;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
// Chat-only stream settings have no Responses counterpart.
|
|
1540
|
+
delete payload.stream_options;
|
|
1541
|
+
// Other chat-shaped overrides, reshaped because Responses rejects them as
|
|
1542
|
+
// written for /chat/completions: a named-function `tool_choice` (its name
|
|
1543
|
+
// sits under `function`), chat-style function `tools`, `response_format`
|
|
1544
|
+
// (Responses reads `text.format`) and a top-level `verbosity` (Responses
|
|
1545
|
+
// reads `text.verbosity`). Values already in the Responses shape pass
|
|
1546
|
+
// through unchanged.
|
|
1547
|
+
if (payload.tool_choice !== undefined)
|
|
1548
|
+
payload.tool_choice = this.toResponsesToolChoice(payload.tool_choice);
|
|
1549
|
+
if (payload.tools !== undefined)
|
|
1550
|
+
payload.tools = this.toResponsesTools(payload.tools);
|
|
1551
|
+
if ('response_format' in payload || 'verbosity' in payload) {
|
|
1552
|
+
const text = payload.text && typeof payload.text === 'object' ? { ...payload.text } : {};
|
|
1553
|
+
if ('response_format' in payload) {
|
|
1554
|
+
const format = toOpenAiResponsesTextFormat(payload.response_format);
|
|
1555
|
+
if (format)
|
|
1556
|
+
text.format = format;
|
|
1557
|
+
delete payload.response_format;
|
|
1558
|
+
}
|
|
1559
|
+
if ('verbosity' in payload) {
|
|
1560
|
+
if (payload.verbosity !== undefined && payload.verbosity !== null)
|
|
1561
|
+
text.verbosity = payload.verbosity;
|
|
1562
|
+
delete payload.verbosity;
|
|
1563
|
+
}
|
|
1564
|
+
if (Object.keys(text).length > 0)
|
|
1565
|
+
payload.text = text;
|
|
1566
|
+
}
|
|
984
1567
|
return payload;
|
|
985
1568
|
}
|
|
986
1569
|
/** Reshape chat-completions function tools → the Responses flat tool shape. */
|
|
@@ -994,8 +1577,13 @@ export class OpenAIProvider {
|
|
|
994
1577
|
type: 'function',
|
|
995
1578
|
name: tool.function.name,
|
|
996
1579
|
description: tool.function.description,
|
|
997
|
-
parameters
|
|
998
|
-
|
|
1580
|
+
// Responses requires `parameters` on a function tool, so an
|
|
1581
|
+
// argument-less tool gets the empty object schema.
|
|
1582
|
+
parameters: tool.function.parameters ?? { type: 'object', properties: {} },
|
|
1583
|
+
// A caller's explicit `strict` is kept. Otherwise false: strict mode
|
|
1584
|
+
// needs a schema most tools do not have (every property required,
|
|
1585
|
+
// no additional properties).
|
|
1586
|
+
strict: tool.function.strict ?? false,
|
|
999
1587
|
};
|
|
1000
1588
|
}
|
|
1001
1589
|
return t; // already-flat or non-function tool: pass through
|
|
@@ -1009,6 +1597,28 @@ export class OpenAIProvider {
|
|
|
1009
1597
|
if (tc?.type === 'function' && tc.function?.name) {
|
|
1010
1598
|
return { type: 'function', name: tc.function.name };
|
|
1011
1599
|
}
|
|
1600
|
+
// Chat nests an allowed-tools choice under `allowed_tools`, with
|
|
1601
|
+
// `{type:'function', function:{name}}` entries; Responses takes `mode` and
|
|
1602
|
+
// `tools` at the top level, with flat function names.
|
|
1603
|
+
if (tc?.type === 'allowed_tools' && tc.allowed_tools && typeof tc.allowed_tools === 'object') {
|
|
1604
|
+
const { mode, tools } = tc.allowed_tools;
|
|
1605
|
+
return {
|
|
1606
|
+
type: 'allowed_tools',
|
|
1607
|
+
...(mode !== undefined ? { mode } : {}),
|
|
1608
|
+
...(tools !== undefined
|
|
1609
|
+
? {
|
|
1610
|
+
tools: Array.isArray(tools)
|
|
1611
|
+
? tools.map((entry) => {
|
|
1612
|
+
const tool = entry;
|
|
1613
|
+
return tool?.type === 'function' && tool.function?.name
|
|
1614
|
+
? { type: 'function', name: tool.function.name }
|
|
1615
|
+
: entry;
|
|
1616
|
+
})
|
|
1617
|
+
: tools,
|
|
1618
|
+
}
|
|
1619
|
+
: {}),
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1012
1622
|
return toolChoice;
|
|
1013
1623
|
}
|
|
1014
1624
|
/**
|
|
@@ -1027,34 +1637,9 @@ export class OpenAIProvider {
|
|
|
1027
1637
|
* @private
|
|
1028
1638
|
*/
|
|
1029
1639
|
mapResponsesToCompletionResponse(apiResponse, modelId) {
|
|
1030
|
-
const
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
for (const item of output) {
|
|
1034
|
-
if (item.type === 'message') {
|
|
1035
|
-
for (const part of item.content ?? []) {
|
|
1036
|
-
if (part.type === 'output_text' && typeof part.text === 'string')
|
|
1037
|
-
assistantText += part.text;
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
else if (item.type === 'function_call') {
|
|
1041
|
-
toolCalls.push({
|
|
1042
|
-
id: item.call_id ?? '',
|
|
1043
|
-
type: 'function',
|
|
1044
|
-
function: { name: item.name ?? '', arguments: item.arguments ?? '' },
|
|
1045
|
-
});
|
|
1046
|
-
}
|
|
1047
|
-
// reasoning + any other item type: ignored.
|
|
1048
|
-
}
|
|
1049
|
-
const incompleteReason = apiResponse.incomplete_details?.reason;
|
|
1050
|
-
const finishReason = toolCalls.length > 0
|
|
1051
|
-
? 'tool_calls'
|
|
1052
|
-
: apiResponse.status === 'incomplete'
|
|
1053
|
-
? (incompleteReason === 'max_output_tokens' ? 'length'
|
|
1054
|
-
: incompleteReason === 'content_filter' ? 'content_filter'
|
|
1055
|
-
: 'stop')
|
|
1056
|
-
: 'stop';
|
|
1057
|
-
if (output.length === 0) {
|
|
1640
|
+
const { text: assistantText, toolCalls, itemCount } = summarizeResponsesOutput(apiResponse.output);
|
|
1641
|
+
const finishReason = responsesFinishReason(apiResponse.status, apiResponse.incomplete_details?.reason, toolCalls.length > 0);
|
|
1642
|
+
if (itemCount === 0) {
|
|
1058
1643
|
// GENUINELY empty 2xx — no output items at all. This is the malformed
|
|
1059
1644
|
// body the guard was written for: throw so generateText's fallback
|
|
1060
1645
|
// advances instead of treating it as a successful empty turn.
|
|
@@ -1069,17 +1654,7 @@ export class OpenAIProvider {
|
|
|
1069
1654
|
throw new OpenAIProviderError(`OpenAI /responses returned no usable output (status: ${apiResponse.status ?? 'unknown'})`, 'INVALID_RESPONSE', undefined, undefined, undefined, apiResponse);
|
|
1070
1655
|
}
|
|
1071
1656
|
if (assistantText.length === 0 && toolCalls.length === 0) {
|
|
1072
|
-
|
|
1073
|
-
// Surfaced as an empty turn carrying the real finishReason ('length'
|
|
1074
|
-
// when max_output_tokens capped it), so the caller can retry with a
|
|
1075
|
-
// larger budget. Reasoning tokens are drawn from the SAME output budget
|
|
1076
|
-
// as the visible answer, so a reasoning model needs a materially larger
|
|
1077
|
-
// maxTokens than a non-reasoning one to leave room for a reply.
|
|
1078
|
-
console.warn(`OpenAIProvider: /responses returned reasoning-only output for ${apiResponse.model ?? modelId} ` +
|
|
1079
|
-
`(status: ${apiResponse.status ?? 'unknown'}${incompleteReason ? `, reason: ${incompleteReason}` : ''}` +
|
|
1080
|
-
`${apiResponse.usage?.output_tokens !== undefined ? `, output_tokens: ${apiResponse.usage.output_tokens}` : ''}` +
|
|
1081
|
-
`) — returning an empty turn with finishReason '${finishReason}'. ` +
|
|
1082
|
-
`Raise max output tokens: reasoning tokens consume the same budget as the reply.`);
|
|
1657
|
+
this.warnReasoningOnlyOutput(apiResponse, modelId, finishReason);
|
|
1083
1658
|
}
|
|
1084
1659
|
return {
|
|
1085
1660
|
id: apiResponse.id,
|
|
@@ -1098,22 +1673,50 @@ export class OpenAIProvider {
|
|
|
1098
1673
|
logprobs: null,
|
|
1099
1674
|
}],
|
|
1100
1675
|
usage: apiResponse.usage
|
|
1101
|
-
? this.
|
|
1102
|
-
prompt_tokens: apiResponse.usage.input_tokens,
|
|
1103
|
-
completion_tokens: apiResponse.usage.output_tokens,
|
|
1104
|
-
total_tokens: apiResponse.usage.total_tokens,
|
|
1105
|
-
// Responses API spells the cached detail input_tokens_details;
|
|
1106
|
-
// re-key to the chat spelling the shared mapper reads.
|
|
1107
|
-
...(apiResponse.usage.input_tokens_details
|
|
1108
|
-
? { prompt_tokens_details: {
|
|
1109
|
-
cached_tokens: apiResponse.usage.input_tokens_details.cached_tokens,
|
|
1110
|
-
cache_write_tokens: apiResponse.usage.input_tokens_details.cache_write_tokens,
|
|
1111
|
-
} }
|
|
1112
|
-
: {}),
|
|
1113
|
-
}, apiResponse.model ?? modelId)
|
|
1676
|
+
? this.mapResponsesUsage(apiResponse.usage, apiResponse.model ?? modelId)
|
|
1114
1677
|
: undefined,
|
|
1115
1678
|
};
|
|
1116
1679
|
}
|
|
1680
|
+
/**
|
|
1681
|
+
* Logs a `/v1/responses` turn that holds output items but no message and
|
|
1682
|
+
* no function call: reasoning-only output. It is returned as an empty turn
|
|
1683
|
+
* carrying the real finish reason (`length` when max_output_tokens capped
|
|
1684
|
+
* it), so the caller can retry with a larger budget. Reasoning tokens come
|
|
1685
|
+
* out of the same output budget as the visible answer, so a reasoning model
|
|
1686
|
+
* needs a materially larger maxTokens than a non-reasoning one to leave
|
|
1687
|
+
* room for a reply.
|
|
1688
|
+
* @private
|
|
1689
|
+
*/
|
|
1690
|
+
warnReasoningOnlyOutput(apiResponse, modelId, finishReason) {
|
|
1691
|
+
const incompleteReason = apiResponse.incomplete_details?.reason;
|
|
1692
|
+
console.warn(`OpenAIProvider: /responses returned reasoning-only output for ${apiResponse.model ?? modelId} ` +
|
|
1693
|
+
`(status: ${apiResponse.status ?? 'unknown'}${incompleteReason ? `, reason: ${incompleteReason}` : ''}` +
|
|
1694
|
+
`${apiResponse.usage?.output_tokens !== undefined ? `, output_tokens: ${apiResponse.usage.output_tokens}` : ''}` +
|
|
1695
|
+
`); returning an empty turn with finishReason '${finishReason}'. ` +
|
|
1696
|
+
`Raise max output tokens: reasoning tokens consume the same budget as the reply.`);
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Token usage and cost for a `/v1/responses` result. The Responses API
|
|
1700
|
+
* spells the counts `input_tokens` / `output_tokens` and the cached detail
|
|
1701
|
+
* `input_tokens_details`; they are re-keyed to the chat spelling so both
|
|
1702
|
+
* endpoints meter through {@link calculateUsage}.
|
|
1703
|
+
* @private
|
|
1704
|
+
*/
|
|
1705
|
+
mapResponsesUsage(usage, modelId) {
|
|
1706
|
+
return this.calculateUsage({
|
|
1707
|
+
prompt_tokens: usage.input_tokens,
|
|
1708
|
+
completion_tokens: usage.output_tokens,
|
|
1709
|
+
total_tokens: usage.total_tokens,
|
|
1710
|
+
...(usage.input_tokens_details
|
|
1711
|
+
? {
|
|
1712
|
+
prompt_tokens_details: {
|
|
1713
|
+
cached_tokens: usage.input_tokens_details.cached_tokens,
|
|
1714
|
+
cache_write_tokens: usage.input_tokens_details.cache_write_tokens,
|
|
1715
|
+
},
|
|
1716
|
+
}
|
|
1717
|
+
: {}),
|
|
1718
|
+
}, modelId);
|
|
1719
|
+
}
|
|
1117
1720
|
/**
|
|
1118
1721
|
* Maps the raw OpenAI API Chat Completion response to the standard ModelCompletionResponse.
|
|
1119
1722
|
* @private
|
|
@@ -1353,7 +1956,7 @@ export class OpenAIProvider {
|
|
|
1353
1956
|
const outputRate = isLong ? pricing.output * OPENAI_LONG_CONTEXT_OUTPUT_MULTIPLIER : pricing.output;
|
|
1354
1957
|
return (promptTokens / 1000) * inputRate + (completionTokens / 1000) * outputRate;
|
|
1355
1958
|
}
|
|
1356
|
-
async makeApiRequest(endpoint, method, apiKey, body, expectStream = false, requestTimeoutOverride) {
|
|
1959
|
+
async makeApiRequest(endpoint, method, apiKey, body, expectStream = false, requestTimeoutOverride, abortSignal) {
|
|
1357
1960
|
const url = `${this.config.baseURL}${endpoint}`;
|
|
1358
1961
|
const headers = {
|
|
1359
1962
|
'Authorization': `Bearer ${apiKey}`,
|
|
@@ -1368,6 +1971,9 @@ export class OpenAIProvider {
|
|
|
1368
1971
|
}
|
|
1369
1972
|
let lastError = new OpenAIProviderError('Request failed after all retries.', 'MAX_RETRIES_REACHED');
|
|
1370
1973
|
for (let attempt = 0; attempt < this.config.maxRetries; attempt++) {
|
|
1974
|
+
if (abortSignal?.aborted) {
|
|
1975
|
+
throw new OpenAIProviderError('Request aborted by caller.', 'REQUEST_ABORTED');
|
|
1976
|
+
}
|
|
1371
1977
|
const controller = new AbortController();
|
|
1372
1978
|
// CR8: honor a per-call requestTimeout override (e.g. long codegen) over
|
|
1373
1979
|
// the provider's default; only Anthropic read this before.
|
|
@@ -1375,6 +1981,11 @@ export class OpenAIProvider {
|
|
|
1375
1981
|
? requestTimeoutOverride
|
|
1376
1982
|
: this.config.requestTimeout;
|
|
1377
1983
|
const timeoutId = setTimeout(() => controller.abort(), effectiveTimeout);
|
|
1984
|
+
// The caller's signal aborts this attempt's fetch as the timer does. The
|
|
1985
|
+
// listener is removed when the attempt settles; a returned stream body
|
|
1986
|
+
// is cancelled by parseSseStream instead.
|
|
1987
|
+
const abortAttempt = () => controller.abort();
|
|
1988
|
+
abortSignal?.addEventListener('abort', abortAttempt, { once: true });
|
|
1378
1989
|
try {
|
|
1379
1990
|
const requestOptions = {
|
|
1380
1991
|
method,
|
|
@@ -1420,6 +2031,10 @@ export class OpenAIProvider {
|
|
|
1420
2031
|
}
|
|
1421
2032
|
catch (error) {
|
|
1422
2033
|
clearTimeout(timeoutId);
|
|
2034
|
+
// A caller abort ends the request here; it is not a timeout to retry.
|
|
2035
|
+
if (abortSignal?.aborted) {
|
|
2036
|
+
throw new OpenAIProviderError('Request aborted by caller.', 'REQUEST_ABORTED');
|
|
2037
|
+
}
|
|
1423
2038
|
if (error instanceof OpenAIProviderError) { // If already our custom error, re-throw if not retryable
|
|
1424
2039
|
if (error.code === 'API_CLIENT_ERROR')
|
|
1425
2040
|
throw error;
|
|
@@ -1445,6 +2060,9 @@ export class OpenAIProvider {
|
|
|
1445
2060
|
console.warn(`[LLM] Retry ${attempt + 1}/${this.config.maxRetries - 1} in ${(delay / 1000).toFixed(1)}s — ${shortMsg}`);
|
|
1446
2061
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1447
2062
|
}
|
|
2063
|
+
finally {
|
|
2064
|
+
abortSignal?.removeEventListener('abort', abortAttempt);
|
|
2065
|
+
}
|
|
1448
2066
|
}
|
|
1449
2067
|
// For network errors, replace the cryptic cause chain with a clean message
|
|
1450
2068
|
if (lastError instanceof OpenAIProviderError && lastError.code === 'NETWORK_ERROR') {
|
|
@@ -1453,17 +2071,44 @@ export class OpenAIProvider {
|
|
|
1453
2071
|
throw lastError; // Throw the last encountered error after all retries
|
|
1454
2072
|
}
|
|
1455
2073
|
/**
|
|
1456
|
-
*
|
|
2074
|
+
* {@link parseSseStream} for the /v1/responses mapper: a read that fails
|
|
2075
|
+
* mid-body (parseSseStream's `STREAM_PARSING_ERROR`) ends the stream and is
|
|
2076
|
+
* recorded on `reading.failure` instead of propagating, so the mapper
|
|
2077
|
+
* handles it like a body cut off before its terminal event.
|
|
2078
|
+
* @private
|
|
2079
|
+
*/
|
|
2080
|
+
async *readResponsesSse(stream, abortSignal, reading) {
|
|
2081
|
+
try {
|
|
2082
|
+
yield* this.parseSseStream(stream, abortSignal);
|
|
2083
|
+
}
|
|
2084
|
+
catch (error) {
|
|
2085
|
+
if (error instanceof OpenAIProviderError && error.code === 'STREAM_PARSING_ERROR') {
|
|
2086
|
+
reading.failure = error;
|
|
2087
|
+
return;
|
|
2088
|
+
}
|
|
2089
|
+
throw error;
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* Parses an SSE (Server-Sent Events) stream and yields each `data:` payload.
|
|
2094
|
+
*
|
|
2095
|
+
* When `abortSignal` fires, the reader is cancelled at once, which settles
|
|
2096
|
+
* a read that is waiting on the network, and the generator ends without
|
|
2097
|
+
* yielding more; the caller then emits its abort chunk.
|
|
1457
2098
|
* @private
|
|
1458
2099
|
*/
|
|
1459
|
-
async *parseSseStream(stream) {
|
|
2100
|
+
async *parseSseStream(stream, abortSignal) {
|
|
1460
2101
|
const reader = stream.getReader();
|
|
1461
2102
|
const decoder = new TextDecoder();
|
|
1462
2103
|
let buffer = '';
|
|
2104
|
+
const cancelOnAbort = () => {
|
|
2105
|
+
reader.cancel().catch(() => undefined);
|
|
2106
|
+
};
|
|
2107
|
+
abortSignal?.addEventListener('abort', cancelOnAbort, { once: true });
|
|
1463
2108
|
try {
|
|
1464
|
-
while (
|
|
2109
|
+
while (!abortSignal?.aborted) {
|
|
1465
2110
|
const { done, value } = await reader.read();
|
|
1466
|
-
if (done)
|
|
2111
|
+
if (done || abortSignal?.aborted)
|
|
1467
2112
|
break;
|
|
1468
2113
|
buffer += decoder.decode(value, { stream: true });
|
|
1469
2114
|
let eolIndex;
|
|
@@ -1483,25 +2128,42 @@ export class OpenAIProvider {
|
|
|
1483
2128
|
}
|
|
1484
2129
|
}
|
|
1485
2130
|
}
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
2131
|
+
if (abortSignal?.aborted)
|
|
2132
|
+
return;
|
|
2133
|
+
// A body that ends without the closing blank line leaves its last block
|
|
2134
|
+
// in the buffer. Treat it as one block, so a terminal `event:` +
|
|
2135
|
+
// `data:` pair (the /v1/responses framing) is read like any other.
|
|
2136
|
+
buffer += decoder.decode();
|
|
2137
|
+
const trailing = buffer.trim();
|
|
2138
|
+
if (trailing) {
|
|
2139
|
+
let sawData = false;
|
|
2140
|
+
for (const line of buffer.split('\n')) {
|
|
2141
|
+
if (!line.startsWith('data: '))
|
|
2142
|
+
continue;
|
|
2143
|
+
sawData = true;
|
|
2144
|
+
const dataContent = line.substring('data: '.length).trim();
|
|
2145
|
+
if (dataContent)
|
|
2146
|
+
yield dataContent;
|
|
2147
|
+
}
|
|
2148
|
+
// Sometimes the final [DONE] is not prefixed by "data: ".
|
|
2149
|
+
if (!sawData) {
|
|
2150
|
+
if (trailing === '[DONE]')
|
|
2151
|
+
yield '[DONE]';
|
|
2152
|
+
else
|
|
2153
|
+
console.warn('OpenAIProvider: Trailing stream data not processed:', buffer);
|
|
2154
|
+
}
|
|
1497
2155
|
}
|
|
1498
2156
|
}
|
|
1499
2157
|
catch (error) {
|
|
2158
|
+
// A read that fails because the caller aborted is the abort, not a stream error.
|
|
2159
|
+
if (abortSignal?.aborted)
|
|
2160
|
+
return;
|
|
1500
2161
|
const message = error instanceof Error ? error.message : 'Stream parsing error';
|
|
1501
2162
|
console.error('OpenAIProvider: Error reading or parsing SSE stream:', message, error);
|
|
1502
2163
|
throw new OpenAIProviderError(message, 'STREAM_PARSING_ERROR', undefined, undefined, undefined, error);
|
|
1503
2164
|
}
|
|
1504
2165
|
finally {
|
|
2166
|
+
abortSignal?.removeEventListener('abort', cancelOnAbort);
|
|
1505
2167
|
// Ensure stream is closed if `break` or `return` is called within the generator
|
|
1506
2168
|
if (!reader.closed) {
|
|
1507
2169
|
await reader.cancel().catch(err => console.error("OpenAIProvider: Error cancelling stream reader:", err));
|