agent-accelerator 0.5.5 → 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 +10 -4
- package/package.json +1 -1
- package/src/agent/loop.ts +1 -1
- 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 +102 -175
- package/src/providers/shared.ts +134 -5
- package/src/providers.ts +21 -6
- package/src/session/store.ts +3 -3
- package/src/types/message.ts +1 -1
- package/src/utils/errors.ts +4 -1
- package/src/utils/headers.ts +1 -1
- package/src/utils/media.ts +10 -1
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* - EVERY request streams, `generate()` included (it collects its own `stream()`): OpenRouter only cancels a request that
|
|
18
18
|
* streams ("Stream cancellation" in its docs), so a plain non-streaming request could not be stopped by an interrupt.
|
|
19
19
|
* - STATELESS per request: every turn sends the full canonical `messages`
|
|
20
|
-
* array explicitly. Completions
|
|
21
|
-
* `conversation`/`prompt_cache_key
|
|
22
|
-
*
|
|
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.
|
|
23
24
|
* - IDs are provider-generated (`gen-…` generations, `call_…`/uuid tool ids)
|
|
24
25
|
* and echoed verbatim (`tool_call_id`). The `call_${random}` fallback in
|
|
25
26
|
* parsers is a local canonical correlation id only; it is never sent.
|
|
@@ -39,7 +40,6 @@ import type { ProviderContext, ContentPart } from "../types/message.ts";
|
|
|
39
40
|
import type { StandardToolDeclaration, ToolCallRecord } from "../types/tool.ts";
|
|
40
41
|
import type { TokenUsage } from "../types/core.ts";
|
|
41
42
|
import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
42
|
-
import { SSEParser } from "../streaming/sse-parser.ts";
|
|
43
43
|
import { AgentResponse } from "../types/response.ts";
|
|
44
44
|
import { getApiKey, getEnv } from "../utils/env.ts";
|
|
45
45
|
import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
|
|
@@ -52,7 +52,14 @@ import {
|
|
|
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,7 +122,6 @@ 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;
|
|
@@ -164,6 +170,13 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
164
170
|
return rawId.replace(/^openrouter\//i, "");
|
|
165
171
|
}
|
|
166
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
|
+
|
|
167
180
|
function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
|
|
168
181
|
if (!tools || tools.length === 0) return undefined;
|
|
169
182
|
return tools.map((t) => ({
|
|
@@ -177,20 +190,31 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
|
|
|
177
190
|
}));
|
|
178
191
|
}
|
|
179
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" };
|
|
180
195
|
function audioFormatFor(mimeType?: string): string {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
return "mp3";
|
|
196
|
+
const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
|
|
197
|
+
return AUDIO_FORMAT[subtype] ?? (subtype || "mp3");
|
|
184
198
|
}
|
|
185
199
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
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[]> {
|
|
191
217
|
const blocks: ChatContentPart[] = [];
|
|
192
|
-
const fileUrls: string[] = [];
|
|
193
|
-
let hasFiles = false;
|
|
194
218
|
for (const part of parts) {
|
|
195
219
|
if (part.type === "text" && part.text) {
|
|
196
220
|
blocks.push({ type: "text", text: part.text });
|
|
@@ -198,15 +222,16 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
198
222
|
const raw = (part as { image?: unknown }).image;
|
|
199
223
|
// Remote URLs pass through (router downloads; `image_download_failed`
|
|
200
224
|
// surfaces if unreachable). Inline only local/base64/binary.
|
|
225
|
+
const detail = IMAGE_DETAIL[(part as { resolution?: string }).resolution ?? ""];
|
|
201
226
|
if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
|
|
202
|
-
blocks.push({ type: "image_url", image_url: { url: raw } });
|
|
227
|
+
blocks.push({ type: "image_url", image_url: { url: raw, ...(detail ? { detail } : {}) } });
|
|
203
228
|
continue;
|
|
204
229
|
}
|
|
205
230
|
const norm = await normalizeMediaInput(
|
|
206
231
|
raw as string | Uint8Array | ArrayBuffer,
|
|
207
232
|
(part as { mimeType?: string }).mimeType
|
|
208
233
|
);
|
|
209
|
-
blocks.push({ type: "image_url", image_url: { url: norm.dataUrl } });
|
|
234
|
+
blocks.push({ type: "image_url", image_url: { url: norm.dataUrl, ...(detail ? { detail } : {}) } });
|
|
210
235
|
} else if (part.type === "video") {
|
|
211
236
|
const raw = (part as { video?: unknown }).video;
|
|
212
237
|
// `video_url` is the completions video shape (live: shape accepted,
|
|
@@ -220,7 +245,9 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
220
245
|
raw as string | Uint8Array | ArrayBuffer,
|
|
221
246
|
(part as { mimeType?: string }).mimeType
|
|
222
247
|
);
|
|
223
|
-
|
|
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 } });
|
|
224
251
|
} else if (part.type === "audio") {
|
|
225
252
|
const raw = (part as { audio?: unknown }).audio;
|
|
226
253
|
const mimeType = (part as { mimeType?: string }).mimeType;
|
|
@@ -235,37 +262,20 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
235
262
|
input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
|
|
236
263
|
});
|
|
237
264
|
} else if (part.type === "file") {
|
|
238
|
-
hasFiles = true;
|
|
239
265
|
const raw = (part as { file?: unknown }).file;
|
|
240
266
|
const filename = (part as { filename?: string }).filename;
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
blocks.push({
|
|
247
|
-
type: "file",
|
|
248
|
-
file: {
|
|
249
|
-
...(typeof filename === "string" && filename ? { filename } : {}),
|
|
250
|
-
file_url: raw,
|
|
251
|
-
},
|
|
252
|
-
});
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
const norm = await normalizeMediaInput(
|
|
256
|
-
raw as string | Uint8Array | ArrayBuffer,
|
|
257
|
-
(part as { mimeType?: string }).mimeType
|
|
258
|
-
);
|
|
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;
|
|
259
272
|
blocks.push({
|
|
260
273
|
type: "file",
|
|
261
|
-
file: {
|
|
262
|
-
...(typeof filename === "string" && filename ? { filename } : {}),
|
|
263
|
-
file_data: norm.dataUrl,
|
|
264
|
-
},
|
|
274
|
+
file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
|
|
265
275
|
});
|
|
266
276
|
}
|
|
267
277
|
}
|
|
268
|
-
return
|
|
278
|
+
return blocks;
|
|
269
279
|
}
|
|
270
280
|
|
|
271
281
|
function resultToOutput(result: unknown): string {
|
|
@@ -278,15 +288,8 @@ function resultToOutput(result: unknown): string {
|
|
|
278
288
|
* Stateless transport: no chaining primitives exist here, so every turn
|
|
279
289
|
* carries everything. Assistant items carry no provider `id`/`status`
|
|
280
290
|
* requirements (unlike the discontinued Responses skin).
|
|
281
|
-
*
|
|
282
|
-
* File handling: remote file URLs must be visible in text for the
|
|
283
|
-
* `file-parser` plugin (bare `{type:file}` parts are ignored by the route),
|
|
284
|
-
* so each remote file URL is appended as its own text part.
|
|
285
291
|
*/
|
|
286
|
-
async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
287
|
-
messages: ChatMessage[];
|
|
288
|
-
hasFiles: boolean;
|
|
289
|
-
}> {
|
|
292
|
+
async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
|
|
290
293
|
// Map assistant tool_call ids so tool results pair even though the
|
|
291
294
|
// executor keys results by the call id.
|
|
292
295
|
const pairing = new Map<string, string>();
|
|
@@ -310,7 +313,6 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
310
313
|
if (context.systemPrompt) {
|
|
311
314
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
312
315
|
}
|
|
313
|
-
let hasFiles = false;
|
|
314
316
|
let lastRole: string | null = "system";
|
|
315
317
|
for (const m of context.messages) {
|
|
316
318
|
if (m.role === "system") continue;
|
|
@@ -323,12 +325,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
323
325
|
if (typeof m.content === "string") {
|
|
324
326
|
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
325
327
|
} else {
|
|
326
|
-
const
|
|
327
|
-
if (hf) hasFiles = true;
|
|
328
|
-
// Surface remote file URLs in text for the file-parser plugin.
|
|
329
|
-
for (const url of fileUrls) {
|
|
330
|
-
blocks.push({ type: "text", text: url });
|
|
331
|
-
}
|
|
328
|
+
const blocks = await contentPartsToBlocks(m.content);
|
|
332
329
|
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
333
330
|
}
|
|
334
331
|
} else if (m.role === "assistant") {
|
|
@@ -345,7 +342,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
345
342
|
calls.push({
|
|
346
343
|
id: clampToolCallId(part.callId || part.id),
|
|
347
344
|
type: "function",
|
|
348
|
-
function: { name: part.name, arguments:
|
|
345
|
+
function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
|
|
349
346
|
});
|
|
350
347
|
} else if (part.type === "text" && part.text) {
|
|
351
348
|
texts.push(part.text);
|
|
@@ -388,7 +385,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
388
385
|
}
|
|
389
386
|
}
|
|
390
387
|
}
|
|
391
|
-
return
|
|
388
|
+
return messages;
|
|
392
389
|
}
|
|
393
390
|
|
|
394
391
|
// ---------------------------------------------------------------------------
|
|
@@ -410,7 +407,7 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
|
|
|
410
407
|
cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
|
|
411
408
|
thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
|
|
412
409
|
};
|
|
413
|
-
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
|
|
414
411
|
usage.cost = { totalCost: raw.cost };
|
|
415
412
|
}
|
|
416
413
|
return usage;
|
|
@@ -454,8 +451,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
454
451
|
const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
|
|
455
452
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
456
453
|
// Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
|
|
457
|
-
const cache = resolveCache("openrouter", options?.cache, modelId
|
|
458
|
-
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"]);
|
|
459
456
|
|
|
460
457
|
// Structured outputs: native `response_format` when supported, else
|
|
461
458
|
// emulated via system-prompt instruction + client validation (catalog
|
|
@@ -483,7 +480,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
483
480
|
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
484
481
|
});
|
|
485
482
|
}
|
|
486
|
-
const
|
|
483
|
+
const messages = await fullHistoryMessages(effectiveContext);
|
|
487
484
|
const body: ChatRequestBody = {
|
|
488
485
|
model: modelId,
|
|
489
486
|
messages,
|
|
@@ -499,7 +496,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
499
496
|
if (serviceTier) body.service_tier = serviceTier;
|
|
500
497
|
if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
|
|
501
498
|
// Documented Chat Completions generation controls (only those the caller set).
|
|
502
|
-
Object.assign(body, mapGenerationParamsToChat(options));
|
|
499
|
+
Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
|
|
503
500
|
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
504
501
|
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
505
502
|
if (outputSpec && !outputEmulated) {
|
|
@@ -510,9 +507,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
510
507
|
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
511
508
|
}
|
|
512
509
|
}
|
|
513
|
-
// PDF ingestion is a documented plugin, not a message shape: enable it
|
|
514
|
-
// only when file parts are present (never by default).
|
|
515
|
-
if (hasFiles) body.plugins = [{ id: "file-parser" }];
|
|
516
510
|
// No store/previous_response_id/conversation: they do not exist
|
|
517
511
|
// on this endpoint.
|
|
518
512
|
// Session affinity: top-level `session_id` is the documented sticky-routing
|
|
@@ -548,14 +542,18 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
548
542
|
url: string,
|
|
549
543
|
bodyText: string,
|
|
550
544
|
modelId: string,
|
|
551
|
-
headers?: Record<string, string
|
|
545
|
+
headers?: Record<string, string>,
|
|
546
|
+
remoteMedia = false
|
|
552
547
|
): void {
|
|
553
548
|
if (status >= 200 && status < 300) return;
|
|
554
|
-
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
549
|
+
const { message, code, errorType, raw, limitSource } = readErrorPayload(bodyText);
|
|
555
550
|
const hint =
|
|
556
551
|
status === 401
|
|
557
552
|
? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
|
|
558
|
-
: ""
|
|
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
|
+
: "";
|
|
559
557
|
const fullMessage = `${message}${hint}`;
|
|
560
558
|
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
561
559
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
@@ -568,7 +566,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
568
566
|
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
569
567
|
const retryAfterMs = parseRetryAfterMs(headers);
|
|
570
568
|
if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
|
|
571
|
-
|
|
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;
|
|
572
576
|
}
|
|
573
577
|
|
|
574
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.
|
|
@@ -626,23 +630,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
626
630
|
const startTime = Date.now();
|
|
627
631
|
const clean = cleanModelId(model);
|
|
628
632
|
|
|
629
|
-
const linked =
|
|
630
|
-
const forwardUserAbort = () => {
|
|
631
|
-
try {
|
|
632
|
-
linked.abort((options?.signal as { reason?: unknown })?.reason);
|
|
633
|
-
} catch {
|
|
634
|
-
try {
|
|
635
|
-
linked.abort();
|
|
636
|
-
} catch {}
|
|
637
|
-
}
|
|
638
|
-
};
|
|
639
|
-
if (options?.signal?.aborted) forwardUserAbort();
|
|
640
|
-
else options?.signal?.addEventListener("abort", forwardUserAbort, { once: true });
|
|
641
|
-
const removeStreamCancel = eventStream.onCancel(() => {
|
|
642
|
-
try {
|
|
643
|
-
linked.abort();
|
|
644
|
-
} catch {}
|
|
645
|
-
});
|
|
633
|
+
const { linked, release } = linkStreamAbort(eventStream, options?.signal);
|
|
646
634
|
|
|
647
635
|
(async () => {
|
|
648
636
|
try {
|
|
@@ -675,22 +663,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
675
663
|
// Transient failures (429/5xx/network) are retried here, before the
|
|
676
664
|
// first byte; once the stream is live it is never retried.
|
|
677
665
|
const res = await openStreamResponse({
|
|
678
|
-
fetchOnce:
|
|
679
|
-
|
|
680
|
-
streamTimeoutMs && streamTimeoutMs > 0
|
|
681
|
-
? setTimeout(() => {
|
|
682
|
-
try {
|
|
683
|
-
linked.abort();
|
|
684
|
-
} catch {}
|
|
685
|
-
}, streamTimeoutMs)
|
|
686
|
-
: undefined;
|
|
687
|
-
try {
|
|
688
|
-
return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
689
|
-
} finally {
|
|
690
|
-
if (timer) clearTimeout(timer);
|
|
691
|
-
}
|
|
692
|
-
},
|
|
693
|
-
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)),
|
|
694
668
|
noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
|
|
695
669
|
retry: retryOptionsFrom(options),
|
|
696
670
|
signal: linked.signal,
|
|
@@ -703,9 +677,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
703
677
|
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
704
678
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
705
679
|
|
|
706
|
-
const parser = new SSEParser();
|
|
707
|
-
const reader = res.body!.getReader(); // openStreamResponse guarantees a body
|
|
708
|
-
const decoder = new TextDecoder();
|
|
709
680
|
let text = "";
|
|
710
681
|
let thinking = "";
|
|
711
682
|
// Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
|
|
@@ -722,18 +693,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
722
693
|
let framesSeen = 0;
|
|
723
694
|
let responseId: string | undefined;
|
|
724
695
|
let completedBody: unknown = undefined;
|
|
725
|
-
let aborted = false;
|
|
726
|
-
linked.signal.addEventListener(
|
|
727
|
-
"abort",
|
|
728
|
-
() => {
|
|
729
|
-
aborted = true;
|
|
730
|
-
try {
|
|
731
|
-
void reader.cancel();
|
|
732
|
-
} catch {}
|
|
733
|
-
},
|
|
734
|
-
{ once: true }
|
|
735
|
-
);
|
|
736
|
-
|
|
737
696
|
const handleMessage = (data: string): void => {
|
|
738
697
|
if (!data) return;
|
|
739
698
|
if (data === "[DONE]") {
|
|
@@ -759,6 +718,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
759
718
|
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
760
719
|
if (topError.code !== undefined) failure["code"] = topError.code;
|
|
761
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
|
|
762
723
|
failure["url"] = url;
|
|
763
724
|
throw toConciseProviderError(failure, "openrouter", clean);
|
|
764
725
|
}
|
|
@@ -855,32 +816,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
855
816
|
}
|
|
856
817
|
};
|
|
857
818
|
|
|
858
|
-
|
|
859
|
-
if (linked.signal.aborted || eventStream.isCancelled()) {
|
|
860
|
-
try {
|
|
861
|
-
await reader.cancel();
|
|
862
|
-
} catch {}
|
|
863
|
-
throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
|
|
864
|
-
}
|
|
865
|
-
const { done, value } = await reader.read();
|
|
866
|
-
if (done) break;
|
|
867
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
868
|
-
for (const m of parser.feed(chunk)) handleMessage(m.data);
|
|
869
|
-
}
|
|
870
|
-
for (const m of parser.flush()) handleMessage(m.data);
|
|
871
|
-
try {
|
|
872
|
-
reader.releaseLock();
|
|
873
|
-
} catch {}
|
|
874
|
-
|
|
875
|
-
if (linked.signal.aborted || eventStream.isCancelled() || aborted) {
|
|
876
|
-
throw Object.assign(new Error("Stream aborted"), { name: "AbortError" });
|
|
877
|
-
}
|
|
819
|
+
await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
|
|
878
820
|
|
|
879
821
|
const toolCalls: ToolCallRecord[] = [];
|
|
880
822
|
for (const c of calls.values()) {
|
|
881
823
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
882
824
|
const record: ToolCallRecord = {
|
|
883
|
-
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
|
|
884
826
|
name: c.name,
|
|
885
827
|
arguments: args,
|
|
886
828
|
rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
|
|
@@ -889,9 +831,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
889
831
|
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
890
832
|
}
|
|
891
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;
|
|
892
836
|
noteProviderTurn(sessionId, "openrouter");
|
|
893
837
|
if (toolCalls.length > 0) {
|
|
894
|
-
finishReason =
|
|
838
|
+
finishReason = toolTurnFinishReason(finishReason, toolCalls);
|
|
895
839
|
sawFinishReason = true;
|
|
896
840
|
} else if (refusalSeen && finishReason !== "content_filter") {
|
|
897
841
|
finishReason = "refusal";
|
|
@@ -901,26 +845,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
901
845
|
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
902
846
|
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
903
847
|
// (JSON mocks) where no SSE frames were ever seen.
|
|
904
|
-
const
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
}
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
918
|
-
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
919
|
-
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);
|
|
920
861
|
return;
|
|
921
862
|
}
|
|
922
|
-
|
|
923
|
-
const cleanThinking = cleanThinkingEarly;
|
|
924
863
|
const finalResponse = new AgentResponse({
|
|
925
864
|
text,
|
|
926
865
|
thinking: cleanThinking || undefined,
|
|
@@ -940,21 +879,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
940
879
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|
|
941
880
|
eventStream.end(finalResponse);
|
|
942
881
|
} catch (err: unknown) {
|
|
943
|
-
|
|
944
|
-
const isAbort =
|
|
945
|
-
linked.signal.aborted ||
|
|
946
|
-
eventStream.isCancelled() ||
|
|
947
|
-
(raw as { name?: string }).name === "AbortError";
|
|
948
|
-
eventStream.fail(
|
|
949
|
-
isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
|
|
950
|
-
);
|
|
882
|
+
failStream(eventStream, linked, err);
|
|
951
883
|
} finally {
|
|
952
|
-
|
|
953
|
-
options?.signal?.removeEventListener("abort", forwardUserAbort);
|
|
954
|
-
} catch {}
|
|
955
|
-
try {
|
|
956
|
-
removeStreamCancel();
|
|
957
|
-
} catch {}
|
|
884
|
+
release();
|
|
958
885
|
}
|
|
959
886
|
})();
|
|
960
887
|
|