agent-accelerator 0.5.5 → 0.5.7
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 +16 -17
- package/package.json +1 -1
- package/src/agent/agent.ts +16 -18
- package/src/agent/architecture.md +25 -0
- package/src/agent/context.ts +4 -0
- package/src/agent/delegation.ts +18 -84
- package/src/agent/loop.ts +14 -16
- package/src/agent/worker.ts +1 -3
- package/src/architecture.md +28 -0
- package/src/index.ts +6 -73
- package/src/models/architecture.md +14 -0
- package/src/models/catalog-cache.ts +3 -0
- package/src/models/catalog.ts +7 -0
- package/src/providers/architecture.md +24 -0
- package/src/providers/google.ts +26 -39
- package/src/providers/openai-compat.ts +53 -104
- package/src/providers/openai.ts +47 -104
- package/src/providers/openrouter.ts +114 -175
- package/src/providers/registry.ts +1 -0
- package/src/providers/shared.ts +134 -5
- package/src/providers.ts +30 -59
- package/src/scheduler/architecture.md +19 -0
- package/src/scheduler/scheduler.ts +21 -0
- package/src/scheduler/store.ts +6 -0
- package/src/scheduler/time.ts +2 -0
- package/src/session/architecture.md +19 -0
- package/src/session/store.ts +18 -3
- package/src/session/usage.ts +1 -0
- package/src/streaming/architecture.md +13 -0
- package/src/streaming/event-stream.ts +5 -0
- package/src/telemetry.ts +1 -0
- package/src/tools/architecture.md +19 -0
- package/src/tools/executor.ts +13 -0
- package/src/tools/schema.ts +1 -0
- package/src/tools/subagent-result.ts +1 -0
- package/src/types/architecture.md +20 -0
- package/src/types/message.ts +1 -1
- package/src/utils/architecture.md +25 -0
- package/src/utils/base64.ts +2 -0
- package/src/utils/cache.ts +2 -0
- package/src/utils/errors.ts +8 -1
- package/src/utils/headers.ts +4 -1
- package/src/utils/media.ts +11 -1
- package/src/utils/raw-capture.ts +3 -0
- package/src/utils/retry.ts +1 -0
- package/src/utils/safe-io.ts +4 -0
- package/src/utils/session.ts +5 -161
|
@@ -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;
|
|
@@ -139,6 +145,7 @@ const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
|
139
145
|
// Request building (canonical -> Chat Completions)
|
|
140
146
|
// ---------------------------------------------------------------------------
|
|
141
147
|
|
|
148
|
+
// Endpoint from the option, OPENROUTER_BASE_URL, else openrouter.ai; in: each request, out: the base URL without a trailing slash.
|
|
142
149
|
function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
143
150
|
return (
|
|
144
151
|
options?.baseUrl ||
|
|
@@ -148,6 +155,7 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
148
155
|
).replace(/\/+$/, "");
|
|
149
156
|
}
|
|
150
157
|
|
|
158
|
+
// Key from the option or env, or `unused` when custom headers carry the auth; in: each request, out: the key or undefined.
|
|
151
159
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
152
160
|
const key = getApiKey("openrouter", options?.apiKey, options?.env);
|
|
153
161
|
if (key) return key;
|
|
@@ -164,6 +172,15 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
164
172
|
return rawId.replace(/^openrouter\//i, "");
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
// 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.
|
|
176
|
+
const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = { rate_limit_exceeded: 429, provider_overloaded: 503, provider_unavailable: 502, timeout: 504, server: 500 };
|
|
177
|
+
// HTTP-like status of an error chunk inside a stream, or undefined when unknown; in: streamInto's error branch, out: the status that makes the error retryable.
|
|
178
|
+
function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
|
|
179
|
+
if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
|
|
180
|
+
return typeof errorType === "string" ? RETRYABLE_ERROR_TYPE_STATUS[errorType] : undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Canonical tool declarations as Chat Completions function tools; in: options.tools, out: request `tools`.
|
|
167
184
|
function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<string, unknown>> | undefined {
|
|
168
185
|
if (!tools || tools.length === 0) return undefined;
|
|
169
186
|
return tools.map((t) => ({
|
|
@@ -177,20 +194,32 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
|
|
|
177
194
|
}));
|
|
178
195
|
}
|
|
179
196
|
|
|
197
|
+
// 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.
|
|
198
|
+
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" };
|
|
199
|
+
// The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
|
|
180
200
|
function audioFormatFor(mimeType?: string): string {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
return "mp3";
|
|
201
|
+
const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
|
|
202
|
+
return AUDIO_FORMAT[subtype] ?? (subtype || "mp3");
|
|
184
203
|
}
|
|
185
204
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
205
|
+
// `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.
|
|
206
|
+
const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = { low: "low", medium: "auto", high: "high", ultra_high: "original" };
|
|
207
|
+
|
|
208
|
+
// 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.
|
|
209
|
+
function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
210
|
+
return messages.some(
|
|
211
|
+
(m) =>
|
|
212
|
+
Array.isArray(m.content) &&
|
|
213
|
+
(m.content as ChatContentPart[]).some((p) => {
|
|
214
|
+
const url = (p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ?? (p as { video_url?: { url?: string } }).video_url?.url;
|
|
215
|
+
return typeof url === "string" && /^https?:\/\//.test(url);
|
|
216
|
+
})
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Maps canonical text and media parts to Chat Completions content blocks; in: a user message's parts, out: fullHistoryMessages.
|
|
221
|
+
async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPart[]> {
|
|
191
222
|
const blocks: ChatContentPart[] = [];
|
|
192
|
-
const fileUrls: string[] = [];
|
|
193
|
-
let hasFiles = false;
|
|
194
223
|
for (const part of parts) {
|
|
195
224
|
if (part.type === "text" && part.text) {
|
|
196
225
|
blocks.push({ type: "text", text: part.text });
|
|
@@ -198,15 +227,16 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
198
227
|
const raw = (part as { image?: unknown }).image;
|
|
199
228
|
// Remote URLs pass through (router downloads; `image_download_failed`
|
|
200
229
|
// surfaces if unreachable). Inline only local/base64/binary.
|
|
230
|
+
const detail = IMAGE_DETAIL[(part as { resolution?: string }).resolution ?? ""];
|
|
201
231
|
if (typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))) {
|
|
202
|
-
blocks.push({ type: "image_url", image_url: { url: raw } });
|
|
232
|
+
blocks.push({ type: "image_url", image_url: { url: raw, ...(detail ? { detail } : {}) } });
|
|
203
233
|
continue;
|
|
204
234
|
}
|
|
205
235
|
const norm = await normalizeMediaInput(
|
|
206
236
|
raw as string | Uint8Array | ArrayBuffer,
|
|
207
237
|
(part as { mimeType?: string }).mimeType
|
|
208
238
|
);
|
|
209
|
-
blocks.push({ type: "image_url", image_url: { url: norm.dataUrl } });
|
|
239
|
+
blocks.push({ type: "image_url", image_url: { url: norm.dataUrl, ...(detail ? { detail } : {}) } });
|
|
210
240
|
} else if (part.type === "video") {
|
|
211
241
|
const raw = (part as { video?: unknown }).video;
|
|
212
242
|
// `video_url` is the completions video shape (live: shape accepted,
|
|
@@ -220,7 +250,9 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
220
250
|
raw as string | Uint8Array | ArrayBuffer,
|
|
221
251
|
(part as { mimeType?: string }).mimeType
|
|
222
252
|
);
|
|
223
|
-
|
|
253
|
+
// video-inputs.md names the QuickTime type `video/mov`.
|
|
254
|
+
const url = norm.mimeType === "video/quicktime" ? `data:video/mov;base64,${norm.base64Data}` : norm.dataUrl;
|
|
255
|
+
blocks.push({ type: "video_url", video_url: { url } });
|
|
224
256
|
} else if (part.type === "audio") {
|
|
225
257
|
const raw = (part as { audio?: unknown }).audio;
|
|
226
258
|
const mimeType = (part as { mimeType?: string }).mimeType;
|
|
@@ -235,39 +267,23 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<{
|
|
|
235
267
|
input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
|
|
236
268
|
});
|
|
237
269
|
} else if (part.type === "file") {
|
|
238
|
-
hasFiles = true;
|
|
239
270
|
const raw = (part as { file?: unknown }).file;
|
|
240
271
|
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
|
-
);
|
|
272
|
+
// `file_data` takes a public URL or a base64 data URL (api-ref ChatContentFile; a `file_url` field does not exist).
|
|
273
|
+
const fileData =
|
|
274
|
+
typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
|
|
275
|
+
? raw
|
|
276
|
+
: (await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, (part as { mimeType?: string }).mimeType)).dataUrl;
|
|
259
277
|
blocks.push({
|
|
260
278
|
type: "file",
|
|
261
|
-
file: {
|
|
262
|
-
...(typeof filename === "string" && filename ? { filename } : {}),
|
|
263
|
-
file_data: norm.dataUrl,
|
|
264
|
-
},
|
|
279
|
+
file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
|
|
265
280
|
});
|
|
266
281
|
}
|
|
267
282
|
}
|
|
268
|
-
return
|
|
283
|
+
return blocks;
|
|
269
284
|
}
|
|
270
285
|
|
|
286
|
+
// A tool result as the text of a tool message; in: fullHistoryMessages, out: a string.
|
|
271
287
|
function resultToOutput(result: unknown): string {
|
|
272
288
|
if (typeof result === "string") return result;
|
|
273
289
|
return safeStringify(result);
|
|
@@ -278,15 +294,8 @@ function resultToOutput(result: unknown): string {
|
|
|
278
294
|
* Stateless transport: no chaining primitives exist here, so every turn
|
|
279
295
|
* carries everything. Assistant items carry no provider `id`/`status`
|
|
280
296
|
* 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
297
|
*/
|
|
286
|
-
async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
287
|
-
messages: ChatMessage[];
|
|
288
|
-
hasFiles: boolean;
|
|
289
|
-
}> {
|
|
298
|
+
async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
|
|
290
299
|
// Map assistant tool_call ids so tool results pair even though the
|
|
291
300
|
// executor keys results by the call id.
|
|
292
301
|
const pairing = new Map<string, string>();
|
|
@@ -310,7 +319,6 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
310
319
|
if (context.systemPrompt) {
|
|
311
320
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
312
321
|
}
|
|
313
|
-
let hasFiles = false;
|
|
314
322
|
let lastRole: string | null = "system";
|
|
315
323
|
for (const m of context.messages) {
|
|
316
324
|
if (m.role === "system") continue;
|
|
@@ -323,12 +331,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
323
331
|
if (typeof m.content === "string") {
|
|
324
332
|
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
325
333
|
} 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
|
-
}
|
|
334
|
+
const blocks = await contentPartsToBlocks(m.content);
|
|
332
335
|
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
333
336
|
}
|
|
334
337
|
} else if (m.role === "assistant") {
|
|
@@ -345,7 +348,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
345
348
|
calls.push({
|
|
346
349
|
id: clampToolCallId(part.callId || part.id),
|
|
347
350
|
type: "function",
|
|
348
|
-
function: { name: part.name, arguments:
|
|
351
|
+
function: { name: part.name, arguments: toolArgumentsJson(part.arguments) },
|
|
349
352
|
});
|
|
350
353
|
} else if (part.type === "text" && part.text) {
|
|
351
354
|
texts.push(part.text);
|
|
@@ -388,13 +391,14 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
388
391
|
}
|
|
389
392
|
}
|
|
390
393
|
}
|
|
391
|
-
return
|
|
394
|
+
return messages;
|
|
392
395
|
}
|
|
393
396
|
|
|
394
397
|
// ---------------------------------------------------------------------------
|
|
395
398
|
// Response mapping (Chat Completions -> canonical)
|
|
396
399
|
// ---------------------------------------------------------------------------
|
|
397
400
|
|
|
401
|
+
// Chat usage as TokenUsage with cache reads clamped to input, cache writes, reasoning tokens and OpenRouter's reported cost (0 counts); in: the last stream chunk, out: TokenUsage.
|
|
398
402
|
function mapUsage(raw?: ChatUsage): TokenUsage {
|
|
399
403
|
const input = raw?.prompt_tokens ?? 0;
|
|
400
404
|
const output = raw?.completion_tokens ?? 0;
|
|
@@ -410,7 +414,7 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
|
|
|
410
414
|
cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
|
|
411
415
|
thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
|
|
412
416
|
};
|
|
413
|
-
if (typeof raw?.cost === "number" && raw.cost
|
|
417
|
+
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
418
|
usage.cost = { totalCost: raw.cost };
|
|
415
419
|
}
|
|
416
420
|
return usage;
|
|
@@ -433,6 +437,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
433
437
|
return getModelsForProvider("openrouter");
|
|
434
438
|
}
|
|
435
439
|
|
|
440
|
+
// The catalog spec of a model, else a permissive generic one; in: the registry, out: a ModelSpec.
|
|
436
441
|
getModel(modelId: string): ModelSpec | undefined {
|
|
437
442
|
const clean = cleanModelId(modelId);
|
|
438
443
|
return (
|
|
@@ -443,6 +448,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
443
448
|
);
|
|
444
449
|
}
|
|
445
450
|
|
|
451
|
+
// Builds the Chat request (messages, tools, tool_choice, reasoning, tier, cache_control, generation controls, response_format, session_id); in: streamInto, out: the body.
|
|
446
452
|
private async buildBody(
|
|
447
453
|
modelId: string,
|
|
448
454
|
context: ProviderContext,
|
|
@@ -454,8 +460,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
454
460
|
const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
|
|
455
461
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
456
462
|
// 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}
|
|
463
|
+
const cache = resolveCache("openrouter", options?.cache, /^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"], `openrouter/${modelId}`);
|
|
464
|
+
warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
|
|
459
465
|
|
|
460
466
|
// Structured outputs: native `response_format` when supported, else
|
|
461
467
|
// emulated via system-prompt instruction + client validation (catalog
|
|
@@ -483,7 +489,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
483
489
|
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
484
490
|
});
|
|
485
491
|
}
|
|
486
|
-
const
|
|
492
|
+
const messages = await fullHistoryMessages(effectiveContext);
|
|
487
493
|
const body: ChatRequestBody = {
|
|
488
494
|
model: modelId,
|
|
489
495
|
messages,
|
|
@@ -499,7 +505,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
499
505
|
if (serviceTier) body.service_tier = serviceTier;
|
|
500
506
|
if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
|
|
501
507
|
// Documented Chat Completions generation controls (only those the caller set).
|
|
502
|
-
Object.assign(body, mapGenerationParamsToChat(options));
|
|
508
|
+
Object.assign(body, mapGenerationParamsToChat(options, "openrouter", `openrouter/${modelId}`));
|
|
503
509
|
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
504
510
|
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
505
511
|
if (outputSpec && !outputEmulated) {
|
|
@@ -510,9 +516,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
510
516
|
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
511
517
|
}
|
|
512
518
|
}
|
|
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
519
|
// No store/previous_response_id/conversation: they do not exist
|
|
517
520
|
// on this endpoint.
|
|
518
521
|
// Session affinity: top-level `session_id` is the documented sticky-routing
|
|
@@ -524,6 +527,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
524
527
|
return body;
|
|
525
528
|
}
|
|
526
529
|
|
|
530
|
+
// Fetch options with session and opt-in attribution headers, JSON content type and the Bearer key; in: streamInto, out: RequestInit.
|
|
527
531
|
private requestInit(
|
|
528
532
|
body: ChatRequestBody,
|
|
529
533
|
options: ProviderRequestOptions | undefined,
|
|
@@ -543,19 +547,24 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
543
547
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
544
548
|
}
|
|
545
549
|
|
|
550
|
+
// Throws a concise provider error (status, code, Retry-After, key and media-link hints) for a non-2xx reply, marking the in-flight-budget 402 retryable; in: streamInto, out: nothing or a thrown error.
|
|
546
551
|
private throwIfError(
|
|
547
552
|
status: number,
|
|
548
553
|
url: string,
|
|
549
554
|
bodyText: string,
|
|
550
555
|
modelId: string,
|
|
551
|
-
headers?: Record<string, string
|
|
556
|
+
headers?: Record<string, string>,
|
|
557
|
+
remoteMedia = false
|
|
552
558
|
): void {
|
|
553
559
|
if (status >= 200 && status < 300) return;
|
|
554
|
-
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
560
|
+
const { message, code, errorType, raw, limitSource } = readErrorPayload(bodyText);
|
|
555
561
|
const hint =
|
|
556
562
|
status === 401
|
|
557
563
|
? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
|
|
558
|
-
: ""
|
|
564
|
+
: // A bad or unreachable image/video link comes back as the bare "Provider returned error" without saying which part failed.
|
|
565
|
+
status === 400 && remoteMedia && message === "Provider returned error"
|
|
566
|
+
? " 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."
|
|
567
|
+
: "";
|
|
559
568
|
const fullMessage = `${message}${hint}`;
|
|
560
569
|
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
561
570
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
@@ -568,7 +577,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
568
577
|
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
569
578
|
const retryAfterMs = parseRetryAfterMs(headers);
|
|
570
579
|
if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
|
|
571
|
-
|
|
580
|
+
const concise = toConciseProviderError(err, "openrouter", modelId);
|
|
581
|
+
// 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.
|
|
582
|
+
if (status === 402 && limitSource === "openrouter_in_flight_budget") {
|
|
583
|
+
(concise as unknown as Record<string, unknown>)["retryable"] = true;
|
|
584
|
+
(concise as unknown as Record<string, unknown>)["transient"] = true;
|
|
585
|
+
}
|
|
586
|
+
throw concise;
|
|
572
587
|
}
|
|
573
588
|
|
|
574
589
|
// 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.
|
|
@@ -608,6 +623,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
608
623
|
}
|
|
609
624
|
}
|
|
610
625
|
|
|
626
|
+
// Streaming turn: a normal event stream from streamInto; in: streamAgentLoop, out: an AssistantMessageEventStream.
|
|
611
627
|
stream(
|
|
612
628
|
model: string | ModelSpec,
|
|
613
629
|
context: ProviderContext,
|
|
@@ -626,23 +642,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
626
642
|
const startTime = Date.now();
|
|
627
643
|
const clean = cleanModelId(model);
|
|
628
644
|
|
|
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
|
-
});
|
|
645
|
+
const { linked, release } = linkStreamAbort(eventStream, options?.signal);
|
|
646
646
|
|
|
647
647
|
(async () => {
|
|
648
648
|
try {
|
|
@@ -675,22 +675,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
675
675
|
// Transient failures (429/5xx/network) are retried here, before the
|
|
676
676
|
// first byte; once the stream is live it is never retried.
|
|
677
677
|
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)),
|
|
678
|
+
fetchOnce: () => withAbortTimer(linked, streamTimeoutMs, () => (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))),
|
|
679
|
+
onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
|
|
694
680
|
noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
|
|
695
681
|
retry: retryOptionsFrom(options),
|
|
696
682
|
signal: linked.signal,
|
|
@@ -703,9 +689,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
703
689
|
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
704
690
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
705
691
|
|
|
706
|
-
const parser = new SSEParser();
|
|
707
|
-
const reader = res.body!.getReader(); // openStreamResponse guarantees a body
|
|
708
|
-
const decoder = new TextDecoder();
|
|
709
692
|
let text = "";
|
|
710
693
|
let thinking = "";
|
|
711
694
|
// Tool calls keyed by choice index: { id, name, startArgs, deltaArgs }.
|
|
@@ -722,18 +705,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
722
705
|
let framesSeen = 0;
|
|
723
706
|
let responseId: string | undefined;
|
|
724
707
|
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
708
|
const handleMessage = (data: string): void => {
|
|
738
709
|
if (!data) return;
|
|
739
710
|
if (data === "[DONE]") {
|
|
@@ -759,6 +730,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
759
730
|
const failure: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
|
|
760
731
|
if (topError.code !== undefined) failure["code"] = topError.code;
|
|
761
732
|
if (typeof topError.metadata?.error_type === "string") failure["errorType"] = topError.metadata.error_type;
|
|
733
|
+
const status = midStreamStatus(topError.code, topError.metadata?.error_type);
|
|
734
|
+
if (status !== undefined) failure["statusCode"] = status; // lets the retry logic and the message treat it like an HTTP error
|
|
762
735
|
failure["url"] = url;
|
|
763
736
|
throw toConciseProviderError(failure, "openrouter", clean);
|
|
764
737
|
}
|
|
@@ -855,32 +828,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
855
828
|
}
|
|
856
829
|
};
|
|
857
830
|
|
|
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
|
-
}
|
|
831
|
+
await readSseData(res.body!, linked, eventStream, handleMessage); // openStreamResponse guarantees a body
|
|
878
832
|
|
|
879
833
|
const toolCalls: ToolCallRecord[] = [];
|
|
880
834
|
for (const c of calls.values()) {
|
|
881
835
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
882
836
|
const record: ToolCallRecord = {
|
|
883
|
-
id: c.id
|
|
837
|
+
id: clampToolCallId(c.id || newToolCallId()), // the id the history sends back, so the stored id and the wire id are one
|
|
884
838
|
name: c.name,
|
|
885
839
|
arguments: args,
|
|
886
840
|
rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
|
|
@@ -889,9 +843,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
889
843
|
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
890
844
|
}
|
|
891
845
|
|
|
846
|
+
// Did the stream really end? Read before the tool-call and refusal overrides below, which also set sawFinishReason.
|
|
847
|
+
const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
|
|
892
848
|
noteProviderTurn(sessionId, "openrouter");
|
|
893
849
|
if (toolCalls.length > 0) {
|
|
894
|
-
finishReason =
|
|
850
|
+
finishReason = toolTurnFinishReason(finishReason, toolCalls);
|
|
895
851
|
sawFinishReason = true;
|
|
896
852
|
} else if (refusalSeen && finishReason !== "content_filter") {
|
|
897
853
|
finishReason = "refusal";
|
|
@@ -901,26 +857,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
901
857
|
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
902
858
|
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
903
859
|
// (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));
|
|
860
|
+
const cleanThinking = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
861
|
+
const incomplete = incompleteStreamCheck({
|
|
862
|
+
provider: "openrouter",
|
|
863
|
+
modelId: clean,
|
|
864
|
+
hasTerminal,
|
|
865
|
+
empty: !text && !cleanThinking && toolCalls.length === 0,
|
|
866
|
+
unfinishedCall: hasUnfinishedToolCall(calls.values(), toolCalls),
|
|
867
|
+
responseHeaders,
|
|
868
|
+
framesSeen,
|
|
869
|
+
badFrames,
|
|
870
|
+
});
|
|
871
|
+
if (incomplete) {
|
|
872
|
+
eventStream.fail(incomplete);
|
|
920
873
|
return;
|
|
921
874
|
}
|
|
922
|
-
|
|
923
|
-
const cleanThinking = cleanThinkingEarly;
|
|
924
875
|
const finalResponse = new AgentResponse({
|
|
925
876
|
text,
|
|
926
877
|
thinking: cleanThinking || undefined,
|
|
@@ -940,21 +891,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
940
891
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|
|
941
892
|
eventStream.end(finalResponse);
|
|
942
893
|
} 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
|
-
);
|
|
894
|
+
failStream(eventStream, linked, err);
|
|
951
895
|
} finally {
|
|
952
|
-
|
|
953
|
-
options?.signal?.removeEventListener("abort", forwardUserAbort);
|
|
954
|
-
} catch {}
|
|
955
|
-
try {
|
|
956
|
-
removeStreamCancel();
|
|
957
|
-
} catch {}
|
|
896
|
+
release();
|
|
958
897
|
}
|
|
959
898
|
})();
|
|
960
899
|
|
|
@@ -42,6 +42,7 @@ const FIRST_CLASS = new Set([
|
|
|
42
42
|
// resolve anywhere.
|
|
43
43
|
const REMOVED_PROVIDERS = new Set(["opencode", "opencode-go"]);
|
|
44
44
|
|
|
45
|
+
// The error text for a removed provider prefix; in: assertProviderNotRemoved, out: a message.
|
|
45
46
|
function removedProviderMessage(id: string): string {
|
|
46
47
|
return (
|
|
47
48
|
`[Agent Accelerator] Provider "${id}" has been removed: OpenCode free models are ` +
|