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.
- package/README.md +83 -112
- package/{SYSTEM_PROMPT_AGENT.md → examples/prompts/SYSTEM_PROMPT_AGENT.md} +1 -1
- package/package.json +14 -9
- package/src/agent/agent.ts +191 -71
- package/src/agent/context.ts +1 -0
- package/src/agent/delegation.ts +61 -12
- package/src/agent/loop.ts +71 -48
- package/src/data/README.md +6 -6
- package/src/index.ts +130 -45
- package/src/models/catalog-cache.ts +60 -9
- package/src/models/catalog.ts +53 -7
- package/src/providers/google.ts +926 -0
- package/src/providers/openai-compat.ts +1147 -0
- package/src/providers/openai.ts +959 -0
- package/src/providers/openrouter-responses.ts +949 -0
- package/src/providers/openrouter.ts +1037 -0
- package/src/{ai-sdk → providers}/registry.ts +43 -55
- package/src/providers.ts +490 -0
- package/src/streaming/sse-parser.ts +6 -4
- package/src/tools/executor.ts +19 -6
- package/src/tools/schema.ts +21 -11
- package/src/types/agent.ts +8 -1
- package/src/types/core.ts +1 -1
- package/src/types/message.ts +5 -0
- package/src/types/model.ts +3 -7
- package/src/types/provider-payloads.ts +2 -84
- package/src/types/tool.ts +6 -0
- package/src/update-models.ts +56 -0
- package/src/utils/cache.ts +1 -1
- package/src/utils/documents.ts +517 -0
- package/src/utils/env.ts +0 -7
- package/src/{ai-sdk → utils}/errors.ts +61 -2
- package/src/utils/headers.ts +10 -20
- package/src/utils/media.ts +5 -2
- package/src/utils/retry.ts +89 -0
- package/src/utils/serialization.ts +15 -0
- package/src/ai-sdk/converters.ts +0 -342
- package/src/ai-sdk/executor.ts +0 -454
- package/src/ai-sdk/index.ts +0 -55
- package/src/ai-sdk/model-provider.ts +0 -303
- package/src/ai-sdk/options.ts +0 -306
- package/src/ai-sdk/provider.ts +0 -415
- package/src/tokens/counter.ts +0 -136
- /package/{SYSTEM_PROMPT.md → examples/prompts/SYSTEM_PROMPT.md} +0 -0
- /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
|
+
}
|