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
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Strictness contract (differs from the old lenient passthrough on purpose):
|
|
14
14
|
* - Requests carry ONLY standard Chat Completions fields (`model`, `messages`,
|
|
15
|
-
* `tools`, `tool_choice`, `stream
|
|
15
|
+
* `tools`, `tool_choice`, `stream`, plus `max_tokens`/
|
|
16
|
+
* `stop`/`seed` when explicitly set). No `reasoning`, `service_tier`,
|
|
16
17
|
* `session_id`, `prompt_cache_key`, or other extras: strict endpoints
|
|
17
18
|
* (groq, ollama, …) fail unknown body properties, so anything unsupported
|
|
18
19
|
* is warn+drop, never sent.
|
|
@@ -60,6 +61,7 @@ import { normalizeMediaInput } from "../utils/media.ts";
|
|
|
60
61
|
import { safeStringify } from "../utils/serialization.ts";
|
|
61
62
|
import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
|
|
62
63
|
import { withRetries } from "../utils/retry.ts";
|
|
64
|
+
import { ConfigError } from "../types/errors.ts";
|
|
63
65
|
import {
|
|
64
66
|
parseArguments,
|
|
65
67
|
normalizeThinkingParts,
|
|
@@ -67,16 +69,28 @@ import {
|
|
|
67
69
|
redactedHeaders,
|
|
68
70
|
readErrorPayload,
|
|
69
71
|
incompleteStreamError,
|
|
72
|
+
hasTruncatedToolArguments,
|
|
70
73
|
combinedSignal,
|
|
71
74
|
timeoutFor,
|
|
75
|
+
hasAuthHeader,
|
|
76
|
+
clampToolCallId,
|
|
77
|
+
openStreamResponse,
|
|
78
|
+
retryOptionsFrom,
|
|
79
|
+
headersToRecord,
|
|
80
|
+
parseRetryAfterMs,
|
|
72
81
|
} from "./shared.ts";
|
|
73
|
-
import { getModelFromCatalog, createGenericModelSpec } from "../models/catalog.ts";
|
|
82
|
+
import { getModelFromCatalog, createGenericModelSpec, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
74
83
|
import {
|
|
75
84
|
applyCacheForCustom,
|
|
76
85
|
mapToolChoiceToOpenAI,
|
|
77
86
|
emitProviderWarning,
|
|
78
87
|
noteProviderTurn,
|
|
79
88
|
parseStreamedToolArguments,
|
|
89
|
+
selectRawArguments,
|
|
90
|
+
mapOutputToCompatChat,
|
|
91
|
+
mapGenerationParamsToChat,
|
|
92
|
+
normalizeStructuredOutput,
|
|
93
|
+
augmentSystemPromptWithStructuredOutput,
|
|
80
94
|
} from "../providers.ts";
|
|
81
95
|
|
|
82
96
|
// ---------------------------------------------------------------------------
|
|
@@ -110,6 +124,7 @@ interface ChatRequestBody {
|
|
|
110
124
|
messages: ChatMessage[];
|
|
111
125
|
tools?: Array<Record<string, unknown>>;
|
|
112
126
|
tool_choice?: string | { type: "function"; function: { name: string } };
|
|
127
|
+
response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
113
128
|
stream?: boolean;
|
|
114
129
|
[k: string]: unknown;
|
|
115
130
|
}
|
|
@@ -167,8 +182,6 @@ interface ChatResponse {
|
|
|
167
182
|
[k: string]: unknown;
|
|
168
183
|
}
|
|
169
184
|
|
|
170
|
-
const DEFAULT_BASE_URL = "https://api.openai.com/v1";
|
|
171
|
-
|
|
172
185
|
/** Headers that must never leak onto native REST requests. */
|
|
173
186
|
const INTERNAL_HEADERS = new Set([
|
|
174
187
|
"x-thought-signature-map",
|
|
@@ -190,7 +203,7 @@ function resolveBaseUrl(
|
|
|
190
203
|
options?: ProviderRequestOptions
|
|
191
204
|
): string {
|
|
192
205
|
const envPrefix = envPrefixOf(prefix);
|
|
193
|
-
|
|
206
|
+
const resolved =
|
|
194
207
|
options?.baseUrl ||
|
|
195
208
|
configuredBaseUrl ||
|
|
196
209
|
options?.env?.[`${envPrefix}_BASE_URL`] ||
|
|
@@ -198,9 +211,15 @@ function resolveBaseUrl(
|
|
|
198
211
|
options?.env?.[`${envPrefix}_API_BASE`] ||
|
|
199
212
|
getEnv(`${envPrefix}_BASE_URL`) ||
|
|
200
213
|
getEnv(`${envPrefix}_BASEURL`) ||
|
|
201
|
-
getEnv(`${envPrefix}_API_BASE`)
|
|
202
|
-
|
|
203
|
-
|
|
214
|
+
getEnv(`${envPrefix}_API_BASE`);
|
|
215
|
+
// Never fall back to another vendor's cloud: a custom prefix's API key
|
|
216
|
+
// must only ever travel to the endpoint the developer named.
|
|
217
|
+
if (!resolved) {
|
|
218
|
+
throw new ConfigError(
|
|
219
|
+
`[Agent Accelerator] Missing base URL for "${prefix}". Set ${envPrefix}_BASE_URL (or pass baseUrl); custom endpoints never default to api.openai.com.`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return resolved.replace(/\/+$/, "");
|
|
204
223
|
}
|
|
205
224
|
|
|
206
225
|
function resolveApiKey(
|
|
@@ -208,17 +227,23 @@ function resolveApiKey(
|
|
|
208
227
|
configuredApiKey: string | undefined,
|
|
209
228
|
options?: ProviderRequestOptions
|
|
210
229
|
): string | undefined {
|
|
230
|
+
const clean = (v: unknown): string | undefined => {
|
|
231
|
+
if (typeof v !== "string") return undefined;
|
|
232
|
+
const t = v.trim().replace(/^["']|["']$/g, "").trim();
|
|
233
|
+
return t || undefined;
|
|
234
|
+
};
|
|
211
235
|
const envPrefix = envPrefixOf(prefix);
|
|
212
|
-
|
|
213
|
-
options?.apiKey ||
|
|
214
|
-
configuredApiKey ||
|
|
215
|
-
options?.env?.[`${envPrefix}_API_KEY`] ||
|
|
216
|
-
options?.env?.[`${envPrefix}_BASE_API_KEY`] ||
|
|
217
|
-
getEnv(`${envPrefix}_API_KEY`) ||
|
|
218
|
-
getEnv(`${envPrefix}_BASE_API_KEY`) ||
|
|
236
|
+
const key = (
|
|
237
|
+
clean(options?.apiKey) ||
|
|
238
|
+
clean(configuredApiKey) ||
|
|
239
|
+
clean(options?.env?.[`${envPrefix}_API_KEY`]) ||
|
|
240
|
+
clean(options?.env?.[`${envPrefix}_BASE_API_KEY`]) ||
|
|
219
241
|
getApiKey(prefix, undefined, options?.env) ||
|
|
220
242
|
undefined
|
|
221
243
|
);
|
|
244
|
+
if (key) return key;
|
|
245
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
246
|
+
return undefined;
|
|
222
247
|
}
|
|
223
248
|
|
|
224
249
|
/** Local endpoints (loopback / LAN / *.local) may omit the API key. Literal IPs only; hostnames like 10.evil.com are not local. */
|
|
@@ -395,14 +420,18 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
395
420
|
if (context.systemPrompt) {
|
|
396
421
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
397
422
|
}
|
|
423
|
+
let lastRole: string | null = "system";
|
|
398
424
|
for (const m of context.messages) {
|
|
399
425
|
if (m.role === "system") continue;
|
|
400
426
|
if (m.role === "user") {
|
|
427
|
+
if (lastRole === "tool") {
|
|
428
|
+
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
429
|
+
}
|
|
401
430
|
if (typeof m.content === "string") {
|
|
402
|
-
if (m.content) messages.push({ role: "user", content: m.content });
|
|
431
|
+
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
403
432
|
} else {
|
|
404
433
|
const blocks = await contentPartsToBlocks(m.content);
|
|
405
|
-
if (blocks.length > 0) messages.push({ role: "user", content: blocks });
|
|
434
|
+
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
406
435
|
}
|
|
407
436
|
} else if (m.role === "assistant") {
|
|
408
437
|
if (typeof m.content === "string") {
|
|
@@ -426,7 +455,7 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
426
455
|
function: { name: string; arguments: string };
|
|
427
456
|
extra_content?: { google: { thought_signature: string } };
|
|
428
457
|
} = {
|
|
429
|
-
id: part.callId || part.id,
|
|
458
|
+
id: clampToolCallId(part.callId || part.id),
|
|
430
459
|
type: "function",
|
|
431
460
|
function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
|
|
432
461
|
};
|
|
@@ -443,21 +472,26 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
443
472
|
texts.push(part.text);
|
|
444
473
|
}
|
|
445
474
|
}
|
|
475
|
+
// Skip empty assistant turns (no text + no tool calls): strict
|
|
476
|
+
// routes reject them, and they carry no signal (pi drop rule).
|
|
477
|
+
if (texts.length === 0 && calls.length === 0) continue;
|
|
446
478
|
messages.push({
|
|
447
479
|
role: "assistant",
|
|
448
480
|
content: texts.length > 0 ? texts.join("\n") : null,
|
|
449
481
|
...(calls.length > 0 ? { tool_calls: calls } : {}),
|
|
450
482
|
});
|
|
483
|
+
lastRole = "assistant";
|
|
451
484
|
} else if (m.role === "tool") {
|
|
452
485
|
if (!Array.isArray(m.content)) continue;
|
|
453
486
|
for (const part of m.content) {
|
|
454
487
|
if (part.type === "tool_result") {
|
|
455
488
|
messages.push({
|
|
456
489
|
role: "tool",
|
|
457
|
-
tool_call_id: pairing.get(part.id) || part.id,
|
|
490
|
+
tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
|
|
458
491
|
content: resultToOutput(part.result),
|
|
459
492
|
name: part.name,
|
|
460
493
|
});
|
|
494
|
+
lastRole = "tool";
|
|
461
495
|
}
|
|
462
496
|
}
|
|
463
497
|
}
|
|
@@ -551,7 +585,7 @@ function parseResponse(
|
|
|
551
585
|
const args = parseArguments(tc.function?.arguments);
|
|
552
586
|
const sig = extractSignature(tc);
|
|
553
587
|
toolCalls.push({
|
|
554
|
-
id: tc.id || newToolCallId(),
|
|
588
|
+
id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
|
|
555
589
|
name: tc.function?.name || "unknown",
|
|
556
590
|
arguments: args,
|
|
557
591
|
rawArguments:
|
|
@@ -590,7 +624,7 @@ export interface CustomProviderOptions {
|
|
|
590
624
|
baseUrl?: string;
|
|
591
625
|
/** Static apiKey — overrides env. Explicit per-call `apiKey` still wins. */
|
|
592
626
|
apiKey?: string;
|
|
593
|
-
/** Default baseUrl when no env is set.
|
|
627
|
+
/** Default baseUrl when no env is set. There is no implicit default: without one the request fails fast. */
|
|
594
628
|
defaultBaseUrl?: string;
|
|
595
629
|
}
|
|
596
630
|
|
|
@@ -678,7 +712,32 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
678
712
|
}
|
|
679
713
|
applyCacheForCustom(this.prefix, options?.cache, `${this.prefix}/${modelId}`);
|
|
680
714
|
|
|
681
|
-
|
|
715
|
+
// Structured outputs: native `response_format` when supported, else
|
|
716
|
+
// emulated via system prompt (strict endpoints would 400 on json_schema
|
|
717
|
+
// for catalog `structured_output: false` models).
|
|
718
|
+
let effectiveContext = context;
|
|
719
|
+
let outputEmulated = false;
|
|
720
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
721
|
+
try {
|
|
722
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
723
|
+
} catch {
|
|
724
|
+
outputSpec = undefined;
|
|
725
|
+
}
|
|
726
|
+
if (outputSpec && !modelSupportsStructuredOutput(this.prefix, modelId)) {
|
|
727
|
+
outputEmulated = true;
|
|
728
|
+
effectiveContext = {
|
|
729
|
+
...context,
|
|
730
|
+
systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
|
|
731
|
+
};
|
|
732
|
+
emitProviderWarning({
|
|
733
|
+
provider: this.prefix,
|
|
734
|
+
capability: "structured output",
|
|
735
|
+
requested: `${outputSpec.name} (${this.prefix}/${modelId})`,
|
|
736
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
737
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
const messages = await fullHistoryMessages(effectiveContext);
|
|
682
741
|
const body: ChatRequestBody = {
|
|
683
742
|
model: modelId,
|
|
684
743
|
messages,
|
|
@@ -686,6 +745,8 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
686
745
|
};
|
|
687
746
|
const compatTools = toCompatTools(tools);
|
|
688
747
|
if (compatTools) body.tools = compatTools;
|
|
748
|
+
// Standard Chat Completions generation fields, only when the caller set them.
|
|
749
|
+
Object.assign(body, mapGenerationParamsToChat(options));
|
|
689
750
|
const toolChoice = mapToolChoiceToOpenAI(
|
|
690
751
|
options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
|
|
691
752
|
);
|
|
@@ -697,8 +758,19 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
697
758
|
? { type: "function", function: { name: (toolChoice as { name: string }).name } }
|
|
698
759
|
: (toolChoice as ChatRequestBody["tool_choice"]);
|
|
699
760
|
}
|
|
700
|
-
//
|
|
701
|
-
//
|
|
761
|
+
// Structured outputs use the standard Chat Completions `response_format`
|
|
762
|
+
// (safe on strict endpoints). Skipped when emulated (instruction already
|
|
763
|
+
// in messages). No reasoning / service_tier / session /
|
|
764
|
+
// cache body primitives: strict endpoints reject unknown properties.
|
|
765
|
+
// Affinity stays headers-only.
|
|
766
|
+
if (outputSpec && !outputEmulated) {
|
|
767
|
+
try {
|
|
768
|
+
const mapped = mapOutputToCompatChat(outputSpec);
|
|
769
|
+
if (mapped) body.response_format = mapped.response_format;
|
|
770
|
+
} catch {
|
|
771
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
772
|
+
}
|
|
773
|
+
}
|
|
702
774
|
// Q-38: request usage on streams where supported so terminal accounting
|
|
703
775
|
// arrives on the final chunk. Strict servers that reject unknown fields
|
|
704
776
|
// opt out via `COMPAT_NO_STREAM_OPTIONS=1`; an explicit per-call
|
|
@@ -731,8 +803,26 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
731
803
|
for (const [k, v] of Object.entries(headers)) {
|
|
732
804
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
733
805
|
}
|
|
806
|
+
// Strict-endpoint opt-out (pi sendSessionAffinityHeaders pattern):
|
|
807
|
+
// `COMPAT_NO_SESSION_HEADERS=1` or per-call `sendSessionHeaders:false`
|
|
808
|
+
// drops affinity headers that trigger CORS preflights / strict 400s.
|
|
809
|
+
// Body `session_id` is already omitted on compat (headers-only affinity).
|
|
810
|
+
try {
|
|
811
|
+
const noAffinity =
|
|
812
|
+
(options as { sendSessionHeaders?: unknown } | undefined)?.sendSessionHeaders === false ||
|
|
813
|
+
getEnv("COMPAT_NO_SESSION_HEADERS") === "1" ||
|
|
814
|
+
options?.env?.["COMPAT_NO_SESSION_HEADERS"] === "1";
|
|
815
|
+
if (noAffinity) {
|
|
816
|
+
for (const k of Object.keys(headers)) {
|
|
817
|
+
const lower = k.toLowerCase();
|
|
818
|
+
if (lower === "x-session-id" || lower === "x-client-request-id" || lower === "session_id") {
|
|
819
|
+
delete headers[k];
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
} catch {}
|
|
734
824
|
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
735
|
-
if (apiKey) setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
825
|
+
if (apiKey && apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
736
826
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
737
827
|
}
|
|
738
828
|
|
|
@@ -746,7 +836,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
746
836
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
747
837
|
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
748
838
|
try {
|
|
749
|
-
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
839
|
+
const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
750
840
|
const text = await res.text();
|
|
751
841
|
const headers: Record<string, string> = {};
|
|
752
842
|
res.headers.forEach((v, k) => {
|
|
@@ -762,17 +852,27 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
762
852
|
status: number,
|
|
763
853
|
url: string,
|
|
764
854
|
bodyText: string,
|
|
765
|
-
modelId: string
|
|
855
|
+
modelId: string,
|
|
856
|
+
headers?: Record<string, string>
|
|
766
857
|
): void {
|
|
767
858
|
if (status >= 200 && status < 300) return;
|
|
768
|
-
const { message, code, errorType } = readErrorPayload(bodyText);
|
|
769
|
-
const
|
|
859
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
860
|
+
const hint =
|
|
861
|
+
status === 401
|
|
862
|
+
? ` Check ${envPrefixOf(this.prefix)}_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local).`
|
|
863
|
+
: "";
|
|
864
|
+
const fullMessage = `${message}${hint}`;
|
|
865
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
770
866
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
771
867
|
(err as Record<string, unknown>)["status"] = status;
|
|
772
868
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
773
869
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
870
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
774
871
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
775
872
|
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
873
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
874
|
+
const retryAfterMs = parseRetryAfterMs(headers);
|
|
875
|
+
if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
|
|
776
876
|
throw toConciseProviderError(err, this.prefix, modelId);
|
|
777
877
|
}
|
|
778
878
|
|
|
@@ -783,13 +883,13 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
783
883
|
): Promise<ProviderGenerateResult> {
|
|
784
884
|
const startTime = Date.now();
|
|
785
885
|
const clean = cleanModelId(this.prefix, model);
|
|
786
|
-
const baseUrl = this.baseUrl(options);
|
|
787
|
-
const apiKey = this.requireApiKey(baseUrl, options);
|
|
788
886
|
// Fail fast before any network call when the catalog knows the model
|
|
789
887
|
// lacks the requested modality. Unknown models skip the guard; the
|
|
790
|
-
// endpoint verdict surfaces concisely.
|
|
888
|
+
// endpoint verdict surfaces concisely. Cheap local checks run first.
|
|
791
889
|
assertModalitiesSupported(context, this.prefix, clean);
|
|
792
890
|
assertNoVideoOrFileParts(context, this.prefix, clean);
|
|
891
|
+
const baseUrl = this.baseUrl(options);
|
|
892
|
+
const apiKey = this.requireApiKey(baseUrl, options);
|
|
793
893
|
const url = `${baseUrl}/chat/completions`;
|
|
794
894
|
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
795
895
|
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
@@ -812,7 +912,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
812
912
|
|
|
813
913
|
const doCall = async (): Promise<ProviderGenerateResult> => {
|
|
814
914
|
const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
|
|
815
|
-
this.throwIfError(res.status, url, res.text, clean);
|
|
915
|
+
this.throwIfError(res.status, url, res.text, clean, res.headers);
|
|
816
916
|
let response: ChatResponse;
|
|
817
917
|
try {
|
|
818
918
|
response = JSON.parse(res.text) as ChatResponse;
|
|
@@ -841,8 +941,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
841
941
|
|
|
842
942
|
try {
|
|
843
943
|
return await withRetries(doCall, {
|
|
844
|
-
|
|
845
|
-
maxRetryDelayMs: options?.maxRetryDelayMs,
|
|
944
|
+
...retryOptionsFrom(options),
|
|
846
945
|
signal: options?.signal,
|
|
847
946
|
label: { providerId: this.prefix, modelId: clean },
|
|
848
947
|
});
|
|
@@ -882,10 +981,10 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
882
981
|
|
|
883
982
|
(async () => {
|
|
884
983
|
try {
|
|
885
|
-
const baseUrl = this.baseUrl(options);
|
|
886
|
-
const apiKey = this.requireApiKey(baseUrl, options);
|
|
887
984
|
assertModalitiesSupported(context, prefix, clean);
|
|
888
985
|
assertNoVideoOrFileParts(context, prefix, clean);
|
|
986
|
+
const baseUrl = this.baseUrl(options);
|
|
987
|
+
const apiKey = this.requireApiKey(baseUrl, options);
|
|
889
988
|
const url = `${baseUrl}/chat/completions`;
|
|
890
989
|
const sessionId = options?.sessionId || options?.cache?.sessionId;
|
|
891
990
|
const tools = options?.tools as StandardToolDeclaration[] | undefined;
|
|
@@ -907,25 +1006,30 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
907
1006
|
};
|
|
908
1007
|
|
|
909
1008
|
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
1009
|
+
// Transient failures (429/5xx/network) are retried here, before the
|
|
1010
|
+
// first byte; once the stream is live it is never retried.
|
|
1011
|
+
const res = await openStreamResponse({
|
|
1012
|
+
fetchOnce: async () => {
|
|
1013
|
+
const timer =
|
|
1014
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
1015
|
+
? setTimeout(() => {
|
|
1016
|
+
try {
|
|
1017
|
+
linked.abort();
|
|
1018
|
+
} catch {}
|
|
1019
|
+
}, streamTimeoutMs)
|
|
1020
|
+
: undefined;
|
|
1021
|
+
try {
|
|
1022
|
+
return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
1023
|
+
} finally {
|
|
1024
|
+
if (timer) clearTimeout(timer);
|
|
1025
|
+
}
|
|
1026
|
+
},
|
|
1027
|
+
onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
|
|
1028
|
+
noBodyError: () => toConciseProviderError(new Error("OpenAI-compatible streaming response had no body"), prefix, clean),
|
|
1029
|
+
retry: retryOptionsFrom(options),
|
|
1030
|
+
signal: linked.signal,
|
|
1031
|
+
label: { providerId: prefix, modelId: clean },
|
|
1032
|
+
});
|
|
929
1033
|
const responseHeaders: Record<string, string> = {};
|
|
930
1034
|
res.headers.forEach((v, k) => {
|
|
931
1035
|
responseHeaders[k] = v;
|
|
@@ -934,7 +1038,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
934
1038
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
935
1039
|
|
|
936
1040
|
const parser = new SSEParser();
|
|
937
|
-
const reader = res.body
|
|
1041
|
+
const reader = res.body!.getReader(); // openStreamResponse guarantees a body
|
|
938
1042
|
const decoder = new TextDecoder();
|
|
939
1043
|
let text = "";
|
|
940
1044
|
let thinking = "";
|
|
@@ -1113,10 +1217,10 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1113
1217
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
1114
1218
|
const sig = signatures.get(index);
|
|
1115
1219
|
const record: ToolCallRecord = {
|
|
1116
|
-
id: c.id || newToolCallId(),
|
|
1220
|
+
id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
|
|
1117
1221
|
name: c.name,
|
|
1118
1222
|
arguments: args,
|
|
1119
|
-
rawArguments: c.startArgs
|
|
1223
|
+
rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
|
|
1120
1224
|
...withCompatSignature(sig),
|
|
1121
1225
|
} as ToolCallRecord;
|
|
1122
1226
|
toolCalls.push(record);
|
|
@@ -1146,6 +1250,13 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1146
1250
|
eventStream.fail(incompleteStreamError(prefix, clean));
|
|
1147
1251
|
return;
|
|
1148
1252
|
}
|
|
1253
|
+
// Refuse unfinished tool calls whose args never parsed (output_item.done
|
|
1254
|
+
// never arrived): truncated `{raw}` must not reach the executor (pi
|
|
1255
|
+
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1256
|
+
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1257
|
+
eventStream.fail(incompleteStreamError(prefix, clean));
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1149
1260
|
|
|
1150
1261
|
const cleanThinking = cleanThinkingEarly;
|
|
1151
1262
|
const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
|
|
@@ -1172,8 +1283,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1172
1283
|
const isAbort =
|
|
1173
1284
|
linked.signal.aborted ||
|
|
1174
1285
|
eventStream.isCancelled() ||
|
|
1175
|
-
(raw as { name?: string }).name === "AbortError"
|
|
1176
|
-
/abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
|
|
1286
|
+
(raw as { name?: string }).name === "AbortError";
|
|
1177
1287
|
eventStream.fail(
|
|
1178
1288
|
isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
|
|
1179
1289
|
);
|