agent-accelerator 0.5.8 → 0.6.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 +26 -5
- package/examples/prompts/SYSTEM_PROMPT.md +2 -0
- package/examples/prompts/SYSTEM_PROMPT_AGENT.md +4 -0
- package/examples/prompts/SYSTEM_PROMPT_TOOLS.md +1 -0
- package/package.json +4 -1
- package/src/agent/agent.ts +374 -167
- package/src/agent/architecture.md +14 -12
- package/src/agent/config-blocks.ts +39 -12
- package/src/agent/context.ts +8 -3
- package/src/agent/delegation.ts +82 -29
- package/src/agent/loop.ts +449 -373
- package/src/agent/subagent.ts +1 -3
- package/src/agent/subagents-tool.ts +45 -7
- package/src/agent/subagents.md +3 -0
- package/src/agent/turns.ts +3 -1
- package/src/agent/worker.ts +58 -28
- package/src/agent/workers.ts +113 -20
- package/src/architecture.md +16 -15
- package/src/index.ts +34 -10
- package/src/models/architecture.md +4 -3
- package/src/models/catalog-cache.ts +6 -5
- package/src/models/catalog.ts +46 -49
- package/src/providers/architecture.md +10 -8
- package/src/providers/google.ts +150 -62
- package/src/providers/openai-compat.ts +60 -58
- package/src/providers/openai.ts +57 -39
- package/src/providers/openrouter.ts +87 -32
- package/src/providers/registry.ts +12 -26
- package/src/providers/shared.ts +89 -39
- package/src/providers.ts +56 -115
- package/src/scheduler/architecture.md +9 -8
- package/src/scheduler/scheduled-actions.md +4 -0
- package/src/scheduler/scheduler.ts +186 -60
- package/src/scheduler/store.ts +23 -10
- package/src/scheduler/time.ts +17 -3
- package/src/scheduler/tool.ts +33 -7
- package/src/session/architecture.md +7 -6
- package/src/session/journal.ts +38 -12
- package/src/session/store.ts +122 -61
- package/src/session/usage.ts +58 -9
- package/src/streaming/architecture.md +4 -3
- package/src/streaming/event-stream.ts +26 -12
- package/src/telemetry.ts +6 -2
- package/src/tools/architecture.md +8 -7
- package/src/tools/executor.ts +50 -48
- package/src/tools/schema.ts +10 -6
- package/src/tools/subagent-result.ts +6 -1
- package/src/tools/tool.ts +9 -11
- package/src/types/agent.ts +2 -21
- package/src/types/architecture.md +11 -10
- package/src/types/core.ts +1 -9
- package/src/types/errors.ts +26 -4
- package/src/types/model.ts +0 -4
- package/src/types/tool.ts +12 -2
- package/src/update-models.ts +5 -5
- package/src/utils/architecture.md +16 -15
- package/src/utils/cache.ts +1 -3
- package/src/utils/durable.ts +17 -5
- package/src/utils/env.ts +9 -7
- package/src/utils/errors.ts +50 -44
- package/src/utils/headers.ts +3 -10
- package/src/utils/media.ts +23 -8
- package/src/utils/raw-capture.ts +9 -2
- package/src/utils/retry.ts +17 -31
- package/src/utils/safe-io.ts +29 -7
- package/src/utils/serialization.ts +1 -4
- package/src/utils/session.ts +1 -2
|
@@ -46,12 +46,11 @@ import { buildSessionHeaders, setHeaderCaseInsensitive } from "../utils/headers.
|
|
|
46
46
|
import { normalizeMediaInput } from "../utils/media.ts";
|
|
47
47
|
import { safeStringify } from "../utils/serialization.ts";
|
|
48
48
|
import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
|
|
49
|
-
import { isAbortError } from "../types/errors.ts";
|
|
49
|
+
import { ConfigError, isAbortError } from "../types/errors.ts";
|
|
50
50
|
import {
|
|
51
51
|
newToolCallId,
|
|
52
52
|
redactedHeaders,
|
|
53
53
|
readErrorPayload,
|
|
54
|
-
incompleteStreamError,
|
|
55
54
|
hasUnfinishedToolCall,
|
|
56
55
|
linkStreamAbort,
|
|
57
56
|
withAbortTimer,
|
|
@@ -123,7 +122,10 @@ interface ChatRequestBody {
|
|
|
123
122
|
service_tier?: string;
|
|
124
123
|
session_id?: string;
|
|
125
124
|
cache_control?: { type: "ephemeral"; ttl: "5m" | "1h" };
|
|
126
|
-
response_format?: {
|
|
125
|
+
response_format?: {
|
|
126
|
+
type: "json_schema";
|
|
127
|
+
json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string };
|
|
128
|
+
};
|
|
127
129
|
stream?: boolean;
|
|
128
130
|
[k: string]: unknown;
|
|
129
131
|
}
|
|
@@ -173,7 +175,13 @@ function cleanModelId(model: string | ModelSpec): string {
|
|
|
173
175
|
}
|
|
174
176
|
|
|
175
177
|
// The HTTP status of an error chunk inside a stream (the HTTP status itself is 200): its numeric `code`, else the status of its retryable `error_type` (errors-and-debugging.md, "Typed Error Codes"); in: the chunk's error, out: streamInto, where it makes the error retryable.
|
|
176
|
-
const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = {
|
|
178
|
+
const RETRYABLE_ERROR_TYPE_STATUS: Record<string, number> = {
|
|
179
|
+
rate_limit_exceeded: 429,
|
|
180
|
+
provider_overloaded: 503,
|
|
181
|
+
provider_unavailable: 502,
|
|
182
|
+
timeout: 504,
|
|
183
|
+
server: 500,
|
|
184
|
+
};
|
|
177
185
|
// HTTP-like status of an error chunk inside a stream, or undefined when unknown; in: streamInto's error branch, out: the status that makes the error retryable.
|
|
178
186
|
function midStreamStatus(code: unknown, errorType: unknown): number | undefined {
|
|
179
187
|
if (typeof code === "number" && Number.isInteger(code) && code >= 100 && code <= 599) return code;
|
|
@@ -195,7 +203,18 @@ function toOpenRouterTools(tools?: StandardToolDeclaration[]): Array<Record<stri
|
|
|
195
203
|
}
|
|
196
204
|
|
|
197
205
|
// The `input_audio.format` for a MIME type: Chat Completions takes any format string the provider supports (audio.md: wav, mp3, aiff, aac, ogg, flac, m4a, pcm16 ...), so the real format is sent, never a guess; in: an audio part's MIME type, out: contentPartsToBlocks.
|
|
198
|
-
const AUDIO_FORMAT: Record<string, string> = {
|
|
206
|
+
const AUDIO_FORMAT: Record<string, string> = {
|
|
207
|
+
wav: "wav",
|
|
208
|
+
wave: "wav",
|
|
209
|
+
mpeg: "mp3",
|
|
210
|
+
mp3: "mp3",
|
|
211
|
+
mp4: "m4a",
|
|
212
|
+
m4a: "m4a",
|
|
213
|
+
aac: "aac",
|
|
214
|
+
ogg: "ogg",
|
|
215
|
+
flac: "flac",
|
|
216
|
+
aiff: "aiff",
|
|
217
|
+
};
|
|
199
218
|
// The `input_audio.format` for an audio MIME type (see AUDIO_FORMAT above); in: contentPartsToBlocks, out: the format string.
|
|
200
219
|
function audioFormatFor(mimeType?: string): string {
|
|
201
220
|
const subtype = (mimeType || "").toLowerCase().split(";")[0]!.split("/")[1]?.replace(/^x-/, "") ?? "";
|
|
@@ -203,7 +222,12 @@ function audioFormatFor(mimeType?: string): string {
|
|
|
203
222
|
}
|
|
204
223
|
|
|
205
224
|
// `ImagePart.resolution` as OpenRouter's `image_url.detail` (api-ref ChatContentImage: auto, low, high, original; `original` is downgraded to `high` where unsupported); videos have no such field; in: an image part, out: contentPartsToBlocks.
|
|
206
|
-
const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = {
|
|
225
|
+
const IMAGE_DETAIL: Record<string, "low" | "auto" | "high" | "original"> = {
|
|
226
|
+
low: "low",
|
|
227
|
+
medium: "auto",
|
|
228
|
+
high: "high",
|
|
229
|
+
ultra_high: "original",
|
|
230
|
+
};
|
|
207
231
|
|
|
208
232
|
// True when a request's messages carry an image or video by web link (the router fetches those itself); in: the built messages, out: throwIfError's hint.
|
|
209
233
|
function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
@@ -211,7 +235,9 @@ function hasRemoteMedia(messages: ChatMessage[]): boolean {
|
|
|
211
235
|
(m) =>
|
|
212
236
|
Array.isArray(m.content) &&
|
|
213
237
|
(m.content as ChatContentPart[]).some((p) => {
|
|
214
|
-
const url =
|
|
238
|
+
const url =
|
|
239
|
+
(p as { image_url?: { url?: string }; video_url?: { url?: string } }).image_url?.url ??
|
|
240
|
+
(p as { video_url?: { url?: string } }).video_url?.url;
|
|
215
241
|
return typeof url === "string" && /^https?:\/\//.test(url);
|
|
216
242
|
})
|
|
217
243
|
);
|
|
@@ -258,10 +284,7 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
|
|
|
258
284
|
const mimeType = (part as { mimeType?: string }).mimeType;
|
|
259
285
|
// OpenAI chat audio shape (router parsed it live; capability routing
|
|
260
286
|
// decides per model). Always inline — `input_audio` carries data only.
|
|
261
|
-
const norm = await normalizeMediaInput(
|
|
262
|
-
raw as string | Uint8Array | ArrayBuffer,
|
|
263
|
-
mimeType
|
|
264
|
-
);
|
|
287
|
+
const norm = await normalizeMediaInput(raw as string | Uint8Array | ArrayBuffer, mimeType);
|
|
265
288
|
blocks.push({
|
|
266
289
|
type: "input_audio",
|
|
267
290
|
input_audio: { data: norm.base64Data, format: audioFormatFor(norm.mimeType) },
|
|
@@ -273,7 +296,12 @@ async function contentPartsToBlocks(parts: ContentPart[]): Promise<ChatContentPa
|
|
|
273
296
|
const fileData =
|
|
274
297
|
typeof raw === "string" && (raw.startsWith("http://") || raw.startsWith("https://"))
|
|
275
298
|
? raw
|
|
276
|
-
: (
|
|
299
|
+
: (
|
|
300
|
+
await normalizeMediaInput(
|
|
301
|
+
raw as string | Uint8Array | ArrayBuffer,
|
|
302
|
+
(part as { mimeType?: string }).mimeType
|
|
303
|
+
)
|
|
304
|
+
).dataUrl;
|
|
277
305
|
blocks.push({
|
|
278
306
|
type: "file",
|
|
279
307
|
file: { ...(typeof filename === "string" && filename ? { filename } : {}), file_data: fileData },
|
|
@@ -329,10 +357,16 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
329
357
|
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
330
358
|
}
|
|
331
359
|
if (typeof m.content === "string") {
|
|
332
|
-
if (m.content) {
|
|
360
|
+
if (m.content) {
|
|
361
|
+
messages.push({ role: "user", content: m.content });
|
|
362
|
+
lastRole = "user";
|
|
363
|
+
}
|
|
333
364
|
} else {
|
|
334
365
|
const blocks = await contentPartsToBlocks(m.content);
|
|
335
|
-
if (blocks.length > 0) {
|
|
366
|
+
if (blocks.length > 0) {
|
|
367
|
+
messages.push({ role: "user", content: blocks });
|
|
368
|
+
lastRole = "user";
|
|
369
|
+
}
|
|
336
370
|
}
|
|
337
371
|
} else if (m.role === "assistant") {
|
|
338
372
|
if (typeof m.content === "string") {
|
|
@@ -414,7 +448,8 @@ function mapUsage(raw?: ChatUsage): TokenUsage {
|
|
|
414
448
|
cacheWriteTokens: raw?.prompt_tokens_details?.cache_write_tokens ?? 0,
|
|
415
449
|
thinkingTokens: raw?.completion_tokens_details?.reasoning_tokens ?? 0,
|
|
416
450
|
};
|
|
417
|
-
if (typeof raw?.cost === "number" && raw.cost >= 0) {
|
|
451
|
+
if (typeof raw?.cost === "number" && raw.cost >= 0) {
|
|
452
|
+
// an explicit 0 (a free model, a response-cache hit) is a known cost, not a missing one
|
|
418
453
|
usage.cost = { totalCost: raw.cost };
|
|
419
454
|
}
|
|
420
455
|
return usage;
|
|
@@ -460,7 +495,12 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
460
495
|
const { effort } = mapThinkingLevelToOpenRouterChat(options?.thinking?.level);
|
|
461
496
|
const serviceTier = mapServiceTierToOpenRouter(options?.serviceTier);
|
|
462
497
|
// Anthropic models take a top-level `cache_control` TTL; every other route caches implicitly (docs: prompt-caching.md).
|
|
463
|
-
const cache = resolveCache(
|
|
498
|
+
const cache = resolveCache(
|
|
499
|
+
"openrouter",
|
|
500
|
+
options?.cache,
|
|
501
|
+
/^~?anthropic\//.test(modelId) ? ["implicit", "5m", "1h"] : ["implicit"],
|
|
502
|
+
`openrouter/${modelId}`
|
|
503
|
+
);
|
|
464
504
|
warnIgnoredMediaResolution("openrouter", context.messages, `openrouter/${modelId}`, ["video"]);
|
|
465
505
|
|
|
466
506
|
// Structured outputs: native `response_format` when supported, else
|
|
@@ -485,7 +525,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
485
525
|
provider: "openrouter",
|
|
486
526
|
capability: "structured output",
|
|
487
527
|
requested: `${outputSpec.name} (openrouter/${modelId})`,
|
|
488
|
-
reason:
|
|
528
|
+
reason:
|
|
529
|
+
"the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
489
530
|
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
490
531
|
});
|
|
491
532
|
}
|
|
@@ -505,7 +546,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
505
546
|
if (serviceTier) body.service_tier = serviceTier;
|
|
506
547
|
if (cache === "5m" || cache === "1h") body.cache_control = { type: "ephemeral", ttl: cache };
|
|
507
548
|
// Documented Chat Completions generation controls (only those the caller set).
|
|
508
|
-
Object.assign(body, mapGenerationParamsToChat(options
|
|
549
|
+
Object.assign(body, mapGenerationParamsToChat(options));
|
|
509
550
|
// Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
|
|
510
551
|
// Skipped when emulated (unsupported model): instruction already in messages.
|
|
511
552
|
if (outputSpec && !outputEmulated) {
|
|
@@ -537,11 +578,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
537
578
|
): RequestInit {
|
|
538
579
|
// Attribution + session headers mirror the previous transport exactly
|
|
539
580
|
// (HTTP-Referer / X-Title / x-session-id / x-client-request-id).
|
|
540
|
-
const headers: Record<string, string> = buildSessionHeaders(
|
|
541
|
-
"openrouter",
|
|
542
|
-
options?.headers,
|
|
543
|
-
sessionId
|
|
544
|
-
);
|
|
581
|
+
const headers: Record<string, string> = buildSessionHeaders("openrouter", options?.headers, sessionId);
|
|
545
582
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
546
583
|
if (apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
547
584
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
@@ -593,10 +630,16 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
593
630
|
options?: ProviderRequestOptions
|
|
594
631
|
): Promise<ProviderGenerateResult> {
|
|
595
632
|
// The whole call stays bounded by `timeoutMs`; the stream's own timer only covers the wait for its first byte.
|
|
596
|
-
const { signal, cleanup } = combinedSignal(
|
|
633
|
+
const { signal, cleanup } = combinedSignal(
|
|
634
|
+
options?.signal,
|
|
635
|
+
timeoutFor(options as { timeoutMs?: number } | undefined)
|
|
636
|
+
);
|
|
597
637
|
try {
|
|
598
638
|
// Nobody reads the deltas here, so the stream keeps none (a burst of frames would overflow its buffer for nothing).
|
|
599
|
-
const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, {
|
|
639
|
+
const events = this.streamInto(new AssistantMessageEventStream({ buffer: false }), model, context, {
|
|
640
|
+
...options,
|
|
641
|
+
signal,
|
|
642
|
+
});
|
|
600
643
|
const res = await events.result();
|
|
601
644
|
return {
|
|
602
645
|
text: res.text,
|
|
@@ -648,7 +691,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
648
691
|
try {
|
|
649
692
|
const apiKey = resolveApiKey(options);
|
|
650
693
|
if (!apiKey) {
|
|
651
|
-
throw new
|
|
694
|
+
throw new ConfigError(
|
|
652
695
|
"[Agent Accelerator] Missing OpenRouter API key. Set OPENROUTER_API_KEY or pass apiKey."
|
|
653
696
|
);
|
|
654
697
|
}
|
|
@@ -675,9 +718,14 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
675
718
|
// Transient failures (429/5xx/network) are retried here, before the
|
|
676
719
|
// first byte; once the stream is live it is never retried.
|
|
677
720
|
const res = await openStreamResponse({
|
|
678
|
-
fetchOnce: () =>
|
|
679
|
-
|
|
680
|
-
|
|
721
|
+
fetchOnce: () =>
|
|
722
|
+
withAbortTimer(linked, streamTimeoutMs, () =>
|
|
723
|
+
(options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal))
|
|
724
|
+
),
|
|
725
|
+
onHttpError: (r, text) =>
|
|
726
|
+
this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers), hasRemoteMedia(body.messages)),
|
|
727
|
+
noBodyError: () =>
|
|
728
|
+
toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
|
|
681
729
|
retry: retryOptionsFrom(options),
|
|
682
730
|
signal: linked.signal,
|
|
683
731
|
label: { providerId: "openrouter", modelId: clean },
|
|
@@ -686,7 +734,11 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
686
734
|
res.headers.forEach((v, k) => {
|
|
687
735
|
responseHeaders[k] = v;
|
|
688
736
|
});
|
|
689
|
-
const responseMeta: Record<string, unknown> & {
|
|
737
|
+
const responseMeta: Record<string, unknown> & {
|
|
738
|
+
status: number;
|
|
739
|
+
statusText: string;
|
|
740
|
+
headers: Record<string, string>;
|
|
741
|
+
} = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
690
742
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
691
743
|
|
|
692
744
|
let text = "";
|
|
@@ -721,7 +773,9 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
721
773
|
}
|
|
722
774
|
// Mid-stream provider error: top-level `error`, HTTP stays 200.
|
|
723
775
|
// Must surface — resolving empty success hides it.
|
|
724
|
-
const topError = msg["error"] as
|
|
776
|
+
const topError = msg["error"] as
|
|
777
|
+
| { message?: string; code?: string | number; metadata?: { error_type?: string } }
|
|
778
|
+
| undefined;
|
|
725
779
|
if (topError && typeof topError === "object") {
|
|
726
780
|
const message =
|
|
727
781
|
typeof topError.message === "string" && topError.message
|
|
@@ -738,7 +792,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
|
|
|
738
792
|
if (typeof msg["id"] === "string" && !responseId) {
|
|
739
793
|
responseId = msg["id"] as string;
|
|
740
794
|
}
|
|
741
|
-
const choice =
|
|
795
|
+
const choice =
|
|
796
|
+
(Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
|
|
742
797
|
const delta = (choice["delta"] as Record<string, unknown>) ?? {};
|
|
743
798
|
// Text delta (content-free accounting frames carry "" — ignored).
|
|
744
799
|
// Refusal deltas surface as text with a `refusal` finish.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ConfigError } from "../types/errors.ts";
|
|
1
2
|
import type { Provider, ProviderId, ModelSpec } from "../types/model.ts";
|
|
2
3
|
import type { ThinkingLevel } from "../types/core.ts";
|
|
3
4
|
import { OpenAICompatibleChatProvider } from "./openai-compat.ts";
|
|
@@ -30,12 +31,7 @@ providerRegistry.set("openai", openaiProvider);
|
|
|
30
31
|
// MODEL="cerebras/gpt-oss-120b" + CEREBRAS_API_KEY + CEREBRAS_BASE_URL
|
|
31
32
|
// MODEL="ollama/qwen2.5-coder" + OLLAMA_BASE_URL (key optional for local)
|
|
32
33
|
// ---------------------------------------------------------------------------
|
|
33
|
-
const FIRST_CLASS = new Set([
|
|
34
|
-
"google",
|
|
35
|
-
"gemini",
|
|
36
|
-
"openrouter",
|
|
37
|
-
"openai",
|
|
38
|
-
]);
|
|
34
|
+
const FIRST_CLASS = new Set(["google", "gemini", "openrouter", "openai"]);
|
|
39
35
|
|
|
40
36
|
// Removed providers: fail loudly instead of misrouting. OpenCode free models
|
|
41
37
|
// are harness-only since 2026, so the opencode/opencode-go prefixes no longer
|
|
@@ -54,7 +50,7 @@ function removedProviderMessage(id: string): string {
|
|
|
54
50
|
/** Throws for removed provider prefixes. */
|
|
55
51
|
function assertProviderNotRemoved(id: string): void {
|
|
56
52
|
if (REMOVED_PROVIDERS.has(id.trim().toLowerCase())) {
|
|
57
|
-
throw new
|
|
53
|
+
throw new ConfigError(removedProviderMessage(id.trim()));
|
|
58
54
|
}
|
|
59
55
|
}
|
|
60
56
|
|
|
@@ -83,7 +79,7 @@ export function ensureCustomProvider(
|
|
|
83
79
|
const id = normalizeProviderPrefix(prefix) as ProviderId;
|
|
84
80
|
if (FIRST_CLASS.has(id.toLowerCase())) {
|
|
85
81
|
if (opts?.baseUrl !== undefined || opts?.apiKey !== undefined) {
|
|
86
|
-
throw new
|
|
82
|
+
throw new ConfigError(
|
|
87
83
|
`[Agent Accelerator] Provider "${id}" is first-class and cannot be replaced with custom baseUrl/apiKey. Pass baseUrl/apiKey per-Agent instead.`
|
|
88
84
|
);
|
|
89
85
|
}
|
|
@@ -93,7 +89,7 @@ export function ensureCustomProvider(
|
|
|
93
89
|
if (existing) {
|
|
94
90
|
const prevBase = (existing as { configuredBaseUrl?: string }).configuredBaseUrl;
|
|
95
91
|
if (opts?.baseUrl !== undefined && prevBase !== undefined && prevBase !== opts.baseUrl) {
|
|
96
|
-
throw new
|
|
92
|
+
throw new ConfigError(
|
|
97
93
|
`[Agent Accelerator] Conflicting re-registration for provider "${id}". Registry is immutable after creation.`
|
|
98
94
|
);
|
|
99
95
|
}
|
|
@@ -120,7 +116,7 @@ export function getProvider(id: ProviderId | string): Provider {
|
|
|
120
116
|
const provider = providerRegistry.get(key);
|
|
121
117
|
if (provider) return provider;
|
|
122
118
|
if (!key) {
|
|
123
|
-
throw new
|
|
119
|
+
throw new ConfigError(
|
|
124
120
|
`Provider '' is not supported or not registered. Available first-support providers: google, openrouter, openai — or any custom prefix via {PREFIX}_API_KEY + {PREFIX}_BASE_URL.`
|
|
125
121
|
);
|
|
126
122
|
}
|
|
@@ -146,7 +142,7 @@ export interface ResolvedModel {
|
|
|
146
142
|
*/
|
|
147
143
|
export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
148
144
|
if (!model || (typeof model === "string" && !model.trim())) {
|
|
149
|
-
throw new
|
|
145
|
+
throw new ConfigError(
|
|
150
146
|
"[Agent Accelerator] No model specified. Please specify 'model' in Agent configuration or set the MODEL environment variable."
|
|
151
147
|
);
|
|
152
148
|
}
|
|
@@ -167,9 +163,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
167
163
|
// Try catalog first for any provider/model
|
|
168
164
|
const tryCatalogLookup = (prov: string, mid: string): ModelSpec | undefined => {
|
|
169
165
|
return (
|
|
170
|
-
getModelFromCatalog(prov, mid) ||
|
|
171
|
-
getModelFromCatalog(prov.toLowerCase(), mid) ||
|
|
172
|
-
getModelFromCatalog(mid, mid)
|
|
166
|
+
getModelFromCatalog(prov, mid) || getModelFromCatalog(prov.toLowerCase(), mid) || getModelFromCatalog(mid, mid)
|
|
173
167
|
);
|
|
174
168
|
};
|
|
175
169
|
|
|
@@ -193,11 +187,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
193
187
|
}
|
|
194
188
|
|
|
195
189
|
// 2. Unified layer: any non-first-class prefix routes to auto-created custom provider
|
|
196
|
-
const isExplicitOther = [
|
|
197
|
-
"google",
|
|
198
|
-
"gemini",
|
|
199
|
-
"openrouter",
|
|
200
|
-
].includes(providerPrefix);
|
|
190
|
+
const isExplicitOther = ["google", "gemini", "openrouter"].includes(providerPrefix);
|
|
201
191
|
|
|
202
192
|
if (!isExplicitOther && providerPrefix !== "openai") {
|
|
203
193
|
const envName = providerPrefix.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
@@ -209,8 +199,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
209
199
|
getEnv(`${envName}_BASE_API_KEY`)
|
|
210
200
|
);
|
|
211
201
|
|
|
212
|
-
const looksLikeOpenRouterScope =
|
|
213
|
-
parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
|
|
202
|
+
const looksLikeOpenRouterScope = parts.length === 2 && modelStr.includes(":") && !hasDedicatedEnv;
|
|
214
203
|
|
|
215
204
|
if (!looksLikeOpenRouterScope) {
|
|
216
205
|
const custom = ensureCustomProvider(providerPrefix);
|
|
@@ -250,9 +239,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
250
239
|
}
|
|
251
240
|
|
|
252
241
|
// Check catalog for this exact provider/model
|
|
253
|
-
const catalogSpec =
|
|
254
|
-
tryCatalogLookup(providerPrefix, remainingModel) ||
|
|
255
|
-
tryCatalogLookup(providerPrefix, modelStr);
|
|
242
|
+
const catalogSpec = tryCatalogLookup(providerPrefix, remainingModel) || tryCatalogLookup(providerPrefix, modelStr);
|
|
256
243
|
if (catalogSpec) {
|
|
257
244
|
const internalProvider = getProvider(catalogSpec.provider);
|
|
258
245
|
return { provider: internalProvider, modelId: catalogSpec.id, modelSpec: catalogSpec };
|
|
@@ -276,8 +263,7 @@ export function resolveModel(model: string | ModelSpec): ResolvedModel {
|
|
|
276
263
|
|
|
277
264
|
// Fallback custom provider
|
|
278
265
|
const fallbackCustom = ensureCustomProvider(providerPrefix);
|
|
279
|
-
const fallbackSpec =
|
|
280
|
-
fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
|
|
266
|
+
const fallbackSpec = fallbackCustom.getModel(remainingModel) || fallbackCustom.getModel(modelStr);
|
|
281
267
|
return {
|
|
282
268
|
provider: fallbackCustom,
|
|
283
269
|
modelId: remainingModel,
|
package/src/providers/shared.ts
CHANGED
|
@@ -28,9 +28,7 @@ export function parseArguments(raw: string | undefined): Record<string, unknown>
|
|
|
28
28
|
|
|
29
29
|
/** Trims/collapses assembled thinking parts (no edge-tripling). */
|
|
30
30
|
export function normalizeThinkingParts(parts: string[]): string | undefined {
|
|
31
|
-
const cleaned = parts
|
|
32
|
-
.map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
|
|
33
|
-
.filter((p) => p.length > 0);
|
|
31
|
+
const cleaned = parts.map((p) => p.replace(/\n{3,}/g, "\n\n").trim()).filter((p) => p.length > 0);
|
|
34
32
|
return cleaned.length > 0 ? cleaned.join("\n") : undefined;
|
|
35
33
|
}
|
|
36
34
|
|
|
@@ -49,21 +47,35 @@ export function redactedHeaders(headers: Record<string, string>): Record<string,
|
|
|
49
47
|
* OpenAI `type`, OpenRouter `metadata.error_type` + `metadata.raw` + `metadata.limit_source`, and Google
|
|
50
48
|
* `status` code shapes; unknown bodies degrade to a 300-char slice.
|
|
51
49
|
*/
|
|
52
|
-
export function readErrorPayload(
|
|
53
|
-
|
|
54
|
-
|
|
50
|
+
export function readErrorPayload(bodyText: string): {
|
|
51
|
+
message: string;
|
|
52
|
+
code?: string | number;
|
|
53
|
+
errorType?: string;
|
|
54
|
+
raw?: string;
|
|
55
|
+
limitSource?: string;
|
|
56
|
+
} {
|
|
55
57
|
try {
|
|
56
58
|
const parsed: unknown = JSON.parse(bodyText);
|
|
57
59
|
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
58
|
-
const err = (
|
|
60
|
+
const err = (
|
|
61
|
+
first as {
|
|
62
|
+
error?: {
|
|
63
|
+
message?: string;
|
|
64
|
+
code?: string | number;
|
|
65
|
+
type?: string;
|
|
66
|
+
status?: string | number;
|
|
67
|
+
metadata?: { error_type?: string; raw?: unknown; limit_source?: unknown };
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
)?.error;
|
|
59
71
|
if (err && typeof err.message === "string") {
|
|
60
72
|
const code = err.code ?? err.status;
|
|
61
|
-
const errorType =
|
|
62
|
-
err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
|
|
73
|
+
const errorType = err.type ?? err.metadata?.error_type ?? (first as { error_type?: string })?.error_type;
|
|
63
74
|
const rawCandidate =
|
|
64
75
|
(err as { metadata?: { raw?: unknown } }).metadata?.raw ??
|
|
65
76
|
(first as { metadata?: { raw?: unknown } })?.metadata?.raw;
|
|
66
|
-
const raw =
|
|
77
|
+
const raw =
|
|
78
|
+
typeof rawCandidate === "string" && rawCandidate.trim() ? rawCandidate.trim().slice(0, 500) : undefined;
|
|
67
79
|
return {
|
|
68
80
|
message: err.message,
|
|
69
81
|
...(code !== undefined ? { code } : {}),
|
|
@@ -78,7 +90,7 @@ export function readErrorPayload(
|
|
|
78
90
|
}
|
|
79
91
|
}
|
|
80
92
|
|
|
81
|
-
/** Retryable incomplete-stream error for truncated SSE
|
|
93
|
+
/** Retryable incomplete-stream error for truncated SSE: a `ProviderError` whose `code` is `incomplete_stream`. */
|
|
82
94
|
export function incompleteStreamError(provider: string, modelId: string): Error {
|
|
83
95
|
const err = toConciseProviderError(
|
|
84
96
|
Object.assign(new Error("Stream ended without terminal event"), {
|
|
@@ -110,12 +122,10 @@ export function isNotFoundChainError(error: unknown): boolean {
|
|
|
110
122
|
NaN
|
|
111
123
|
);
|
|
112
124
|
if (status === 404) return true;
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
).toLowerCase();
|
|
118
|
-
return code === "not_found";
|
|
125
|
+
// `providerCode` on a wrapped ProviderError (its `code` is the SDK kind), `code` on a raw error.
|
|
126
|
+
return [err["providerCode"], err["code"], err["errorType"]].some(
|
|
127
|
+
(c) => String(c ?? "").toLowerCase() === "not_found"
|
|
128
|
+
);
|
|
119
129
|
}
|
|
120
130
|
|
|
121
131
|
/** Per-request timeout ms from provider options. */
|
|
@@ -125,25 +135,40 @@ export function timeoutFor(options: { timeoutMs?: number } | undefined): number
|
|
|
125
135
|
}
|
|
126
136
|
|
|
127
137
|
/** 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. */
|
|
128
|
-
export function combinedSignal(
|
|
138
|
+
export function combinedSignal(
|
|
139
|
+
signal?: AbortSignal,
|
|
140
|
+
timeoutMs?: number
|
|
141
|
+
): { signal: AbortSignal | undefined; cleanup: () => void } {
|
|
129
142
|
if (timeoutMs === undefined) return { signal, cleanup: () => {} };
|
|
130
143
|
const ctrl = new AbortController();
|
|
131
144
|
const timer = setTimeout(() => {
|
|
132
|
-
try {
|
|
145
|
+
try {
|
|
146
|
+
ctrl.abort();
|
|
147
|
+
} catch {}
|
|
133
148
|
}, timeoutMs);
|
|
134
|
-
try {
|
|
149
|
+
try {
|
|
150
|
+
(timer as unknown as { unref?: () => void }).unref?.();
|
|
151
|
+
} catch {}
|
|
135
152
|
const cleanup = () => {
|
|
136
|
-
try {
|
|
153
|
+
try {
|
|
154
|
+
clearTimeout(timer);
|
|
155
|
+
} catch {}
|
|
137
156
|
};
|
|
138
157
|
if (signal) {
|
|
139
158
|
if (signal.aborted) {
|
|
140
159
|
cleanup();
|
|
141
160
|
ctrl.abort(signal.reason); // the caller's reason survives (`interrupt(new Error("..."))`)
|
|
142
161
|
} else {
|
|
143
|
-
signal.addEventListener(
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
162
|
+
signal.addEventListener(
|
|
163
|
+
"abort",
|
|
164
|
+
() => {
|
|
165
|
+
cleanup();
|
|
166
|
+
try {
|
|
167
|
+
ctrl.abort(signal.reason);
|
|
168
|
+
} catch {}
|
|
169
|
+
},
|
|
170
|
+
{ once: true }
|
|
171
|
+
);
|
|
147
172
|
}
|
|
148
173
|
}
|
|
149
174
|
return { signal: ctrl.signal, cleanup };
|
|
@@ -171,7 +196,11 @@ export function hasAuthHeader(headers: Record<string, string> | undefined): bool
|
|
|
171
196
|
if (!headers) return false;
|
|
172
197
|
for (const [k, v] of Object.entries(headers)) {
|
|
173
198
|
const lower = k.toLowerCase();
|
|
174
|
-
if (
|
|
199
|
+
if (
|
|
200
|
+
(lower === "authorization" || lower === "cf-aig-authorization") &&
|
|
201
|
+
typeof v === "string" &&
|
|
202
|
+
v.trim().length > 0
|
|
203
|
+
) {
|
|
175
204
|
return true;
|
|
176
205
|
}
|
|
177
206
|
}
|
|
@@ -184,7 +213,10 @@ export function hasTruncatedToolArguments(args: unknown): boolean {
|
|
|
184
213
|
}
|
|
185
214
|
|
|
186
215
|
// True when a stream with no terminal event left a tool call whose arguments never fully arrived (none at all, or cut mid-JSON); in: the adapter's assembled calls and their records, out: its incomplete_stream guard.
|
|
187
|
-
export function hasUnfinishedToolCall(
|
|
216
|
+
export function hasUnfinishedToolCall(
|
|
217
|
+
calls: Iterable<{ startArgs: string; deltaArgs: string }>,
|
|
218
|
+
toolCalls: Array<{ arguments: unknown }>
|
|
219
|
+
): boolean {
|
|
188
220
|
for (const c of calls) if (!c.startArgs.trim() && !c.deltaArgs.trim()) return true;
|
|
189
221
|
return toolCalls.some((c) => isTruncatedArguments(c.arguments));
|
|
190
222
|
}
|
|
@@ -224,7 +256,9 @@ export function clampToolCallId(id: string): string {
|
|
|
224
256
|
|
|
225
257
|
// The finish reason of a turn that produced tool calls: `tool_calls`, except that a turn cut off by the output limit (`length`) keeps saying so when one of its calls was cut mid-JSON; in: the wire finish reason + the assembled calls, out: each streaming adapter.
|
|
226
258
|
export function toolTurnFinishReason(finishReason: string, toolCalls: Array<{ arguments: unknown }>): string {
|
|
227
|
-
return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments))
|
|
259
|
+
return finishReason === "length" && toolCalls.some((c) => isTruncatedArguments(c.arguments))
|
|
260
|
+
? "length"
|
|
261
|
+
: "tool_calls";
|
|
228
262
|
}
|
|
229
263
|
|
|
230
264
|
// The JSON text of a tool call's arguments for a history message: `{}` for a call whose streamed arguments were cut off (the internal `__truncated_raw` marker was never the model's own output and is never resent); in: a stored tool call, out: each adapter's history builder.
|
|
@@ -253,7 +287,8 @@ export function parseRetryAfterMs(headers: Record<string, string> | undefined, n
|
|
|
253
287
|
return undefined;
|
|
254
288
|
};
|
|
255
289
|
const ms = get("retry-after-ms");
|
|
256
|
-
if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0)
|
|
290
|
+
if (ms !== undefined && ms.trim() !== "" && Number.isFinite(Number(ms)) && Number(ms) >= 0)
|
|
291
|
+
return Math.round(Number(ms));
|
|
257
292
|
const ra = get("retry-after");
|
|
258
293
|
if (ra === undefined || ra.trim() === "") return undefined;
|
|
259
294
|
const secs = Number(ra);
|
|
@@ -291,12 +326,16 @@ export function openStreamResponse(args: {
|
|
|
291
326
|
}
|
|
292
327
|
|
|
293
328
|
/** Retry knobs from provider request options, in the shape `withRetries` expects. */
|
|
294
|
-
export function retryOptionsFrom(
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
329
|
+
export function retryOptionsFrom(
|
|
330
|
+
options:
|
|
331
|
+
| {
|
|
332
|
+
maxRetries?: number;
|
|
333
|
+
maxRetryDelayMs?: number;
|
|
334
|
+
retryBaseDelayMs?: number;
|
|
335
|
+
retryBudgetMs?: number;
|
|
336
|
+
}
|
|
337
|
+
| undefined
|
|
338
|
+
): { maxRetries?: number; maxRetryDelayMs?: number; baseDelayMs?: number; retryBudgetMs?: number } {
|
|
300
339
|
return {
|
|
301
340
|
maxRetries: options?.maxRetries,
|
|
302
341
|
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
@@ -310,7 +349,10 @@ export function retryOptionsFrom(options: {
|
|
|
310
349
|
// ---------------------------------------------------------------------------
|
|
311
350
|
|
|
312
351
|
// Ties a provider stream to the caller's signal and to the stream's own cancel: one controller that aborts when either does; in: an adapter's stream(), out: the controller its request uses and the `release` its finally block calls.
|
|
313
|
-
export function linkStreamAbort(
|
|
352
|
+
export function linkStreamAbort(
|
|
353
|
+
eventStream: AssistantMessageEventStream,
|
|
354
|
+
signal: AbortSignal | undefined
|
|
355
|
+
): { linked: AbortController; release: () => void } {
|
|
314
356
|
const linked = new AbortController();
|
|
315
357
|
const forwardUserAbort = (): void => {
|
|
316
358
|
try {
|
|
@@ -342,7 +384,11 @@ export function linkStreamAbort(eventStream: AssistantMessageEventStream, signal
|
|
|
342
384
|
}
|
|
343
385
|
|
|
344
386
|
// Runs one request attempt under a timer that aborts `controller` after `ms` (none when unset); in: an adapter's fetchOnce, out: its Response.
|
|
345
|
-
export async function withAbortTimer<T>(
|
|
387
|
+
export async function withAbortTimer<T>(
|
|
388
|
+
controller: AbortController,
|
|
389
|
+
ms: number | undefined,
|
|
390
|
+
run: () => Promise<T>
|
|
391
|
+
): Promise<T> {
|
|
346
392
|
const timer =
|
|
347
393
|
ms && ms > 0
|
|
348
394
|
? setTimeout(() => {
|
|
@@ -401,13 +447,17 @@ export function incompleteStreamCheck(a: {
|
|
|
401
447
|
}): Error | undefined {
|
|
402
448
|
if (a.hasTerminal || !(a.empty || a.unfinishedCall)) return undefined;
|
|
403
449
|
const contentType = Object.entries(a.responseHeaders).find(([k]) => k.toLowerCase() === "content-type")?.[1];
|
|
404
|
-
const isSSEPayload =
|
|
450
|
+
const isSSEPayload =
|
|
451
|
+
(typeof contentType === "string" && (contentType.includes("event-stream") || contentType.startsWith("text/"))) ||
|
|
452
|
+
a.framesSeen > 0 ||
|
|
453
|
+
a.badFrames > 0;
|
|
405
454
|
return isSSEPayload ? incompleteStreamError(a.provider, a.modelId) : undefined;
|
|
406
455
|
}
|
|
407
456
|
|
|
408
457
|
// Fails a stream with the right error: an abort (the signal, the stream's cancel, or an AbortError) becomes the plain `AbortError`, anything else passes through; in: an adapter's catch block, out: the stream's result() rejection.
|
|
409
458
|
export function failStream(eventStream: AssistantMessageEventStream, linked: AbortController, err: unknown): void {
|
|
410
459
|
const raw = err instanceof Error ? err : new Error(String(err));
|
|
411
|
-
const isAbort =
|
|
460
|
+
const isAbort =
|
|
461
|
+
linked.signal.aborted || eventStream.isCancelled() || (raw as { name?: string }).name === "AbortError";
|
|
412
462
|
eventStream.fail(isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw);
|
|
413
463
|
}
|