agent-accelerator 0.4.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 +16 -1
- package/src/agent/loop.ts +74 -1
- package/src/index.ts +19 -0
- package/src/models/catalog.ts +26 -0
- package/src/providers/google.ts +56 -7
- package/src/providers/openai-compat.ts +110 -20
- package/src/providers/openai.ts +128 -13
- package/src/providers/openrouter.ts +89 -15
- package/src/providers/shared.ts +63 -5
- package/src/providers.ts +256 -5
- package/src/session/store.ts +18 -9
- package/src/types/agent.ts +17 -2
- package/src/types/model.ts +21 -0
- package/src/types/response.ts +7 -0
- package/src/utils/env.ts +21 -7
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +1 -1
|
@@ -67,16 +67,22 @@ import {
|
|
|
67
67
|
redactedHeaders,
|
|
68
68
|
readErrorPayload,
|
|
69
69
|
incompleteStreamError,
|
|
70
|
+
hasTruncatedToolArguments,
|
|
70
71
|
combinedSignal,
|
|
71
72
|
timeoutFor,
|
|
73
|
+
hasAuthHeader,
|
|
74
|
+
clampToolCallId,
|
|
72
75
|
} from "./shared.ts";
|
|
73
|
-
import { getModelFromCatalog, createGenericModelSpec } from "../models/catalog.ts";
|
|
76
|
+
import { getModelFromCatalog, createGenericModelSpec, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
74
77
|
import {
|
|
75
78
|
applyCacheForCustom,
|
|
76
79
|
mapToolChoiceToOpenAI,
|
|
77
80
|
emitProviderWarning,
|
|
78
81
|
noteProviderTurn,
|
|
79
82
|
parseStreamedToolArguments,
|
|
83
|
+
mapOutputToCompatChat,
|
|
84
|
+
normalizeStructuredOutput,
|
|
85
|
+
augmentSystemPromptWithStructuredOutput,
|
|
80
86
|
} from "../providers.ts";
|
|
81
87
|
|
|
82
88
|
// ---------------------------------------------------------------------------
|
|
@@ -110,6 +116,7 @@ interface ChatRequestBody {
|
|
|
110
116
|
messages: ChatMessage[];
|
|
111
117
|
tools?: Array<Record<string, unknown>>;
|
|
112
118
|
tool_choice?: string | { type: "function"; function: { name: string } };
|
|
119
|
+
response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
113
120
|
stream?: boolean;
|
|
114
121
|
[k: string]: unknown;
|
|
115
122
|
}
|
|
@@ -208,17 +215,23 @@ function resolveApiKey(
|
|
|
208
215
|
configuredApiKey: string | undefined,
|
|
209
216
|
options?: ProviderRequestOptions
|
|
210
217
|
): string | undefined {
|
|
218
|
+
const clean = (v: unknown): string | undefined => {
|
|
219
|
+
if (typeof v !== "string") return undefined;
|
|
220
|
+
const t = v.trim().replace(/^["']|["']$/g, "").trim();
|
|
221
|
+
return t || undefined;
|
|
222
|
+
};
|
|
211
223
|
const envPrefix = envPrefixOf(prefix);
|
|
212
|
-
|
|
213
|
-
options?.apiKey ||
|
|
214
|
-
configuredApiKey ||
|
|
215
|
-
options?.env?.[`${envPrefix}_API_KEY`] ||
|
|
216
|
-
options?.env?.[`${envPrefix}_BASE_API_KEY`] ||
|
|
217
|
-
getEnv(`${envPrefix}_API_KEY`) ||
|
|
218
|
-
getEnv(`${envPrefix}_BASE_API_KEY`) ||
|
|
224
|
+
const key = (
|
|
225
|
+
clean(options?.apiKey) ||
|
|
226
|
+
clean(configuredApiKey) ||
|
|
227
|
+
clean(options?.env?.[`${envPrefix}_API_KEY`]) ||
|
|
228
|
+
clean(options?.env?.[`${envPrefix}_BASE_API_KEY`]) ||
|
|
219
229
|
getApiKey(prefix, undefined, options?.env) ||
|
|
220
230
|
undefined
|
|
221
231
|
);
|
|
232
|
+
if (key) return key;
|
|
233
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
234
|
+
return undefined;
|
|
222
235
|
}
|
|
223
236
|
|
|
224
237
|
/** Local endpoints (loopback / LAN / *.local) may omit the API key. Literal IPs only; hostnames like 10.evil.com are not local. */
|
|
@@ -395,14 +408,18 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
395
408
|
if (context.systemPrompt) {
|
|
396
409
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
397
410
|
}
|
|
411
|
+
let lastRole: string | null = "system";
|
|
398
412
|
for (const m of context.messages) {
|
|
399
413
|
if (m.role === "system") continue;
|
|
400
414
|
if (m.role === "user") {
|
|
415
|
+
if (lastRole === "tool") {
|
|
416
|
+
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
417
|
+
}
|
|
401
418
|
if (typeof m.content === "string") {
|
|
402
|
-
if (m.content) messages.push({ role: "user", content: m.content });
|
|
419
|
+
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
403
420
|
} else {
|
|
404
421
|
const blocks = await contentPartsToBlocks(m.content);
|
|
405
|
-
if (blocks.length > 0) messages.push({ role: "user", content: blocks });
|
|
422
|
+
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
406
423
|
}
|
|
407
424
|
} else if (m.role === "assistant") {
|
|
408
425
|
if (typeof m.content === "string") {
|
|
@@ -426,7 +443,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
426
443
|
function: { name: string; arguments: string };
|
|
427
444
|
extra_content?: { google: { thought_signature: string } };
|
|
428
445
|
} = {
|
|
429
|
-
id: part.callId || part.id,
|
|
446
|
+
id: clampToolCallId(part.callId || part.id),
|
|
430
447
|
type: "function",
|
|
431
448
|
function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
|
|
432
449
|
};
|
|
@@ -443,21 +460,26 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
443
460
|
texts.push(part.text);
|
|
444
461
|
}
|
|
445
462
|
}
|
|
463
|
+
// Skip empty assistant turns (no text + no tool calls): strict
|
|
464
|
+
// routes reject them, and they carry no signal (pi drop rule).
|
|
465
|
+
if (texts.length === 0 && calls.length === 0) continue;
|
|
446
466
|
messages.push({
|
|
447
467
|
role: "assistant",
|
|
448
468
|
content: texts.length > 0 ? texts.join("\n") : null,
|
|
449
469
|
...(calls.length > 0 ? { tool_calls: calls } : {}),
|
|
450
470
|
});
|
|
471
|
+
lastRole = "assistant";
|
|
451
472
|
} else if (m.role === "tool") {
|
|
452
473
|
if (!Array.isArray(m.content)) continue;
|
|
453
474
|
for (const part of m.content) {
|
|
454
475
|
if (part.type === "tool_result") {
|
|
455
476
|
messages.push({
|
|
456
477
|
role: "tool",
|
|
457
|
-
tool_call_id: pairing.get(part.id) || part.id,
|
|
478
|
+
tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
|
|
458
479
|
content: resultToOutput(part.result),
|
|
459
480
|
name: part.name,
|
|
460
481
|
});
|
|
482
|
+
lastRole = "tool";
|
|
461
483
|
}
|
|
462
484
|
}
|
|
463
485
|
}
|
|
@@ -551,7 +573,7 @@ function parseResponse(
|
|
|
551
573
|
const args = parseArguments(tc.function?.arguments);
|
|
552
574
|
const sig = extractSignature(tc);
|
|
553
575
|
toolCalls.push({
|
|
554
|
-
id: tc.id || newToolCallId(),
|
|
576
|
+
id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
|
|
555
577
|
name: tc.function?.name || "unknown",
|
|
556
578
|
arguments: args,
|
|
557
579
|
rawArguments:
|
|
@@ -678,7 +700,32 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
678
700
|
}
|
|
679
701
|
applyCacheForCustom(this.prefix, options?.cache, `${this.prefix}/${modelId}`);
|
|
680
702
|
|
|
681
|
-
|
|
703
|
+
// Structured outputs: native `response_format` when supported, else
|
|
704
|
+
// emulated via system prompt (strict endpoints would 400 on json_schema
|
|
705
|
+
// for catalog `structured_output: false` models).
|
|
706
|
+
let effectiveContext = context;
|
|
707
|
+
let outputEmulated = false;
|
|
708
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
709
|
+
try {
|
|
710
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
711
|
+
} catch {
|
|
712
|
+
outputSpec = undefined;
|
|
713
|
+
}
|
|
714
|
+
if (outputSpec && !modelSupportsStructuredOutput(this.prefix, modelId)) {
|
|
715
|
+
outputEmulated = true;
|
|
716
|
+
effectiveContext = {
|
|
717
|
+
...context,
|
|
718
|
+
systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
|
|
719
|
+
};
|
|
720
|
+
emitProviderWarning({
|
|
721
|
+
provider: this.prefix,
|
|
722
|
+
capability: "structured output",
|
|
723
|
+
requested: `${outputSpec.name} (${this.prefix}/${modelId})`,
|
|
724
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
725
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
const messages = await fullHistoryMessages(effectiveContext);
|
|
682
729
|
const body: ChatRequestBody = {
|
|
683
730
|
model: modelId,
|
|
684
731
|
messages,
|
|
@@ -697,8 +744,19 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
697
744
|
? { type: "function", function: { name: (toolChoice as { name: string }).name } }
|
|
698
745
|
: (toolChoice as ChatRequestBody["tool_choice"]);
|
|
699
746
|
}
|
|
700
|
-
//
|
|
701
|
-
//
|
|
747
|
+
// Structured outputs use the standard Chat Completions `response_format`
|
|
748
|
+
// (safe on strict endpoints). Skipped when emulated (instruction already
|
|
749
|
+
// in messages). No reasoning / service_tier / session /
|
|
750
|
+
// cache body primitives: strict endpoints reject unknown properties.
|
|
751
|
+
// Affinity stays headers-only.
|
|
752
|
+
if (outputSpec && !outputEmulated) {
|
|
753
|
+
try {
|
|
754
|
+
const mapped = mapOutputToCompatChat(outputSpec);
|
|
755
|
+
if (mapped) body.response_format = mapped.response_format;
|
|
756
|
+
} catch {
|
|
757
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
758
|
+
}
|
|
759
|
+
}
|
|
702
760
|
// Q-38: request usage on streams where supported so terminal accounting
|
|
703
761
|
// arrives on the final chunk. Strict servers that reject unknown fields
|
|
704
762
|
// opt out via `COMPAT_NO_STREAM_OPTIONS=1`; an explicit per-call
|
|
@@ -731,8 +789,26 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
731
789
|
for (const [k, v] of Object.entries(headers)) {
|
|
732
790
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
733
791
|
}
|
|
792
|
+
// Strict-endpoint opt-out (pi sendSessionAffinityHeaders pattern):
|
|
793
|
+
// `COMPAT_NO_SESSION_HEADERS=1` or per-call `sendSessionHeaders:false`
|
|
794
|
+
// drops affinity headers that trigger CORS preflights / strict 400s.
|
|
795
|
+
// Body `session_id` is already omitted on compat (headers-only affinity).
|
|
796
|
+
try {
|
|
797
|
+
const noAffinity =
|
|
798
|
+
(options as { sendSessionHeaders?: unknown } | undefined)?.sendSessionHeaders === false ||
|
|
799
|
+
getEnv("COMPAT_NO_SESSION_HEADERS") === "1" ||
|
|
800
|
+
options?.env?.["COMPAT_NO_SESSION_HEADERS"] === "1";
|
|
801
|
+
if (noAffinity) {
|
|
802
|
+
for (const k of Object.keys(headers)) {
|
|
803
|
+
const lower = k.toLowerCase();
|
|
804
|
+
if (lower === "x-session-id" || lower === "x-client-request-id" || lower === "session_id") {
|
|
805
|
+
delete headers[k];
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
} catch {}
|
|
734
810
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
735
|
-
if (apiKey) setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
811
|
+
if (apiKey && apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
736
812
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
737
813
|
}
|
|
738
814
|
|
|
@@ -765,14 +841,21 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
765
841
|
modelId: string
|
|
766
842
|
): void {
|
|
767
843
|
if (status >= 200 && status < 300) return;
|
|
768
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
769
|
-
const
|
|
844
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
845
|
+
const hint =
|
|
846
|
+
status === 401
|
|
847
|
+
? ` Check ${envPrefixOf(this.prefix)}_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local).`
|
|
848
|
+
: "";
|
|
849
|
+
const fullMessage = `${message}${hint}`;
|
|
850
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
770
851
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
771
852
|
(err as Record<string, unknown>)["status"] = status;
|
|
772
853
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
773
854
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
855
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
774
856
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
775
857
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
858
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
776
859
|
throw toConciseProviderError(err, this.prefix, modelId);
|
|
777
860
|
}
|
|
778
861
|
|
|
@@ -1113,7 +1196,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1113
1196
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
1114
1197
|
const sig = signatures.get(index);
|
|
1115
1198
|
const record: ToolCallRecord = {
|
|
1116
|
-
id: c.id || newToolCallId(),
|
|
1199
|
+
id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
|
|
1117
1200
|
name: c.name,
|
|
1118
1201
|
arguments: args,
|
|
1119
1202
|
rawArguments: c.startArgs + c.deltaArgs,
|
|
@@ -1146,6 +1229,13 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1146
1229
|
eventStream.fail(incompleteStreamError(prefix, clean));
|
|
1147
1230
|
return;
|
|
1148
1231
|
}
|
|
1232
|
+
// Refuse unfinished tool calls whose args never parsed (output_item.done
|
|
1233
|
+
// never arrived): truncated `{raw}` must not reach the executor (pi
|
|
1234
|
+
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1235
|
+
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1236
|
+
eventStream.fail(incompleteStreamError(prefix, clean));
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1149
1239
|
|
|
1150
1240
|
const cleanThinking = cleanThinkingEarly;
|
|
1151
1241
|
const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
|
package/src/providers/openai.ts
CHANGED
|
@@ -59,11 +59,14 @@ import {
|
|
|
59
59
|
redactedHeaders,
|
|
60
60
|
readErrorPayload,
|
|
61
61
|
incompleteStreamError,
|
|
62
|
+
hasTruncatedToolArguments,
|
|
62
63
|
combinedSignal,
|
|
63
64
|
timeoutFor,
|
|
65
|
+
hasAuthHeader,
|
|
66
|
+
clampToolCallId,
|
|
64
67
|
} from "./shared.ts";
|
|
65
68
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
66
|
-
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
69
|
+
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
67
70
|
import {
|
|
68
71
|
mapThinkingLevelToOpenAI,
|
|
69
72
|
mapServiceTierToOpenAI,
|
|
@@ -71,6 +74,10 @@ import {
|
|
|
71
74
|
mapToolChoiceToOpenAI,
|
|
72
75
|
noteProviderTurn,
|
|
73
76
|
parseStreamedToolArguments,
|
|
77
|
+
mapOutputToOpenAI,
|
|
78
|
+
normalizeStructuredOutput,
|
|
79
|
+
augmentSystemPromptWithStructuredOutput,
|
|
80
|
+
emitProviderWarning,
|
|
74
81
|
} from "../providers.ts";
|
|
75
82
|
|
|
76
83
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +109,7 @@ interface ResponsesRequestBody {
|
|
|
102
109
|
prompt_cache_key?: string;
|
|
103
110
|
store?: boolean;
|
|
104
111
|
stream?: boolean;
|
|
112
|
+
text?: { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
105
113
|
[k: string]: unknown;
|
|
106
114
|
}
|
|
107
115
|
|
|
@@ -170,7 +178,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
170
178
|
}
|
|
171
179
|
|
|
172
180
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
173
|
-
|
|
181
|
+
const key = getApiKey("openai", options?.apiKey, options?.env);
|
|
182
|
+
if (key) return key;
|
|
183
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
184
|
+
return undefined;
|
|
174
185
|
}
|
|
175
186
|
|
|
176
187
|
/** Strips ONLY the `openai/` prefix. Bare ids pass through untouched. */
|
|
@@ -251,6 +262,20 @@ function resultToOutput(result: unknown): string {
|
|
|
251
262
|
return safeStringify(result);
|
|
252
263
|
}
|
|
253
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
|
+
|
|
254
279
|
/**
|
|
255
280
|
* Builds the FULL explicit history (stateless — no server state, no
|
|
256
281
|
* chaining). Assistant items intentionally carry no provider `id`/`status`:
|
|
@@ -300,10 +325,25 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
300
325
|
const texts: string[] = [];
|
|
301
326
|
for (const part of m.content) {
|
|
302
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
|
+
}
|
|
303
343
|
items.push({
|
|
304
344
|
type: "function_call",
|
|
305
|
-
id:
|
|
306
|
-
call_id:
|
|
345
|
+
id: normalizeResponsesItemId(itemId),
|
|
346
|
+
call_id: normalizeResponsesIdPart(callId),
|
|
307
347
|
name: part.name,
|
|
308
348
|
arguments: JSON.stringify(part.arguments || {}),
|
|
309
349
|
});
|
|
@@ -319,9 +359,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
319
359
|
} else if (m.role === "tool") {
|
|
320
360
|
if (typeof m.content === "string") {
|
|
321
361
|
const queue = (m.name && idsByName.get(m.name)) || [];
|
|
362
|
+
const rawPair = queue.shift() || newToolCallId();
|
|
363
|
+
const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
|
|
322
364
|
items.push({
|
|
323
365
|
type: "function_call_output",
|
|
324
|
-
call_id:
|
|
366
|
+
call_id: normalizeResponsesIdPart(rawCall),
|
|
325
367
|
output: m.content,
|
|
326
368
|
});
|
|
327
369
|
continue;
|
|
@@ -329,9 +371,11 @@ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInput
|
|
|
329
371
|
if (!Array.isArray(m.content)) continue;
|
|
330
372
|
for (const part of m.content) {
|
|
331
373
|
if (part.type === "tool_result") {
|
|
374
|
+
const rawPair = pairing.get(part.id) || part.id;
|
|
375
|
+
const rawCall = rawPair.includes("|") ? rawPair.split("|")[0]! : rawPair;
|
|
332
376
|
items.push({
|
|
333
377
|
type: "function_call_output",
|
|
334
|
-
call_id:
|
|
378
|
+
call_id: normalizeResponsesIdPart(rawCall),
|
|
335
379
|
output: resultToOutput(part.result),
|
|
336
380
|
});
|
|
337
381
|
}
|
|
@@ -486,16 +530,38 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
486
530
|
const serviceTier = mapServiceTierToOpenAI(options?.serviceTier);
|
|
487
531
|
applyCacheForOpenAI(options?.cache, `openai/${modelId}`);
|
|
488
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
|
+
|
|
489
555
|
const body: ResponsesRequestBody = {
|
|
490
556
|
model: modelId,
|
|
491
|
-
input: await fullHistoryInput(context),
|
|
557
|
+
input: await fullHistoryInput({ ...context, systemPrompt: effectiveSystemPrompt }),
|
|
492
558
|
// Stateless by design: the server defaults store:true, so opt out
|
|
493
559
|
// explicitly. Full history is always sent, so no chaining is needed
|
|
494
560
|
// and provider switching stays trivial.
|
|
495
561
|
store: false,
|
|
496
562
|
...(stream ? { stream: true } : {}),
|
|
497
563
|
};
|
|
498
|
-
if (
|
|
564
|
+
if (effectiveSystemPrompt) body.instructions = effectiveSystemPrompt;
|
|
499
565
|
const aiTools = toOpenAITools(tools);
|
|
500
566
|
if (aiTools) body.tools = aiTools;
|
|
501
567
|
const toolChoice = mapToolChoiceToOpenAI(
|
|
@@ -504,8 +570,17 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
504
570
|
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
505
571
|
if (effort) body.reasoning = { effort };
|
|
506
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
|
+
}
|
|
507
583
|
// Cache affinity: prompt_cache_key replaces the legacy `user` field.
|
|
508
|
-
// (prompt_cache_options explicit breakpoints are out of scope.)
|
|
509
584
|
// OpenAI enforces max 64 chars — clamp defensively so child session ids
|
|
510
585
|
// (`parent-sub-tag-rand`) and user-supplied long ids never 400. Headers
|
|
511
586
|
// are clamped the same way via buildSessionHeaders, keeping affinity
|
|
@@ -514,6 +589,29 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
514
589
|
const cacheKey = clampCacheKey(sessionId);
|
|
515
590
|
if (cacheKey) body.prompt_cache_key = cacheKey;
|
|
516
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
|
+
}
|
|
517
615
|
// Stateless API use: previous_response_id / background / conversation are
|
|
518
616
|
// NEVER sent (server state would break provider-agnostic switching).
|
|
519
617
|
return body;
|
|
@@ -536,9 +634,10 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
536
634
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
537
635
|
}
|
|
538
636
|
// Case-insensitive merge so a lowercase `authorization` custom header
|
|
539
|
-
// never coexists with the canonical bearer.
|
|
637
|
+
// never coexists with the canonical bearer. Proxy flows
|
|
638
|
+
// (`apiKey === "unused"`) preserve custom auth instead of overwriting.
|
|
540
639
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
541
|
-
setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
640
|
+
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
542
641
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
543
642
|
}
|
|
544
643
|
|
|
@@ -571,14 +670,23 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
571
670
|
modelId: string
|
|
572
671
|
): void {
|
|
573
672
|
if (status >= 200 && status < 300) return;
|
|
574
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
575
|
-
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;
|
|
576
680
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
577
681
|
(err as Record<string, unknown>)["status"] = status;
|
|
578
682
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
579
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 } };
|
|
580
687
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
581
688
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
689
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
582
690
|
throw toConciseProviderError(err, "openai", modelId);
|
|
583
691
|
}
|
|
584
692
|
|
|
@@ -994,6 +1102,13 @@ export class OpenAIResponsesProvider implements Provider {
|
|
|
994
1102
|
eventStream.fail(incompleteStreamError("openai", clean));
|
|
995
1103
|
return;
|
|
996
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
|
+
}
|
|
997
1112
|
|
|
998
1113
|
const cleanThinking = cleanThinkingEarly;
|
|
999
1114
|
const finalResponse = new AgentResponse({
|