@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.
- package/dist/api/AgentOS.d.ts.map +1 -1
- package/dist/api/AgentOS.js +3 -2
- package/dist/api/AgentOS.js.map +1 -1
- package/dist/api/agent.d.ts.map +1 -1
- package/dist/api/agent.js +25 -10
- package/dist/api/agent.js.map +1 -1
- package/dist/api/embedText.d.ts.map +1 -1
- package/dist/api/embedText.js +53 -17
- package/dist/api/embedText.js.map +1 -1
- package/dist/api/generateImage.d.ts.map +1 -1
- package/dist/api/generateImage.js +51 -7
- package/dist/api/generateImage.js.map +1 -1
- package/dist/api/generateText.d.ts +36 -6
- package/dist/api/generateText.d.ts.map +1 -1
- package/dist/api/generateText.js +102 -17
- package/dist/api/generateText.js.map +1 -1
- package/dist/api/model.d.ts.map +1 -1
- package/dist/api/model.js +11 -4
- package/dist/api/model.js.map +1 -1
- package/dist/api/runtime/SelfImprovementSessionManager.d.ts +34 -2
- package/dist/api/runtime/SelfImprovementSessionManager.d.ts.map +1 -1
- package/dist/api/runtime/SelfImprovementSessionManager.js +119 -33
- package/dist/api/runtime/SelfImprovementSessionManager.js.map +1 -1
- package/dist/api/runtime/provider-defaults.d.ts.map +1 -1
- package/dist/api/runtime/provider-defaults.js +5 -2
- package/dist/api/runtime/provider-defaults.js.map +1 -1
- package/dist/api/streamText.d.ts.map +1 -1
- package/dist/api/streamText.js +116 -34
- package/dist/api/streamText.js.map +1 -1
- package/dist/api/types/AgentOSInput.d.ts +11 -0
- package/dist/api/types/AgentOSInput.d.ts.map +1 -1
- package/dist/api/types.d.ts +14 -6
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/cognition/emergent/AdaptPersonalityTool.d.ts +13 -4
- package/dist/cognition/emergent/AdaptPersonalityTool.d.ts.map +1 -1
- package/dist/cognition/emergent/AdaptPersonalityTool.js +32 -23
- package/dist/cognition/emergent/AdaptPersonalityTool.js.map +1 -1
- package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts +18 -6
- package/dist/cognition/emergent/EmergentCapabilityEngine.d.ts.map +1 -1
- package/dist/cognition/emergent/EmergentCapabilityEngine.js.map +1 -1
- package/dist/cognition/emergent/SelfEvaluateTool.d.ts +10 -3
- package/dist/cognition/emergent/SelfEvaluateTool.d.ts.map +1 -1
- package/dist/cognition/emergent/SelfEvaluateTool.js +3 -2
- package/dist/cognition/emergent/SelfEvaluateTool.js.map +1 -1
- package/dist/cognition/substrate/CognitiveMemoryBridge.d.ts +51 -2
- package/dist/cognition/substrate/CognitiveMemoryBridge.d.ts.map +1 -1
- package/dist/cognition/substrate/CognitiveMemoryBridge.js +84 -2
- package/dist/cognition/substrate/CognitiveMemoryBridge.js.map +1 -1
- package/dist/cognition/substrate/ConversationHistoryManager.d.ts +8 -4
- package/dist/cognition/substrate/ConversationHistoryManager.d.ts.map +1 -1
- package/dist/cognition/substrate/ConversationHistoryManager.js +17 -5
- package/dist/cognition/substrate/ConversationHistoryManager.js.map +1 -1
- package/dist/cognition/substrate/GMI.d.ts +92 -5
- package/dist/cognition/substrate/GMI.d.ts.map +1 -1
- package/dist/cognition/substrate/GMI.js +291 -65
- package/dist/cognition/substrate/GMI.js.map +1 -1
- package/dist/cognition/substrate/GMIManager.d.ts +12 -0
- package/dist/cognition/substrate/GMIManager.d.ts.map +1 -1
- package/dist/cognition/substrate/GMIManager.js +22 -16
- package/dist/cognition/substrate/GMIManager.js.map +1 -1
- package/dist/cognition/substrate/IGMI.d.ts +23 -0
- package/dist/cognition/substrate/IGMI.d.ts.map +1 -1
- package/dist/cognition/substrate/IGMI.js.map +1 -1
- package/dist/cognition/substrate/MetapromptExecutor.d.ts +183 -28
- package/dist/cognition/substrate/MetapromptExecutor.d.ts.map +1 -1
- package/dist/cognition/substrate/MetapromptExecutor.js +431 -108
- package/dist/cognition/substrate/MetapromptExecutor.js.map +1 -1
- package/dist/cognition/substrate/personas/IPersonaDefinition.d.ts +5 -0
- package/dist/cognition/substrate/personas/IPersonaDefinition.d.ts.map +1 -1
- package/dist/cognition/substrate/personas/PersonaValidation.d.ts.map +1 -1
- package/dist/cognition/substrate/personas/PersonaValidation.js +30 -0
- package/dist/cognition/substrate/personas/PersonaValidation.js.map +1 -1
- package/dist/cognition/substrate/personas/SoulLoader.d.ts +14 -1
- package/dist/cognition/substrate/personas/SoulLoader.d.ts.map +1 -1
- package/dist/cognition/substrate/personas/SoulLoader.js +45 -12
- package/dist/cognition/substrate/personas/SoulLoader.js.map +1 -1
- package/dist/cognition/substrate/personas/definitions/default_assistant_persona.json +3 -35
- package/dist/cognition/substrate/personas/hexaco.d.ts +34 -0
- package/dist/cognition/substrate/personas/hexaco.d.ts.map +1 -0
- package/dist/cognition/substrate/personas/hexaco.js +66 -0
- package/dist/cognition/substrate/personas/hexaco.js.map +1 -0
- package/dist/cognition/substrate/userFeedback.d.ts +69 -0
- package/dist/cognition/substrate/userFeedback.d.ts.map +1 -0
- package/dist/cognition/substrate/userFeedback.js +107 -0
- package/dist/cognition/substrate/userFeedback.js.map +1 -0
- package/dist/core/conversation/ConversationMessage.d.ts +9 -0
- package/dist/core/conversation/ConversationMessage.d.ts.map +1 -1
- package/dist/core/conversation/ConversationMessage.js.map +1 -1
- package/dist/core/llm/IPromptEngine.d.ts +9 -0
- package/dist/core/llm/IPromptEngine.d.ts.map +1 -1
- package/dist/core/llm/IPromptEngine.js.map +1 -1
- package/dist/core/llm/PromptEngine.d.ts +30 -1
- package/dist/core/llm/PromptEngine.d.ts.map +1 -1
- package/dist/core/llm/PromptEngine.js +229 -92
- package/dist/core/llm/PromptEngine.js.map +1 -1
- package/dist/core/llm/providers/IProvider.d.ts +28 -8
- package/dist/core/llm/providers/IProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/AnthropicProvider.d.ts +135 -49
- package/dist/core/llm/providers/implementations/AnthropicProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/AnthropicProvider.js +294 -110
- package/dist/core/llm/providers/implementations/AnthropicProvider.js.map +1 -1
- package/dist/core/llm/providers/implementations/ClaudeCodeProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/ClaudeCodeProvider.js +15 -1
- package/dist/core/llm/providers/implementations/ClaudeCodeProvider.js.map +1 -1
- package/dist/core/llm/providers/implementations/GeminiCLIProvider.d.ts +1 -1
- package/dist/core/llm/providers/implementations/GeminiCLIProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/GeminiCLIProvider.js +51 -24
- package/dist/core/llm/providers/implementations/GeminiCLIProvider.js.map +1 -1
- package/dist/core/llm/providers/implementations/GeminiProvider.d.ts +19 -6
- package/dist/core/llm/providers/implementations/GeminiProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/GeminiProvider.js +62 -24
- package/dist/core/llm/providers/implementations/GeminiProvider.js.map +1 -1
- package/dist/core/llm/providers/implementations/OpenAIProvider.d.ts +255 -70
- package/dist/core/llm/providers/implementations/OpenAIProvider.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/OpenAIProvider.js +943 -214
- package/dist/core/llm/providers/implementations/OpenAIProvider.js.map +1 -1
- package/dist/core/llm/providers/implementations/cacheLeakDetector.d.ts +15 -7
- package/dist/core/llm/providers/implementations/cacheLeakDetector.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/cacheLeakDetector.js +12 -7
- package/dist/core/llm/providers/implementations/cacheLeakDetector.js.map +1 -1
- package/dist/core/llm/providers/implementations/openai-response-format-guard.d.ts +17 -0
- package/dist/core/llm/providers/implementations/openai-response-format-guard.d.ts.map +1 -1
- package/dist/core/llm/providers/implementations/openai-response-format-guard.js +31 -0
- package/dist/core/llm/providers/implementations/openai-response-format-guard.js.map +1 -1
- package/dist/core/llm/providers/model-cache-capabilities.d.ts +24 -9
- package/dist/core/llm/providers/model-cache-capabilities.d.ts.map +1 -1
- package/dist/core/llm/providers/model-cache-capabilities.js +59 -44
- package/dist/core/llm/providers/model-cache-capabilities.js.map +1 -1
- package/dist/core/llm/providers/model-effort.d.ts +21 -0
- package/dist/core/llm/providers/model-effort.d.ts.map +1 -1
- package/dist/core/llm/providers/model-effort.js +38 -2
- package/dist/core/llm/providers/model-effort.js.map +1 -1
- package/dist/core/llm/providers/model-forced-tool-choice.d.ts +13 -6
- package/dist/core/llm/providers/model-forced-tool-choice.d.ts.map +1 -1
- package/dist/core/llm/providers/model-forced-tool-choice.js +14 -7
- package/dist/core/llm/providers/model-forced-tool-choice.js.map +1 -1
- package/dist/core/llm/providers/model-thinking.d.ts +64 -13
- package/dist/core/llm/providers/model-thinking.d.ts.map +1 -1
- package/dist/core/llm/providers/model-thinking.js +52 -12
- package/dist/core/llm/providers/model-thinking.js.map +1 -1
- package/dist/core/llm/streaming/StreamingBatcher.d.ts +4 -3
- package/dist/core/llm/streaming/StreamingBatcher.d.ts.map +1 -1
- package/dist/core/llm/streaming/StreamingBatcher.js +8 -3
- package/dist/core/llm/streaming/StreamingBatcher.js.map +1 -1
- package/dist/core/safety/LLMProviderHealthRegistry.d.ts +4 -0
- package/dist/core/safety/LLMProviderHealthRegistry.d.ts.map +1 -1
- package/dist/core/safety/LLMProviderHealthRegistry.js +26 -0
- package/dist/core/safety/LLMProviderHealthRegistry.js.map +1 -1
- package/dist/core/utils/sha256.d.ts +19 -0
- package/dist/core/utils/sha256.d.ts.map +1 -0
- package/dist/core/utils/sha256.js +101 -0
- package/dist/core/utils/sha256.js.map +1 -0
- package/dist/orchestration/pipeline/query/types.js +1 -1
- 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
|
|
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
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
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.
|
|
93
|
-
*
|
|
94
|
-
* - GPT-6
|
|
95
|
-
*
|
|
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
|
|
146
|
-
* the `/v1/chat/completions` endpoint for `modelId`. The GPT-5 family
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
* 2026-07-08 when the codegen frontier fallback chain
|
|
150
|
-
* effort:xhigh orchestrator turn to gpt-5.5
|
|
211
|
+
* Whether OpenAI rejects `reasoning_effort` sent alongside function tools on
|
|
212
|
+
* the `/v1/chat/completions` endpoint for `modelId`. The GPT-5 family does:
|
|
213
|
+
* "Function tools with reasoning_effort are not supported for gpt-5.5 in
|
|
214
|
+
* /v1/chat/completions. Please use /v1/responses instead." (live-observed
|
|
215
|
+
* 2026-07-08, when the codegen frontier fallback chain sent a tool-carrying,
|
|
216
|
+
* effort:xhigh orchestrator turn to gpt-5.5 and the whole build failed).
|
|
151
217
|
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
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
|
-
*
|
|
251
|
-
*
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
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-
|
|
552
|
-
//
|
|
553
|
-
//
|
|
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
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
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
|
-
|
|
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
|
|
617
|
-
|
|
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?.
|
|
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'`
|
|
745
|
-
//
|
|
746
|
-
//
|
|
747
|
-
// GPT-5
|
|
748
|
-
//
|
|
749
|
-
//
|
|
750
|
-
// the model reasons at its default
|
|
1288
|
+
// reject. Map the agentos effort scale onto it so `effort: 'max'` drives
|
|
1289
|
+
// them at their xhigh ceiling. customModelParams (below) can still
|
|
1290
|
+
// override.
|
|
1291
|
+
// GPT-5 and GPT-6 reject `reasoning_effort` with function tools on
|
|
1292
|
+
// chat/completions. shouldRouteToOpenAiResponsesApi sends those calls to
|
|
1293
|
+
// /v1/responses; one that still arrives here (its content cannot go to
|
|
1294
|
+
// Responses) drops the effort, so the model reasons at its default
|
|
1295
|
+
// depth and the tool call succeeds.
|
|
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
|
|
865
|
-
*
|
|
866
|
-
*
|
|
867
|
-
*
|
|
868
|
-
*
|
|
869
|
-
*
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
*
|
|
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:
|
|
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 /
|
|
901
|
-
//
|
|
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
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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
|
|
950
|
-
|
|
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
|
|
983
|
-
|
|
984
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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 *
|
|
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 (
|
|
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
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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));
|