agent-accelerator 0.5.5 → 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.
@@ -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;
@@ -164,6 +170,13 @@ function cleanModelId(model: string | ModelSpec): string {
164
170
  return rawId.replace(/^openrouter\//i, "");
165
171
  }
166
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
+
167
180
  function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
168
181
  if (!tools || tools.length === 0) return undefined;
169
182
  return tools.map((t) => ({
@@ -177,20 +190,31 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
177
190
  }));
178
191
  }
179
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" };
180
195
  function audioFormatFor(mimeType?: string): string {
181
- const mime = (mimeType || "").toLowerCase();
182
- if (mime.includes("wav")) return "wav";
183
- return "mp3";
196
+ const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
197
+ return AUDIO_FORMAT[subtype] ?? (subtype || "mp3");
184
198
  }
185
199
 
186
- async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
187
- blocks: ChatContentPart[];
188
- fileUrls: string[];
189
- hasFiles: boolean;
190
- }> {
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
+ );
213
+ }
214
+
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[]> {
191
217
  const blocks: ChatContentPart[] = [];
192
- const fileUrls: string[] = [];
193
- let hasFiles = false;
194
218
  for (const part of parts) {
195
219
  if (part.type === "text" && part.text) {
196
220
  blocks.push({ type: "text", text: part.text });
@@ -198,15 +222,16 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
198
222
  const raw = (part as { image?: unknown }).image;
199
223
  // Remote URLs pass through (router downloads; `image_download_failed`
200
224
  // surfaces if unreachable). Inline only local/base64/binary.
225
+ const detail = IMAGE_DETAIL[(part as { resolution?: string }).resolution ?? ""];
201
226
  if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
202
- blocks.push({ type: "image_url", image_url: { url: raw } });
227
+ blocks.push({ type: "image_url", image_url: { url: raw, ...(detail ? { detail } : {}) } });
203
228
  continue;
204
229
  }
205
230
  const norm = await normalizeMediaInput(
206
231
  raw as string | Uint8Array | ArrayBuffer,
207
232
  (part as { mimeType?: string }).mimeType
208
233
  );
209
- blocks.push({ type: "image_url", image_url: { url: norm.dataUrl } });
234
+ blocks.push({ type: "image_url", image_url: { url: norm.dataUrl, ...(detail ? { detail } : {}) } });
210
235
  } else if (part.type === "video") {
211
236
  const raw = (part as { video?: unknown }).video;
212
237
  // `video_url` is the completions video shape (live: shape accepted,
@@ -220,7 +245,9 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
220
245
  raw as string | Uint8Array | ArrayBuffer,
221
246
  (part as { mimeType?: string }).mimeType
222
247
  );
223
- 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 } });
224
251
  } else if (part.type === "audio") {
225
252
  const raw = (part as { audio?: unknown }).audio;
226
253
  const mimeType = (part as { mimeType?: string }).mimeType;
@@ -235,37 +262,20 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
235
262
  input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
236
263
  });
237
264
  } else if (part.type === "file") {
238
- hasFiles = true;
239
265
  const raw = (part as { file?: unknown }).file;
240
266
  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
- );
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;
259
272
  blocks.push({
260
273
  type: "file",
261
- file: {
262
- ...(typeof filename === "string" && filename ? { filename } : {}),
263
- file_data: norm.dataUrl,
264
- },
274
+ file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
265
275
  });
266
276
  }
267
277
  }
268
- return { blocks, fileUrls, hasFiles };
278
+ return blocks;
269
279
  }
270
280
 
271
281
  function resultToOutput(result: unknown): string {
@@ -278,15 +288,8 @@ function resultToOutput(result: unknown): string {
278
288
  * Stateless transport: no chaining primitives exist here, so every turn
279
289
  * carries everything. Assistant items carry no provider `id`/`status`
280
290
  * 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
291
  */
286
- async function fullHistoryMessages(context: ProviderContext): Promise<{
287
- messages: ChatMessage[];
288
- hasFiles: boolean;
289
- }> {
292
+ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
290
293
  // Map assistant tool_call ids so tool results pair even though the
291
294
  // executor keys results by the call id.
292
295
  const pairing = new Map<string, string>();
@@ -310,7 +313,6 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
310
313
  if (context.systemPrompt) {
311
314
  messages.push({ role: "system", content: context.systemPrompt });
312
315
  }
313
- let hasFiles = false;
314
316
  let lastRole: string | null = "system";
315
317
  for (const m of context.messages) {
316
318
  if (m.role === "system") continue;
@@ -323,12 +325,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
323
325
  if (typeof m.content === "string") {
324
326
  if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
325
327
  } 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
- }
328
+ const blocks = await contentPartsToBlocks(m.content);
332
329
  if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
333
330
  }
334
331
  } else if (m.role === "assistant") {
@@ -345,7 +342,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
345
342
  calls.push({
346
343
  id: clampToolCallId(part.callId || part.id),
347
344
  type: "function",
348
- function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
345
+ function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
349
346
  });
350
347
  } else if (part.type === "text" && part.text) {
351
348
  texts.push(part.text);
@@ -388,7 +385,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
388
385
  }
389
386
  }
390
387
  }
391
- return { messages, hasFiles };
388
+ return messages;
392
389
  }
393
390
 
394
391
  // ---------------------------------------------------------------------------
@@ -410,7 +407,7 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
410
407
  cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
411
408
  thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
412
409
  };
413
- 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
414
411
  usage.cost = { totalCost: raw.cost };
415
412
  }
416
413
  return usage;
@@ -454,8 +451,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
454
451
  const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
455
452
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
456
453
  // 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}`);
454
+ const cache = resolveCache("openrouter", options?.cache, /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
455
+ warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
459
456
 
460
457
  // Structured outputs: native `response_format` when supported, else
461
458
  // emulated via system-prompt instruction + client validation (catalog
@@ -483,7 +480,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
483
480
  fallback: "emulated JSON instruction + client validation (no response_format is sent)",
484
481
  });
485
482
  }
486
- const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
483
+ const messages = await fullHistoryMessages(effectiveContext);
487
484
  const body: ChatRequestBody = {
488
485
  model: modelId,
489
486
  messages,
@@ -499,7 +496,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
499
496
  if (serviceTier) body.service_tier = serviceTier;
500
497
  if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
501
498
  // Documented Chat Completions generation controls (only those the caller set).
502
- Object.assign(body, mapGenerationParamsToChat(options));
499
+ Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
503
500
  // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
504
501
  // Skipped when emulated (unsupported model): instruction already in messages.
505
502
  if (outputSpec && !outputEmulated) {
@@ -510,9 +507,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
510
507
  // why: invalid output shapes fail fast at Agent construction/loop validation.
511
508
  }
512
509
  }
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
510
  // No store/previous_response_id/conversation: they do not exist
517
511
  // on this endpoint.
518
512
  // Session affinity: top-level `session_id` is the documented sticky-routing
@@ -548,14 +542,18 @@ export class OpenRouterChatCompletionsProvider implements Provider {
548
542
  url: string,
549
543
  bodyText: string,
550
544
  modelId: string,
551
- headers?: Record<string, string>
545
+ headers?: Record<string, string>,
546
+ remoteMedia = false
552
547
  ): void {
553
548
  if (status >= 200 && status < 300) return;
554
- const { message, code, errorType, raw } = readErrorPayload(bodyText);
549
+ const { message, code, errorType, raw, limitSource } = readErrorPayload(bodyText);
555
550
  const hint =
556
551
  status === 401
557
552
  ? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
558
- : "";
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
+ : "";
559
557
  const fullMessage = `${message}${hint}`;
560
558
  const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
561
559
  (err as Record<string, unknown>)["statusCode"] = status;
@@ -568,7 +566,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
568
566
  if (raw) (err as Record<string, unknown>)["raw"] = raw;
569
567
  const retryAfterMs = parseRetryAfterMs(headers);
570
568
  if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
571
- 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;
572
576
  }
573
577
 
574
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.
@@ -626,23 +630,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
626
630
  const startTime = Date.now();
627
631
  const clean = cleanModelId(model);
628
632
 
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
- });
633
+ const { linked, release } = linkStreamAbort(eventStream, options?.signal);
646
634
 
647
635
  (async () => {
648
636
  try {
@@ -675,22 +663,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
675
663
  // Transient failures (429/5xx/network) are retried here, before the
676
664
  // first byte; once the stream is live it is never retried.
677
665
  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)),
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)),
694
668
  noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
695
669
  retry: retryOptionsFrom(options),
696
670
  signal: linked.signal,
@@ -703,9 +677,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
703
677
  const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
704
678
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
705
679
 
706
- const parser = new SSEParser();
707
- const reader = res.body!.getReader(); // openStreamResponse guarantees a body
708
- const decoder = new TextDecoder();
709
680
  let text = "";
710
681
  let thinking = "";
711
682
  // Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
@@ -722,18 +693,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
722
693
  let framesSeen = 0;
723
694
  let responseId: string | undefined;
724
695
  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
696
  const handleMessage = (data: string): void => {
738
697
  if (!data) return;
739
698
  if (data === "[DONE]") {
@@ -759,6 +718,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
759
718
  const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
760
719
  if (topError.code !== undefined) failure["code"] = topError.code;
761
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
762
723
  failure["url"] = url;
763
724
  throw toConciseProviderError(failure, "openrouter", clean);
764
725
  }
@@ -855,32 +816,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
855
816
  }
856
817
  };
857
818
 
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
- }
819
+ await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
878
820
 
879
821
  const toolCalls: ToolCallRecord[] = [];
880
822
  for (const c of calls.values()) {
881
823
  const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
882
824
  const record: ToolCallRecord = {
883
- 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
884
826
  name: c.name,
885
827
  arguments: args,
886
828
  rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
@@ -889,9 +831,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
889
831
  eventStream.push({ type: "tool_call_complete", toolCall: record });
890
832
  }
891
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;
892
836
  noteProviderTurn(sessionId, "openrouter");
893
837
  if (toolCalls.length > 0) {
894
- finishReason = "tool_calls";
838
+ finishReason = toolTurnFinishReason(finishReason, toolCalls);
895
839
  sawFinishReason = true;
896
840
  } else if (refusalSeen && finishReason !== "content_filter") {
897
841
  finishReason = "refusal";
@@ -901,26 +845,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
901
845
  // Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
902
846
  // instead of resolving empty success. Skipped for non-SSE payloads
903
847
  // (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));
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);
920
861
  return;
921
862
  }
922
-
923
- const cleanThinking = cleanThinkingEarly;
924
863
  const finalResponse = new AgentResponse({
925
864
  text,
926
865
  thinking: cleanThinking || undefined,
@@ -940,21 +879,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
940
879
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
941
880
  eventStream.end(finalResponse);
942
881
  } 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
- );
882
+ failStream(eventStream, linked, err);
951
883
  } finally {
952
- try {
953
- options?.signal?.removeEventListener("abort", forwardUserAbort);
954
- } catch {}
955
- try {
956
- removeStreamCancel();
957
- } catch {}
884
+ release();
958
885
  }
959
886
  })();
960
887