@framers/agentos 0.10.24 → 0.10.25

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.
@@ -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. OpenAIProvider sends a request to /v1/responses
119
- * only when it is a non-streamed GPT-5 or GPT-6 call carrying function tools
120
- * and an effort, and to /v1/chat/completions otherwise, where these ids fail.
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 /-pro(-\d{4}-\d{2}-\d{2})?$|codex|^gpt-5\.6-cyber|deep-research/i.test(modelId);
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. This provider reaches them only on its narrow Responses
134
- * path, and the refresh leaves the Responses-only o-series ids out.
135
- * - GPT-6 omits `tool_use`. OpenAI requires the Responses API for GPT-6
136
- * Astra tool calls and `reasoning_effort: "none"` for Sol and Luna tool
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 ALONGSIDE function tools on
187
- * the `/v1/chat/completions` endpoint for `modelId`. The GPT-5 family enforces
188
- * this — "Function tools with reasoning_effort are not supported for gpt-5.5
189
- * in /v1/chat/completions. Please use /v1/responses instead." (live-observed
190
- * 2026-07-08 when the codegen frontier fallback chain routed a tool-carrying,
191
- * effort:xhigh orchestrator turn to gpt-5.5, hard-erroring the whole build).
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
- * Such a call is routed to `/v1/responses` when it qualifies (see
194
- * {@link shouldRouteToOpenAiResponsesApi}, which preserves BOTH effort and
195
- * tools); otherwise it stays on chat/completions and must DROP
196
- * `reasoning_effort` (the model then reasons at its default depth) rather than
197
- * 400 the entire request. Scoped to the GPT-5 and GPT-6 families: the o-series
198
- * (o1/o3/o4) still accepts `reasoning_effort` + tools on chat/completions, so
199
- * dropping it there would be a needless reasoning-depth regression. Widen this
200
- * predicate if a future o-series enforces the same Responses-API requirement.
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
- * GPT-6 enforces the SAME restriction — live-probed 2026-09-10
203
- * (chat.completions, `gpt-6-astra`, `reasoning_effort: 'xhigh'` + one function
204
- * tool): HTTP 400 `invalid_request_error` — "Function tools with
205
- * reasoning_effort are not supported for gpt-6-astra in /v1/chat/completions.
206
- * To use function tools, use /v1/responses or set reasoning_effort to 'none'."
207
- * The suggested `'none'` escape hatch does NOT exist on this family: the same
208
- * probe with `reasoning_effort: 'none'` returns HTTP 400 `unsupported_value`
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 a NON-streaming call must use `/v1/responses` instead of
280
- * `/v1/chat/completions`: a gpt-5/gpt-6 reasoning model carrying function tools
281
- * AND a requested effort — the ONLY combination chat/completions 400s on
282
- * ("Function tools with reasoning_effort are not supported … Please use
283
- * /v1/responses instead") and the only case where the Responses path buys
284
- * anything (preserved reasoning depth). Excludes, per the 2026-07-08 Codex
285
- * review, requests that carry a `responseFormat` (the Responses path maps no
286
- * structured output — those stay on chat/completions where R4 drops effort) or
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
- * Content gating is delegated to {@link isResponsesMappableContent} rather
292
- * than tested inline. It previously required EVERY message's content to be a
293
- * plain string, which silently excluded Anthropic-style cache-marked system
294
- * blocks (`systemBlocks` / `SystemContentBlock[]`, which reach the provider as
295
- * an all-text content array). Callers that set `systemBlocks` to fix a cold
296
- * prompt cache therefore lost Responses routing and hard-400ed on
297
- * chat/completions — the exact failure this router exists to prevent. All-text
298
- * arrays now route and are flattened losslessly; true multimodal content is
299
- * still excluded, because the mapper still cannot represent it.
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
- options.responseFormat === undefined &&
306
- messages.every((m) => isResponsesMappableContent(m.content)));
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-5 rejects `reasoning_effort` + function tools on /chat/completions
600
- // and demands /v1/responses. Route ONLY that exact case there (preserving
601
- // BOTH reasoning depth and tools); everything else keeps the chat path.
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 abortHandler = () => {
634
- // Emit final abort chunk and ensure generator completes.
635
- // We cannot directly cancel the underlying ReadableStream cleanly cross-platform here; consumer side will stop.
636
- // Provide a terminal chunk for consistent teardown.
637
- // Note: We don't attempt to read further chunks after abort.
638
- };
639
- abortSignal?.addEventListener('abort', abortHandler, { once: true });
640
- for await (const chunk of this.parseSseStream(stream)) {
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
- id: `openai-abort-${Date.now()}`,
644
- object: 'chat.completion.chunk',
645
- created: Math.floor(Date.now() / 1000),
646
- modelId,
647
- choices: [],
648
- error: { message: 'Stream aborted by caller', type: 'abort' },
649
- isFinal: true,
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
- if (chunk === '[DONE]') {
654
- // The [DONE] message is a signal for the end of the stream from OpenAI.
655
- // A final response chunk with isFinal=true and potential usage should be yielded by the parser if data exists.
656
- // If OpenAI includes usage in the last data chunk before [DONE], parseSseStream handles it.
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
- const apiChunk = JSON.parse(chunk);
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 chunk JSON:', chunk, error);
665
- // Decide if to yield an error chunk or continue if minor parsing issue
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?.removeEventListener('abort', abortHandler);
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'` actually
793
- // drives gpt-5.x at its xhigh ceiling instead of being silently dropped.
794
- // customModelParams (below) can still override.
795
- // GPT-5 rejects `reasoning_effort` + function tools on chat/completions
796
- // (it demands the Responses API, which agentos doesn't implement). When
797
- // the request carries tools, DROP the effort param instead of 400ing —
798
- // the model reasons at its default depth and the tool call succeeds.
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 gpt-5/gpt-6 reasoning +
913
- * function-tool call (the only case
914
- * {@link shouldRouteToOpenAiResponsesApi} routes here).
915
- * Preserves BOTH `reasoning.effort` and `tools`, which chat/completions
916
- * rejects together. No structured output by construction (the router
917
- * excludes responseFormat). Message content arrives as either a plain
918
- * string or an all-text block array — {@link flattenResponsesTextContent}
919
- * joins the latter, so cache-marked `systemBlocks` survive intact instead of
920
- * collapsing to an empty prompt. True multimodal content never reaches here
921
- * (the router excludes it via {@link isResponsesMappableContent}).
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: text });
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 / user / plain assistant. Drop an empty assistant turn (no text,
949
- // no tool_calls carries nothing); keep empty system/user defensively.
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
- model: modelId,
956
- input,
957
- tools: this.toResponsesTools(options.tools),
958
- reasoning: { effort: mapEffortToOpenAiResponsesEffort(modelId, options.effort) },
959
- store: false,
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: tool.function.parameters,
998
- strict: false,
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 output = apiResponse.output ?? [];
1031
- let assistantText = '';
1032
- const toolCalls = [];
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
- // Items present but no message/function_call — reasoning-only output.
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.calculateUsage({
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
- * Parses an SSE (Server-Sent Events) stream.
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 (true) {
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
- // Process any remaining data in the buffer (e.g. if stream ends without \n\n)
1487
- if (buffer.startsWith('data: ')) {
1488
- const dataContent = buffer.substring('data: '.length).trim();
1489
- if (dataContent)
1490
- yield dataContent;
1491
- }
1492
- else if (buffer.trim()) { // Sometimes final [DONE] might not be prefixed by "data: "
1493
- if (buffer.trim() === "[DONE]")
1494
- yield "[DONE]";
1495
- else
1496
- console.warn("OpenAIProvider: Trailing stream data not processed:", buffer);
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));