agent-accelerator 0.5.8 → 0.6.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 (67) hide show
  1. package/README.md +26 -5
  2. package/examples/prompts/SYSTEM_PROMPT.md +2 -0
  3. package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
  4. package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
  5. package/package.json +4 -1
  6. package/src/agent/agent.ts +374 -167
  7. package/src/agent/architecture.md +14 -12
  8. package/src/agent/config-blocks.ts +39 -12
  9. package/src/agent/context.ts +8 -3
  10. package/src/agent/delegation.ts +82 -29
  11. package/src/agent/loop.ts +449 -373
  12. package/src/agent/subagent.ts +1 -3
  13. package/src/agent/subagents-tool.ts +45 -7
  14. package/src/agent/subagents.md +3 -0
  15. package/src/agent/turns.ts +3 -1
  16. package/src/agent/worker.ts +58 -28
  17. package/src/agent/workers.ts +113 -20
  18. package/src/architecture.md +16 -15
  19. package/src/index.ts +34 -10
  20. package/src/models/architecture.md +4 -3
  21. package/src/models/catalog-cache.ts +6 -5
  22. package/src/models/catalog.ts +46 -49
  23. package/src/providers/architecture.md +10 -8
  24. package/src/providers/google.ts +150 -62
  25. package/src/providers/openai-compat.ts +60 -58
  26. package/src/providers/openai.ts +57 -39
  27. package/src/providers/openrouter.ts +87 -32
  28. package/src/providers/registry.ts +12 -26
  29. package/src/providers/shared.ts +89 -39
  30. package/src/providers.ts +56 -115
  31. package/src/scheduler/architecture.md +9 -8
  32. package/src/scheduler/scheduled-actions.md +4 -0
  33. package/src/scheduler/scheduler.ts +186 -60
  34. package/src/scheduler/store.ts +23 -10
  35. package/src/scheduler/time.ts +17 -3
  36. package/src/scheduler/tool.ts +33 -7
  37. package/src/session/architecture.md +7 -6
  38. package/src/session/journal.ts +38 -12
  39. package/src/session/store.ts +122 -61
  40. package/src/session/usage.ts +58 -9
  41. package/src/streaming/architecture.md +4 -3
  42. package/src/streaming/event-stream.ts +26 -12
  43. package/src/telemetry.ts +6 -2
  44. package/src/tools/architecture.md +8 -7
  45. package/src/tools/executor.ts +50 -48
  46. package/src/tools/schema.ts +10 -6
  47. package/src/tools/subagent-result.ts +6 -1
  48. package/src/tools/tool.ts +9 -11
  49. package/src/types/agent.ts +2 -21
  50. package/src/types/architecture.md +11 -10
  51. package/src/types/core.ts +1 -9
  52. package/src/types/errors.ts +26 -4
  53. package/src/types/model.ts +0 -4
  54. package/src/types/tool.ts +12 -2
  55. package/src/update-models.ts +5 -5
  56. package/src/utils/architecture.md +16 -15
  57. package/src/utils/cache.ts +1 -3
  58. package/src/utils/durable.ts +17 -5
  59. package/src/utils/env.ts +9 -7
  60. package/src/utils/errors.ts +50 -44
  61. package/src/utils/headers.ts +3 -10
  62. package/src/utils/media.ts +23 -8
  63. package/src/utils/raw-capture.ts +9 -2
  64. package/src/utils/retry.ts +17 -31
  65. package/src/utils/safe-io.ts +29 -7
  66. package/src/utils/serialization.ts +1 -4
  67. package/src/utils/session.ts +1 -2
@@ -46,12 +46,11 @@ import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.
46
46
  import { normalizeMediaInput } from "../utils/media.ts";
47
47
  import { safeStringify } from "../utils/serialization.ts";
48
48
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
49
- import { isAbortError } from "../types/errors.ts";
49
+ import { ConfigError, isAbortError } from "../types/errors.ts";
50
50
  import {
51
51
  newToolCallId,
52
52
  redactedHeaders,
53
53
  readErrorPayload,
54
- incompleteStreamError,
55
54
  hasUnfinishedToolCall,
56
55
  linkStreamAbort,
57
56
  withAbortTimer,
@@ -123,7 +122,10 @@ interface ChatRequestBody {
123
122
  service_tier?: string;
124
123
  session_id?: string;
125
124
  cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
126
- response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
125
+ response_format?: {
126
+ type: "json_schema";
127
+ json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string };
128
+ };
127
129
  stream?: boolean;
128
130
  [k: string]: unknown;
129
131
  }
@@ -173,7 +175,13 @@ function cleanModelId(model: string | ModelSpec): string {
173
175
  }
174
176
 
175
177
  // The HTTP status of an error chunk inside a stream (the HTTP status itself is 200): its numeric `code`, else the status of its retryable `error_type` (errors-and-debugging.md, "Typed Error Codes"); in: the chunk's error, out: streamInto, where it makes the error retryable.
176
- const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = { rate_limit_exceeded: 429, provider_overloaded: 503, provider_unavailable: 502, timeout: 504, server: 500 };
178
+ const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = {
179
+ rate_limit_exceeded: 429,
180
+ provider_overloaded: 503,
181
+ provider_unavailable: 502,
182
+ timeout: 504,
183
+ server: 500,
184
+ };
177
185
  // HTTP-like status of an error chunk inside a stream, or undefined when unknown; in: streamInto's error branch, out: the status that makes the error retryable.
178
186
  function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
179
187
  if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
@@ -195,7 +203,18 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
195
203
  }
196
204
 
197
205
  // The `input_audio.format` for a MIME type: Chat Completions takes any format string the provider supports (audio.md: wav, mp3, aiff, aac, ogg, flac, m4a, pcm16 ...), so the real format is sent, never a guess; in: an audio part's MIME type, out: contentPartsToBlocks.
198
- const AUDIO_FORMAT: Record<string, string> = { wav: "wav", wave: "wav", mpeg: "mp3", mp3: "mp3", mp4: "m4a", m4a: "m4a", aac: "aac", ogg: "ogg", flac: "flac", aiff: "aiff" };
206
+ const AUDIO_FORMAT: Record<string, string> = {
207
+ wav: "wav",
208
+ wave: "wav",
209
+ mpeg: "mp3",
210
+ mp3: "mp3",
211
+ mp4: "m4a",
212
+ m4a: "m4a",
213
+ aac: "aac",
214
+ ogg: "ogg",
215
+ flac: "flac",
216
+ aiff: "aiff",
217
+ };
199
218
  // The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
200
219
  function audioFormatFor(mimeType?: string): string {
201
220
  const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
@@ -203,7 +222,12 @@ function audioFormatFor(mimeType?: string): string {
203
222
  }
204
223
 
205
224
  // `ImagePart.resolution` as OpenRouter's `image_url.detail` (api-ref ChatContentImage: auto, low, high, original; `original` is downgraded to `high` where unsupported); videos have no such field; in: an image part, out: contentPartsToBlocks.
206
- const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = { low: "low", medium: "auto", high: "high", ultra_high: "original" };
225
+ const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = {
226
+ low: "low",
227
+ medium: "auto",
228
+ high: "high",
229
+ ultra_high: "original",
230
+ };
207
231
 
208
232
  // True when a request's messages carry an image or video by web link (the router fetches those itself); in: the built messages, out: throwIfError's hint.
209
233
  function hasRemoteMedia(messages: ChatMessage[]): boolean {
@@ -211,7 +235,9 @@ function hasRemoteMedia(messages: ChatMessage[]): boolean {
211
235
  (m) =>
212
236
  Array.isArray(m.content) &&
213
237
  (m.content as ChatContentPart[]).some((p) => {
214
- const url = (p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ?? (p as { video_url?: { url?: string } }).video_url?.url;
238
+ const url =
239
+ (p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ??
240
+ (p as { video_url?: { url?: string } }).video_url?.url;
215
241
  return typeof url === "string" && /^https?:\/\//.test(url);
216
242
  })
217
243
  );
@@ -258,10 +284,7 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
258
284
  const mimeType = (part as { mimeType?: string }).mimeType;
259
285
  // OpenAI chat audio shape (router parsed it live; capability routing
260
286
  // decides per model). Always inline — `input_audio` carries data only.
261
- const norm = await normalizeMediaInput(
262
- raw as string | Uint8Array | ArrayBuffer,
263
- mimeType
264
- );
287
+ const norm = await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, mimeType);
265
288
  blocks.push({
266
289
  type: "input_audio",
267
290
  input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
@@ -273,7 +296,12 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
273
296
  const fileData =
274
297
  typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
275
298
  ? raw
276
- : (await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, (part as { mimeType?: string }).mimeType)).dataUrl;
299
+ : (
300
+ await normalizeMediaInput(
301
+ raw as string | Uint8Array | ArrayBuffer,
302
+ (part as { mimeType?: string }).mimeType
303
+ )
304
+ ).dataUrl;
277
305
  blocks.push({
278
306
  type: "file",
279
307
  file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
@@ -329,10 +357,16 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
329
357
  messages.push({ role: "assistant", content: "I have processed the tool results." });
330
358
  }
331
359
  if (typeof m.content === "string") {
332
- if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
360
+ if (m.content) {
361
+ messages.push({ role: "user", content: m.content });
362
+ lastRole = "user";
363
+ }
333
364
  } else {
334
365
  const blocks = await contentPartsToBlocks(m.content);
335
- if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
366
+ if (blocks.length > 0) {
367
+ messages.push({ role: "user", content: blocks });
368
+ lastRole = "user";
369
+ }
336
370
  }
337
371
  } else if (m.role === "assistant") {
338
372
  if (typeof m.content === "string") {
@@ -414,7 +448,8 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
414
448
  cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
415
449
  thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
416
450
  };
417
- if (typeof raw?.cost === "number" && raw.cost >= 0) { // an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
451
+ if (typeof raw?.cost === "number" && raw.cost >= 0) {
452
+ // an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
418
453
  usage.cost = { totalCost: raw.cost };
419
454
  }
420
455
  return usage;
@@ -460,7 +495,12 @@ export class OpenRouterChatCompletionsProvider implements Provider {
460
495
  const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
461
496
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
462
497
  // Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
463
- const cache = resolveCache("openrouter", options?.cache, /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
498
+ const cache = resolveCache(
499
+ "openrouter",
500
+ options?.cache,
501
+ /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"],
502
+ `openrouter/${modelId}`
503
+ );
464
504
  warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
465
505
 
466
506
  // Structured outputs: native `response_format` when supported, else
@@ -485,7 +525,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
485
525
  provider: "openrouter",
486
526
  capability: "structured output",
487
527
  requested: `${outputSpec.name} (openrouter/${modelId})`,
488
- reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
528
+ reason:
529
+ "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
489
530
  fallback: "emulated JSON instruction + client validation (no response_format is sent)",
490
531
  });
491
532
  }
@@ -505,7 +546,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
505
546
  if (serviceTier) body.service_tier = serviceTier;
506
547
  if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
507
548
  // Documented Chat Completions generation controls (only those the caller set).
508
- Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
549
+ Object.assign(body, mapGenerationParamsToChat(options));
509
550
  // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
510
551
  // Skipped when emulated (unsupported model): instruction already in messages.
511
552
  if (outputSpec && !outputEmulated) {
@@ -537,11 +578,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
537
578
  ): RequestInit {
538
579
  // Attribution + session headers mirror the previous transport exactly
539
580
  // (HTTP-Referer / X-Title / x-session-id / x-client-request-id).
540
- const headers: Record<string, string> = buildSessionHeaders(
541
- "openrouter",
542
- options?.headers,
543
- sessionId
544
- );
581
+ const headers: Record<string, string> = buildSessionHeaders("openrouter", options?.headers, sessionId);
545
582
  setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
546
583
  if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
547
584
  return { method: "POST", headers, body: JSON.stringify(body), signal };
@@ -593,10 +630,16 @@ export class OpenRouterChatCompletionsProvider implements Provider {
593
630
  options?: ProviderRequestOptions
594
631
  ): Promise<ProviderGenerateResult> {
595
632
  // The whole call stays bounded by `timeoutMs`; the stream's own timer only covers the wait for its first byte.
596
- const { signal, cleanup } = combinedSignal(options?.signal, timeoutFor(options as { timeoutMs?: number } | undefined));
633
+ const { signal, cleanup } = combinedSignal(
634
+ options?.signal,
635
+ timeoutFor(options as { timeoutMs?: number } | undefined)
636
+ );
597
637
  try {
598
638
  // Nobody reads the deltas here, so the stream keeps none (a burst of frames would overflow its buffer for nothing).
599
- const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, { ...options, signal });
639
+ const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, {
640
+ ...options,
641
+ signal,
642
+ });
600
643
  const res = await events.result();
601
644
  return {
602
645
  text: res.text,
@@ -648,7 +691,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
648
691
  try {
649
692
  const apiKey = resolveApiKey(options);
650
693
  if (!apiKey) {
651
- throw new Error(
694
+ throw new ConfigError(
652
695
  "[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
653
696
  );
654
697
  }
@@ -675,9 +718,14 @@ export class OpenRouterChatCompletionsProvider implements Provider {
675
718
  // Transient failures (429/5xx/network) are retried here, before the
676
719
  // first byte; once the stream is live it is never retried.
677
720
  const res = await openStreamResponse({
678
- fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
679
- onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
680
- noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
721
+ fetchOnce: () =>
722
+ withAbortTimer(linked, streamTimeoutMs, () =>
723
+ (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))
724
+ ),
725
+ onHttpError: (r, text) =>
726
+ this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
727
+ noBodyError: () =>
728
+ toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
681
729
  retry: retryOptionsFrom(options),
682
730
  signal: linked.signal,
683
731
  label: { providerId: "openrouter", modelId: clean },
@@ -686,7 +734,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
686
734
  res.headers.forEach((v, k) => {
687
735
  responseHeaders[k] = v;
688
736
  });
689
- const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
737
+ const responseMeta: Record<string, unknown> & {
738
+ status: number;
739
+ statusText: string;
740
+ headers: Record<string, string>;
741
+ } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
690
742
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
691
743
 
692
744
  let text = "";
@@ -721,7 +773,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
721
773
  }
722
774
  // Mid-stream provider error: top-level `error`, HTTP stays 200.
723
775
  // Must surface — resolving empty success hides it.
724
- const topError = msg["error"] as { message?: string; code?: string | number; metadata?: { error_type?: string } } | undefined;
776
+ const topError = msg["error"] as
777
+ | { message?: string; code?: string | number; metadata?: { error_type?: string } }
778
+ | undefined;
725
779
  if (topError && typeof topError === "object") {
726
780
  const message =
727
781
  typeof topError.message === "string" && topError.message
@@ -738,7 +792,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
738
792
  if (typeof msg["id"] === "string" && !responseId) {
739
793
  responseId = msg["id"] as string;
740
794
  }
741
- const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
795
+ const choice =
796
+ (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
742
797
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
743
798
  // Text delta (content-free accounting frames carry "" — ignored).
744
799
  // Refusal deltas surface as text with a `refusal` finish.
@@ -1,3 +1,4 @@
1
+ import { ConfigError } from "../types/errors.ts";
1
2
  import type { Provider, ProviderId, ModelSpec } from "../types/model.ts";
2
3
  import type { ThinkingLevel } from "../types/core.ts";
3
4
  import { OpenAICompatibleChatProvider } from "./openai-compat.ts";
@@ -30,12 +31,7 @@ providerRegistry.set("openai", openaiProvider);
30
31
  // MODEL="cerebras/gpt-oss-120b" + CEREBRAS_API_KEY + CEREBRAS_BASE_URL
31
32
  // MODEL="ollama/qwen2.5-coder" + OLLAMA_BASE_URL (key optional for local)
32
33
  // ---------------------------------------------------------------------------
33
- const FIRST_CLASS = new Set([
34
- "google",
35
- "gemini",
36
- "openrouter",
37
- "openai",
38
- ]);
34
+ const FIRST_CLASS = new Set(["google", "gemini", "openrouter", "openai"]);
39
35
 
40
36
  // Removed providers: fail loudly instead of misrouting. OpenCode free models
41
37
  // are harness-only since 2026, so the opencode/opencode-go prefixes no longer
@@ -54,7 +50,7 @@ function removedProviderMessage(id: string): string {
54
50
  /** Throws for removed provider prefixes. */
55
51
  function assertProviderNotRemoved(id: string): void {
56
52
  if (REMOVED_PROVIDERS.has(id.trim().toLowerCase())) {
57
- throw new Error(removedProviderMessage(id.trim()));
53
+ throw new ConfigError(removedProviderMessage(id.trim()));
58
54
  }
59
55
  }
60
56
 
@@ -83,7 +79,7 @@ export function ensureCustomProvider(
83
79
  const id = normalizeProviderPrefix(prefix) as ProviderId;
84
80
  if (FIRST_CLASS.has(id.toLowerCase())) {
85
81
  if (opts?.baseUrl !== undefined || opts?.apiKey !== undefined) {
86
- throw new Error(
82
+ throw new ConfigError(
87
83
  `[Agent Accelerator] Provider "${id}" is first-class and cannot be replaced with custom baseUrl/apiKey. Pass baseUrl/apiKey per-Agent instead.`
88
84
  );
89
85
  }
@@ -93,7 +89,7 @@ export function ensureCustomProvider(
93
89
  if (existing) {
94
90
  const prevBase = (existing as { configuredBaseUrl?: string }).configuredBaseUrl;
95
91
  if (opts?.baseUrl !== undefined && prevBase !== undefined && prevBase !== opts.baseUrl) {
96
- throw new Error(
92
+ throw new ConfigError(
97
93
  `[Agent Accelerator] Conflicting re-registration for provider "${id}". Registry is immutable after creation.`
98
94
  );
99
95
  }
@@ -120,7 +116,7 @@ export function getProvider(id: ProviderId | string): Provider {
120
116
  const provider = providerRegistry.get(key);
121
117
  if (provider) return provider;
122
118
  if (!key) {
123
- throw new Error(
119
+ throw new ConfigError(
124
120
  `Provider '' is not supported or not registered. Available first-support providers: google, openrouter, openai — or any custom prefix via {PREFIX}_API_KEY + {PREFIX}_BASE_URL.`
125
121
  );
126
122
  }
@@ -146,7 +142,7 @@ export interface ResolvedModel {
146
142
  */
147
143
  export function resolveModel(model: string | ModelSpec): ResolvedModel {
148
144
  if (!model || (typeof model === "string" && !model.trim())) {
149
- throw new Error(
145
+ throw new ConfigError(
150
146
  "[Agent Accelerator] No model specified. Please specify 'model' in Agent configuration or set the MODEL environment variable."
151
147
  );
152
148
  }
@@ -167,9 +163,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
167
163
  // Try catalog first for any provider/model
168
164
  const tryCatalogLookup = (prov: string, mid: string): ModelSpec | undefined => {
169
165
  return (
170
- getModelFromCatalog(prov, mid) ||
171
- getModelFromCatalog(prov.toLowerCase(), mid) ||
172
- getModelFromCatalog(mid, mid)
166
+ getModelFromCatalog(prov, mid) || getModelFromCatalog(prov.toLowerCase(), mid) || getModelFromCatalog(mid, mid)
173
167
  );
174
168
  };
175
169
 
@@ -193,11 +187,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
193
187
  }
194
188
 
195
189
  // 2. Unified layer: any non-first-class prefix routes to auto-created custom provider
196
- const isExplicitOther = [
197
- "google",
198
- "gemini",
199
- "openrouter",
200
- ].includes(providerPrefix);
190
+ const isExplicitOther = ["google", "gemini", "openrouter"].includes(providerPrefix);
201
191
 
202
192
  if (!isExplicitOther && providerPrefix !== "openai") {
203
193
  const envName = providerPrefix.toUpperCase().replace(/[^A-Z0-9]/g, "_");
@@ -209,8 +199,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
209
199
  getEnv(`${envName}_BASE_API_KEY`)
210
200
  );
211
201
 
212
- const looksLikeOpenRouterScope =
213
- parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
202
+ const looksLikeOpenRouterScope = parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
214
203
 
215
204
  if (!looksLikeOpenRouterScope) {
216
205
  const custom = ensureCustomProvider(providerPrefix);
@@ -250,9 +239,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
250
239
  }
251
240
 
252
241
  // Check catalog for this exact provider/model
253
- const catalogSpec =
254
- tryCatalogLookup(providerPrefix, remainingModel) ||
255
- tryCatalogLookup(providerPrefix, modelStr);
242
+ const catalogSpec = tryCatalogLookup(providerPrefix, remainingModel) || tryCatalogLookup(providerPrefix, modelStr);
256
243
  if (catalogSpec) {
257
244
  const internalProvider = getProvider(catalogSpec.provider);
258
245
  return { provider: internalProvider, modelId: catalogSpec.id, modelSpec: catalogSpec };
@@ -276,8 +263,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
276
263
 
277
264
  // Fallback custom provider
278
265
  const fallbackCustom = ensureCustomProvider(providerPrefix);
279
- const fallbackSpec =
280
- fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
266
+ const fallbackSpec = fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
281
267
  return {
282
268
  provider: fallbackCustom,
283
269
  modelId: remainingModel,
@@ -28,9 +28,7 @@ export function parseArguments(raw: string | undefined): Record<string, unknown>
28
28
 
29
29
  /** Trims/collapses assembled thinking parts (no edge-tripling). */
30
30
  export function normalizeThinkingParts(parts: string[]): string | undefined {
31
- const cleaned = parts
32
- .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
33
- .filter((p) => p.length > 0);
31
+ const cleaned = parts.map((p) => p.replace(/\n{3,}/g, "\n\n").trim()).filter((p) => p.length > 0);
34
32
  return cleaned.length > 0 ? cleaned.join("\n") : undefined;
35
33
  }
36
34
 
@@ -49,21 +47,35 @@ export function redactedHeaders(headers: Record<string, string>): Record<string,
49
47
  * OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw` + `metadata.limit_source`, and Google
50
48
  * `status` code shapes; unknown bodies degrade to a 300-char slice.
51
49
  */
52
- export function readErrorPayload(
53
- bodyText: string
54
- ): { message: string; code?: string | number; errorType?: string; raw?: string; limitSource?: string } {
50
+ export function readErrorPayload(bodyText: string): {
51
+ message: string;
52
+ code?: string | number;
53
+ errorType?: string;
54
+ raw?: string;
55
+ limitSource?: string;
56
+ } {
55
57
  try {
56
58
  const parsed: unknown = JSON.parse(bodyText);
57
59
  const first = Array.isArray(parsed) ? parsed[0] : parsed;
58
- const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown } } })?.error;
60
+ const err = (
61
+ first as {
62
+ error?: {
63
+ message?: string;
64
+ code?: string | number;
65
+ type?: string;
66
+ status?: string | number;
67
+ metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown };
68
+ };
69
+ }
70
+ )?.error;
59
71
  if (err && typeof err.message === "string") {
60
72
  const code = err.code ?? err.status;
61
- const errorType =
62
- err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
73
+ const errorType = err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
63
74
  const rawCandidate =
64
75
  (err as { metadata?: { raw?: unknown } }).metadata?.raw ??
65
76
  (first as { metadata?: { raw?: unknown } })?.metadata?.raw;
66
- const raw = typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
77
+ const raw =
78
+ typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
67
79
  return {
68
80
  message: err.message,
69
81
  ...(code !== undefined ? { code } : {}),
@@ -78,7 +90,7 @@ export function readErrorPayload(
78
90
  }
79
91
  }
80
92
 
81
- /** Retryable incomplete-stream error for truncated SSE. */
93
+ /** Retryable incomplete-stream error for truncated SSE: a `ProviderError` whose `code` is `incomplete_stream`. */
82
94
  export function incompleteStreamError(provider: string, modelId: string): Error {
83
95
  const err = toConciseProviderError(
84
96
  Object.assign(new Error("Stream ended without terminal event"), {
@@ -110,12 +122,10 @@ export function isNotFoundChainError(error: unknown): boolean {
110
122
  NaN
111
123
  );
112
124
  if (status === 404) return true;
113
- const code = String(
114
- (err["code"] as string | undefined) ??
115
- (err["errorType"] as string | undefined) ??
116
- ""
117
- ).toLowerCase();
118
- return code === "not_found";
125
+ // `providerCode` on a wrapped ProviderError (its `code` is the SDK kind), `code` on a raw error.
126
+ return [err["providerCode"], err["code"], err["errorType"]].some(
127
+ (c) => String(c ?? "").toLowerCase() === "not_found"
128
+ );
119
129
  }
120
130
 
121
131
  /** Per-request timeout ms from provider options. */
@@ -125,25 +135,40 @@ export function timeoutFor(options: { timeoutMs?: number } | undefined): number
125
135
  }
126
136
 
127
137
  /** Merges a parent signal with a timeout into one effective signal. Caller must call cleanup when the request settles so the timer never holds the loop open. */
128
- export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { signal: AbortSignal | undefined; cleanup: () => void } {
138
+ export function combinedSignal(
139
+ signal?: AbortSignal,
140
+ timeoutMs?: number
141
+ ): { signal: AbortSignal | undefined; cleanup: () => void } {
129
142
  if (timeoutMs === undefined) return { signal, cleanup: () => {} };
130
143
  const ctrl = new AbortController();
131
144
  const timer = setTimeout(() => {
132
- try { ctrl.abort(); } catch {}
145
+ try {
146
+ ctrl.abort();
147
+ } catch {}
133
148
  }, timeoutMs);
134
- try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
149
+ try {
150
+ (timer as unknown as { unref?: () => void }).unref?.();
151
+ } catch {}
135
152
  const cleanup = () => {
136
- try { clearTimeout(timer); } catch {}
153
+ try {
154
+ clearTimeout(timer);
155
+ } catch {}
137
156
  };
138
157
  if (signal) {
139
158
  if (signal.aborted) {
140
159
  cleanup();
141
160
  ctrl.abort(signal.reason); // the caller's reason survives (`interrupt(new Error("..."))`)
142
161
  } else {
143
- signal.addEventListener("abort", () => {
144
- cleanup();
145
- try { ctrl.abort(signal.reason); } catch {}
146
- }, { once: true });
162
+ signal.addEventListener(
163
+ "abort",
164
+ () => {
165
+ cleanup();
166
+ try {
167
+ ctrl.abort(signal.reason);
168
+ } catch {}
169
+ },
170
+ { once: true }
171
+ );
147
172
  }
148
173
  }
149
174
  return { signal: ctrl.signal, cleanup };
@@ -171,7 +196,11 @@ export function hasAuthHeader(headers: Record<string, string> | undefined): bool
171
196
  if (!headers) return false;
172
197
  for (const [k, v] of Object.entries(headers)) {
173
198
  const lower = k.toLowerCase();
174
- if ((lower === "authorization" || lower === "cf-aig-authorization") && typeof v === "string" && v.trim().length > 0) {
199
+ if (
200
+ (lower === "authorization" || lower === "cf-aig-authorization") &&
201
+ typeof v === "string" &&
202
+ v.trim().length > 0
203
+ ) {
175
204
  return true;
176
205
  }
177
206
  }
@@ -184,7 +213,10 @@ export function hasTruncatedToolArguments(args: unknown): boolean {
184
213
  }
185
214
 
186
215
  // True when a stream with no terminal event left a tool call whose arguments never fully arrived (none at all, or cut mid-JSON); in: the adapter's assembled calls and their records, out: its incomplete_stream guard.
187
- export function hasUnfinishedToolCall(calls: Iterable<{ startArgs: string; deltaArgs: string }>, toolCalls: Array<{ arguments: unknown }>): boolean {
216
+ export function hasUnfinishedToolCall(
217
+ calls: Iterable<{ startArgs: string; deltaArgs: string }>,
218
+ toolCalls: Array<{ arguments: unknown }>
219
+ ): boolean {
188
220
  for (const c of calls) if (!c.startArgs.trim() && !c.deltaArgs.trim()) return true;
189
221
  return toolCalls.some((c) => isTruncatedArguments(c.arguments));
190
222
  }
@@ -224,7 +256,9 @@ export function clampToolCallId(id: string): string {
224
256
 
225
257
  // The finish reason of a turn that produced tool calls: `tool_calls`, except that a turn cut off by the output limit (`length`) keeps saying so when one of its calls was cut mid-JSON; in: the wire finish reason + the assembled calls, out: each streaming adapter.
226
258
  export function toolTurnFinishReason(finishReason: string, toolCalls: Array<{ arguments: unknown }>): string {
227
- return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments)) ? "length" : "tool_calls";
259
+ return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments))
260
+ ? "length"
261
+ : "tool_calls";
228
262
  }
229
263
 
230
264
  // The JSON text of a tool call's arguments for a history message: `{}` for a call whose streamed arguments were cut off (the internal `__truncated_raw` marker was never the model's own output and is never resent); in: a stored tool call, out: each adapter's history builder.
@@ -253,7 +287,8 @@ export function parseRetryAfterMs(headers: Record<string, string> | undefined, n
253
287
  return undefined;
254
288
  };
255
289
  const ms = get("retry-after-ms");
256
- if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0) return Math.round(Number(ms));
290
+ if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0)
291
+ return Math.round(Number(ms));
257
292
  const ra = get("retry-after");
258
293
  if (ra === undefined || ra.trim() === "") return undefined;
259
294
  const secs = Number(ra);
@@ -291,12 +326,16 @@ export function openStreamResponse(args: {
291
326
  }
292
327
 
293
328
  /** Retry knobs from provider request options, in the shape `withRetries` expects. */
294
- export function retryOptionsFrom(options: {
295
- maxRetries?: number;
296
- maxRetryDelayMs?: number;
297
- retryBaseDelayMs?: number;
298
- retryBudgetMs?: number;
299
- } | undefined): { maxRetries?: number; maxRetryDelayMs?: number; baseDelayMs?: number; retryBudgetMs?: number } {
329
+ export function retryOptionsFrom(
330
+ options:
331
+ | {
332
+ maxRetries?: number;
333
+ maxRetryDelayMs?: number;
334
+ retryBaseDelayMs?: number;
335
+ retryBudgetMs?: number;
336
+ }
337
+ | undefined
338
+ ): { maxRetries?: number; maxRetryDelayMs?: number; baseDelayMs?: number; retryBudgetMs?: number } {
300
339
  return {
301
340
  maxRetries: options?.maxRetries,
302
341
  maxRetryDelayMs: options?.maxRetryDelayMs,
@@ -310,7 +349,10 @@ export function retryOptionsFrom(options: {
310
349
  // ---------------------------------------------------------------------------
311
350
 
312
351
  // Ties a provider stream to the caller's signal and to the stream's own cancel: one controller that aborts when either does; in: an adapter's stream(), out: the controller its request uses and the `release` its finally block calls.
313
- export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal: AbortSignal | undefined): { linked: AbortController; release: () => void } {
352
+ export function linkStreamAbort(
353
+ eventStream: AssistantMessageEventStream,
354
+ signal: AbortSignal | undefined
355
+ ): { linked: AbortController; release: () => void } {
314
356
  const linked = new AbortController();
315
357
  const forwardUserAbort = (): void => {
316
358
  try {
@@ -342,7 +384,11 @@ export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal
342
384
  }
343
385
 
344
386
  // Runs one request attempt under a timer that aborts `controller` after `ms` (none when unset); in: an adapter's fetchOnce, out: its Response.
345
- export async function withAbortTimer<T>(controller: AbortController, ms: number | undefined, run: () => Promise<T>): Promise<T> {
387
+ export async function withAbortTimer<T>(
388
+ controller: AbortController,
389
+ ms: number | undefined,
390
+ run: () => Promise<T>
391
+ ): Promise<T> {
346
392
  const timer =
347
393
  ms && ms > 0
348
394
  ? setTimeout(() => {
@@ -401,13 +447,17 @@ export function incompleteStreamCheck(a: {
401
447
  }): Error | undefined {
402
448
  if (a.hasTerminal || !(a.empty || a.unfinishedCall)) return undefined;
403
449
  const contentType = Object.entries(a.responseHeaders).find(([k]) => k.toLowerCase() === "content-type")?.[1];
404
- const isSSEPayload = (typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) || a.framesSeen > 0 || a.badFrames > 0;
450
+ const isSSEPayload =
451
+ (typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) ||
452
+ a.framesSeen > 0 ||
453
+ a.badFrames > 0;
405
454
  return isSSEPayload ? incompleteStreamError(a.provider, a.modelId) : undefined;
406
455
  }
407
456
 
408
457
  // Fails a stream with the right error: an abort (the signal, the stream's cancel, or an AbortError) becomes the plain `AbortError`, anything else passes through; in: an adapter's catch block, out: the stream's result() rejection.
409
458
  export function failStream(eventStream: AssistantMessageEventStream, linked: AbortController, err: unknown): void {
410
459
  const raw = err instanceof Error ? err : new Error(String(err));
411
- const isAbort = linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
460
+ const isAbort =
461
+ linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
412
462
  eventStream.fail(isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw);
413
463
  }