agent-accelerator 0.5.4 → 0.5.6
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 +34 -17
- package/package.json +1 -1
- package/src/agent/agent.ts +111 -35
- package/src/agent/config-blocks.ts +80 -0
- package/src/agent/loop.ts +6 -4
- package/src/agent/subagents-tool.ts +2 -2
- package/src/agent/subagents.md +2 -2
- package/src/agent/worker.ts +7 -5
- package/src/agent/workers.ts +2 -2
- package/src/index.ts +3 -3
- package/src/providers/google.ts +6 -39
- package/src/providers/openai-compat.ts +31 -104
- package/src/providers/openai.ts +30 -103
- package/src/providers/openrouter.ts +151 -400
- package/src/providers/shared.ts +136 -7
- package/src/providers.ts +21 -6
- package/src/scheduler/scheduled-actions.md +4 -2
- package/src/scheduler/scheduler.ts +87 -50
- package/src/scheduler/store.ts +23 -1
- package/src/scheduler/tool.ts +9 -8
- package/src/session/store.ts +3 -3
- package/src/streaming/event-stream.ts +7 -2
- package/src/tools/executor.ts +17 -2
- package/src/tools/tool.ts +0 -16
- package/src/types/agent.ts +32 -17
- package/src/types/message.ts +1 -1
- package/src/types/scheduler.ts +9 -2
- package/src/utils/errors.ts +4 -1
- package/src/utils/headers.ts +1 -1
- package/src/utils/media.ts +10 -1
|
@@ -14,10 +14,13 @@
|
|
|
14
14
|
* lossy error codes, beta event-vocabulary drift.
|
|
15
15
|
*
|
|
16
16
|
* Notes:
|
|
17
|
+
* - EVERY request streams, `generate()` included (it collects its own `stream()`): OpenRouter only cancels a request that
|
|
18
|
+
* streams ("Stream cancellation" in its docs), so a plain non-streaming request could not be stopped by an interrupt.
|
|
17
19
|
* - STATELESS per request: every turn sends the full canonical `messages`
|
|
18
|
-
* array explicitly. Completions
|
|
19
|
-
* `conversation`/`prompt_cache_key
|
|
20
|
-
*
|
|
20
|
+
* array explicitly. Completions has no `store`/`previous_response_id`/
|
|
21
|
+
* `conversation`/`prompt_cache_key`, so none are ever sent. Session affinity is
|
|
22
|
+
* the documented top-level body `session_id` (the sticky-routing key, which wins
|
|
23
|
+
* over the header) plus the `x-session-id` / `x-client-request-id` headers.
|
|
21
24
|
* - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
|
|
22
25
|
* and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
|
|
23
26
|
* parsers is a local canonical correlation id only; it is never sent.
|
|
@@ -37,22 +40,26 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
|
|
|
37
40
|
import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
|
|
38
41
|
import type { TokenUsage } from "../types/core.ts";
|
|
39
42
|
import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
40
|
-
import { SSEParser } from "../streaming/sse-parser.ts";
|
|
41
43
|
import { AgentResponse } from "../types/response.ts";
|
|
42
44
|
import { getApiKey, getEnv } from "../utils/env.ts";
|
|
43
45
|
import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
|
|
44
46
|
import { normalizeMediaInput } from "../utils/media.ts";
|
|
45
47
|
import { safeStringify } from "../utils/serialization.ts";
|
|
46
48
|
import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
|
|
47
|
-
import {
|
|
49
|
+
import { isAbortError } from "../types/errors.ts";
|
|
48
50
|
import {
|
|
49
|
-
parseArguments,
|
|
50
|
-
normalizeThinkingParts,
|
|
51
51
|
newToolCallId,
|
|
52
52
|
redactedHeaders,
|
|
53
53
|
readErrorPayload,
|
|
54
54
|
incompleteStreamError,
|
|
55
|
-
|
|
55
|
+
hasUnfinishedToolCall,
|
|
56
|
+
linkStreamAbort,
|
|
57
|
+
withAbortTimer,
|
|
58
|
+
readSseData,
|
|
59
|
+
incompleteStreamCheck,
|
|
60
|
+
failStream,
|
|
61
|
+
toolArgumentsJson,
|
|
62
|
+
toolTurnFinishReason,
|
|
56
63
|
combinedSignal,
|
|
57
64
|
timeoutFor,
|
|
58
65
|
hasAuthHeader,
|
|
@@ -86,7 +93,7 @@ import {
|
|
|
86
93
|
|
|
87
94
|
type ChatContentPart =
|
|
88
95
|
| { type: "text"; text: string }
|
|
89
|
-
| { type: "image_url"; image_url: { url: string } }
|
|
96
|
+
| { type: "image_url"; image_url: { url: string; detail?: string } }
|
|
90
97
|
| { type: "video_url"; video_url: { url: string } }
|
|
91
98
|
| { type: "input_audio"; input_audio: { data: string; format: string } }
|
|
92
99
|
| { type: "file"; file: { filename?: string; file_data?: string; file_url?: string } }
|
|
@@ -115,68 +122,20 @@ interface ChatRequestBody {
|
|
|
115
122
|
reasoning?: { effort: string };
|
|
116
123
|
service_tier?: string;
|
|
117
124
|
session_id?: string;
|
|
118
|
-
plugins?: Array<{ id: string }>;
|
|
119
125
|
cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
|
|
120
126
|
response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
121
127
|
stream?: boolean;
|
|
122
128
|
[k: string]: unknown;
|
|
123
129
|
}
|
|
124
130
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
index?: number;
|
|
134
|
-
function?: { name?: string; arguments?: string };
|
|
135
|
-
}>;
|
|
136
|
-
reasoning?: string | null;
|
|
137
|
-
reasoning_details?: Array<{ type?: string; text?: string }>;
|
|
138
|
-
refusal?: string | null;
|
|
139
|
-
};
|
|
140
|
-
delta?: {
|
|
141
|
-
role?: string;
|
|
142
|
-
content?: string | null;
|
|
143
|
-
tool_calls?: Array<{
|
|
144
|
-
index?: number;
|
|
145
|
-
id?: string;
|
|
146
|
-
type?: string;
|
|
147
|
-
function?: { name?: string; arguments?: string };
|
|
148
|
-
}>;
|
|
149
|
-
reasoning_content?: string;
|
|
150
|
-
reasoning?: string;
|
|
151
|
-
reasoning_details?: Array<{ type?: string; text?: string }>;
|
|
152
|
-
refusal?: string | null;
|
|
153
|
-
};
|
|
154
|
-
finish_reason?: string | null;
|
|
155
|
-
native_finish_reason?: string | null;
|
|
156
|
-
error?: { message?: string; code?: string | number };
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
interface ChatResponse {
|
|
160
|
-
id?: string;
|
|
161
|
-
object?: string;
|
|
162
|
-
created?: number;
|
|
163
|
-
model?: string;
|
|
164
|
-
provider?: string;
|
|
165
|
-
choices?: ChatChoice[];
|
|
166
|
-
usage?: {
|
|
167
|
-
prompt_tokens?: number;
|
|
168
|
-
completion_tokens?: number;
|
|
169
|
-
total_tokens?: number;
|
|
170
|
-
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
|
|
171
|
-
completion_tokens_details?: { reasoning_tokens?: number };
|
|
172
|
-
cost?: number;
|
|
173
|
-
[k: string]: unknown;
|
|
174
|
-
};
|
|
175
|
-
error?: {
|
|
176
|
-
message?: string;
|
|
177
|
-
code?: string | number;
|
|
178
|
-
metadata?: { error_type?: string; provider_code?: string | number; [k: string]: unknown };
|
|
179
|
-
};
|
|
131
|
+
/** The usage block of a Chat Completions chunk (the last chunk of a stream carries it, with OpenRouter's `cost`). */
|
|
132
|
+
interface ChatUsage {
|
|
133
|
+
prompt_tokens?: number;
|
|
134
|
+
completion_tokens?: number;
|
|
135
|
+
total_tokens?: number;
|
|
136
|
+
prompt_tokens_details?: { cached_tokens?: number; cache_write_tokens?: number };
|
|
137
|
+
completion_tokens_details?: { reasoning_tokens?: number };
|
|
138
|
+
cost?: number;
|
|
180
139
|
[k: string]: unknown;
|
|
181
140
|
}
|
|
182
141
|
|
|
@@ -211,6 +170,13 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
211
170
|
return rawId.replace(/^openrouter\//i, "");
|
|
212
171
|
}
|
|
213
172
|
|
|
173
|
+
// The HTTP status of an error chunk inside a stream (the HTTP status itself is 200): its numeric `code`, else the status of its retryable `error_type` (errors-and-debugging.md, "Typed Error Codes"); in: the chunk's error, out: streamInto, where it makes the error retryable.
|
|
174
|
+
const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = { rate_limit_exceeded: 429, provider_overloaded: 503, provider_unavailable: 502, timeout: 504, server: 500 };
|
|
175
|
+
function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
|
|
176
|
+
if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
|
|
177
|
+
return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
214
180
|
function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
|
|
215
181
|
if (!tools || tools.length === 0) return undefined;
|
|
216
182
|
return tools.map((t) => ({
|
|
@@ -224,20 +190,31 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
|
|
|
224
190
|
}));
|
|
225
191
|
}
|
|
226
192
|
|
|
193
|
+
// The `input_audio.format` for a MIME type: Chat Completions takes any format string the provider supports (audio.md: wav, mp3, aiff, aac, ogg, flac, m4a, pcm16 ...), so the real format is sent, never a guess; in: an audio part's MIME type, out: contentPartsToBlocks.
|
|
194
|
+
const AUDIO_FORMAT: Record<string, string> = { wav: "wav", wave: "wav", mpeg: "mp3", mp3: "mp3", mp4: "m4a", m4a: "m4a", aac: "aac", ogg: "ogg", flac: "flac", aiff: "aiff" };
|
|
227
195
|
function audioFormatFor(mimeType?: string): string {
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
196
|
+
const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
|
|
197
|
+
return AUDIO_FORMAT[subtype] ?? (subtype || "mp3");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// `ImagePart.resolution` as OpenRouter's `image_url.detail` (api-ref ChatContentImage: auto, low, high, original; `original` is downgraded to `high` where unsupported); videos have no such field; in: an image part, out: contentPartsToBlocks.
|
|
201
|
+
const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = { low: "low", medium: "auto", high: "high", ultra_high: "original" };
|
|
202
|
+
|
|
203
|
+
// True when a request's messages carry an image or video by web link (the router fetches those itself); in: the built messages, out: throwIfError's hint.
|
|
204
|
+
function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
205
|
+
return messages.some(
|
|
206
|
+
(m) =>
|
|
207
|
+
Array.isArray(m.content) &&
|
|
208
|
+
(m.content as ChatContentPart[]).some((p) => {
|
|
209
|
+
const url = (p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ?? (p as { video_url?: { url?: string } }).video_url?.url;
|
|
210
|
+
return typeof url === "string" && /^https?:\/\//.test(url);
|
|
211
|
+
})
|
|
212
|
+
);
|
|
231
213
|
}
|
|
232
214
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
fileUrls: string[];
|
|
236
|
-
hasFiles: boolean;
|
|
237
|
-
}> {
|
|
215
|
+
// Maps canonical text and media parts to Chat Completions content blocks; in: a user message's parts, out: fullHistoryMessages.
|
|
216
|
+
async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPart[]> {
|
|
238
217
|
const blocks: ChatContentPart[] = [];
|
|
239
|
-
const fileUrls: string[] = [];
|
|
240
|
-
let hasFiles = false;
|
|
241
218
|
for (const part of parts) {
|
|
242
219
|
if (part.type === "text" && part.text) {
|
|
243
220
|
blocks.push({ type: "text", text: part.text });
|
|
@@ -245,15 +222,16 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
245
222
|
const raw = (part as { image?: unknown }).image;
|
|
246
223
|
// Remote URLs pass through (router downloads; `image_download_failed`
|
|
247
224
|
// surfaces if unreachable). Inline only local/base64/binary.
|
|
225
|
+
const detail = IMAGE_DETAIL[(part as { resolution?: string }).resolution ?? ""];
|
|
248
226
|
if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
|
|
249
|
-
blocks.push({ type: "image_url", image_url: { url: raw } });
|
|
227
|
+
blocks.push({ type: "image_url", image_url: { url: raw, ...(detail ? { detail } : {}) } });
|
|
250
228
|
continue;
|
|
251
229
|
}
|
|
252
230
|
const norm = await normalizeMediaInput(
|
|
253
231
|
raw as string | Uint8Array | ArrayBuffer,
|
|
254
232
|
(part as { mimeType?: string }).mimeType
|
|
255
233
|
);
|
|
256
|
-
blocks.push({ type: "image_url", image_url: { url: norm.dataUrl } });
|
|
234
|
+
blocks.push({ type: "image_url", image_url: { url: norm.dataUrl, ...(detail ? { detail } : {}) } });
|
|
257
235
|
} else if (part.type === "video") {
|
|
258
236
|
const raw = (part as { video?: unknown }).video;
|
|
259
237
|
// `video_url` is the completions video shape (live: shape accepted,
|
|
@@ -267,7 +245,9 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
267
245
|
raw as string | Uint8Array | ArrayBuffer,
|
|
268
246
|
(part as { mimeType?: string }).mimeType
|
|
269
247
|
);
|
|
270
|
-
|
|
248
|
+
// video-inputs.md names the QuickTime type `video/mov`.
|
|
249
|
+
const url = norm.mimeType === "video/quicktime" ? `data:video/mov;base64,${norm.base64Data}` : norm.dataUrl;
|
|
250
|
+
blocks.push({ type: "video_url", video_url: { url } });
|
|
271
251
|
} else if (part.type === "audio") {
|
|
272
252
|
const raw = (part as { audio?: unknown }).audio;
|
|
273
253
|
const mimeType = (part as { mimeType?: string }).mimeType;
|
|
@@ -282,37 +262,20 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
282
262
|
input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
|
|
283
263
|
});
|
|
284
264
|
} else if (part.type === "file") {
|
|
285
|
-
hasFiles = true;
|
|
286
265
|
const raw = (part as { file?: unknown }).file;
|
|
287
266
|
const filename = (part as { filename?: string }).filename;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
blocks.push({
|
|
294
|
-
type: "file",
|
|
295
|
-
file: {
|
|
296
|
-
...(typeof filename === "string" && filename ? { filename } : {}),
|
|
297
|
-
file_url: raw,
|
|
298
|
-
},
|
|
299
|
-
});
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
|
-
const norm = await normalizeMediaInput(
|
|
303
|
-
raw as string | Uint8Array | ArrayBuffer,
|
|
304
|
-
(part as { mimeType?: string }).mimeType
|
|
305
|
-
);
|
|
267
|
+
// `file_data` takes a public URL or a base64 data URL (api-ref ChatContentFile; a `file_url` field does not exist).
|
|
268
|
+
const fileData =
|
|
269
|
+
typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
|
|
270
|
+
? raw
|
|
271
|
+
: (await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, (part as { mimeType?: string }).mimeType)).dataUrl;
|
|
306
272
|
blocks.push({
|
|
307
273
|
type: "file",
|
|
308
|
-
file: {
|
|
309
|
-
...(typeof filename === "string" && filename ? { filename } : {}),
|
|
310
|
-
file_data: norm.dataUrl,
|
|
311
|
-
},
|
|
274
|
+
file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
|
|
312
275
|
});
|
|
313
276
|
}
|
|
314
277
|
}
|
|
315
|
-
return
|
|
278
|
+
return blocks;
|
|
316
279
|
}
|
|
317
280
|
|
|
318
281
|
function resultToOutput(result: unknown): string {
|
|
@@ -325,15 +288,8 @@ function resultToOutput(result: unknown): string {
|
|
|
325
288
|
* Stateless transport: no chaining primitives exist here, so every turn
|
|
326
289
|
* carries everything. Assistant items carry no provider `id`/`status`
|
|
327
290
|
* requirements (unlike the discontinued Responses skin).
|
|
328
|
-
*
|
|
329
|
-
* File handling: remote file URLs must be visible in text for the
|
|
330
|
-
* `file-parser` plugin (bare `{type:file}` parts are ignored by the route),
|
|
331
|
-
* so each remote file URL is appended as its own text part.
|
|
332
291
|
*/
|
|
333
|
-
async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
334
|
-
messages: ChatMessage[];
|
|
335
|
-
hasFiles: boolean;
|
|
336
|
-
}> {
|
|
292
|
+
async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
|
|
337
293
|
// Map assistant tool_call ids so tool results pair even though the
|
|
338
294
|
// executor keys results by the call id.
|
|
339
295
|
const pairing = new Map<string, string>();
|
|
@@ -357,7 +313,6 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
357
313
|
if (context.systemPrompt) {
|
|
358
314
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
359
315
|
}
|
|
360
|
-
let hasFiles = false;
|
|
361
316
|
let lastRole: string | null = "system";
|
|
362
317
|
for (const m of context.messages) {
|
|
363
318
|
if (m.role === "system") continue;
|
|
@@ -370,12 +325,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
370
325
|
if (typeof m.content === "string") {
|
|
371
326
|
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
372
327
|
} else {
|
|
373
|
-
const
|
|
374
|
-
if (hf) hasFiles = true;
|
|
375
|
-
// Surface remote file URLs in text for the file-parser plugin.
|
|
376
|
-
for (const url of fileUrls) {
|
|
377
|
-
blocks.push({ type: "text", text: url });
|
|
378
|
-
}
|
|
328
|
+
const blocks = await contentPartsToBlocks(m.content);
|
|
379
329
|
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
380
330
|
}
|
|
381
331
|
} else if (m.role === "assistant") {
|
|
@@ -392,7 +342,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
392
342
|
calls.push({
|
|
393
343
|
id: clampToolCallId(part.callId || part.id),
|
|
394
344
|
type: "function",
|
|
395
|
-
function: { name: part.name, arguments:
|
|
345
|
+
function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
|
|
396
346
|
});
|
|
397
347
|
} else if (part.type === "text" && part.text) {
|
|
398
348
|
texts.push(part.text);
|
|
@@ -435,14 +385,14 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
435
385
|
}
|
|
436
386
|
}
|
|
437
387
|
}
|
|
438
|
-
return
|
|
388
|
+
return messages;
|
|
439
389
|
}
|
|
440
390
|
|
|
441
391
|
// ---------------------------------------------------------------------------
|
|
442
392
|
// Response mapping (Chat Completions -> canonical)
|
|
443
393
|
// ---------------------------------------------------------------------------
|
|
444
394
|
|
|
445
|
-
function mapUsage(raw?:
|
|
395
|
+
function mapUsage(raw?: ChatUsage): TokenUsage {
|
|
446
396
|
const input = raw?.prompt_tokens ?? 0;
|
|
447
397
|
const output = raw?.completion_tokens ?? 0;
|
|
448
398
|
// Canonical invariant: cache hits are a SUBSET of input (no turn may
|
|
@@ -457,87 +407,12 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
|
|
|
457
407
|
cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
|
|
458
408
|
thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
|
|
459
409
|
};
|
|
460
|
-
if (typeof raw?.cost === "number" && raw.cost
|
|
410
|
+
if (typeof raw?.cost === "number" && raw.cost >= 0) { // an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
|
|
461
411
|
usage.cost = { totalCost: raw.cost };
|
|
462
412
|
}
|
|
463
413
|
return usage;
|
|
464
414
|
}
|
|
465
415
|
|
|
466
|
-
function throwResponseError(response: ChatResponse, modelId: string): void {
|
|
467
|
-
const message =
|
|
468
|
-
response.error && typeof response.error.message === "string" && response.error.message
|
|
469
|
-
? response.error.message
|
|
470
|
-
: "OpenRouter response failed";
|
|
471
|
-
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
472
|
-
if (response.error?.code !== undefined) failure["code"] = response.error.code;
|
|
473
|
-
// Chat skin keeps the stable typed code INSIDE metadata (unlike Responses'
|
|
474
|
-
// top-level `error_type`).
|
|
475
|
-
const errorType = (response.error?.metadata as { error_type?: string } | undefined)?.error_type;
|
|
476
|
-
if (typeof errorType === "string") failure["errorType"] = errorType;
|
|
477
|
-
throw toConciseProviderError(failure, "openrouter", modelId);
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
function parseResponse(
|
|
481
|
-
response: ChatResponse,
|
|
482
|
-
modelId: string,
|
|
483
|
-
durationMs: number,
|
|
484
|
-
raw: ProviderRawData
|
|
485
|
-
): ProviderGenerateResult {
|
|
486
|
-
// Provider-interrupted generations arrive as HTTP 200 carrying ONLY `error`
|
|
487
|
-
// (no `choices`) — must check the body, not just the status.
|
|
488
|
-
if (response.error) throwResponseError(response, modelId);
|
|
489
|
-
|
|
490
|
-
const choice = response.choices?.[0];
|
|
491
|
-
const message = choice?.message;
|
|
492
|
-
// Q-23: refusal content surfaces as text with a `refusal` finish reason.
|
|
493
|
-
const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
|
|
494
|
-
const text = typeof message?.content === "string" && message.content ? message.content : refusal;
|
|
495
|
-
// Thinking: `reasoning_details[].text` duplicates `message.reasoning` when
|
|
496
|
-
// both ride along (observed live) — prefer details, else the plain field.
|
|
497
|
-
const thinkingParts: string[] = [];
|
|
498
|
-
const detailTexts: string[] = [];
|
|
499
|
-
for (const block of message?.reasoning_details ?? []) {
|
|
500
|
-
if (block?.type === "reasoning.text" && block.text) detailTexts.push(block.text);
|
|
501
|
-
}
|
|
502
|
-
if (detailTexts.length > 0) {
|
|
503
|
-
thinkingParts.push(...detailTexts);
|
|
504
|
-
} else if (typeof message?.reasoning === "string" && message.reasoning) {
|
|
505
|
-
thinkingParts.push(message.reasoning);
|
|
506
|
-
}
|
|
507
|
-
const toolCalls: ToolCallRecord[] = [];
|
|
508
|
-
for (const tc of message?.tool_calls ?? []) {
|
|
509
|
-
const args = parseArguments(tc.function?.arguments);
|
|
510
|
-
toolCalls.push({
|
|
511
|
-
id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
|
|
512
|
-
name: tc.function?.name || "unknown",
|
|
513
|
-
arguments: args,
|
|
514
|
-
rawArguments:
|
|
515
|
-
typeof tc.function?.arguments === "string"
|
|
516
|
-
? tc.function.arguments
|
|
517
|
-
: JSON.stringify(tc.function?.arguments ?? {}),
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// Normalized finish reasons: tool_calls|stop|length|content_filter|error.
|
|
522
|
-
// Q-23: refusal content forces `refusal` (even when the wire says stop).
|
|
523
|
-
const wireReason = choice?.finish_reason ?? undefined;
|
|
524
|
-
const finishReason =
|
|
525
|
-
toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
|
|
526
|
-
return {
|
|
527
|
-
text,
|
|
528
|
-
thinking: normalizeThinkingParts(thinkingParts),
|
|
529
|
-
thoughtSignature: undefined,
|
|
530
|
-
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
531
|
-
usage: mapUsage(response.usage),
|
|
532
|
-
finishReason,
|
|
533
|
-
responseId: response.id,
|
|
534
|
-
model: modelId,
|
|
535
|
-
provider: "openrouter",
|
|
536
|
-
raw,
|
|
537
|
-
durationMs,
|
|
538
|
-
};
|
|
539
|
-
}
|
|
540
|
-
|
|
541
416
|
// ---------------------------------------------------------------------------
|
|
542
417
|
// Provider
|
|
543
418
|
// ---------------------------------------------------------------------------
|
|
@@ -576,8 +451,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
576
451
|
const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
|
|
577
452
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
578
453
|
// Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
|
|
579
|
-
const cache = resolveCache("openrouter", options?.cache, modelId
|
|
580
|
-
warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}
|
|
454
|
+
const cache = resolveCache("openrouter", options?.cache, /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
|
|
455
|
+
warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
|
|
581
456
|
|
|
582
457
|
// Structured outputs: native `response_format` when supported, else
|
|
583
458
|
// emulated via system-prompt instruction + client validation (catalog
|
|
@@ -605,7 +480,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
605
480
|
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
606
481
|
});
|
|
607
482
|
}
|
|
608
|
-
const
|
|
483
|
+
const messages = await fullHistoryMessages(effectiveContext);
|
|
609
484
|
const body: ChatRequestBody = {
|
|
610
485
|
model: modelId,
|
|
611
486
|
messages,
|
|
@@ -621,7 +496,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
621
496
|
if (serviceTier) body.service_tier = serviceTier;
|
|
622
497
|
if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
|
|
623
498
|
// Documented Chat Completions generation controls (only those the caller set).
|
|
624
|
-
Object.assign(body, mapGenerationParamsToChat(options));
|
|
499
|
+
Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
|
|
625
500
|
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
626
501
|
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
627
502
|
if (outputSpec && !outputEmulated) {
|
|
@@ -632,9 +507,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
632
507
|
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
633
508
|
}
|
|
634
509
|
}
|
|
635
|
-
// PDF ingestion is a documented plugin, not a message shape: enable it
|
|
636
|
-
// only when file parts are present (never by default).
|
|
637
|
-
if (hasFiles) body.plugins = [{ id: "file-parser" }];
|
|
638
510
|
// No store/previous_response_id/conversation: they do not exist
|
|
639
511
|
// on this endpoint.
|
|
640
512
|
// Session affinity: top-level `session_id` is the documented sticky-routing
|
|
@@ -665,41 +537,23 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
665
537
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
666
538
|
}
|
|
667
539
|
|
|
668
|
-
private async doFetch(
|
|
669
|
-
url: string,
|
|
670
|
-
body: ChatRequestBody,
|
|
671
|
-
options: ProviderRequestOptions | undefined,
|
|
672
|
-
apiKey: string,
|
|
673
|
-
sessionId: string | undefined,
|
|
674
|
-
signal?: AbortSignal
|
|
675
|
-
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
676
|
-
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
677
|
-
try {
|
|
678
|
-
const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
679
|
-
const text = await res.text();
|
|
680
|
-
const headers: Record<string, string> = {};
|
|
681
|
-
res.headers.forEach((v, k) => {
|
|
682
|
-
headers[k] = v;
|
|
683
|
-
});
|
|
684
|
-
return { status: res.status, statusText: res.statusText, headers, text };
|
|
685
|
-
} finally {
|
|
686
|
-
cleanup();
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
|
|
690
540
|
private throwIfError(
|
|
691
541
|
status: number,
|
|
692
542
|
url: string,
|
|
693
543
|
bodyText: string,
|
|
694
544
|
modelId: string,
|
|
695
|
-
headers?: Record<string, string
|
|
545
|
+
headers?: Record<string, string>,
|
|
546
|
+
remoteMedia = false
|
|
696
547
|
): void {
|
|
697
548
|
if (status >= 200 && status < 300) return;
|
|
698
|
-
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
549
|
+
const { message, code, errorType, raw, limitSource } = readErrorPayload(bodyText);
|
|
699
550
|
const hint =
|
|
700
551
|
status === 401
|
|
701
552
|
? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
|
|
702
|
-
: ""
|
|
553
|
+
: // A bad or unreachable image/video link comes back as the bare "Provider returned error" without saying which part failed.
|
|
554
|
+
status === 400 && remoteMedia && message === "Provider returned error"
|
|
555
|
+
? " The request carries a remote image or video link: it may be unreachable or not accepted by this route. Try passing the file itself (a local path or bytes) instead."
|
|
556
|
+
: "";
|
|
703
557
|
const fullMessage = `${message}${hint}`;
|
|
704
558
|
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
705
559
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
@@ -712,84 +566,49 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
712
566
|
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
713
567
|
const retryAfterMs = parseRetryAfterMs(headers);
|
|
714
568
|
if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
|
|
715
|
-
|
|
569
|
+
const concise = toConciseProviderError(err, "openrouter", modelId);
|
|
570
|
+
// A 402 from the in-flight spending budget clears by itself once running requests settle (errors-and-debugging.md, Retry-After): retry it after the header's wait. A 402 for missing credits does not.
|
|
571
|
+
if (status === 402 && limitSource === "openrouter_in_flight_budget") {
|
|
572
|
+
(concise as unknown as Record<string, unknown>)["retryable"] = true;
|
|
573
|
+
(concise as unknown as Record<string, unknown>)["transient"] = true;
|
|
574
|
+
}
|
|
575
|
+
throw concise;
|
|
716
576
|
}
|
|
717
577
|
|
|
578
|
+
// OpenRouter can only cancel a request that streams ("Stream cancellation": for a non-streaming request the model keeps processing and you are billed in full), so even a plain generate is sent as a stream and collected here: an interrupt then closes the connection and stops processing and billing at the providers OpenRouter can cancel; in: the loop's non-streaming turn, out: the same ProviderGenerateResult a JSON reply gave.
|
|
718
579
|
async generate(
|
|
719
580
|
model: string | ModelSpec,
|
|
720
581
|
context: ProviderContext,
|
|
721
582
|
options?: ProviderRequestOptions
|
|
722
583
|
): Promise<ProviderGenerateResult> {
|
|
723
|
-
|
|
724
|
-
const
|
|
725
|
-
const apiKey = resolveApiKey(options);
|
|
726
|
-
if (!apiKey) {
|
|
727
|
-
throw new Error(
|
|
728
|
-
"[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
|
|
729
|
-
);
|
|
730
|
-
}
|
|
731
|
-
// Fail fast before any network call when the catalog knows the model
|
|
732
|
-
// lacks the requested modality (clear one-liner instead of a confusing
|
|
733
|
-
// router 404/400). Unknown models skip the guard; the router verdict
|
|
734
|
-
// surfaces concisely.
|
|
735
|
-
assertModalitiesSupported(context, "openrouter", clean);
|
|
736
|
-
const baseUrl = resolveBaseUrl(options);
|
|
737
|
-
const url = `${baseUrl}/chat/completions`;
|
|
738
|
-
const sessionId = options?.sessionId;
|
|
739
|
-
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
740
|
-
const body = await this.buildBody(clean, context, options, sessionId, tools, false);
|
|
741
|
-
|
|
742
|
-
// Audit trail: record the actual wire headers (attribution + session
|
|
743
|
-
// affinity included), redacted.
|
|
744
|
-
const auditRaw: Record<string, string> = {
|
|
745
|
-
...buildSessionHeaders("openrouter", options?.headers, sessionId),
|
|
746
|
-
"Content-Type": "application/json",
|
|
747
|
-
Authorization: "[REDACTED]",
|
|
748
|
-
};
|
|
749
|
-
const rawRequest = {
|
|
750
|
-
url,
|
|
751
|
-
method: "POST",
|
|
752
|
-
headers: redactedHeaders(auditRaw),
|
|
753
|
-
body,
|
|
754
|
-
};
|
|
755
|
-
|
|
756
|
-
const doCall = async (): Promise<ProviderGenerateResult> => {
|
|
757
|
-
const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
|
|
758
|
-
this.throwIfError(res.status, url, res.text, clean, res.headers);
|
|
759
|
-
let response: ChatResponse;
|
|
760
|
-
try {
|
|
761
|
-
response = JSON.parse(res.text) as ChatResponse;
|
|
762
|
-
} catch {
|
|
763
|
-
throw toConciseProviderError(
|
|
764
|
-
Object.assign(new Error("Invalid JSON response from OpenRouter Chat Completions API"), {
|
|
765
|
-
statusCode: res.status,
|
|
766
|
-
responseBody: res.text.slice(0, 500),
|
|
767
|
-
url,
|
|
768
|
-
}),
|
|
769
|
-
"openrouter",
|
|
770
|
-
clean
|
|
771
|
-
);
|
|
772
|
-
}
|
|
773
|
-
// Stateless turns never establish chains, but the session store still
|
|
774
|
-
// records the turn so other providers' switch detection keeps working.
|
|
775
|
-
noteProviderTurn(sessionId, "openrouter");
|
|
776
|
-
return parseResponse(
|
|
777
|
-
response,
|
|
778
|
-
clean,
|
|
779
|
-
Date.now() - startTime,
|
|
780
|
-
{ request: rawRequest, response: { status: res.status, statusText: res.statusText, headers: res.headers, body: response } }
|
|
781
|
-
);
|
|
782
|
-
};
|
|
783
|
-
|
|
584
|
+
// The whole call stays bounded by `timeoutMs`; the stream's own timer only covers the wait for its first byte.
|
|
585
|
+
const { signal, cleanup } = combinedSignal(options?.signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
784
586
|
try {
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
587
|
+
// Nobody reads the deltas here, so the stream keeps none (a burst of frames would overflow its buffer for nothing).
|
|
588
|
+
const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, { ...options, signal });
|
|
589
|
+
const res = await events.result();
|
|
590
|
+
return {
|
|
591
|
+
text: res.text,
|
|
592
|
+
thinking: res.thinking,
|
|
593
|
+
thoughtSignature: undefined,
|
|
594
|
+
toolCalls: res.toolCalls.length > 0 ? res.toolCalls : undefined,
|
|
595
|
+
usage: res.usage,
|
|
596
|
+
finishReason: res.finishReason,
|
|
597
|
+
responseId: res.responseId,
|
|
598
|
+
model: res.model,
|
|
599
|
+
provider: "openrouter",
|
|
600
|
+
raw: res.raw,
|
|
601
|
+
durationMs: res.durationMs,
|
|
602
|
+
};
|
|
790
603
|
} catch (err) {
|
|
791
|
-
|
|
604
|
+
// A stream reports its own generic abort; the caller's reason (`interrupt(new Error("..."))`) is what the run should carry.
|
|
605
|
+
const reason = options?.signal?.reason;
|
|
606
|
+
if (options?.signal?.aborted && reason instanceof Error && isAbortError(err)) {
|
|
607
|
+
throw Object.assign(new Error(reason.message), { name: "AbortError" });
|
|
608
|
+
}
|
|
792
609
|
throw err;
|
|
610
|
+
} finally {
|
|
611
|
+
cleanup();
|
|
793
612
|
}
|
|
794
613
|
}
|
|
795
614
|
|
|
@@ -798,27 +617,20 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
798
617
|
context: ProviderContext,
|
|
799
618
|
options?: ProviderRequestOptions
|
|
800
619
|
): AssistantMessageEventStream {
|
|
801
|
-
|
|
620
|
+
return this.streamInto(new AssistantMessageEventStream(), model, context, options);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Runs one streamed request, pushing its events into `eventStream`; in: stream() (a normal stream) and generate() (one that keeps no events), out: that same stream, finished with the AgentResponse.
|
|
624
|
+
private streamInto(
|
|
625
|
+
eventStream: AssistantMessageEventStream,
|
|
626
|
+
model: string | ModelSpec,
|
|
627
|
+
context: ProviderContext,
|
|
628
|
+
options?: ProviderRequestOptions
|
|
629
|
+
): AssistantMessageEventStream {
|
|
802
630
|
const startTime = Date.now();
|
|
803
631
|
const clean = cleanModelId(model);
|
|
804
632
|
|
|
805
|
-
const linked =
|
|
806
|
-
const forwardUserAbort = () => {
|
|
807
|
-
try {
|
|
808
|
-
linked.abort((options?.signal as { reason?: unknown })?.reason);
|
|
809
|
-
} catch {
|
|
810
|
-
try {
|
|
811
|
-
linked.abort();
|
|
812
|
-
} catch {}
|
|
813
|
-
}
|
|
814
|
-
};
|
|
815
|
-
if (options?.signal?.aborted) forwardUserAbort();
|
|
816
|
-
else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
|
|
817
|
-
const removeStreamCancel = eventStream.onCancel(() => {
|
|
818
|
-
try {
|
|
819
|
-
linked.abort();
|
|
820
|
-
} catch {}
|
|
821
|
-
});
|
|
633
|
+
const { linked, release } = linkStreamAbort(eventStream, options?.signal);
|
|
822
634
|
|
|
823
635
|
(async () => {
|
|
824
636
|
try {
|
|
@@ -851,22 +663,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
851
663
|
// Transient failures (429/5xx/network) are retried here, before the
|
|
852
664
|
// first byte; once the stream is live it is never retried.
|
|
853
665
|
const res = await openStreamResponse({
|
|
854
|
-
fetchOnce:
|
|
855
|
-
|
|
856
|
-
streamTimeoutMs && streamTimeoutMs > 0
|
|
857
|
-
? setTimeout(() => {
|
|
858
|
-
try {
|
|
859
|
-
linked.abort();
|
|
860
|
-
} catch {}
|
|
861
|
-
}, streamTimeoutMs)
|
|
862
|
-
: undefined;
|
|
863
|
-
try {
|
|
864
|
-
return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
865
|
-
} finally {
|
|
866
|
-
if (timer) clearTimeout(timer);
|
|
867
|
-
}
|
|
868
|
-
},
|
|
869
|
-
onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
|
|
666
|
+
fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
|
|
667
|
+
onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
|
|
870
668
|
noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
|
|
871
669
|
retry: retryOptionsFrom(options),
|
|
872
670
|
signal: linked.signal,
|
|
@@ -879,9 +677,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
879
677
|
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
880
678
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
881
679
|
|
|
882
|
-
const parser = new SSEParser();
|
|
883
|
-
const reader = res.body!.getReader(); // openStreamResponse guarantees a body
|
|
884
|
-
const decoder = new TextDecoder();
|
|
885
680
|
let text = "";
|
|
886
681
|
let thinking = "";
|
|
887
682
|
// Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
|
|
@@ -898,18 +693,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
898
693
|
let framesSeen = 0;
|
|
899
694
|
let responseId: string | undefined;
|
|
900
695
|
let completedBody: unknown = undefined;
|
|
901
|
-
let aborted = false;
|
|
902
|
-
linked.signal.addEventListener(
|
|
903
|
-
"abort",
|
|
904
|
-
() => {
|
|
905
|
-
aborted = true;
|
|
906
|
-
try {
|
|
907
|
-
void reader.cancel();
|
|
908
|
-
} catch {}
|
|
909
|
-
},
|
|
910
|
-
{ once: true }
|
|
911
|
-
);
|
|
912
|
-
|
|
913
696
|
const handleMessage = (data: string): void => {
|
|
914
697
|
if (!data) return;
|
|
915
698
|
if (data === "[DONE]") {
|
|
@@ -935,6 +718,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
935
718
|
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
936
719
|
if (topError.code !== undefined) failure["code"] = topError.code;
|
|
937
720
|
if (typeof topError.metadata?.error_type === "string") failure["errorType"] = topError.metadata.error_type;
|
|
721
|
+
const status = midStreamStatus(topError.code, topError.metadata?.error_type);
|
|
722
|
+
if (status !== undefined) failure["statusCode"] = status; // lets the retry logic and the message treat it like an HTTP error
|
|
938
723
|
failure["url"] = url;
|
|
939
724
|
throw toConciseProviderError(failure, "openrouter", clean);
|
|
940
725
|
}
|
|
@@ -1022,7 +807,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1022
807
|
finishReason = fr;
|
|
1023
808
|
sawFinishReason = true;
|
|
1024
809
|
}
|
|
1025
|
-
const chunkUsage = msg["usage"] as
|
|
810
|
+
const chunkUsage = msg["usage"] as ChatUsage | undefined;
|
|
1026
811
|
if (chunkUsage) {
|
|
1027
812
|
usage = mapUsage(chunkUsage);
|
|
1028
813
|
sawUsage = true;
|
|
@@ -1031,32 +816,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1031
816
|
}
|
|
1032
817
|
};
|
|
1033
818
|
|
|
1034
|
-
|
|
1035
|
-
if (linked.signal.aborted || eventStream.isCancelled()) {
|
|
1036
|
-
try {
|
|
1037
|
-
await reader.cancel();
|
|
1038
|
-
} catch {}
|
|
1039
|
-
throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
|
|
1040
|
-
}
|
|
1041
|
-
const { done, value } = await reader.read();
|
|
1042
|
-
if (done) break;
|
|
1043
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
1044
|
-
for (const m of parser.feed(chunk)) handleMessage(m.data);
|
|
1045
|
-
}
|
|
1046
|
-
for (const m of parser.flush()) handleMessage(m.data);
|
|
1047
|
-
try {
|
|
1048
|
-
reader.releaseLock();
|
|
1049
|
-
} catch {}
|
|
1050
|
-
|
|
1051
|
-
if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
|
|
1052
|
-
throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
|
|
1053
|
-
}
|
|
819
|
+
await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
|
|
1054
820
|
|
|
1055
821
|
const toolCalls: ToolCallRecord[] = [];
|
|
1056
822
|
for (const c of calls.values()) {
|
|
1057
823
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
1058
824
|
const record: ToolCallRecord = {
|
|
1059
|
-
id: c.id
|
|
825
|
+
id: clampToolCallId(c.id || newToolCallId()), // the id the history sends back, so the stored id and the wire id are one
|
|
1060
826
|
name: c.name,
|
|
1061
827
|
arguments: args,
|
|
1062
828
|
rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
|
|
@@ -1065,9 +831,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1065
831
|
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
1066
832
|
}
|
|
1067
833
|
|
|
834
|
+
// Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
|
|
835
|
+
const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
|
|
1068
836
|
noteProviderTurn(sessionId, "openrouter");
|
|
1069
837
|
if (toolCalls.length > 0) {
|
|
1070
|
-
finishReason =
|
|
838
|
+
finishReason = toolTurnFinishReason(finishReason, toolCalls);
|
|
1071
839
|
sawFinishReason = true;
|
|
1072
840
|
} else if (refusalSeen && finishReason !== "content_filter") {
|
|
1073
841
|
finishReason = "refusal";
|
|
@@ -1077,26 +845,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1077
845
|
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
1078
846
|
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
1079
847
|
// (JSON mocks) where no SSE frames were ever seen.
|
|
1080
|
-
const
|
|
1081
|
-
const
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1094
|
-
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1095
|
-
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
848
|
+
const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
849
|
+
const incomplete = incompleteStreamCheck({
|
|
850
|
+
provider: "openrouter",
|
|
851
|
+
modelId: clean,
|
|
852
|
+
hasTerminal,
|
|
853
|
+
empty: !text && !cleanThinking && toolCalls.length === 0,
|
|
854
|
+
unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
|
|
855
|
+
responseHeaders,
|
|
856
|
+
framesSeen,
|
|
857
|
+
badFrames,
|
|
858
|
+
});
|
|
859
|
+
if (incomplete) {
|
|
860
|
+
eventStream.fail(incomplete);
|
|
1096
861
|
return;
|
|
1097
862
|
}
|
|
1098
|
-
|
|
1099
|
-
const cleanThinking = cleanThinkingEarly;
|
|
1100
863
|
const finalResponse = new AgentResponse({
|
|
1101
864
|
text,
|
|
1102
865
|
thinking: cleanThinking || undefined,
|
|
@@ -1116,21 +879,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1116
879
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|
|
1117
880
|
eventStream.end(finalResponse);
|
|
1118
881
|
} catch (err: unknown) {
|
|
1119
|
-
|
|
1120
|
-
const isAbort =
|
|
1121
|
-
linked.signal.aborted ||
|
|
1122
|
-
eventStream.isCancelled() ||
|
|
1123
|
-
(raw as { name?: string }).name === "AbortError";
|
|
1124
|
-
eventStream.fail(
|
|
1125
|
-
isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
|
|
1126
|
-
);
|
|
882
|
+
failStream(eventStream, linked, err);
|
|
1127
883
|
} finally {
|
|
1128
|
-
|
|
1129
|
-
options?.signal?.removeEventListener("abort", forwardUserAbort);
|
|
1130
|
-
} catch {}
|
|
1131
|
-
try {
|
|
1132
|
-
removeStreamCancel();
|
|
1133
|
-
} catch {}
|
|
884
|
+
release();
|
|
1134
885
|
}
|
|
1135
886
|
})();
|
|
1136
887
|
|