agent-accelerator 0.5.9 → 0.6.5

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 (85) hide show
  1. package/README.md +64 -16
  2. package/examples/prompts/SYSTEM_PROMPT.md +2 -0
  3. package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
  4. package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
  5. package/package.json +4 -1
  6. package/src/agent/agent.ts +447 -172
  7. package/src/agent/architecture.md +16 -12
  8. package/src/agent/config-blocks.ts +39 -12
  9. package/src/agent/context.ts +9 -3
  10. package/src/agent/defaults.ts +88 -0
  11. package/src/agent/delegation.ts +86 -28
  12. package/src/agent/loop.ts +474 -374
  13. package/src/agent/subagent.ts +1 -3
  14. package/src/agent/subagents-tool.ts +50 -19
  15. package/src/agent/turns.ts +3 -1
  16. package/src/agent/usage-limit.ts +92 -0
  17. package/src/agent/worker.ts +73 -30
  18. package/src/agent/workers.ts +126 -21
  19. package/src/architecture.md +17 -15
  20. package/src/index.ts +43 -19
  21. package/src/models/architecture.md +4 -3
  22. package/src/models/catalog-cache.ts +6 -5
  23. package/src/models/catalog.ts +39 -30
  24. package/src/prompts/additional-context.md +2 -0
  25. package/src/prompts/architecture.md +19 -0
  26. package/src/prompts/prompts.ts +81 -0
  27. package/src/prompts/reasoning-directive.md +5 -0
  28. package/src/{scheduler/scheduled-actions.md → prompts/scheduled-actions-tool.md} +4 -0
  29. package/src/prompts/structured-output.md +2 -0
  30. package/src/{agent/subagents.md → prompts/subagents-tool.md} +3 -0
  31. package/src/prompts/tool-bridge.md +1 -0
  32. package/src/prompts/tool-interrupted.md +1 -0
  33. package/src/prompts/tool-not-found.md +1 -0
  34. package/src/prompts/tool-repeat-blocked.md +1 -0
  35. package/src/prompts/tool-schema-error.md +1 -0
  36. package/src/prompts/tool-truncated-arguments.md +1 -0
  37. package/src/prompts/worker-failed.md +1 -0
  38. package/src/prompts/worker-replied.md +2 -0
  39. package/src/providers/architecture.md +10 -8
  40. package/src/providers/google.ts +228 -69
  41. package/src/providers/openai-compat.ts +74 -56
  42. package/src/providers/openai.ts +64 -41
  43. package/src/providers/openrouter.ts +270 -48
  44. package/src/providers/registry.ts +6 -21
  45. package/src/providers/shared.ts +87 -33
  46. package/src/providers.ts +115 -124
  47. package/src/scheduler/architecture.md +8 -7
  48. package/src/scheduler/scheduler.ts +226 -60
  49. package/src/scheduler/store.ts +23 -10
  50. package/src/scheduler/time.ts +17 -3
  51. package/src/scheduler/tool.ts +38 -16
  52. package/src/session/architecture.md +7 -6
  53. package/src/session/journal.ts +39 -12
  54. package/src/session/store.ts +126 -61
  55. package/src/session/usage.ts +58 -9
  56. package/src/streaming/architecture.md +4 -3
  57. package/src/streaming/event-stream.ts +26 -12
  58. package/src/telemetry.ts +6 -2
  59. package/src/tools/architecture.md +9 -7
  60. package/src/tools/executor.ts +109 -62
  61. package/src/tools/schema.ts +10 -6
  62. package/src/tools/subagent-result.ts +6 -1
  63. package/src/tools/tool-content.ts +32 -0
  64. package/src/tools/tool.ts +9 -11
  65. package/src/types/agent.ts +37 -22
  66. package/src/types/architecture.md +11 -10
  67. package/src/types/core.ts +5 -9
  68. package/src/types/errors.ts +26 -4
  69. package/src/types/message.ts +7 -0
  70. package/src/types/model.ts +4 -5
  71. package/src/types/scheduler.ts +4 -4
  72. package/src/types/tool.ts +14 -2
  73. package/src/update-models.ts +5 -5
  74. package/src/utils/architecture.md +16 -15
  75. package/src/utils/cache.ts +1 -3
  76. package/src/utils/durable.ts +17 -5
  77. package/src/utils/env.ts +9 -7
  78. package/src/utils/errors.ts +9 -17
  79. package/src/utils/headers.ts +3 -10
  80. package/src/utils/media.ts +23 -8
  81. package/src/utils/raw-capture.ts +9 -2
  82. package/src/utils/retry.ts +15 -3
  83. package/src/utils/safe-io.ts +29 -7
  84. package/src/utils/serialization.ts +1 -4
  85. package/src/utils/session.ts +1 -2
@@ -6,7 +6,7 @@
6
6
  * never in the canonical layer (`src/providers.ts`).
7
7
  *
8
8
  * Wire contract: `references/testings/openrouter/CONTRACT.md` (docs + live
9
- * captures on `stealth/space-bunny-alpha`, 2026-09-24). Capability matrix:
9
+ * captures, 2026-09-24). Capability matrix:
10
10
  * `references/testings/openrouter/CAPABILITY-MATRIX.md`.
11
11
  *
12
12
  * This is the STABLE transport. The beta Responses skin
@@ -24,9 +24,10 @@
24
24
  * - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
25
25
  * and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
26
26
  * parsers is a local canonical correlation id only; it is never sent.
27
- * - Prior-turn reasoning is NOT resent (chat history is messages + tool
28
- * calls only). This matches the Responses adapter behavior and avoids 400s
29
- * on strict routes; documented in the capability matrix.
27
+ * - Prior-turn reasoning is resent as `reasoning_details` exactly as returned (kept on the assistant message as
28
+ * `providerSteps`), which tool turns need (reasoning-tokens.md, "Preserving Reasoning"). Plain `reasoning` text alone is
29
+ * never resent. Parsed-PDF `file` annotations ride the same way and go back as `annotations`, so the router does not parse
30
+ * the same PDF again (pdf-inputs.md, "Skip Parsing Costs").
30
31
  */
31
32
  import type {
32
33
  Provider,
@@ -47,11 +48,11 @@ import { normalizeMediaInput } from "../utils/media.ts";
47
48
  import { safeStringify } from "../utils/serialization.ts";
48
49
  import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
49
50
  import { ConfigError, isAbortError } from "../types/errors.ts";
51
+ import { DEFAULT_PROMPTS } from "../prompts/prompts.ts";
50
52
  import {
51
53
  newToolCallId,
52
54
  redactedHeaders,
53
55
  readErrorPayload,
54
- incompleteStreamError,
55
56
  hasUnfinishedToolCall,
56
57
  linkStreamAbort,
57
58
  withAbortTimer,
@@ -85,6 +86,8 @@ import {
85
86
  augmentSystemPromptWithStructuredOutput,
86
87
  emitProviderWarning,
87
88
  warnIgnoredMediaResolution,
89
+ warnIgnoredVideoProcessing,
90
+ warnIgnoredToolMedia,
88
91
  } from "../providers.ts";
89
92
 
90
93
  // ---------------------------------------------------------------------------
@@ -109,6 +112,8 @@ type ChatMessage =
109
112
  type: "function";
110
113
  function: { name: string; arguments: string };
111
114
  }>;
115
+ reasoning_details?: unknown[];
116
+ annotations?: unknown[];
112
117
  name?: string;
113
118
  }
114
119
  | { role: "tool"; content: string; tool_call_id: string; name?: string }
@@ -119,11 +124,17 @@ interface ChatRequestBody {
119
124
  messages: ChatMessage[];
120
125
  tools?: Array<Record<string, unknown>>;
121
126
  tool_choice?: string | { type: "function"; function: { name: string } };
122
- reasoning?: { effort: string };
127
+ reasoning?: { effort?: string; exclude?: boolean };
128
+ provider?: Record<string, unknown>;
129
+ parallel_tool_calls?: boolean;
130
+ user?: string;
123
131
  service_tier?: string;
124
132
  session_id?: string;
125
133
  cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
126
- response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
134
+ response_format?: {
135
+ type: "json_schema";
136
+ json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string };
137
+ };
127
138
  stream?: boolean;
128
139
  [k: string]: unknown;
129
140
  }
@@ -133,8 +144,13 @@ interface ChatUsage {
133
144
  prompt_tokens?: number;
134
145
  completion_tokens?: number;
135
146
  total_tokens?: number;
136
- prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
137
- completion_tokens_details?: { reasoning_tokens?: number };
147
+ prompt_tokens_details?: {
148
+ cached_tokens?: number;
149
+ cache_write_tokens?: number;
150
+ audio_tokens?: number;
151
+ video_tokens?: number;
152
+ };
153
+ completion_tokens_details?: { reasoning_tokens?: number; audio_tokens?: number; image_tokens?: number };
138
154
  cost?: number;
139
155
  [k: string]: unknown;
140
156
  }
@@ -173,13 +189,84 @@ function cleanModelId(model: string | ModelSpec): string {
173
189
  }
174
190
 
175
191
  // 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 };
192
+ const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = {
193
+ rate_limit_exceeded: 429,
194
+ provider_overloaded: 503,
195
+ provider_unavailable: 502,
196
+ timeout: 504,
197
+ server: 500,
198
+ };
177
199
  // 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
200
  function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
179
201
  if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
180
202
  return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
181
203
  }
182
204
 
205
+ // OpenRouter options that come from env vars, never from AgentConfig (provider routing, `parallel_tool_calls`, `reasoning.exclude`, `user`). A per-Agent `env` wins over the process env; nothing is sent when no variable is set; a bad value is a ConfigError before any network call. NOT supported on purpose (the owner does not want them for now): `reasoning.max_tokens` and `trace`; in: buildBody, out: the extra body fields.
206
+ const ROUTING_LISTS = { OPENROUTER_ORDER: "order", OPENROUTER_ONLY: "only", OPENROUTER_IGNORE: "ignore" } as const;
207
+ const ROUTING_FLAGS = {
208
+ OPENROUTER_ALLOW_FALLBACKS: "allow_fallbacks",
209
+ OPENROUTER_REQUIRE_PARAMETERS: "require_parameters",
210
+ OPENROUTER_ZDR: "zdr",
211
+ } as const;
212
+ function openRouterEnvOptions(options?: ProviderRequestOptions): {
213
+ provider?: Record<string, unknown>;
214
+ parallel_tool_calls?: boolean;
215
+ reasoning_exclude?: boolean;
216
+ user?: string;
217
+ } {
218
+ const read = (name: string): string | undefined => {
219
+ const own = options?.env?.[name]?.trim();
220
+ return own ? own : getEnv(name);
221
+ };
222
+ const bad = (name: string, value: string, expected: string): never => {
223
+ throw new ConfigError(`[Agent Accelerator] Invalid ${name} ${JSON.stringify(value)}: expected ${expected}.`);
224
+ };
225
+ const flag = (name: string): boolean | undefined => {
226
+ const v = read(name);
227
+ if (v === undefined) return undefined;
228
+ const t = v.toLowerCase();
229
+ if (t === "true" || t === "1") return true;
230
+ if (t === "false" || t === "0") return false;
231
+ return bad(name, v, "true or false");
232
+ };
233
+ const provider: Record<string, unknown> = {};
234
+ for (const [name, field] of Object.entries(ROUTING_LISTS)) {
235
+ const v = read(name);
236
+ if (v === undefined) continue;
237
+ const list = v
238
+ .split(",")
239
+ .map((x) => x.trim())
240
+ .filter(Boolean);
241
+ if (list.length === 0) bad(name, v, "a comma-separated list of provider slugs");
242
+ provider[field] = list;
243
+ }
244
+ for (const [name, field] of Object.entries(ROUTING_FLAGS)) {
245
+ const v = flag(name);
246
+ if (v !== undefined) provider[field] = v;
247
+ }
248
+ const collection = read("OPENROUTER_DATA_COLLECTION");
249
+ if (collection !== undefined) {
250
+ if (collection !== "allow" && collection !== "deny") bad("OPENROUTER_DATA_COLLECTION", collection, "allow or deny");
251
+ provider["data_collection"] = collection;
252
+ }
253
+ const sort = read("OPENROUTER_SORT");
254
+ if (sort !== undefined) {
255
+ if (sort !== "price" && sort !== "throughput" && sort !== "latency")
256
+ bad("OPENROUTER_SORT", sort, "price, throughput or latency");
257
+ provider["sort"] = sort;
258
+ }
259
+ const parallel = flag("OPENROUTER_PARALLEL_TOOL_CALLS");
260
+ const exclude = flag("OPENROUTER_REASONING_EXCLUDE");
261
+ const user = read("OPENROUTER_USER");
262
+ return {
263
+ ...(Object.keys(provider).length > 0 ? { provider } : {}),
264
+ ...(parallel !== undefined ? { parallel_tool_calls: parallel } : {}),
265
+ ...(exclude !== undefined ? { reasoning_exclude: exclude } : {}),
266
+ ...(user ? { user } : {}),
267
+ };
268
+ }
269
+
183
270
  // Canonical tool declarations as Chat Completions function tools; in: options.tools, out: request `tools`.
184
271
  function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
185
272
  if (!tools || tools.length === 0) return undefined;
@@ -195,7 +282,18 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
195
282
  }
196
283
 
197
284
  // 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" };
285
+ const AUDIO_FORMAT: Record<string, string> = {
286
+ wav: "wav",
287
+ wave: "wav",
288
+ mpeg: "mp3",
289
+ mp3: "mp3",
290
+ mp4: "m4a",
291
+ m4a: "m4a",
292
+ aac: "aac",
293
+ ogg: "ogg",
294
+ flac: "flac",
295
+ aiff: "aiff",
296
+ };
199
297
  // The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
200
298
  function audioFormatFor(mimeType?: string): string {
201
299
  const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
@@ -203,7 +301,12 @@ function audioFormatFor(mimeType?: string): string {
203
301
  }
204
302
 
205
303
  // `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" };
304
+ const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = {
305
+ low: "low",
306
+ medium: "auto",
307
+ high: "high",
308
+ ultra_high: "original",
309
+ };
207
310
 
208
311
  // 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
312
  function hasRemoteMedia(messages: ChatMessage[]): boolean {
@@ -211,7 +314,9 @@ function hasRemoteMedia(messages: ChatMessage[]): boolean {
211
314
  (m) =>
212
315
  Array.isArray(m.content) &&
213
316
  (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;
317
+ const url =
318
+ (p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ??
319
+ (p as { video_url?: { url?: string } }).video_url?.url;
215
320
  return typeof url === "string" && /^https?:\/\//.test(url);
216
321
  })
217
322
  );
@@ -258,10 +363,7 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
258
363
  const mimeType = (part as { mimeType?: string }).mimeType;
259
364
  // OpenAI chat audio shape (router parsed it live; capability routing
260
365
  // decides per model). Always inline — `input_audio` carries data only.
261
- const norm = await normalizeMediaInput(
262
- raw as string | Uint8Array | ArrayBuffer,
263
- mimeType
264
- );
366
+ const norm = await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, mimeType);
265
367
  blocks.push({
266
368
  type: "input_audio",
267
369
  input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
@@ -273,7 +375,12 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
273
375
  const fileData =
274
376
  typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
275
377
  ? raw
276
- : (await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, (part as { mimeType?: string }).mimeType)).dataUrl;
378
+ : (
379
+ await normalizeMediaInput(
380
+ raw as string | Uint8Array | ArrayBuffer,
381
+ (part as { mimeType?: string }).mimeType
382
+ )
383
+ ).dataUrl;
277
384
  blocks.push({
278
385
  type: "file",
279
386
  file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
@@ -283,6 +390,48 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
283
390
  return blocks;
284
391
  }
285
392
 
393
+ // Folds one streamed `reasoning_details` chunk into the turn's blocks: chunks with the same index (else id) are one block whose text, summary, data and signature are concatenated or filled in, in order; in: the stream handler, out: the blocks replayed on the next request.
394
+ function mergeReasoningDetails(blocks: Array<Record<string, unknown>>, chunk: unknown): void {
395
+ if (!chunk || typeof chunk !== "object" || Array.isArray(chunk)) return;
396
+ const c = chunk as Record<string, unknown>;
397
+ const same = blocks.find((b) =>
398
+ typeof c["index"] === "number" ? b["index"] === c["index"] : c["id"] != null && b["id"] === c["id"]
399
+ );
400
+ if (!same) {
401
+ blocks.push({ ...c });
402
+ return;
403
+ }
404
+ for (const [k, v] of Object.entries(c)) {
405
+ if (v === null || v === undefined) continue;
406
+ if ((k === "text" || k === "summary" || k === "data") && typeof v === "string" && typeof same[k] === "string")
407
+ same[k] = (same[k] as string) + v;
408
+ else same[k] = v;
409
+ }
410
+ }
411
+
412
+ // Adds a streamed `file` annotation (a parsed PDF) once per `file.hash`; in: the stream handler, out: the annotations replayed on the next request.
413
+ function mergeAnnotation(blocks: Array<Record<string, unknown>>, chunk: unknown): void {
414
+ if (!chunk || typeof chunk !== "object" || (chunk as { type?: unknown }).type !== "file") return;
415
+ const hash = (chunk as { file?: { hash?: unknown } }).file?.hash;
416
+ if (typeof hash === "string" && blocks.some((b) => (b as { file?: { hash?: unknown } }).file?.hash === hash)) return;
417
+ blocks.push(chunk as Record<string, unknown>);
418
+ }
419
+
420
+ // The reasoning blocks and the parsed-PDF annotations of an assistant turn as OpenRouter returned them (both live in `providerSteps`, told apart by `type: "file"`); in: a stored assistant message, out: fullHistoryMessages.
421
+ function storedReplay(m: { providerSteps?: { provider: string; steps: unknown[] } }): {
422
+ reasoning_details?: unknown[];
423
+ annotations?: unknown[];
424
+ } {
425
+ if (m.providerSteps?.provider !== "openrouter") return {};
426
+ const isFile = (x: unknown): boolean => (x as { type?: unknown } | null)?.type === "file";
427
+ const annotations = m.providerSteps.steps.filter(isFile);
428
+ const details = m.providerSteps.steps.filter((x) => !isFile(x));
429
+ return {
430
+ ...(details.length > 0 ? { reasoning_details: details } : {}),
431
+ ...(annotations.length > 0 ? { annotations } : {}),
432
+ };
433
+ }
434
+
286
435
  // A tool result as the text of a tool message; in: fullHistoryMessages, out: a string.
287
436
  function resultToOutput(result: unknown): string {
288
437
  if (typeof result === "string") return result;
@@ -295,7 +444,10 @@ function resultToOutput(result: unknown): string {
295
444
  * carries everything. Assistant items carry no provider `id`/`status`
296
445
  * requirements (unlike the discontinued Responses skin).
297
446
  */
298
- async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
447
+ async function fullHistoryMessages(
448
+ context: ProviderContext,
449
+ bridge = DEFAULT_PROMPTS.toolBridge
450
+ ): Promise<ChatMessage[]> {
299
451
  // Map assistant tool_call ids so tool results pair even though the
300
452
  // executor keys results by the call id.
301
453
  const pairing = new Map<string, string>();
@@ -326,18 +478,25 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
326
478
  // Strict routes reject `user` directly after `tool`: bridge with a
327
479
  // synthetic assistant turn (pi api/openai-completions.ts:1233).
328
480
  if (lastRole === "tool") {
329
- messages.push({ role: "assistant", content: "I have processed the tool results." });
481
+ messages.push({ role: "assistant", content: bridge });
330
482
  }
331
483
  if (typeof m.content === "string") {
332
- if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
484
+ if (m.content) {
485
+ messages.push({ role: "user", content: m.content });
486
+ lastRole = "user";
487
+ }
333
488
  } else {
334
489
  const blocks = await contentPartsToBlocks(m.content);
335
- if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
490
+ if (blocks.length > 0) {
491
+ messages.push({ role: "user", content: blocks });
492
+ lastRole = "user";
493
+ }
336
494
  }
337
495
  } else if (m.role === "assistant") {
496
+ const replay = storedReplay(m);
338
497
  if (typeof m.content === "string") {
339
498
  if (m.content) {
340
- messages.push({ role: "assistant", content: m.content });
499
+ messages.push({ role: "assistant", content: m.content, ...replay });
341
500
  }
342
501
  continue;
343
502
  }
@@ -353,8 +512,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
353
512
  } else if (part.type === "text" && part.text) {
354
513
  texts.push(part.text);
355
514
  }
356
- // Prior-turn reasoning is NOT resent: chat history is messages +
357
- // tool calls only (documented decision; avoids strict-route 400s).
515
+ // Reasoning is not rebuilt from text: the stored `reasoning_details` blocks are replayed verbatim below.
358
516
  }
359
517
  // Skip empty assistant turns (no text + no tool calls): strict
360
518
  // routes reject them, and they carry no signal (pi drop rule).
@@ -363,6 +521,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
363
521
  role: "assistant",
364
522
  content: texts.length > 0 ? texts.join("\n") : null,
365
523
  ...(calls.length > 0 ? { tool_calls: calls } : {}),
524
+ ...replay,
366
525
  });
367
526
  lastRole = "assistant";
368
527
  } else if (m.role === "tool") {
@@ -414,7 +573,27 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
414
573
  cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
415
574
  thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
416
575
  };
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
576
+ // Media tokens are listed only when the provider reported more than zero of them.
577
+ const count = (n: unknown): number | undefined =>
578
+ typeof n === "number" && Number.isFinite(n) && n > 0 ? n : undefined;
579
+ const inDet = {
580
+ audioTokens: count(raw?.prompt_tokens_details?.audio_tokens),
581
+ videoTokens: count(raw?.prompt_tokens_details?.video_tokens),
582
+ };
583
+ const outDet = {
584
+ audioTokens: count(raw?.completion_tokens_details?.audio_tokens),
585
+ imageTokens: count(raw?.completion_tokens_details?.image_tokens),
586
+ };
587
+ const keep = (d: Record<string, number | undefined>): Record<string, number> | undefined => {
588
+ const kept = Object.fromEntries(Object.entries(d).filter(([, v]) => v !== undefined)) as Record<string, number>;
589
+ return Object.keys(kept).length > 0 ? kept : undefined;
590
+ };
591
+ const inKept = keep(inDet);
592
+ const outKept = keep(outDet);
593
+ if (inKept) usage.inputDetails = inKept;
594
+ if (outKept) usage.outputDetails = outKept;
595
+ if (typeof raw?.cost === "number" && raw.cost >= 0) {
596
+ // an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
418
597
  usage.cost = { totalCost: raw.cost };
419
598
  }
420
599
  return usage;
@@ -460,8 +639,15 @@ export class OpenRouterChatCompletionsProvider implements Provider {
460
639
  const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
461
640
  const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
462
641
  // Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
463
- const cache = resolveCache("openrouter", options?.cache, /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
642
+ const cache = resolveCache(
643
+ "openrouter",
644
+ options?.cache,
645
+ /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"],
646
+ `openrouter/${modelId}`
647
+ );
464
648
  warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
649
+ warnIgnoredVideoProcessing("openrouter", context.messages, `openrouter/${modelId}`);
650
+ warnIgnoredToolMedia("openrouter", context.messages, `openrouter/${modelId}`);
465
651
 
466
652
  // Structured outputs: native `response_format` when supported, else
467
653
  // emulated via system-prompt instruction + client validation (catalog
@@ -479,17 +665,22 @@ export class OpenRouterChatCompletionsProvider implements Provider {
479
665
  outputEmulated = true;
480
666
  effectiveContext = {
481
667
  ...context,
482
- systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
668
+ systemPrompt: augmentSystemPromptWithStructuredOutput(
669
+ context.systemPrompt,
670
+ outputSpec,
671
+ options?.prompts?.structuredOutput
672
+ ),
483
673
  };
484
674
  emitProviderWarning({
485
675
  provider: "openrouter",
486
676
  capability: "structured output",
487
677
  requested: `${outputSpec.name} (openrouter/${modelId})`,
488
- reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
678
+ reason:
679
+ "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
489
680
  fallback: "emulated JSON instruction + client validation (no response_format is sent)",
490
681
  });
491
682
  }
492
- const messages = await fullHistoryMessages(effectiveContext);
683
+ const messages = await fullHistoryMessages(effectiveContext, options?.prompts?.toolBridge);
493
684
  const body: ChatRequestBody = {
494
685
  model: modelId,
495
686
  messages,
@@ -497,15 +688,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
497
688
  };
498
689
  const orTools = toOpenRouterTools(tools);
499
690
  if (orTools) body.tools = orTools;
500
- const toolChoice = mapToolChoiceToOpenRouterChat(
501
- options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
502
- );
691
+ const toolChoice = mapToolChoiceToOpenRouterChat(options?.toolChoice);
503
692
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
504
- if (effort) body.reasoning = { effort };
693
+ const env = openRouterEnvOptions(options);
694
+ if (effort || env.reasoning_exclude !== undefined)
695
+ body.reasoning = {
696
+ ...(effort ? { effort } : {}),
697
+ ...(env.reasoning_exclude !== undefined ? { exclude: env.reasoning_exclude } : {}),
698
+ };
699
+ if (env.provider) body.provider = env.provider;
700
+ if (env.parallel_tool_calls !== undefined && orTools) body.parallel_tool_calls = env.parallel_tool_calls;
701
+ if (env.user) body.user = env.user;
505
702
  if (serviceTier) body.service_tier = serviceTier;
506
703
  if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
507
704
  // Documented Chat Completions generation controls (only those the caller set).
508
- Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
705
+ Object.assign(body, mapGenerationParamsToChat(options));
509
706
  // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
510
707
  // Skipped when emulated (unsupported model): instruction already in messages.
511
708
  if (outputSpec && !outputEmulated) {
@@ -537,11 +734,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
537
734
  ): RequestInit {
538
735
  // Attribution + session headers mirror the previous transport exactly
539
736
  // (HTTP-Referer / X-Title / x-session-id / x-client-request-id).
540
- const headers: Record<string, string> = buildSessionHeaders(
541
- "openrouter",
542
- options?.headers,
543
- sessionId
544
- );
737
+ const headers: Record<string, string> = buildSessionHeaders("openrouter", options?.headers, sessionId);
545
738
  setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
546
739
  if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
547
740
  return { method: "POST", headers, body: JSON.stringify(body), signal };
@@ -593,10 +786,16 @@ export class OpenRouterChatCompletionsProvider implements Provider {
593
786
  options?: ProviderRequestOptions
594
787
  ): Promise<ProviderGenerateResult> {
595
788
  // The whole call stays bounded by `timeoutMs`; the stream's own timer only covers the wait for its first byte.
596
- const { signal, cleanup } = combinedSignal(options?.signal, timeoutFor(options as { timeoutMs?: number } | undefined));
789
+ const { signal, cleanup } = combinedSignal(
790
+ options?.signal,
791
+ timeoutFor(options as { timeoutMs?: number } | undefined)
792
+ );
597
793
  try {
598
794
  // Nobody reads the deltas here, so the stream keeps none (a burst of frames would overflow its buffer for nothing).
599
- const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, { ...options, signal });
795
+ const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, {
796
+ ...options,
797
+ signal,
798
+ });
600
799
  const res = await events.result();
601
800
  return {
602
801
  text: res.text,
@@ -610,6 +809,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
610
809
  provider: "openrouter",
611
810
  raw: res.raw,
612
811
  durationMs: res.durationMs,
812
+ providerSteps: res.providerSteps,
613
813
  };
614
814
  } catch (err) {
615
815
  // A stream reports its own generic abort; the caller's reason (`interrupt(new Error("..."))`) is what the run should carry.
@@ -675,9 +875,14 @@ export class OpenRouterChatCompletionsProvider implements Provider {
675
875
  // Transient failures (429/5xx/network) are retried here, before the
676
876
  // first byte; once the stream is live it is never retried.
677
877
  const res = await openStreamResponse({
678
- fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
679
- onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
680
- noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
878
+ fetchOnce: () =>
879
+ withAbortTimer(linked, streamTimeoutMs, () =>
880
+ (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))
881
+ ),
882
+ onHttpError: (r, text) =>
883
+ this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
884
+ noBodyError: () =>
885
+ toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
681
886
  retry: retryOptionsFrom(options),
682
887
  signal: linked.signal,
683
888
  label: { providerId: "openrouter", modelId: clean },
@@ -686,7 +891,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
686
891
  res.headers.forEach((v, k) => {
687
892
  responseHeaders[k] = v;
688
893
  });
689
- const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
894
+ const responseMeta: Record<string, unknown> & {
895
+ status: number;
896
+ statusText: string;
897
+ headers: Record<string, string>;
898
+ } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
690
899
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
691
900
 
692
901
  let text = "";
@@ -696,6 +905,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
696
905
  // Index of the most recent tool-call delta: unindexed chunks continue
697
906
  // it (falling back to calls.size would fork a phantom call per chunk).
698
907
  let lastToolIndex = 0;
908
+ // The turn's reasoning blocks exactly as returned, replayed on the next request (tool turns need them).
909
+ const reasoningBlocks: Array<Record<string, unknown>> = [];
910
+ // Parsed PDFs (`file` annotations) of the turn: sent back so OpenRouter does not parse the same file again.
911
+ const annotationBlocks: Array<Record<string, unknown>> = [];
699
912
  let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
700
913
  let finishReason = "stop";
701
914
  let sawFinishReason = false;
@@ -721,7 +934,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
721
934
  }
722
935
  // Mid-stream provider error: top-level `error`, HTTP stays 200.
723
936
  // Must surface — resolving empty success hides it.
724
- const topError = msg["error"] as { message?: string; code?: string | number; metadata?: { error_type?: string } } | undefined;
937
+ const topError = msg["error"] as
938
+ | { message?: string; code?: string | number; metadata?: { error_type?: string } }
939
+ | undefined;
725
940
  if (topError && typeof topError === "object") {
726
941
  const message =
727
942
  typeof topError.message === "string" && topError.message
@@ -738,7 +953,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
738
953
  if (typeof msg["id"] === "string" && !responseId) {
739
954
  responseId = msg["id"] as string;
740
955
  }
741
- const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
956
+ const choice =
957
+ (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
742
958
  const delta = (choice["delta"] as Record<string, unknown>) ?? {};
743
959
  // Text delta (content-free accounting frames carry "" — ignored).
744
960
  // Refusal deltas surface as text with a `refusal` finish.
@@ -757,6 +973,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
757
973
  const details = delta["reasoning_details"];
758
974
  if (Array.isArray(details)) {
759
975
  for (const block of details) {
976
+ mergeReasoningDetails(reasoningBlocks, block);
760
977
  const t = (block as { text?: string })?.text;
761
978
  if (typeof t === "string" && t) {
762
979
  thinking += t;
@@ -778,6 +995,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
778
995
  partialThinking: thinking,
779
996
  });
780
997
  }
998
+ if (Array.isArray(delta["annotations"]))
999
+ for (const a of delta["annotations"]) mergeAnnotation(annotationBlocks, a);
781
1000
  // Tool-call deltas accumulate per choice index.
782
1001
  const deltaCalls = delta["tool_calls"];
783
1002
  if (Array.isArray(deltaCalls)) {
@@ -887,6 +1106,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
887
1106
  response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
888
1107
  },
889
1108
  durationMs: Date.now() - startTime,
1109
+ ...(reasoningBlocks.length + annotationBlocks.length > 0
1110
+ ? { providerSteps: { provider: "openrouter", steps: [...reasoningBlocks, ...annotationBlocks] } }
1111
+ : {}),
890
1112
  });
891
1113
  eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
892
1114
  eventStream.end(finalResponse);
@@ -31,12 +31,7 @@ providerRegistry.set("openai", openaiProvider);
31
31
  // MODEL="cerebras/gpt-oss-120b" + CEREBRAS_API_KEY + CEREBRAS_BASE_URL
32
32
  // MODEL="ollama/qwen2.5-coder" + OLLAMA_BASE_URL (key optional for local)
33
33
  // ---------------------------------------------------------------------------
34
- const FIRST_CLASS = new Set([
35
- "google",
36
- "gemini",
37
- "openrouter",
38
- "openai",
39
- ]);
34
+ const FIRST_CLASS = new Set(["google", "gemini", "openrouter", "openai"]);
40
35
 
41
36
  // Removed providers: fail loudly instead of misrouting. OpenCode free models
42
37
  // are harness-only since 2026, so the opencode/opencode-go prefixes no longer
@@ -168,9 +163,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
168
163
  // Try catalog first for any provider/model
169
164
  const tryCatalogLookup = (prov: string, mid: string): ModelSpec | undefined => {
170
165
  return (
171
- getModelFromCatalog(prov, mid) ||
172
- getModelFromCatalog(prov.toLowerCase(), mid) ||
173
- getModelFromCatalog(mid, mid)
166
+ getModelFromCatalog(prov, mid) || getModelFromCatalog(prov.toLowerCase(), mid) || getModelFromCatalog(mid, mid)
174
167
  );
175
168
  };
176
169
 
@@ -194,11 +187,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
194
187
  }
195
188
 
196
189
  // 2. Unified layer: any non-first-class prefix routes to auto-created custom provider
197
- const isExplicitOther = [
198
- "google",
199
- "gemini",
200
- "openrouter",
201
- ].includes(providerPrefix);
190
+ const isExplicitOther = ["google", "gemini", "openrouter"].includes(providerPrefix);
202
191
 
203
192
  if (!isExplicitOther && providerPrefix !== "openai") {
204
193
  const envName = providerPrefix.toUpperCase().replace(/[^A-Z0-9]/g, "_");
@@ -210,8 +199,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
210
199
  getEnv(`${envName}_BASE_API_KEY`)
211
200
  );
212
201
 
213
- const looksLikeOpenRouterScope =
214
- parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
202
+ const looksLikeOpenRouterScope = parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
215
203
 
216
204
  if (!looksLikeOpenRouterScope) {
217
205
  const custom = ensureCustomProvider(providerPrefix);
@@ -251,9 +239,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
251
239
  }
252
240
 
253
241
  // Check catalog for this exact provider/model
254
- const catalogSpec =
255
- tryCatalogLookup(providerPrefix, remainingModel) ||
256
- tryCatalogLookup(providerPrefix, modelStr);
242
+ const catalogSpec = tryCatalogLookup(providerPrefix, remainingModel) || tryCatalogLookup(providerPrefix, modelStr);
257
243
  if (catalogSpec) {
258
244
  const internalProvider = getProvider(catalogSpec.provider);
259
245
  return { provider: internalProvider, modelId: catalogSpec.id, modelSpec: catalogSpec };
@@ -277,8 +263,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
277
263
 
278
264
  // Fallback custom provider
279
265
  const fallbackCustom = ensureCustomProvider(providerPrefix);
280
- const fallbackSpec =
281
- fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
266
+ const fallbackSpec = fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
282
267
  return {
283
268
  provider: fallbackCustom,
284
269
  modelId: remainingModel,