agent-accelerator 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -14
- package/package.json +1 -1
- package/src/agent/agent.ts +1010 -104
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +758 -40
- package/src/agent/loop.ts +811 -102
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +186 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +101 -21
- package/src/providers/google.ts +99 -39
- package/src/providers/openai-compat.ts +140 -80
- package/src/providers/openai.ts +149 -69
- package/src/providers/openrouter.ts +106 -71
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +132 -0
- package/src/providers.ts +14 -13
- package/src/session/store.ts +1285 -0
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +252 -16
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +253 -2
- package/src/types/core.ts +2 -1
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +1 -1
- package/src/types/response.ts +150 -3
- package/src/types/tool.ts +71 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +2 -6
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +320 -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,11 +40,21 @@ 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
|
+
combinedSignal,
|
|
56
|
+
timeoutFor,
|
|
57
|
+
} from "./shared.ts";
|
|
48
58
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
49
59
|
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
50
60
|
import {
|
|
@@ -156,6 +166,12 @@ interface ChatResponse {
|
|
|
156
166
|
|
|
157
167
|
const DEFAULT_BASE_URL = "https://openrouter.ai/api/v1";
|
|
158
168
|
|
|
169
|
+
/** Q-18: Chat transport never echoes thought signatures (no wire field).
|
|
170
|
+
* Local helper documents the namespacing decision per provider file. */
|
|
171
|
+
function withProviderSignature(_sig: string | undefined): { thoughtSignature?: undefined } {
|
|
172
|
+
return {};
|
|
173
|
+
}
|
|
174
|
+
|
|
159
175
|
/** Internal headers that must never leak onto native REST requests. */
|
|
160
176
|
const INTERNAL_HEADERS = new Set([
|
|
161
177
|
"x-thought-signature-map",
|
|
@@ -382,7 +398,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
382
398
|
const queue = (m.name && idsByName.get(m.name)) || [];
|
|
383
399
|
messages.push({
|
|
384
400
|
role: "tool",
|
|
385
|
-
tool_call_id: queue.shift() ||
|
|
401
|
+
tool_call_id: queue.shift() || newToolCallId(),
|
|
386
402
|
content: m.content,
|
|
387
403
|
name: m.name,
|
|
388
404
|
});
|
|
@@ -429,27 +445,6 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
|
|
|
429
445
|
return usage;
|
|
430
446
|
}
|
|
431
447
|
|
|
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
448
|
function throwResponseError(response: ChatResponse, modelId: string): void {
|
|
454
449
|
const message =
|
|
455
450
|
response.error && typeof response.error.message === "string" && response.error.message
|
|
@@ -476,7 +471,9 @@ function parseResponse(
|
|
|
476
471
|
|
|
477
472
|
const choice = response.choices?.[0];
|
|
478
473
|
const message = choice?.message;
|
|
479
|
-
|
|
474
|
+
// Q-23: refusal content surfaces as text with a `refusal` finish reason.
|
|
475
|
+
const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
|
|
476
|
+
const text = typeof message?.content === "string" && message.content ? message.content : refusal;
|
|
480
477
|
// Thinking: `reasoning_details[].text` duplicates `message.reasoning` when
|
|
481
478
|
// both ride along (observed live) — prefer details, else the plain field.
|
|
482
479
|
const thinkingParts: string[] = [];
|
|
@@ -493,7 +490,7 @@ function parseResponse(
|
|
|
493
490
|
for (const tc of message?.tool_calls ?? []) {
|
|
494
491
|
const args = parseArguments(tc.function?.arguments);
|
|
495
492
|
toolCalls.push({
|
|
496
|
-
id: tc.id ||
|
|
493
|
+
id: tc.id || newToolCallId(),
|
|
497
494
|
name: tc.function?.name || "unknown",
|
|
498
495
|
arguments: args,
|
|
499
496
|
rawArguments:
|
|
@@ -504,15 +501,17 @@ function parseResponse(
|
|
|
504
501
|
}
|
|
505
502
|
|
|
506
503
|
// Normalized finish reasons: tool_calls|stop|length|content_filter|error.
|
|
504
|
+
// Q-23: refusal content forces `refusal` (even when the wire says stop).
|
|
507
505
|
const wireReason = choice?.finish_reason ?? undefined;
|
|
506
|
+
const finishReason =
|
|
507
|
+
toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
|
|
508
508
|
return {
|
|
509
509
|
text,
|
|
510
510
|
thinking: normalizeThinkingParts(thinkingParts),
|
|
511
511
|
thoughtSignature: undefined,
|
|
512
512
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
513
513
|
usage: mapUsage(response.usage),
|
|
514
|
-
finishReason
|
|
515
|
-
toolCalls.length > 0 ? "tool_calls" : wireReason || "stop",
|
|
514
|
+
finishReason,
|
|
516
515
|
responseId: response.id,
|
|
517
516
|
model: modelId,
|
|
518
517
|
provider: "openrouter",
|
|
@@ -521,33 +520,6 @@ function parseResponse(
|
|
|
521
520
|
};
|
|
522
521
|
}
|
|
523
522
|
|
|
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
523
|
// ---------------------------------------------------------------------------
|
|
552
524
|
// Provider
|
|
553
525
|
// ---------------------------------------------------------------------------
|
|
@@ -635,8 +607,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
635
607
|
for (const [k, v] of Object.entries(headers)) {
|
|
636
608
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
637
609
|
}
|
|
638
|
-
headers
|
|
639
|
-
headers
|
|
610
|
+
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
611
|
+
setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
640
612
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
641
613
|
}
|
|
642
614
|
|
|
@@ -648,13 +620,18 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
648
620
|
sessionId: string | undefined,
|
|
649
621
|
signal?: AbortSignal
|
|
650
622
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
headers
|
|
656
|
-
|
|
657
|
-
|
|
623
|
+
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
624
|
+
try {
|
|
625
|
+
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
626
|
+
const text = await res.text();
|
|
627
|
+
const headers: Record<string, string> = {};
|
|
628
|
+
res.headers.forEach((v, k) => {
|
|
629
|
+
headers[k] = v;
|
|
630
|
+
});
|
|
631
|
+
return { status: res.status, statusText: res.statusText, headers, text };
|
|
632
|
+
} finally {
|
|
633
|
+
cleanup();
|
|
634
|
+
}
|
|
658
635
|
}
|
|
659
636
|
|
|
660
637
|
private throwIfError(
|
|
@@ -814,7 +791,21 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
814
791
|
body,
|
|
815
792
|
};
|
|
816
793
|
|
|
817
|
-
const
|
|
794
|
+
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
795
|
+
const streamTimeoutTimer =
|
|
796
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
797
|
+
? setTimeout(() => {
|
|
798
|
+
try {
|
|
799
|
+
linked.abort();
|
|
800
|
+
} catch {}
|
|
801
|
+
}, streamTimeoutMs)
|
|
802
|
+
: undefined;
|
|
803
|
+
let res: Response;
|
|
804
|
+
try {
|
|
805
|
+
res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
806
|
+
} finally {
|
|
807
|
+
if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
|
|
808
|
+
}
|
|
818
809
|
if (!res.ok || !res.body) {
|
|
819
810
|
const text = !res.ok ? await res.text().catch(() => "") : "";
|
|
820
811
|
if (!res.ok) this.throwIfError(res.status, url, text, clean);
|
|
@@ -824,7 +815,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
824
815
|
res.headers.forEach((v, k) => {
|
|
825
816
|
responseHeaders[k] = v;
|
|
826
817
|
});
|
|
827
|
-
const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
818
|
+
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
828
819
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
829
820
|
|
|
830
821
|
const parser = new SSEParser();
|
|
@@ -839,6 +830,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
839
830
|
let lastToolIndex = 0;
|
|
840
831
|
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
841
832
|
let finishReason = "stop";
|
|
833
|
+
let sawFinishReason = false;
|
|
834
|
+
let sawUsage = false;
|
|
835
|
+
let refusalSeen = false;
|
|
836
|
+
let badFrames = 0;
|
|
837
|
+
let framesSeen = 0;
|
|
842
838
|
let responseId: string | undefined;
|
|
843
839
|
let completedBody: unknown = undefined;
|
|
844
840
|
let aborted = false;
|
|
@@ -854,11 +850,17 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
854
850
|
);
|
|
855
851
|
|
|
856
852
|
const handleMessage = (data: string): void => {
|
|
857
|
-
if (!data
|
|
853
|
+
if (!data) return;
|
|
854
|
+
if (data === "[DONE]") {
|
|
855
|
+
sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
858
|
let msg: Record<string, unknown>;
|
|
859
859
|
try {
|
|
860
860
|
msg = JSON.parse(data) as Record<string, unknown>;
|
|
861
|
+
framesSeen += 1;
|
|
861
862
|
} catch {
|
|
863
|
+
badFrames += 1;
|
|
862
864
|
return;
|
|
863
865
|
}
|
|
864
866
|
// Mid-stream provider error: top-level `error`, HTTP stays 200.
|
|
@@ -881,6 +883,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
881
883
|
const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
|
|
882
884
|
const delta = (choice["delta"] as Record<string, unknown>) ?? {};
|
|
883
885
|
// Text delta (content-free accounting frames carry "" — ignored).
|
|
886
|
+
// Q-23: refusal deltas surface as text with a `refusal` finish.
|
|
887
|
+
const deltaRefusal = delta["refusal"];
|
|
888
|
+
if (typeof deltaRefusal === "string" && deltaRefusal) {
|
|
889
|
+
text += deltaRefusal;
|
|
890
|
+
refusalSeen = true;
|
|
891
|
+
eventStream.push({ type: "text_delta", delta: deltaRefusal, partialText: text });
|
|
892
|
+
}
|
|
884
893
|
if (typeof delta["content"] === "string" && delta["content"]) {
|
|
885
894
|
text += delta["content"] as string;
|
|
886
895
|
eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
|
|
@@ -950,10 +959,12 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
950
959
|
throw toConciseProviderError(failure, "openrouter", clean);
|
|
951
960
|
}
|
|
952
961
|
finishReason = fr;
|
|
962
|
+
sawFinishReason = true;
|
|
953
963
|
}
|
|
954
964
|
const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
|
|
955
965
|
if (chunkUsage) {
|
|
956
966
|
usage = mapUsage(chunkUsage);
|
|
967
|
+
sawUsage = true;
|
|
957
968
|
completedBody = completedBody ?? msg;
|
|
958
969
|
eventStream.push({ type: "usage", usage });
|
|
959
970
|
}
|
|
@@ -984,7 +995,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
984
995
|
for (const c of calls.values()) {
|
|
985
996
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
986
997
|
const record: ToolCallRecord = {
|
|
987
|
-
id: c.id ||
|
|
998
|
+
id: c.id || newToolCallId(),
|
|
988
999
|
name: c.name,
|
|
989
1000
|
arguments: args,
|
|
990
1001
|
rawArguments: c.startArgs + c.deltaArgs,
|
|
@@ -994,9 +1005,30 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
994
1005
|
}
|
|
995
1006
|
|
|
996
1007
|
noteProviderTurn(sessionId, "openrouter");
|
|
997
|
-
if (toolCalls.length > 0)
|
|
1008
|
+
if (toolCalls.length > 0) {
|
|
1009
|
+
finishReason = "tool_calls";
|
|
1010
|
+
sawFinishReason = true;
|
|
1011
|
+
} else if (refusalSeen && finishReason !== "content_filter") {
|
|
1012
|
+
finishReason = "refusal";
|
|
1013
|
+
sawFinishReason = true;
|
|
1014
|
+
}
|
|
998
1015
|
|
|
999
|
-
|
|
1016
|
+
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
1017
|
+
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
1018
|
+
// (JSON mocks) where no SSE frames were ever seen.
|
|
1019
|
+
const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
|
|
1020
|
+
const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
1021
|
+
const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
|
|
1022
|
+
const isSSEPayload =
|
|
1023
|
+
(typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
|
|
1024
|
+
framesSeen > 0 ||
|
|
1025
|
+
badFrames > 0;
|
|
1026
|
+
if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
|
|
1027
|
+
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
const cleanThinking = cleanThinkingEarly;
|
|
1000
1032
|
const finalResponse = new AgentResponse({
|
|
1001
1033
|
text,
|
|
1002
1034
|
thinking: cleanThinking || undefined,
|
|
@@ -1007,7 +1039,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1007
1039
|
responseId,
|
|
1008
1040
|
model: clean,
|
|
1009
1041
|
provider: "openrouter",
|
|
1010
|
-
raw: {
|
|
1042
|
+
raw: {
|
|
1043
|
+
request: rawRequest,
|
|
1044
|
+
response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
|
|
1045
|
+
},
|
|
1011
1046
|
durationMs: Date.now() - startTime,
|
|
1012
1047
|
});
|
|
1013
1048
|
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,
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared provider helpers (Q-45-partial).
|
|
3
|
+
*
|
|
4
|
+
* Single home for the `redactedHeaders` / `readErrorPayload` /
|
|
5
|
+
* `parseArguments` / `normalizeThinkingParts` helpers previously duplicated
|
|
6
|
+
* across the four REST transports, plus timeout/signal and id helpers.
|
|
7
|
+
*/
|
|
8
|
+
import { toConciseProviderError } from "../utils/errors.ts";
|
|
9
|
+
|
|
10
|
+
/** Parses a JSON tool-argument string; non-objects degrade to `{ raw }`. */
|
|
11
|
+
export function parseArguments(raw: string | undefined): Record<string, unknown> {
|
|
12
|
+
if (!raw) return {};
|
|
13
|
+
try {
|
|
14
|
+
const parsed: unknown = JSON.parse(raw);
|
|
15
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
16
|
+
return parsed as Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
return { raw };
|
|
19
|
+
} catch {
|
|
20
|
+
return { raw };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Trims/collapses assembled thinking parts (no edge-tripling). */
|
|
25
|
+
export function normalizeThinkingParts(parts: string[]): string | undefined {
|
|
26
|
+
const cleaned = parts
|
|
27
|
+
.map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
|
|
28
|
+
.filter((p) => p.length > 0);
|
|
29
|
+
return cleaned.length > 0 ? cleaned.join("\n") : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Redacts secret-bearing headers for the raw audit trail. Superset of the
|
|
34
|
+
* four adapter versions: `authorization` (any case) and `x-goog-api-key` are
|
|
35
|
+
* redacted; all other headers pass through untouched.
|
|
36
|
+
*/
|
|
37
|
+
export function redactedHeaders(headers: Record<string, string>): Record<string, string> {
|
|
38
|
+
const out: Record<string, string> = {};
|
|
39
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
40
|
+
const lower = k.toLowerCase();
|
|
41
|
+
out[k] = lower === "authorization" || lower === "x-goog-api-key" ? "[REDACTED]" : v;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Extracts `{ message, code, errorType }` from a non-2xx JSON body. Handles
|
|
48
|
+
* OpenAI `type`, OpenRouter `metadata.error_type`, and Google `status` code
|
|
49
|
+
* shapes; unknown bodies degrade to a 300-char slice.
|
|
50
|
+
*/
|
|
51
|
+
export function readErrorPayload(
|
|
52
|
+
bodyText: string
|
|
53
|
+
): { message: string; code?: string | number; errorType?: string } {
|
|
54
|
+
try {
|
|
55
|
+
const parsed: unknown = JSON.parse(bodyText);
|
|
56
|
+
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
57
|
+
const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string } } })?.error;
|
|
58
|
+
if (err && typeof err.message === "string") {
|
|
59
|
+
const code = err.code ?? err.status;
|
|
60
|
+
const errorType =
|
|
61
|
+
err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
|
|
62
|
+
return {
|
|
63
|
+
message: err.message,
|
|
64
|
+
...(code !== undefined ? { code } : {}),
|
|
65
|
+
...(typeof errorType === "string" ? { errorType } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { message: bodyText.slice(0, 300) };
|
|
69
|
+
} catch {
|
|
70
|
+
return { message: bodyText.slice(0, 300) };
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Retryable incomplete-stream error for truncated SSE (Q-23). */
|
|
75
|
+
export function incompleteStreamError(provider: string, modelId: string): Error {
|
|
76
|
+
const err = toConciseProviderError(
|
|
77
|
+
Object.assign(new Error("Stream ended without terminal event"), {
|
|
78
|
+
statusCode: 502,
|
|
79
|
+
transient: true,
|
|
80
|
+
retryable: true,
|
|
81
|
+
url: provider,
|
|
82
|
+
}),
|
|
83
|
+
provider,
|
|
84
|
+
modelId
|
|
85
|
+
);
|
|
86
|
+
(err as any).code = "incomplete_stream";
|
|
87
|
+
return err;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Per-request timeout ms from provider options (Q-22). */
|
|
91
|
+
export function timeoutFor(options: { timeoutMs?: number } | undefined): number | undefined {
|
|
92
|
+
const t = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
|
|
93
|
+
return typeof t === "number" && Number.isFinite(t) && t > 0 ? Math.floor(t) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Merges a parent signal with a timeout into one effective signal. Caller must call cleanup when the request settles so the timer never holds the loop open. */
|
|
97
|
+
export function combinedSignal(signal?: AbortSignal, timeoutMs?: number): { signal: AbortSignal | undefined; cleanup: () => void } {
|
|
98
|
+
if (timeoutMs === undefined) return { signal, cleanup: () => {} };
|
|
99
|
+
const ctrl = new AbortController();
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
try { ctrl.abort(); } catch {}
|
|
102
|
+
}, timeoutMs);
|
|
103
|
+
try { (timer as unknown as { unref?: () => void }).unref?.(); } catch {}
|
|
104
|
+
const cleanup = () => {
|
|
105
|
+
try { clearTimeout(timer); } catch {}
|
|
106
|
+
};
|
|
107
|
+
if (signal) {
|
|
108
|
+
if (signal.aborted) {
|
|
109
|
+
cleanup();
|
|
110
|
+
ctrl.abort();
|
|
111
|
+
} else {
|
|
112
|
+
signal.addEventListener("abort", () => {
|
|
113
|
+
cleanup();
|
|
114
|
+
try { ctrl.abort(); } catch {}
|
|
115
|
+
}, { once: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return { signal: ctrl.signal, cleanup };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Q-34: crypto-backed tool-call id, no Math.random primary. */
|
|
122
|
+
export function newToolCallId(prefix = "call"): string {
|
|
123
|
+
try {
|
|
124
|
+
const c = (globalThis as any)?.crypto;
|
|
125
|
+
if (c?.randomUUID) return `${prefix}_${c.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
126
|
+
} catch {}
|
|
127
|
+
try {
|
|
128
|
+
const nc: any = (globalThis as any)?.process?.getBuiltinModule?.("node:crypto") ?? null;
|
|
129
|
+
if (nc?.randomUUID) return `${prefix}_${nc.randomUUID().replace(/-/g, "").slice(0, 12)}`;
|
|
130
|
+
} catch {}
|
|
131
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
|
132
|
+
}
|
package/src/providers.ts
CHANGED
|
@@ -50,9 +50,8 @@ export function emitProviderWarning(options: {
|
|
|
50
50
|
// Leading newline: warnings fire at the start of a turn's request build,
|
|
51
51
|
// when the previous turn's streamed text may have left the terminal cursor
|
|
52
52
|
// mid-line. Without it the warning glues onto streamed output.
|
|
53
|
-
console.warn(
|
|
54
|
-
|
|
55
|
-
);
|
|
53
|
+
// Q-41 dual: telemetry bus (new) + console.warn (compat for existing captureWarnings tests; /cli logger replaces in 0.5).
|
|
54
|
+
{ const msg = `[Agent Accelerator] WARNING [${options.provider}] ${options.capability}${requested}: ${options.reason} Using ${options.fallback} instead.`; try { (globalThis as any).__agentAccelTelemetry?.emitWarning?.({ code: "provider_warning", provider: options.provider, capability: options.capability, message: msg }); } catch {} try { (globalThis as any).__agentAccelWarnSink?.(msg, options); } catch {} try { console.warn(msg); } catch {} }
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
/** Clears deduped-warning state (mainly for tests). */
|
|
@@ -108,7 +107,7 @@ export function clearSessionRouting(sessionId?: string): void {
|
|
|
108
107
|
|
|
109
108
|
/**
|
|
110
109
|
* Maps a canonical ThinkingLevel onto Google Interactions `thinking_level`.
|
|
111
|
-
* Google values: minimal|low|medium|high (no off switch, no xhigh).
|
|
110
|
+
* Google values: minimal|low|medium|high (no off switch, no xhigh/max).
|
|
112
111
|
*/
|
|
113
112
|
export function mapThinkingLevelToGoogle(
|
|
114
113
|
level: ThinkingLevel | string | undefined
|
|
@@ -126,7 +125,7 @@ export function mapThinkingLevelToGoogle(
|
|
|
126
125
|
});
|
|
127
126
|
return {};
|
|
128
127
|
}
|
|
129
|
-
if (norm === "xhigh") {
|
|
128
|
+
if (norm === "xhigh" || norm === "max") {
|
|
130
129
|
emitProviderWarning({
|
|
131
130
|
provider: "google",
|
|
132
131
|
capability: "thinking level",
|
|
@@ -155,7 +154,7 @@ export function mapServiceTierToGoogle(tier: ServiceTier | undefined): "flex" |
|
|
|
155
154
|
* Completions documents `reasoning_effort: xhigh|high|medium|low|minimal|none`
|
|
156
155
|
* (parameters.md) and live probes accept `xhigh` verbatim (`03`), so — unlike
|
|
157
156
|
* the discontinued Responses skin, which clamped `xhigh` — everything passes
|
|
158
|
-
* through. `dynamic` omits (server default).
|
|
157
|
+
* through. `max` rides the same verbatim path. `dynamic` omits (server default).
|
|
159
158
|
*/
|
|
160
159
|
export function mapThinkingLevelToOpenRouterChat(
|
|
161
160
|
level: ThinkingLevel | string | undefined
|
|
@@ -169,7 +168,8 @@ export function mapThinkingLevelToOpenRouterChat(
|
|
|
169
168
|
norm === "low" ||
|
|
170
169
|
norm === "medium" ||
|
|
171
170
|
norm === "high" ||
|
|
172
|
-
norm === "xhigh"
|
|
171
|
+
norm === "xhigh" ||
|
|
172
|
+
norm === "max"
|
|
173
173
|
) {
|
|
174
174
|
return { effort: norm };
|
|
175
175
|
}
|
|
@@ -179,11 +179,11 @@ export function mapThinkingLevelToOpenRouterChat(
|
|
|
179
179
|
/**
|
|
180
180
|
* Maps a canonical ThinkingLevel onto OpenRouter Responses `reasoning.effort`.
|
|
181
181
|
* Documented efforts: minimal|low|medium|high (server default medium).
|
|
182
|
-
* Wire-validated extras: `none` disables reasoning output; `xhigh`
|
|
183
|
-
* but
|
|
182
|
+
* Wire-validated extras: `none` disables reasoning output; `xhigh`/`max` are
|
|
183
|
+
* echoed but are NOT documented levels, so they clamp to `high` with a warning.
|
|
184
184
|
*
|
|
185
185
|
* @deprecated The Responses skin is discontinued for OpenRouter
|
|
186
|
-
* (
|
|
186
|
+
* (removed from core). Use
|
|
187
187
|
* {@link mapThinkingLevelToOpenRouterChat} with the stable Chat Completions
|
|
188
188
|
* transport instead.
|
|
189
189
|
*/
|
|
@@ -194,7 +194,7 @@ export function mapThinkingLevelToOpenRouter(
|
|
|
194
194
|
const norm = String(level).toLowerCase().trim();
|
|
195
195
|
if (norm === "dynamic") return {}; // server default (medium)
|
|
196
196
|
if (norm === "none") return { effort: "none" };
|
|
197
|
-
if (norm === "xhigh") {
|
|
197
|
+
if (norm === "xhigh" || norm === "max") {
|
|
198
198
|
emitProviderWarning({
|
|
199
199
|
provider: "openrouter",
|
|
200
200
|
capability: "thinking level",
|
|
@@ -327,7 +327,7 @@ export function mapToolChoiceToOpenRouter(
|
|
|
327
327
|
* Maps a canonical ThinkingLevel onto OpenAI Responses `reasoning.effort`.
|
|
328
328
|
* Documented efforts: none|minimal|low|medium|high|xhigh|max (server default
|
|
329
329
|
* medium). Unlike OpenRouter (which clamps xhigh), OpenAI documents xhigh
|
|
330
|
-
* natively so
|
|
330
|
+
* and max natively so they pass through verbatim. `dynamic` omits (server default).
|
|
331
331
|
*/
|
|
332
332
|
export function mapThinkingLevelToOpenAI(
|
|
333
333
|
level: ThinkingLevel | string | undefined
|
|
@@ -341,7 +341,8 @@ export function mapThinkingLevelToOpenAI(
|
|
|
341
341
|
norm === "low" ||
|
|
342
342
|
norm === "medium" ||
|
|
343
343
|
norm === "high" ||
|
|
344
|
-
norm === "xhigh"
|
|
344
|
+
norm === "xhigh" ||
|
|
345
|
+
norm === "max"
|
|
345
346
|
) {
|
|
346
347
|
return { effort: norm };
|
|
347
348
|
}
|