agent-accelerator 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +83 -112
  2. package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
  3. package/package.json +14 -9
  4. package/src/agent/agent.ts +191 -71
  5. package/src/agent/context.ts +1 -0
  6. package/src/agent/delegation.ts +61 -12
  7. package/src/agent/loop.ts +71 -48
  8. package/src/data/README.md +6 -6
  9. package/src/index.ts +130 -45
  10. package/src/models/catalog-cache.ts +60 -9
  11. package/src/models/catalog.ts +53 -7
  12. package/src/providers/google.ts +926 -0
  13. package/src/providers/openai-compat.ts +1147 -0
  14. package/src/providers/openai.ts +959 -0
  15. package/src/providers/openrouter-responses.ts +949 -0
  16. package/src/providers/openrouter.ts +1037 -0
  17. package/src/{ai-sdk → providers}/registry.ts +43 -55
  18. package/src/providers.ts +490 -0
  19. package/src/streaming/sse-parser.ts +6 -4
  20. package/src/tools/executor.ts +19 -6
  21. package/src/tools/schema.ts +21 -11
  22. package/src/types/agent.ts +8 -1
  23. package/src/types/core.ts +1 -1
  24. package/src/types/message.ts +5 -0
  25. package/src/types/model.ts +3 -7
  26. package/src/types/provider-payloads.ts +2 -84
  27. package/src/types/tool.ts +6 -0
  28. package/src/update-models.ts +56 -0
  29. package/src/utils/cache.ts +1 -1
  30. package/src/utils/documents.ts +517 -0
  31. package/src/utils/env.ts +0 -7
  32. package/src/{ai-sdk → utils}/errors.ts +61 -2
  33. package/src/utils/headers.ts +10 -20
  34. package/src/utils/media.ts +5 -2
  35. package/src/utils/retry.ts +89 -0
  36. package/src/utils/serialization.ts +15 -0
  37. package/src/ai-sdk/converters.ts +0 -342
  38. package/src/ai-sdk/executor.ts +0 -454
  39. package/src/ai-sdk/index.ts +0 -55
  40. package/src/ai-sdk/model-provider.ts +0 -303
  41. package/src/ai-sdk/options.ts +0 -306
  42. package/src/ai-sdk/provider.ts +0 -415
  43. package/src/tokens/counter.ts +0 -136
  44. /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
  45. /package/{SYSTEM_PROMPT_TOOLS.md → examples/prompts/SYSTEM_PROMPT_TOOLS.md} +0 -0
@@ -0,0 +1,1037 @@
1
+ /**
2
+ * OpenRouter Chat Completions provider (`POST {baseUrl}/chat/completions`).
3
+ *
4
+ * All OpenRouter-specific HTTP, endpoints, headers, auth, request
5
+ * construction, response/SSE parsing, and wire transformations live HERE —
6
+ * never in the canonical layer (`src/providers.ts`).
7
+ *
8
+ * Wire contract: `references/testings/openrouter/CONTRACT.md` (docs + live
9
+ * captures on `stealth/space-bunny-alpha`, 2026-09-24). Capability matrix:
10
+ * `references/testings/openrouter/CAPABILITY-MATRIX.md`.
11
+ *
12
+ * This is the STABLE transport. The beta Responses skin
13
+ * (`src/providers/openrouter-responses.ts`) is discontinued: no video shape,
14
+ * lossy error codes, beta event-vocabulary drift.
15
+ *
16
+ * Notes:
17
+ * - 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`).
21
+ * - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
22
+ * and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
23
+ * parsers is a local canonical correlation id only; it is never sent.
24
+ * - Prior-turn reasoning is NOT resent (chat history is messages + tool
25
+ * calls only). This matches the Responses adapter behavior and avoids 400s
26
+ * on strict routes; documented in the capability matrix.
27
+ */
28
+ import type {
29
+ Provider,
30
+ ProviderId,
31
+ ModelSpec,
32
+ ProviderRequestOptions,
33
+ ProviderGenerateResult,
34
+ ProviderRawData,
35
+ } from "../types/model.ts";
36
+ import type { ProviderContext, ContentPart } from "../types/message.ts";
37
+ import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
38
+ import type { TokenUsage } from "../types/core.ts";
39
+ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
40
+ import { SSEParser } from "../streaming/sse-parser.ts";
41
+ import { AgentResponse } from "../types/response.ts";
42
+ import { getApiKey, getEnv } from "../utils/env.ts";
43
+ import { buildSessionHeaders } from "../utils/headers.ts";
44
+ import { normalizeMediaInput } from "../utils/media.ts";
45
+ import { safeStringify } from "../utils/serialization.ts";
46
+ import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
47
+ import { withRetries } from "../utils/retry.ts";
48
+ import { createGenericModelSpec } from "../models/catalog.ts";
49
+ import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
50
+ import {
51
+ mapThinkingLevelToOpenRouterChat,
52
+ mapServiceTierToOpenRouter,
53
+ applyCacheForOpenRouter,
54
+ mapToolChoiceToOpenRouterChat,
55
+ noteProviderTurn,
56
+ parseStreamedToolArguments,
57
+ } from "../providers.ts";
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Chat Completions wire shapes (most-important subset)
61
+ // ---------------------------------------------------------------------------
62
+
63
+ type ChatContentPart =
64
+ | { type: "text"; text: string }
65
+ | { type: "image_url"; image_url: { url: string } }
66
+ | { type: "video_url"; video_url: { url: string } }
67
+ | { type: "input_audio"; input_audio: { data: string; format: string } }
68
+ | { type: "file"; file: { filename?: string; file_data?: string; file_url?: string } }
69
+ | { type: string; [k: string]: unknown };
70
+
71
+ type ChatMessage =
72
+ | { role: "system" | "user"; content: string | ChatContentPart[]; name?: string }
73
+ | {
74
+ role: "assistant";
75
+ content: string | null;
76
+ tool_calls?: Array<{
77
+ id: string;
78
+ type: "function";
79
+ function: { name: string; arguments: string };
80
+ }>;
81
+ name?: string;
82
+ }
83
+ | { role: "tool"; content: string; tool_call_id: string; name?: string }
84
+ | { type: string; [k: string]: unknown };
85
+
86
+ interface ChatRequestBody {
87
+ model: string;
88
+ messages: ChatMessage[];
89
+ tools?: Array<Record<string, unknown>>;
90
+ tool_choice?: string | { type: "function"; function: { name: string } };
91
+ reasoning?: { effort: string };
92
+ service_tier?: string;
93
+ session_id?: string;
94
+ plugins?: Array<{ id: string }>;
95
+ stream?: boolean;
96
+ [k: string]: unknown;
97
+ }
98
+
99
+ interface ChatChoice {
100
+ index?: number;
101
+ message?: {
102
+ role?: string;
103
+ content?: string | null;
104
+ tool_calls?: Array<{
105
+ id?: string;
106
+ type?: string;
107
+ index?: number;
108
+ function?: { name?: string; arguments?: string };
109
+ }>;
110
+ reasoning?: string | null;
111
+ reasoning_details?: Array<{ type?: string; text?: string }>;
112
+ refusal?: string | null;
113
+ };
114
+ delta?: {
115
+ role?: string;
116
+ content?: string | null;
117
+ tool_calls?: Array<{
118
+ index?: number;
119
+ id?: string;
120
+ type?: string;
121
+ function?: { name?: string; arguments?: string };
122
+ }>;
123
+ reasoning_content?: string;
124
+ reasoning?: string;
125
+ reasoning_details?: Array<{ type?: string; text?: string }>;
126
+ refusal?: string | null;
127
+ };
128
+ finish_reason?: string | null;
129
+ native_finish_reason?: string | null;
130
+ error?: { message?: string; code?: string | number };
131
+ }
132
+
133
+ interface ChatResponse {
134
+ id?: string;
135
+ object?: string;
136
+ created?: number;
137
+ model?: string;
138
+ provider?: string;
139
+ choices?: ChatChoice[];
140
+ usage?: {
141
+ prompt_tokens?: number;
142
+ completion_tokens?: number;
143
+ total_tokens?: number;
144
+ prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
145
+ completion_tokens_details?: { reasoning_tokens?: number };
146
+ cost?: number;
147
+ [k: string]: unknown;
148
+ };
149
+ error?: {
150
+ message?: string;
151
+ code?: string | number;
152
+ metadata?: { error_type?: string; provider_code?: string | number; [k: string]: unknown };
153
+ };
154
+ [k: string]: unknown;
155
+ }
156
+
157
+ const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
158
+
159
+ /** Internal headers that must never leak onto native REST requests. */
160
+ const INTERNAL_HEADERS = new Set([
161
+ "x-thought-signature-map",
162
+ "x-cached-content-id",
163
+ "x-multimodal-user-content",
164
+ ]);
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Request building (canonical -> Chat Completions)
168
+ // ---------------------------------------------------------------------------
169
+
170
+ function resolveBaseUrl(options?: ProviderRequestOptions): string {
171
+ return (
172
+ options?.baseUrl ||
173
+ options?.env?.["OPENROUTER_BASE_URL"] ||
174
+ getEnv("OPENROUTER_BASE_URL") ||
175
+ DEFAULT_BASE_URL
176
+ ).replace(/\/+$/, "");
177
+ }
178
+
179
+ function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
180
+ return options?.apiKey || getApiKey("openrouter", undefined, options?.env);
181
+ }
182
+
183
+ /**
184
+ * Strips ONLY the `openrouter/` prefix. Scoped ids (`scope/model:variant`)
185
+ * and bare ids pass through untouched — the router resolves them.
186
+ */
187
+ function cleanModelId(model: string | ModelSpec): string {
188
+ const rawId = typeof model === "string" ? model : model.id;
189
+ return rawId.replace(/^openrouter\//i, "");
190
+ }
191
+
192
+ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
193
+ if (!tools || tools.length === 0) return undefined;
194
+ return tools.map((t) => ({
195
+ type: "function",
196
+ function: {
197
+ name: t.name,
198
+ description: t.description,
199
+ parameters: (t.parameters || { type: "object", properties: {} }) as Record<string, unknown>,
200
+ ...(t.strict !== undefined ? { strict: t.strict } : {}),
201
+ },
202
+ }));
203
+ }
204
+
205
+ function audioFormatFor(mimeType?: string): string {
206
+ const mime = (mimeType || "").toLowerCase();
207
+ if (mime.includes("wav")) return "wav";
208
+ return "mp3";
209
+ }
210
+
211
+ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
212
+ blocks: ChatContentPart[];
213
+ fileUrls: string[];
214
+ hasFiles: boolean;
215
+ }> {
216
+ const blocks: ChatContentPart[] = [];
217
+ const fileUrls: string[] = [];
218
+ let hasFiles = false;
219
+ for (const part of parts) {
220
+ if (part.type === "text" && part.text) {
221
+ blocks.push({ type: "text", text: part.text });
222
+ } else if (part.type === "image") {
223
+ const raw = (part as { image?: unknown }).image;
224
+ // Remote URLs pass through (router downloads; `image_download_failed`
225
+ // surfaces if unreachable). Inline only local/base64/binary.
226
+ if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
227
+ blocks.push({ type: "image_url", image_url: { url: raw } });
228
+ continue;
229
+ }
230
+ const norm = await normalizeMediaInput(
231
+ raw as string | Uint8Array | ArrayBuffer,
232
+ (part as { mimeType?: string }).mimeType
233
+ );
234
+ blocks.push({ type: "image_url", image_url: { url: norm.dataUrl } });
235
+ } else if (part.type === "video") {
236
+ const raw = (part as { video?: unknown }).video;
237
+ // `video_url` is the completions video shape (live: shape accepted,
238
+ // 402 billing gate without funded balance). Never download videos —
239
+ // always pass the URL through; inline bytes as a data URL.
240
+ if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
241
+ blocks.push({ type: "video_url", video_url: { url: raw } });
242
+ continue;
243
+ }
244
+ const norm = await normalizeMediaInput(
245
+ raw as string | Uint8Array | ArrayBuffer,
246
+ (part as { mimeType?: string }).mimeType
247
+ );
248
+ blocks.push({ type: "video_url", video_url: { url: norm.dataUrl } });
249
+ } else if (part.type === "audio") {
250
+ const raw = (part as { audio?: unknown }).audio;
251
+ const mimeType = (part as { mimeType?: string }).mimeType;
252
+ // OpenAI chat audio shape (router parsed it live; capability routing
253
+ // decides per model). Always inline — `input_audio` carries data only.
254
+ const norm = await normalizeMediaInput(
255
+ raw as string | Uint8Array | ArrayBuffer,
256
+ mimeType
257
+ );
258
+ blocks.push({
259
+ type: "input_audio",
260
+ input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
261
+ });
262
+ } else if (part.type === "file") {
263
+ hasFiles = true;
264
+ const raw = (part as { file?: unknown }).file;
265
+ const filename = (part as { filename?: string }).filename;
266
+ if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
267
+ // Bare `{type:file}` parts are ignored by the route (observed live),
268
+ // so the URL must ALSO ride in text for the file-parser plugin (see
269
+ // fullHistoryMessages). Still sent forward-compat.
270
+ fileUrls.push(raw);
271
+ blocks.push({
272
+ type: "file",
273
+ file: {
274
+ ...(typeof filename === "string" && filename ? { filename } : {}),
275
+ file_url: raw,
276
+ },
277
+ });
278
+ continue;
279
+ }
280
+ const norm = await normalizeMediaInput(
281
+ raw as string | Uint8Array | ArrayBuffer,
282
+ (part as { mimeType?: string }).mimeType
283
+ );
284
+ blocks.push({
285
+ type: "file",
286
+ file: {
287
+ ...(typeof filename === "string" && filename ? { filename } : {}),
288
+ file_data: norm.dataUrl,
289
+ },
290
+ });
291
+ }
292
+ }
293
+ return { blocks, fileUrls, hasFiles };
294
+ }
295
+
296
+ function resultToOutput(result: unknown): string {
297
+ if (typeof result === "string") return result;
298
+ return safeStringify(result);
299
+ }
300
+
301
+ /**
302
+ * Builds the FULL explicit history as Chat Completions `messages`.
303
+ * Stateless transport: no chaining primitives exist here, so every turn
304
+ * carries everything. Assistant items carry no provider `id`/`status`
305
+ * requirements (unlike the discontinued Responses skin).
306
+ *
307
+ * File handling: remote file URLs must be visible in text for the
308
+ * `file-parser` plugin (bare `{type:file}` parts are ignored by the route),
309
+ * so each remote file URL is appended as its own text part.
310
+ */
311
+ async function fullHistoryMessages(context: ProviderContext): Promise<{
312
+ messages: ChatMessage[];
313
+ hasFiles: boolean;
314
+ }> {
315
+ // Map assistant tool_call ids so tool results pair even though the
316
+ // executor keys results by the call id.
317
+ const pairing = new Map<string, string>();
318
+ // Queues of assistant call ids per tool name, consumed in order when a tool
319
+ // message carries a plain string (no part id to pair with).
320
+ const idsByName = new Map<string, string[]>();
321
+ for (const m of context.messages) {
322
+ if (m.role === "assistant" && Array.isArray(m.content)) {
323
+ for (const part of m.content) {
324
+ if (part.type === "tool_call") {
325
+ pairing.set(part.id, part.callId || part.id);
326
+ const queue = idsByName.get(part.name) ?? [];
327
+ queue.push(part.callId || part.id);
328
+ idsByName.set(part.name, queue);
329
+ }
330
+ }
331
+ }
332
+ }
333
+
334
+ const messages: ChatMessage[] = [];
335
+ if (context.systemPrompt) {
336
+ messages.push({ role: "system", content: context.systemPrompt });
337
+ }
338
+ let hasFiles = false;
339
+ for (const m of context.messages) {
340
+ if (m.role === "system") continue;
341
+ if (m.role === "user") {
342
+ if (typeof m.content === "string") {
343
+ if (m.content) messages.push({ role: "user", content: m.content });
344
+ } else {
345
+ const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
346
+ if (hf) hasFiles = true;
347
+ // Surface remote file URLs in text for the file-parser plugin.
348
+ for (const url of fileUrls) {
349
+ blocks.push({ type: "text", text: url });
350
+ }
351
+ if (blocks.length > 0) messages.push({ role: "user", content: blocks });
352
+ }
353
+ } else if (m.role === "assistant") {
354
+ if (typeof m.content === "string") {
355
+ if (m.content) {
356
+ messages.push({ role: "assistant", content: m.content });
357
+ }
358
+ continue;
359
+ }
360
+ const texts: string[] = [];
361
+ const calls: Array<{ id: string; type: "function"; function: { name: string; arguments: string } }> = [];
362
+ for (const part of m.content) {
363
+ if (part.type === "tool_call") {
364
+ calls.push({
365
+ id: part.callId || part.id,
366
+ type: "function",
367
+ function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
368
+ });
369
+ } else if (part.type === "text" && part.text) {
370
+ texts.push(part.text);
371
+ }
372
+ // Prior-turn reasoning is NOT resent: chat history is messages +
373
+ // tool calls only (documented decision; avoids strict-route 400s).
374
+ }
375
+ messages.push({
376
+ role: "assistant",
377
+ content: texts.length > 0 ? texts.join("\n") : null,
378
+ ...(calls.length > 0 ? { tool_calls: calls } : {}),
379
+ });
380
+ } else if (m.role === "tool") {
381
+ if (typeof m.content === "string") {
382
+ const queue = (m.name && idsByName.get(m.name)) || [];
383
+ messages.push({
384
+ role: "tool",
385
+ tool_call_id: queue.shift() || "call_0",
386
+ content: m.content,
387
+ name: m.name,
388
+ });
389
+ continue;
390
+ }
391
+ if (!Array.isArray(m.content)) continue;
392
+ for (const part of m.content) {
393
+ if (part.type === "tool_result") {
394
+ messages.push({
395
+ role: "tool",
396
+ tool_call_id: pairing.get(part.id) || part.id,
397
+ content: resultToOutput(part.result),
398
+ name: part.name,
399
+ });
400
+ }
401
+ }
402
+ }
403
+ }
404
+ return { messages, hasFiles };
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Response mapping (Chat Completions -> canonical)
409
+ // ---------------------------------------------------------------------------
410
+
411
+ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
412
+ const input = raw?.prompt_tokens ?? 0;
413
+ const output = raw?.completion_tokens ?? 0;
414
+ // Canonical invariant: cache hits are a SUBSET of input (no turn may
415
+ // report a >100% hit rate).
416
+ const cached = Math.min(raw?.prompt_tokens_details?.cached_tokens ?? 0, input);
417
+ const usage: TokenUsage = {
418
+ inputTokens: input,
419
+ outputTokens: output,
420
+ totalTokens: raw?.total_tokens ?? input + output,
421
+ cachedTokens: cached,
422
+ cacheReadTokens: cached,
423
+ cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
424
+ thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
425
+ };
426
+ if (typeof raw?.cost === "number" && raw.cost > 0) {
427
+ usage.cost = { totalCost: raw.cost };
428
+ }
429
+ return usage;
430
+ }
431
+
432
+ function parseArguments(raw: string | undefined): Record<string, unknown> {
433
+ if (!raw) return {};
434
+ try {
435
+ const parsed: unknown = JSON.parse(raw);
436
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
437
+ return parsed as Record<string, unknown>;
438
+ }
439
+ return { raw };
440
+ } catch {
441
+ return { raw };
442
+ }
443
+ }
444
+
445
+ /** Trims/collapses assembled thinking parts (no edge-tripling). */
446
+ function normalizeThinkingParts(parts: string[]): string | undefined {
447
+ const cleaned = parts
448
+ .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
449
+ .filter((p) => p.length > 0);
450
+ return cleaned.length > 0 ? cleaned.join("\n") : undefined;
451
+ }
452
+
453
+ function throwResponseError(response: ChatResponse, modelId: string): void {
454
+ const message =
455
+ response.error && typeof response.error.message === "string" && response.error.message
456
+ ? response.error.message
457
+ : "OpenRouter response failed";
458
+ const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
459
+ if (response.error?.code !== undefined) failure["code"] = response.error.code;
460
+ // Chat skin keeps the stable typed code INSIDE metadata (unlike Responses'
461
+ // top-level `error_type`).
462
+ const errorType = (response.error?.metadata as { error_type?: string } | undefined)?.error_type;
463
+ if (typeof errorType === "string") failure["errorType"] = errorType;
464
+ throw toConciseProviderError(failure, "openrouter", modelId);
465
+ }
466
+
467
+ function parseResponse(
468
+ response: ChatResponse,
469
+ modelId: string,
470
+ durationMs: number,
471
+ raw: ProviderRawData
472
+ ): ProviderGenerateResult {
473
+ // Provider-interrupted generations arrive as HTTP 200 carrying ONLY `error`
474
+ // (no `choices`) — must check the body, not just the status.
475
+ if (response.error) throwResponseError(response, modelId);
476
+
477
+ const choice = response.choices?.[0];
478
+ const message = choice?.message;
479
+ const text = typeof message?.content === "string" ? message.content : "";
480
+ // Thinking: `reasoning_details[].text` duplicates `message.reasoning` when
481
+ // both ride along (observed live) — prefer details, else the plain field.
482
+ const thinkingParts: string[] = [];
483
+ const detailTexts: string[] = [];
484
+ for (const block of message?.reasoning_details ?? []) {
485
+ if (block?.type === "reasoning.text" && block.text) detailTexts.push(block.text);
486
+ }
487
+ if (detailTexts.length > 0) {
488
+ thinkingParts.push(...detailTexts);
489
+ } else if (typeof message?.reasoning === "string" && message.reasoning) {
490
+ thinkingParts.push(message.reasoning);
491
+ }
492
+ const toolCalls: ToolCallRecord[] = [];
493
+ for (const tc of message?.tool_calls ?? []) {
494
+ const args = parseArguments(tc.function?.arguments);
495
+ toolCalls.push({
496
+ id: tc.id || `call_${Math.random().toString(36).slice(2, 9)}`,
497
+ name: tc.function?.name || "unknown",
498
+ arguments: args,
499
+ rawArguments:
500
+ typeof tc.function?.arguments === "string"
501
+ ? tc.function.arguments
502
+ : JSON.stringify(tc.function?.arguments ?? {}),
503
+ });
504
+ }
505
+
506
+ // Normalized finish reasons: tool_calls|stop|length|content_filter|error.
507
+ const wireReason = choice?.finish_reason ?? undefined;
508
+ return {
509
+ text,
510
+ thinking: normalizeThinkingParts(thinkingParts),
511
+ thoughtSignature: undefined,
512
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
513
+ usage: mapUsage(response.usage),
514
+ finishReason:
515
+ toolCalls.length > 0 ? "tool_calls" : wireReason || "stop",
516
+ responseId: response.id,
517
+ model: modelId,
518
+ provider: "openrouter",
519
+ raw,
520
+ durationMs,
521
+ };
522
+ }
523
+
524
+ function redactedHeaders(headers: Record<string, string>): Record<string, string> {
525
+ const out: Record<string, string> = {};
526
+ for (const [k, v] of Object.entries(headers)) {
527
+ out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
528
+ }
529
+ return out;
530
+ }
531
+
532
+ function readErrorPayload(bodyText: string): { message: string; code?: string | number; errorType?: string } {
533
+ try {
534
+ const parsed: unknown = JSON.parse(bodyText);
535
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
536
+ const err = (first as { error?: { message?: string; code?: string | number; metadata?: { error_type?: string } } })?.error;
537
+ if (err && typeof err.message === "string") {
538
+ const errorType = (err as { metadata?: { error_type?: string } })?.metadata?.error_type;
539
+ return {
540
+ message: err.message,
541
+ code: err.code,
542
+ ...(typeof errorType === "string" ? { errorType } : {}),
543
+ };
544
+ }
545
+ return { message: bodyText.slice(0, 300) };
546
+ } catch {
547
+ return { message: bodyText.slice(0, 300) };
548
+ }
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // Provider
553
+ // ---------------------------------------------------------------------------
554
+
555
+ /**
556
+ * OpenRouter provider implemented directly on the Chat Completions REST API
557
+ * (`POST {baseUrl}/chat/completions`, streaming on the same endpoint).
558
+ */
559
+ export class OpenRouterChatCompletionsProvider implements Provider {
560
+ readonly id: ProviderId = "openrouter";
561
+ readonly name = "OpenRouter Chat Completions";
562
+
563
+ /** Live catalog view: a constructor snapshot would go stale after refresh. */
564
+ get models(): ModelSpec[] {
565
+ return getModelsForProvider("openrouter");
566
+ }
567
+
568
+ getModel(modelId: string): ModelSpec | undefined {
569
+ const clean = cleanModelId(modelId);
570
+ return (
571
+ getModelFromCatalog(this.id, modelId) ||
572
+ getModelFromCatalog(this.id, clean) ||
573
+ this.models.find((m) => m.id === modelId || m.id === clean) ||
574
+ createGenericModelSpec(this.id, clean)
575
+ );
576
+ }
577
+
578
+ private async buildBody(
579
+ modelId: string,
580
+ context: ProviderContext,
581
+ options: ProviderRequestOptions | undefined,
582
+ sessionId: string | undefined,
583
+ tools: StandardToolDeclaration[] | undefined,
584
+ stream: boolean
585
+ ): Promise<ChatRequestBody> {
586
+ const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
587
+ const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
588
+ applyCacheForOpenRouter(options?.cache, `openrouter/${modelId}`);
589
+
590
+ const { messages, hasFiles } = await fullHistoryMessages(context);
591
+ const body: ChatRequestBody = {
592
+ model: modelId,
593
+ messages,
594
+ ...(stream ? { stream: true } : {}),
595
+ };
596
+ const orTools = toOpenRouterTools(tools);
597
+ if (orTools) body.tools = orTools;
598
+ const toolChoice = mapToolChoiceToOpenRouterChat(
599
+ options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
600
+ );
601
+ if (toolChoice !== undefined) body.tool_choice = toolChoice;
602
+ if (effort) body.reasoning = { effort };
603
+ if (serviceTier) body.service_tier = serviceTier;
604
+ // PDF ingestion is a documented plugin, not a message shape: enable it
605
+ // only when file parts are present (never by default).
606
+ if (hasFiles) body.plugins = [{ id: "file-parser" }];
607
+ // No temperature/top_p/max_tokens/stop/response_format/user/seed knobs
608
+ // (no canonical options exist; omitting also keeps provider cache keys
609
+ // stable). No store/previous_response_id/conversation: they do not exist
610
+ // on this endpoint.
611
+ // Session affinity: top-level `session_id` is the documented sticky-routing
612
+ // key on Chat Completions (prompt-caching guide) and takes precedence over
613
+ // the `x-session-id` header (still sent in requestInit as fallback). It is
614
+ // constant per session so prefix-cache stability is untouched, and —
615
+ // unlike headers — it survives browser CORS stripping.
616
+ if (sessionId) body.session_id = sessionId;
617
+ return body;
618
+ }
619
+
620
+ private requestInit(
621
+ body: ChatRequestBody,
622
+ options: ProviderRequestOptions | undefined,
623
+ apiKey: string,
624
+ sessionId: string | undefined,
625
+ signal?: AbortSignal
626
+ ): RequestInit {
627
+ // Attribution + session headers mirror the previous transport exactly
628
+ // (HTTP-Referer / X-Title / x-session-id / x-client-request-id).
629
+ const headers: Record<string, string> = buildSessionHeaders(
630
+ "openrouter",
631
+ options?.cache,
632
+ options?.headers,
633
+ sessionId
634
+ );
635
+ for (const [k, v] of Object.entries(headers)) {
636
+ if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
637
+ }
638
+ headers["Content-Type"] = "application/json";
639
+ headers["Authorization"] = `Bearer ${apiKey}`;
640
+ return { method: "POST", headers, body: JSON.stringify(body), signal };
641
+ }
642
+
643
+ private async doFetch(
644
+ url: string,
645
+ body: ChatRequestBody,
646
+ options: ProviderRequestOptions | undefined,
647
+ apiKey: string,
648
+ sessionId: string | undefined,
649
+ signal?: AbortSignal
650
+ ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
651
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, signal));
652
+ const text = await res.text();
653
+ const headers: Record<string, string> = {};
654
+ res.headers.forEach((v, k) => {
655
+ headers[k] = v;
656
+ });
657
+ return { status: res.status, statusText: res.statusText, headers, text };
658
+ }
659
+
660
+ private throwIfError(
661
+ status: number,
662
+ url: string,
663
+ bodyText: string,
664
+ modelId: string
665
+ ): void {
666
+ if (status >= 200 && status < 300) return;
667
+ const { message, code, errorType } = readErrorPayload(bodyText);
668
+ const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
669
+ (err as Record<string, unknown>)["statusCode"] = status;
670
+ (err as Record<string, unknown>)["status"] = status;
671
+ (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
672
+ (err as Record<string, unknown>)["url"] = url.split("?")[0];
673
+ if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
674
+ if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
675
+ throw toConciseProviderError(err, "openrouter", modelId);
676
+ }
677
+
678
+ async generate(
679
+ model: string | ModelSpec,
680
+ context: ProviderContext,
681
+ options?: ProviderRequestOptions
682
+ ): Promise<ProviderGenerateResult> {
683
+ const startTime = Date.now();
684
+ const clean = cleanModelId(model);
685
+ const apiKey = resolveApiKey(options);
686
+ if (!apiKey) {
687
+ throw new Error(
688
+ "[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
689
+ );
690
+ }
691
+ // Fail fast before any network call when the catalog knows the model
692
+ // lacks the requested modality (clear one-liner instead of a confusing
693
+ // router 404/400). Unknown models skip the guard; the router verdict
694
+ // surfaces concisely.
695
+ assertModalitiesSupported(context, "openrouter", clean);
696
+ const baseUrl = resolveBaseUrl(options);
697
+ const url = `${baseUrl}/chat/completions`;
698
+ const sessionId = options?.sessionId || options?.cache?.sessionId;
699
+ const tools = options?.tools as StandardToolDeclaration[] | undefined;
700
+ const body = await this.buildBody(clean, context, options, sessionId, tools, false);
701
+
702
+ // Audit trail: record the actual wire headers (attribution + session
703
+ // affinity included), redacted.
704
+ const auditRaw: Record<string, string> = {
705
+ ...buildSessionHeaders("openrouter", options?.cache, options?.headers, sessionId),
706
+ "Content-Type": "application/json",
707
+ Authorization: "[REDACTED]",
708
+ };
709
+ for (const k of Object.keys(auditRaw)) {
710
+ if (INTERNAL_HEADERS.has(k.toLowerCase())) delete auditRaw[k];
711
+ }
712
+ const rawRequest = {
713
+ url,
714
+ method: "POST",
715
+ headers: redactedHeaders(auditRaw),
716
+ body,
717
+ };
718
+
719
+ const doCall = async (): Promise<ProviderGenerateResult> => {
720
+ const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
721
+ this.throwIfError(res.status, url, res.text, clean);
722
+ let response: ChatResponse;
723
+ try {
724
+ response = JSON.parse(res.text) as ChatResponse;
725
+ } catch {
726
+ throw toConciseProviderError(
727
+ Object.assign(new Error("Invalid JSON response from OpenRouter Chat Completions API"), {
728
+ statusCode: res.status,
729
+ responseBody: res.text.slice(0, 500),
730
+ url,
731
+ }),
732
+ "openrouter",
733
+ clean
734
+ );
735
+ }
736
+ // Stateless turns never establish chains, but the session store still
737
+ // records the turn so other providers' switch detection keeps working.
738
+ noteProviderTurn(sessionId, "openrouter");
739
+ return parseResponse(
740
+ response,
741
+ clean,
742
+ Date.now() - startTime,
743
+ { request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: response } }
744
+ );
745
+ };
746
+
747
+ try {
748
+ return await withRetries(doCall, {
749
+ maxRetries: options?.maxRetries,
750
+ maxRetryDelayMs: options?.maxRetryDelayMs,
751
+ signal: options?.signal,
752
+ label: { providerId: "openrouter", modelId: clean },
753
+ });
754
+ } catch (err) {
755
+ if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
756
+ throw err;
757
+ }
758
+ }
759
+
760
+ stream(
761
+ model: string | ModelSpec,
762
+ context: ProviderContext,
763
+ options?: ProviderRequestOptions
764
+ ): AssistantMessageEventStream {
765
+ const eventStream = new AssistantMessageEventStream();
766
+ const startTime = Date.now();
767
+ const clean = cleanModelId(model);
768
+
769
+ const linked = new AbortController();
770
+ const forwardUserAbort = () => {
771
+ try {
772
+ linked.abort((options?.signal as { reason?: unknown })?.reason);
773
+ } catch {
774
+ try {
775
+ linked.abort();
776
+ } catch {}
777
+ }
778
+ };
779
+ if (options?.signal?.aborted) forwardUserAbort();
780
+ else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
781
+ const removeStreamCancel = eventStream.onCancel(() => {
782
+ try {
783
+ linked.abort();
784
+ } catch {}
785
+ });
786
+
787
+ (async () => {
788
+ try {
789
+ const apiKey = resolveApiKey(options);
790
+ if (!apiKey) {
791
+ throw new Error(
792
+ "[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
793
+ );
794
+ }
795
+ assertModalitiesSupported(context, "openrouter", clean);
796
+ const baseUrl = resolveBaseUrl(options);
797
+ const url = `${baseUrl}/chat/completions`;
798
+ const sessionId = options?.sessionId || options?.cache?.sessionId;
799
+ const tools = options?.tools as StandardToolDeclaration[] | undefined;
800
+ const body = await this.buildBody(clean, context, options, sessionId, tools, true);
801
+
802
+ const streamAuditRaw: Record<string, string> = {
803
+ ...buildSessionHeaders("openrouter", options?.cache, options?.headers, sessionId),
804
+ "Content-Type": "application/json",
805
+ Authorization: "[REDACTED]",
806
+ };
807
+ for (const k of Object.keys(streamAuditRaw)) {
808
+ if (INTERNAL_HEADERS.has(k.toLowerCase())) delete streamAuditRaw[k];
809
+ }
810
+ const rawRequest = {
811
+ url,
812
+ method: "POST",
813
+ headers: redactedHeaders(streamAuditRaw),
814
+ body,
815
+ };
816
+
817
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
818
+ if (!res.ok || !res.body) {
819
+ const text = !res.ok ? await res.text().catch(() => "") : "";
820
+ if (!res.ok) this.throwIfError(res.status, url, text, clean);
821
+ throw toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean);
822
+ }
823
+ const responseHeaders: Record<string, string> = {};
824
+ res.headers.forEach((v, k) => {
825
+ responseHeaders[k] = v;
826
+ });
827
+ const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
828
+ eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
829
+
830
+ const parser = new SSEParser();
831
+ const reader = res.body.getReader();
832
+ const decoder = new TextDecoder();
833
+ let text = "";
834
+ let thinking = "";
835
+ // Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
836
+ const calls = new Map<number, { id: string; name: string; startArgs: string; deltaArgs: string }>();
837
+ // Index of the most recent tool-call delta: unindexed chunks continue
838
+ // it (falling back to calls.size would fork a phantom call per chunk).
839
+ let lastToolIndex = 0;
840
+ let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
841
+ let finishReason = "stop";
842
+ let responseId: string | undefined;
843
+ let completedBody: unknown = undefined;
844
+ let aborted = false;
845
+ linked.signal.addEventListener(
846
+ "abort",
847
+ () => {
848
+ aborted = true;
849
+ try {
850
+ void reader.cancel();
851
+ } catch {}
852
+ },
853
+ { once: true }
854
+ );
855
+
856
+ const handleMessage = (data: string): void => {
857
+ if (!data || data === "[DONE]") return;
858
+ let msg: Record<string, unknown>;
859
+ try {
860
+ msg = JSON.parse(data) as Record<string, unknown>;
861
+ } catch {
862
+ return;
863
+ }
864
+ // Mid-stream provider error: top-level `error`, HTTP stays 200.
865
+ // Must surface — resolving empty success hides it.
866
+ const topError = msg["error"] as { message?: string; code?: string | number; metadata?: { error_type?: string } } | undefined;
867
+ if (topError && typeof topError === "object") {
868
+ const message =
869
+ typeof topError.message === "string" && topError.message
870
+ ? topError.message
871
+ : "OpenRouter streaming error";
872
+ const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
873
+ if (topError.code !== undefined) failure["code"] = topError.code;
874
+ if (typeof topError.metadata?.error_type === "string") failure["errorType"] = topError.metadata.error_type;
875
+ failure["url"] = url;
876
+ throw toConciseProviderError(failure, "openrouter", clean);
877
+ }
878
+ if (typeof msg["id"] === "string" && !responseId) {
879
+ responseId = msg["id"] as string;
880
+ }
881
+ const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
882
+ const delta = (choice["delta"] as Record<string, unknown>) ?? {};
883
+ // Text delta (content-free accounting frames carry "" — ignored).
884
+ if (typeof delta["content"] === "string" && delta["content"]) {
885
+ text += delta["content"] as string;
886
+ eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
887
+ }
888
+ // Thinking deltas: `reasoning_details[].text` duplicates `reasoning`
889
+ // when both ride along — prefer details, else the plain field.
890
+ const details = delta["reasoning_details"];
891
+ if (Array.isArray(details)) {
892
+ for (const block of details) {
893
+ const t = (block as { text?: string })?.text;
894
+ if (typeof t === "string" && t) {
895
+ thinking += t;
896
+ eventStream.push({ type: "thinking_delta", thinkingDelta: t, partialThinking: thinking });
897
+ }
898
+ }
899
+ } else if (typeof delta["reasoning_content"] === "string" && delta["reasoning_content"]) {
900
+ thinking += delta["reasoning_content"] as string;
901
+ eventStream.push({
902
+ type: "thinking_delta",
903
+ thinkingDelta: delta["reasoning_content"] as string,
904
+ partialThinking: thinking,
905
+ });
906
+ } else if (typeof delta["reasoning"] === "string" && delta["reasoning"]) {
907
+ thinking += delta["reasoning"] as string;
908
+ eventStream.push({
909
+ type: "thinking_delta",
910
+ thinkingDelta: delta["reasoning"] as string,
911
+ partialThinking: thinking,
912
+ });
913
+ }
914
+ // Tool-call deltas accumulate per choice index.
915
+ const deltaCalls = delta["tool_calls"];
916
+ if (Array.isArray(deltaCalls)) {
917
+ for (const tc of deltaCalls) {
918
+ const entry = tc as {
919
+ index?: number;
920
+ id?: string;
921
+ type?: string;
922
+ function?: { name?: string; arguments?: string };
923
+ };
924
+ const index = typeof entry.index === "number" ? (lastToolIndex = entry.index) : lastToolIndex;
925
+ const existing = calls.get(index);
926
+ if (existing) {
927
+ if (entry.id) existing.id = entry.id;
928
+ if (entry.function?.name) existing.name = entry.function.name;
929
+ if (typeof entry.function?.arguments === "string") {
930
+ existing.deltaArgs += entry.function.arguments;
931
+ }
932
+ } else {
933
+ calls.set(index, {
934
+ id: entry.id || "",
935
+ name: entry.function?.name || "unknown",
936
+ startArgs: "",
937
+ deltaArgs: typeof entry.function?.arguments === "string" ? entry.function.arguments : "",
938
+ });
939
+ }
940
+ }
941
+ }
942
+ // Terminal accounting: finish_reason repeats on the usage chunk.
943
+ if (typeof choice["finish_reason"] === "string" && choice["finish_reason"]) {
944
+ const fr = choice["finish_reason"] as string;
945
+ if (fr === "error") {
946
+ const failure: Record<string, unknown> & Error = new Error(
947
+ "OpenRouter stream terminated with finish_reason error"
948
+ ) as Record<string, unknown> & Error;
949
+ failure["url"] = url;
950
+ throw toConciseProviderError(failure, "openrouter", clean);
951
+ }
952
+ finishReason = fr;
953
+ }
954
+ const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
955
+ if (chunkUsage) {
956
+ usage = mapUsage(chunkUsage);
957
+ completedBody = completedBody ?? msg;
958
+ eventStream.push({ type: "usage", usage });
959
+ }
960
+ };
961
+
962
+ while (true) {
963
+ if (linked.signal.aborted || eventStream.isCancelled()) {
964
+ try {
965
+ await reader.cancel();
966
+ } catch {}
967
+ throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
968
+ }
969
+ const { done, value } = await reader.read();
970
+ if (done) break;
971
+ const chunk = decoder.decode(value, { stream: true });
972
+ for (const m of parser.feed(chunk)) handleMessage(m.data);
973
+ }
974
+ for (const m of parser.flush()) handleMessage(m.data);
975
+ try {
976
+ reader.releaseLock();
977
+ } catch {}
978
+
979
+ if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
980
+ throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
981
+ }
982
+
983
+ const toolCalls: ToolCallRecord[] = [];
984
+ for (const c of calls.values()) {
985
+ const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
986
+ const record: ToolCallRecord = {
987
+ id: c.id || `call_${Math.random().toString(36).slice(2, 9)}`,
988
+ name: c.name,
989
+ arguments: args,
990
+ rawArguments: c.startArgs + c.deltaArgs,
991
+ };
992
+ toolCalls.push(record);
993
+ eventStream.push({ type: "tool_call_complete", toolCall: record });
994
+ }
995
+
996
+ noteProviderTurn(sessionId, "openrouter");
997
+ if (toolCalls.length > 0) finishReason = "tool_calls";
998
+
999
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
1000
+ const finalResponse = new AgentResponse({
1001
+ text,
1002
+ thinking: cleanThinking || undefined,
1003
+ thoughtSignature: undefined,
1004
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
1005
+ usage,
1006
+ finishReason,
1007
+ responseId,
1008
+ model: clean,
1009
+ provider: "openrouter",
1010
+ raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
1011
+ durationMs: Date.now() - startTime,
1012
+ });
1013
+ eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
1014
+ eventStream.end(finalResponse);
1015
+ } catch (err: unknown) {
1016
+ const raw = err instanceof Error ? err : new Error(String(err));
1017
+ const isAbort =
1018
+ linked.signal.aborted ||
1019
+ eventStream.isCancelled() ||
1020
+ (raw as { name?: string }).name === "AbortError" ||
1021
+ /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
1022
+ eventStream.fail(
1023
+ isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1024
+ );
1025
+ } finally {
1026
+ try {
1027
+ options?.signal?.removeEventListener("abort", forwardUserAbort);
1028
+ } catch {}
1029
+ try {
1030
+ removeStreamCancel();
1031
+ } catch {}
1032
+ }
1033
+ })();
1034
+
1035
+ return eventStream;
1036
+ }
1037
+ }