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
|
@@ -52,11 +52,14 @@ import {
|
|
|
52
52
|
redactedHeaders,
|
|
53
53
|
readErrorPayload,
|
|
54
54
|
incompleteStreamError,
|
|
55
|
+
hasTruncatedToolArguments,
|
|
55
56
|
combinedSignal,
|
|
56
57
|
timeoutFor,
|
|
58
|
+
hasAuthHeader,
|
|
59
|
+
clampToolCallId,
|
|
57
60
|
} from "./shared.ts";
|
|
58
61
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
59
|
-
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
62
|
+
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
60
63
|
import {
|
|
61
64
|
mapThinkingLevelToOpenRouterChat,
|
|
62
65
|
mapServiceTierToOpenRouter,
|
|
@@ -64,6 +67,10 @@ import {
|
|
|
64
67
|
mapToolChoiceToOpenRouterChat,
|
|
65
68
|
noteProviderTurn,
|
|
66
69
|
parseStreamedToolArguments,
|
|
70
|
+
mapOutputToOpenRouterChat,
|
|
71
|
+
normalizeStructuredOutput,
|
|
72
|
+
augmentSystemPromptWithStructuredOutput,
|
|
73
|
+
emitProviderWarning,
|
|
67
74
|
} from "../providers.ts";
|
|
68
75
|
|
|
69
76
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +109,7 @@ interface ChatRequestBody {
|
|
|
102
109
|
service_tier?: string;
|
|
103
110
|
session_id?: string;
|
|
104
111
|
plugins?: Array<{ id: string }>;
|
|
112
|
+
response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
105
113
|
stream?: boolean;
|
|
106
114
|
[k: string]: unknown;
|
|
107
115
|
}
|
|
@@ -193,7 +201,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
193
201
|
}
|
|
194
202
|
|
|
195
203
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
196
|
-
|
|
204
|
+
const key = getApiKey("openrouter", options?.apiKey, options?.env);
|
|
205
|
+
if (key) return key;
|
|
206
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
207
|
+
return undefined;
|
|
197
208
|
}
|
|
198
209
|
|
|
199
210
|
/**
|
|
@@ -352,11 +363,17 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
352
363
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
353
364
|
}
|
|
354
365
|
let hasFiles = false;
|
|
366
|
+
let lastRole: string | null = "system";
|
|
355
367
|
for (const m of context.messages) {
|
|
356
368
|
if (m.role === "system") continue;
|
|
357
369
|
if (m.role === "user") {
|
|
370
|
+
// Strict routes reject `user` directly after `tool`: bridge with a
|
|
371
|
+
// synthetic assistant turn (pi api/openai-completions.ts:1233).
|
|
372
|
+
if (lastRole === "tool") {
|
|
373
|
+
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
374
|
+
}
|
|
358
375
|
if (typeof m.content === "string") {
|
|
359
|
-
if (m.content) messages.push({ role: "user", content: m.content });
|
|
376
|
+
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
360
377
|
} else {
|
|
361
378
|
const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
|
|
362
379
|
if (hf) hasFiles = true;
|
|
@@ -364,7 +381,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
364
381
|
for (const url of fileUrls) {
|
|
365
382
|
blocks.push({ type: "text", text: url });
|
|
366
383
|
}
|
|
367
|
-
if (blocks.length > 0) messages.push({ role: "user", content: blocks });
|
|
384
|
+
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
368
385
|
}
|
|
369
386
|
} else if (m.role === "assistant") {
|
|
370
387
|
if (typeof m.content === "string") {
|
|
@@ -378,7 +395,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
378
395
|
for (const part of m.content) {
|
|
379
396
|
if (part.type === "tool_call") {
|
|
380
397
|
calls.push({
|
|
381
|
-
id: part.callId || part.id,
|
|
398
|
+
id: clampToolCallId(part.callId || part.id),
|
|
382
399
|
type: "function",
|
|
383
400
|
function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
|
|
384
401
|
});
|
|
@@ -388,20 +405,25 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
388
405
|
// Prior-turn reasoning is NOT resent: chat history is messages +
|
|
389
406
|
// tool calls only (documented decision; avoids strict-route 400s).
|
|
390
407
|
}
|
|
408
|
+
// Skip empty assistant turns (no text + no tool calls): strict
|
|
409
|
+
// routes reject them, and they carry no signal (pi drop rule).
|
|
410
|
+
if (texts.length === 0 && calls.length === 0) continue;
|
|
391
411
|
messages.push({
|
|
392
412
|
role: "assistant",
|
|
393
413
|
content: texts.length > 0 ? texts.join("\n") : null,
|
|
394
414
|
...(calls.length > 0 ? { tool_calls: calls } : {}),
|
|
395
415
|
});
|
|
416
|
+
lastRole = "assistant";
|
|
396
417
|
} else if (m.role === "tool") {
|
|
397
418
|
if (typeof m.content === "string") {
|
|
398
419
|
const queue = (m.name && idsByName.get(m.name)) || [];
|
|
399
420
|
messages.push({
|
|
400
421
|
role: "tool",
|
|
401
|
-
tool_call_id: queue.shift() || newToolCallId(),
|
|
422
|
+
tool_call_id: clampToolCallId(queue.shift() || newToolCallId()),
|
|
402
423
|
content: m.content,
|
|
403
424
|
name: m.name,
|
|
404
425
|
});
|
|
426
|
+
lastRole = "tool";
|
|
405
427
|
continue;
|
|
406
428
|
}
|
|
407
429
|
if (!Array.isArray(m.content)) continue;
|
|
@@ -409,10 +431,11 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
409
431
|
if (part.type === "tool_result") {
|
|
410
432
|
messages.push({
|
|
411
433
|
role: "tool",
|
|
412
|
-
tool_call_id: pairing.get(part.id) || part.id,
|
|
434
|
+
tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
|
|
413
435
|
content: resultToOutput(part.result),
|
|
414
436
|
name: part.name,
|
|
415
437
|
});
|
|
438
|
+
lastRole = "tool";
|
|
416
439
|
}
|
|
417
440
|
}
|
|
418
441
|
}
|
|
@@ -490,7 +513,7 @@ function parseResponse(
|
|
|
490
513
|
for (const tc of message?.tool_calls ?? []) {
|
|
491
514
|
const args = parseArguments(tc.function?.arguments);
|
|
492
515
|
toolCalls.push({
|
|
493
|
-
id: tc.id || newToolCallId(),
|
|
516
|
+
id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
|
|
494
517
|
name: tc.function?.name || "unknown",
|
|
495
518
|
arguments: args,
|
|
496
519
|
rawArguments:
|
|
@@ -559,7 +582,33 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
559
582
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
560
583
|
applyCacheForOpenRouter(options?.cache, `openrouter/${modelId}`);
|
|
561
584
|
|
|
562
|
-
|
|
585
|
+
// Structured outputs: native `response_format` when supported, else
|
|
586
|
+
// emulated via system-prompt instruction + client validation (catalog
|
|
587
|
+
// `structured_output: false` models would 400 on the wire shape).
|
|
588
|
+
let effectiveContext = context;
|
|
589
|
+
let outputEmulated = false;
|
|
590
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
591
|
+
try {
|
|
592
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
593
|
+
} catch {
|
|
594
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
595
|
+
outputSpec = undefined;
|
|
596
|
+
}
|
|
597
|
+
if (outputSpec && !modelSupportsStructuredOutput("openrouter", modelId)) {
|
|
598
|
+
outputEmulated = true;
|
|
599
|
+
effectiveContext = {
|
|
600
|
+
...context,
|
|
601
|
+
systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
|
|
602
|
+
};
|
|
603
|
+
emitProviderWarning({
|
|
604
|
+
provider: "openrouter",
|
|
605
|
+
capability: "structured output",
|
|
606
|
+
requested: `${outputSpec.name} (openrouter/${modelId})`,
|
|
607
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
608
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
|
|
563
612
|
const body: ChatRequestBody = {
|
|
564
613
|
model: modelId,
|
|
565
614
|
messages,
|
|
@@ -573,12 +622,23 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
573
622
|
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
574
623
|
if (effort) body.reasoning = { effort };
|
|
575
624
|
if (serviceTier) body.service_tier = serviceTier;
|
|
625
|
+
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
626
|
+
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
627
|
+
if (outputSpec && !outputEmulated) {
|
|
628
|
+
try {
|
|
629
|
+
const mapped = mapOutputToOpenRouterChat(outputSpec);
|
|
630
|
+
if (mapped) body.response_format = mapped.response_format;
|
|
631
|
+
} catch {
|
|
632
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
633
|
+
}
|
|
634
|
+
}
|
|
576
635
|
// PDF ingestion is a documented plugin, not a message shape: enable it
|
|
577
636
|
// only when file parts are present (never by default).
|
|
578
637
|
if (hasFiles) body.plugins = [{ id: "file-parser" }];
|
|
579
|
-
// No temperature/top_p/max_tokens/stop/
|
|
638
|
+
// No temperature/top_p/max_tokens/stop/user/seed knobs
|
|
580
639
|
// (no canonical options exist; omitting also keeps provider cache keys
|
|
581
|
-
// stable).
|
|
640
|
+
// stable). `response_format` above is the sole structured-output exception.
|
|
641
|
+
// No store/previous_response_id/conversation: they do not exist
|
|
582
642
|
// on this endpoint.
|
|
583
643
|
// Session affinity: top-level `session_id` is the documented sticky-routing
|
|
584
644
|
// key on Chat Completions (prompt-caching guide) and takes precedence over
|
|
@@ -608,7 +668,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
608
668
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
609
669
|
}
|
|
610
670
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
611
|
-
setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
671
|
+
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
612
672
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
613
673
|
}
|
|
614
674
|
|
|
@@ -641,14 +701,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
641
701
|
modelId: string
|
|
642
702
|
): void {
|
|
643
703
|
if (status >= 200 && status < 300) return;
|
|
644
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
645
|
-
const
|
|
704
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
705
|
+
const hint =
|
|
706
|
+
status === 401
|
|
707
|
+
? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
|
|
708
|
+
: "";
|
|
709
|
+
const fullMessage = `${message}${hint}`;
|
|
710
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
646
711
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
647
712
|
(err as Record<string, unknown>)["status"] = status;
|
|
648
713
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
649
714
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
715
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
650
716
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
651
717
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
718
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
652
719
|
throw toConciseProviderError(err, "openrouter", modelId);
|
|
653
720
|
}
|
|
654
721
|
|
|
@@ -995,7 +1062,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
995
1062
|
for (const c of calls.values()) {
|
|
996
1063
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
997
1064
|
const record: ToolCallRecord = {
|
|
998
|
-
id: c.id || newToolCallId(),
|
|
1065
|
+
id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
|
|
999
1066
|
name: c.name,
|
|
1000
1067
|
arguments: args,
|
|
1001
1068
|
rawArguments: c.startArgs + c.deltaArgs,
|
|
@@ -1027,6 +1094,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1027
1094
|
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
1028
1095
|
return;
|
|
1029
1096
|
}
|
|
1097
|
+
// Refuse unfinished tool calls whose args never parsed (output_item.done
|
|
1098
|
+
// never arrived): truncated `{raw}` must not reach the executor (pi
|
|
1099
|
+
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1100
|
+
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1101
|
+
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1030
1104
|
|
|
1031
1105
|
const cleanThinking = cleanThinkingEarly;
|
|
1032
1106
|
const finalResponse = new AgentResponse({
|
package/src/providers/shared.ts
CHANGED
|
@@ -44,25 +44,30 @@ export function redactedHeaders(headers: Record<string, string>): Record<string,
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
/**
|
|
47
|
-
* Extracts `{ message, code, errorType }` from a non-2xx JSON body. Handles
|
|
48
|
-
* OpenAI `type`, OpenRouter `metadata.error_type`, and Google
|
|
49
|
-
* shapes; unknown bodies degrade to a 300-char slice.
|
|
47
|
+
* Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
|
|
48
|
+
* OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
|
|
49
|
+
* `status` code shapes; unknown bodies degrade to a 300-char slice.
|
|
50
50
|
*/
|
|
51
51
|
export function readErrorPayload(
|
|
52
52
|
bodyText: string
|
|
53
|
-
): { message: string; code?: string | number; errorType?: string } {
|
|
53
|
+
): { message: string; code?: string | number; errorType?: string; raw?: string } {
|
|
54
54
|
try {
|
|
55
55
|
const parsed: unknown = JSON.parse(bodyText);
|
|
56
56
|
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
57
|
-
const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string } } })?.error;
|
|
57
|
+
const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
|
|
58
58
|
if (err && typeof err.message === "string") {
|
|
59
59
|
const code = err.code ?? err.status;
|
|
60
60
|
const errorType =
|
|
61
61
|
err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
|
|
62
|
+
const rawCandidate =
|
|
63
|
+
(err as { metadata?: { raw?: unknown } }).metadata?.raw ??
|
|
64
|
+
(first as { metadata?: { raw?: unknown } })?.metadata?.raw;
|
|
65
|
+
const raw = typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
|
|
62
66
|
return {
|
|
63
67
|
message: err.message,
|
|
64
68
|
...(code !== undefined ? { code } : {}),
|
|
65
69
|
...(typeof errorType === "string" ? { errorType } : {}),
|
|
70
|
+
...(raw ? { raw } : {}),
|
|
66
71
|
};
|
|
67
72
|
}
|
|
68
73
|
return { message: bodyText.slice(0, 300) };
|
|
@@ -130,3 +135,56 @@ export function newToolCallId(prefix = "call"): string {
|
|
|
130
135
|
} catch {}
|
|
131
136
|
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
|
132
137
|
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* True when custom headers already carry auth (proxy/Copilot flows).
|
|
141
|
+
* Case-insensitive, non-empty `Authorization` or `cf-aig-authorization`.
|
|
142
|
+
* Callers return `"unused"` instead of throwing missing-key.
|
|
143
|
+
*/
|
|
144
|
+
export function hasAuthHeader(headers: Record<string, string> | undefined): boolean {
|
|
145
|
+
if (!headers) return false;
|
|
146
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
147
|
+
const lower = k.toLowerCase();
|
|
148
|
+
if ((lower === "authorization" || lower === "cf-aig-authorization") && typeof v === "string" && v.trim().length > 0) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** True for truncated streamed tool args (`{ raw }` marker from `parseStreamedToolArguments`). */
|
|
156
|
+
export function hasTruncatedToolArguments(args: unknown): boolean {
|
|
157
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return false;
|
|
158
|
+
const keys = Object.keys(args as Record<string, unknown>);
|
|
159
|
+
return keys.length === 1 && keys[0] === "raw" && typeof (args as Record<string, unknown>).raw === "string";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
|
|
163
|
+
export function shortHashId(value: string): string {
|
|
164
|
+
let h = 0x811c9dc5;
|
|
165
|
+
for (let i = 0; i < value.length; i++) {
|
|
166
|
+
h ^= value.charCodeAt(i);
|
|
167
|
+
h = Math.imul(h, 0x01000193);
|
|
168
|
+
}
|
|
169
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Clamps a tool-call ID to the 40-char OpenAI limit (pi `api/openai-completions.ts:1194`).
|
|
174
|
+
* Pipe IDs (`callId|itemId`) become `callId_itemHash`; overlong IDs truncate
|
|
175
|
+
* with hash suffix. Plain IDs pass through (truncated if needed).
|
|
176
|
+
*/
|
|
177
|
+
export function clampToolCallId(id: string): string {
|
|
178
|
+
const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
179
|
+
if (!clean.includes("|")) {
|
|
180
|
+
return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
|
|
181
|
+
}
|
|
182
|
+
const sep = clean.indexOf("|");
|
|
183
|
+
const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
|
|
184
|
+
const itemId = clean.slice(sep + 1).replace(/_+$/, "");
|
|
185
|
+
const combined = itemId ? `${callId}_${itemId}` : callId;
|
|
186
|
+
if (combined.length <= 40) return combined;
|
|
187
|
+
const hash = shortHashId(id).slice(0, 8);
|
|
188
|
+
const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1));
|
|
189
|
+
return `${prefix}_${hash}`;
|
|
190
|
+
}
|
package/src/providers.ts
CHANGED
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
* redesigning this abstraction — only new per-provider mappers/adapters.
|
|
11
11
|
*/
|
|
12
12
|
import type { CacheConfig, ServiceTier, ThinkingLevel } from "./types/core.ts";
|
|
13
|
+
import type { StructuredOutputSpec } from "./types/model.ts";
|
|
14
|
+
import { zodToJsonSchema } from "./tools/schema.ts";
|
|
15
|
+
import { ValidationError } from "./types/errors.ts";
|
|
13
16
|
|
|
14
17
|
/** Classification of a canonical capability on a given provider. */
|
|
15
18
|
export type ProviderCapabilityStatus =
|
|
@@ -357,17 +360,17 @@ export function mapServiceTierToOpenAI(tier: ServiceTier | undefined): "flex" |
|
|
|
357
360
|
|
|
358
361
|
/**
|
|
359
362
|
* Applies canonical cache config for OpenAI. Session affinity flows via
|
|
360
|
-
* `prompt_cache_key` (handled by the adapter);
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* are UNSUPPORTED: warn and drop.
|
|
363
|
+
* `prompt_cache_key` (handled by the adapter); `long` retention maps to
|
|
364
|
+
* `prompt_cache_retention: "24h"` when the catalog supports it (handled by
|
|
365
|
+
* the adapter, pi `api/openai-responses.ts:97` rule). Other explicit
|
|
366
|
+
* retention modes and `cachedContentId` are UNSUPPORTED: warn and drop.
|
|
364
367
|
*/
|
|
365
368
|
export function applyCacheForOpenAI(
|
|
366
369
|
cache: CacheConfig | undefined,
|
|
367
370
|
modelRef: string
|
|
368
371
|
): void {
|
|
369
372
|
if (!cache) return;
|
|
370
|
-
if (cache.retention && cache.retention !== "implicit") {
|
|
373
|
+
if (cache.retention && cache.retention !== "implicit" && cache.retention !== "long") {
|
|
371
374
|
emitProviderWarning({
|
|
372
375
|
provider: "openai",
|
|
373
376
|
capability: "cache retention",
|
|
@@ -489,3 +492,251 @@ export function parseStreamedToolArguments(startText: string, deltaText: string)
|
|
|
489
492
|
}
|
|
490
493
|
return { raw: startText + deltaText };
|
|
491
494
|
}
|
|
495
|
+
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
// Structured outputs (canonical, pure, no I/O)
|
|
498
|
+
// ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
function isZodLike(value: unknown): boolean {
|
|
501
|
+
if (!value || typeof value !== "object") return false;
|
|
502
|
+
const v = value as Record<string, unknown>;
|
|
503
|
+
return (
|
|
504
|
+
typeof v["safeParse"] === "function" ||
|
|
505
|
+
typeof v["parse"] === "function" ||
|
|
506
|
+
"_def" in v ||
|
|
507
|
+
"~standard" in v
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function looksLikeJsonSchema(value: unknown): boolean {
|
|
512
|
+
if (!isPlainObject(value)) return false;
|
|
513
|
+
const v = value as Record<string, unknown>;
|
|
514
|
+
return (
|
|
515
|
+
typeof v["type"] === "string" ||
|
|
516
|
+
isPlainObject(v["properties"]) ||
|
|
517
|
+
Array.isArray(v["anyOf"]) ||
|
|
518
|
+
Array.isArray(v["oneOf"]) ||
|
|
519
|
+
Array.isArray(v["allOf"]) ||
|
|
520
|
+
typeof v["$schema"] === "string" ||
|
|
521
|
+
isPlainObject(v["$defs"]) ||
|
|
522
|
+
isPlainObject(v["definitions"])
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function sanitizeOutputName(raw: unknown): string {
|
|
527
|
+
const base = typeof raw === "string" && raw.trim() ? raw.trim() : "structured_output";
|
|
528
|
+
const cleaned = base.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "structured_output";
|
|
529
|
+
return cleaned;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function schemaToJsonSchema(schema: unknown, what: string): Record<string, unknown> {
|
|
533
|
+
if (isZodLike(schema)) {
|
|
534
|
+
try {
|
|
535
|
+
const converted = zodToJsonSchema(schema);
|
|
536
|
+
if (isPlainObject(converted)) return converted;
|
|
537
|
+
} catch {
|
|
538
|
+
// fall through to error below
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
if (isPlainObject(schema)) return schema as Record<string, unknown>;
|
|
542
|
+
throw new ValidationError(
|
|
543
|
+
`[Agent Accelerator] Invalid structured output ${what}: expected a Zod schema, a JSON Schema object, or { name, schema, strict }.`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Normalizes `AgentConfig.output` into a provider-agnostic spec.
|
|
549
|
+
*
|
|
550
|
+
* Accepts a Zod schema, a JSON Schema object, or an explicit
|
|
551
|
+
* `{ name?, description?, schema, strict? }` wrapper. Returns `undefined`
|
|
552
|
+
* when no output was requested. Throws `ValidationError` for invalid shapes.
|
|
553
|
+
*
|
|
554
|
+
* @example `normalizeStructuredOutput(z.object({ city: z.string() }))`
|
|
555
|
+
*/
|
|
556
|
+
export function normalizeStructuredOutput(output: unknown): StructuredOutputSpec | undefined {
|
|
557
|
+
if (output === undefined || output === null) return undefined;
|
|
558
|
+
if (isPlainObject(output) && "schema" in (output as Record<string, unknown>)) {
|
|
559
|
+
const rec = output as Record<string, unknown>;
|
|
560
|
+
const schema = schemaToJsonSchema(rec["schema"], "`output.schema`");
|
|
561
|
+
return {
|
|
562
|
+
name: sanitizeOutputName(rec["name"]),
|
|
563
|
+
schema,
|
|
564
|
+
strict: typeof rec["strict"] === "boolean" ? (rec["strict"] as boolean) : true,
|
|
565
|
+
...(typeof rec["description"] === "string" && (rec["description"] as string).trim()
|
|
566
|
+
? { description: (rec["description"] as string).trim() }
|
|
567
|
+
: {}),
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
if (isZodLike(output) || looksLikeJsonSchema(output)) {
|
|
571
|
+
return {
|
|
572
|
+
name: "structured_output",
|
|
573
|
+
schema: schemaToJsonSchema(output, "`output`"),
|
|
574
|
+
strict: true,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
throw new ValidationError(
|
|
578
|
+
"[Agent Accelerator] Invalid structured output: expected a Zod schema, a JSON Schema object, or { name, schema, strict }."
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Extracts the original Zod validator from a canonical output option, when
|
|
584
|
+
* present (bare Zod schema or `{ schema: Zod }`). Used by the loop for
|
|
585
|
+
* post-parse validation; `undefined` means JSON-parse only.
|
|
586
|
+
*/
|
|
587
|
+
export function extractOutputValidator(output: unknown): { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } } | undefined {
|
|
588
|
+
if (!output) return undefined;
|
|
589
|
+
if (isZodLike(output) && typeof (output as { safeParse?: unknown }).safeParse === "function") {
|
|
590
|
+
return output as { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } };
|
|
591
|
+
}
|
|
592
|
+
if (isPlainObject(output) && isZodLike((output as Record<string, unknown>)["schema"])) {
|
|
593
|
+
const inner = (output as Record<string, unknown>)["schema"] as { safeParse?: unknown };
|
|
594
|
+
if (typeof inner.safeParse === "function") {
|
|
595
|
+
return inner as { safeParse: (v: unknown) => { success: boolean; data?: unknown; error?: { message: string } } };
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function stripCodeFences(text: string): string {
|
|
602
|
+
const trimmed = text.trim();
|
|
603
|
+
const fenced = trimmed.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/i);
|
|
604
|
+
if (fenced && typeof fenced[1] === "string") return fenced[1].trim();
|
|
605
|
+
return trimmed;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Parses and validates a final answer against a canonical output option.
|
|
610
|
+
* Strips markdown fences, requires valid JSON, then applies the Zod validator
|
|
611
|
+
* when the original option carried one. Throws `ValidationError` on mismatch.
|
|
612
|
+
*
|
|
613
|
+
* @example `parseStructuredOutput('{"city":"Paris"}', z.object({ city: z.string() }))`
|
|
614
|
+
*/
|
|
615
|
+
export function parseStructuredOutput(text: string, output: unknown): unknown {
|
|
616
|
+
const cleaned = stripCodeFences(typeof text === "string" ? text : String(text ?? ""));
|
|
617
|
+
if (!cleaned) {
|
|
618
|
+
throw new ValidationError(
|
|
619
|
+
"[Agent Accelerator] Structured output validation failed: model returned empty text, expected JSON matching the output schema."
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
let parsed: unknown;
|
|
623
|
+
try {
|
|
624
|
+
parsed = JSON.parse(cleaned);
|
|
625
|
+
} catch {
|
|
626
|
+
throw new ValidationError(
|
|
627
|
+
`[Agent Accelerator] Structured output validation failed: model text is not valid JSON (${cleaned.slice(0, 120)}).`
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
const validator = extractOutputValidator(output);
|
|
631
|
+
if (validator) {
|
|
632
|
+
const res = validator.safeParse(parsed);
|
|
633
|
+
if (!res.success) {
|
|
634
|
+
const detail = res.error?.message ? ` ${res.error.message}`.slice(0, 500) : "";
|
|
635
|
+
throw new ValidationError(
|
|
636
|
+
`[Agent Accelerator] Structured output validation failed: JSON does not match the output schema.${detail}`
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
return res.data ?? parsed;
|
|
640
|
+
}
|
|
641
|
+
return parsed;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Maps a normalized spec onto OpenAI Responses `text.format`.
|
|
646
|
+
* `{ type: json_schema, name, schema, strict }` (docs: responses/create).
|
|
647
|
+
*/
|
|
648
|
+
export function mapOutputToOpenAI(
|
|
649
|
+
spec: StructuredOutputSpec | undefined
|
|
650
|
+
): { format: { type: "json_schema"; name: string; schema: Record<string, unknown>; strict: boolean; description?: string } } | undefined {
|
|
651
|
+
if (!spec) return undefined;
|
|
652
|
+
return {
|
|
653
|
+
format: {
|
|
654
|
+
type: "json_schema",
|
|
655
|
+
name: spec.name,
|
|
656
|
+
schema: spec.schema,
|
|
657
|
+
strict: spec.strict ?? true,
|
|
658
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Maps a normalized spec onto Google Interactions `response_format`.
|
|
665
|
+
* `{ type: text, mime_type: application/json, schema }` (docs: structured-output).
|
|
666
|
+
*/
|
|
667
|
+
export function mapOutputToGoogle(
|
|
668
|
+
spec: StructuredOutputSpec | undefined
|
|
669
|
+
): { responseFormat: { type: "text"; mime_type: "application/json"; schema: Record<string, unknown> } } | undefined {
|
|
670
|
+
if (!spec) return undefined;
|
|
671
|
+
return {
|
|
672
|
+
responseFormat: {
|
|
673
|
+
type: "text",
|
|
674
|
+
mime_type: "application/json",
|
|
675
|
+
schema: spec.schema,
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Chat Completions `response_format` wire shape (OpenRouter + compat). */
|
|
681
|
+
export type ChatCompletionsResponseFormat =
|
|
682
|
+
| { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* Maps a normalized spec onto Chat Completions `response_format`.
|
|
686
|
+
* `{ type: json_schema, json_schema: { name, schema, strict } }`
|
|
687
|
+
* (docs: openai completions/create, openrouter parameters).
|
|
688
|
+
*/
|
|
689
|
+
export function mapOutputToOpenRouterChat(
|
|
690
|
+
spec: StructuredOutputSpec | undefined
|
|
691
|
+
): { response_format: ChatCompletionsResponseFormat } | undefined {
|
|
692
|
+
if (!spec) return undefined;
|
|
693
|
+
return {
|
|
694
|
+
response_format: {
|
|
695
|
+
type: "json_schema",
|
|
696
|
+
json_schema: {
|
|
697
|
+
name: spec.name,
|
|
698
|
+
schema: spec.schema,
|
|
699
|
+
strict: spec.strict ?? true,
|
|
700
|
+
...(spec.description ? { description: spec.description } : {}),
|
|
701
|
+
},
|
|
702
|
+
},
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Maps a normalized spec onto generic compat Chat Completions
|
|
708
|
+
* `response_format` (same standard shape as OpenRouter Chat).
|
|
709
|
+
*/
|
|
710
|
+
export function mapOutputToCompatChat(
|
|
711
|
+
spec: StructuredOutputSpec | undefined
|
|
712
|
+
): { response_format: ChatCompletionsResponseFormat } | undefined {
|
|
713
|
+
return mapOutputToOpenRouterChat(spec);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* Builds the emulated structured-output instruction for models without
|
|
718
|
+
* native `response_format` support (catalog `structured_output: false`).
|
|
719
|
+
* Appended to the system prompt so the model still targets JSON; the loop
|
|
720
|
+
* still validates into `AgentResponse.parsed`.
|
|
721
|
+
*
|
|
722
|
+
* @example `augmentSystemPromptWithStructuredOutput(sys, spec)`
|
|
723
|
+
*/
|
|
724
|
+
export function buildStructuredOutputInstruction(spec: StructuredOutputSpec): string {
|
|
725
|
+
return `Return ONLY valid JSON matching this JSON Schema, with no markdown fences, no commentary, no extra text.\nSchema: ${JSON.stringify(spec.schema)}`;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Returns a system prompt with the emulated structured-output instruction
|
|
730
|
+
* appended (stable per Agent since `output` is agent-level, so prefix-cache
|
|
731
|
+
* affinity is preserved). No-op when spec is undefined.
|
|
732
|
+
*/
|
|
733
|
+
export function augmentSystemPromptWithStructuredOutput(
|
|
734
|
+
systemPrompt: string | undefined,
|
|
735
|
+
spec: StructuredOutputSpec | undefined
|
|
736
|
+
): string | undefined {
|
|
737
|
+
if (!spec) return systemPrompt;
|
|
738
|
+
const instruction = `[Structured Output]\n${buildStructuredOutputInstruction(spec)}`;
|
|
739
|
+
if (!systemPrompt) return instruction;
|
|
740
|
+
if (systemPrompt.includes("[Structured Output]")) return systemPrompt;
|
|
741
|
+
return `${systemPrompt}\n\n${instruction}`;
|
|
742
|
+
}
|
package/src/session/store.ts
CHANGED
|
@@ -1098,19 +1098,28 @@ export type PricedCost = NonNullable<TokenUsage["cost"]> & {
|
|
|
1098
1098
|
};
|
|
1099
1099
|
|
|
1100
1100
|
/**
|
|
1101
|
-
* Service-tier price multipliers (Q-36).
|
|
1102
|
-
* `flex`
|
|
1103
|
-
* at ~
|
|
1104
|
-
*
|
|
1101
|
+
* Service-tier price multipliers (Q-36). Aligned with pi
|
|
1102
|
+
* (`api/openai-responses.ts:387`): `flex` trades latency for ~0.5x price,
|
|
1103
|
+
* `priority`/`fast` reserve capacity at ~2x (`2.5x` for `gpt-5.5`).
|
|
1104
|
+
* Standard/default is 1.0. Estimates because vendors do not publish exact
|
|
1105
|
+
* tier multipliers in the catalog.
|
|
1105
1106
|
*/
|
|
1106
1107
|
const SERVICE_TIER_MULTIPLIERS: Record<string, number> = {
|
|
1107
1108
|
flex: 0.5,
|
|
1108
|
-
priority:
|
|
1109
|
+
priority: 2,
|
|
1110
|
+
fast: 2,
|
|
1109
1111
|
};
|
|
1110
1112
|
|
|
1111
|
-
function serviceTierMultiplier(serviceTier?: string): { multiplier: number; tier?: string } {
|
|
1113
|
+
function serviceTierMultiplier(serviceTier?: string, modelId?: string): { multiplier: number; tier?: string } {
|
|
1112
1114
|
if (!serviceTier) return { multiplier: 1 };
|
|
1113
|
-
const
|
|
1115
|
+
const lower = serviceTier.toLowerCase();
|
|
1116
|
+
if (lower === "priority" || lower === "fast") {
|
|
1117
|
+
if (typeof modelId === "string" && modelId.includes("gpt-5.5")) {
|
|
1118
|
+
return { multiplier: 2.5, tier: serviceTier };
|
|
1119
|
+
}
|
|
1120
|
+
return { multiplier: 2, tier: serviceTier };
|
|
1121
|
+
}
|
|
1122
|
+
const m = SERVICE_TIER_MULTIPLIERS[lower];
|
|
1114
1123
|
return m !== undefined ? { multiplier: m, tier: serviceTier } : { multiplier: 1, tier: serviceTier };
|
|
1115
1124
|
}
|
|
1116
1125
|
|
|
@@ -1190,7 +1199,7 @@ function resolveEffectivePricing(
|
|
|
1190
1199
|
export function priceUsage(
|
|
1191
1200
|
usage: TokenUsage,
|
|
1192
1201
|
spec?: ModelSpec,
|
|
1193
|
-
opts?: { serviceTier?: ServiceTier | string }
|
|
1202
|
+
opts?: { serviceTier?: ServiceTier | string; modelId?: string }
|
|
1194
1203
|
): PricedCost | undefined {
|
|
1195
1204
|
const reported = usage?.cost;
|
|
1196
1205
|
if (reported && typeof reported.totalCost === "number" && reported.totalCost > 0) {
|
|
@@ -1233,7 +1242,7 @@ export function priceUsage(
|
|
|
1233
1242
|
if (!pricing.input && !pricing.output && !pricing.cacheRead && !pricing.cacheWrite && !pricing.audio) {
|
|
1234
1243
|
return undefined;
|
|
1235
1244
|
}
|
|
1236
|
-
const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier);
|
|
1245
|
+
const { multiplier, tier: serviceTier } = serviceTierMultiplier(opts?.serviceTier, opts?.modelId ?? spec?.id);
|
|
1237
1246
|
const nonCachedInput = Math.max(0, inputTokens - cachedTokens);
|
|
1238
1247
|
// Integer micro-USD accumulation: microUSD = tokens * pricePerMillion
|
|
1239
1248
|
// ($P per 1M tokens == $P/1e6 per token == P micro-USD per token).
|