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.
@@ -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
- return options?.apiKey || getApiKey("openrouter", undefined, options?.env);
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
- const { messages, hasFiles } = await fullHistoryMessages(context);
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/response_format/user/seed knobs
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). No store/previous_response_id/conversation: they do not exist
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 err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
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({
@@ -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 `status` code
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); explicit retention control
361
- * lives in `prompt_cache_options` (gpt-5.6+ explicit breakpoints) which is
362
- * out of scope for the most-important subset, so retention/cachedContentId
363
- * are UNSUPPORTED: warn and drop. `prompt_cache_retention` is deprecated.
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
+ }
@@ -1098,19 +1098,28 @@ export type PricedCost = NonNullable<TokenUsage["cost"]> & {
1098
1098
  };
1099
1099
 
1100
1100
  /**
1101
- * Service-tier price multipliers (Q-36). ESTIMATES, not vendor guarantees:
1102
- * `flex` batches trade latency for ~0.5x price, `priority` reserves capacity
1103
- * at ~1.5x. Standard/default is 1.0. Documented as estimates because vendors
1104
- * do not publish exact tier multipliers in the catalog.
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: 1.5,
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 m = SERVICE_TIER_MULTIPLIERS[serviceTier.toLowerCase()];
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).