agent-accelerator 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +229 -113
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. package/src/data/README.md +0 -84
@@ -52,11 +52,18 @@ import {
52
52
  redactedHeaders,
53
53
  readErrorPayload,
54
54
  incompleteStreamError,
55
+ hasTruncatedToolArguments,
55
56
  combinedSignal,
56
57
  timeoutFor,
58
+ hasAuthHeader,
59
+ clampToolCallId,
60
+ openStreamResponse,
61
+ retryOptionsFrom,
62
+ headersToRecord,
63
+ parseRetryAfterMs,
57
64
  } from "./shared.ts";
58
65
  import { createGenericModelSpec } from "../models/catalog.ts";
59
- import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
66
+ import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
60
67
  import {
61
68
  mapThinkingLevelToOpenRouterChat,
62
69
  mapServiceTierToOpenRouter,
@@ -64,6 +71,12 @@ import {
64
71
  mapToolChoiceToOpenRouterChat,
65
72
  noteProviderTurn,
66
73
  parseStreamedToolArguments,
74
+ selectRawArguments,
75
+ mapOutputToOpenRouterChat,
76
+ mapGenerationParamsToChat,
77
+ normalizeStructuredOutput,
78
+ augmentSystemPromptWithStructuredOutput,
79
+ emitProviderWarning,
67
80
  } from "../providers.ts";
68
81
 
69
82
  // ---------------------------------------------------------------------------
@@ -102,6 +115,7 @@ interface ChatRequestBody {
102
115
  service_tier?: string;
103
116
  session_id?: string;
104
117
  plugins?: Array<{ id: string }>;
118
+ response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
105
119
  stream?: boolean;
106
120
  [k: string]: unknown;
107
121
  }
@@ -193,7 +207,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
193
207
  }
194
208
 
195
209
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
196
- return options?.apiKey || getApiKey("openrouter", undefined, options?.env);
210
+ const key = getApiKey("openrouter", options?.apiKey, options?.env);
211
+ if (key) return key;
212
+ if (hasAuthHeader(options?.headers)) return "unused";
213
+ return undefined;
197
214
  }
198
215
 
199
216
  /**
@@ -352,11 +369,17 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
352
369
  messages.push({ role: "system", content: context.systemPrompt });
353
370
  }
354
371
  let hasFiles = false;
372
+ let lastRole: string | null = "system";
355
373
  for (const m of context.messages) {
356
374
  if (m.role === "system") continue;
357
375
  if (m.role === "user") {
376
+ // Strict routes reject `user` directly after `tool`: bridge with a
377
+ // synthetic assistant turn (pi api/openai-completions.ts:1233).
378
+ if (lastRole === "tool") {
379
+ messages.push({ role: "assistant", content: "I have processed the tool results." });
380
+ }
358
381
  if (typeof m.content === "string") {
359
- if (m.content) messages.push({ role: "user", content: m.content });
382
+ if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
360
383
  } else {
361
384
  const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
362
385
  if (hf) hasFiles = true;
@@ -364,7 +387,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
364
387
  for (const url of fileUrls) {
365
388
  blocks.push({ type: "text", text: url });
366
389
  }
367
- if (blocks.length > 0) messages.push({ role: "user", content: blocks });
390
+ if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
368
391
  }
369
392
  } else if (m.role === "assistant") {
370
393
  if (typeof m.content === "string") {
@@ -378,7 +401,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
378
401
  for (const part of m.content) {
379
402
  if (part.type === "tool_call") {
380
403
  calls.push({
381
- id: part.callId || part.id,
404
+ id: clampToolCallId(part.callId || part.id),
382
405
  type: "function",
383
406
  function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
384
407
  });
@@ -388,20 +411,25 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
388
411
  // Prior-turn reasoning is NOT resent: chat history is messages +
389
412
  // tool calls only (documented decision; avoids strict-route 400s).
390
413
  }
414
+ // Skip empty assistant turns (no text + no tool calls): strict
415
+ // routes reject them, and they carry no signal (pi drop rule).
416
+ if (texts.length === 0 && calls.length === 0) continue;
391
417
  messages.push({
392
418
  role: "assistant",
393
419
  content: texts.length > 0 ? texts.join("\n") : null,
394
420
  ...(calls.length > 0 ? { tool_calls: calls } : {}),
395
421
  });
422
+ lastRole = "assistant";
396
423
  } else if (m.role === "tool") {
397
424
  if (typeof m.content === "string") {
398
425
  const queue = (m.name && idsByName.get(m.name)) || [];
399
426
  messages.push({
400
427
  role: "tool",
401
- tool_call_id: queue.shift() || newToolCallId(),
428
+ tool_call_id: clampToolCallId(queue.shift() || newToolCallId()),
402
429
  content: m.content,
403
430
  name: m.name,
404
431
  });
432
+ lastRole = "tool";
405
433
  continue;
406
434
  }
407
435
  if (!Array.isArray(m.content)) continue;
@@ -409,10 +437,11 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
409
437
  if (part.type === "tool_result") {
410
438
  messages.push({
411
439
  role: "tool",
412
- tool_call_id: pairing.get(part.id) || part.id,
440
+ tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
413
441
  content: resultToOutput(part.result),
414
442
  name: part.name,
415
443
  });
444
+ lastRole = "tool";
416
445
  }
417
446
  }
418
447
  }
@@ -490,7 +519,7 @@ function parseResponse(
490
519
  for (const tc of message?.tool_calls ?? []) {
491
520
  const args = parseArguments(tc.function?.arguments);
492
521
  toolCalls.push({
493
- id: tc.id || newToolCallId(),
522
+ id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
494
523
  name: tc.function?.name || "unknown",
495
524
  arguments: args,
496
525
  rawArguments:
@@ -559,7 +588,33 @@ export class OpenRouterChatCompletionsProvider implements Provider {
559
588
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
560
589
  applyCacheForOpenRouter(options?.cache, `openrouter/${modelId}`);
561
590
 
562
- const { messages, hasFiles } = await fullHistoryMessages(context);
591
+ // Structured outputs: native `response_format` when supported, else
592
+ // emulated via system-prompt instruction + client validation (catalog
593
+ // `structured_output: false` models would 400 on the wire shape).
594
+ let effectiveContext = context;
595
+ let outputEmulated = false;
596
+ let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
597
+ try {
598
+ outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
599
+ } catch {
600
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
601
+ outputSpec = undefined;
602
+ }
603
+ if (outputSpec && !modelSupportsStructuredOutput("openrouter", modelId)) {
604
+ outputEmulated = true;
605
+ effectiveContext = {
606
+ ...context,
607
+ systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
608
+ };
609
+ emitProviderWarning({
610
+ provider: "openrouter",
611
+ capability: "structured output",
612
+ requested: `${outputSpec.name} (openrouter/${modelId})`,
613
+ reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
614
+ fallback: "emulated JSON instruction + client validation (no response_format is sent)",
615
+ });
616
+ }
617
+ const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
563
618
  const body: ChatRequestBody = {
564
619
  model: modelId,
565
620
  messages,
@@ -573,12 +628,22 @@ export class OpenRouterChatCompletionsProvider implements Provider {
573
628
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
574
629
  if (effort) body.reasoning = { effort };
575
630
  if (serviceTier) body.service_tier = serviceTier;
631
+ // Documented Chat Completions generation controls (only those the caller set).
632
+ Object.assign(body, mapGenerationParamsToChat(options));
633
+ // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
634
+ // Skipped when emulated (unsupported model): instruction already in messages.
635
+ if (outputSpec && !outputEmulated) {
636
+ try {
637
+ const mapped = mapOutputToOpenRouterChat(outputSpec);
638
+ if (mapped) body.response_format = mapped.response_format;
639
+ } catch {
640
+ // why: invalid output shapes fail fast at Agent construction/loop validation.
641
+ }
642
+ }
576
643
  // PDF ingestion is a documented plugin, not a message shape: enable it
577
644
  // only when file parts are present (never by default).
578
645
  if (hasFiles) body.plugins = [{ id: "file-parser" }];
579
- // No temperature/top_p/max_tokens/stop/response_format/user/seed knobs
580
- // (no canonical options exist; omitting also keeps provider cache keys
581
- // stable). No store/previous_response_id/conversation: they do not exist
646
+ // No store/previous_response_id/conversation: they do not exist
582
647
  // on this endpoint.
583
648
  // Session affinity: top-level `session_id` is the documented sticky-routing
584
649
  // key on Chat Completions (prompt-caching guide) and takes precedence over
@@ -608,7 +673,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
608
673
  if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
609
674
  }
610
675
  setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
611
- setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
676
+ if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
612
677
  return { method: "POST", headers, body: JSON.stringify(body), signal };
613
678
  }
614
679
 
@@ -622,7 +687,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
622
687
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
623
688
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
624
689
  try {
625
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
690
+ const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
626
691
  const text = await res.text();
627
692
  const headers: Record<string, string> = {};
628
693
  res.headers.forEach((v, k) => {
@@ -638,17 +703,27 @@ export class OpenRouterChatCompletionsProvider implements Provider {
638
703
  status: number,
639
704
  url: string,
640
705
  bodyText: string,
641
- modelId: string
706
+ modelId: string,
707
+ headers?: Record<string, string>
642
708
  ): void {
643
709
  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;
710
+ const { message, code, errorType, raw } = readErrorPayload(bodyText);
711
+ const hint =
712
+ status === 401
713
+ ? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
714
+ : "";
715
+ const fullMessage = `${message}${hint}`;
716
+ const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
646
717
  (err as Record<string, unknown>)["statusCode"] = status;
647
718
  (err as Record<string, unknown>)["status"] = status;
648
719
  (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
649
720
  (err as Record<string, unknown>)["url"] = url.split("?")[0];
721
+ (err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
650
722
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
651
723
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
724
+ if (raw) (err as Record<string, unknown>)["raw"] = raw;
725
+ const retryAfterMs = parseRetryAfterMs(headers);
726
+ if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
652
727
  throw toConciseProviderError(err, "openrouter", modelId);
653
728
  }
654
729
 
@@ -695,7 +770,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
695
770
 
696
771
  const doCall = async (): Promise<ProviderGenerateResult> => {
697
772
  const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
698
- this.throwIfError(res.status, url, res.text, clean);
773
+ this.throwIfError(res.status, url, res.text, clean, res.headers);
699
774
  let response: ChatResponse;
700
775
  try {
701
776
  response = JSON.parse(res.text) as ChatResponse;
@@ -723,8 +798,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
723
798
 
724
799
  try {
725
800
  return await withRetries(doCall, {
726
- maxRetries: options?.maxRetries,
727
- maxRetryDelayMs: options?.maxRetryDelayMs,
801
+ ...retryOptionsFrom(options),
728
802
  signal: options?.signal,
729
803
  label: { providerId: "openrouter", modelId: clean },
730
804
  });
@@ -792,25 +866,30 @@ export class OpenRouterChatCompletionsProvider implements Provider {
792
866
  };
793
867
 
794
868
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
795
- const streamTimeoutTimer =
796
- streamTimeoutMs && streamTimeoutMs > 0
797
- ? setTimeout(() => {
798
- try {
799
- linked.abort();
800
- } catch {}
801
- }, streamTimeoutMs)
802
- : undefined;
803
- let res: Response;
804
- try {
805
- res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
806
- } finally {
807
- if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
808
- }
809
- if (!res.ok || !res.body) {
810
- const text = !res.ok ? await res.text().catch(() => "") : "";
811
- if (!res.ok) this.throwIfError(res.status, url, text, clean);
812
- throw toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean);
813
- }
869
+ // Transient failures (429/5xx/network) are retried here, before the
870
+ // first byte; once the stream is live it is never retried.
871
+ const res = await openStreamResponse({
872
+ fetchOnce: async () => {
873
+ const timer =
874
+ streamTimeoutMs && streamTimeoutMs > 0
875
+ ? setTimeout(() => {
876
+ try {
877
+ linked.abort();
878
+ } catch {}
879
+ }, streamTimeoutMs)
880
+ : undefined;
881
+ try {
882
+ return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
883
+ } finally {
884
+ if (timer) clearTimeout(timer);
885
+ }
886
+ },
887
+ onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
888
+ noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
889
+ retry: retryOptionsFrom(options),
890
+ signal: linked.signal,
891
+ label: { providerId: "openrouter", modelId: clean },
892
+ });
814
893
  const responseHeaders: Record<string, string> = {};
815
894
  res.headers.forEach((v, k) => {
816
895
  responseHeaders[k] = v;
@@ -819,7 +898,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
819
898
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
820
899
 
821
900
  const parser = new SSEParser();
822
- const reader = res.body.getReader();
901
+ const reader = res.body!.getReader(); // openStreamResponse guarantees a body
823
902
  const decoder = new TextDecoder();
824
903
  let text = "";
825
904
  let thinking = "";
@@ -995,10 +1074,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
995
1074
  for (const c of calls.values()) {
996
1075
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
997
1076
  const record: ToolCallRecord = {
998
- id: c.id || newToolCallId(),
1077
+ id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
999
1078
  name: c.name,
1000
1079
  arguments: args,
1001
- rawArguments: c.startArgs + c.deltaArgs,
1080
+ rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
1002
1081
  };
1003
1082
  toolCalls.push(record);
1004
1083
  eventStream.push({ type: "tool_call_complete", toolCall: record });
@@ -1027,6 +1106,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1027
1106
  eventStream.fail(incompleteStreamError("openrouter", clean));
1028
1107
  return;
1029
1108
  }
1109
+ // Refuse unfinished tool calls whose args never parsed (output_item.done
1110
+ // never arrived): truncated `{raw}` must not reach the executor (pi
1111
+ // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1112
+ if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1113
+ eventStream.fail(incompleteStreamError("openrouter", clean));
1114
+ return;
1115
+ }
1030
1116
 
1031
1117
  const cleanThinking = cleanThinkingEarly;
1032
1118
  const finalResponse = new AgentResponse({
@@ -1052,8 +1138,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1052
1138
  const isAbort =
1053
1139
  linked.signal.aborted ||
1054
1140
  eventStream.isCancelled() ||
1055
- (raw as { name?: string }).name === "AbortError" ||
1056
- /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
1141
+ (raw as { name?: string }).name === "AbortError";
1057
1142
  eventStream.fail(
1058
1143
  isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1059
1144
  );
@@ -6,8 +6,11 @@
6
6
  * across the four REST transports, plus timeout/signal and id helpers.
7
7
  */
8
8
  import { toConciseProviderError } from "../utils/errors.ts";
9
+ import { withRetries } from "../utils/retry.ts";
10
+ import { redactHeadersCentral } from "../utils/cache.ts";
11
+ import { TRUNCATED_ARGS_KEY, isTruncatedArguments } from "../types/tool.ts";
9
12
 
10
- /** Parses a JSON tool-argument string; non-objects degrade to `{ raw }`. */
13
+ /** Parses a JSON tool-argument string; non-objects degrade to the truncated-arguments marker. */
11
14
  export function parseArguments(raw: string | undefined): Record<string, unknown> {
12
15
  if (!raw) return {};
13
16
  try {
@@ -15,9 +18,9 @@ export function parseArguments(raw: string | undefined): Record<string, unknown>
15
18
  if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
16
19
  return parsed as Record<string, unknown>;
17
20
  }
18
- return { raw };
21
+ return { [TRUNCATED_ARGS_KEY]: raw };
19
22
  } catch {
20
- return { raw };
23
+ return { [TRUNCATED_ARGS_KEY]: raw };
21
24
  }
22
25
  }
23
26
 
@@ -30,39 +33,40 @@ export function normalizeThinkingParts(parts: string[]): string | undefined {
30
33
  }
31
34
 
32
35
  /**
33
- * Redacts secret-bearing headers for the raw audit trail. Superset of the
34
- * four adapter versions: `authorization` (any case) and `x-goog-api-key` are
35
- * redacted; all other headers pass through untouched.
36
+ * Redacts secret-bearing headers for the raw audit trail via the central
37
+ * deny-list: any header whose NAME contains key / token / secret / cookie /
38
+ * authorization (so `x-api-key`, `api-key`, `cf-aig-authorization` …), plus any
39
+ * value that looks like a credential. Everything else passes through.
36
40
  */
37
41
  export function redactedHeaders(headers: Record<string, string>): Record<string, string> {
38
- const out: Record<string, string> = {};
39
- for (const [k, v] of Object.entries(headers)) {
40
- const lower = k.toLowerCase();
41
- out[k] = lower === "authorization" || lower === "x-goog-api-key" ? "[REDACTED]" : v;
42
- }
43
- return out;
42
+ return redactHeadersCentral(headers) as Record<string, string>;
44
43
  }
45
44
 
46
45
  /**
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.
46
+ * Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
47
+ * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
48
+ * `status` code shapes; unknown bodies degrade to a 300-char slice.
50
49
  */
51
50
  export function readErrorPayload(
52
51
  bodyText: string
53
- ): { message: string; code?: string | number; errorType?: string } {
52
+ ): { message: string; code?: string | number; errorType?: string; raw?: string } {
54
53
  try {
55
54
  const parsed: unknown = JSON.parse(bodyText);
56
55
  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;
56
+ const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
58
57
  if (err && typeof err.message === "string") {
59
58
  const code = err.code ?? err.status;
60
59
  const errorType =
61
60
  err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
61
+ const rawCandidate =
62
+ (err as { metadata?: { raw?: unknown } }).metadata?.raw ??
63
+ (first as { metadata?: { raw?: unknown } })?.metadata?.raw;
64
+ const raw = typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
62
65
  return {
63
66
  message: err.message,
64
67
  ...(code !== undefined ? { code } : {}),
65
68
  ...(typeof errorType === "string" ? { errorType } : {}),
69
+ ...(raw ? { raw } : {}),
66
70
  };
67
71
  }
68
72
  return { message: bodyText.slice(0, 300) };
@@ -87,6 +91,30 @@ export function incompleteStreamError(provider: string, modelId: string): Error
87
91
  return err;
88
92
  }
89
93
 
94
+ /**
95
+ * True when an error signals an evicted/invalid chained interaction
96
+ * (Google Interactions `404` / `{code: not_found}` on REST, `{event_type:
97
+ * error, error: {code: not_found}}` on SSE). Structured-data only: HTTP
98
+ * status, `code`, or `errorType` — never message text.
99
+ */
100
+ export function isNotFoundChainError(error: unknown): boolean {
101
+ if (!error || typeof error !== "object") return false;
102
+ const err = error as Record<string, unknown>;
103
+ const status = Number(
104
+ (err["status"] as number | undefined) ??
105
+ (err["statusCode"] as number | undefined) ??
106
+ ((err["response"] as Record<string, unknown> | undefined)?.["status"] as number | undefined) ??
107
+ NaN
108
+ );
109
+ if (status === 404) return true;
110
+ const code = String(
111
+ (err["code"] as string | undefined) ??
112
+ (err["errorType"] as string | undefined) ??
113
+ ""
114
+ ).toLowerCase();
115
+ return code === "not_found";
116
+ }
117
+
90
118
  /** Per-request timeout ms from provider options (Q-22). */
91
119
  export function timeoutFor(options: { timeoutMs?: number } | undefined): number | undefined {
92
120
  const t = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
@@ -130,3 +158,127 @@ export function newToolCallId(prefix = "call"): string {
130
158
  } catch {}
131
159
  return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
132
160
  }
161
+
162
+ /**
163
+ * True when custom headers already carry auth (proxy/Copilot flows).
164
+ * Case-insensitive, non-empty `Authorization` or `cf-aig-authorization`.
165
+ * Callers return `"unused"` instead of throwing missing-key.
166
+ */
167
+ export function hasAuthHeader(headers: Record<string, string> | undefined): boolean {
168
+ if (!headers) return false;
169
+ for (const [k, v] of Object.entries(headers)) {
170
+ const lower = k.toLowerCase();
171
+ if ((lower === "authorization" || lower === "cf-aig-authorization") && typeof v === "string" && v.trim().length > 0) {
172
+ return true;
173
+ }
174
+ }
175
+ return false;
176
+ }
177
+
178
+ /** True for truncated streamed tool args (the `__truncated_raw` marker from `parseStreamedToolArguments`). */
179
+ export function hasTruncatedToolArguments(args: unknown): boolean {
180
+ return isTruncatedArguments(args);
181
+ }
182
+
183
+ /** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
184
+ export function shortHashId(value: string): string {
185
+ let h = 0x811c9dc5;
186
+ for (let i = 0; i < value.length; i++) {
187
+ h ^= value.charCodeAt(i);
188
+ h = Math.imul(h, 0x01000193);
189
+ }
190
+ return (h >>> 0).toString(16).padStart(8, "0");
191
+ }
192
+
193
+ /**
194
+ * Clamps a tool-call ID to the 40-char OpenAI limit (pi `api/openai-completions.ts:1194`).
195
+ * Pipe IDs (`callId|itemId`) become `callId_itemHash`; overlong IDs truncate
196
+ * with hash suffix. Plain IDs pass through (truncated if needed).
197
+ */
198
+ export function clampToolCallId(id: string): string {
199
+ const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
200
+ if (!clean.includes("|")) {
201
+ return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
202
+ }
203
+ const sep = clean.indexOf("|");
204
+ const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
205
+ const itemId = clean.slice(sep + 1).replace(/_+$/, "");
206
+ const combined = itemId ? `${callId}_${itemId}` : callId;
207
+ if (combined.length <= 40) return combined;
208
+ const hash = shortHashId(id).slice(0, 8);
209
+ const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1));
210
+ return `${prefix}_${hash}`;
211
+ }
212
+
213
+ /** Plain lower-cased header record from a fetch `Headers` object. */
214
+ export function headersToRecord(headers: Headers): Record<string, string> {
215
+ const out: Record<string, string> = {};
216
+ headers.forEach((v, k) => {
217
+ out[k] = v;
218
+ });
219
+ return out;
220
+ }
221
+
222
+ /**
223
+ * Parses a rate-limit hint from response headers into milliseconds:
224
+ * `retry-after-ms`, then `retry-after` (delta-seconds or HTTP-date).
225
+ * Returns undefined when absent or unparseable.
226
+ */
227
+ export function parseRetryAfterMs(headers: Record<string, string> | undefined, now = Date.now()): number | undefined {
228
+ if (!headers) return undefined;
229
+ const get = (name: string): string | undefined => {
230
+ for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name) return v;
231
+ return undefined;
232
+ };
233
+ const ms = get("retry-after-ms");
234
+ if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0) return Math.round(Number(ms));
235
+ const ra = get("retry-after");
236
+ if (ra === undefined || ra.trim() === "") return undefined;
237
+ const secs = Number(ra);
238
+ if (Number.isFinite(secs)) return secs >= 0 ? Math.round(secs * 1000) : undefined;
239
+ const at = Date.parse(ra);
240
+ return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
241
+ }
242
+
243
+ /**
244
+ * Opens an SSE response, retrying transient failures (429/5xx/network) BEFORE
245
+ * any byte is consumed, so a streaming caller gets the same resilience as
246
+ * `generate()`. Once this resolves the stream is live and never retried.
247
+ * `onHttpError` must throw (usually the adapter's concise `throwIfError`).
248
+ */
249
+ export function openStreamResponse(args: {
250
+ fetchOnce: () => Promise<Response>;
251
+ onHttpError: (res: Response, bodyText: string) => void;
252
+ noBodyError: () => Error;
253
+ retry: ReturnType<typeof retryOptionsFrom>;
254
+ signal: AbortSignal;
255
+ label: { providerId: string; modelId: string };
256
+ }): Promise<Response> {
257
+ return withRetries(
258
+ async () => {
259
+ const res = await args.fetchOnce();
260
+ if (!res.ok) {
261
+ const text = await res.text().catch(() => "");
262
+ args.onHttpError(res, text);
263
+ }
264
+ if (!res.body) throw args.noBodyError();
265
+ return res;
266
+ },
267
+ { ...args.retry, signal: args.signal, label: args.label }
268
+ );
269
+ }
270
+
271
+ /** Retry knobs from provider request options, in the shape `withRetries` expects. */
272
+ export function retryOptionsFrom(options: {
273
+ maxRetries?: number;
274
+ maxRetryDelayMs?: number;
275
+ retryBaseDelayMs?: number;
276
+ retryBudgetMs?: number;
277
+ } | undefined): { maxRetries?: number; maxRetryDelayMs?: number; baseDelayMs?: number; retryBudgetMs?: number } {
278
+ return {
279
+ maxRetries: options?.maxRetries,
280
+ maxRetryDelayMs: options?.maxRetryDelayMs,
281
+ baseDelayMs: options?.retryBaseDelayMs,
282
+ retryBudgetMs: options?.retryBudgetMs,
283
+ };
284
+ }