agent-accelerator 0.5.5 → 0.5.7

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 (47) hide show
  1. package/README.md +16 -17
  2. package/package.json +1 -1
  3. package/src/agent/agent.ts +16 -18
  4. package/src/agent/architecture.md +25 -0
  5. package/src/agent/context.ts +4 -0
  6. package/src/agent/delegation.ts +18 -84
  7. package/src/agent/loop.ts +14 -16
  8. package/src/agent/worker.ts +1 -3
  9. package/src/architecture.md +28 -0
  10. package/src/index.ts +6 -73
  11. package/src/models/architecture.md +14 -0
  12. package/src/models/catalog-cache.ts +3 -0
  13. package/src/models/catalog.ts +7 -0
  14. package/src/providers/architecture.md +24 -0
  15. package/src/providers/google.ts +26 -39
  16. package/src/providers/openai-compat.ts +53 -104
  17. package/src/providers/openai.ts +47 -104
  18. package/src/providers/openrouter.ts +114 -175
  19. package/src/providers/registry.ts +1 -0
  20. package/src/providers/shared.ts +134 -5
  21. package/src/providers.ts +30 -59
  22. package/src/scheduler/architecture.md +19 -0
  23. package/src/scheduler/scheduler.ts +21 -0
  24. package/src/scheduler/store.ts +6 -0
  25. package/src/scheduler/time.ts +2 -0
  26. package/src/session/architecture.md +19 -0
  27. package/src/session/store.ts +18 -3
  28. package/src/session/usage.ts +1 -0
  29. package/src/streaming/architecture.md +13 -0
  30. package/src/streaming/event-stream.ts +5 -0
  31. package/src/telemetry.ts +1 -0
  32. package/src/tools/architecture.md +19 -0
  33. package/src/tools/executor.ts +13 -0
  34. package/src/tools/schema.ts +1 -0
  35. package/src/tools/subagent-result.ts +1 -0
  36. package/src/types/architecture.md +20 -0
  37. package/src/types/message.ts +1 -1
  38. package/src/utils/architecture.md +25 -0
  39. package/src/utils/base64.ts +2 -0
  40. package/src/utils/cache.ts +2 -0
  41. package/src/utils/errors.ts +8 -1
  42. package/src/utils/headers.ts +4 -1
  43. package/src/utils/media.ts +11 -1
  44. package/src/utils/raw-capture.ts +3 -0
  45. package/src/utils/retry.ts +1 -0
  46. package/src/utils/safe-io.ts +4 -0
  47. package/src/utils/session.ts +5 -161
@@ -17,9 +17,10 @@
17
17
  * - EVERY request streams, `generate()` included (it collects its own `stream()`): OpenRouter only cancels a request that
18
18
  * streams ("Stream cancellation" in its docs), so a plain non-streaming request could not be stopped by an interrupt.
19
19
  * - STATELESS per request: every turn sends the full canonical `messages`
20
- * array explicitly. Completions defines no `store`/`previous_response_id`/
21
- * `conversation`/`prompt_cache_key`/`session_id` body primitives, so none
22
- * are ever sent; affinity is headers-only best effort (`x-session-id`).
20
+ * array explicitly. Completions has no `store`/`previous_response_id`/
21
+ * `conversation`/`prompt_cache_key`, so none are ever sent. Session affinity is
22
+ * the documented top-level body `session_id` (the sticky-routing key, which wins
23
+ * over the header) plus the `x-session-id` / `x-client-request-id` headers.
23
24
  * - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
24
25
  * and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
25
26
  * parsers is a local canonical correlation id only; it is never sent.
@@ -39,7 +40,6 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
39
40
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
40
41
  import type { TokenUsage } from "../types/core.ts";
41
42
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
42
- import { SSEParser } from "../streaming/sse-parser.ts";
43
43
  import { AgentResponse } from "../types/response.ts";
44
44
  import { getApiKey, getEnv } from "../utils/env.ts";
45
45
  import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
@@ -52,7 +52,14 @@ import {
52
52
  redactedHeaders,
53
53
  readErrorPayload,
54
54
  incompleteStreamError,
55
- hasTruncatedToolArguments,
55
+ hasUnfinishedToolCall,
56
+ linkStreamAbort,
57
+ withAbortTimer,
58
+ readSseData,
59
+ incompleteStreamCheck,
60
+ failStream,
61
+ toolArgumentsJson,
62
+ toolTurnFinishReason,
56
63
  combinedSignal,
57
64
  timeoutFor,
58
65
  hasAuthHeader,
@@ -86,7 +93,7 @@ import {
86
93
 
87
94
  type ChatContentPart =
88
95
  | { type: "text"; text: string }
89
- | { type: "image_url"; image_url: { url: string } }
96
+ | { type: "image_url"; image_url: { url: string; detail?: string } }
90
97
  | { type: "video_url"; video_url: { url: string } }
91
98
  | { type: "input_audio"; input_audio: { data: string; format: string } }
92
99
  | { type: "file"; file: { filename?: string; file_data?: string; file_url?: string } }
@@ -115,7 +122,6 @@ interface ChatRequestBody {
115
122
  reasoning?: { effort: string };
116
123
  service_tier?: string;
117
124
  session_id?: string;
118
- plugins?: Array<{ id: string }>;
119
125
  cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
120
126
  response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
121
127
  stream?: boolean;
@@ -139,6 +145,7 @@ const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
139
145
  // Request building (canonical -> Chat Completions)
140
146
  // ---------------------------------------------------------------------------
141
147
 
148
+ // Endpoint from the option, OPENROUTER_BASE_URL, else openrouter.ai; in: each request, out: the base URL without a trailing slash.
142
149
  function resolveBaseUrl(options?: ProviderRequestOptions): string {
143
150
  return (
144
151
  options?.baseUrl ||
@@ -148,6 +155,7 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
148
155
  ).replace(/\/+$/, "");
149
156
  }
150
157
 
158
+ // Key from the option or env, or `unused` when custom headers carry the auth; in: each request, out: the key or undefined.
151
159
  function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
152
160
  const key = getApiKey("openrouter", options?.apiKey, options?.env);
153
161
  if (key) return key;
@@ -164,6 +172,15 @@ function cleanModelId(model: string | ModelSpec): string {
164
172
  return rawId.replace(/^openrouter\//i, "");
165
173
  }
166
174
 
175
+ // 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 };
177
+ // 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
+ function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
179
+ if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
180
+ return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
181
+ }
182
+
183
+ // Canonical tool declarations as Chat Completions function tools; in: options.tools, out: request `tools`.
167
184
  function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
168
185
  if (!tools || tools.length === 0) return undefined;
169
186
  return tools.map((t) => ({
@@ -177,20 +194,32 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
177
194
  }));
178
195
  }
179
196
 
197
+ // 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" };
199
+ // The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
180
200
  function audioFormatFor(mimeType?: string): string {
181
- const mime = (mimeType || "").toLowerCase();
182
- if (mime.includes("wav")) return "wav";
183
- return "mp3";
201
+ const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
202
+ return AUDIO_FORMAT[subtype] ?? (subtype || "mp3");
184
203
  }
185
204
 
186
- async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
187
- blocks: ChatContentPart[];
188
- fileUrls: string[];
189
- hasFiles: boolean;
190
- }> {
205
+ // `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" };
207
+
208
+ // 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
+ function hasRemoteMedia(messages: ChatMessage[]): boolean {
210
+ return messages.some(
211
+ (m) =>
212
+ Array.isArray(m.content) &&
213
+ (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;
215
+ return typeof url === "string" && /^https?:\/\//.test(url);
216
+ })
217
+ );
218
+ }
219
+
220
+ // Maps canonical text and media parts to Chat Completions content blocks; in: a user message's parts, out: fullHistoryMessages.
221
+ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPart[]> {
191
222
  const blocks: ChatContentPart[] = [];
192
- const fileUrls: string[] = [];
193
- let hasFiles = false;
194
223
  for (const part of parts) {
195
224
  if (part.type === "text" && part.text) {
196
225
  blocks.push({ type: "text", text: part.text });
@@ -198,15 +227,16 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
198
227
  const raw = (part as { image?: unknown }).image;
199
228
  // Remote URLs pass through (router downloads; `image_download_failed`
200
229
  // surfaces if unreachable). Inline only local/base64/binary.
230
+ const detail = IMAGE_DETAIL[(part as { resolution?: string }).resolution ?? ""];
201
231
  if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
202
- blocks.push({ type: "image_url", image_url: { url: raw } });
232
+ blocks.push({ type: "image_url", image_url: { url: raw, ...(detail ? { detail } : {}) } });
203
233
  continue;
204
234
  }
205
235
  const norm = await normalizeMediaInput(
206
236
  raw as string | Uint8Array | ArrayBuffer,
207
237
  (part as { mimeType?: string }).mimeType
208
238
  );
209
- blocks.push({ type: "image_url", image_url: { url: norm.dataUrl } });
239
+ blocks.push({ type: "image_url", image_url: { url: norm.dataUrl, ...(detail ? { detail } : {}) } });
210
240
  } else if (part.type === "video") {
211
241
  const raw = (part as { video?: unknown }).video;
212
242
  // `video_url` is the completions video shape (live: shape accepted,
@@ -220,7 +250,9 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
220
250
  raw as string | Uint8Array | ArrayBuffer,
221
251
  (part as { mimeType?: string }).mimeType
222
252
  );
223
- blocks.push({ type: "video_url", video_url: { url: norm.dataUrl } });
253
+ // video-inputs.md names the QuickTime type `video/mov`.
254
+ const url = norm.mimeType === "video/quicktime" ? `data:video/mov;base64,${norm.base64Data}` : norm.dataUrl;
255
+ blocks.push({ type: "video_url", video_url: { url } });
224
256
  } else if (part.type === "audio") {
225
257
  const raw = (part as { audio?: unknown }).audio;
226
258
  const mimeType = (part as { mimeType?: string }).mimeType;
@@ -235,39 +267,23 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
235
267
  input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
236
268
  });
237
269
  } else if (part.type === "file") {
238
- hasFiles = true;
239
270
  const raw = (part as { file?: unknown }).file;
240
271
  const filename = (part as { filename?: string }).filename;
241
- if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
242
- // Bare `{type:file}` parts are ignored by the route (observed live),
243
- // so the URL must ALSO ride in text for the file-parser plugin (see
244
- // fullHistoryMessages). Still sent forward-compat.
245
- fileUrls.push(raw);
246
- blocks.push({
247
- type: "file",
248
- file: {
249
- ...(typeof filename === "string" && filename ? { filename } : {}),
250
- file_url: raw,
251
- },
252
- });
253
- continue;
254
- }
255
- const norm = await normalizeMediaInput(
256
- raw as string | Uint8Array | ArrayBuffer,
257
- (part as { mimeType?: string }).mimeType
258
- );
272
+ // `file_data` takes a public URL or a base64 data URL (api-ref ChatContentFile; a `file_url` field does not exist).
273
+ const fileData =
274
+ typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
275
+ ? raw
276
+ : (await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, (part as { mimeType?: string }).mimeType)).dataUrl;
259
277
  blocks.push({
260
278
  type: "file",
261
- file: {
262
- ...(typeof filename === "string" && filename ? { filename } : {}),
263
- file_data: norm.dataUrl,
264
- },
279
+ file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
265
280
  });
266
281
  }
267
282
  }
268
- return { blocks, fileUrls, hasFiles };
283
+ return blocks;
269
284
  }
270
285
 
286
+ // A tool result as the text of a tool message; in: fullHistoryMessages, out: a string.
271
287
  function resultToOutput(result: unknown): string {
272
288
  if (typeof result === "string") return result;
273
289
  return safeStringify(result);
@@ -278,15 +294,8 @@ function resultToOutput(result: unknown): string {
278
294
  * Stateless transport: no chaining primitives exist here, so every turn
279
295
  * carries everything. Assistant items carry no provider `id`/`status`
280
296
  * requirements (unlike the discontinued Responses skin).
281
- *
282
- * File handling: remote file URLs must be visible in text for the
283
- * `file-parser` plugin (bare `{type:file}` parts are ignored by the route),
284
- * so each remote file URL is appended as its own text part.
285
297
  */
286
- async function fullHistoryMessages(context: ProviderContext): Promise<{
287
- messages: ChatMessage[];
288
- hasFiles: boolean;
289
- }> {
298
+ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
290
299
  // Map assistant tool_call ids so tool results pair even though the
291
300
  // executor keys results by the call id.
292
301
  const pairing = new Map<string, string>();
@@ -310,7 +319,6 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
310
319
  if (context.systemPrompt) {
311
320
  messages.push({ role: "system", content: context.systemPrompt });
312
321
  }
313
- let hasFiles = false;
314
322
  let lastRole: string | null = "system";
315
323
  for (const m of context.messages) {
316
324
  if (m.role === "system") continue;
@@ -323,12 +331,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
323
331
  if (typeof m.content === "string") {
324
332
  if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
325
333
  } else {
326
- const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
327
- if (hf) hasFiles = true;
328
- // Surface remote file URLs in text for the file-parser plugin.
329
- for (const url of fileUrls) {
330
- blocks.push({ type: "text", text: url });
331
- }
334
+ const blocks = await contentPartsToBlocks(m.content);
332
335
  if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
333
336
  }
334
337
  } else if (m.role === "assistant") {
@@ -345,7 +348,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
345
348
  calls.push({
346
349
  id: clampToolCallId(part.callId || part.id),
347
350
  type: "function",
348
- function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
351
+ function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
349
352
  });
350
353
  } else if (part.type === "text" && part.text) {
351
354
  texts.push(part.text);
@@ -388,13 +391,14 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
388
391
  }
389
392
  }
390
393
  }
391
- return { messages, hasFiles };
394
+ return messages;
392
395
  }
393
396
 
394
397
  // ---------------------------------------------------------------------------
395
398
  // Response mapping (Chat Completions -> canonical)
396
399
  // ---------------------------------------------------------------------------
397
400
 
401
+ // Chat usage as TokenUsage with cache reads clamped to input, cache writes, reasoning tokens and OpenRouter's reported cost (0 counts); in: the last stream chunk, out: TokenUsage.
398
402
  function mapUsage(raw?: ChatUsage): TokenUsage {
399
403
  const input = raw?.prompt_tokens ?? 0;
400
404
  const output = raw?.completion_tokens ?? 0;
@@ -410,7 +414,7 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
410
414
  cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
411
415
  thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
412
416
  };
413
- if (typeof raw?.cost === "number" && raw.cost > 0) {
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
414
418
  usage.cost = { totalCost: raw.cost };
415
419
  }
416
420
  return usage;
@@ -433,6 +437,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
433
437
  return getModelsForProvider("openrouter");
434
438
  }
435
439
 
440
+ // The catalog spec of a model, else a permissive generic one; in: the registry, out: a ModelSpec.
436
441
  getModel(modelId: string): ModelSpec | undefined {
437
442
  const clean = cleanModelId(modelId);
438
443
  return (
@@ -443,6 +448,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
443
448
  );
444
449
  }
445
450
 
451
+ // Builds the Chat request (messages, tools, tool_choice, reasoning, tier, cache_control, generation controls, response_format, session_id); in: streamInto, out: the body.
446
452
  private async buildBody(
447
453
  modelId: string,
448
454
  context: ProviderContext,
@@ -454,8 +460,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
454
460
  const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
455
461
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
456
462
  // Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
457
- const cache = resolveCache("openrouter", options?.cache, modelId.startsWith("anthropic/") ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
458
- warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`);
463
+ const cache = resolveCache("openrouter", options?.cache, /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
464
+ warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
459
465
 
460
466
  // Structured outputs: native `response_format` when supported, else
461
467
  // emulated via system-prompt instruction + client validation (catalog
@@ -483,7 +489,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
483
489
  fallback: "emulated JSON instruction + client validation (no response_format is sent)",
484
490
  });
485
491
  }
486
- const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
492
+ const messages = await fullHistoryMessages(effectiveContext);
487
493
  const body: ChatRequestBody = {
488
494
  model: modelId,
489
495
  messages,
@@ -499,7 +505,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
499
505
  if (serviceTier) body.service_tier = serviceTier;
500
506
  if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
501
507
  // Documented Chat Completions generation controls (only those the caller set).
502
- Object.assign(body, mapGenerationParamsToChat(options));
508
+ Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
503
509
  // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
504
510
  // Skipped when emulated (unsupported model): instruction already in messages.
505
511
  if (outputSpec && !outputEmulated) {
@@ -510,9 +516,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
510
516
  // why: invalid output shapes fail fast at Agent construction/loop validation.
511
517
  }
512
518
  }
513
- // PDF ingestion is a documented plugin, not a message shape: enable it
514
- // only when file parts are present (never by default).
515
- if (hasFiles) body.plugins = [{ id: "file-parser" }];
516
519
  // No store/previous_response_id/conversation: they do not exist
517
520
  // on this endpoint.
518
521
  // Session affinity: top-level `session_id` is the documented sticky-routing
@@ -524,6 +527,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
524
527
  return body;
525
528
  }
526
529
 
530
+ // Fetch options with session and opt-in attribution headers, JSON content type and the Bearer key; in: streamInto, out: RequestInit.
527
531
  private requestInit(
528
532
  body: ChatRequestBody,
529
533
  options: ProviderRequestOptions | undefined,
@@ -543,19 +547,24 @@ export class OpenRouterChatCompletionsProvider implements Provider {
543
547
  return { method: "POST", headers, body: JSON.stringify(body), signal };
544
548
  }
545
549
 
550
+ // Throws a concise provider error (status, code, Retry-After, key and media-link hints) for a non-2xx reply, marking the in-flight-budget 402 retryable; in: streamInto, out: nothing or a thrown error.
546
551
  private throwIfError(
547
552
  status: number,
548
553
  url: string,
549
554
  bodyText: string,
550
555
  modelId: string,
551
- headers?: Record<string, string>
556
+ headers?: Record<string, string>,
557
+ remoteMedia = false
552
558
  ): void {
553
559
  if (status >= 200 && status < 300) return;
554
- const { message, code, errorType, raw } = readErrorPayload(bodyText);
560
+ const { message, code, errorType, raw, limitSource } = readErrorPayload(bodyText);
555
561
  const hint =
556
562
  status === 401
557
563
  ? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
558
- : "";
564
+ : // A bad or unreachable image/video link comes back as the bare "Provider returned error" without saying which part failed.
565
+ status === 400 && remoteMedia && message === "Provider returned error"
566
+ ? " The request carries a remote image or video link: it may be unreachable or not accepted by this route. Try passing the file itself (a local path or bytes) instead."
567
+ : "";
559
568
  const fullMessage = `${message}${hint}`;
560
569
  const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
561
570
  (err as Record<string, unknown>)["statusCode"] = status;
@@ -568,7 +577,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
568
577
  if (raw) (err as Record<string, unknown>)["raw"] = raw;
569
578
  const retryAfterMs = parseRetryAfterMs(headers);
570
579
  if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
571
- throw toConciseProviderError(err, "openrouter", modelId);
580
+ const concise = toConciseProviderError(err, "openrouter", modelId);
581
+ // A 402 from the in-flight spending budget clears by itself once running requests settle (errors-and-debugging.md, Retry-After): retry it after the header's wait. A 402 for missing credits does not.
582
+ if (status === 402 && limitSource === "openrouter_in_flight_budget") {
583
+ (concise as unknown as Record<string, unknown>)["retryable"] = true;
584
+ (concise as unknown as Record<string, unknown>)["transient"] = true;
585
+ }
586
+ throw concise;
572
587
  }
573
588
 
574
589
  // OpenRouter can only cancel a request that streams ("Stream cancellation": for a non-streaming request the model keeps processing and you are billed in full), so even a plain generate is sent as a stream and collected here: an interrupt then closes the connection and stops processing and billing at the providers OpenRouter can cancel; in: the loop's non-streaming turn, out: the same ProviderGenerateResult a JSON reply gave.
@@ -608,6 +623,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
608
623
  }
609
624
  }
610
625
 
626
+ // Streaming turn: a normal event stream from streamInto; in: streamAgentLoop, out: an AssistantMessageEventStream.
611
627
  stream(
612
628
  model: string | ModelSpec,
613
629
  context: ProviderContext,
@@ -626,23 +642,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
626
642
  const startTime = Date.now();
627
643
  const clean = cleanModelId(model);
628
644
 
629
- const linked = new AbortController();
630
- const forwardUserAbort = () => {
631
- try {
632
- linked.abort((options?.signal as { reason?: unknown })?.reason);
633
- } catch {
634
- try {
635
- linked.abort();
636
- } catch {}
637
- }
638
- };
639
- if (options?.signal?.aborted) forwardUserAbort();
640
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
641
- const removeStreamCancel = eventStream.onCancel(() => {
642
- try {
643
- linked.abort();
644
- } catch {}
645
- });
645
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
646
646
 
647
647
  (async () => {
648
648
  try {
@@ -675,22 +675,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
675
675
  // Transient failures (429/5xx/network) are retried here, before the
676
676
  // first byte; once the stream is live it is never retried.
677
677
  const res = await openStreamResponse({
678
- fetchOnce: async () => {
679
- const timer =
680
- streamTimeoutMs && streamTimeoutMs > 0
681
- ? setTimeout(() => {
682
- try {
683
- linked.abort();
684
- } catch {}
685
- }, streamTimeoutMs)
686
- : undefined;
687
- try {
688
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
689
- } finally {
690
- if (timer) clearTimeout(timer);
691
- }
692
- },
693
- onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
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)),
694
680
  noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
695
681
  retry: retryOptionsFrom(options),
696
682
  signal: linked.signal,
@@ -703,9 +689,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
703
689
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
704
690
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
705
691
 
706
- const parser = new SSEParser();
707
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
708
- const decoder = new TextDecoder();
709
692
  let text = "";
710
693
  let thinking = "";
711
694
  // Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
@@ -722,18 +705,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
722
705
  let framesSeen = 0;
723
706
  let responseId: string | undefined;
724
707
  let completedBody: unknown = undefined;
725
- let aborted = false;
726
- linked.signal.addEventListener(
727
- "abort",
728
- () => {
729
- aborted = true;
730
- try {
731
- void reader.cancel();
732
- } catch {}
733
- },
734
- { once: true }
735
- );
736
-
737
708
  const handleMessage = (data: string): void => {
738
709
  if (!data) return;
739
710
  if (data === "[DONE]") {
@@ -759,6 +730,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
759
730
  const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
760
731
  if (topError.code !== undefined) failure["code"] = topError.code;
761
732
  if (typeof topError.metadata?.error_type === "string") failure["errorType"] = topError.metadata.error_type;
733
+ const status = midStreamStatus(topError.code, topError.metadata?.error_type);
734
+ if (status !== undefined) failure["statusCode"] = status; // lets the retry logic and the message treat it like an HTTP error
762
735
  failure["url"] = url;
763
736
  throw toConciseProviderError(failure, "openrouter", clean);
764
737
  }
@@ -855,32 +828,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
855
828
  }
856
829
  };
857
830
 
858
- while (true) {
859
- if (linked.signal.aborted || eventStream.isCancelled()) {
860
- try {
861
- await reader.cancel();
862
- } catch {}
863
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
864
- }
865
- const { done, value } = await reader.read();
866
- if (done) break;
867
- const chunk = decoder.decode(value, { stream: true });
868
- for (const m of parser.feed(chunk)) handleMessage(m.data);
869
- }
870
- for (const m of parser.flush()) handleMessage(m.data);
871
- try {
872
- reader.releaseLock();
873
- } catch {}
874
-
875
- if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
876
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
877
- }
831
+ await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
878
832
 
879
833
  const toolCalls: ToolCallRecord[] = [];
880
834
  for (const c of calls.values()) {
881
835
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
882
836
  const record: ToolCallRecord = {
883
- id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
837
+ id: clampToolCallId(c.id || newToolCallId()), // the id the history sends back, so the stored id and the wire id are one
884
838
  name: c.name,
885
839
  arguments: args,
886
840
  rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
@@ -889,9 +843,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
889
843
  eventStream.push({ type: "tool_call_complete", toolCall: record });
890
844
  }
891
845
 
846
+ // Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
847
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
892
848
  noteProviderTurn(sessionId, "openrouter");
893
849
  if (toolCalls.length > 0) {
894
- finishReason = "tool_calls";
850
+ finishReason = toolTurnFinishReason(finishReason, toolCalls);
895
851
  sawFinishReason = true;
896
852
  } else if (refusalSeen && finishReason !== "content_filter") {
897
853
  finishReason = "refusal";
@@ -901,26 +857,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
901
857
  // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
902
858
  // instead of resolving empty success. Skipped for non-SSE payloads
903
859
  // (JSON mocks) where no SSE frames were ever seen.
904
- const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
905
- const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
906
- const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
907
- const isSSEPayload =
908
- (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
909
- framesSeen > 0 ||
910
- badFrames > 0;
911
- if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
912
- eventStream.fail(incompleteStreamError("openrouter", clean));
913
- return;
914
- }
915
- // Refuse unfinished tool calls whose args never parsed (output_item.done
916
- // never arrived): truncated `{raw}` must not reach the executor (pi
917
- // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
918
- if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
919
- eventStream.fail(incompleteStreamError("openrouter", clean));
860
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
861
+ const incomplete = incompleteStreamCheck({
862
+ provider: "openrouter",
863
+ modelId: clean,
864
+ hasTerminal,
865
+ empty: !text && !cleanThinking && toolCalls.length === 0,
866
+ unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
867
+ responseHeaders,
868
+ framesSeen,
869
+ badFrames,
870
+ });
871
+ if (incomplete) {
872
+ eventStream.fail(incomplete);
920
873
  return;
921
874
  }
922
-
923
- const cleanThinking = cleanThinkingEarly;
924
875
  const finalResponse = new AgentResponse({
925
876
  text,
926
877
  thinking: cleanThinking || undefined,
@@ -940,21 +891,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
940
891
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
941
892
  eventStream.end(finalResponse);
942
893
  } catch (err: unknown) {
943
- const raw = err instanceof Error ? err : new Error(String(err));
944
- const isAbort =
945
- linked.signal.aborted ||
946
- eventStream.isCancelled() ||
947
- (raw as { name?: string }).name === "AbortError";
948
- eventStream.fail(
949
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
950
- );
894
+ failStream(eventStream, linked, err);
951
895
  } finally {
952
- try {
953
- options?.signal?.removeEventListener("abort", forwardUserAbort);
954
- } catch {}
955
- try {
956
- removeStreamCancel();
957
- } catch {}
896
+ release();
958
897
  }
959
898
  })();
960
899
 
@@ -42,6 +42,7 @@ const FIRST_CLASS = new Set([
42
42
  // resolve anywhere.
43
43
  const REMOVED_PROVIDERS = new Set(["opencode", "opencode-go"]);
44
44
 
45
+ // The error text for a removed provider prefix; in: assertProviderNotRemoved, out: a message.
45
46
  function removedProviderMessage(id: string): string {
46
47
  return (
47
48
  `[Agent Accelerator] Provider "${id}" has been removed: OpenCode free models are ` +