agent-accelerator 0.4.0 → 0.5.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 +149 -57
- package/bunfig.toml +1 -0
- package/package.json +2 -3
- package/src/agent/agent.ts +414 -118
- package/src/agent/context.ts +11 -16
- package/src/agent/delegation.ts +111 -274
- package/src/agent/loop.ts +327 -99
- package/src/agent/subagent.ts +4 -12
- package/src/agent/worker.ts +435 -0
- package/src/index.ts +58 -5
- package/src/models/catalog-cache.ts +65 -25
- package/src/models/catalog.ts +47 -2
- package/src/providers/google.ts +338 -81
- package/src/providers/openai-compat.ts +171 -61
- package/src/providers/openai.ts +174 -44
- package/src/providers/openrouter.ts +129 -44
- package/src/providers/shared.ts +169 -17
- package/src/providers.ts +375 -11
- package/src/scheduler/scheduled-actions.md +41 -0
- package/src/scheduler/scheduler.ts +814 -0
- package/src/scheduler/store.ts +263 -0
- package/src/scheduler/time.ts +148 -0
- package/src/scheduler/tool.ts +151 -0
- package/src/session/store.ts +229 -113
- package/src/streaming/event-stream.ts +6 -4
- package/src/telemetry-file.ts +31 -0
- package/src/telemetry.ts +65 -0
- package/src/tools/executor.ts +163 -38
- package/src/tools/subagent-result.ts +60 -0
- package/src/types/agent.ts +74 -22
- package/src/types/model.ts +39 -0
- package/src/types/response.ts +9 -0
- package/src/types/scheduler.ts +81 -0
- package/src/types/tool.ts +15 -0
- package/src/utils/cache.ts +14 -5
- package/src/utils/documents.ts +112 -52
- package/src/utils/env.ts +21 -7
- package/src/utils/errors.ts +26 -1
- package/src/utils/headers.ts +15 -15
- package/src/utils/home.ts +30 -0
- package/src/utils/media.ts +57 -12
- package/src/utils/raw-capture.ts +76 -0
- package/src/utils/retry.ts +11 -3
- package/src/utils/safe-io.ts +252 -0
- package/src/utils/session.ts +7 -8
- package/src/data/README.md +0 -84
|
@@ -52,11 +52,18 @@ import {
|
|
|
52
52
|
redactedHeaders,
|
|
53
53
|
readErrorPayload,
|
|
54
54
|
incompleteStreamError,
|
|
55
|
+
hasTruncatedToolArguments,
|
|
55
56
|
combinedSignal,
|
|
56
57
|
timeoutFor,
|
|
58
|
+
hasAuthHeader,
|
|
59
|
+
clampToolCallId,
|
|
60
|
+
openStreamResponse,
|
|
61
|
+
retryOptionsFrom,
|
|
62
|
+
headersToRecord,
|
|
63
|
+
parseRetryAfterMs,
|
|
57
64
|
} from "./shared.ts";
|
|
58
65
|
import { createGenericModelSpec } from "../models/catalog.ts";
|
|
59
|
-
import { getModelFromCatalog, getModelsForProvider } from "../models/catalog.ts";
|
|
66
|
+
import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
60
67
|
import {
|
|
61
68
|
mapThinkingLevelToOpenRouterChat,
|
|
62
69
|
mapServiceTierToOpenRouter,
|
|
@@ -64,6 +71,12 @@ import {
|
|
|
64
71
|
mapToolChoiceToOpenRouterChat,
|
|
65
72
|
noteProviderTurn,
|
|
66
73
|
parseStreamedToolArguments,
|
|
74
|
+
selectRawArguments,
|
|
75
|
+
mapOutputToOpenRouterChat,
|
|
76
|
+
mapGenerationParamsToChat,
|
|
77
|
+
normalizeStructuredOutput,
|
|
78
|
+
augmentSystemPromptWithStructuredOutput,
|
|
79
|
+
emitProviderWarning,
|
|
67
80
|
} from "../providers.ts";
|
|
68
81
|
|
|
69
82
|
// ---------------------------------------------------------------------------
|
|
@@ -102,6 +115,7 @@ interface ChatRequestBody {
|
|
|
102
115
|
service_tier?: string;
|
|
103
116
|
session_id?: string;
|
|
104
117
|
plugins?: Array<{ id: string }>;
|
|
118
|
+
response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
105
119
|
stream?: boolean;
|
|
106
120
|
[k: string]: unknown;
|
|
107
121
|
}
|
|
@@ -193,7 +207,10 @@ function resolveBaseUrl(options?: ProviderRequestOptions): string {
|
|
|
193
207
|
}
|
|
194
208
|
|
|
195
209
|
function resolveApiKey(options?: ProviderRequestOptions): string | undefined {
|
|
196
|
-
|
|
210
|
+
const key = getApiKey("openrouter", options?.apiKey, options?.env);
|
|
211
|
+
if (key) return key;
|
|
212
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
213
|
+
return undefined;
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
/**
|
|
@@ -352,11 +369,17 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
352
369
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
353
370
|
}
|
|
354
371
|
let hasFiles = false;
|
|
372
|
+
let lastRole: string | null = "system";
|
|
355
373
|
for (const m of context.messages) {
|
|
356
374
|
if (m.role === "system") continue;
|
|
357
375
|
if (m.role === "user") {
|
|
376
|
+
// Strict routes reject `user` directly after `tool`: bridge with a
|
|
377
|
+
// synthetic assistant turn (pi api/openai-completions.ts:1233).
|
|
378
|
+
if (lastRole === "tool") {
|
|
379
|
+
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
380
|
+
}
|
|
358
381
|
if (typeof m.content === "string") {
|
|
359
|
-
if (m.content) messages.push({ role: "user", content: m.content });
|
|
382
|
+
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
360
383
|
} else {
|
|
361
384
|
const { blocks, fileUrls, hasFiles: hf } = await contentPartsToBlocks(m.content);
|
|
362
385
|
if (hf) hasFiles = true;
|
|
@@ -364,7 +387,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
364
387
|
for (const url of fileUrls) {
|
|
365
388
|
blocks.push({ type: "text", text: url });
|
|
366
389
|
}
|
|
367
|
-
if (blocks.length > 0) messages.push({ role: "user", content: blocks });
|
|
390
|
+
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
368
391
|
}
|
|
369
392
|
} else if (m.role === "assistant") {
|
|
370
393
|
if (typeof m.content === "string") {
|
|
@@ -378,7 +401,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
378
401
|
for (const part of m.content) {
|
|
379
402
|
if (part.type === "tool_call") {
|
|
380
403
|
calls.push({
|
|
381
|
-
id: part.callId || part.id,
|
|
404
|
+
id: clampToolCallId(part.callId || part.id),
|
|
382
405
|
type: "function",
|
|
383
406
|
function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
|
|
384
407
|
});
|
|
@@ -388,20 +411,25 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
388
411
|
// Prior-turn reasoning is NOT resent: chat history is messages +
|
|
389
412
|
// tool calls only (documented decision; avoids strict-route 400s).
|
|
390
413
|
}
|
|
414
|
+
// Skip empty assistant turns (no text + no tool calls): strict
|
|
415
|
+
// routes reject them, and they carry no signal (pi drop rule).
|
|
416
|
+
if (texts.length === 0 && calls.length === 0) continue;
|
|
391
417
|
messages.push({
|
|
392
418
|
role: "assistant",
|
|
393
419
|
content: texts.length > 0 ? texts.join("\n") : null,
|
|
394
420
|
...(calls.length > 0 ? { tool_calls: calls } : {}),
|
|
395
421
|
});
|
|
422
|
+
lastRole = "assistant";
|
|
396
423
|
} else if (m.role === "tool") {
|
|
397
424
|
if (typeof m.content === "string") {
|
|
398
425
|
const queue = (m.name && idsByName.get(m.name)) || [];
|
|
399
426
|
messages.push({
|
|
400
427
|
role: "tool",
|
|
401
|
-
tool_call_id: queue.shift() || newToolCallId(),
|
|
428
|
+
tool_call_id: clampToolCallId(queue.shift() || newToolCallId()),
|
|
402
429
|
content: m.content,
|
|
403
430
|
name: m.name,
|
|
404
431
|
});
|
|
432
|
+
lastRole = "tool";
|
|
405
433
|
continue;
|
|
406
434
|
}
|
|
407
435
|
if (!Array.isArray(m.content)) continue;
|
|
@@ -409,10 +437,11 @@ async function fullHistoryMessages(context: ProviderContext): Promise<{
|
|
|
409
437
|
if (part.type === "tool_result") {
|
|
410
438
|
messages.push({
|
|
411
439
|
role: "tool",
|
|
412
|
-
tool_call_id: pairing.get(part.id) || part.id,
|
|
440
|
+
tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
|
|
413
441
|
content: resultToOutput(part.result),
|
|
414
442
|
name: part.name,
|
|
415
443
|
});
|
|
444
|
+
lastRole = "tool";
|
|
416
445
|
}
|
|
417
446
|
}
|
|
418
447
|
}
|
|
@@ -490,7 +519,7 @@ function parseResponse(
|
|
|
490
519
|
for (const tc of message?.tool_calls ?? []) {
|
|
491
520
|
const args = parseArguments(tc.function?.arguments);
|
|
492
521
|
toolCalls.push({
|
|
493
|
-
id: tc.id || newToolCallId(),
|
|
522
|
+
id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
|
|
494
523
|
name: tc.function?.name || "unknown",
|
|
495
524
|
arguments: args,
|
|
496
525
|
rawArguments:
|
|
@@ -559,7 +588,33 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
559
588
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
560
589
|
applyCacheForOpenRouter(options?.cache, `openrouter/${modelId}`);
|
|
561
590
|
|
|
562
|
-
|
|
591
|
+
// Structured outputs: native `response_format` when supported, else
|
|
592
|
+
// emulated via system-prompt instruction + client validation (catalog
|
|
593
|
+
// `structured_output: false` models would 400 on the wire shape).
|
|
594
|
+
let effectiveContext = context;
|
|
595
|
+
let outputEmulated = false;
|
|
596
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
597
|
+
try {
|
|
598
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
599
|
+
} catch {
|
|
600
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
601
|
+
outputSpec = undefined;
|
|
602
|
+
}
|
|
603
|
+
if (outputSpec && !modelSupportsStructuredOutput("openrouter", modelId)) {
|
|
604
|
+
outputEmulated = true;
|
|
605
|
+
effectiveContext = {
|
|
606
|
+
...context,
|
|
607
|
+
systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
|
|
608
|
+
};
|
|
609
|
+
emitProviderWarning({
|
|
610
|
+
provider: "openrouter",
|
|
611
|
+
capability: "structured output",
|
|
612
|
+
requested: `${outputSpec.name} (openrouter/${modelId})`,
|
|
613
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
614
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const { messages, hasFiles } = await fullHistoryMessages(effectiveContext);
|
|
563
618
|
const body: ChatRequestBody = {
|
|
564
619
|
model: modelId,
|
|
565
620
|
messages,
|
|
@@ -573,12 +628,22 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
573
628
|
if (toolChoice !== undefined) body.tool_choice = toolChoice;
|
|
574
629
|
if (effort) body.reasoning = { effort };
|
|
575
630
|
if (serviceTier) body.service_tier = serviceTier;
|
|
631
|
+
// Documented Chat Completions generation controls (only those the caller set).
|
|
632
|
+
Object.assign(body, mapGenerationParamsToChat(options));
|
|
633
|
+
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
634
|
+
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
635
|
+
if (outputSpec && !outputEmulated) {
|
|
636
|
+
try {
|
|
637
|
+
const mapped = mapOutputToOpenRouterChat(outputSpec);
|
|
638
|
+
if (mapped) body.response_format = mapped.response_format;
|
|
639
|
+
} catch {
|
|
640
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
641
|
+
}
|
|
642
|
+
}
|
|
576
643
|
// PDF ingestion is a documented plugin, not a message shape: enable it
|
|
577
644
|
// only when file parts are present (never by default).
|
|
578
645
|
if (hasFiles) body.plugins = [{ id: "file-parser" }];
|
|
579
|
-
// No
|
|
580
|
-
// (no canonical options exist; omitting also keeps provider cache keys
|
|
581
|
-
// stable). No store/previous_response_id/conversation: they do not exist
|
|
646
|
+
// No store/previous_response_id/conversation: they do not exist
|
|
582
647
|
// on this endpoint.
|
|
583
648
|
// Session affinity: top-level `session_id` is the documented sticky-routing
|
|
584
649
|
// key on Chat Completions (prompt-caching guide) and takes precedence over
|
|
@@ -608,7 +673,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
608
673
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
609
674
|
}
|
|
610
675
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
611
|
-
setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
676
|
+
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
612
677
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
613
678
|
}
|
|
614
679
|
|
|
@@ -622,7 +687,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
622
687
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
623
688
|
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
624
689
|
try {
|
|
625
|
-
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
690
|
+
const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
626
691
|
const text = await res.text();
|
|
627
692
|
const headers: Record<string, string> = {};
|
|
628
693
|
res.headers.forEach((v, k) => {
|
|
@@ -638,17 +703,27 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
638
703
|
status: number,
|
|
639
704
|
url: string,
|
|
640
705
|
bodyText: string,
|
|
641
|
-
modelId: string
|
|
706
|
+
modelId: string,
|
|
707
|
+
headers?: Record<string, string>
|
|
642
708
|
): void {
|
|
643
709
|
if (status >= 200 && status < 300) return;
|
|
644
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
645
|
-
const
|
|
710
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
711
|
+
const hint =
|
|
712
|
+
status === 401
|
|
713
|
+
? " Check OPENROUTER_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local)."
|
|
714
|
+
: "";
|
|
715
|
+
const fullMessage = `${message}${hint}`;
|
|
716
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
646
717
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
647
718
|
(err as Record<string, unknown>)["status"] = status;
|
|
648
719
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
649
720
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
721
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
650
722
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
651
723
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
724
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
725
|
+
const retryAfterMs = parseRetryAfterMs(headers);
|
|
726
|
+
if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
|
|
652
727
|
throw toConciseProviderError(err, "openrouter", modelId);
|
|
653
728
|
}
|
|
654
729
|
|
|
@@ -695,7 +770,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
695
770
|
|
|
696
771
|
const doCall = async (): Promise<ProviderGenerateResult> => {
|
|
697
772
|
const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
|
|
698
|
-
this.throwIfError(res.status, url, res.text, clean);
|
|
773
|
+
this.throwIfError(res.status, url, res.text, clean, res.headers);
|
|
699
774
|
let response: ChatResponse;
|
|
700
775
|
try {
|
|
701
776
|
response = JSON.parse(res.text) as ChatResponse;
|
|
@@ -723,8 +798,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
723
798
|
|
|
724
799
|
try {
|
|
725
800
|
return await withRetries(doCall, {
|
|
726
|
-
|
|
727
|
-
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
801
|
+
...retryOptionsFrom(options),
|
|
728
802
|
signal: options?.signal,
|
|
729
803
|
label: { providerId: "openrouter", modelId: clean },
|
|
730
804
|
});
|
|
@@ -792,25 +866,30 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
792
866
|
};
|
|
793
867
|
|
|
794
868
|
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
869
|
+
// Transient failures (429/5xx/network) are retried here, before the
|
|
870
|
+
// first byte; once the stream is live it is never retried.
|
|
871
|
+
const res = await openStreamResponse({
|
|
872
|
+
fetchOnce: async () => {
|
|
873
|
+
const timer =
|
|
874
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
875
|
+
? setTimeout(() => {
|
|
876
|
+
try {
|
|
877
|
+
linked.abort();
|
|
878
|
+
} catch {}
|
|
879
|
+
}, streamTimeoutMs)
|
|
880
|
+
: undefined;
|
|
881
|
+
try {
|
|
882
|
+
return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
883
|
+
} finally {
|
|
884
|
+
if (timer) clearTimeout(timer);
|
|
885
|
+
}
|
|
886
|
+
},
|
|
887
|
+
onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
|
|
888
|
+
noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
|
|
889
|
+
retry: retryOptionsFrom(options),
|
|
890
|
+
signal: linked.signal,
|
|
891
|
+
label: { providerId: "openrouter", modelId: clean },
|
|
892
|
+
});
|
|
814
893
|
const responseHeaders: Record<string, string> = {};
|
|
815
894
|
res.headers.forEach((v, k) => {
|
|
816
895
|
responseHeaders[k] = v;
|
|
@@ -819,7 +898,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
819
898
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
820
899
|
|
|
821
900
|
const parser = new SSEParser();
|
|
822
|
-
const reader = res.body
|
|
901
|
+
const reader = res.body!.getReader(); // openStreamResponse guarantees a body
|
|
823
902
|
const decoder = new TextDecoder();
|
|
824
903
|
let text = "";
|
|
825
904
|
let thinking = "";
|
|
@@ -995,10 +1074,10 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
995
1074
|
for (const c of calls.values()) {
|
|
996
1075
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
997
1076
|
const record: ToolCallRecord = {
|
|
998
|
-
id: c.id || newToolCallId(),
|
|
1077
|
+
id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
|
|
999
1078
|
name: c.name,
|
|
1000
1079
|
arguments: args,
|
|
1001
|
-
rawArguments: c.startArgs
|
|
1080
|
+
rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
|
|
1002
1081
|
};
|
|
1003
1082
|
toolCalls.push(record);
|
|
1004
1083
|
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
@@ -1027,6 +1106,13 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1027
1106
|
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
1028
1107
|
return;
|
|
1029
1108
|
}
|
|
1109
|
+
// Refuse unfinished tool calls whose args never parsed (output_item.done
|
|
1110
|
+
// never arrived): truncated `{raw}` must not reach the executor (pi
|
|
1111
|
+
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1112
|
+
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1113
|
+
eventStream.fail(incompleteStreamError("openrouter", clean));
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1030
1116
|
|
|
1031
1117
|
const cleanThinking = cleanThinkingEarly;
|
|
1032
1118
|
const finalResponse = new AgentResponse({
|
|
@@ -1052,8 +1138,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
1052
1138
|
const isAbort =
|
|
1053
1139
|
linked.signal.aborted ||
|
|
1054
1140
|
eventStream.isCancelled() ||
|
|
1055
|
-
(raw as { name?: string }).name === "AbortError"
|
|
1056
|
-
/abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
|
|
1141
|
+
(raw as { name?: string }).name === "AbortError";
|
|
1057
1142
|
eventStream.fail(
|
|
1058
1143
|
isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
|
|
1059
1144
|
);
|
package/src/providers/shared.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
* across the four REST transports, plus timeout/signal and id helpers.
|
|
7
7
|
*/
|
|
8
8
|
import { toConciseProviderError } from "../utils/errors.ts";
|
|
9
|
+
import { withRetries } from "../utils/retry.ts";
|
|
10
|
+
import { redactHeadersCentral } from "../utils/cache.ts";
|
|
11
|
+
import { TRUNCATED_ARGS_KEY, isTruncatedArguments } from "../types/tool.ts";
|
|
9
12
|
|
|
10
|
-
/** Parses a JSON tool-argument string; non-objects degrade to
|
|
13
|
+
/** Parses a JSON tool-argument string; non-objects degrade to the truncated-arguments marker. */
|
|
11
14
|
export function parseArguments(raw: string | undefined): Record<string, unknown> {
|
|
12
15
|
if (!raw) return {};
|
|
13
16
|
try {
|
|
@@ -15,9 +18,9 @@ export function parseArguments(raw: string | undefined): Record<string, unknown>
|
|
|
15
18
|
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
16
19
|
return parsed as Record<string, unknown>;
|
|
17
20
|
}
|
|
18
|
-
return { raw };
|
|
21
|
+
return { [TRUNCATED_ARGS_KEY]: raw };
|
|
19
22
|
} catch {
|
|
20
|
-
return { raw };
|
|
23
|
+
return { [TRUNCATED_ARGS_KEY]: raw };
|
|
21
24
|
}
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -30,39 +33,40 @@ export function normalizeThinkingParts(parts: string[]): string | undefined {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
/**
|
|
33
|
-
* Redacts secret-bearing headers for the raw audit trail
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
+
* Redacts secret-bearing headers for the raw audit trail via the central
|
|
37
|
+
* deny-list: any header whose NAME contains key / token / secret / cookie /
|
|
38
|
+
* authorization (so `x-api-key`, `api-key`, `cf-aig-authorization` …), plus any
|
|
39
|
+
* value that looks like a credential. Everything else passes through.
|
|
36
40
|
*/
|
|
37
41
|
export function redactedHeaders(headers: Record<string, string>): Record<string, string> {
|
|
38
|
-
|
|
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;
|
|
42
|
+
return redactHeadersCentral(headers) as Record<string, string>;
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
/**
|
|
47
|
-
* Extracts `{ message, code, errorType }` from a non-2xx JSON body. Handles
|
|
48
|
-
* OpenAI `type`, OpenRouter `metadata.error_type`, and Google
|
|
49
|
-
* shapes; unknown bodies degrade to a 300-char slice.
|
|
46
|
+
* Extracts `{ message, code, errorType, raw }` from a non-2xx JSON body. Handles
|
|
47
|
+
* OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw`, and Google
|
|
48
|
+
* `status` code shapes; unknown bodies degrade to a 300-char slice.
|
|
50
49
|
*/
|
|
51
50
|
export function readErrorPayload(
|
|
52
51
|
bodyText: string
|
|
53
|
-
): { message: string; code?: string | number; errorType?: string } {
|
|
52
|
+
): { message: string; code?: string | number; errorType?: string; raw?: string } {
|
|
54
53
|
try {
|
|
55
54
|
const parsed: unknown = JSON.parse(bodyText);
|
|
56
55
|
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;
|
|
56
|
+
const err = (first as { error?: { message?: string; code?: string | number; type?: string; status?: string | number; metadata?: { error_type?: string; raw?: unknown } } })?.error;
|
|
58
57
|
if (err && typeof err.message === "string") {
|
|
59
58
|
const code = err.code ?? err.status;
|
|
60
59
|
const errorType =
|
|
61
60
|
err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
|
|
61
|
+
const rawCandidate =
|
|
62
|
+
(err as { metadata?: { raw?: unknown } }).metadata?.raw ??
|
|
63
|
+
(first as { metadata?: { raw?: unknown } })?.metadata?.raw;
|
|
64
|
+
const raw = typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
|
|
62
65
|
return {
|
|
63
66
|
message: err.message,
|
|
64
67
|
...(code !== undefined ? { code } : {}),
|
|
65
68
|
...(typeof errorType === "string" ? { errorType } : {}),
|
|
69
|
+
...(raw ? { raw } : {}),
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
return { message: bodyText.slice(0, 300) };
|
|
@@ -87,6 +91,30 @@ export function incompleteStreamError(provider: string, modelId: string): Error
|
|
|
87
91
|
return err;
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
/**
|
|
95
|
+
* True when an error signals an evicted/invalid chained interaction
|
|
96
|
+
* (Google Interactions `404` / `{code: not_found}` on REST, `{event_type:
|
|
97
|
+
* error, error: {code: not_found}}` on SSE). Structured-data only: HTTP
|
|
98
|
+
* status, `code`, or `errorType` — never message text.
|
|
99
|
+
*/
|
|
100
|
+
export function isNotFoundChainError(error: unknown): boolean {
|
|
101
|
+
if (!error || typeof error !== "object") return false;
|
|
102
|
+
const err = error as Record<string, unknown>;
|
|
103
|
+
const status = Number(
|
|
104
|
+
(err["status"] as number | undefined) ??
|
|
105
|
+
(err["statusCode"] as number | undefined) ??
|
|
106
|
+
((err["response"] as Record<string, unknown> | undefined)?.["status"] as number | undefined) ??
|
|
107
|
+
NaN
|
|
108
|
+
);
|
|
109
|
+
if (status === 404) return true;
|
|
110
|
+
const code = String(
|
|
111
|
+
(err["code"] as string | undefined) ??
|
|
112
|
+
(err["errorType"] as string | undefined) ??
|
|
113
|
+
""
|
|
114
|
+
).toLowerCase();
|
|
115
|
+
return code === "not_found";
|
|
116
|
+
}
|
|
117
|
+
|
|
90
118
|
/** Per-request timeout ms from provider options (Q-22). */
|
|
91
119
|
export function timeoutFor(options: { timeoutMs?: number } | undefined): number | undefined {
|
|
92
120
|
const t = (options as { timeoutMs?: unknown } | undefined)?.timeoutMs;
|
|
@@ -130,3 +158,127 @@ export function newToolCallId(prefix = "call"): string {
|
|
|
130
158
|
} catch {}
|
|
131
159
|
return `${prefix}_${Math.random().toString(36).slice(2, 10)}`;
|
|
132
160
|
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* True when custom headers already carry auth (proxy/Copilot flows).
|
|
164
|
+
* Case-insensitive, non-empty `Authorization` or `cf-aig-authorization`.
|
|
165
|
+
* Callers return `"unused"` instead of throwing missing-key.
|
|
166
|
+
*/
|
|
167
|
+
export function hasAuthHeader(headers: Record<string, string> | undefined): boolean {
|
|
168
|
+
if (!headers) return false;
|
|
169
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
170
|
+
const lower = k.toLowerCase();
|
|
171
|
+
if ((lower === "authorization" || lower === "cf-aig-authorization") && typeof v === "string" && v.trim().length > 0) {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** True for truncated streamed tool args (the `__truncated_raw` marker from `parseStreamedToolArguments`). */
|
|
179
|
+
export function hasTruncatedToolArguments(args: unknown): boolean {
|
|
180
|
+
return isTruncatedArguments(args);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Short hash for tool-ID clamping (FNV-1a, 8 hex chars, no deps). */
|
|
184
|
+
export function shortHashId(value: string): string {
|
|
185
|
+
let h = 0x811c9dc5;
|
|
186
|
+
for (let i = 0; i < value.length; i++) {
|
|
187
|
+
h ^= value.charCodeAt(i);
|
|
188
|
+
h = Math.imul(h, 0x01000193);
|
|
189
|
+
}
|
|
190
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Clamps a tool-call ID to the 40-char OpenAI limit (pi `api/openai-completions.ts:1194`).
|
|
195
|
+
* Pipe IDs (`callId|itemId`) become `callId_itemHash`; overlong IDs truncate
|
|
196
|
+
* with hash suffix. Plain IDs pass through (truncated if needed).
|
|
197
|
+
*/
|
|
198
|
+
export function clampToolCallId(id: string): string {
|
|
199
|
+
const clean = String(id ?? "").replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
200
|
+
if (!clean.includes("|")) {
|
|
201
|
+
return clean.length > 40 ? clean.slice(0, 40) : clean || "call_unknown";
|
|
202
|
+
}
|
|
203
|
+
const sep = clean.indexOf("|");
|
|
204
|
+
const callId = clean.slice(0, sep).replace(/_+$/, "") || "call";
|
|
205
|
+
const itemId = clean.slice(sep + 1).replace(/_+$/, "");
|
|
206
|
+
const combined = itemId ? `${callId}_${itemId}` : callId;
|
|
207
|
+
if (combined.length <= 40) return combined;
|
|
208
|
+
const hash = shortHashId(id).slice(0, 8);
|
|
209
|
+
const prefix = callId.slice(0, Math.max(1, 40 - hash.length - 1));
|
|
210
|
+
return `${prefix}_${hash}`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Plain lower-cased header record from a fetch `Headers` object. */
|
|
214
|
+
export function headersToRecord(headers: Headers): Record<string, string> {
|
|
215
|
+
const out: Record<string, string> = {};
|
|
216
|
+
headers.forEach((v, k) => {
|
|
217
|
+
out[k] = v;
|
|
218
|
+
});
|
|
219
|
+
return out;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Parses a rate-limit hint from response headers into milliseconds:
|
|
224
|
+
* `retry-after-ms`, then `retry-after` (delta-seconds or HTTP-date).
|
|
225
|
+
* Returns undefined when absent or unparseable.
|
|
226
|
+
*/
|
|
227
|
+
export function parseRetryAfterMs(headers: Record<string, string> | undefined, now = Date.now()): number | undefined {
|
|
228
|
+
if (!headers) return undefined;
|
|
229
|
+
const get = (name: string): string | undefined => {
|
|
230
|
+
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name) return v;
|
|
231
|
+
return undefined;
|
|
232
|
+
};
|
|
233
|
+
const ms = get("retry-after-ms");
|
|
234
|
+
if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0) return Math.round(Number(ms));
|
|
235
|
+
const ra = get("retry-after");
|
|
236
|
+
if (ra === undefined || ra.trim() === "") return undefined;
|
|
237
|
+
const secs = Number(ra);
|
|
238
|
+
if (Number.isFinite(secs)) return secs >= 0 ? Math.round(secs * 1000) : undefined;
|
|
239
|
+
const at = Date.parse(ra);
|
|
240
|
+
return Number.isFinite(at) ? Math.max(0, at - now) : undefined;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Opens an SSE response, retrying transient failures (429/5xx/network) BEFORE
|
|
245
|
+
* any byte is consumed, so a streaming caller gets the same resilience as
|
|
246
|
+
* `generate()`. Once this resolves the stream is live and never retried.
|
|
247
|
+
* `onHttpError` must throw (usually the adapter's concise `throwIfError`).
|
|
248
|
+
*/
|
|
249
|
+
export function openStreamResponse(args: {
|
|
250
|
+
fetchOnce: () => Promise<Response>;
|
|
251
|
+
onHttpError: (res: Response, bodyText: string) => void;
|
|
252
|
+
noBodyError: () => Error;
|
|
253
|
+
retry: ReturnType<typeof retryOptionsFrom>;
|
|
254
|
+
signal: AbortSignal;
|
|
255
|
+
label: { providerId: string; modelId: string };
|
|
256
|
+
}): Promise<Response> {
|
|
257
|
+
return withRetries(
|
|
258
|
+
async () => {
|
|
259
|
+
const res = await args.fetchOnce();
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
const text = await res.text().catch(() => "");
|
|
262
|
+
args.onHttpError(res, text);
|
|
263
|
+
}
|
|
264
|
+
if (!res.body) throw args.noBodyError();
|
|
265
|
+
return res;
|
|
266
|
+
},
|
|
267
|
+
{ ...args.retry, signal: args.signal, label: args.label }
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Retry knobs from provider request options, in the shape `withRetries` expects. */
|
|
272
|
+
export function retryOptionsFrom(options: {
|
|
273
|
+
maxRetries?: number;
|
|
274
|
+
maxRetryDelayMs?: number;
|
|
275
|
+
retryBaseDelayMs?: number;
|
|
276
|
+
retryBudgetMs?: number;
|
|
277
|
+
} | undefined): { maxRetries?: number; maxRetryDelayMs?: number; baseDelayMs?: number; retryBudgetMs?: number } {
|
|
278
|
+
return {
|
|
279
|
+
maxRetries: options?.maxRetries,
|
|
280
|
+
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
281
|
+
baseDelayMs: options?.retryBaseDelayMs,
|
|
282
|
+
retryBudgetMs: options?.retryBudgetMs,
|
|
283
|
+
};
|
|
284
|
+
}
|