agent-accelerator 0.3.0 → 0.4.5
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/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* `references/testings/openrouter/CAPABILITY-MATRIX.md`.
|
|
11
11
|
*
|
|
12
12
|
* This is the STABLE transport. The beta Responses skin
|
|
13
|
-
*
|
|
13
|
+
* was removed from core: no video shape,
|
|
14
14
|
* lossy error codes, beta event-vocabulary drift.
|
|
15
15
|
*
|
|
16
16
|
* Notes:
|
|
@@ -40,13 +40,26 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
|
40
40
|
import { SSEParser } from "../streaming/sse-parser.ts";
|
|
41
41
|
import { AgentResponse } from "../types/response.ts";
|
|
42
42
|
import { getApiKey, getEnv } from "../utils/env.ts";
|
|
43
|
-
import { buildSessionHeaders } from "../utils/headers.ts";
|
|
43
|
+
import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.ts";
|
|
44
44
|
import { normalizeMediaInput } from "../utils/media.ts";
|
|
45
45
|
import { safeStringify } from "../utils/serialization.ts";
|
|
46
46
|
import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
|
|
47
47
|
import { withRetries } from "../utils/retry.ts";
|
|
48
|
+
import {
|
|
49
|
+
parseArguments,
|
|
50
|
+
normalizeThinkingParts,
|
|
51
|
+
newToolCallId,
|
|
52
|
+
redactedHeaders,
|
|
53
|
+
readErrorPayload,
|
|
54
|
+
incompleteStreamError,
|
|
55
|
+
hasTruncatedToolArguments,
|
|
56
|
+
combinedSignal,
|
|
57
|
+
timeoutFor,
|
|
58
|
+
hasAuthHeader,
|
|
59
|
+
clampToolCallId,
|
|
60
|
+
} from "./shared.ts";
|
|
48
61
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
49
|
-
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
62
|
+
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
50
63
|
import {
|
|
51
64
|
mapThinkingLevelToOpenRouterChat,
|
|
52
65
|
mapServiceTierToOpenRouter,
|
|
@@ -54,6 +67,10 @@ import {
|
|
|
54
67
|
mapToolChoiceToOpenRouterChat,
|
|
55
68
|
noteProviderTurn,
|
|
56
69
|
parseStreamedToolArguments,
|
|
70
|
+
mapOutputToOpenRouterChat,
|
|
71
|
+
normalizeStructuredOutput,
|
|
72
|
+
augmentSystemPromptWithStructuredOutput,
|
|
73
|
+
emitProviderWarning,
|
|
57
74
|
} from "../providers.ts";
|
|
58
75
|
|
|
59
76
|
// ---------------------------------------------------------------------------
|
|
@@ -92,6 +109,7 @@ interface ChatRequestBody {
|
|
|
92
109
|
service_tier?: string;
|
|
93
110
|
session_id?: string;
|
|
94
111
|
plugins?: Array<{ id: string }>;
|
|
112
|
+
response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
95
113
|
stream?: boolean;
|
|
96
114
|
[k: string]: unknown;
|
|
97
115
|
}
|
|
@@ -156,6 +174,12 @@ interface ChatResponse {
|
|
|
156
174
|
|
|
157
175
|
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
158
176
|
|
|
177
|
+
/** Q-18: Chat transport never echoes thought signatures (no wire field).
|
|
178
|
+
* Local helper documents the namespacing decision per provider file. */
|
|
179
|
+
function withProviderSignature(_sig: string | undefined): { thoughtSignature?: undefined } {
|
|
180
|
+
return {};
|
|
181
|
+
}
|
|
182
|
+
|
|
159
183
|
/** Internal headers that must never leak onto native REST requests. */
|
|
160
184
|
const INTERNAL_HEADERS = new Set([
|
|
161
185
|
"x-thought-signature-map",
|
|
@@ -177,7 +201,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
177
201
|
}
|
|
178
202
|
|
|
179
203
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
180
|
-
|
|
204
|
+
const key = getApiKey("openrouter", options?.apiKey, options?.env);
|
|
205
|
+
if (key) return key;
|
|
206
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
207
|
+
return undefined;
|
|
181
208
|
}
|
|
182
209
|
|
|
183
210
|
/**
|
|
@@ -336,11 +363,17 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
336
363
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
337
364
|
}
|
|
338
365
|
let hasFiles = false;
|
|
366
|
+
let lastRole: string | null = "system";
|
|
339
367
|
for (const m of context.messages) {
|
|
340
368
|
if (m.role === "system") continue;
|
|
341
369
|
if (m.role === "user") {
|
|
370
|
+
// Strict routes reject `user` directly after `tool`: bridge with a
|
|
371
|
+
// synthetic assistant turn (pi api/openai-completions.ts:1233).
|
|
372
|
+
if (lastRole === "tool") {
|
|
373
|
+
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
374
|
+
}
|
|
342
375
|
if (typeof m.content === "string") {
|
|
343
|
-
if (m.content) messages.push({ role: "user", content: m.content });
|
|
376
|
+
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
344
377
|
} else {
|
|
345
378
|
const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
|
|
346
379
|
if (hf) hasFiles = true;
|
|
@@ -348,7 +381,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
348
381
|
for (const url of fileUrls) {
|
|
349
382
|
blocks.push({ type: "text", text: url });
|
|
350
383
|
}
|
|
351
|
-
if (blocks.length > 0) messages.push({ role: "user", content: blocks });
|
|
384
|
+
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
352
385
|
}
|
|
353
386
|
} else if (m.role === "assistant") {
|
|
354
387
|
if (typeof m.content === "string") {
|
|
@@ -362,7 +395,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
362
395
|
for (const part of m.content) {
|
|
363
396
|
if (part.type === "tool_call") {
|
|
364
397
|
calls.push({
|
|
365
|
-
id: part.callId || part.id,
|
|
398
|
+
id: clampToolCallId(part.callId || part.id),
|
|
366
399
|
type: "function",
|
|
367
400
|
function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
|
|
368
401
|
});
|
|
@@ -372,20 +405,25 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
372
405
|
// Prior-turn reasoning is NOT resent: chat history is messages +
|
|
373
406
|
// tool calls only (documented decision; avoids strict-route 400s).
|
|
374
407
|
}
|
|
408
|
+
// Skip empty assistant turns (no text + no tool calls): strict
|
|
409
|
+
// routes reject them, and they carry no signal (pi drop rule).
|
|
410
|
+
if (texts.length === 0 && calls.length === 0) continue;
|
|
375
411
|
messages.push({
|
|
376
412
|
role: "assistant",
|
|
377
413
|
content: texts.length > 0 ? texts.join("\n") : null,
|
|
378
414
|
...(calls.length > 0 ? { tool_calls: calls } : {}),
|
|
379
415
|
});
|
|
416
|
+
lastRole = "assistant";
|
|
380
417
|
} else if (m.role === "tool") {
|
|
381
418
|
if (typeof m.content === "string") {
|
|
382
419
|
const queue = (m.name && idsByName.get(m.name)) || [];
|
|
383
420
|
messages.push({
|
|
384
421
|
role: "tool",
|
|
385
|
-
tool_call_id: queue.shift() ||
|
|
422
|
+
tool_call_id: clampToolCallId(queue.shift() || newToolCallId()),
|
|
386
423
|
content: m.content,
|
|
387
424
|
name: m.name,
|
|
388
425
|
});
|
|
426
|
+
lastRole = "tool";
|
|
389
427
|
continue;
|
|
390
428
|
}
|
|
391
429
|
if (!Array.isArray(m.content)) continue;
|
|
@@ -393,10 +431,11 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
393
431
|
if (part.type === "tool_result") {
|
|
394
432
|
messages.push({
|
|
395
433
|
role: "tool",
|
|
396
|
-
tool_call_id: pairing.get(part.id) || part.id,
|
|
434
|
+
tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
|
|
397
435
|
content: resultToOutput(part.result),
|
|
398
436
|
name: part.name,
|
|
399
437
|
});
|
|
438
|
+
lastRole = "tool";
|
|
400
439
|
}
|
|
401
440
|
}
|
|
402
441
|
}
|
|
@@ -429,27 +468,6 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
|
|
|
429
468
|
return usage;
|
|
430
469
|
}
|
|
431
470
|
|
|
432
|
-
function parseArguments(raw: string | undefined): Record<string, unknown> {
|
|
433
|
-
if (!raw) return {};
|
|
434
|
-
try {
|
|
435
|
-
const parsed: unknown = JSON.parse(raw);
|
|
436
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
437
|
-
return parsed as Record<string, unknown>;
|
|
438
|
-
}
|
|
439
|
-
return { raw };
|
|
440
|
-
} catch {
|
|
441
|
-
return { raw };
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** Trims/collapses assembled thinking parts (no edge-tripling). */
|
|
446
|
-
function normalizeThinkingParts(parts: string[]): string | undefined {
|
|
447
|
-
const cleaned = parts
|
|
448
|
-
.map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
|
|
449
|
-
.filter((p) => p.length > 0);
|
|
450
|
-
return cleaned.length > 0 ? cleaned.join("\n") : undefined;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
471
|
function throwResponseError(response: ChatResponse, modelId: string): void {
|
|
454
472
|
const message =
|
|
455
473
|
response.error && typeof response.error.message === "string" && response.error.message
|
|
@@ -476,7 +494,9 @@ function parseResponse(
|
|
|
476
494
|
|
|
477
495
|
const choice = response.choices?.[0];
|
|
478
496
|
const message = choice?.message;
|
|
479
|
-
|
|
497
|
+
// Q-23: refusal content surfaces as text with a `refusal` finish reason.
|
|
498
|
+
const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
|
|
499
|
+
const text = typeof message?.content === "string" && message.content ? message.content : refusal;
|
|
480
500
|
// Thinking: `reasoning_details[].text` duplicates `message.reasoning` when
|
|
481
501
|
// both ride along (observed live) — prefer details, else the plain field.
|
|
482
502
|
const thinkingParts: string[] = [];
|
|
@@ -493,7 +513,7 @@ function parseResponse(
|
|
|
493
513
|
for (const tc of message?.tool_calls ?? []) {
|
|
494
514
|
const args = parseArguments(tc.function?.arguments);
|
|
495
515
|
toolCalls.push({
|
|
496
|
-
id: tc.id
|
|
516
|
+
id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
|
|
497
517
|
name: tc.function?.name || "unknown",
|
|
498
518
|
arguments: args,
|
|
499
519
|
rawArguments:
|
|
@@ -504,15 +524,17 @@ function parseResponse(
|
|
|
504
524
|
}
|
|
505
525
|
|
|
506
526
|
// Normalized finish reasons: tool_calls|stop|length|content_filter|error.
|
|
527
|
+
// Q-23: refusal content forces `refusal` (even when the wire says stop).
|
|
507
528
|
const wireReason = choice?.finish_reason ?? undefined;
|
|
529
|
+
const finishReason =
|
|
530
|
+
toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
|
|
508
531
|
return {
|
|
509
532
|
text,
|
|
510
533
|
thinking: normalizeThinkingParts(thinkingParts),
|
|
511
534
|
thoughtSignature: undefined,
|
|
512
535
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
513
536
|
usage: mapUsage(response.usage),
|
|
514
|
-
finishReason
|
|
515
|
-
toolCalls.length > 0 ? "tool_calls" : wireReason || "stop",
|
|
537
|
+
finishReason,
|
|
516
538
|
responseId: response.id,
|
|
517
539
|
model: modelId,
|
|
518
540
|
provider: "openrouter",
|
|
@@ -521,33 +543,6 @@ function parseResponse(
|
|
|
521
543
|
};
|
|
522
544
|
}
|
|
523
545
|
|
|
524
|
-
function redactedHeaders(headers: Record<string, string>): Record<string, string> {
|
|
525
|
-
const out: Record<string, string> = {};
|
|
526
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
527
|
-
out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
|
|
528
|
-
}
|
|
529
|
-
return out;
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function readErrorPayload(bodyText: string): { message: string; code?: string | number; errorType?: string } {
|
|
533
|
-
try {
|
|
534
|
-
const parsed: unknown = JSON.parse(bodyText);
|
|
535
|
-
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
536
|
-
const err = (first as { error?: { message?: string; code?: string | number; metadata?: { error_type?: string } } })?.error;
|
|
537
|
-
if (err && typeof err.message === "string") {
|
|
538
|
-
const errorType = (err as { metadata?: { error_type?: string } })?.metadata?.error_type;
|
|
539
|
-
return {
|
|
540
|
-
message: err.message,
|
|
541
|
-
code: err.code,
|
|
542
|
-
...(typeof errorType === "string" ? { errorType } : {}),
|
|
543
|
-
};
|
|
544
|
-
}
|
|
545
|
-
return { message: bodyText.slice(0, 300) };
|
|
546
|
-
} catch {
|
|
547
|
-
return { message: bodyText.slice(0, 300) };
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
|
|
551
546
|
// ---------------------------------------------------------------------------
|
|
552
547
|
// Provider
|
|
553
548
|
// ---------------------------------------------------------------------------
|
|
@@ -587,7 +582,33 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
587
582
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
588
583
|
applyCacheForOpenRouter(options?.cache, `openrouter/${modelId}`);
|
|
589
584
|
|
|
590
|
-
|
|
585
|
+
// Structured outputs: native `response_format` when supported, else
|
|
586
|
+
// emulated via system-prompt instruction + client validation (catalog
|
|
587
|
+
// `structured_output: false` models would 400 on the wire shape).
|
|
588
|
+
let effectiveContext = context;
|
|
589
|
+
let outputEmulated = false;
|
|
590
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
591
|
+
try {
|
|
592
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
593
|
+
} catch {
|
|
594
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
595
|
+
outputSpec = undefined;
|
|
596
|
+
}
|
|
597
|
+
if (outputSpec && !modelSupportsStructuredOutput("openrouter", modelId)) {
|
|
598
|
+
outputEmulated = true;
|
|
599
|
+
effectiveContext = {
|
|
600
|
+
...context,
|
|
601
|
+
systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
|
|
602
|
+
};
|
|
603
|
+
emitProviderWarning({
|
|
604
|
+
provider: "openrouter",
|
|
605
|
+
capability: "structured output",
|
|
606
|
+
requested: `${outputSpec.name} (openrouter/${modelId})`,
|
|
607
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
608
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
|
|
591
612
|
const body: ChatRequestBody = {
|
|
592
613
|
model: modelId,
|
|
593
614
|
messages,
|
|
@@ -601,12 +622,23 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
601
622
|
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
602
623
|
if (effort) body.reasoning = { effort };
|
|
603
624
|
if (serviceTier) body.service_tier = serviceTier;
|
|
625
|
+
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
626
|
+
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
627
|
+
if (outputSpec && !outputEmulated) {
|
|
628
|
+
try {
|
|
629
|
+
const mapped = mapOutputToOpenRouterChat(outputSpec);
|
|
630
|
+
if (mapped) body.response_format = mapped.response_format;
|
|
631
|
+
} catch {
|
|
632
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
633
|
+
}
|
|
634
|
+
}
|
|
604
635
|
// PDF ingestion is a documented plugin, not a message shape: enable it
|
|
605
636
|
// only when file parts are present (never by default).
|
|
606
637
|
if (hasFiles) body.plugins = [{ id: "file-parser" }];
|
|
607
|
-
// No temperature/top_p/max_tokens/stop/
|
|
638
|
+
// No temperature/top_p/max_tokens/stop/user/seed knobs
|
|
608
639
|
// (no canonical options exist; omitting also keeps provider cache keys
|
|
609
|
-
// stable).
|
|
640
|
+
// stable). `response_format` above is the sole structured-output exception.
|
|
641
|
+
// No store/previous_response_id/conversation: they do not exist
|
|
610
642
|
// on this endpoint.
|
|
611
643
|
// Session affinity: top-level `session_id` is the documented sticky-routing
|
|
612
644
|
// key on Chat Completions (prompt-caching guide) and takes precedence over
|
|
@@ -635,8 +667,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
635
667
|
for (const [k, v] of Object.entries(headers)) {
|
|
636
668
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
637
669
|
}
|
|
638
|
-
headers
|
|
639
|
-
headers
|
|
670
|
+
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
671
|
+
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
640
672
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
641
673
|
}
|
|
642
674
|
|
|
@@ -648,13 +680,18 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
648
680
|
sessionId: string | undefined,
|
|
649
681
|
signal?: AbortSignal
|
|
650
682
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
headers
|
|
656
|
-
|
|
657
|
-
|
|
683
|
+
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
684
|
+
try {
|
|
685
|
+
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
686
|
+
const text = await res.text();
|
|
687
|
+
const headers: Record<string, string> = {};
|
|
688
|
+
res.headers.forEach((v, k) => {
|
|
689
|
+
headers[k] = v;
|
|
690
|
+
});
|
|
691
|
+
return { status: res.status, statusText: res.statusText, headers, text };
|
|
692
|
+
} finally {
|
|
693
|
+
cleanup();
|
|
694
|
+
}
|
|
658
695
|
}
|
|
659
696
|
|
|
660
697
|
private throwIfError(
|
|
@@ -664,14 +701,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
664
701
|
modelId: string
|
|
665
702
|
): void {
|
|
666
703
|
if (status >= 200 && status < 300) return;
|
|
667
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
668
|
-
const
|
|
704
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
705
|
+
const hint =
|
|
706
|
+
status === 401
|
|
707
|
+
? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
|
|
708
|
+
: "";
|
|
709
|
+
const fullMessage = `${message}${hint}`;
|
|
710
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
669
711
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
670
712
|
(err as Record<string, unknown>)["status"] = status;
|
|
671
713
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
672
714
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
715
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
673
716
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
674
717
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
718
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
675
719
|
throw toConciseProviderError(err, "openrouter", modelId);
|
|
676
720
|
}
|
|
677
721
|
|
|
@@ -814,7 +858,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
814
858
|
body,
|
|
815
859
|
};
|
|
816
860
|
|
|
817
|
-
const
|
|
861
|
+
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
862
|
+
const streamTimeoutTimer =
|
|
863
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
864
|
+
? setTimeout(() => {
|
|
865
|
+
try {
|
|
866
|
+
linked.abort();
|
|
867
|
+
} catch {}
|
|
868
|
+
}, streamTimeoutMs)
|
|
869
|
+
: undefined;
|
|
870
|
+
let res: Response;
|
|
871
|
+
try {
|
|
872
|
+
res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
873
|
+
} finally {
|
|
874
|
+
if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
|
|
875
|
+
}
|
|
818
876
|
if (!res.ok || !res.body) {
|
|
819
877
|
const text = !res.ok ? await res.text().catch(() => "") : "";
|
|
820
878
|
if (!res.ok) this.throwIfError(res.status, url, text, clean);
|
|
@@ -824,7 +882,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
824
882
|
res.headers.forEach((v, k) => {
|
|
825
883
|
responseHeaders[k] = v;
|
|
826
884
|
});
|
|
827
|
-
const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
885
|
+
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
828
886
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
829
887
|
|
|
830
888
|
const parser = new SSEParser();
|
|
@@ -839,6 +897,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
839
897
|
let lastToolIndex = 0;
|
|
840
898
|
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
841
899
|
let finishReason = "stop";
|
|
900
|
+
let sawFinishReason = false;
|
|
901
|
+
let sawUsage = false;
|
|
902
|
+
let refusalSeen = false;
|
|
903
|
+
let badFrames = 0;
|
|
904
|
+
let framesSeen = 0;
|
|
842
905
|
let responseId: string | undefined;
|
|
843
906
|
let completedBody: unknown = undefined;
|
|
844
907
|
let aborted = false;
|
|
@@ -854,11 +917,17 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
854
917
|
);
|
|
855
918
|
|
|
856
919
|
const handleMessage = (data: string): void => {
|
|
857
|
-
if (!data
|
|
920
|
+
if (!data) return;
|
|
921
|
+
if (data === "[DONE]") {
|
|
922
|
+
sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
858
925
|
let msg: Record<string, unknown>;
|
|
859
926
|
try {
|
|
860
927
|
msg = JSON.parse(data) as Record<string, unknown>;
|
|
928
|
+
framesSeen += 1;
|
|
861
929
|
} catch {
|
|
930
|
+
badFrames += 1;
|
|
862
931
|
return;
|
|
863
932
|
}
|
|
864
933
|
// Mid-stream provider error: top-level `error`, HTTP stays 200.
|
|
@@ -881,6 +950,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
881
950
|
const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
|
|
882
951
|
const delta = (choice["delta"] as Record<string, unknown>) ?? {};
|
|
883
952
|
// Text delta (content-free accounting frames carry "" — ignored).
|
|
953
|
+
// Q-23: refusal deltas surface as text with a `refusal` finish.
|
|
954
|
+
const deltaRefusal = delta["refusal"];
|
|
955
|
+
if (typeof deltaRefusal === "string" && deltaRefusal) {
|
|
956
|
+
text += deltaRefusal;
|
|
957
|
+
refusalSeen = true;
|
|
958
|
+
eventStream.push({ type: "text_delta", delta: deltaRefusal, partialText: text });
|
|
959
|
+
}
|
|
884
960
|
if (typeof delta["content"] === "string" && delta["content"]) {
|
|
885
961
|
text += delta["content"] as string;
|
|
886
962
|
eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
|
|
@@ -950,10 +1026,12 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
950
1026
|
throw toConciseProviderError(failure, "openrouter", clean);
|
|
951
1027
|
}
|
|
952
1028
|
finishReason = fr;
|
|
1029
|
+
sawFinishReason = true;
|
|
953
1030
|
}
|
|
954
1031
|
const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
|
|
955
1032
|
if (chunkUsage) {
|
|
956
1033
|
usage = mapUsage(chunkUsage);
|
|
1034
|
+
sawUsage = true;
|
|
957
1035
|
completedBody = completedBody ?? msg;
|
|
958
1036
|
eventStream.push({ type: "usage", usage });
|
|
959
1037
|
}
|
|
@@ -984,7 +1062,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
984
1062
|
for (const c of calls.values()) {
|
|
985
1063
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
986
1064
|
const record: ToolCallRecord = {
|
|
987
|
-
id: c.id
|
|
1065
|
+
id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
|
|
988
1066
|
name: c.name,
|
|
989
1067
|
arguments: args,
|
|
990
1068
|
rawArguments: c.startArgs + c.deltaArgs,
|
|
@@ -994,9 +1072,37 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
994
1072
|
}
|
|
995
1073
|
|
|
996
1074
|
noteProviderTurn(sessionId, "openrouter");
|
|
997
|
-
if (toolCalls.length > 0)
|
|
1075
|
+
if (toolCalls.length > 0) {
|
|
1076
|
+
finishReason = "tool_calls";
|
|
1077
|
+
sawFinishReason = true;
|
|
1078
|
+
} else if (refusalSeen && finishReason !== "content_filter") {
|
|
1079
|
+
finishReason = "refusal";
|
|
1080
|
+
sawFinishReason = true;
|
|
1081
|
+
}
|
|
998
1082
|
|
|
999
|
-
|
|
1083
|
+
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
1084
|
+
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
1085
|
+
// (JSON mocks) where no SSE frames were ever seen.
|
|
1086
|
+
const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
|
|
1087
|
+
const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
1088
|
+
const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
|
|
1089
|
+
const isSSEPayload =
|
|
1090
|
+
(typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
|
|
1091
|
+
framesSeen > 0 ||
|
|
1092
|
+
badFrames > 0;
|
|
1093
|
+
if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
|
|
1094
|
+
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
// Refuse unfinished tool calls whose args never parsed (output_item.done
|
|
1098
|
+
// never arrived): truncated `{raw}` must not reach the executor (pi
|
|
1099
|
+
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1100
|
+
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1101
|
+
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
const cleanThinking = cleanThinkingEarly;
|
|
1000
1106
|
const finalResponse = new AgentResponse({
|
|
1001
1107
|
text,
|
|
1002
1108
|
thinking: cleanThinking || undefined,
|
|
@@ -1007,7 +1113,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1007
1113
|
responseId,
|
|
1008
1114
|
model: clean,
|
|
1009
1115
|
provider: "openrouter",
|
|
1010
|
-
raw: {
|
|
1116
|
+
raw: {
|
|
1117
|
+
request: rawRequest,
|
|
1118
|
+
response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
|
|
1119
|
+
},
|
|
1011
1120
|
durationMs: Date.now() - startTime,
|
|
1012
1121
|
});
|
|
1013
1122
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|
|
@@ -80,12 +80,25 @@ export function ensureCustomProvider(
|
|
|
80
80
|
): Provider {
|
|
81
81
|
assertProviderNotRemoved(prefix);
|
|
82
82
|
const id = normalizeProviderPrefix(prefix) as ProviderId;
|
|
83
|
+
if (FIRST_CLASS.has(id.toLowerCase())) {
|
|
84
|
+
if (opts?.baseUrl !== undefined || opts?.apiKey !== undefined) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`[Agent Accelerator] Provider "${id}" is first-class and cannot be replaced with custom baseUrl/apiKey. Pass baseUrl/apiKey per-Agent instead.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return providerRegistry.get(id)!;
|
|
90
|
+
}
|
|
83
91
|
const existing = providerRegistry.get(id);
|
|
84
|
-
if (existing
|
|
85
|
-
|
|
92
|
+
if (existing) {
|
|
93
|
+
const prevBase = (existing as { configuredBaseUrl?: string }).configuredBaseUrl;
|
|
94
|
+
if (opts?.baseUrl !== undefined && prevBase !== undefined && prevBase !== opts.baseUrl) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`[Agent Accelerator] Conflicting re-registration for provider "${id}". Registry is immutable after creation.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
86
99
|
return existing;
|
|
87
100
|
}
|
|
88
|
-
const created = new OpenAICompatibleChatProvider(id, opts);
|
|
101
|
+
const created = new OpenAICompatibleChatProvider(id, { baseUrl: opts?.baseUrl, name: opts?.name });
|
|
89
102
|
providerRegistry.set(id, created);
|
|
90
103
|
return created;
|
|
91
104
|
}
|
|
@@ -199,11 +212,6 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
199
212
|
parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
|
|
200
213
|
|
|
201
214
|
if (!looksLikeOpenRouterScope) {
|
|
202
|
-
if (hasCustomOpenAIBase && !hasDedicatedEnv) {
|
|
203
|
-
const p = getProvider("openai");
|
|
204
|
-
const spec = p.getModel(remainingModel) || getModelFromCatalog(remainingModel, remainingModel);
|
|
205
|
-
return { provider: p, modelId: remainingModel, modelSpec: spec as ModelSpec };
|
|
206
|
-
}
|
|
207
215
|
const custom = ensureCustomProvider(providerPrefix);
|
|
208
216
|
const spec =
|
|
209
217
|
custom.getModel(remainingModel) ||
|
|
@@ -372,10 +380,6 @@ export const ModelProvider = {
|
|
|
372
380
|
options?: { thinkingLevel?: ThinkingLevel; baseUrl?: string }
|
|
373
381
|
): ModelProviderInstance {
|
|
374
382
|
const normalized = model.includes("/") ? model : `custom/${model}`;
|
|
375
|
-
const prefix = normalized.split("/")[0]!;
|
|
376
|
-
try {
|
|
377
|
-
ensureCustomProvider(prefix, { baseUrl: options?.baseUrl, apiKey });
|
|
378
|
-
} catch {}
|
|
379
383
|
return {
|
|
380
384
|
model: normalized,
|
|
381
385
|
apiKey,
|