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,949 @@
1
+ /**
2
+ * DISCONTINUED: OpenRouter Responses API provider (`POST {baseUrl}/responses`).
3
+ *
4
+ * Retained frozen as migration evidence (see
5
+ * `references/testings/openrouter-responses/`). The registry and public
6
+ * barrel route `openrouter` to the stable Chat Completions transport
7
+ * (`src/providers/openrouter.ts`). Do not extend this file.
8
+ *
9
+ * All OpenRouter-specific HTTP, endpoints, headers, auth, request
10
+ * construction, response/SSE parsing, and wire transformations live HERE —
11
+ * never in the canonical layer (`src/providers.ts`).
12
+ *
13
+ * Wire contract: `references/testings/openrouter/CONTRACT.md` (docs + raw
14
+ * captures). Capability matrix:
15
+ * `references/testings/openrouter/CAPABILITY-MATRIX.md`.
16
+ *
17
+ * Notes:
18
+ * - The Responses API is STATELESS ONLY: every turn sends the full
19
+ * canonical history explicitly. `store`, `previous_response_id`, and
20
+ * `background` are never sent (`store:true` is a documented 400).
21
+ * - IDs are provider-generated (`gen-…`, `msg_tmp_…`, `fc_tmp_…` +
22
+ * `call_…`, `rs_tmp_…`) and echoed verbatim (`call_id` in
23
+ * `function_call_output`). The `call_${random}` fallback in parsers is a
24
+ * local canonical correlation id only; it is never sent to the provider.
25
+ * - Assistant history items omit provider `id`/`status`: the docs mark them
26
+ * required, but raw probes (`references/testings/openrouter/`, probes A/B)
27
+ * prove full-history sends without them complete successfully. If a route
28
+ * ever rejects that shape, the concise error surfaces it — revisit then.
29
+ */
30
+ import type {
31
+ Provider,
32
+ ProviderId,
33
+ ModelSpec,
34
+ ProviderRequestOptions,
35
+ ProviderGenerateResult,
36
+ ProviderRawData,
37
+ } from "../types/model.ts";
38
+ import type { ProviderContext, Message, ContentPart } from "../types/message.ts";
39
+ import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
40
+ import type { TokenUsage } from "../types/core.ts";
41
+ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
42
+ import { SSEParser } from "../streaming/sse-parser.ts";
43
+ import { AgentResponse } from "../types/response.ts";
44
+ import { getApiKey, getEnv } from "../utils/env.ts";
45
+ import { buildSessionHeaders } from "../utils/headers.ts";
46
+ import { normalizeMediaInput } from "../utils/media.ts";
47
+ import { safeStringify } from "../utils/serialization.ts";
48
+ import { toConciseProviderError, assertModalitiesSupported, assertNoVideoPartsOnResponses } from "../utils/errors.ts";
49
+ import { withRetries } from "../utils/retry.ts";
50
+ import { createGenericModelSpec } from "../models/catalog.ts";
51
+ import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
52
+ import {
53
+ mapThinkingLevelToOpenRouter,
54
+ mapServiceTierToOpenRouter,
55
+ applyCacheForOpenRouter,
56
+ mapToolChoiceToOpenRouter,
57
+ noteProviderTurn,
58
+ parseStreamedToolArguments,
59
+ } from "../providers.ts";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Responses wire shapes (subset used by this adapter)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ type ResponseContentPart =
66
+ | { type: "input_text"; text: string }
67
+ | { type: "input_image"; image_url: string }
68
+ | { type: "input_file"; file_url: string; filename?: string }
69
+ | { type: "output_text"; text: string; annotations?: unknown[] }
70
+ | { type: "reasoning_text"; text: string }
71
+ | { type: string; [k: string]: unknown };
72
+
73
+ type ResponseInputItem =
74
+ | { type: "message"; role: "user" | "assistant" | "system"; content: ResponseContentPart[] }
75
+ | { type: "function_call"; id: string; call_id: string; name: string; arguments: string }
76
+ | { type: "function_call_output"; call_id: string; output: string }
77
+ | { type: string; [k: string]: unknown };
78
+
79
+ interface ResponsesRequestBody {
80
+ model: string;
81
+ input: ResponseInputItem[];
82
+ instructions?: string;
83
+ tools?: Array<Record<string, unknown>>;
84
+ tool_choice?: string | { type: "function"; name: string };
85
+ reasoning?: { effort: string };
86
+ service_tier?: string;
87
+ prompt_cache_key?: string;
88
+ session_id?: string;
89
+ stream?: boolean;
90
+ [k: string]: unknown;
91
+ }
92
+
93
+ interface ResponsesOutputItem {
94
+ type: string;
95
+ id?: string;
96
+ call_id?: string;
97
+ name?: string;
98
+ arguments?: string;
99
+ status?: string;
100
+ role?: string;
101
+ content?: Array<{ type?: string; text?: string; annotations?: unknown[] }>;
102
+ summary?: string[];
103
+ encrypted_content?: string;
104
+ output?: unknown;
105
+ [k: string]: unknown;
106
+ }
107
+
108
+ interface ResponsesObject {
109
+ id?: string;
110
+ object?: string;
111
+ created_at?: number;
112
+ model?: string;
113
+ status?: string;
114
+ output?: ResponsesOutputItem[];
115
+ error?: { message?: string; code?: string | number } | null;
116
+ error_type?: string;
117
+ usage?: {
118
+ input_tokens?: number;
119
+ output_tokens?: number;
120
+ total_tokens?: number;
121
+ input_tokens_details?: { cached_tokens?: number };
122
+ output_tokens_details?: { reasoning_tokens?: number };
123
+ cost?: number;
124
+ [k: string]: unknown;
125
+ };
126
+ [k: string]: unknown;
127
+ }
128
+
129
+ const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
130
+
131
+ /** Internal headers that must never leak onto native REST requests. */
132
+ const INTERNAL_HEADERS = new Set([
133
+ "x-thought-signature-map",
134
+ "x-cached-content-id",
135
+ "x-multimodal-user-content",
136
+ ]);
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // Request building (canonical -> Responses)
140
+ // ---------------------------------------------------------------------------
141
+
142
+ function resolveBaseUrl(options?: ProviderRequestOptions): string {
143
+ return (
144
+ options?.baseUrl ||
145
+ options?.env?.["OPENROUTER_BASE_URL"] ||
146
+ getEnv("OPENROUTER_BASE_URL") ||
147
+ DEFAULT_BASE_URL
148
+ ).replace(/\/+$/, "");
149
+ }
150
+
151
+ function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
152
+ return options?.apiKey || getApiKey("openrouter", undefined, options?.env);
153
+ }
154
+
155
+ /**
156
+ * Strips ONLY the `openrouter/` prefix. Scoped ids (`scope/model:variant`)
157
+ * and bare ids pass through untouched — the router resolves them.
158
+ */
159
+ function cleanModelId(model: string | ModelSpec): string {
160
+ const rawId = typeof model === "string" ? model : model.id;
161
+ return rawId.replace(/^openrouter\//i, "");
162
+ }
163
+
164
+ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
165
+ if (!tools || tools.length === 0) return undefined;
166
+ return tools.map((t) => ({
167
+ type: "function",
168
+ name: t.name,
169
+ description: t.description,
170
+ parameters: (t.parameters || { type: "object", properties: {} }) as Record<string, unknown>,
171
+ ...(t.strict !== undefined ? { strict: t.strict } : {}),
172
+ }));
173
+ }
174
+
175
+ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ResponseContentPart[]> {
176
+ const blocks: ResponseContentPart[] = [];
177
+ for (const part of parts) {
178
+ if (part.type === "text" && part.text) {
179
+ blocks.push({ type: "input_text", text: part.text });
180
+ } else if (
181
+ part.type === "image" ||
182
+ part.type === "audio" ||
183
+ part.type === "video" ||
184
+ part.type === "file"
185
+ ) {
186
+ const raw = (part as { image?: unknown; audio?: unknown; video?: unknown; file?: unknown }).image ??
187
+ (part as { audio?: unknown }).audio ??
188
+ (part as { video?: unknown }).video ??
189
+ (part as { file?: unknown }).file;
190
+ // Remote http(s) URLs pass through directly. Fetch-and-inline would
191
+ // base64-blowup large files past provider string limits (observed
192
+ // 11MB mp3 → 11.9M chars on OpenAI's 1M `file_url` cap); same guard
193
+ // applies here since OpenRouter is OpenAI-compatible.
194
+ if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
195
+ if (part.type === "image") {
196
+ blocks.push({ type: "input_image", image_url: raw });
197
+ } else {
198
+ const filename = (part as { filename?: string }).filename;
199
+ blocks.push({
200
+ type: "input_file",
201
+ file_url: raw,
202
+ ...(typeof filename === "string" && filename ? { filename } : {}),
203
+ });
204
+ }
205
+ continue;
206
+ }
207
+ const norm = await normalizeMediaInput(
208
+ raw as string | Uint8Array | ArrayBuffer,
209
+ (part as { mimeType?: string }).mimeType
210
+ );
211
+ // `input_image` is the documented shape (also used for tool outputs);
212
+ // documents/files ride the parity `input_file` shape. Audio/video have
213
+ // no documented user-input shape — they travel as `input_file` with
214
+ // their mime intact and the router verdict surfaces if rejected
215
+ // (catalog modality gate fails fast first).
216
+ if (part.type === "image") {
217
+ blocks.push({ type: "input_image", image_url: norm.dataUrl });
218
+ } else {
219
+ const filename = (part as { filename?: string }).filename;
220
+ blocks.push({
221
+ type: "input_file",
222
+ file_url: norm.dataUrl,
223
+ ...(typeof filename === "string" && filename ? { filename } : {}),
224
+ });
225
+ }
226
+ }
227
+ }
228
+ return blocks;
229
+ }
230
+
231
+ function resultToOutput(result: unknown): string {
232
+ if (typeof result === "string") return result;
233
+ return safeStringify(result);
234
+ }
235
+
236
+ /**
237
+ * Builds the FULL explicit history (stateless API — no server state, no
238
+ * chaining). Assistant items intentionally carry no provider `id`/`status`:
239
+ * wire probes prove history without them completes; minting ids client-side
240
+ * would violate the provider-generates-ids rule.
241
+ *
242
+ * Cache-prefix stability: ALWAYS the item-array form, even for a single
243
+ * text-only turn. A bare-string first turn followed by array turns changes
244
+ * the serialized prefix and voids the shared prefix (observed: 0% new hits
245
+ * on the format-switch turn, ~90% once the form stabilizes). The legacy
246
+ * transport always sent message arrays — this preserves that continuity.
247
+ */
248
+ async function fullHistoryInput(context: ProviderContext): Promise<ResponseInputItem[]> {
249
+
250
+ // Map assistant tool_call item ids (fc_…) to pairing ids (call_…) so
251
+ // function_call_output items pair correctly even though the executor keys
252
+ // results by the item id.
253
+ const pairing = new Map<string, string>();
254
+ for (const m of context.messages) {
255
+ if (m.role === "assistant" && Array.isArray(m.content)) {
256
+ for (const part of m.content) {
257
+ if (part.type === "tool_call") {
258
+ pairing.set(part.id, part.callId || part.id);
259
+ }
260
+ }
261
+ }
262
+ }
263
+
264
+ const items: ResponseInputItem[] = [];
265
+ for (const m of context.messages) {
266
+ if (m.role === "system") continue;
267
+ if (m.role === "user") {
268
+ if (typeof m.content === "string") {
269
+ if (m.content) items.push({ type: "message", role: "user", content: [{ type: "input_text", text: m.content }] });
270
+ } else {
271
+ const blocks = await contentPartsToBlocks(m.content);
272
+ if (blocks.length > 0) items.push({ type: "message", role: "user", content: blocks });
273
+ }
274
+ } else if (m.role === "assistant") {
275
+ if (typeof m.content === "string") {
276
+ if (m.content) {
277
+ items.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: m.content }] });
278
+ }
279
+ continue;
280
+ }
281
+ const texts: string[] = [];
282
+ for (const part of m.content) {
283
+ if (part.type === "tool_call") {
284
+ items.push({
285
+ type: "function_call",
286
+ id: part.id,
287
+ call_id: part.callId || part.id,
288
+ name: part.name,
289
+ arguments: JSON.stringify(part.arguments || {}),
290
+ });
291
+ } else if (part.type === "text" && part.text) {
292
+ texts.push(part.text);
293
+ }
294
+ // Prior-turn reasoning is NOT resent: the docs' history examples
295
+ // contain message items only, and reasoning items require
296
+ // provider-minted ids that canonical history does not retain.
297
+ }
298
+ if (texts.length > 0) {
299
+ items.push({ type: "message", role: "assistant", content: texts.map((t) => ({ type: "output_text", text: t })) });
300
+ }
301
+ } else if (m.role === "tool") {
302
+ if (!Array.isArray(m.content)) continue;
303
+ for (const part of m.content) {
304
+ if (part.type === "tool_result") {
305
+ items.push({
306
+ type: "function_call_output",
307
+ call_id: pairing.get(part.id) || part.id,
308
+ output: resultToOutput(part.result),
309
+ });
310
+ }
311
+ }
312
+ }
313
+ }
314
+ return items;
315
+ }
316
+
317
+ // ---------------------------------------------------------------------------
318
+ // Response mapping (Responses -> canonical)
319
+ // ---------------------------------------------------------------------------
320
+
321
+ function mapUsage(raw?: ResponsesObject["usage"]): TokenUsage {
322
+ const input = raw?.input_tokens ?? 0;
323
+ const output = raw?.output_tokens ?? 0;
324
+ // Canonical invariant: cache hits are a SUBSET of input (same clamp as
325
+ // the Google adapter — no turn may report a >100% hit rate).
326
+ const cached = Math.min(raw?.input_tokens_details?.cached_tokens ?? 0, input);
327
+ const usage: TokenUsage = {
328
+ inputTokens: input,
329
+ outputTokens: output,
330
+ totalTokens: raw?.total_tokens ?? input + output,
331
+ cachedTokens: cached,
332
+ cacheReadTokens: cached,
333
+ cacheWriteTokens: 0,
334
+ thinkingTokens: raw?.output_tokens_details?.reasoning_tokens ?? 0,
335
+ };
336
+ if (typeof raw?.cost === "number" && raw.cost > 0) {
337
+ usage.cost = { totalCost: raw.cost };
338
+ }
339
+ return usage;
340
+ }
341
+
342
+ function parseArguments(raw: string | undefined): Record<string, unknown> {
343
+ if (!raw) return {};
344
+ try {
345
+ const parsed: unknown = JSON.parse(raw);
346
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
347
+ return parsed as Record<string, unknown>;
348
+ }
349
+ return { raw };
350
+ } catch {
351
+ return { raw };
352
+ }
353
+ }
354
+
355
+ /** Trims/collapses assembled thinking parts (no edge-tripling). */
356
+ function normalizeThinkingParts(parts: string[]): string | undefined {
357
+ const cleaned = parts
358
+ .map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
359
+ .filter((p) => p.length > 0);
360
+ return cleaned.length > 0 ? cleaned.join("\n") : undefined;
361
+ }
362
+
363
+ function parseResponse(
364
+ response: ResponsesObject,
365
+ modelId: string,
366
+ durationMs: number,
367
+ raw: ProviderRawData
368
+ ): ProviderGenerateResult {
369
+ if (response.status === "failed") {
370
+ const message =
371
+ response.error && typeof response.error.message === "string" && response.error.message
372
+ ? response.error.message
373
+ : "OpenRouter response failed";
374
+ const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
375
+ if (response.error?.code !== undefined) failure["code"] = response.error.code;
376
+ if (typeof response.error_type === "string") failure["errorType"] = response.error_type;
377
+ throw toConciseProviderError(failure, "openrouter", modelId);
378
+ }
379
+
380
+ let text = "";
381
+ const thinkingParts: string[] = [];
382
+ const toolCalls: ToolCallRecord[] = [];
383
+
384
+ for (const item of response.output ?? []) {
385
+ if (item.type === "message") {
386
+ for (const block of item.content ?? []) {
387
+ if (block.type === "output_text" && block.text) text += block.text;
388
+ }
389
+ } else if (item.type === "reasoning") {
390
+ for (const block of item.content ?? []) {
391
+ if ((block.type === "reasoning_text" || block.type === "text") && block.text) {
392
+ thinkingParts.push(block.text);
393
+ }
394
+ }
395
+ for (const s of item.summary ?? []) {
396
+ if (typeof s === "string" && s) thinkingParts.push(s);
397
+ }
398
+ } else if (item.type === "function_call") {
399
+ const args = parseArguments(item.arguments);
400
+ toolCalls.push({
401
+ id: item.id || item.call_id || `call_${Math.random().toString(36).slice(2, 9)}`,
402
+ callId: item.call_id,
403
+ name: item.name || "unknown",
404
+ arguments: args,
405
+ rawArguments: typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments ?? {}),
406
+ });
407
+ }
408
+ }
409
+
410
+ return {
411
+ text,
412
+ // Trim/collapse reasoning assembly. Prior-turn reasoning is never resent
413
+ // by this adapter, so this is fully cache-safe.
414
+ thinking: normalizeThinkingParts(thinkingParts),
415
+ thoughtSignature: undefined,
416
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
417
+ usage: mapUsage(response.usage),
418
+ finishReason: toolCalls.length > 0 ? "tool_calls" : response.status === "incomplete" ? "length" : "stop",
419
+ responseId: response.id,
420
+ model: modelId,
421
+ provider: "openrouter",
422
+ raw,
423
+ durationMs,
424
+ };
425
+ }
426
+
427
+ function redactedHeaders(headers: Record<string, string>): Record<string, string> {
428
+ const out: Record<string, string> = {};
429
+ for (const [k, v] of Object.entries(headers)) {
430
+ out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
431
+ }
432
+ return out;
433
+ }
434
+
435
+ function readErrorPayload(bodyText: string): { message: string; code?: string | number; errorType?: string } {
436
+ try {
437
+ const parsed: unknown = JSON.parse(bodyText);
438
+ const first = Array.isArray(parsed) ? parsed[0] : parsed;
439
+ const err = (first as { error?: { message?: string; code?: string | number }; error_type?: string })?.error;
440
+ if (err && typeof err.message === "string") {
441
+ const errorType = (first as { error_type?: string })?.error_type;
442
+ return {
443
+ message: err.message,
444
+ code: err.code,
445
+ ...(typeof errorType === "string" ? { errorType } : {}),
446
+ };
447
+ }
448
+ return { message: bodyText.slice(0, 300) };
449
+ } catch {
450
+ return { message: bodyText.slice(0, 300) };
451
+ }
452
+ }
453
+
454
+ // ---------------------------------------------------------------------------
455
+ // Provider
456
+ // ---------------------------------------------------------------------------
457
+
458
+ /**
459
+ * OpenRouter provider implemented directly on the Responses REST API
460
+ * (`POST {baseUrl}/responses`, streaming on the same endpoint).
461
+ */
462
+ export class OpenRouterResponsesProvider implements Provider {
463
+ readonly id: ProviderId = "openrouter";
464
+ readonly name = "OpenRouter Responses";
465
+ readonly models: ModelSpec[] = [];
466
+
467
+ constructor() {
468
+ this.models = getModelsForProvider("openrouter");
469
+ }
470
+
471
+ getModel(modelId: string): ModelSpec | undefined {
472
+ const clean = cleanModelId(modelId);
473
+ return (
474
+ getModelFromCatalog(this.id, modelId) ||
475
+ getModelFromCatalog(this.id, clean) ||
476
+ this.models.find((m) => m.id === modelId || m.id === clean) ||
477
+ createGenericModelSpec(this.id, clean)
478
+ );
479
+ }
480
+
481
+ private async buildBody(
482
+ modelId: string,
483
+ context: ProviderContext,
484
+ options: ProviderRequestOptions | undefined,
485
+ sessionId: string | undefined,
486
+ tools: StandardToolDeclaration[] | undefined,
487
+ stream: boolean
488
+ ): Promise<ResponsesRequestBody> {
489
+ const { effort } = mapThinkingLevelToOpenRouter(options?.thinking?.level);
490
+ const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
491
+ applyCacheForOpenRouter(options?.cache, `openrouter/${modelId}`);
492
+
493
+ const body: ResponsesRequestBody = {
494
+ model: modelId,
495
+ input: await fullHistoryInput(context),
496
+ ...(stream ? { stream: true } : {}),
497
+ };
498
+ if (context.systemPrompt) body.instructions = context.systemPrompt;
499
+ const orTools = toOpenRouterTools(tools);
500
+ if (orTools) body.tools = orTools;
501
+ const toolChoice = mapToolChoiceToOpenRouter(
502
+ options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
503
+ );
504
+ if (toolChoice !== undefined) body.tool_choice = toolChoice;
505
+ if (effort) body.reasoning = { effort };
506
+ if (serviceTier) body.service_tier = serviceTier;
507
+ // Sticky routing (prompt-caching guide): top-level body `session_id`
508
+ // activates provider stickiness from the FIRST successful request — even
509
+ // before any cache hit is observed. `prompt_cache_key` alone only pins
510
+ // routing after a hit is detected, which strands the second turn of a
511
+ // session on a cold provider (observed: 0% new hits on turn 2, ~90% on
512
+ // turn 3). Both are sent: body takes precedence per docs, key stays as
513
+ // the documented fallback. x-session-id header is also kept (grouping).
514
+ if (sessionId) {
515
+ body.session_id = sessionId;
516
+ body.prompt_cache_key = sessionId;
517
+ }
518
+ // Stateless API: store / previous_response_id / background are NEVER sent
519
+ // (both rejected with 400: "not supported on this proxy").
520
+ return body;
521
+ }
522
+
523
+ private requestInit(
524
+ body: ResponsesRequestBody,
525
+ options: ProviderRequestOptions | undefined,
526
+ apiKey: string,
527
+ sessionId: string | undefined,
528
+ signal?: AbortSignal
529
+ ): RequestInit {
530
+ // Attribution + session headers mirror the previous attribution set
531
+ // exactly (HTTP-Referer / X-Title / x-session-id / x-client-request-id).
532
+ const headers: Record<string, string> = buildSessionHeaders(
533
+ "openrouter",
534
+ options?.cache,
535
+ options?.headers,
536
+ sessionId
537
+ );
538
+ for (const [k, v] of Object.entries(headers)) {
539
+ if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
540
+ }
541
+ headers["Content-Type"] = "application/json";
542
+ headers["Authorization"] = `Bearer ${apiKey}`;
543
+ return { method: "POST", headers, body: JSON.stringify(body), signal };
544
+ }
545
+
546
+ private async doFetch(
547
+ url: string,
548
+ body: ResponsesRequestBody,
549
+ options: ProviderRequestOptions | undefined,
550
+ apiKey: string,
551
+ sessionId: string | undefined,
552
+ signal?: AbortSignal
553
+ ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
554
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, signal));
555
+ const text = await res.text();
556
+ const headers: Record<string, string> = {};
557
+ res.headers.forEach((v, k) => {
558
+ headers[k] = v;
559
+ });
560
+ return { status: res.status, statusText: res.statusText, headers, text };
561
+ }
562
+
563
+ private throwIfError(
564
+ status: number,
565
+ url: string,
566
+ bodyText: string,
567
+ modelId: string
568
+ ): void {
569
+ if (status >= 200 && status < 300) return;
570
+ const { message, code, errorType } = readErrorPayload(bodyText);
571
+ const err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
572
+ (err as Record<string, unknown>)["statusCode"] = status;
573
+ (err as Record<string, unknown>)["status"] = status;
574
+ (err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
575
+ (err as Record<string, unknown>)["url"] = url.split("?")[0];
576
+ if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
577
+ if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
578
+ throw toConciseProviderError(err, "openrouter", modelId);
579
+ }
580
+
581
+ async generate(
582
+ model: string | ModelSpec,
583
+ context: ProviderContext,
584
+ options?: ProviderRequestOptions
585
+ ): Promise<ProviderGenerateResult> {
586
+ const startTime = Date.now();
587
+ const clean = cleanModelId(model);
588
+ const apiKey = resolveApiKey(options);
589
+ if (!apiKey) {
590
+ throw new Error(
591
+ "[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
592
+ );
593
+ }
594
+ // Fail fast before any network call when the catalog knows the model
595
+ // lacks the requested modality (clear one-liner instead of a confusing
596
+ // router 404/400). Unknown models skip the guard; the router verdict
597
+ // surfaces concisely.
598
+ assertModalitiesSupported(context, "openrouter", clean);
599
+ assertNoVideoPartsOnResponses(context, "openrouter", clean);
600
+ const baseUrl = resolveBaseUrl(options);
601
+ const url = `${baseUrl}/responses`;
602
+ const sessionId = options?.sessionId || options?.cache?.sessionId;
603
+ const tools = options?.tools as StandardToolDeclaration[] | undefined;
604
+ const body = await this.buildBody(clean, context, options, sessionId, tools, false);
605
+
606
+ // Audit trail: record the actual wire headers (attribution + session
607
+ // affinity included), redacted.
608
+ const auditRaw: Record<string, string> = {
609
+ ...buildSessionHeaders("openrouter", options?.cache, options?.headers, sessionId),
610
+ "Content-Type": "application/json",
611
+ Authorization: "[REDACTED]",
612
+ };
613
+ for (const k of Object.keys(auditRaw)) {
614
+ if (INTERNAL_HEADERS.has(k.toLowerCase())) delete auditRaw[k];
615
+ }
616
+ const rawRequest = {
617
+ url,
618
+ method: "POST",
619
+ headers: redactedHeaders(auditRaw),
620
+ body,
621
+ };
622
+
623
+ const doCall = async (): Promise<ProviderGenerateResult> => {
624
+ const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
625
+ this.throwIfError(res.status, url, res.text, clean);
626
+ let response: ResponsesObject;
627
+ try {
628
+ response = JSON.parse(res.text) as ResponsesObject;
629
+ } catch {
630
+ throw toConciseProviderError(
631
+ Object.assign(new Error("Invalid JSON response from OpenRouter Responses API"), {
632
+ statusCode: res.status,
633
+ responseBody: res.text.slice(0, 500),
634
+ url,
635
+ }),
636
+ "openrouter",
637
+ clean
638
+ );
639
+ }
640
+ // Stateless turns never establish chains, but the session store still
641
+ // records the turn so other providers' switch detection keeps working.
642
+ noteProviderTurn(sessionId, "openrouter");
643
+ return parseResponse(
644
+ response,
645
+ clean,
646
+ Date.now() - startTime,
647
+ { request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: response } }
648
+ );
649
+ };
650
+
651
+ try {
652
+ return await withRetries(doCall, {
653
+ maxRetries: options?.maxRetries,
654
+ maxRetryDelayMs: options?.maxRetryDelayMs,
655
+ signal: options?.signal,
656
+ label: { providerId: "openrouter", modelId: clean },
657
+ });
658
+ } catch (err) {
659
+ if (err instanceof Error && (err as { name?: string }).name === "AbortError") throw err;
660
+ throw err;
661
+ }
662
+ }
663
+
664
+ stream(
665
+ model: string | ModelSpec,
666
+ context: ProviderContext,
667
+ options?: ProviderRequestOptions
668
+ ): AssistantMessageEventStream {
669
+ const eventStream = new AssistantMessageEventStream();
670
+ const startTime = Date.now();
671
+ const clean = cleanModelId(model);
672
+
673
+ const linked = new AbortController();
674
+ const forwardUserAbort = () => {
675
+ try {
676
+ linked.abort((options?.signal as { reason?: unknown })?.reason);
677
+ } catch {
678
+ try {
679
+ linked.abort();
680
+ } catch {}
681
+ }
682
+ };
683
+ if (options?.signal?.aborted) forwardUserAbort();
684
+ else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
685
+ const removeStreamCancel = eventStream.onCancel(() => {
686
+ try {
687
+ linked.abort();
688
+ } catch {}
689
+ });
690
+
691
+ (async () => {
692
+ try {
693
+ const apiKey = resolveApiKey(options);
694
+ if (!apiKey) {
695
+ throw new Error(
696
+ "[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
697
+ );
698
+ }
699
+ assertModalitiesSupported(context, "openrouter", clean);
700
+ assertNoVideoPartsOnResponses(context, "openrouter", clean);
701
+ const baseUrl = resolveBaseUrl(options);
702
+ const url = `${baseUrl}/responses`;
703
+ const sessionId = options?.sessionId || options?.cache?.sessionId;
704
+ const tools = options?.tools as StandardToolDeclaration[] | undefined;
705
+ const body = await this.buildBody(clean, context, options, sessionId, tools, true);
706
+
707
+ const streamAuditRaw: Record<string, string> = {
708
+ ...buildSessionHeaders("openrouter", options?.cache, options?.headers, sessionId),
709
+ "Content-Type": "application/json",
710
+ Authorization: "[REDACTED]",
711
+ };
712
+ for (const k of Object.keys(streamAuditRaw)) {
713
+ if (INTERNAL_HEADERS.has(k.toLowerCase())) delete streamAuditRaw[k];
714
+ }
715
+ const rawRequest = {
716
+ url,
717
+ method: "POST",
718
+ headers: redactedHeaders(streamAuditRaw),
719
+ body,
720
+ };
721
+
722
+ const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
723
+ if (!res.ok || !res.body) {
724
+ const text = !res.ok ? await res.text().catch(() => "") : "";
725
+ if (!res.ok) this.throwIfError(res.status, url, text, clean);
726
+ throw toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean);
727
+ }
728
+ const responseHeaders: Record<string, string> = {};
729
+ res.headers.forEach((v, k) => {
730
+ responseHeaders[k] = v;
731
+ });
732
+ const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
733
+ eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
734
+
735
+ const parser = new SSEParser();
736
+ const reader = res.body.getReader();
737
+ const decoder = new TextDecoder();
738
+ let text = "";
739
+ let thinking = "";
740
+ // Tool calls keyed by output_index: { itemId, callId, name, startArgs, deltaArgs }.
741
+ const calls = new Map<number, { itemId: string; callId: string; name: string; startArgs: string; deltaArgs: string }>();
742
+ let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
743
+ let finishReason = "stop";
744
+ let responseId: string | undefined;
745
+ let completedBody: unknown = undefined;
746
+ let aborted = false;
747
+ linked.signal.addEventListener(
748
+ "abort",
749
+ () => {
750
+ aborted = true;
751
+ try {
752
+ void reader.cancel();
753
+ } catch {}
754
+ },
755
+ { once: true }
756
+ );
757
+
758
+ const handleMessage = (data: string): void => {
759
+ if (!data || data === "[DONE]") return;
760
+ let msg: Record<string, unknown>;
761
+ try {
762
+ msg = JSON.parse(data) as Record<string, unknown>;
763
+ } catch {
764
+ return;
765
+ }
766
+ const type = msg["type"] as string;
767
+ if (!type) return;
768
+ if (type === "error") {
769
+ const errObj = (msg["error"] as { message?: string; code?: string | number }) ?? {};
770
+ const message =
771
+ typeof errObj.message === "string" && errObj.message ? errObj.message : "OpenRouter streaming error";
772
+ const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
773
+ if (errObj.code !== undefined) failure["code"] = errObj.code;
774
+ if (typeof msg["error_type"] === "string") failure["errorType"] = msg["error_type"];
775
+ failure["url"] = url;
776
+ throw toConciseProviderError(failure, "openrouter", clean);
777
+ }
778
+ if (type === "response.created" || type === "response.in_progress") {
779
+ const response = (msg["response"] as { id?: string }) ?? {};
780
+ if (response.id) responseId = response.id;
781
+ } else if (type === "response.output_text.delta" && typeof msg["delta"] === "string") {
782
+ text += msg["delta"] as string;
783
+ eventStream.push({ type: "text_delta", delta: msg["delta"] as string, partialText: text });
784
+ } else if (
785
+ (type === "response.reasoning_text.delta" || type === "response.reasoning.delta") &&
786
+ typeof msg["delta"] === "string"
787
+ ) {
788
+ thinking += msg["delta"] as string;
789
+ eventStream.push({ type: "thinking_delta", thinkingDelta: msg["delta"] as string, partialThinking: thinking });
790
+ } else if (type === "response.output_item.added") {
791
+ const index = (msg["output_index"] as number) ?? 0;
792
+ const item = (msg["item"] as Record<string, unknown>) ?? {};
793
+ if (item["type"] === "function_call") {
794
+ const args = item["arguments"];
795
+ calls.set(index, {
796
+ itemId: (item["id"] as string) || "",
797
+ callId: (item["call_id"] as string) || "",
798
+ name: (item["name"] as string) || "unknown",
799
+ startArgs: typeof args === "string" ? args : args ? JSON.stringify(args) : "",
800
+ deltaArgs: "",
801
+ });
802
+ }
803
+ } else if (type === "response.function_call_arguments.delta") {
804
+ const index = (msg["output_index"] as number) ?? 0;
805
+ const chunk = (msg["delta"] as string) ?? "";
806
+ const entry = calls.get(index);
807
+ if (entry && typeof chunk === "string") entry.deltaArgs += chunk;
808
+ } else if (type === "response.function_call_arguments.done") {
809
+ const index = (msg["output_index"] as number) ?? 0;
810
+ const entry = calls.get(index);
811
+ // The done event carries the COMPLETE arguments string — it wins
812
+ // over accumulated deltas (same candidate strategy as elsewhere).
813
+ if (entry && typeof msg["arguments"] === "string") {
814
+ entry.startArgs = "";
815
+ entry.deltaArgs = msg["arguments"] as string;
816
+ }
817
+ } else if (type === "response.completed" || type === "response.failed" || type === "response.done") {
818
+ const response = (msg["response"] as ResponsesObject) ?? {};
819
+ completedBody = msg["response"];
820
+ if (response.id) responseId = response.id;
821
+ if (response.usage) usage = mapUsage(response.usage);
822
+ if (type === "response.failed" || response.status === "failed") {
823
+ const message =
824
+ (response.error && typeof response.error.message === "string" && response.error.message) ||
825
+ "OpenRouter response failed";
826
+ const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
827
+ if (response.error?.code !== undefined) failure["code"] = response.error.code;
828
+ if (typeof response.error_type === "string") failure["errorType"] = response.error_type;
829
+ throw toConciseProviderError(failure, "openrouter", clean);
830
+ }
831
+ // Non-streaming maps `incomplete` → `length` (max tokens). Streaming
832
+ // must do the same — otherwise truncated turns misreport `stop`.
833
+ if (response.status === "incomplete") finishReason = "length";
834
+ // Merge any full tool items delivered at completion (authoritative
835
+ // when present) so nothing depends solely on delta assembly.
836
+ for (const item of response.output ?? []) {
837
+ if (item.type !== "function_call") continue;
838
+ const key = [...calls.entries()].find(
839
+ ([, c]) => (c.itemId && c.itemId === item.id) || (c.callId && c.callId === item.call_id)
840
+ )?.[0];
841
+ if (key !== undefined && typeof item.arguments === "string") {
842
+ const entry = calls.get(key)!;
843
+ entry.itemId = item.id || entry.itemId;
844
+ entry.callId = item.call_id || entry.callId;
845
+ entry.name = item.name || entry.name;
846
+ entry.startArgs = "";
847
+ entry.deltaArgs = item.arguments;
848
+ }
849
+ }
850
+ // Merge completed reasoning (summary often arrives only here).
851
+ // Append only text not already streamed to avoid doubling.
852
+ for (const item of response.output ?? []) {
853
+ if (item.type !== "reasoning") continue;
854
+ const parts: string[] = [];
855
+ for (const block of item.content ?? []) {
856
+ if ((block.type === "reasoning_text" || block.type === "text") && block.text) {
857
+ parts.push(block.text);
858
+ }
859
+ }
860
+ for (const s of item.summary ?? []) {
861
+ if (typeof s === "string" && s) parts.push(s);
862
+ }
863
+ for (const p of parts) {
864
+ if (p && !thinking.includes(p)) {
865
+ thinking += (thinking ? "\n" : "") + p;
866
+ }
867
+ }
868
+ }
869
+ eventStream.push({ type: "usage", usage });
870
+ }
871
+ };
872
+
873
+ while (true) {
874
+ if (linked.signal.aborted || eventStream.isCancelled()) {
875
+ try {
876
+ await reader.cancel();
877
+ } catch {}
878
+ throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
879
+ }
880
+ const { done, value } = await reader.read();
881
+ if (done) break;
882
+ const chunk = decoder.decode(value, { stream: true });
883
+ for (const m of parser.feed(chunk)) handleMessage(m.data);
884
+ }
885
+ for (const m of parser.flush()) handleMessage(m.data);
886
+ try {
887
+ reader.releaseLock();
888
+ } catch {}
889
+
890
+ if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
891
+ throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
892
+ }
893
+
894
+ const toolCalls: ToolCallRecord[] = [];
895
+ for (const c of calls.values()) {
896
+ const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
897
+ const record: ToolCallRecord = {
898
+ id: c.itemId || c.callId || `call_${Math.random().toString(36).slice(2, 9)}`,
899
+ callId: c.callId || undefined,
900
+ name: c.name,
901
+ arguments: args,
902
+ rawArguments: c.startArgs + c.deltaArgs,
903
+ };
904
+ toolCalls.push(record);
905
+ eventStream.push({ type: "tool_call_complete", toolCall: record });
906
+ }
907
+
908
+ noteProviderTurn(sessionId, "openrouter");
909
+ if (toolCalls.length > 0) finishReason = "tool_calls";
910
+
911
+ const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
912
+ const finalResponse = new AgentResponse({
913
+ text,
914
+ thinking: cleanThinking || undefined,
915
+ thoughtSignature: undefined,
916
+ toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
917
+ usage,
918
+ finishReason,
919
+ responseId,
920
+ model: clean,
921
+ provider: "openrouter",
922
+ raw: { request: rawRequest, response: { ...responseMeta, body: completedBody } },
923
+ durationMs: Date.now() - startTime,
924
+ });
925
+ eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
926
+ eventStream.end(finalResponse);
927
+ } catch (err: unknown) {
928
+ const raw = err instanceof Error ? err : new Error(String(err));
929
+ const isAbort =
930
+ linked.signal.aborted ||
931
+ eventStream.isCancelled() ||
932
+ (raw as { name?: string }).name === "AbortError" ||
933
+ /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
934
+ eventStream.fail(
935
+ isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
936
+ );
937
+ } finally {
938
+ try {
939
+ options?.signal?.removeEventListener("abort", forwardUserAbort);
940
+ } catch {}
941
+ try {
942
+ removeStreamCancel();
943
+ } catch {}
944
+ }
945
+ })();
946
+
947
+ return eventStream;
948
+ }
949
+ }