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.
Files changed (45) hide show
  1. package/README.md +83 -112
  2. package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
  3. package/package.json +14 -9
  4. package/src/agent/agent.ts +191 -71
  5. package/src/agent/context.ts +1 -0
  6. package/src/agent/delegation.ts +61 -12
  7. package/src/agent/loop.ts +71 -48
  8. package/src/data/README.md +6 -6
  9. package/src/index.ts +130 -45
  10. package/src/models/catalog-cache.ts +60 -9
  11. package/src/models/catalog.ts +53 -7
  12. package/src/providers/google.ts +926 -0
  13. package/src/providers/openai-compat.ts +1147 -0
  14. package/src/providers/openai.ts +959 -0
  15. package/src/providers/openrouter-responses.ts +949 -0
  16. package/src/providers/openrouter.ts +1037 -0
  17. package/src/{ai-sdk → providers}/registry.ts +43 -55
  18. package/src/providers.ts +490 -0
  19. package/src/streaming/sse-parser.ts +6 -4
  20. package/src/tools/executor.ts +19 -6
  21. package/src/tools/schema.ts +21 -11
  22. package/src/types/agent.ts +8 -1
  23. package/src/types/core.ts +1 -1
  24. package/src/types/message.ts +5 -0
  25. package/src/types/model.ts +3 -7
  26. package/src/types/provider-payloads.ts +2 -84
  27. package/src/types/tool.ts +6 -0
  28. package/src/update-models.ts +56 -0
  29. package/src/utils/cache.ts +1 -1
  30. package/src/utils/documents.ts +517 -0
  31. package/src/utils/env.ts +0 -7
  32. package/src/{ai-sdk → utils}/errors.ts +61 -2
  33. package/src/utils/headers.ts +10 -20
  34. package/src/utils/media.ts +5 -2
  35. package/src/utils/retry.ts +89 -0
  36. package/src/utils/serialization.ts +15 -0
  37. package/src/ai-sdk/converters.ts +0 -342
  38. package/src/ai-sdk/executor.ts +0 -454
  39. package/src/ai-sdk/index.ts +0 -55
  40. package/src/ai-sdk/model-provider.ts +0 -303
  41. package/src/ai-sdk/options.ts +0 -306
  42. package/src/ai-sdk/provider.ts +0 -415
  43. package/src/tokens/counter.ts +0 -136
  44. /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
  45. /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
+ }