agent-accelerator 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -112
- package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
- package/package.json +14 -9
- package/src/agent/agent.ts +191 -71
- package/src/agent/context.ts +1 -0
- package/src/agent/delegation.ts +61 -12
- package/src/agent/loop.ts +71 -48
- package/src/data/README.md +6 -6
- package/src/index.ts +130 -45
- package/src/models/catalog-cache.ts +60 -9
- package/src/models/catalog.ts +53 -7
- package/src/providers/google.ts +926 -0
- package/src/providers/openai-compat.ts +1147 -0
- package/src/providers/openai.ts +959 -0
- package/src/providers/openrouter-responses.ts +949 -0
- package/src/providers/openrouter.ts +1037 -0
- package/src/{ai-sdk → providers}/registry.ts +43 -55
- package/src/providers.ts +490 -0
- package/src/streaming/sse-parser.ts +6 -4
- package/src/tools/executor.ts +19 -6
- package/src/tools/schema.ts +21 -11
- package/src/types/agent.ts +8 -1
- package/src/types/core.ts +1 -1
- package/src/types/message.ts +5 -0
- package/src/types/model.ts +3 -7
- package/src/types/provider-payloads.ts +2 -84
- package/src/types/tool.ts +6 -0
- package/src/update-models.ts +56 -0
- package/src/utils/cache.ts +1 -1
- package/src/utils/documents.ts +517 -0
- package/src/utils/env.ts +0 -7
- package/src/{ai-sdk → utils}/errors.ts +61 -2
- package/src/utils/headers.ts +10 -20
- package/src/utils/media.ts +5 -2
- package/src/utils/retry.ts +89 -0
- package/src/utils/serialization.ts +15 -0
- package/src/ai-sdk/converters.ts +0 -342
- package/src/ai-sdk/executor.ts +0 -454
- package/src/ai-sdk/index.ts +0 -55
- package/src/ai-sdk/model-provider.ts +0 -303
- package/src/ai-sdk/options.ts +0 -306
- package/src/ai-sdk/provider.ts +0 -415
- package/src/tokens/counter.ts +0 -136
- /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
- /package/{SYSTEM_PROMPT_TOOLS.md → examples/prompts/SYSTEM_PROMPT_TOOLS.md} +0 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI Responses API provider (`POST {baseUrl}/responses`).
|
|
3
|
+
*
|
|
4
|
+
* All OpenAI-specific HTTP, endpoints, headers, auth, request
|
|
5
|
+
* construction, response/SSE parsing, and wire transformations live HERE —
|
|
6
|
+
* never in the canonical layer (`src/providers.ts`).
|
|
7
|
+
*
|
|
8
|
+
* Wire contract: `references/documentations/openai-doc/responses/create.md`
|
|
9
|
+
* (plus `retrieve.md` for the response envelope). Only the most-important
|
|
10
|
+
* subset is implemented: text, instructions, full-history multi-turn,
|
|
11
|
+
* streaming, function tools (+parallel), image/audio/file input, reasoning
|
|
12
|
+
* effort, service_tier flex/priority, prompt_cache_key affinity, usage,
|
|
13
|
+
* finish reasons, switching, abort, errors.
|
|
14
|
+
*
|
|
15
|
+
* Out of scope (never sent): background, conversation, previous_response_id
|
|
16
|
+
* chaining, include, metadata, temperature/top_p, max_output_tokens,
|
|
17
|
+
* truncation, safety_identifier, prompt_cache_options explicit breakpoints,
|
|
18
|
+
* text.format structured output, built-in/MCP tools. Those remain
|
|
19
|
+
* UNSUPPORTED and are handled via warn+drop in the canonical layer where
|
|
20
|
+
* applicable.
|
|
21
|
+
*
|
|
22
|
+
* Notes:
|
|
23
|
+
* - STATELESS by design (like the OpenRouter adapter): every turn sends the
|
|
24
|
+
* full canonical history explicitly with `store:false`. `previous_response_id`,
|
|
25
|
+
* `background`, and `conversation` are never sent so Google↔OpenAI↔OpenRouter
|
|
26
|
+
* switching never depends on server state.
|
|
27
|
+
* - IDs are provider-generated (`resp_…`, `msg_…`, `fc_…` + `call_…`,
|
|
28
|
+
* `rs_…`) and echoed verbatim (`call_id` in `function_call_output`). The
|
|
29
|
+
* `call_${random}` fallback in parsers is a local canonical correlation id
|
|
30
|
+
* only (used when the wire omits both ids); it is never sent to the provider.
|
|
31
|
+
* - Assistant history items omit provider `id`/`status` (same proof as
|
|
32
|
+
* OpenRouter: full-history sends without them complete successfully).
|
|
33
|
+
*/
|
|
34
|
+
import type {
|
|
35
|
+
Provider,
|
|
36
|
+
ProviderId,
|
|
37
|
+
ModelSpec,
|
|
38
|
+
ProviderRequestOptions,
|
|
39
|
+
ProviderGenerateResult,
|
|
40
|
+
ProviderRawData,
|
|
41
|
+
} from "../types/model.ts";
|
|
42
|
+
import type { ProviderContext, ContentPart } from "../types/message.ts";
|
|
43
|
+
import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
|
|
44
|
+
import type { TokenUsage } from "../types/core.ts";
|
|
45
|
+
import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
46
|
+
import { SSEParser } from "../streaming/sse-parser.ts";
|
|
47
|
+
import { AgentResponse } from "../types/response.ts";
|
|
48
|
+
import { getApiKey, getEnv } from "../utils/env.ts";
|
|
49
|
+
import { buildSessionHeaders } from "../utils/headers.ts";
|
|
50
|
+
import { clampCacheKey } from "../utils/cache.ts";
|
|
51
|
+
import { normalizeMediaInput } from "../utils/media.ts";
|
|
52
|
+
import { safeStringify } from "../utils/serialization.ts";
|
|
53
|
+
import { toConciseProviderError, assertModalitiesSupported, assertNoVideoPartsOnResponses } from "../utils/errors.ts";
|
|
54
|
+
import { withRetries } from "../utils/retry.ts";
|
|
55
|
+
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
56
|
+
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
57
|
+
import {
|
|
58
|
+
mapThinkingLevelToOpenAI,
|
|
59
|
+
mapServiceTierToOpenAI,
|
|
60
|
+
applyCacheForOpenAI,
|
|
61
|
+
mapToolChoiceToOpenAI,
|
|
62
|
+
noteProviderTurn,
|
|
63
|
+
parseStreamedToolArguments,
|
|
64
|
+
} from "../providers.ts";
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Responses wire shapes (most-important subset)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
type ResponseContentPart =
|
|
71
|
+
| { type: "input_text"; text: string }
|
|
72
|
+
| { type: "input_image"; image_url: string; detail?: string }
|
|
73
|
+
| { type: "input_file"; file_url: string; filename?: string }
|
|
74
|
+
| { type: "output_text"; text: string; annotations?: unknown[] }
|
|
75
|
+
| { type: "reasoning_text"; text: string }
|
|
76
|
+
| { type: string; [k: string]: unknown };
|
|
77
|
+
|
|
78
|
+
type ResponseInputItem =
|
|
79
|
+
| { type: "message"; role: "user" | "assistant" | "system" | "developer"; content: ResponseContentPart[] }
|
|
80
|
+
| { type: "function_call"; id: string; call_id: string; name: string; arguments: string }
|
|
81
|
+
| { type: "function_call_output"; call_id: string; output: string }
|
|
82
|
+
| { type: string; [k: string]: unknown };
|
|
83
|
+
|
|
84
|
+
interface ResponsesRequestBody {
|
|
85
|
+
model: string;
|
|
86
|
+
input: ResponseInputItem[];
|
|
87
|
+
instructions?: string;
|
|
88
|
+
tools?: Array<Record<string, unknown>>;
|
|
89
|
+
tool_choice?: string | { type: "function"; name: string };
|
|
90
|
+
reasoning?: { effort: string };
|
|
91
|
+
service_tier?: string;
|
|
92
|
+
prompt_cache_key?: string;
|
|
93
|
+
store?: boolean;
|
|
94
|
+
stream?: boolean;
|
|
95
|
+
[k: string]: unknown;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface ResponsesOutputItem {
|
|
99
|
+
type: string;
|
|
100
|
+
id?: string;
|
|
101
|
+
call_id?: string;
|
|
102
|
+
name?: string;
|
|
103
|
+
arguments?: string;
|
|
104
|
+
status?: string;
|
|
105
|
+
role?: string;
|
|
106
|
+
content?: Array<{ type?: string; text?: string; annotations?: unknown[] }>;
|
|
107
|
+
summary?: string[];
|
|
108
|
+
encrypted_content?: string;
|
|
109
|
+
output?: unknown;
|
|
110
|
+
[k: string]: unknown;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface ResponsesObject {
|
|
114
|
+
id?: string;
|
|
115
|
+
object?: string;
|
|
116
|
+
created_at?: number;
|
|
117
|
+
model?: string;
|
|
118
|
+
status?: string;
|
|
119
|
+
output?: ResponsesOutputItem[];
|
|
120
|
+
error?: { message?: string; code?: string | number; type?: string; param?: string } | null;
|
|
121
|
+
usage?: {
|
|
122
|
+
input_tokens?: number;
|
|
123
|
+
output_tokens?: number;
|
|
124
|
+
total_tokens?: number;
|
|
125
|
+
input_tokens_details?: { cached_tokens?: number };
|
|
126
|
+
output_tokens_details?: { reasoning_tokens?: number };
|
|
127
|
+
[k: string]: unknown;
|
|
128
|
+
};
|
|
129
|
+
[k: string]: unknown;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
133
|
+
|
|
134
|
+
/** Internal headers that must never leak onto native REST requests. */
|
|
135
|
+
const INTERNAL_HEADERS = new Set([
|
|
136
|
+
"x-thought-signature-map",
|
|
137
|
+
"x-cached-content-id",
|
|
138
|
+
"x-multimodal-user-content",
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Request building (canonical -> Responses)
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
146
|
+
return (
|
|
147
|
+
options?.baseUrl ||
|
|
148
|
+
options?.env?.["OPENAI_BASE_URL"] ||
|
|
149
|
+
options?.env?.["OPENAI_API_BASE"] ||
|
|
150
|
+
getEnv("OPENAI_BASE_URL") ||
|
|
151
|
+
getEnv("OPENAI_API_BASE") ||
|
|
152
|
+
DEFAULT_BASE_URL
|
|
153
|
+
).replace(/\/+$/, "");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
157
|
+
return options?.apiKey || getApiKey("openai", undefined, options?.env);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Strips ONLY the `openai/` prefix. Bare ids pass through untouched. */
|
|
161
|
+
function cleanModelId(model: string | ModelSpec): string {
|
|
162
|
+
const rawId = typeof model === "string" ? model : model.id;
|
|
163
|
+
return rawId.replace(/^openai\//i, "");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function toOpenAITools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
|
|
167
|
+
if (!tools || tools.length === 0) return undefined;
|
|
168
|
+
return tools.map((t) => ({
|
|
169
|
+
type: "function",
|
|
170
|
+
name: t.name,
|
|
171
|
+
description: t.description,
|
|
172
|
+
parameters: (t.parameters || { type: "object", properties: {} }) as Record<string, unknown>,
|
|
173
|
+
...(t.strict !== undefined ? { strict: t.strict } : {}),
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function contentPartsToBlocks(parts: ContentPart[]): Promise<ResponseContentPart[]> {
|
|
178
|
+
const blocks: ResponseContentPart[] = [];
|
|
179
|
+
for (const part of parts) {
|
|
180
|
+
if (part.type === "text" && part.text) {
|
|
181
|
+
blocks.push({ type: "input_text", text: part.text });
|
|
182
|
+
} else if (
|
|
183
|
+
part.type === "image" ||
|
|
184
|
+
part.type === "audio" ||
|
|
185
|
+
part.type === "video" ||
|
|
186
|
+
part.type === "file"
|
|
187
|
+
) {
|
|
188
|
+
const raw = (part as { image?: unknown; audio?: unknown; video?: unknown; file?: unknown }).image ??
|
|
189
|
+
(part as { audio?: unknown }).audio ??
|
|
190
|
+
(part as { video?: unknown }).video ??
|
|
191
|
+
(part as { file?: unknown }).file;
|
|
192
|
+
// Remote http(s) URLs pass through directly (docs example:
|
|
193
|
+
// `file_url: "https://...pdf"`). Fetch-and-inline would base64-blowup
|
|
194
|
+
// (11MB mp3 → 11,926,995 chars > 1,048,576 `file_url` limit) and break
|
|
195
|
+
// PDFs with "Failed to download file." Let OpenAI fetch instead.
|
|
196
|
+
if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
|
|
197
|
+
if (part.type === "image") {
|
|
198
|
+
blocks.push({ type: "input_image", image_url: raw });
|
|
199
|
+
} else {
|
|
200
|
+
const filename = (part as { filename?: string }).filename;
|
|
201
|
+
blocks.push({
|
|
202
|
+
type: "input_file",
|
|
203
|
+
file_url: raw,
|
|
204
|
+
...(typeof filename === "string" && filename ? { filename } : {}),
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const norm = await normalizeMediaInput(
|
|
210
|
+
raw as string | Uint8Array | ArrayBuffer,
|
|
211
|
+
(part as { mimeType?: string }).mimeType
|
|
212
|
+
);
|
|
213
|
+
// `input_image` is the documented shape; documents/files ride the
|
|
214
|
+
// parity `input_file` shape. Audio/video have no documented user-input
|
|
215
|
+
// shape — they travel as `input_file` with mime intact and the OpenAI
|
|
216
|
+
// verdict surfaces if rejected (catalog modality gate fails fast first).
|
|
217
|
+
// `filename` is forwarded when the canonical part carries one (PDFs).
|
|
218
|
+
if (part.type === "image") {
|
|
219
|
+
blocks.push({ type: "input_image", image_url: norm.dataUrl });
|
|
220
|
+
} else {
|
|
221
|
+
const filename = (part as { filename?: string }).filename;
|
|
222
|
+
blocks.push({
|
|
223
|
+
type: "input_file",
|
|
224
|
+
file_url: norm.dataUrl,
|
|
225
|
+
...(typeof filename === "string" && filename ? { filename } : {}),
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return blocks;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function resultToOutput(result: unknown): string {
|
|
234
|
+
if (typeof result === "string") return result;
|
|
235
|
+
return safeStringify(result);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Builds the FULL explicit history (stateless — no server state, no
|
|
240
|
+
* chaining). Assistant items intentionally carry no provider `id`/`status`:
|
|
241
|
+
* minting ids client-side would violate the provider-generates-ids rule.
|
|
242
|
+
*
|
|
243
|
+
* Cache-prefix stability: ALWAYS the item-array form, even for a single
|
|
244
|
+
* text-only turn (same rationale as the OpenRouter adapter).
|
|
245
|
+
*/
|
|
246
|
+
async function fullHistoryInput(context: ProviderContext): Promise<ResponseInputItem[]> {
|
|
247
|
+
// Map assistant tool_call item ids to pairing ids (call_…) so
|
|
248
|
+
// function_call_output items pair correctly even though the executor keys
|
|
249
|
+
// results by the item id.
|
|
250
|
+
const pairing = new Map<string, string>();
|
|
251
|
+
// Queues of assistant call ids per tool name, consumed in order when a tool
|
|
252
|
+
// message carries a plain string (no part id to pair with).
|
|
253
|
+
const idsByName = new Map<string, string[]>();
|
|
254
|
+
for (const m of context.messages) {
|
|
255
|
+
if (m.role === "assistant" && Array.isArray(m.content)) {
|
|
256
|
+
for (const part of m.content) {
|
|
257
|
+
if (part.type === "tool_call") {
|
|
258
|
+
pairing.set(part.id, part.callId || part.id);
|
|
259
|
+
const queue = idsByName.get(part.name) ?? [];
|
|
260
|
+
queue.push(part.callId || part.id);
|
|
261
|
+
idsByName.set(part.name, queue);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const items: ResponseInputItem[] = [];
|
|
268
|
+
for (const m of context.messages) {
|
|
269
|
+
if (m.role === "system") continue;
|
|
270
|
+
if (m.role === "user") {
|
|
271
|
+
if (typeof m.content === "string") {
|
|
272
|
+
if (m.content) items.push({ type: "message", role: "user", content: [{ type: "input_text", text: m.content }] });
|
|
273
|
+
} else {
|
|
274
|
+
const blocks = await contentPartsToBlocks(m.content);
|
|
275
|
+
if (blocks.length > 0) items.push({ type: "message", role: "user", content: blocks });
|
|
276
|
+
}
|
|
277
|
+
} else if (m.role === "assistant") {
|
|
278
|
+
if (typeof m.content === "string") {
|
|
279
|
+
if (m.content) {
|
|
280
|
+
items.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: m.content }] });
|
|
281
|
+
}
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const texts: string[] = [];
|
|
285
|
+
for (const part of m.content) {
|
|
286
|
+
if (part.type === "tool_call") {
|
|
287
|
+
items.push({
|
|
288
|
+
type: "function_call",
|
|
289
|
+
id: part.id,
|
|
290
|
+
call_id: part.callId || part.id,
|
|
291
|
+
name: part.name,
|
|
292
|
+
arguments: JSON.stringify(part.arguments || {}),
|
|
293
|
+
});
|
|
294
|
+
} else if (part.type === "text" && part.text) {
|
|
295
|
+
texts.push(part.text);
|
|
296
|
+
}
|
|
297
|
+
// Prior-turn reasoning is NOT resent: reasoning items require
|
|
298
|
+
// provider-minted ids that canonical history does not retain.
|
|
299
|
+
}
|
|
300
|
+
if (texts.length > 0) {
|
|
301
|
+
items.push({ type: "message", role: "assistant", content: texts.map((t) => ({ type: "output_text", text: t })) });
|
|
302
|
+
}
|
|
303
|
+
} else if (m.role === "tool") {
|
|
304
|
+
if (typeof m.content === "string") {
|
|
305
|
+
const queue = (m.name && idsByName.get(m.name)) || [];
|
|
306
|
+
items.push({
|
|
307
|
+
type: "function_call_output",
|
|
308
|
+
call_id: queue.shift() || "call_0",
|
|
309
|
+
output: m.content,
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!Array.isArray(m.content)) continue;
|
|
314
|
+
for (const part of m.content) {
|
|
315
|
+
if (part.type === "tool_result") {
|
|
316
|
+
items.push({
|
|
317
|
+
type: "function_call_output",
|
|
318
|
+
call_id: pairing.get(part.id) || part.id,
|
|
319
|
+
output: resultToOutput(part.result),
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return items;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
// Response mapping (Responses -> canonical)
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
function mapUsage(raw?: ResponsesObject["usage"]): TokenUsage {
|
|
333
|
+
const input = raw?.input_tokens ?? 0;
|
|
334
|
+
const output = raw?.output_tokens ?? 0;
|
|
335
|
+
// Canonical invariant: cache hits are a SUBSET of input.
|
|
336
|
+
const cached = Math.min(raw?.input_tokens_details?.cached_tokens ?? 0, input);
|
|
337
|
+
return {
|
|
338
|
+
inputTokens: input,
|
|
339
|
+
outputTokens: output,
|
|
340
|
+
totalTokens: raw?.total_tokens ?? input + output,
|
|
341
|
+
cachedTokens: cached,
|
|
342
|
+
cacheReadTokens: cached,
|
|
343
|
+
cacheWriteTokens: 0,
|
|
344
|
+
thinkingTokens: raw?.output_tokens_details?.reasoning_tokens ?? 0,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function parseArguments(raw: string | undefined): Record<string, unknown> {
|
|
349
|
+
if (!raw) return {};
|
|
350
|
+
try {
|
|
351
|
+
const parsed: unknown = JSON.parse(raw);
|
|
352
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
353
|
+
return parsed as Record<string, unknown>;
|
|
354
|
+
}
|
|
355
|
+
return { raw };
|
|
356
|
+
} catch {
|
|
357
|
+
return { raw };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Trims/collapses assembled thinking parts (no edge-tripling). */
|
|
362
|
+
function normalizeThinkingParts(parts: string[]): string | undefined {
|
|
363
|
+
const cleaned = parts
|
|
364
|
+
.map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
|
|
365
|
+
.filter((p) => p.length > 0);
|
|
366
|
+
return cleaned.length > 0 ? cleaned.join("\n") : undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function parseResponse(
|
|
370
|
+
response: ResponsesObject,
|
|
371
|
+
modelId: string,
|
|
372
|
+
durationMs: number,
|
|
373
|
+
raw: ProviderRawData
|
|
374
|
+
): ProviderGenerateResult {
|
|
375
|
+
if (response.status === "failed") {
|
|
376
|
+
const message =
|
|
377
|
+
response.error && typeof response.error.message === "string" && response.error.message
|
|
378
|
+
? response.error.message
|
|
379
|
+
: "OpenAI response failed";
|
|
380
|
+
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
381
|
+
if (response.error?.code !== undefined) failure["code"] = response.error.code;
|
|
382
|
+
if (typeof response.error?.type === "string") failure["errorType"] = response.error.type;
|
|
383
|
+
throw toConciseProviderError(failure, "openai", modelId);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
let text = "";
|
|
387
|
+
const thinkingParts: string[] = [];
|
|
388
|
+
const toolCalls: ToolCallRecord[] = [];
|
|
389
|
+
|
|
390
|
+
for (const item of response.output ?? []) {
|
|
391
|
+
if (item.type === "message") {
|
|
392
|
+
for (const block of item.content ?? []) {
|
|
393
|
+
if (block.type === "output_text" && block.text) text += block.text;
|
|
394
|
+
}
|
|
395
|
+
} else if (item.type === "reasoning") {
|
|
396
|
+
for (const block of item.content ?? []) {
|
|
397
|
+
if ((block.type === "reasoning_text" || block.type === "text") && block.text) {
|
|
398
|
+
thinkingParts.push(block.text);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
for (const s of item.summary ?? []) {
|
|
402
|
+
if (typeof s === "string" && s) thinkingParts.push(s);
|
|
403
|
+
}
|
|
404
|
+
} else if (item.type === "function_call") {
|
|
405
|
+
const args = parseArguments(item.arguments);
|
|
406
|
+
toolCalls.push({
|
|
407
|
+
id: item.id || item.call_id || `call_${Math.random().toString(36).slice(2, 9)}`,
|
|
408
|
+
callId: item.call_id,
|
|
409
|
+
name: item.name || "unknown",
|
|
410
|
+
arguments: args,
|
|
411
|
+
rawArguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
text,
|
|
418
|
+
// Trim/collapse reasoning assembly (provider summaries can trail with
|
|
419
|
+
// blank lines). Prior-turn reasoning is never resent by this adapter, so
|
|
420
|
+
// this is fully cache-safe.
|
|
421
|
+
thinking: normalizeThinkingParts(thinkingParts),
|
|
422
|
+
thoughtSignature: undefined,
|
|
423
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
424
|
+
usage: mapUsage(response.usage),
|
|
425
|
+
finishReason: toolCalls.length > 0 ? "tool_calls" : response.status === "incomplete" ? "length" : "stop",
|
|
426
|
+
responseId: response.id,
|
|
427
|
+
model: modelId,
|
|
428
|
+
provider: "openai",
|
|
429
|
+
raw,
|
|
430
|
+
durationMs,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function redactedHeaders(headers: Record<string, string>): Record<string, string> {
|
|
435
|
+
const out: Record<string, string> = {};
|
|
436
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
437
|
+
out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function readErrorPayload(bodyText: string): { message: string; code?: string | number; errorType?: string } {
|
|
443
|
+
try {
|
|
444
|
+
const parsed: unknown = JSON.parse(bodyText);
|
|
445
|
+
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
446
|
+
const err = (first as { error?: { message?: string; code?: string | number; type?: string } })?.error;
|
|
447
|
+
if (err && typeof err.message === "string") {
|
|
448
|
+
return {
|
|
449
|
+
message: err.message,
|
|
450
|
+
code: err.code,
|
|
451
|
+
...(typeof err.type === "string" ? { errorType: err.type } : {}),
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
return { message: bodyText.slice(0, 300) };
|
|
455
|
+
} catch {
|
|
456
|
+
return { message: bodyText.slice(0, 300) };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
// Provider
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* OpenAI provider implemented directly on the Responses REST API
|
|
466
|
+
* (`POST {baseUrl}/responses`, streaming on the same endpoint).
|
|
467
|
+
*/
|
|
468
|
+
export class OpenAIResponsesProvider implements Provider {
|
|
469
|
+
readonly id: ProviderId = "openai";
|
|
470
|
+
readonly name = "OpenAI Responses";
|
|
471
|
+
|
|
472
|
+
/** Live catalog view: a constructor snapshot would go stale after refresh. */
|
|
473
|
+
get models(): ModelSpec[] {
|
|
474
|
+
return getModelsForProvider("openai");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
getModel(modelId: string): ModelSpec | undefined {
|
|
478
|
+
const clean = cleanModelId(modelId);
|
|
479
|
+
return (
|
|
480
|
+
getModelFromCatalog(this.id, modelId) ||
|
|
481
|
+
getModelFromCatalog(this.id, clean) ||
|
|
482
|
+
this.models.find((m) => m.id === modelId || m.id === clean) ||
|
|
483
|
+
createGenericModelSpec(this.id, clean)
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
private async buildBody(
|
|
488
|
+
modelId: string,
|
|
489
|
+
context: ProviderContext,
|
|
490
|
+
options: ProviderRequestOptions | undefined,
|
|
491
|
+
sessionId: string | undefined,
|
|
492
|
+
tools: StandardToolDeclaration[] | undefined,
|
|
493
|
+
stream: boolean
|
|
494
|
+
): Promise<ResponsesRequestBody> {
|
|
495
|
+
const { effort } = mapThinkingLevelToOpenAI(options?.thinking?.level);
|
|
496
|
+
const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
|
|
497
|
+
applyCacheForOpenAI(options?.cache, `openai/${modelId}`);
|
|
498
|
+
|
|
499
|
+
const body: ResponsesRequestBody = {
|
|
500
|
+
model: modelId,
|
|
501
|
+
input: await fullHistoryInput(context),
|
|
502
|
+
// Stateless by design: the server defaults store:true, so opt out
|
|
503
|
+
// explicitly. Full history is always sent, so no chaining is needed
|
|
504
|
+
// and provider switching stays trivial.
|
|
505
|
+
store: false,
|
|
506
|
+
...(stream ? { stream: true } : {}),
|
|
507
|
+
};
|
|
508
|
+
if (context.systemPrompt) body.instructions = context.systemPrompt;
|
|
509
|
+
const aiTools = toOpenAITools(tools);
|
|
510
|
+
if (aiTools) body.tools = aiTools;
|
|
511
|
+
const toolChoice = mapToolChoiceToOpenAI(
|
|
512
|
+
options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
|
|
513
|
+
);
|
|
514
|
+
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
515
|
+
if (effort) body.reasoning = { effort };
|
|
516
|
+
if (serviceTier) body.service_tier = serviceTier;
|
|
517
|
+
// Cache affinity: prompt_cache_key replaces the legacy `user` field.
|
|
518
|
+
// (prompt_cache_options explicit breakpoints are out of scope.)
|
|
519
|
+
// OpenAI enforces max 64 chars — clamp defensively so child session ids
|
|
520
|
+
// (`parent-sub-tag-rand`) and user-supplied long ids never 400. Headers
|
|
521
|
+
// are clamped the same way via buildSessionHeaders, keeping affinity
|
|
522
|
+
// consistent.
|
|
523
|
+
if (sessionId) {
|
|
524
|
+
const cacheKey = clampCacheKey(sessionId);
|
|
525
|
+
if (cacheKey) body.prompt_cache_key = cacheKey;
|
|
526
|
+
}
|
|
527
|
+
// Stateless API use: previous_response_id / background / conversation are
|
|
528
|
+
// NEVER sent (server state would break provider-agnostic switching).
|
|
529
|
+
return body;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
private requestInit(
|
|
533
|
+
body: ResponsesRequestBody,
|
|
534
|
+
options: ProviderRequestOptions | undefined,
|
|
535
|
+
apiKey: string,
|
|
536
|
+
sessionId: string | undefined,
|
|
537
|
+
signal?: AbortSignal
|
|
538
|
+
): RequestInit {
|
|
539
|
+
const headers: Record<string, string> = buildSessionHeaders(
|
|
540
|
+
"openai",
|
|
541
|
+
options?.cache,
|
|
542
|
+
options?.headers,
|
|
543
|
+
sessionId
|
|
544
|
+
);
|
|
545
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
546
|
+
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
547
|
+
}
|
|
548
|
+
headers["Content-Type"] = "application/json";
|
|
549
|
+
headers["Authorization"] = `Bearer ${apiKey}`;
|
|
550
|
+
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
private async doFetch(
|
|
554
|
+
url: string,
|
|
555
|
+
body: ResponsesRequestBody,
|
|
556
|
+
options: ProviderRequestOptions | undefined,
|
|
557
|
+
apiKey: string,
|
|
558
|
+
sessionId: string | undefined,
|
|
559
|
+
signal?: AbortSignal
|
|
560
|
+
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
561
|
+
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, signal));
|
|
562
|
+
const text = await res.text();
|
|
563
|
+
const headers: Record<string, string> = {};
|
|
564
|
+
res.headers.forEach((v, k) => {
|
|
565
|
+
headers[k] = v;
|
|
566
|
+
});
|
|
567
|
+
return { status: res.status, statusText: res.statusText, headers, text };
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private throwIfError(
|
|
571
|
+
status: number,
|
|
572
|
+
url: string,
|
|
573
|
+
bodyText: string,
|
|
574
|
+
modelId: string
|
|
575
|
+
): void {
|
|
576
|
+
if (status >= 200 && status < 300) return;
|
|
577
|
+
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
578
|
+
const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
579
|
+
(err as Record<string, unknown>)["statusCode"] = status;
|
|
580
|
+
(err as Record<string, unknown>)["status"] = status;
|
|
581
|
+
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
582
|
+
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
583
|
+
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
584
|
+
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
585
|
+
throw toConciseProviderError(err, "openai", modelId);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async generate(
|
|
589
|
+
model: string | ModelSpec,
|
|
590
|
+
context: ProviderContext,
|
|
591
|
+
options?: ProviderRequestOptions
|
|
592
|
+
): Promise<ProviderGenerateResult> {
|
|
593
|
+
const startTime = Date.now();
|
|
594
|
+
const clean = cleanModelId(model);
|
|
595
|
+
const apiKey = resolveApiKey(options);
|
|
596
|
+
if (!apiKey) {
|
|
597
|
+
throw new Error(
|
|
598
|
+
"[Agent Accelerator] Missing OpenAI API key. Set OPENAI_API_KEY (or OPENAI_BASE_API_KEY) or pass apiKey."
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
// Fail fast before any network call when the catalog knows the model
|
|
602
|
+
// lacks the requested modality (e.g. gpt-5-nano is text+image only, so
|
|
603
|
+
// audio/pdf/video get a one-line `unsupported X input` instead of a
|
|
604
|
+
// confusing `file_url too long` / `Failed to download file.`).
|
|
605
|
+
assertModalitiesSupported(context, "openai", clean);
|
|
606
|
+
assertNoVideoPartsOnResponses(context, "openai", clean);
|
|
607
|
+
const baseUrl = resolveBaseUrl(options);
|
|
608
|
+
const url = `${baseUrl}/responses`;
|
|
609
|
+
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
610
|
+
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
611
|
+
const body = await this.buildBody(clean, context, options, sessionId, tools, false);
|
|
612
|
+
|
|
613
|
+
// Audit trail: record the actual wire headers (session affinity included),
|
|
614
|
+
// redacted. Previously only Content-Type + custom headers were stored,
|
|
615
|
+
// hiding the x-session-id affinity actually sent.
|
|
616
|
+
const auditHeadersRaw: Record<string, string> = {
|
|
617
|
+
...buildSessionHeaders("openai", options?.cache, options?.headers, sessionId),
|
|
618
|
+
"Content-Type": "application/json",
|
|
619
|
+
Authorization: "[REDACTED]",
|
|
620
|
+
};
|
|
621
|
+
for (const k of Object.keys(auditHeadersRaw)) {
|
|
622
|
+
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete auditHeadersRaw[k];
|
|
623
|
+
}
|
|
624
|
+
const auditHeaders = redactedHeaders(auditHeadersRaw);
|
|
625
|
+
const rawRequest = {
|
|
626
|
+
url,
|
|
627
|
+
method: "POST",
|
|
628
|
+
headers: auditHeaders,
|
|
629
|
+
body,
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
const doCall = async (): Promise<ProviderGenerateResult> => {
|
|
633
|
+
const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
|
|
634
|
+
this.throwIfError(res.status, url, res.text, clean);
|
|
635
|
+
let response: ResponsesObject;
|
|
636
|
+
try {
|
|
637
|
+
response = JSON.parse(res.text) as ResponsesObject;
|
|
638
|
+
} catch {
|
|
639
|
+
throw toConciseProviderError(
|
|
640
|
+
Object.assign(new Error("Invalid JSON response from OpenAI Responses API"), {
|
|
641
|
+
statusCode: res.status,
|
|
642
|
+
responseBody: res.text.slice(0, 500),
|
|
643
|
+
url,
|
|
644
|
+
}),
|
|
645
|
+
"openai",
|
|
646
|
+
clean
|
|
647
|
+
);
|
|
648
|
+
}
|
|
649
|
+
// Stateless turns never establish chains, but the session store still
|
|
650
|
+
// records the turn so other providers' switch detection keeps working.
|
|
651
|
+
noteProviderTurn(sessionId, "openai");
|
|
652
|
+
return parseResponse(
|
|
653
|
+
response,
|
|
654
|
+
clean,
|
|
655
|
+
Date.now() - startTime,
|
|
656
|
+
{ request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: response } }
|
|
657
|
+
);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
return await withRetries(doCall, {
|
|
662
|
+
maxRetries: options?.maxRetries,
|
|
663
|
+
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
664
|
+
signal: options?.signal,
|
|
665
|
+
label: { providerId: "openai", modelId: clean },
|
|
666
|
+
});
|
|
667
|
+
} catch (err) {
|
|
668
|
+
if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
|
|
669
|
+
throw err;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
stream(
|
|
674
|
+
model: string | ModelSpec,
|
|
675
|
+
context: ProviderContext,
|
|
676
|
+
options?: ProviderRequestOptions
|
|
677
|
+
): AssistantMessageEventStream {
|
|
678
|
+
const eventStream = new AssistantMessageEventStream();
|
|
679
|
+
const startTime = Date.now();
|
|
680
|
+
const clean = cleanModelId(model);
|
|
681
|
+
|
|
682
|
+
const linked = new AbortController();
|
|
683
|
+
const forwardUserAbort = () => {
|
|
684
|
+
try {
|
|
685
|
+
linked.abort((options?.signal as { reason?: unknown })?.reason);
|
|
686
|
+
} catch {
|
|
687
|
+
try {
|
|
688
|
+
linked.abort();
|
|
689
|
+
} catch {}
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
if (options?.signal?.aborted) forwardUserAbort();
|
|
693
|
+
else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
|
|
694
|
+
const removeStreamCancel = eventStream.onCancel(() => {
|
|
695
|
+
try {
|
|
696
|
+
linked.abort();
|
|
697
|
+
} catch {}
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
(async () => {
|
|
701
|
+
try {
|
|
702
|
+
const apiKey = resolveApiKey(options);
|
|
703
|
+
if (!apiKey) {
|
|
704
|
+
throw new Error(
|
|
705
|
+
"[Agent Accelerator] Missing OpenAI API key. Set OPENAI_API_KEY (or OPENAI_BASE_API_KEY) or pass apiKey."
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
assertModalitiesSupported(context, "openai", clean);
|
|
709
|
+
assertNoVideoPartsOnResponses(context, "openai", clean);
|
|
710
|
+
const baseUrl = resolveBaseUrl(options);
|
|
711
|
+
const url = `${baseUrl}/responses`;
|
|
712
|
+
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
713
|
+
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
714
|
+
const body = await this.buildBody(clean, context, options, sessionId, tools, true);
|
|
715
|
+
|
|
716
|
+
const streamAuditRaw: Record<string, string> = {
|
|
717
|
+
...buildSessionHeaders("openai", options?.cache, options?.headers, sessionId),
|
|
718
|
+
"Content-Type": "application/json",
|
|
719
|
+
Authorization: "[REDACTED]",
|
|
720
|
+
};
|
|
721
|
+
for (const k of Object.keys(streamAuditRaw)) {
|
|
722
|
+
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete streamAuditRaw[k];
|
|
723
|
+
}
|
|
724
|
+
const rawRequest = {
|
|
725
|
+
url,
|
|
726
|
+
method: "POST",
|
|
727
|
+
headers: redactedHeaders(streamAuditRaw),
|
|
728
|
+
body,
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
732
|
+
if (!res.ok || !res.body) {
|
|
733
|
+
const text = !res.ok ? await res.text().catch(() => "") : "";
|
|
734
|
+
if (!res.ok) this.throwIfError(res.status, url, text, clean);
|
|
735
|
+
throw toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean);
|
|
736
|
+
}
|
|
737
|
+
const responseHeaders: Record<string, string> = {};
|
|
738
|
+
res.headers.forEach((v, k) => {
|
|
739
|
+
responseHeaders[k] = v;
|
|
740
|
+
});
|
|
741
|
+
const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
742
|
+
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
743
|
+
|
|
744
|
+
const parser = new SSEParser();
|
|
745
|
+
const reader = res.body.getReader();
|
|
746
|
+
const decoder = new TextDecoder();
|
|
747
|
+
let text = "";
|
|
748
|
+
let thinking = "";
|
|
749
|
+
// Tool calls keyed by output_index: { itemId, callId, name, startArgs, deltaArgs }.
|
|
750
|
+
const calls = new Map<number, { itemId: string; callId: string; name: string; startArgs: string; deltaArgs: string }>();
|
|
751
|
+
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
752
|
+
let finishReason = "stop";
|
|
753
|
+
let responseId: string | undefined;
|
|
754
|
+
let completedBody: unknown = undefined;
|
|
755
|
+
let aborted = false;
|
|
756
|
+
linked.signal.addEventListener(
|
|
757
|
+
"abort",
|
|
758
|
+
() => {
|
|
759
|
+
aborted = true;
|
|
760
|
+
try {
|
|
761
|
+
void reader.cancel();
|
|
762
|
+
} catch {}
|
|
763
|
+
},
|
|
764
|
+
{ once: true }
|
|
765
|
+
);
|
|
766
|
+
|
|
767
|
+
const handleMessage = (data: string): void => {
|
|
768
|
+
if (!data || data === "[DONE]") return;
|
|
769
|
+
let msg: Record<string, unknown>;
|
|
770
|
+
try {
|
|
771
|
+
msg = JSON.parse(data) as Record<string, unknown>;
|
|
772
|
+
} catch {
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const type = msg["type"] as string;
|
|
776
|
+
if (!type) return;
|
|
777
|
+
if (type === "error") {
|
|
778
|
+
const errObj = (msg["error"] as { message?: string; code?: string | number; type?: string }) ?? {};
|
|
779
|
+
const message =
|
|
780
|
+
typeof errObj.message === "string" && errObj.message ? errObj.message : "OpenAI streaming error";
|
|
781
|
+
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
782
|
+
if (errObj.code !== undefined) failure["code"] = errObj.code;
|
|
783
|
+
if (typeof errObj.type === "string") failure["errorType"] = errObj.type;
|
|
784
|
+
failure["url"] = url;
|
|
785
|
+
throw toConciseProviderError(failure, "openai", clean);
|
|
786
|
+
}
|
|
787
|
+
if (type === "response.created" || type === "response.in_progress") {
|
|
788
|
+
const response = (msg["response"] as { id?: string }) ?? {};
|
|
789
|
+
if (response.id) responseId = response.id;
|
|
790
|
+
} else if (type === "response.output_text.delta" && typeof msg["delta"] === "string") {
|
|
791
|
+
text += msg["delta"] as string;
|
|
792
|
+
eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
|
|
793
|
+
} else if (
|
|
794
|
+
(type === "response.reasoning_text.delta" || type === "response.reasoning.delta") &&
|
|
795
|
+
typeof msg["delta"] === "string"
|
|
796
|
+
) {
|
|
797
|
+
thinking += msg["delta"] as string;
|
|
798
|
+
eventStream.push({ type: "thinking_delta", thinkingDelta: msg["delta"] as string, partialThinking: thinking });
|
|
799
|
+
} else if (type === "response.output_item.added") {
|
|
800
|
+
const index = (msg["output_index"] as number) ?? 0;
|
|
801
|
+
const item = (msg["item"] as Record<string, unknown>) ?? {};
|
|
802
|
+
if (item["type"] === "function_call") {
|
|
803
|
+
const args = item["arguments"];
|
|
804
|
+
calls.set(index, {
|
|
805
|
+
itemId: (item["id"] as string) || "",
|
|
806
|
+
callId: (item["call_id"] as string) || "",
|
|
807
|
+
name: (item["name"] as string) || "unknown",
|
|
808
|
+
startArgs: typeof args === "string" ? args : args ? JSON.stringify(args) : "",
|
|
809
|
+
deltaArgs: "",
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
} else if (type === "response.function_call_arguments.delta") {
|
|
813
|
+
const index = (msg["output_index"] as number) ?? 0;
|
|
814
|
+
const chunk = (msg["delta"] as string) ?? "";
|
|
815
|
+
const entry = calls.get(index);
|
|
816
|
+
if (entry && typeof chunk === "string") entry.deltaArgs += chunk;
|
|
817
|
+
} else if (type === "response.function_call_arguments.done") {
|
|
818
|
+
const index = (msg["output_index"] as number) ?? 0;
|
|
819
|
+
const entry = calls.get(index);
|
|
820
|
+
// The done event carries the COMPLETE arguments string — it wins
|
|
821
|
+
// over accumulated deltas (same candidate strategy as elsewhere).
|
|
822
|
+
if (entry && typeof msg["arguments"] === "string") {
|
|
823
|
+
entry.startArgs = "";
|
|
824
|
+
entry.deltaArgs = msg["arguments"] as string;
|
|
825
|
+
}
|
|
826
|
+
} else if (type === "response.completed" || type === "response.failed" || type === "response.done") {
|
|
827
|
+
const response = (msg["response"] as ResponsesObject) ?? {};
|
|
828
|
+
completedBody = msg["response"];
|
|
829
|
+
if (response.id) responseId = response.id;
|
|
830
|
+
if (response.usage) usage = mapUsage(response.usage);
|
|
831
|
+
if (type === "response.failed" || response.status === "failed") {
|
|
832
|
+
const message =
|
|
833
|
+
(response.error && typeof response.error.message === "string" && response.error.message) ||
|
|
834
|
+
"OpenAI response failed";
|
|
835
|
+
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
836
|
+
if (response.error?.code !== undefined) failure["code"] = response.error.code;
|
|
837
|
+
if (typeof response.error?.type === "string") failure["errorType"] = response.error.type;
|
|
838
|
+
throw toConciseProviderError(failure, "openai", clean);
|
|
839
|
+
}
|
|
840
|
+
// Non-streaming maps `incomplete` → `length` (max tokens). Streaming
|
|
841
|
+
// must do the same — otherwise truncated turns misreport `stop`.
|
|
842
|
+
if (response.status === "incomplete") finishReason = "length";
|
|
843
|
+
// Merge any full tool items delivered at completion (authoritative
|
|
844
|
+
// when present) so nothing depends solely on delta assembly.
|
|
845
|
+
for (const item of response.output ?? []) {
|
|
846
|
+
if (item.type !== "function_call") continue;
|
|
847
|
+
const key = [...calls.entries()].find(
|
|
848
|
+
([, c]) => (c.itemId && c.itemId === item.id) || (c.callId && c.callId === item.call_id)
|
|
849
|
+
)?.[0];
|
|
850
|
+
if (key !== undefined && typeof item.arguments === "string") {
|
|
851
|
+
const entry = calls.get(key)!;
|
|
852
|
+
entry.itemId = item.id || entry.itemId;
|
|
853
|
+
entry.callId = item.call_id || entry.callId;
|
|
854
|
+
entry.name = item.name || entry.name;
|
|
855
|
+
entry.startArgs = "";
|
|
856
|
+
entry.deltaArgs = item.arguments;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
// Merge completed reasoning (summary often arrives only here, with
|
|
860
|
+
// no preceding `reasoning_text.delta`). Append only text not
|
|
861
|
+
// already streamed to avoid doubling deltas + completed content.
|
|
862
|
+
for (const item of response.output ?? []) {
|
|
863
|
+
if (item.type !== "reasoning") continue;
|
|
864
|
+
const parts: string[] = [];
|
|
865
|
+
for (const block of item.content ?? []) {
|
|
866
|
+
if ((block.type === "reasoning_text" || block.type === "text") && block.text) {
|
|
867
|
+
parts.push(block.text);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
for (const s of item.summary ?? []) {
|
|
871
|
+
if (typeof s === "string" && s) parts.push(s);
|
|
872
|
+
}
|
|
873
|
+
for (const p of parts) {
|
|
874
|
+
if (p && !thinking.includes(p)) {
|
|
875
|
+
thinking += (thinking ? "\n" : "") + p;
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
eventStream.push({ type: "usage", usage });
|
|
880
|
+
}
|
|
881
|
+
};
|
|
882
|
+
|
|
883
|
+
while (true) {
|
|
884
|
+
if (linked.signal.aborted || eventStream.isCancelled()) {
|
|
885
|
+
try {
|
|
886
|
+
await reader.cancel();
|
|
887
|
+
} catch {}
|
|
888
|
+
throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
|
|
889
|
+
}
|
|
890
|
+
const { done, value } = await reader.read();
|
|
891
|
+
if (done) break;
|
|
892
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
893
|
+
for (const m of parser.feed(chunk)) handleMessage(m.data);
|
|
894
|
+
}
|
|
895
|
+
for (const m of parser.flush()) handleMessage(m.data);
|
|
896
|
+
try {
|
|
897
|
+
reader.releaseLock();
|
|
898
|
+
} catch {}
|
|
899
|
+
|
|
900
|
+
if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
|
|
901
|
+
throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const toolCalls: ToolCallRecord[] = [];
|
|
905
|
+
for (const c of calls.values()) {
|
|
906
|
+
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
907
|
+
const record: ToolCallRecord = {
|
|
908
|
+
id: c.itemId || c.callId || `call_${Math.random().toString(36).slice(2, 9)}`,
|
|
909
|
+
callId: c.callId || undefined,
|
|
910
|
+
name: c.name,
|
|
911
|
+
arguments: args,
|
|
912
|
+
rawArguments: c.startArgs + c.deltaArgs,
|
|
913
|
+
};
|
|
914
|
+
toolCalls.push(record);
|
|
915
|
+
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
noteProviderTurn(sessionId, "openai");
|
|
919
|
+
if (toolCalls.length > 0) finishReason = "tool_calls";
|
|
920
|
+
|
|
921
|
+
const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
922
|
+
const finalResponse = new AgentResponse({
|
|
923
|
+
text,
|
|
924
|
+
thinking: cleanThinking || undefined,
|
|
925
|
+
thoughtSignature: undefined,
|
|
926
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
927
|
+
usage,
|
|
928
|
+
finishReason,
|
|
929
|
+
responseId,
|
|
930
|
+
model: clean,
|
|
931
|
+
provider: "openai",
|
|
932
|
+
raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
|
|
933
|
+
durationMs: Date.now() - startTime,
|
|
934
|
+
});
|
|
935
|
+
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|
|
936
|
+
eventStream.end(finalResponse);
|
|
937
|
+
} catch (err: unknown) {
|
|
938
|
+
const raw = err instanceof Error ? err : new Error(String(err));
|
|
939
|
+
const isAbort =
|
|
940
|
+
linked.signal.aborted ||
|
|
941
|
+
eventStream.isCancelled() ||
|
|
942
|
+
(raw as { name?: string }).name === "AbortError" ||
|
|
943
|
+
/abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
|
|
944
|
+
eventStream.fail(
|
|
945
|
+
isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
|
|
946
|
+
);
|
|
947
|
+
} finally {
|
|
948
|
+
try {
|
|
949
|
+
options?.signal?.removeEventListener("abort", forwardUserAbort);
|
|
950
|
+
} catch {}
|
|
951
|
+
try {
|
|
952
|
+
removeStreamCancel();
|
|
953
|
+
} catch {}
|
|
954
|
+
}
|
|
955
|
+
})();
|
|
956
|
+
|
|
957
|
+
return eventStream;
|
|
958
|
+
}
|
|
959
|
+
}
|