agent-accelerator 0.5.4 → 0.5.6

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.
@@ -14,10 +14,13 @@
14
14
  * lossy error codes, beta event-vocabulary drift.
15
15
  *
16
16
  * Notes:
17
+ * - EVERY request streams, `generate()` included (it collects its own `stream()`): OpenRouter only cancels a request that
18
+ * streams ("Stream cancellation" in its docs), so a plain non-streaming request could not be stopped by an interrupt.
17
19
  * - STATELESS per request: every turn sends the full canonical `messages`
18
- * array explicitly. Completions defines no `store`/`previous_response_id`/
19
- * `conversation`/`prompt_cache_key`/`session_id` body primitives, so none
20
- * 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.
21
24
  * - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
22
25
  * and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
23
26
  * parsers is a local canonical correlation id only; it is never sent.
@@ -37,22 +40,26 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
37
40
  import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
38
41
  import type { TokenUsage } from "../types/core.ts";
39
42
  import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
40
- import { SSEParser } from "../streaming/sse-parser.ts";
41
43
  import { AgentResponse } from "../types/response.ts";
42
44
  import { getApiKey, getEnv } from "../utils/env.ts";
43
45
  import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
44
46
  import { normalizeMediaInput } from "../utils/media.ts";
45
47
  import { safeStringify } from "../utils/serialization.ts";
46
48
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
47
- import { withRetries } from "../utils/retry.ts";
49
+ import { isAbortError } from "../types/errors.ts";
48
50
  import {
49
- parseArguments,
50
- normalizeThinkingParts,
51
51
  newToolCallId,
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,68 +122,20 @@ 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;
122
128
  [k: string]: unknown;
123
129
  }
124
130
 
125
- interface ChatChoice {
126
- index?: number;
127
- message?: {
128
- role?: string;
129
- content?: string | null;
130
- tool_calls?: Array<{
131
- id?: string;
132
- type?: string;
133
- index?: number;
134
- function?: { name?: string; arguments?: string };
135
- }>;
136
- reasoning?: string | null;
137
- reasoning_details?: Array<{ type?: string; text?: string }>;
138
- refusal?: string | null;
139
- };
140
- delta?: {
141
- role?: string;
142
- content?: string | null;
143
- tool_calls?: Array<{
144
- index?: number;
145
- id?: string;
146
- type?: string;
147
- function?: { name?: string; arguments?: string };
148
- }>;
149
- reasoning_content?: string;
150
- reasoning?: string;
151
- reasoning_details?: Array<{ type?: string; text?: string }>;
152
- refusal?: string | null;
153
- };
154
- finish_reason?: string | null;
155
- native_finish_reason?: string | null;
156
- error?: { message?: string; code?: string | number };
157
- }
158
-
159
- interface ChatResponse {
160
- id?: string;
161
- object?: string;
162
- created?: number;
163
- model?: string;
164
- provider?: string;
165
- choices?: ChatChoice[];
166
- usage?: {
167
- prompt_tokens?: number;
168
- completion_tokens?: number;
169
- total_tokens?: number;
170
- prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
171
- completion_tokens_details?: { reasoning_tokens?: number };
172
- cost?: number;
173
- [k: string]: unknown;
174
- };
175
- error?: {
176
- message?: string;
177
- code?: string | number;
178
- metadata?: { error_type?: string; provider_code?: string | number; [k: string]: unknown };
179
- };
131
+ /** The usage block of a Chat Completions chunk (the last chunk of a stream carries it, with OpenRouter's `cost`). */
132
+ interface ChatUsage {
133
+ prompt_tokens?: number;
134
+ completion_tokens?: number;
135
+ total_tokens?: number;
136
+ prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
137
+ completion_tokens_details?: { reasoning_tokens?: number };
138
+ cost?: number;
180
139
  [k: string]: unknown;
181
140
  }
182
141
 
@@ -211,6 +170,13 @@ function cleanModelId(model: string | ModelSpec): string {
211
170
  return rawId.replace(/^openrouter\//i, "");
212
171
  }
213
172
 
173
+ // 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.
174
+ const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = { rate_limit_exceeded: 429, provider_overloaded: 503, provider_unavailable: 502, timeout: 504, server: 500 };
175
+ function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
176
+ if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
177
+ return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
178
+ }
179
+
214
180
  function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
215
181
  if (!tools || tools.length === 0) return undefined;
216
182
  return tools.map((t) => ({
@@ -224,20 +190,31 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
224
190
  }));
225
191
  }
226
192
 
193
+ // 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.
194
+ 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" };
227
195
  function audioFormatFor(mimeType?: string): string {
228
- const mime = (mimeType || "").toLowerCase();
229
- if (mime.includes("wav")) return "wav";
230
- return "mp3";
196
+ const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
197
+ return AUDIO_FORMAT[subtype] ?? (subtype || "mp3");
198
+ }
199
+
200
+ // `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.
201
+ const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = { low: "low", medium: "auto", high: "high", ultra_high: "original" };
202
+
203
+ // 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.
204
+ function hasRemoteMedia(messages: ChatMessage[]): boolean {
205
+ return messages.some(
206
+ (m) =>
207
+ Array.isArray(m.content) &&
208
+ (m.content as ChatContentPart[]).some((p) => {
209
+ const url = (p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ?? (p as { video_url?: { url?: string } }).video_url?.url;
210
+ return typeof url === "string" && /^https?:\/\//.test(url);
211
+ })
212
+ );
231
213
  }
232
214
 
233
- async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
234
- blocks: ChatContentPart[];
235
- fileUrls: string[];
236
- hasFiles: boolean;
237
- }> {
215
+ // Maps canonical text and media parts to Chat Completions content blocks; in: a user message's parts, out: fullHistoryMessages.
216
+ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPart[]> {
238
217
  const blocks: ChatContentPart[] = [];
239
- const fileUrls: string[] = [];
240
- let hasFiles = false;
241
218
  for (const part of parts) {
242
219
  if (part.type === "text" && part.text) {
243
220
  blocks.push({ type: "text", text: part.text });
@@ -245,15 +222,16 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
245
222
  const raw = (part as { image?: unknown }).image;
246
223
  // Remote URLs pass through (router downloads; `image_download_failed`
247
224
  // surfaces if unreachable). Inline only local/base64/binary.
225
+ const detail = IMAGE_DETAIL[(part as { resolution?: string }).resolution ?? ""];
248
226
  if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
249
- blocks.push({ type: "image_url", image_url: { url: raw } });
227
+ blocks.push({ type: "image_url", image_url: { url: raw, ...(detail ? { detail } : {}) } });
250
228
  continue;
251
229
  }
252
230
  const norm = await normalizeMediaInput(
253
231
  raw as string | Uint8Array | ArrayBuffer,
254
232
  (part as { mimeType?: string }).mimeType
255
233
  );
256
- blocks.push({ type: "image_url", image_url: { url: norm.dataUrl } });
234
+ blocks.push({ type: "image_url", image_url: { url: norm.dataUrl, ...(detail ? { detail } : {}) } });
257
235
  } else if (part.type === "video") {
258
236
  const raw = (part as { video?: unknown }).video;
259
237
  // `video_url` is the completions video shape (live: shape accepted,
@@ -267,7 +245,9 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
267
245
  raw as string | Uint8Array | ArrayBuffer,
268
246
  (part as { mimeType?: string }).mimeType
269
247
  );
270
- blocks.push({ type: "video_url", video_url: { url: norm.dataUrl } });
248
+ // video-inputs.md names the QuickTime type `video/mov`.
249
+ const url = norm.mimeType === "video/quicktime" ? `data:video/mov;base64,${norm.base64Data}` : norm.dataUrl;
250
+ blocks.push({ type: "video_url", video_url: { url } });
271
251
  } else if (part.type === "audio") {
272
252
  const raw = (part as { audio?: unknown }).audio;
273
253
  const mimeType = (part as { mimeType?: string }).mimeType;
@@ -282,37 +262,20 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
282
262
  input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
283
263
  });
284
264
  } else if (part.type === "file") {
285
- hasFiles = true;
286
265
  const raw = (part as { file?: unknown }).file;
287
266
  const filename = (part as { filename?: string }).filename;
288
- if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
289
- // Bare `{type:file}` parts are ignored by the route (observed live),
290
- // so the URL must ALSO ride in text for the file-parser plugin (see
291
- // fullHistoryMessages). Still sent forward-compat.
292
- fileUrls.push(raw);
293
- blocks.push({
294
- type: "file",
295
- file: {
296
- ...(typeof filename === "string" && filename ? { filename } : {}),
297
- file_url: raw,
298
- },
299
- });
300
- continue;
301
- }
302
- const norm = await normalizeMediaInput(
303
- raw as string | Uint8Array | ArrayBuffer,
304
- (part as { mimeType?: string }).mimeType
305
- );
267
+ // `file_data` takes a public URL or a base64 data URL (api-ref ChatContentFile; a `file_url` field does not exist).
268
+ const fileData =
269
+ typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
270
+ ? raw
271
+ : (await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, (part as { mimeType?: string }).mimeType)).dataUrl;
306
272
  blocks.push({
307
273
  type: "file",
308
- file: {
309
- ...(typeof filename === "string" && filename ? { filename } : {}),
310
- file_data: norm.dataUrl,
311
- },
274
+ file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
312
275
  });
313
276
  }
314
277
  }
315
- return { blocks, fileUrls, hasFiles };
278
+ return blocks;
316
279
  }
317
280
 
318
281
  function resultToOutput(result: unknown): string {
@@ -325,15 +288,8 @@ function resultToOutput(result: unknown): string {
325
288
  * Stateless transport: no chaining primitives exist here, so every turn
326
289
  * carries everything. Assistant items carry no provider `id`/`status`
327
290
  * requirements (unlike the discontinued Responses skin).
328
- *
329
- * File handling: remote file URLs must be visible in text for the
330
- * `file-parser` plugin (bare `{type:file}` parts are ignored by the route),
331
- * so each remote file URL is appended as its own text part.
332
291
  */
333
- async function fullHistoryMessages(context: ProviderContext): Promise<{
334
- messages: ChatMessage[];
335
- hasFiles: boolean;
336
- }> {
292
+ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
337
293
  // Map assistant tool_call ids so tool results pair even though the
338
294
  // executor keys results by the call id.
339
295
  const pairing = new Map<string, string>();
@@ -357,7 +313,6 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
357
313
  if (context.systemPrompt) {
358
314
  messages.push({ role: "system", content: context.systemPrompt });
359
315
  }
360
- let hasFiles = false;
361
316
  let lastRole: string | null = "system";
362
317
  for (const m of context.messages) {
363
318
  if (m.role === "system") continue;
@@ -370,12 +325,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
370
325
  if (typeof m.content === "string") {
371
326
  if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
372
327
  } else {
373
- const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
374
- if (hf) hasFiles = true;
375
- // Surface remote file URLs in text for the file-parser plugin.
376
- for (const url of fileUrls) {
377
- blocks.push({ type: "text", text: url });
378
- }
328
+ const blocks = await contentPartsToBlocks(m.content);
379
329
  if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
380
330
  }
381
331
  } else if (m.role === "assistant") {
@@ -392,7 +342,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
392
342
  calls.push({
393
343
  id: clampToolCallId(part.callId || part.id),
394
344
  type: "function",
395
- function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
345
+ function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
396
346
  });
397
347
  } else if (part.type === "text" && part.text) {
398
348
  texts.push(part.text);
@@ -435,14 +385,14 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
435
385
  }
436
386
  }
437
387
  }
438
- return { messages, hasFiles };
388
+ return messages;
439
389
  }
440
390
 
441
391
  // ---------------------------------------------------------------------------
442
392
  // Response mapping (Chat Completions -> canonical)
443
393
  // ---------------------------------------------------------------------------
444
394
 
445
- function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
395
+ function mapUsage(raw?: ChatUsage): TokenUsage {
446
396
  const input = raw?.prompt_tokens ?? 0;
447
397
  const output = raw?.completion_tokens ?? 0;
448
398
  // Canonical invariant: cache hits are a SUBSET of input (no turn may
@@ -457,87 +407,12 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
457
407
  cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
458
408
  thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
459
409
  };
460
- if (typeof raw?.cost === "number" && raw.cost > 0) {
410
+ 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
461
411
  usage.cost = { totalCost: raw.cost };
462
412
  }
463
413
  return usage;
464
414
  }
465
415
 
466
- function throwResponseError(response: ChatResponse, modelId: string): void {
467
- const message =
468
- response.error && typeof response.error.message === "string" && response.error.message
469
- ? response.error.message
470
- : "OpenRouter response failed";
471
- const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
472
- if (response.error?.code !== undefined) failure["code"] = response.error.code;
473
- // Chat skin keeps the stable typed code INSIDE metadata (unlike Responses'
474
- // top-level `error_type`).
475
- const errorType = (response.error?.metadata as { error_type?: string } | undefined)?.error_type;
476
- if (typeof errorType === "string") failure["errorType"] = errorType;
477
- throw toConciseProviderError(failure, "openrouter", modelId);
478
- }
479
-
480
- function parseResponse(
481
- response: ChatResponse,
482
- modelId: string,
483
- durationMs: number,
484
- raw: ProviderRawData
485
- ): ProviderGenerateResult {
486
- // Provider-interrupted generations arrive as HTTP 200 carrying ONLY `error`
487
- // (no `choices`) — must check the body, not just the status.
488
- if (response.error) throwResponseError(response, modelId);
489
-
490
- const choice = response.choices?.[0];
491
- const message = choice?.message;
492
- // Q-23: refusal content surfaces as text with a `refusal` finish reason.
493
- const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
494
- const text = typeof message?.content === "string" && message.content ? message.content : refusal;
495
- // Thinking: `reasoning_details[].text` duplicates `message.reasoning` when
496
- // both ride along (observed live) — prefer details, else the plain field.
497
- const thinkingParts: string[] = [];
498
- const detailTexts: string[] = [];
499
- for (const block of message?.reasoning_details ?? []) {
500
- if (block?.type === "reasoning.text" && block.text) detailTexts.push(block.text);
501
- }
502
- if (detailTexts.length > 0) {
503
- thinkingParts.push(...detailTexts);
504
- } else if (typeof message?.reasoning === "string" && message.reasoning) {
505
- thinkingParts.push(message.reasoning);
506
- }
507
- const toolCalls: ToolCallRecord[] = [];
508
- for (const tc of message?.tool_calls ?? []) {
509
- const args = parseArguments(tc.function?.arguments);
510
- toolCalls.push({
511
- id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
512
- name: tc.function?.name || "unknown",
513
- arguments: args,
514
- rawArguments:
515
- typeof tc.function?.arguments === "string"
516
- ? tc.function.arguments
517
- : JSON.stringify(tc.function?.arguments ?? {}),
518
- });
519
- }
520
-
521
- // Normalized finish reasons: tool_calls|stop|length|content_filter|error.
522
- // Q-23: refusal content forces `refusal` (even when the wire says stop).
523
- const wireReason = choice?.finish_reason ?? undefined;
524
- const finishReason =
525
- toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
526
- return {
527
- text,
528
- thinking: normalizeThinkingParts(thinkingParts),
529
- thoughtSignature: undefined,
530
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
531
- usage: mapUsage(response.usage),
532
- finishReason,
533
- responseId: response.id,
534
- model: modelId,
535
- provider: "openrouter",
536
- raw,
537
- durationMs,
538
- };
539
- }
540
-
541
416
  // ---------------------------------------------------------------------------
542
417
  // Provider
543
418
  // ---------------------------------------------------------------------------
@@ -576,8 +451,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
576
451
  const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
577
452
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
578
453
  // Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
579
- const cache = resolveCache("openrouter", options?.cache, modelId.startsWith("anthropic/") ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
580
- warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`);
454
+ const cache = resolveCache("openrouter", options?.cache, /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
455
+ warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
581
456
 
582
457
  // Structured outputs: native `response_format` when supported, else
583
458
  // emulated via system-prompt instruction + client validation (catalog
@@ -605,7 +480,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
605
480
  fallback: "emulated JSON instruction + client validation (no response_format is sent)",
606
481
  });
607
482
  }
608
- const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
483
+ const messages = await fullHistoryMessages(effectiveContext);
609
484
  const body: ChatRequestBody = {
610
485
  model: modelId,
611
486
  messages,
@@ -621,7 +496,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
621
496
  if (serviceTier) body.service_tier = serviceTier;
622
497
  if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
623
498
  // Documented Chat Completions generation controls (only those the caller set).
624
- Object.assign(body, mapGenerationParamsToChat(options));
499
+ Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
625
500
  // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
626
501
  // Skipped when emulated (unsupported model): instruction already in messages.
627
502
  if (outputSpec && !outputEmulated) {
@@ -632,9 +507,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
632
507
  // why: invalid output shapes fail fast at Agent construction/loop validation.
633
508
  }
634
509
  }
635
- // PDF ingestion is a documented plugin, not a message shape: enable it
636
- // only when file parts are present (never by default).
637
- if (hasFiles) body.plugins = [{ id: "file-parser" }];
638
510
  // No store/previous_response_id/conversation: they do not exist
639
511
  // on this endpoint.
640
512
  // Session affinity: top-level `session_id` is the documented sticky-routing
@@ -665,41 +537,23 @@ export class OpenRouterChatCompletionsProvider implements Provider {
665
537
  return { method: "POST", headers, body: JSON.stringify(body), signal };
666
538
  }
667
539
 
668
- private async doFetch(
669
- url: string,
670
- body: ChatRequestBody,
671
- options: ProviderRequestOptions | undefined,
672
- apiKey: string,
673
- sessionId: string | undefined,
674
- signal?: AbortSignal
675
- ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
676
- const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
677
- try {
678
- const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
679
- const text = await res.text();
680
- const headers: Record<string, string> = {};
681
- res.headers.forEach((v, k) => {
682
- headers[k] = v;
683
- });
684
- return { status: res.status, statusText: res.statusText, headers, text };
685
- } finally {
686
- cleanup();
687
- }
688
- }
689
-
690
540
  private throwIfError(
691
541
  status: number,
692
542
  url: string,
693
543
  bodyText: string,
694
544
  modelId: string,
695
- headers?: Record<string, string>
545
+ headers?: Record<string, string>,
546
+ remoteMedia = false
696
547
  ): void {
697
548
  if (status >= 200 && status < 300) return;
698
- const { message, code, errorType, raw } = readErrorPayload(bodyText);
549
+ const { message, code, errorType, raw, limitSource } = readErrorPayload(bodyText);
699
550
  const hint =
700
551
  status === 401
701
552
  ? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
702
- : "";
553
+ : // A bad or unreachable image/video link comes back as the bare "Provider returned error" without saying which part failed.
554
+ status === 400 && remoteMedia && message === "Provider returned error"
555
+ ? " 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."
556
+ : "";
703
557
  const fullMessage = `${message}${hint}`;
704
558
  const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
705
559
  (err as Record<string, unknown>)["statusCode"] = status;
@@ -712,84 +566,49 @@ export class OpenRouterChatCompletionsProvider implements Provider {
712
566
  if (raw) (err as Record<string, unknown>)["raw"] = raw;
713
567
  const retryAfterMs = parseRetryAfterMs(headers);
714
568
  if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
715
- throw toConciseProviderError(err, "openrouter", modelId);
569
+ const concise = toConciseProviderError(err, "openrouter", modelId);
570
+ // 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.
571
+ if (status === 402 && limitSource === "openrouter_in_flight_budget") {
572
+ (concise as unknown as Record<string, unknown>)["retryable"] = true;
573
+ (concise as unknown as Record<string, unknown>)["transient"] = true;
574
+ }
575
+ throw concise;
716
576
  }
717
577
 
578
+ // 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.
718
579
  async generate(
719
580
  model: string | ModelSpec,
720
581
  context: ProviderContext,
721
582
  options?: ProviderRequestOptions
722
583
  ): Promise<ProviderGenerateResult> {
723
- const startTime = Date.now();
724
- const clean = cleanModelId(model);
725
- const apiKey = resolveApiKey(options);
726
- if (!apiKey) {
727
- throw new Error(
728
- "[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
729
- );
730
- }
731
- // Fail fast before any network call when the catalog knows the model
732
- // lacks the requested modality (clear one-liner instead of a confusing
733
- // router 404/400). Unknown models skip the guard; the router verdict
734
- // surfaces concisely.
735
- assertModalitiesSupported(context, "openrouter", clean);
736
- const baseUrl = resolveBaseUrl(options);
737
- const url = `${baseUrl}/chat/completions`;
738
- const sessionId = options?.sessionId;
739
- const tools = options?.tools as StandardToolDeclaration[] | undefined;
740
- const body = await this.buildBody(clean, context, options, sessionId, tools, false);
741
-
742
- // Audit trail: record the actual wire headers (attribution + session
743
- // affinity included), redacted.
744
- const auditRaw: Record<string, string> = {
745
- ...buildSessionHeaders("openrouter", options?.headers, sessionId),
746
- "Content-Type": "application/json",
747
- Authorization: "[REDACTED]",
748
- };
749
- const rawRequest = {
750
- url,
751
- method: "POST",
752
- headers: redactedHeaders(auditRaw),
753
- body,
754
- };
755
-
756
- const doCall = async (): Promise<ProviderGenerateResult> => {
757
- const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
758
- this.throwIfError(res.status, url, res.text, clean, res.headers);
759
- let response: ChatResponse;
760
- try {
761
- response = JSON.parse(res.text) as ChatResponse;
762
- } catch {
763
- throw toConciseProviderError(
764
- Object.assign(new Error("Invalid JSON response from OpenRouter Chat Completions API"), {
765
- statusCode: res.status,
766
- responseBody: res.text.slice(0, 500),
767
- url,
768
- }),
769
- "openrouter",
770
- clean
771
- );
772
- }
773
- // Stateless turns never establish chains, but the session store still
774
- // records the turn so other providers' switch detection keeps working.
775
- noteProviderTurn(sessionId, "openrouter");
776
- return parseResponse(
777
- response,
778
- clean,
779
- Date.now() - startTime,
780
- { request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: response } }
781
- );
782
- };
783
-
584
+ // The whole call stays bounded by `timeoutMs`; the stream's own timer only covers the wait for its first byte.
585
+ const { signal, cleanup } = combinedSignal(options?.signal, timeoutFor(options as { timeoutMs?: number } | undefined));
784
586
  try {
785
- return await withRetries(doCall, {
786
- ...retryOptionsFrom(options),
787
- signal: options?.signal,
788
- label: { providerId: "openrouter", modelId: clean },
789
- });
587
+ // Nobody reads the deltas here, so the stream keeps none (a burst of frames would overflow its buffer for nothing).
588
+ const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, { ...options, signal });
589
+ const res = await events.result();
590
+ return {
591
+ text: res.text,
592
+ thinking: res.thinking,
593
+ thoughtSignature: undefined,
594
+ toolCalls: res.toolCalls.length > 0 ? res.toolCalls : undefined,
595
+ usage: res.usage,
596
+ finishReason: res.finishReason,
597
+ responseId: res.responseId,
598
+ model: res.model,
599
+ provider: "openrouter",
600
+ raw: res.raw,
601
+ durationMs: res.durationMs,
602
+ };
790
603
  } catch (err) {
791
- if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
604
+ // A stream reports its own generic abort; the caller's reason (`interrupt(new Error("..."))`) is what the run should carry.
605
+ const reason = options?.signal?.reason;
606
+ if (options?.signal?.aborted && reason instanceof Error && isAbortError(err)) {
607
+ throw Object.assign(new Error(reason.message), { name: "AbortError" });
608
+ }
792
609
  throw err;
610
+ } finally {
611
+ cleanup();
793
612
  }
794
613
  }
795
614
 
@@ -798,27 +617,20 @@ export class OpenRouterChatCompletionsProvider implements Provider {
798
617
  context: ProviderContext,
799
618
  options?: ProviderRequestOptions
800
619
  ): AssistantMessageEventStream {
801
- const eventStream = new AssistantMessageEventStream();
620
+ return this.streamInto(new AssistantMessageEventStream(), model, context, options);
621
+ }
622
+
623
+ // Runs one streamed request, pushing its events into `eventStream`; in: stream() (a normal stream) and generate() (one that keeps no events), out: that same stream, finished with the AgentResponse.
624
+ private streamInto(
625
+ eventStream: AssistantMessageEventStream,
626
+ model: string | ModelSpec,
627
+ context: ProviderContext,
628
+ options?: ProviderRequestOptions
629
+ ): AssistantMessageEventStream {
802
630
  const startTime = Date.now();
803
631
  const clean = cleanModelId(model);
804
632
 
805
- const linked = new AbortController();
806
- const forwardUserAbort = () => {
807
- try {
808
- linked.abort((options?.signal as { reason?: unknown })?.reason);
809
- } catch {
810
- try {
811
- linked.abort();
812
- } catch {}
813
- }
814
- };
815
- if (options?.signal?.aborted) forwardUserAbort();
816
- else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
817
- const removeStreamCancel = eventStream.onCancel(() => {
818
- try {
819
- linked.abort();
820
- } catch {}
821
- });
633
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
822
634
 
823
635
  (async () => {
824
636
  try {
@@ -851,22 +663,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
851
663
  // Transient failures (429/5xx/network) are retried here, before the
852
664
  // first byte; once the stream is live it is never retried.
853
665
  const res = await openStreamResponse({
854
- fetchOnce: async () => {
855
- const timer =
856
- streamTimeoutMs && streamTimeoutMs > 0
857
- ? setTimeout(() => {
858
- try {
859
- linked.abort();
860
- } catch {}
861
- }, streamTimeoutMs)
862
- : undefined;
863
- try {
864
- return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
865
- } finally {
866
- if (timer) clearTimeout(timer);
867
- }
868
- },
869
- onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
666
+ fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
667
+ onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
870
668
  noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
871
669
  retry: retryOptionsFrom(options),
872
670
  signal: linked.signal,
@@ -879,9 +677,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
879
677
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
880
678
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
881
679
 
882
- const parser = new SSEParser();
883
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
884
- const decoder = new TextDecoder();
885
680
  let text = "";
886
681
  let thinking = "";
887
682
  // Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
@@ -898,18 +693,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
898
693
  let framesSeen = 0;
899
694
  let responseId: string | undefined;
900
695
  let completedBody: unknown = undefined;
901
- let aborted = false;
902
- linked.signal.addEventListener(
903
- "abort",
904
- () => {
905
- aborted = true;
906
- try {
907
- void reader.cancel();
908
- } catch {}
909
- },
910
- { once: true }
911
- );
912
-
913
696
  const handleMessage = (data: string): void => {
914
697
  if (!data) return;
915
698
  if (data === "[DONE]") {
@@ -935,6 +718,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
935
718
  const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
936
719
  if (topError.code !== undefined) failure["code"] = topError.code;
937
720
  if (typeof topError.metadata?.error_type === "string") failure["errorType"] = topError.metadata.error_type;
721
+ const status = midStreamStatus(topError.code, topError.metadata?.error_type);
722
+ if (status !== undefined) failure["statusCode"] = status; // lets the retry logic and the message treat it like an HTTP error
938
723
  failure["url"] = url;
939
724
  throw toConciseProviderError(failure, "openrouter", clean);
940
725
  }
@@ -1022,7 +807,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1022
807
  finishReason = fr;
1023
808
  sawFinishReason = true;
1024
809
  }
1025
- const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
810
+ const chunkUsage = msg["usage"] as ChatUsage | undefined;
1026
811
  if (chunkUsage) {
1027
812
  usage = mapUsage(chunkUsage);
1028
813
  sawUsage = true;
@@ -1031,32 +816,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1031
816
  }
1032
817
  };
1033
818
 
1034
- while (true) {
1035
- if (linked.signal.aborted || eventStream.isCancelled()) {
1036
- try {
1037
- await reader.cancel();
1038
- } catch {}
1039
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1040
- }
1041
- const { done, value } = await reader.read();
1042
- if (done) break;
1043
- const chunk = decoder.decode(value, { stream: true });
1044
- for (const m of parser.feed(chunk)) handleMessage(m.data);
1045
- }
1046
- for (const m of parser.flush()) handleMessage(m.data);
1047
- try {
1048
- reader.releaseLock();
1049
- } catch {}
1050
-
1051
- if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
1052
- throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
1053
- }
819
+ await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
1054
820
 
1055
821
  const toolCalls: ToolCallRecord[] = [];
1056
822
  for (const c of calls.values()) {
1057
823
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
1058
824
  const record: ToolCallRecord = {
1059
- id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
825
+ id: clampToolCallId(c.id || newToolCallId()), // the id the history sends back, so the stored id and the wire id are one
1060
826
  name: c.name,
1061
827
  arguments: args,
1062
828
  rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
@@ -1065,9 +831,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1065
831
  eventStream.push({ type: "tool_call_complete", toolCall: record });
1066
832
  }
1067
833
 
834
+ // Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
835
+ const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1068
836
  noteProviderTurn(sessionId, "openrouter");
1069
837
  if (toolCalls.length > 0) {
1070
- finishReason = "tool_calls";
838
+ finishReason = toolTurnFinishReason(finishReason, toolCalls);
1071
839
  sawFinishReason = true;
1072
840
  } else if (refusalSeen && finishReason !== "content_filter") {
1073
841
  finishReason = "refusal";
@@ -1077,26 +845,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1077
845
  // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
1078
846
  // instead of resolving empty success. Skipped for non-SSE payloads
1079
847
  // (JSON mocks) where no SSE frames were ever seen.
1080
- const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
1081
- const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
1082
- const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
1083
- const isSSEPayload =
1084
- (typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
1085
- framesSeen > 0 ||
1086
- badFrames > 0;
1087
- if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
1088
- eventStream.fail(incompleteStreamError("openrouter", clean));
1089
- return;
1090
- }
1091
- // Refuse unfinished tool calls whose args never parsed (output_item.done
1092
- // never arrived): truncated `{raw}` must not reach the executor (pi
1093
- // api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
1094
- if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
1095
- eventStream.fail(incompleteStreamError("openrouter", clean));
848
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
849
+ const incomplete = incompleteStreamCheck({
850
+ provider: "openrouter",
851
+ modelId: clean,
852
+ hasTerminal,
853
+ empty: !text && !cleanThinking && toolCalls.length === 0,
854
+ unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
855
+ responseHeaders,
856
+ framesSeen,
857
+ badFrames,
858
+ });
859
+ if (incomplete) {
860
+ eventStream.fail(incomplete);
1096
861
  return;
1097
862
  }
1098
-
1099
- const cleanThinking = cleanThinkingEarly;
1100
863
  const finalResponse = new AgentResponse({
1101
864
  text,
1102
865
  thinking: cleanThinking || undefined,
@@ -1116,21 +879,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1116
879
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1117
880
  eventStream.end(finalResponse);
1118
881
  } catch (err: unknown) {
1119
- const raw = err instanceof Error ? err : new Error(String(err));
1120
- const isAbort =
1121
- linked.signal.aborted ||
1122
- eventStream.isCancelled() ||
1123
- (raw as { name?: string }).name === "AbortError";
1124
- eventStream.fail(
1125
- isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1126
- );
882
+ failStream(eventStream, linked, err);
1127
883
  } finally {
1128
- try {
1129
- options?.signal?.removeEventListener("abort", forwardUserAbort);
1130
- } catch {}
1131
- try {
1132
- removeStreamCancel();
1133
- } catch {}
884
+ release();
1134
885
  }
1135
886
  })();
1136
887