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,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
|
+
}
|