@framers/agentos 0.10.23 → 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.
Files changed (155) hide show
  1. package/dist/api/AgentOS.d.ts.map +1 -1
  2. package/dist/api/AgentOS.js +3 -2
  3. package/dist/api/AgentOS.js.map +1 -1
  4. package/dist/api/agent.d.ts.map +1 -1
  5. package/dist/api/agent.js +25 -10
  6. package/dist/api/agent.js.map +1 -1
  7. package/dist/api/embedText.d.ts.map +1 -1
  8. package/dist/api/embedText.js +53 -17
  9. package/dist/api/embedText.js.map +1 -1
  10. package/dist/api/generateImage.d.ts.map +1 -1
  11. package/dist/api/generateImage.js +51 -7
  12. package/dist/api/generateImage.js.map +1 -1
  13. package/dist/api/generateText.d.ts +36 -6
  14. package/dist/api/generateText.d.ts.map +1 -1
  15. package/dist/api/generateText.js +102 -17
  16. package/dist/api/generateText.js.map +1 -1
  17. package/dist/api/model.d.ts.map +1 -1
  18. package/dist/api/model.js +11 -4
  19. package/dist/api/model.js.map +1 -1
  20. package/dist/api/runtime/SelfImprovementSessionManager.d.ts +34 -2
  21. package/dist/api/runtime/SelfImprovementSessionManager.d.ts.map +1 -1
  22. package/dist/api/runtime/SelfImprovementSessionManager.js +119 -33
  23. package/dist/api/runtime/SelfImprovementSessionManager.js.map +1 -1
  24. package/dist/api/runtime/provider-defaults.d.ts.map +1 -1
  25. package/dist/api/runtime/provider-defaults.js +5 -2
  26. package/dist/api/runtime/provider-defaults.js.map +1 -1
  27. package/dist/api/streamText.d.ts.map +1 -1
  28. package/dist/api/streamText.js +116 -34
  29. package/dist/api/streamText.js.map +1 -1
  30. package/dist/api/types/AgentOSInput.d.ts +11 -0
  31. package/dist/api/types/AgentOSInput.d.ts.map +1 -1
  32. package/dist/api/types.d.ts +14 -6
  33. package/dist/api/types.d.ts.map +1 -1
  34. package/dist/api/types.js.map +1 -1
  35. package/dist/cognition/emergent/AdaptPersonalityTool.d.ts +13 -4
  36. package/dist/cognition/emergent/AdaptPersonalityTool.d.ts.map +1 -1
  37. package/dist/cognition/emergent/AdaptPersonalityTool.js +32 -23
  38. package/dist/cognition/emergent/AdaptPersonalityTool.js.map +1 -1
  39. package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts +18 -6
  40. package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts.map +1 -1
  41. package/dist/cognition/emergent/EmergentCapabilityEngine.js.map +1 -1
  42. package/dist/cognition/emergent/SelfEvaluateTool.d.ts +10 -3
  43. package/dist/cognition/emergent/SelfEvaluateTool.d.ts.map +1 -1
  44. package/dist/cognition/emergent/SelfEvaluateTool.js +3 -2
  45. package/dist/cognition/emergent/SelfEvaluateTool.js.map +1 -1
  46. package/dist/cognition/substrate/CognitiveMemoryBridge.d.ts +51 -2
  47. package/dist/cognition/substrate/CognitiveMemoryBridge.d.ts.map +1 -1
  48. package/dist/cognition/substrate/CognitiveMemoryBridge.js +84 -2
  49. package/dist/cognition/substrate/CognitiveMemoryBridge.js.map +1 -1
  50. package/dist/cognition/substrate/ConversationHistoryManager.d.ts +8 -4
  51. package/dist/cognition/substrate/ConversationHistoryManager.d.ts.map +1 -1
  52. package/dist/cognition/substrate/ConversationHistoryManager.js +17 -5
  53. package/dist/cognition/substrate/ConversationHistoryManager.js.map +1 -1
  54. package/dist/cognition/substrate/GMI.d.ts +92 -5
  55. package/dist/cognition/substrate/GMI.d.ts.map +1 -1
  56. package/dist/cognition/substrate/GMI.js +291 -65
  57. package/dist/cognition/substrate/GMI.js.map +1 -1
  58. package/dist/cognition/substrate/GMIManager.d.ts +12 -0
  59. package/dist/cognition/substrate/GMIManager.d.ts.map +1 -1
  60. package/dist/cognition/substrate/GMIManager.js +22 -16
  61. package/dist/cognition/substrate/GMIManager.js.map +1 -1
  62. package/dist/cognition/substrate/IGMI.d.ts +23 -0
  63. package/dist/cognition/substrate/IGMI.d.ts.map +1 -1
  64. package/dist/cognition/substrate/IGMI.js.map +1 -1
  65. package/dist/cognition/substrate/MetapromptExecutor.d.ts +183 -28
  66. package/dist/cognition/substrate/MetapromptExecutor.d.ts.map +1 -1
  67. package/dist/cognition/substrate/MetapromptExecutor.js +431 -108
  68. package/dist/cognition/substrate/MetapromptExecutor.js.map +1 -1
  69. package/dist/cognition/substrate/personas/IPersonaDefinition.d.ts +5 -0
  70. package/dist/cognition/substrate/personas/IPersonaDefinition.d.ts.map +1 -1
  71. package/dist/cognition/substrate/personas/PersonaValidation.d.ts.map +1 -1
  72. package/dist/cognition/substrate/personas/PersonaValidation.js +30 -0
  73. package/dist/cognition/substrate/personas/PersonaValidation.js.map +1 -1
  74. package/dist/cognition/substrate/personas/SoulLoader.d.ts +14 -1
  75. package/dist/cognition/substrate/personas/SoulLoader.d.ts.map +1 -1
  76. package/dist/cognition/substrate/personas/SoulLoader.js +45 -12
  77. package/dist/cognition/substrate/personas/SoulLoader.js.map +1 -1
  78. package/dist/cognition/substrate/personas/definitions/default_assistant_persona.json +3 -35
  79. package/dist/cognition/substrate/personas/hexaco.d.ts +34 -0
  80. package/dist/cognition/substrate/personas/hexaco.d.ts.map +1 -0
  81. package/dist/cognition/substrate/personas/hexaco.js +66 -0
  82. package/dist/cognition/substrate/personas/hexaco.js.map +1 -0
  83. package/dist/cognition/substrate/userFeedback.d.ts +69 -0
  84. package/dist/cognition/substrate/userFeedback.d.ts.map +1 -0
  85. package/dist/cognition/substrate/userFeedback.js +107 -0
  86. package/dist/cognition/substrate/userFeedback.js.map +1 -0
  87. package/dist/core/conversation/ConversationMessage.d.ts +9 -0
  88. package/dist/core/conversation/ConversationMessage.d.ts.map +1 -1
  89. package/dist/core/conversation/ConversationMessage.js.map +1 -1
  90. package/dist/core/llm/IPromptEngine.d.ts +9 -0
  91. package/dist/core/llm/IPromptEngine.d.ts.map +1 -1
  92. package/dist/core/llm/IPromptEngine.js.map +1 -1
  93. package/dist/core/llm/PromptEngine.d.ts +30 -1
  94. package/dist/core/llm/PromptEngine.d.ts.map +1 -1
  95. package/dist/core/llm/PromptEngine.js +229 -92
  96. package/dist/core/llm/PromptEngine.js.map +1 -1
  97. package/dist/core/llm/providers/IProvider.d.ts +28 -8
  98. package/dist/core/llm/providers/IProvider.d.ts.map +1 -1
  99. package/dist/core/llm/providers/implementations/AnthropicProvider.d.ts +135 -49
  100. package/dist/core/llm/providers/implementations/AnthropicProvider.d.ts.map +1 -1
  101. package/dist/core/llm/providers/implementations/AnthropicProvider.js +294 -110
  102. package/dist/core/llm/providers/implementations/AnthropicProvider.js.map +1 -1
  103. package/dist/core/llm/providers/implementations/ClaudeCodeProvider.d.ts.map +1 -1
  104. package/dist/core/llm/providers/implementations/ClaudeCodeProvider.js +15 -1
  105. package/dist/core/llm/providers/implementations/ClaudeCodeProvider.js.map +1 -1
  106. package/dist/core/llm/providers/implementations/GeminiCLIProvider.d.ts +1 -1
  107. package/dist/core/llm/providers/implementations/GeminiCLIProvider.d.ts.map +1 -1
  108. package/dist/core/llm/providers/implementations/GeminiCLIProvider.js +51 -24
  109. package/dist/core/llm/providers/implementations/GeminiCLIProvider.js.map +1 -1
  110. package/dist/core/llm/providers/implementations/GeminiProvider.d.ts +19 -6
  111. package/dist/core/llm/providers/implementations/GeminiProvider.d.ts.map +1 -1
  112. package/dist/core/llm/providers/implementations/GeminiProvider.js +62 -24
  113. package/dist/core/llm/providers/implementations/GeminiProvider.js.map +1 -1
  114. package/dist/core/llm/providers/implementations/OpenAIProvider.d.ts +255 -70
  115. package/dist/core/llm/providers/implementations/OpenAIProvider.d.ts.map +1 -1
  116. package/dist/core/llm/providers/implementations/OpenAIProvider.js +943 -214
  117. package/dist/core/llm/providers/implementations/OpenAIProvider.js.map +1 -1
  118. package/dist/core/llm/providers/implementations/cacheLeakDetector.d.ts +15 -7
  119. package/dist/core/llm/providers/implementations/cacheLeakDetector.d.ts.map +1 -1
  120. package/dist/core/llm/providers/implementations/cacheLeakDetector.js +12 -7
  121. package/dist/core/llm/providers/implementations/cacheLeakDetector.js.map +1 -1
  122. package/dist/core/llm/providers/implementations/openai-response-format-guard.d.ts +17 -0
  123. package/dist/core/llm/providers/implementations/openai-response-format-guard.d.ts.map +1 -1
  124. package/dist/core/llm/providers/implementations/openai-response-format-guard.js +31 -0
  125. package/dist/core/llm/providers/implementations/openai-response-format-guard.js.map +1 -1
  126. package/dist/core/llm/providers/model-cache-capabilities.d.ts +24 -9
  127. package/dist/core/llm/providers/model-cache-capabilities.d.ts.map +1 -1
  128. package/dist/core/llm/providers/model-cache-capabilities.js +59 -44
  129. package/dist/core/llm/providers/model-cache-capabilities.js.map +1 -1
  130. package/dist/core/llm/providers/model-effort.d.ts +21 -0
  131. package/dist/core/llm/providers/model-effort.d.ts.map +1 -1
  132. package/dist/core/llm/providers/model-effort.js +38 -2
  133. package/dist/core/llm/providers/model-effort.js.map +1 -1
  134. package/dist/core/llm/providers/model-forced-tool-choice.d.ts +13 -6
  135. package/dist/core/llm/providers/model-forced-tool-choice.d.ts.map +1 -1
  136. package/dist/core/llm/providers/model-forced-tool-choice.js +14 -7
  137. package/dist/core/llm/providers/model-forced-tool-choice.js.map +1 -1
  138. package/dist/core/llm/providers/model-thinking.d.ts +64 -13
  139. package/dist/core/llm/providers/model-thinking.d.ts.map +1 -1
  140. package/dist/core/llm/providers/model-thinking.js +52 -12
  141. package/dist/core/llm/providers/model-thinking.js.map +1 -1
  142. package/dist/core/llm/streaming/StreamingBatcher.d.ts +4 -3
  143. package/dist/core/llm/streaming/StreamingBatcher.d.ts.map +1 -1
  144. package/dist/core/llm/streaming/StreamingBatcher.js +8 -3
  145. package/dist/core/llm/streaming/StreamingBatcher.js.map +1 -1
  146. package/dist/core/safety/LLMProviderHealthRegistry.d.ts +4 -0
  147. package/dist/core/safety/LLMProviderHealthRegistry.d.ts.map +1 -1
  148. package/dist/core/safety/LLMProviderHealthRegistry.js +26 -0
  149. package/dist/core/safety/LLMProviderHealthRegistry.js.map +1 -1
  150. package/dist/core/utils/sha256.d.ts +19 -0
  151. package/dist/core/utils/sha256.d.ts.map +1 -0
  152. package/dist/core/utils/sha256.js +101 -0
  153. package/dist/core/utils/sha256.js.map +1 -0
  154. package/dist/orchestration/pipeline/query/types.js +1 -1
  155. package/package.json +1 -1
@@ -3,10 +3,11 @@ 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';
10
+ import { redactUrlSecrets } from '../url-secrets.js';
10
11
  /**
11
12
  * @class OpenAIProvider
12
13
  * @implements {IProvider}
@@ -41,7 +42,8 @@ export function modelRequiresMaxCompletionTokens(modelId) {
41
42
  * family prefix alone does not decide the size: `gpt-5.4` is a 1.05M model
42
43
  * while its `-mini` and `-nano` siblings are 400K.
43
44
  *
44
- * - 1,050,000: the GPT-6 family, the GPT-5.6 family except `gpt-5.6-cyber`,
45
+ * - 1,050,000: the GPT-6 family (`gpt-6.1-sol` included, per the
46
+ * 2026-09-29 changelog), the GPT-5.6 family except `gpt-5.6-cyber`,
45
47
  * `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4` and `gpt-5.4-pro` (max input
46
48
  * 922,000 on GPT-6).
47
49
  * - 400,000: `gpt-5.6-cyber`, `gpt-5.4-mini` / `-nano`, `gpt-5.3-codex`,
@@ -71,30 +73,96 @@ export function openAiReasoningContextWindow(modelId) {
71
73
  return 1050000;
72
74
  return 400000;
73
75
  }
76
+ /**
77
+ * Prompt size, in input tokens, above which OpenAI bills a call on a
78
+ * long-context model at the long-context rates. The pricing page splits its
79
+ * flagship table into "<=272K" and ">272K" input tokens, and the comparison is
80
+ * strict: a prompt of exactly 272,000 tokens bills at the standard rates. The
81
+ * count includes cached tokens, and output size never triggers the tier.
82
+ */
83
+ export const OPENAI_LONG_CONTEXT_THRESHOLD_TOKENS = 272000;
84
+ /**
85
+ * Factor OpenAI applies to the input rate of a long-context call. The cached
86
+ * input and cache write rates double as well.
87
+ */
88
+ export const OPENAI_LONG_CONTEXT_INPUT_MULTIPLIER = 2;
89
+ /** Factor OpenAI applies to the output rate of a long-context call. */
90
+ export const OPENAI_LONG_CONTEXT_OUTPUT_MULTIPLIER = 1.5;
91
+ /**
92
+ * Whether OpenAI bills `modelId` at the long-context rates when a prompt
93
+ * exceeds {@link OPENAI_LONG_CONTEXT_THRESHOLD_TOKENS} input tokens. Such a
94
+ * call bills its whole input at {@link OPENAI_LONG_CONTEXT_INPUT_MULTIPLIER}
95
+ * times the input rate and its whole output at
96
+ * {@link OPENAI_LONG_CONTEXT_OUTPUT_MULTIPLIER} times the output rate.
97
+ *
98
+ * OpenAI states the rule for its models with a 1.05M context window, so this
99
+ * reuses {@link openAiReasoningContextWindow}. That covers the GPT-6 family
100
+ * (`gpt-6.1-sol` included), the GPT-5.6 family except `gpt-5.6-cyber`,
101
+ * `gpt-5.5`, `gpt-5.5-pro`, `gpt-5.4` and `gpt-5.4-pro`, bare or dated.
102
+ * Everything else bills flat at every prompt size: the GPT-5 models with a
103
+ * smaller window (`gpt-5.6-cyber`, which the pricing page lists without a
104
+ * long-context rate, `gpt-5.4-mini` / `-nano`, `gpt-5.3` and older, and the
105
+ * chat-latest snapshots), the o-series, and the pre-GPT-5 models, `gpt-4.1`
106
+ * included despite its 1M window. Checked against
107
+ * developers.openai.com/api/docs/pricing and the model pages on 2026-09-30.
108
+ *
109
+ * @param modelId Model id as requested or as echoed by the API.
110
+ * @returns `true` when a prompt above the threshold bills at the long-context rates.
111
+ */
112
+ export function openAiHasLongContextPricing(modelId) {
113
+ return isOpenAIReasoningModel(modelId) && openAiReasoningContextWindow(modelId) === 1050000;
114
+ }
74
115
  /**
75
116
  * Whether OpenAI serves `modelId` only through the Responses API. OpenAI lists
76
117
  * the `-pro` tiers, the codex models, `gpt-5.6-cyber` and the deep-research
77
- * models as Responses-only. OpenAIProvider sends a request to /v1/responses
78
- * only when it is a non-streamed GPT-5 or GPT-6 call carrying function tools
79
- * 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.
80
126
  *
81
127
  * @param modelId Provider-side model identifier.
82
128
  * @returns `true` when the model has no Chat Completions endpoint.
83
129
  */
84
130
  export function isOpenAIResponsesOnlyModel(modelId) {
85
- 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)));
86
156
  }
87
157
  /**
88
158
  * ModelInfo capabilities for a GPT-5, GPT-6 or o-series id, limited to what
89
159
  * OpenAIProvider can serve.
90
160
  *
91
161
  * - Responses-only models ({@link isOpenAIResponsesOnlyModel}) list as
92
- * `chat` only. This provider reaches them only on its narrow Responses
93
- * path, and the refresh leaves the Responses-only o-series ids out.
94
- * - GPT-6 omits `tool_use`. OpenAI requires the Responses API for GPT-6
95
- * Astra tool calls and `reasoning_effort: "none"` for Sol and Luna tool
96
- * calls on Chat Completions, and this provider routes a tool call to
97
- * 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}).
98
166
  * - `o1-mini` and `o1-preview` have no function calling or structured
99
167
  * outputs. They and `o3-mini` take text only, and every other id takes
100
168
  * images (per model input modalities, checked 2026-09-29).
@@ -106,9 +174,7 @@ export function openAiReasoningCapabilities(modelId) {
106
174
  const id = modelId.toLowerCase();
107
175
  if (isOpenAIResponsesOnlyModel(id) || /^o1-(mini|preview)/.test(id))
108
176
  return ['chat'];
109
- const caps = ['chat', 'json_mode'];
110
- if (!/^gpt-6/.test(id))
111
- caps.push('tool_use');
177
+ const caps = ['chat', 'json_mode', 'tool_use'];
112
178
  if (!/^o3-mini/.test(id))
113
179
  caps.push('vision_input');
114
180
  return caps;
@@ -142,32 +208,30 @@ export function isOpenAIReasoningModel(modelId) {
142
208
  return /^(o\d|gpt-5|gpt-6)/i.test(modelId);
143
209
  }
144
210
  /**
145
- * Whether OpenAI rejects `reasoning_effort` sent ALONGSIDE function tools on
146
- * the `/v1/chat/completions` endpoint for `modelId`. The GPT-5 family enforces
147
- * this — "Function tools with reasoning_effort are not supported for gpt-5.5
148
- * in /v1/chat/completions. Please use /v1/responses instead." (live-observed
149
- * 2026-07-08 when the codegen frontier fallback chain routed a tool-carrying,
150
- * 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).
151
217
  *
152
- * Such a call is routed to `/v1/responses` when it qualifies (see
153
- * {@link shouldRouteToOpenAiResponsesApi}, which preserves BOTH effort and
154
- * tools); otherwise it stays on chat/completions and must DROP
155
- * `reasoning_effort` (the model then reasons at its default depth) rather than
156
- * 400 the entire request. Scoped to the GPT-5 and GPT-6 families: the o-series
157
- * (o1/o3/o4) still accepts `reasoning_effort` + tools on chat/completions, so
158
- * dropping it there would be a needless reasoning-depth regression. Widen this
159
- * 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}).
160
227
  *
161
- * GPT-6 enforces the SAME restriction — live-probed 2026-09-10
162
- * (chat.completions, `gpt-6-astra`, `reasoning_effort: 'xhigh'` + one function
163
- * tool): HTTP 400 `invalid_request_error` — "Function tools with
164
- * reasoning_effort are not supported for gpt-6-astra in /v1/chat/completions.
165
- * To use function tools, use /v1/responses or set reasoning_effort to 'none'."
166
- * The suggested `'none'` escape hatch does NOT exist on this family: the same
167
- * probe with `reasoning_effort: 'none'` returns HTTP 400 `unsupported_value`
168
- * ("Supported values are: 'low', 'medium', 'high', and 'xhigh'"). Dropping the
169
- * param entirely (or routing to `/v1/responses`) is therefore the only viable
170
- * 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.
171
235
  *
172
236
  * @param modelId Provider-side model identifier (e.g. `'gpt-5.5'`, `'gpt-6-astra'`).
173
237
  */
@@ -234,35 +298,196 @@ export function flattenResponsesTextContent(content) {
234
298
  return '';
235
299
  return content.filter(isTextPart).map((p) => p.text).join('\n');
236
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
+ }
237
311
  /**
238
- * Whether a NON-streaming call must use `/v1/responses` instead of
239
- * `/v1/chat/completions`: a gpt-5/gpt-6 reasoning model carrying function tools
240
- * AND a requested effort — the ONLY combination chat/completions 400s on
241
- * ("Function tools with reasoning_effort are not supported … Please use
242
- * /v1/responses instead") and the only case where the Responses path buys
243
- * anything (preserved reasoning depth). Excludes, per the 2026-07-08 Codex
244
- * review, requests that carry a `responseFormat` (the Responses path maps no
245
- * structured output — those stay on chat/completions where R4 drops effort) or
246
- * message content the input mapper cannot carry (see
247
- * {@link isResponsesMappableContent}). Every excluded case stays on
248
- * 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.
249
320
  *
250
- * Content gating is delegated to {@link isResponsesMappableContent} rather
251
- * than tested inline. It previously required EVERY message's content to be a
252
- * plain string, which silently excluded Anthropic-style cache-marked system
253
- * blocks (`systemBlocks` / `SystemContentBlock[]`, which reach the provider as
254
- * an all-text content array). Callers that set `systemBlocks` to fix a cold
255
- * prompt cache therefore lost Responses routing and hard-400ed on
256
- * chat/completions — the exact failure this router exists to prevent. All-text
257
- * arrays now route and are flattened losslessly; true multimodal content is
258
- * 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`.
259
370
  */
260
371
  export function shouldRouteToOpenAiResponsesApi(modelId, messages, options) {
372
+ if (openAiRequiresResponsesApi(modelId, options))
373
+ return true;
261
374
  return (openAiRejectsReasoningEffortWithTools(modelId) &&
262
375
  requestHasFunctionTools(options.tools) &&
263
376
  mapEffortToOpenAiReasoningEffort(options.effort) !== undefined &&
264
- options.responseFormat === undefined &&
265
- 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;
266
491
  }
267
492
  export class OpenAIProvider {
268
493
  /**
@@ -281,9 +506,11 @@ export class OpenAIProvider {
281
506
  // on 2026-04-16. Values are standard (non-batch, non-regional) rates.
282
507
  // Input: cost for prompt tokens. Output: cost for completion tokens.
283
508
  // For embedding models, 'input' is total tokens.
284
- // Rates are flat per model. OpenAI bills prompts above 272,000 input tokens
285
- // on the 1.05M-context models at 2x input and 1.5x output for the whole
286
- // request, which this table does not model, so long-context spend reads low.
509
+ // A prompt of more than 272,000 input tokens on a 1.05M-context model bills
510
+ // the whole call at 2x input and 1.5x output
511
+ // (developers.openai.com/api/docs/pricing, 2026-09-30). Rows hold the rates
512
+ // below that threshold, and calculateCost applies the tier from
513
+ // openAiHasLongContextPricing.
287
514
  this.modelPricing = {
288
515
  // GPT-6 family (current flagship, Sep 2026). Astra $10/$50, Sol $2/$10,
289
516
  // Luna $0.10/$0.50 per 1M, from developers.openai.com/api/docs/models on
@@ -295,6 +522,11 @@ export class OpenAIProvider {
295
522
  'gpt-6-astra': { input: 0.01, output: 0.05 },
296
523
  'gpt-6-sol': { input: 0.002, output: 0.01 },
297
524
  'gpt-6-luna': { input: 0.0001, output: 0.0005 },
525
+ // gpt-6.1-sol (developers.openai.com/api/docs/changelog, 2026-09-29): $2
526
+ // input and $10 output per 1M like gpt-6-sol, with cached input at $0.10
527
+ // (5% of input, where the other GPT-6 models charge 10%) and cache writes
528
+ // at $2.50. This table carries no cached or cache-write column.
529
+ 'gpt-6.1-sol': { input: 0.002, output: 0.01 },
298
530
  // GPT-5.5 family (previous flagship, Jun 2026 — $5 / $30 per 1M tokens, a 2x
299
531
  // increase over gpt-5.4; verified against OpenAI's published pricing 2026-06-27)
300
532
  'gpt-5.5': { input: 0.005, output: 0.03 },
@@ -548,9 +780,9 @@ export class OpenAIProvider {
548
780
  async generateCompletion(modelId, messages, options) {
549
781
  this.ensureInitialized();
550
782
  const apiKey = await this.getApiKey(options.apiKeyOverride);
551
- // GPT-5 rejects `reasoning_effort` + function tools on /chat/completions
552
- // and demands /v1/responses. Route ONLY that exact case there (preserving
553
- // 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.
554
786
  if (shouldRouteToOpenAiResponsesApi(modelId, messages, options)) {
555
787
  const responsesBody = this.buildResponsesPayload(modelId, messages, options);
556
788
  const responsesApiResponse = await this.makeApiRequest('/responses', 'POST', apiKey, responsesBody, false, options.requestTimeout);
@@ -563,61 +795,373 @@ export class OpenAIProvider {
563
795
  /** @inheritdoc */
564
796
  async *generateCompletionStream(modelId, messages, options) {
565
797
  this.ensureInitialized();
566
- const apiKey = await this.getApiKey(options.apiKeyOverride);
567
- const requestBody = this.buildChatCompletionPayload(modelId, messages, options, true);
568
- const stream = (await this.makeApiRequest('/chat/completions', 'POST', apiKey, requestBody, true, // Indicate streaming response is expected
569
- options.requestTimeout));
570
- // Accumulators for streaming tool calls
571
- const accumulatedToolCalls = new Map();
572
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.
573
801
  if (abortSignal?.aborted) {
574
- yield {
575
- id: `openai-abort-${Date.now()}`,
576
- object: 'chat.completion.chunk',
577
- created: Math.floor(Date.now() / 1000),
578
- modelId,
579
- choices: [],
580
- error: { message: 'Stream aborted prior to first chunk', type: 'abort' },
581
- isFinal: true,
582
- };
802
+ yield this.abortedStreamChunk(modelId, 'Stream aborted prior to first chunk');
583
803
  return;
584
804
  }
585
- const abortHandler = () => {
586
- // Emit final abort chunk and ensure generator completes.
587
- // We cannot directly cancel the underlying ReadableStream cleanly cross-platform here; consumer side will stop.
588
- // Provide a terminal chunk for consistent teardown.
589
- // Note: We don't attempt to read further chunks after abort.
590
- };
591
- abortSignal?.addEventListener('abort', abortHandler, { once: true });
592
- 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.
593
819
  if (abortSignal?.aborted) {
594
- yield {
595
- id: `openai-abort-${Date.now()}`,
596
- object: 'chat.completion.chunk',
597
- created: Math.floor(Date.now() / 1000),
598
- modelId,
599
- choices: [],
600
- error: { message: 'Stream aborted by caller', type: 'abort' },
601
- isFinal: true,
602
- };
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)
603
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;
604
868
  }
605
- if (chunk === '[DONE]') {
606
- // The [DONE] message is a signal for the end of the stream from OpenAI.
607
- // A final response chunk with isFinal=true and potential usage should be yielded by the parser if data exists.
608
- // 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)
609
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);
610
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;
611
968
  try {
612
- const apiChunk = JSON.parse(chunk);
613
- yield this.mapApiToStreamChunkResponse(apiChunk, accumulatedToolCalls);
969
+ event = JSON.parse(data);
614
970
  }
615
971
  catch (error) {
616
- console.warn('OpenAIProvider: Failed to parse stream chunk JSON:', chunk, error);
617
- // 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;
618
1143
  }
619
1144
  }
620
- 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
+ });
621
1165
  }
622
1166
  /** @inheritdoc */
623
1167
  async generateEmbeddings(modelId, texts, options) {
@@ -739,15 +1283,16 @@ export class OpenAIProvider {
739
1283
  payload.top_p = options.topP;
740
1284
  }
741
1285
  else {
742
- // Reasoning models (o-series, GPT-5) take `reasoning_effort`
1286
+ // Reasoning models (o-series, GPT-5, GPT-6) take `reasoning_effort`
743
1287
  // (none|low|medium|high|xhigh) in place of the sampling params they
744
- // reject. Map the agentos effort scale onto it so `effort: 'max'` actually
745
- // drives gpt-5.x at its xhigh ceiling instead of being silently dropped.
746
- // customModelParams (below) can still override.
747
- // GPT-5 rejects `reasoning_effort` + function tools on chat/completions
748
- // (it demands the Responses API, which agentos doesn't implement). When
749
- // the request carries tools, DROP the effort param instead of 400ing —
750
- // 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.
751
1296
  const reasoningEffort = mapEffortToOpenAiReasoningEffortForModel(options.effort, modelId);
752
1297
  const dropEffortForTools = requestHasFunctionTools(options.tools) &&
753
1298
  openAiRejectsReasoningEffortWithTools(modelId);
@@ -861,28 +1406,54 @@ export class OpenAIProvider {
861
1406
  payload.service_tier = options.serviceTier;
862
1407
  }
863
1408
  /**
864
- * Build a `/v1/responses` request body for a gpt-5/gpt-6 reasoning +
865
- * function-tool call (the only case
866
- * {@link shouldRouteToOpenAiResponsesApi} routes here).
867
- * Preserves BOTH `reasoning.effort` and `tools`, which chat/completions
868
- * rejects together. No structured output by construction (the router
869
- * excludes responseFormat). Message content arrives as either a plain
870
- * string or an all-text block array — {@link flattenResponsesTextContent}
871
- * joins the latter, so cache-marked `systemBlocks` survive intact instead of
872
- * collapsing to an empty prompt. True multimodal content never reaches here
873
- * (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
+ *
874
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.
875
1434
  * @private
876
1435
  */
877
- 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
+ }
878
1445
  const input = [];
879
1446
  for (const m of messages) {
880
- const text = flattenResponsesTextContent(m.content);
881
1447
  if (m.role === 'tool') {
882
1448
  // Tool result → function_call_output paired by call_id.
883
- 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) });
884
1450
  continue;
885
1451
  }
1452
+ if (m.role === 'user') {
1453
+ input.push({ role: 'user', content: toResponsesInputContent(m.content) });
1454
+ continue;
1455
+ }
1456
+ const text = flattenResponsesTextContent(m.content);
886
1457
  if (m.role === 'assistant' && m.tool_calls && m.tool_calls.length > 0) {
887
1458
  // Optional assistant text first, then one function_call item per call.
888
1459
  if (text.length > 0)
@@ -897,19 +1468,25 @@ export class OpenAIProvider {
897
1468
  }
898
1469
  continue;
899
1470
  }
900
- // system / user / plain assistant. Drop an empty assistant turn (no text,
901
- // 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.
902
1473
  if (m.role === 'assistant' && text.length === 0)
903
1474
  continue;
904
1475
  input.push({ role: m.role, content: text });
905
1476
  }
906
- const payload = {
907
- model: modelId,
908
- input,
909
- tools: this.toResponsesTools(options.tools),
910
- reasoning: { effort: mapEffortToOpenAiResponsesEffort(modelId, options.effort) },
911
- store: false,
912
- };
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;
913
1490
  if (options.toolChoice !== undefined) {
914
1491
  payload.tool_choice = this.toResponsesToolChoice(options.toolChoice);
915
1492
  }
@@ -933,6 +1510,60 @@ export class OpenAIProvider {
933
1510
  Object.assign(payload, passthrough);
934
1511
  }
935
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
+ }
936
1567
  return payload;
937
1568
  }
938
1569
  /** Reshape chat-completions function tools → the Responses flat tool shape. */
@@ -946,8 +1577,13 @@ export class OpenAIProvider {
946
1577
  type: 'function',
947
1578
  name: tool.function.name,
948
1579
  description: tool.function.description,
949
- parameters: tool.function.parameters,
950
- 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,
951
1587
  };
952
1588
  }
953
1589
  return t; // already-flat or non-function tool: pass through
@@ -961,6 +1597,28 @@ export class OpenAIProvider {
961
1597
  if (tc?.type === 'function' && tc.function?.name) {
962
1598
  return { type: 'function', name: tc.function.name };
963
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
+ }
964
1622
  return toolChoice;
965
1623
  }
966
1624
  /**
@@ -979,34 +1637,9 @@ export class OpenAIProvider {
979
1637
  * @private
980
1638
  */
981
1639
  mapResponsesToCompletionResponse(apiResponse, modelId) {
982
- const output = apiResponse.output ?? [];
983
- let assistantText = '';
984
- const toolCalls = [];
985
- for (const item of output) {
986
- if (item.type === 'message') {
987
- for (const part of item.content ?? []) {
988
- if (part.type === 'output_text' && typeof part.text === 'string')
989
- assistantText += part.text;
990
- }
991
- }
992
- else if (item.type === 'function_call') {
993
- toolCalls.push({
994
- id: item.call_id ?? '',
995
- type: 'function',
996
- function: { name: item.name ?? '', arguments: item.arguments ?? '' },
997
- });
998
- }
999
- // reasoning + any other item type: ignored.
1000
- }
1001
- const incompleteReason = apiResponse.incomplete_details?.reason;
1002
- const finishReason = toolCalls.length > 0
1003
- ? 'tool_calls'
1004
- : apiResponse.status === 'incomplete'
1005
- ? (incompleteReason === 'max_output_tokens' ? 'length'
1006
- : incompleteReason === 'content_filter' ? 'content_filter'
1007
- : 'stop')
1008
- : 'stop';
1009
- 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) {
1010
1643
  // GENUINELY empty 2xx — no output items at all. This is the malformed
1011
1644
  // body the guard was written for: throw so generateText's fallback
1012
1645
  // advances instead of treating it as a successful empty turn.
@@ -1021,17 +1654,7 @@ export class OpenAIProvider {
1021
1654
  throw new OpenAIProviderError(`OpenAI /responses returned no usable output (status: ${apiResponse.status ?? 'unknown'})`, 'INVALID_RESPONSE', undefined, undefined, undefined, apiResponse);
1022
1655
  }
1023
1656
  if (assistantText.length === 0 && toolCalls.length === 0) {
1024
- // Items present but no message/function_call — reasoning-only output.
1025
- // Surfaced as an empty turn carrying the real finishReason ('length'
1026
- // when max_output_tokens capped it), so the caller can retry with a
1027
- // larger budget. Reasoning tokens are drawn from the SAME output budget
1028
- // as the visible answer, so a reasoning model needs a materially larger
1029
- // maxTokens than a non-reasoning one to leave room for a reply.
1030
- console.warn(`OpenAIProvider: /responses returned reasoning-only output for ${apiResponse.model ?? modelId} ` +
1031
- `(status: ${apiResponse.status ?? 'unknown'}${incompleteReason ? `, reason: ${incompleteReason}` : ''}` +
1032
- `${apiResponse.usage?.output_tokens !== undefined ? `, output_tokens: ${apiResponse.usage.output_tokens}` : ''}` +
1033
- `) — returning an empty turn with finishReason '${finishReason}'. ` +
1034
- `Raise max output tokens: reasoning tokens consume the same budget as the reply.`);
1657
+ this.warnReasoningOnlyOutput(apiResponse, modelId, finishReason);
1035
1658
  }
1036
1659
  return {
1037
1660
  id: apiResponse.id,
@@ -1050,22 +1673,50 @@ export class OpenAIProvider {
1050
1673
  logprobs: null,
1051
1674
  }],
1052
1675
  usage: apiResponse.usage
1053
- ? this.calculateUsage({
1054
- prompt_tokens: apiResponse.usage.input_tokens,
1055
- completion_tokens: apiResponse.usage.output_tokens,
1056
- total_tokens: apiResponse.usage.total_tokens,
1057
- // Responses API spells the cached detail input_tokens_details;
1058
- // re-key to the chat spelling the shared mapper reads.
1059
- ...(apiResponse.usage.input_tokens_details
1060
- ? { prompt_tokens_details: {
1061
- cached_tokens: apiResponse.usage.input_tokens_details.cached_tokens,
1062
- cache_write_tokens: apiResponse.usage.input_tokens_details.cache_write_tokens,
1063
- } }
1064
- : {}),
1065
- }, apiResponse.model ?? modelId)
1676
+ ? this.mapResponsesUsage(apiResponse.usage, apiResponse.model ?? modelId)
1066
1677
  : undefined,
1067
1678
  };
1068
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
+ }
1069
1720
  /**
1070
1721
  * Maps the raw OpenAI API Chat Completion response to the standard ModelCompletionResponse.
1071
1722
  * @private
@@ -1275,7 +1926,20 @@ export class OpenAIProvider {
1275
1926
  return this.modelPricing[modelId] ?? this.modelPricing[modelId.replace(/-\d{4}-\d{2}-\d{2}$/, '')];
1276
1927
  }
1277
1928
  /**
1278
- * Calculates the estimated cost of an API call.
1929
+ * Estimated USD cost of one API call, from the per-1K rates in
1930
+ * `modelPricing`. When the model has long-context pricing
1931
+ * ({@link openAiHasLongContextPricing}) and the prompt exceeds
1932
+ * {@link OPENAI_LONG_CONTEXT_THRESHOLD_TOKENS} input tokens, the whole call
1933
+ * bills at the long-context rates: every input token at 2x and every output
1934
+ * token at 1.5x.
1935
+ *
1936
+ * @param promptTokens Input tokens, cached tokens included. OpenAI compares
1937
+ * this same count against the long-context threshold.
1938
+ * @param completionTokens Output tokens, reasoning tokens included.
1939
+ * @param modelId Model id as echoed by the API; a dated snapshot without its
1940
+ * own row resolves to its base row.
1941
+ * @param isEmbedding Prices `promptTokens` alone at the embedding rate.
1942
+ * @returns Cost in USD, or undefined when the model has no price row.
1279
1943
  * @private
1280
1944
  */
1281
1945
  calculateCost(promptTokens, completionTokens, modelId, isEmbedding = false) {
@@ -1285,11 +1949,14 @@ export class OpenAIProvider {
1285
1949
  if (isEmbedding) {
1286
1950
  return (promptTokens / 1000) * pricing.input;
1287
1951
  }
1288
- const inputCost = (promptTokens / 1000) * pricing.input;
1289
- const outputCost = (completionTokens / 1000) * pricing.output;
1290
- return inputCost + outputCost;
1952
+ // A long prompt moves the output onto the higher rate too, because
1953
+ // OpenAI prices the full request at the long-context tier.
1954
+ const isLong = promptTokens > OPENAI_LONG_CONTEXT_THRESHOLD_TOKENS && openAiHasLongContextPricing(modelId);
1955
+ const inputRate = isLong ? pricing.input * OPENAI_LONG_CONTEXT_INPUT_MULTIPLIER : pricing.input;
1956
+ const outputRate = isLong ? pricing.output * OPENAI_LONG_CONTEXT_OUTPUT_MULTIPLIER : pricing.output;
1957
+ return (promptTokens / 1000) * inputRate + (completionTokens / 1000) * outputRate;
1291
1958
  }
1292
- async makeApiRequest(endpoint, method, apiKey, body, expectStream = false, requestTimeoutOverride) {
1959
+ async makeApiRequest(endpoint, method, apiKey, body, expectStream = false, requestTimeoutOverride, abortSignal) {
1293
1960
  const url = `${this.config.baseURL}${endpoint}`;
1294
1961
  const headers = {
1295
1962
  'Authorization': `Bearer ${apiKey}`,
@@ -1304,6 +1971,9 @@ export class OpenAIProvider {
1304
1971
  }
1305
1972
  let lastError = new OpenAIProviderError('Request failed after all retries.', 'MAX_RETRIES_REACHED');
1306
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
+ }
1307
1977
  const controller = new AbortController();
1308
1978
  // CR8: honor a per-call requestTimeout override (e.g. long codegen) over
1309
1979
  // the provider's default; only Anthropic read this before.
@@ -1311,6 +1981,11 @@ export class OpenAIProvider {
1311
1981
  ? requestTimeoutOverride
1312
1982
  : this.config.requestTimeout;
1313
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 });
1314
1989
  try {
1315
1990
  const requestOptions = {
1316
1991
  method,
@@ -1356,6 +2031,10 @@ export class OpenAIProvider {
1356
2031
  }
1357
2032
  catch (error) {
1358
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
+ }
1359
2038
  if (error instanceof OpenAIProviderError) { // If already our custom error, re-throw if not retryable
1360
2039
  if (error.code === 'API_CLIENT_ERROR')
1361
2040
  throw error;
@@ -1365,7 +2044,10 @@ export class OpenAIProvider {
1365
2044
  lastError = new OpenAIProviderError(`Request timed out after ${effectiveTimeout}ms.`, 'REQUEST_TIMEOUT', undefined, undefined, undefined, error);
1366
2045
  }
1367
2046
  else {
1368
- lastError = new OpenAIProviderError(error instanceof Error ? error.message : 'Network or unknown error', 'NETWORK_ERROR', undefined, undefined, undefined, error);
2047
+ // fetch quotes a URL it rejects (base URL credentials included) and
2048
+ // a header value it rejects (the key), so the message is masked and
2049
+ // the raw error is not kept.
2050
+ lastError = new OpenAIProviderError(redactUrlSecrets(error instanceof Error ? error.message : 'Network or unknown error', this.config.baseURL, [apiKey]), 'NETWORK_ERROR');
1369
2051
  }
1370
2052
  if (attempt === this.config.maxRetries - 1) {
1371
2053
  break; // Last attempt failed, break to throw lastError
@@ -1378,25 +2060,55 @@ export class OpenAIProvider {
1378
2060
  console.warn(`[LLM] Retry ${attempt + 1}/${this.config.maxRetries - 1} in ${(delay / 1000).toFixed(1)}s — ${shortMsg}`);
1379
2061
  await new Promise(resolve => setTimeout(resolve, delay));
1380
2062
  }
2063
+ finally {
2064
+ abortSignal?.removeEventListener('abort', abortAttempt);
2065
+ }
1381
2066
  }
1382
2067
  // For network errors, replace the cryptic cause chain with a clean message
1383
2068
  if (lastError instanceof OpenAIProviderError && lastError.code === 'NETWORK_ERROR') {
1384
- throw new OpenAIProviderError(`Network error: unable to reach ${this.config.baseURL}. Check your internet connection.`, 'NETWORK_ERROR');
2069
+ throw new OpenAIProviderError(redactUrlSecrets(`Network error: unable to reach ${this.config.baseURL}. Check your internet connection.`, this.config.baseURL, [apiKey]), 'NETWORK_ERROR');
1385
2070
  }
1386
2071
  throw lastError; // Throw the last encountered error after all retries
1387
2072
  }
1388
2073
  /**
1389
- * 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.
1390
2078
  * @private
1391
2079
  */
1392
- async *parseSseStream(stream) {
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.
2098
+ * @private
2099
+ */
2100
+ async *parseSseStream(stream, abortSignal) {
1393
2101
  const reader = stream.getReader();
1394
2102
  const decoder = new TextDecoder();
1395
2103
  let buffer = '';
2104
+ const cancelOnAbort = () => {
2105
+ reader.cancel().catch(() => undefined);
2106
+ };
2107
+ abortSignal?.addEventListener('abort', cancelOnAbort, { once: true });
1396
2108
  try {
1397
- while (true) {
2109
+ while (!abortSignal?.aborted) {
1398
2110
  const { done, value } = await reader.read();
1399
- if (done)
2111
+ if (done || abortSignal?.aborted)
1400
2112
  break;
1401
2113
  buffer += decoder.decode(value, { stream: true });
1402
2114
  let eolIndex;
@@ -1416,25 +2128,42 @@ export class OpenAIProvider {
1416
2128
  }
1417
2129
  }
1418
2130
  }
1419
- // Process any remaining data in the buffer (e.g. if stream ends without \n\n)
1420
- if (buffer.startsWith('data: ')) {
1421
- const dataContent = buffer.substring('data: '.length).trim();
1422
- if (dataContent)
1423
- yield dataContent;
1424
- }
1425
- else if (buffer.trim()) { // Sometimes final [DONE] might not be prefixed by "data: "
1426
- if (buffer.trim() === "[DONE]")
1427
- yield "[DONE]";
1428
- else
1429
- 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
+ }
1430
2155
  }
1431
2156
  }
1432
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;
1433
2161
  const message = error instanceof Error ? error.message : 'Stream parsing error';
1434
2162
  console.error('OpenAIProvider: Error reading or parsing SSE stream:', message, error);
1435
2163
  throw new OpenAIProviderError(message, 'STREAM_PARSING_ERROR', undefined, undefined, undefined, error);
1436
2164
  }
1437
2165
  finally {
2166
+ abortSignal?.removeEventListener('abort', cancelOnAbort);
1438
2167
  // Ensure stream is closed if `break` or `return` is called within the generator
1439
2168
  if (!reader.closed) {
1440
2169
  await reader.cancel().catch(err => console.error("OpenAIProvider: Error cancelling stream reader:", err));