agent-accelerator 0.3.0 → 0.4.5
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/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
package/src/providers/openai.ts
CHANGED
|
@@ -46,14 +46,27 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
|
46
46
|
import { SSEParser } from "../streaming/sse-parser.ts";
|
|
47
47
|
import { AgentResponse } from "../types/response.ts";
|
|
48
48
|
import { getApiKey, getEnv } from "../utils/env.ts";
|
|
49
|
-
import { buildSessionHeaders } from "../utils/headers.ts";
|
|
49
|
+
import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
|
|
50
50
|
import { clampCacheKey } from "../utils/cache.ts";
|
|
51
51
|
import { normalizeMediaInput } from "../utils/media.ts";
|
|
52
52
|
import { safeStringify } from "../utils/serialization.ts";
|
|
53
53
|
import { toConciseProviderError, assertModalitiesSupported, assertNoVideoPartsOnResponses } from "../utils/errors.ts";
|
|
54
54
|
import { withRetries } from "../utils/retry.ts";
|
|
55
|
+
import {
|
|
56
|
+
parseArguments,
|
|
57
|
+
normalizeThinkingParts,
|
|
58
|
+
newToolCallId,
|
|
59
|
+
redactedHeaders,
|
|
60
|
+
readErrorPayload,
|
|
61
|
+
incompleteStreamError,
|
|
62
|
+
hasTruncatedToolArguments,
|
|
63
|
+
combinedSignal,
|
|
64
|
+
timeoutFor,
|
|
65
|
+
hasAuthHeader,
|
|
66
|
+
clampToolCallId,
|
|
67
|
+
} from "./shared.ts";
|
|
55
68
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
56
|
-
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
69
|
+
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
57
70
|
import {
|
|
58
71
|
mapThinkingLevelToOpenAI,
|
|
59
72
|
mapServiceTierToOpenAI,
|
|
@@ -61,6 +74,10 @@ import {
|
|
|
61
74
|
mapToolChoiceToOpenAI,
|
|
62
75
|
noteProviderTurn,
|
|
63
76
|
parseStreamedToolArguments,
|
|
77
|
+
mapOutputToOpenAI,
|
|
78
|
+
normalizeStructuredOutput,
|
|
79
|
+
augmentSystemPromptWithStructuredOutput,
|
|
80
|
+
emitProviderWarning,
|
|
64
81
|
} from "../providers.ts";
|
|
65
82
|
|
|
66
83
|
// ---------------------------------------------------------------------------
|
|
@@ -92,6 +109,7 @@ interface ResponsesRequestBody {
|
|
|
92
109
|
prompt_cache_key?: string;
|
|
93
110
|
store?: boolean;
|
|
94
111
|
stream?: boolean;
|
|
112
|
+
text?: { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
95
113
|
[k: string]: unknown;
|
|
96
114
|
}
|
|
97
115
|
|
|
@@ -131,6 +149,12 @@ interface ResponsesObject {
|
|
|
131
149
|
|
|
132
150
|
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
133
151
|
|
|
152
|
+
/** Q-18: Responses transport never echoes thought signatures (no wire field).
|
|
153
|
+
* Local helper documents the namespacing decision per provider file. */
|
|
154
|
+
function withProviderSignature(_sig: string | undefined): { thoughtSignature?: undefined } {
|
|
155
|
+
return {};
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
/** Internal headers that must never leak onto native REST requests. */
|
|
135
159
|
const INTERNAL_HEADERS = new Set([
|
|
136
160
|
"x-thought-signature-map",
|
|
@@ -154,7 +178,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
154
178
|
}
|
|
155
179
|
|
|
156
180
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
157
|
-
|
|
181
|
+
const key = getApiKey("openai", options?.apiKey, options?.env);
|
|
182
|
+
if (key) return key;
|
|
183
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
184
|
+
return undefined;
|
|
158
185
|
}
|
|
159
186
|
|
|
160
187
|
/** Strips ONLY the `openai/` prefix. Bare ids pass through untouched. */
|
|
@@ -235,6 +262,20 @@ function resultToOutput(result: unknown): string {
|
|
|
235
262
|
return safeStringify(result);
|
|
236
263
|
}
|
|
237
264
|
|
|
265
|
+
function normalizeResponsesIdPart(part: string): string {
|
|
266
|
+
const sanitized = String(part ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
267
|
+
const truncated = sanitized.length > 64 ? sanitized.slice(0, 64) : sanitized;
|
|
268
|
+
return truncated.replace(/_+$/, "") || "fc_unknown";
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function normalizeResponsesItemId(itemId: string): string {
|
|
272
|
+
let norm = normalizeResponsesIdPart(itemId);
|
|
273
|
+
if (!norm.startsWith("fc_")) {
|
|
274
|
+
norm = normalizeResponsesIdPart(`fc_${norm}`);
|
|
275
|
+
}
|
|
276
|
+
return norm;
|
|
277
|
+
}
|
|
278
|
+
|
|
238
279
|
/**
|
|
239
280
|
* Builds the FULL explicit history (stateless — no server state, no
|
|
240
281
|
* chaining). Assistant items intentionally carry no provider `id`/`status`:
|
|
@@ -284,10 +325,25 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
284
325
|
const texts: string[] = [];
|
|
285
326
|
for (const part of m.content) {
|
|
286
327
|
if (part.type === "tool_call") {
|
|
328
|
+
// Cross-provider pipe IDs (`callId|itemId`) are split and
|
|
329
|
+
// normalized (64-char, `fc_` prefix) so replay never 400s.
|
|
330
|
+
const rawId = String(part.id ?? "");
|
|
331
|
+
const rawCallId = String(part.callId ?? part.id ?? "");
|
|
332
|
+
let itemId = rawId;
|
|
333
|
+
let callId = rawCallId;
|
|
334
|
+
if (rawId.includes("|")) {
|
|
335
|
+
const [c, i] = rawId.split("|");
|
|
336
|
+
if (c) callId = c;
|
|
337
|
+
if (i) itemId = i;
|
|
338
|
+
}
|
|
339
|
+
if (callId.includes("|")) {
|
|
340
|
+
const [c] = callId.split("|");
|
|
341
|
+
if (c) callId = c;
|
|
342
|
+
}
|
|
287
343
|
items.push({
|
|
288
344
|
type: "function_call",
|
|
289
|
-
id:
|
|
290
|
-
call_id:
|
|
345
|
+
id: normalizeResponsesItemId(itemId),
|
|
346
|
+
call_id: normalizeResponsesIdPart(callId),
|
|
291
347
|
name: part.name,
|
|
292
348
|
arguments: JSON.stringify(part.arguments || {}),
|
|
293
349
|
});
|
|
@@ -303,9 +359,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
303
359
|
} else if (m.role === "tool") {
|
|
304
360
|
if (typeof m.content === "string") {
|
|
305
361
|
const queue = (m.name && idsByName.get(m.name)) || [];
|
|
362
|
+
const rawPair = queue.shift() || newToolCallId();
|
|
363
|
+
const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
|
|
306
364
|
items.push({
|
|
307
365
|
type: "function_call_output",
|
|
308
|
-
call_id:
|
|
366
|
+
call_id: normalizeResponsesIdPart(rawCall),
|
|
309
367
|
output: m.content,
|
|
310
368
|
});
|
|
311
369
|
continue;
|
|
@@ -313,9 +371,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
313
371
|
if (!Array.isArray(m.content)) continue;
|
|
314
372
|
for (const part of m.content) {
|
|
315
373
|
if (part.type === "tool_result") {
|
|
374
|
+
const rawPair = pairing.get(part.id) || part.id;
|
|
375
|
+
const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
|
|
316
376
|
items.push({
|
|
317
377
|
type: "function_call_output",
|
|
318
|
-
call_id:
|
|
378
|
+
call_id: normalizeResponsesIdPart(rawCall),
|
|
319
379
|
output: resultToOutput(part.result),
|
|
320
380
|
});
|
|
321
381
|
}
|
|
@@ -345,27 +405,6 @@ function mapUsage(raw?: ResponsesObject["usage"]): TokenUsage {
|
|
|
345
405
|
};
|
|
346
406
|
}
|
|
347
407
|
|
|
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
408
|
function parseResponse(
|
|
370
409
|
response: ResponsesObject,
|
|
371
410
|
modelId: string,
|
|
@@ -384,6 +423,7 @@ function parseResponse(
|
|
|
384
423
|
}
|
|
385
424
|
|
|
386
425
|
let text = "";
|
|
426
|
+
let refusalText = "";
|
|
387
427
|
const thinkingParts: string[] = [];
|
|
388
428
|
const toolCalls: ToolCallRecord[] = [];
|
|
389
429
|
|
|
@@ -391,6 +431,15 @@ function parseResponse(
|
|
|
391
431
|
if (item.type === "message") {
|
|
392
432
|
for (const block of item.content ?? []) {
|
|
393
433
|
if (block.type === "output_text" && block.text) text += block.text;
|
|
434
|
+
else if (block.type === "refusal" && block.text) {
|
|
435
|
+
text += block.text;
|
|
436
|
+
refusalText += block.text;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const itemRefusal = (item as { refusal?: unknown }).refusal;
|
|
440
|
+
if (typeof itemRefusal === "string" && itemRefusal) {
|
|
441
|
+
text += itemRefusal;
|
|
442
|
+
refusalText += itemRefusal;
|
|
394
443
|
}
|
|
395
444
|
} else if (item.type === "reasoning") {
|
|
396
445
|
for (const block of item.content ?? []) {
|
|
@@ -404,7 +453,7 @@ function parseResponse(
|
|
|
404
453
|
} else if (item.type === "function_call") {
|
|
405
454
|
const args = parseArguments(item.arguments);
|
|
406
455
|
toolCalls.push({
|
|
407
|
-
id: item.id || item.call_id ||
|
|
456
|
+
id: item.id || item.call_id || newToolCallId(),
|
|
408
457
|
callId: item.call_id,
|
|
409
458
|
name: item.name || "unknown",
|
|
410
459
|
arguments: args,
|
|
@@ -413,6 +462,17 @@ function parseResponse(
|
|
|
413
462
|
}
|
|
414
463
|
}
|
|
415
464
|
|
|
465
|
+
// Q-23: `incomplete` -> length (content-filter truncations surface as
|
|
466
|
+
// refusal when refusal text is present).
|
|
467
|
+
const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
|
|
468
|
+
const finishReason =
|
|
469
|
+
toolCalls.length > 0
|
|
470
|
+
? "tool_calls"
|
|
471
|
+
: refusalText || incompleteReason === "content_filter"
|
|
472
|
+
? "refusal"
|
|
473
|
+
: response.status === "incomplete"
|
|
474
|
+
? "length"
|
|
475
|
+
: "stop";
|
|
416
476
|
return {
|
|
417
477
|
text,
|
|
418
478
|
// Trim/collapse reasoning assembly (provider summaries can trail with
|
|
@@ -422,7 +482,7 @@ function parseResponse(
|
|
|
422
482
|
thoughtSignature: undefined,
|
|
423
483
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
424
484
|
usage: mapUsage(response.usage),
|
|
425
|
-
finishReason
|
|
485
|
+
finishReason,
|
|
426
486
|
responseId: response.id,
|
|
427
487
|
model: modelId,
|
|
428
488
|
provider: "openai",
|
|
@@ -431,32 +491,6 @@ function parseResponse(
|
|
|
431
491
|
};
|
|
432
492
|
}
|
|
433
493
|
|
|
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
494
|
// ---------------------------------------------------------------------------
|
|
461
495
|
// Provider
|
|
462
496
|
// ---------------------------------------------------------------------------
|
|
@@ -496,16 +530,38 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
496
530
|
const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
|
|
497
531
|
applyCacheForOpenAI(options?.cache, `openai/${modelId}`);
|
|
498
532
|
|
|
533
|
+
// Structured outputs: native `text.format` when supported, else emulated
|
|
534
|
+
// via instructions (catalog `structured_output: false` would 400).
|
|
535
|
+
let effectiveSystemPrompt = context.systemPrompt;
|
|
536
|
+
let outputEmulated = false;
|
|
537
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
538
|
+
try {
|
|
539
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
540
|
+
} catch {
|
|
541
|
+
outputSpec = undefined;
|
|
542
|
+
}
|
|
543
|
+
if (outputSpec && !modelSupportsStructuredOutput("openai", modelId)) {
|
|
544
|
+
outputEmulated = true;
|
|
545
|
+
effectiveSystemPrompt = augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec);
|
|
546
|
+
emitProviderWarning({
|
|
547
|
+
provider: "openai",
|
|
548
|
+
capability: "structured output",
|
|
549
|
+
requested: `${outputSpec.name} (openai/${modelId})`,
|
|
550
|
+
reason: "the catalog reports structured_output: false for this model, so native text.format is unsupported.",
|
|
551
|
+
fallback: "emulated JSON instruction + client validation (no text.format is sent)",
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
|
|
499
555
|
const body: ResponsesRequestBody = {
|
|
500
556
|
model: modelId,
|
|
501
|
-
input: await fullHistoryInput(context),
|
|
557
|
+
input: await fullHistoryInput({ ...context, systemPrompt: effectiveSystemPrompt }),
|
|
502
558
|
// Stateless by design: the server defaults store:true, so opt out
|
|
503
559
|
// explicitly. Full history is always sent, so no chaining is needed
|
|
504
560
|
// and provider switching stays trivial.
|
|
505
561
|
store: false,
|
|
506
562
|
...(stream ? { stream: true } : {}),
|
|
507
563
|
};
|
|
508
|
-
if (
|
|
564
|
+
if (effectiveSystemPrompt) body.instructions = effectiveSystemPrompt;
|
|
509
565
|
const aiTools = toOpenAITools(tools);
|
|
510
566
|
if (aiTools) body.tools = aiTools;
|
|
511
567
|
const toolChoice = mapToolChoiceToOpenAI(
|
|
@@ -514,8 +570,17 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
514
570
|
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
515
571
|
if (effort) body.reasoning = { effort };
|
|
516
572
|
if (serviceTier) body.service_tier = serviceTier;
|
|
573
|
+
// Structured outputs: Responses `text.format{type:json_schema,name,schema,strict}`.
|
|
574
|
+
// Skipped when emulated (instruction already in `instructions`).
|
|
575
|
+
if (outputSpec && !outputEmulated) {
|
|
576
|
+
try {
|
|
577
|
+
const mapped = mapOutputToOpenAI(outputSpec);
|
|
578
|
+
if (mapped) body.text = { format: mapped.format };
|
|
579
|
+
} catch {
|
|
580
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
581
|
+
}
|
|
582
|
+
}
|
|
517
583
|
// Cache affinity: prompt_cache_key replaces the legacy `user` field.
|
|
518
|
-
// (prompt_cache_options explicit breakpoints are out of scope.)
|
|
519
584
|
// OpenAI enforces max 64 chars — clamp defensively so child session ids
|
|
520
585
|
// (`parent-sub-tag-rand`) and user-supplied long ids never 400. Headers
|
|
521
586
|
// are clamped the same way via buildSessionHeaders, keeping affinity
|
|
@@ -524,6 +589,29 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
524
589
|
const cacheKey = clampCacheKey(sessionId);
|
|
525
590
|
if (cacheKey) body.prompt_cache_key = cacheKey;
|
|
526
591
|
}
|
|
592
|
+
// Long retention: `prompt_cache_retention: "24h"` when the catalog
|
|
593
|
+
// supports it (pi api/openai-responses.ts:97 rule). Other explicit modes
|
|
594
|
+
// stay warn+drop via applyCacheForOpenAI above.
|
|
595
|
+
if (options?.cache?.retention === "long") {
|
|
596
|
+
let supportsLong = true;
|
|
597
|
+
try {
|
|
598
|
+
const spec = getModelFromCatalog("openai", modelId);
|
|
599
|
+
if (spec && typeof spec.capabilities?.supportsLongCacheRetention === "boolean") {
|
|
600
|
+
supportsLong = spec.capabilities.supportsLongCacheRetention;
|
|
601
|
+
}
|
|
602
|
+
} catch {}
|
|
603
|
+
if (supportsLong) {
|
|
604
|
+
(body as Record<string, unknown>)["prompt_cache_retention"] = "24h";
|
|
605
|
+
} else {
|
|
606
|
+
emitProviderWarning({
|
|
607
|
+
provider: "openai",
|
|
608
|
+
capability: "cache retention",
|
|
609
|
+
requested: `long (openai/${modelId})`,
|
|
610
|
+
reason: "the catalog reports no long-cache support for this model.",
|
|
611
|
+
fallback: "default caching with prompt_cache_key affinity (no retention payload is sent)",
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
527
615
|
// Stateless API use: previous_response_id / background / conversation are
|
|
528
616
|
// NEVER sent (server state would break provider-agnostic switching).
|
|
529
617
|
return body;
|
|
@@ -545,8 +633,11 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
545
633
|
for (const [k, v] of Object.entries(headers)) {
|
|
546
634
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
547
635
|
}
|
|
548
|
-
|
|
549
|
-
|
|
636
|
+
// Case-insensitive merge so a lowercase `authorization` custom header
|
|
637
|
+
// never coexists with the canonical bearer. Proxy flows
|
|
638
|
+
// (`apiKey === "unused"`) preserve custom auth instead of overwriting.
|
|
639
|
+
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
640
|
+
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
550
641
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
551
642
|
}
|
|
552
643
|
|
|
@@ -558,13 +649,18 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
558
649
|
sessionId: string | undefined,
|
|
559
650
|
signal?: AbortSignal
|
|
560
651
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
headers
|
|
566
|
-
|
|
567
|
-
|
|
652
|
+
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
653
|
+
try {
|
|
654
|
+
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
655
|
+
const text = await res.text();
|
|
656
|
+
const headers: Record<string, string> = {};
|
|
657
|
+
res.headers.forEach((v, k) => {
|
|
658
|
+
headers[k] = v;
|
|
659
|
+
});
|
|
660
|
+
return { status: res.status, statusText: res.statusText, headers, text };
|
|
661
|
+
} finally {
|
|
662
|
+
cleanup();
|
|
663
|
+
}
|
|
568
664
|
}
|
|
569
665
|
|
|
570
666
|
private throwIfError(
|
|
@@ -574,14 +670,23 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
574
670
|
modelId: string
|
|
575
671
|
): void {
|
|
576
672
|
if (status >= 200 && status < 300) return;
|
|
577
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
578
|
-
const
|
|
673
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
674
|
+
const hint =
|
|
675
|
+
status === 401
|
|
676
|
+
? " Check OPENAI_API_KEY (no quotes/spaces, correct provider), OPENAI_BASE_URL, and that the key is loaded (bun --env-file=.env.local)."
|
|
677
|
+
: "";
|
|
678
|
+
const fullMessage = `${message}${hint}`;
|
|
679
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
579
680
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
580
681
|
(err as Record<string, unknown>)["status"] = status;
|
|
581
682
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
582
683
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
684
|
+
// Preserve hint through toConciseProviderError re-wrap (it prefers
|
|
685
|
+
// data.error.message over responseBody).
|
|
686
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
583
687
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
584
688
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
689
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
585
690
|
throw toConciseProviderError(err, "openai", modelId);
|
|
586
691
|
}
|
|
587
692
|
|
|
@@ -728,7 +833,22 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
728
833
|
body,
|
|
729
834
|
};
|
|
730
835
|
|
|
731
|
-
|
|
836
|
+
// Q-17: optional per-call timeout races the linked abort signal.
|
|
837
|
+
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
838
|
+
const streamTimeoutTimer =
|
|
839
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
840
|
+
? setTimeout(() => {
|
|
841
|
+
try {
|
|
842
|
+
linked.abort();
|
|
843
|
+
} catch {}
|
|
844
|
+
}, streamTimeoutMs)
|
|
845
|
+
: undefined;
|
|
846
|
+
let res: Response;
|
|
847
|
+
try {
|
|
848
|
+
res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
849
|
+
} finally {
|
|
850
|
+
if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
|
|
851
|
+
}
|
|
732
852
|
if (!res.ok || !res.body) {
|
|
733
853
|
const text = !res.ok ? await res.text().catch(() => "") : "";
|
|
734
854
|
if (!res.ok) this.throwIfError(res.status, url, text, clean);
|
|
@@ -738,7 +858,7 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
738
858
|
res.headers.forEach((v, k) => {
|
|
739
859
|
responseHeaders[k] = v;
|
|
740
860
|
});
|
|
741
|
-
const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
861
|
+
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
742
862
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
743
863
|
|
|
744
864
|
const parser = new SSEParser();
|
|
@@ -750,6 +870,11 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
750
870
|
const calls = new Map<number, { itemId: string; callId: string; name: string; startArgs: string; deltaArgs: string }>();
|
|
751
871
|
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
752
872
|
let finishReason = "stop";
|
|
873
|
+
let sawFinishReason = false;
|
|
874
|
+
let sawUsage = false;
|
|
875
|
+
let refusalSeen = false;
|
|
876
|
+
let badFrames = 0;
|
|
877
|
+
let framesSeen = 0;
|
|
753
878
|
let responseId: string | undefined;
|
|
754
879
|
let completedBody: unknown = undefined;
|
|
755
880
|
let aborted = false;
|
|
@@ -765,11 +890,16 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
765
890
|
);
|
|
766
891
|
|
|
767
892
|
const handleMessage = (data: string): void => {
|
|
768
|
-
if (!data || data === "[DONE]")
|
|
893
|
+
if (!data || data === "[DONE]") {
|
|
894
|
+
if (data === "[DONE]") sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
769
897
|
let msg: Record<string, unknown>;
|
|
770
898
|
try {
|
|
771
899
|
msg = JSON.parse(data) as Record<string, unknown>;
|
|
900
|
+
framesSeen += 1;
|
|
772
901
|
} catch {
|
|
902
|
+
badFrames += 1;
|
|
773
903
|
return;
|
|
774
904
|
}
|
|
775
905
|
const type = msg["type"] as string;
|
|
@@ -790,6 +920,17 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
790
920
|
} else if (type === "response.output_text.delta" && typeof msg["delta"] === "string") {
|
|
791
921
|
text += msg["delta"] as string;
|
|
792
922
|
eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
|
|
923
|
+
} else if (
|
|
924
|
+
type === "response.refusal.delta" ||
|
|
925
|
+
type === "response.output_refusal.delta"
|
|
926
|
+
) {
|
|
927
|
+
// Q-23: refusal deltas carry the filter text; surface as text and
|
|
928
|
+
// remember for a `refusal` finish reason.
|
|
929
|
+
if (typeof msg["delta"] === "string" && msg["delta"]) {
|
|
930
|
+
text += msg["delta"] as string;
|
|
931
|
+
refusalSeen = true;
|
|
932
|
+
eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
|
|
933
|
+
}
|
|
793
934
|
} else if (
|
|
794
935
|
(type === "response.reasoning_text.delta" || type === "response.reasoning.delta") &&
|
|
795
936
|
typeof msg["delta"] === "string"
|
|
@@ -827,7 +968,11 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
827
968
|
const response = (msg["response"] as ResponsesObject) ?? {};
|
|
828
969
|
completedBody = msg["response"];
|
|
829
970
|
if (response.id) responseId = response.id;
|
|
830
|
-
if (response.usage)
|
|
971
|
+
if (response.usage) {
|
|
972
|
+
usage = mapUsage(response.usage);
|
|
973
|
+
sawUsage = true;
|
|
974
|
+
}
|
|
975
|
+
sawFinishReason = true;
|
|
831
976
|
if (type === "response.failed" || response.status === "failed") {
|
|
832
977
|
const message =
|
|
833
978
|
(response.error && typeof response.error.message === "string" && response.error.message) ||
|
|
@@ -839,7 +984,12 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
839
984
|
}
|
|
840
985
|
// Non-streaming maps `incomplete` → `length` (max tokens). Streaming
|
|
841
986
|
// must do the same — otherwise truncated turns misreport `stop`.
|
|
842
|
-
|
|
987
|
+
// Q-23: content-filter incompletes surface as `refusal`.
|
|
988
|
+
const incompleteReason = (response as { incomplete_details?: { reason?: string } }).incomplete_details?.reason;
|
|
989
|
+
if (response.status === "incomplete") {
|
|
990
|
+
finishReason = incompleteReason === "content_filter" ? "refusal" : "length";
|
|
991
|
+
sawFinishReason = true;
|
|
992
|
+
}
|
|
843
993
|
// Merge any full tool items delivered at completion (authoritative
|
|
844
994
|
// when present) so nothing depends solely on delta assembly.
|
|
845
995
|
for (const item of response.output ?? []) {
|
|
@@ -876,6 +1026,17 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
876
1026
|
}
|
|
877
1027
|
}
|
|
878
1028
|
}
|
|
1029
|
+
// Merge completed refusal text (filter truncations often arrive only
|
|
1030
|
+
// here with no preceding refusal delta).
|
|
1031
|
+
for (const item of response.output ?? []) {
|
|
1032
|
+
if (item.type !== "message") continue;
|
|
1033
|
+
for (const block of item.content ?? []) {
|
|
1034
|
+
if (block.type === "refusal" && block.text) {
|
|
1035
|
+
if (!text.includes(block.text)) text += block.text;
|
|
1036
|
+
refusalSeen = true;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
879
1040
|
eventStream.push({ type: "usage", usage });
|
|
880
1041
|
}
|
|
881
1042
|
};
|
|
@@ -905,7 +1066,7 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
905
1066
|
for (const c of calls.values()) {
|
|
906
1067
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
907
1068
|
const record: ToolCallRecord = {
|
|
908
|
-
id: c.itemId || c.callId ||
|
|
1069
|
+
id: c.itemId || c.callId || newToolCallId(),
|
|
909
1070
|
callId: c.callId || undefined,
|
|
910
1071
|
name: c.name,
|
|
911
1072
|
arguments: args,
|
|
@@ -916,9 +1077,40 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
916
1077
|
}
|
|
917
1078
|
|
|
918
1079
|
noteProviderTurn(sessionId, "openai");
|
|
919
|
-
if (toolCalls.length > 0)
|
|
1080
|
+
if (toolCalls.length > 0) {
|
|
1081
|
+
finishReason = "tool_calls";
|
|
1082
|
+
sawFinishReason = true;
|
|
1083
|
+
} else if (refusalSeen && finishReason !== "length") {
|
|
1084
|
+
finishReason = "refusal";
|
|
1085
|
+
sawFinishReason = true;
|
|
1086
|
+
}
|
|
920
1087
|
|
|
921
|
-
|
|
1088
|
+
// Q-23: terminal-state guard. Loops ending with no completed body, no
|
|
1089
|
+
// finish_reason, and no usage while carrying no content must fail as
|
|
1090
|
+
// `incomplete_stream` (retryable) instead of resolving empty success.
|
|
1091
|
+
// Skipped for non-SSE payloads (e.g. JSON mocks in delegation tests)
|
|
1092
|
+
// where no SSE frames were ever seen — those preserve legacy empty
|
|
1093
|
+
// success rather than failing the caller on a mock artifact.
|
|
1094
|
+
const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
|
|
1095
|
+
const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
1096
|
+
const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
|
|
1097
|
+
const isSSEPayload =
|
|
1098
|
+
(typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
|
|
1099
|
+
framesSeen > 0 ||
|
|
1100
|
+
badFrames > 0;
|
|
1101
|
+
if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
|
|
1102
|
+
eventStream.fail(incompleteStreamError("openai", clean));
|
|
1103
|
+
return;
|
|
1104
|
+
}
|
|
1105
|
+
// Refuse unfinished tool calls whose args never parsed (output_item.done
|
|
1106
|
+
// never arrived): truncated `{raw}` must not reach the executor (pi
|
|
1107
|
+
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1108
|
+
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1109
|
+
eventStream.fail(incompleteStreamError("openai", clean));
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const cleanThinking = cleanThinkingEarly;
|
|
922
1114
|
const finalResponse = new AgentResponse({
|
|
923
1115
|
text,
|
|
924
1116
|
thinking: cleanThinking || undefined,
|
|
@@ -929,7 +1121,10 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
929
1121
|
responseId,
|
|
930
1122
|
model: clean,
|
|
931
1123
|
provider: "openai",
|
|
932
|
-
raw: {
|
|
1124
|
+
raw: {
|
|
1125
|
+
request: rawRequest,
|
|
1126
|
+
response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
|
|
1127
|
+
},
|
|
933
1128
|
durationMs: Date.now() - startTime,
|
|
934
1129
|
});
|
|
935
1130
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|