agent-accelerator 0.3.0 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent/agent.ts +845 -116
- package/src/agent/context.ts +35 -0
- package/src/agent/delegation.ts +390 -36
- package/src/agent/loop.ts +785 -110
- package/src/agent/subagent.ts +59 -0
- package/src/index.ts +173 -59
- package/src/models/catalog-cache.ts +68 -25
- package/src/models/catalog.ts +123 -18
- package/src/providers/google.ts +152 -43
- package/src/providers/openai-compat.ts +246 -96
- package/src/providers/openai.ts +274 -79
- package/src/providers/openrouter.ts +191 -82
- package/src/providers/registry.ts +16 -12
- package/src/providers/shared.ts +190 -0
- package/src/providers.ts +259 -9
- package/src/session/store.ts +521 -33
- package/src/streaming/event-stream.ts +156 -41
- package/src/tools/executor.ts +248 -15
- package/src/tools/tool.ts +147 -3
- package/src/types/agent.ts +244 -2
- package/src/types/errors.ts +133 -0
- package/src/types/model.ts +22 -1
- package/src/types/response.ts +133 -3
- package/src/types/tool.ts +69 -0
- package/src/update-models.ts +8 -2
- package/src/utils/cache.ts +68 -1
- package/src/utils/env.ts +23 -13
- package/src/utils/errors.ts +15 -1
- package/src/utils/headers.ts +153 -19
- package/src/utils/retry.ts +27 -13
- package/src/utils/session.ts +284 -0
- package/src/providers/openrouter-responses.ts +0 -949
- package/src/types/provider-payloads.ts +0 -352
|
@@ -55,18 +55,34 @@ import { AssistantMessageEventStream } from "../streaming/event-stream.ts";
|
|
|
55
55
|
import { SSEParser } from "../streaming/sse-parser.ts";
|
|
56
56
|
import { AgentResponse } from "../types/response.ts";
|
|
57
57
|
import { getApiKey, getEnv } from "../utils/env.ts";
|
|
58
|
-
import { buildSessionHeaders } from "../utils/headers.ts";
|
|
58
|
+
import { buildSessionHeaders, setHeaderCaseInsensitive, shouldEchoSignature } from "../utils/headers.ts";
|
|
59
59
|
import { normalizeMediaInput } from "../utils/media.ts";
|
|
60
60
|
import { safeStringify } from "../utils/serialization.ts";
|
|
61
61
|
import { toConciseProviderError, assertModalitiesSupported } from "../utils/errors.ts";
|
|
62
62
|
import { withRetries } from "../utils/retry.ts";
|
|
63
|
-
import {
|
|
63
|
+
import {
|
|
64
|
+
parseArguments,
|
|
65
|
+
normalizeThinkingParts,
|
|
66
|
+
newToolCallId,
|
|
67
|
+
redactedHeaders,
|
|
68
|
+
readErrorPayload,
|
|
69
|
+
incompleteStreamError,
|
|
70
|
+
hasTruncatedToolArguments,
|
|
71
|
+
combinedSignal,
|
|
72
|
+
timeoutFor,
|
|
73
|
+
hasAuthHeader,
|
|
74
|
+
clampToolCallId,
|
|
75
|
+
} from "./shared.ts";
|
|
76
|
+
import { getModelFromCatalog, createGenericModelSpec, modelSupportsStructuredOutput } from "../models/catalog.ts";
|
|
64
77
|
import {
|
|
65
78
|
applyCacheForCustom,
|
|
66
79
|
mapToolChoiceToOpenAI,
|
|
67
80
|
emitProviderWarning,
|
|
68
81
|
noteProviderTurn,
|
|
69
82
|
parseStreamedToolArguments,
|
|
83
|
+
mapOutputToCompatChat,
|
|
84
|
+
normalizeStructuredOutput,
|
|
85
|
+
augmentSystemPromptWithStructuredOutput,
|
|
70
86
|
} from "../providers.ts";
|
|
71
87
|
|
|
72
88
|
// ---------------------------------------------------------------------------
|
|
@@ -100,6 +116,7 @@ interface ChatRequestBody {
|
|
|
100
116
|
messages: ChatMessage[];
|
|
101
117
|
tools?: Array<Record<string, unknown>>;
|
|
102
118
|
tool_choice?: string | { type: "function"; function: { name: string } };
|
|
119
|
+
response_format?: { type: "json_schema"; json_schema: { name: string; schema: Record<string, unknown>; strict: boolean; description?: string } };
|
|
103
120
|
stream?: boolean;
|
|
104
121
|
[k: string]: unknown;
|
|
105
122
|
}
|
|
@@ -189,8 +206,6 @@ function resolveBaseUrl(
|
|
|
189
206
|
getEnv(`${envPrefix}_BASE_URL`) ||
|
|
190
207
|
getEnv(`${envPrefix}_BASEURL`) ||
|
|
191
208
|
getEnv(`${envPrefix}_API_BASE`) ||
|
|
192
|
-
getEnv("OPENAI_BASE_URL") ||
|
|
193
|
-
getEnv("OPENAI_API_BASE") ||
|
|
194
209
|
DEFAULT_BASE_URL
|
|
195
210
|
).replace(/\/+$/, "");
|
|
196
211
|
}
|
|
@@ -200,38 +215,48 @@ function resolveApiKey(
|
|
|
200
215
|
configuredApiKey: string | undefined,
|
|
201
216
|
options?: ProviderRequestOptions
|
|
202
217
|
): string | undefined {
|
|
218
|
+
const clean = (v: unknown): string | undefined => {
|
|
219
|
+
if (typeof v !== "string") return undefined;
|
|
220
|
+
const t = v.trim().replace(/^["']|["']$/g, "").trim();
|
|
221
|
+
return t || undefined;
|
|
222
|
+
};
|
|
203
223
|
const envPrefix = envPrefixOf(prefix);
|
|
204
|
-
|
|
205
|
-
options?.apiKey ||
|
|
206
|
-
configuredApiKey ||
|
|
207
|
-
options?.env?.[`${envPrefix}_API_KEY`] ||
|
|
208
|
-
options?.env?.[`${envPrefix}_BASE_API_KEY`] ||
|
|
209
|
-
options?.env?.["OPENAI_BASE_API_KEY"] ||
|
|
210
|
-
options?.env?.["OPENAI_API_KEY"] ||
|
|
211
|
-
getEnv(`${envPrefix}_API_KEY`) ||
|
|
212
|
-
getEnv(`${envPrefix}_BASE_API_KEY`) ||
|
|
213
|
-
getEnv("OPENAI_BASE_API_KEY") ||
|
|
214
|
-
getEnv("OPENAI_API_KEY") ||
|
|
224
|
+
const key = (
|
|
225
|
+
clean(options?.apiKey) ||
|
|
226
|
+
clean(configuredApiKey) ||
|
|
227
|
+
clean(options?.env?.[`${envPrefix}_API_KEY`]) ||
|
|
228
|
+
clean(options?.env?.[`${envPrefix}_BASE_API_KEY`]) ||
|
|
215
229
|
getApiKey(prefix, undefined, options?.env) ||
|
|
216
230
|
undefined
|
|
217
231
|
);
|
|
232
|
+
if (key) return key;
|
|
233
|
+
if (hasAuthHeader(options?.headers)) return "unused";
|
|
234
|
+
return undefined;
|
|
218
235
|
}
|
|
219
236
|
|
|
220
|
-
/** Local endpoints (loopback / LAN / *.local) may omit the API key. */
|
|
237
|
+
/** Local endpoints (loopback / LAN / *.local) may omit the API key. Literal IPs only; hostnames like 10.evil.com are not local. */
|
|
221
238
|
function isLocalEndpoint(baseUrl: string): boolean {
|
|
222
239
|
try {
|
|
223
|
-
|
|
240
|
+
let host = new URL(baseUrl).hostname.toLowerCase();
|
|
241
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1);
|
|
224
242
|
if (
|
|
225
243
|
host === "localhost" ||
|
|
226
244
|
host === "127.0.0.1" ||
|
|
227
245
|
host === "0.0.0.0" ||
|
|
228
246
|
host === "::1" ||
|
|
247
|
+
host === "0:0:0:0:0:0:0:1" ||
|
|
229
248
|
host.endsWith(".local")
|
|
230
249
|
) {
|
|
231
250
|
return true;
|
|
232
251
|
}
|
|
233
|
-
if (
|
|
234
|
-
|
|
252
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
|
253
|
+
const parts = host.split(".").map(Number);
|
|
254
|
+
const [a, b] = parts as number[];
|
|
255
|
+
if (a === 10) return true;
|
|
256
|
+
if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true;
|
|
257
|
+
if (a === 192 && b === 168) return true;
|
|
258
|
+
if (a === 127) return true;
|
|
259
|
+
return false;
|
|
235
260
|
}
|
|
236
261
|
return false;
|
|
237
262
|
} catch {
|
|
@@ -362,6 +387,10 @@ function resultToOutput(result: unknown): string {
|
|
|
362
387
|
* transport: every turn carries everything). Prior-turn reasoning is NOT
|
|
363
388
|
* resent — except echoed Gemini thought signatures, which strict
|
|
364
389
|
* OpenAI-compatible endpoints require on tool calls they generated.
|
|
390
|
+
*
|
|
391
|
+
* Q-18: signatures are namespaced — only echo when the stored provider is
|
|
392
|
+
* `google` (or untagged legacy). Signatures minted by other providers never
|
|
393
|
+
* leak onto this transport.
|
|
365
394
|
*/
|
|
366
395
|
async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessage[]> {
|
|
367
396
|
const pairing = new Map<string, string>();
|
|
@@ -379,14 +408,18 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
379
408
|
if (context.systemPrompt) {
|
|
380
409
|
messages.push({ role: "system", content: context.systemPrompt });
|
|
381
410
|
}
|
|
411
|
+
let lastRole: string | null = "system";
|
|
382
412
|
for (const m of context.messages) {
|
|
383
413
|
if (m.role === "system") continue;
|
|
384
414
|
if (m.role === "user") {
|
|
415
|
+
if (lastRole === "tool") {
|
|
416
|
+
messages.push({ role: "assistant", content: "I have processed the tool results." });
|
|
417
|
+
}
|
|
385
418
|
if (typeof m.content === "string") {
|
|
386
|
-
if (m.content) messages.push({ role: "user", content: m.content });
|
|
419
|
+
if (m.content) { messages.push({ role: "user", content: m.content }); lastRole = "user"; }
|
|
387
420
|
} else {
|
|
388
421
|
const blocks = await contentPartsToBlocks(m.content);
|
|
389
|
-
if (blocks.length > 0) messages.push({ role: "user", content: blocks });
|
|
422
|
+
if (blocks.length > 0) { messages.push({ role: "user", content: blocks }); lastRole = "user"; }
|
|
390
423
|
}
|
|
391
424
|
} else if (m.role === "assistant") {
|
|
392
425
|
if (typeof m.content === "string") {
|
|
@@ -410,15 +443,16 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
410
443
|
function: { name: string; arguments: string };
|
|
411
444
|
extra_content?: { google: { thought_signature: string } };
|
|
412
445
|
} = {
|
|
413
|
-
id: part.callId || part.id,
|
|
446
|
+
id: clampToolCallId(part.callId || part.id),
|
|
414
447
|
type: "function",
|
|
415
448
|
function: { name: part.name, arguments: JSON.stringify(part.arguments || {}) },
|
|
416
449
|
};
|
|
417
450
|
// Echo provider-issued thought signatures verbatim so endpoints
|
|
418
451
|
// that minted them (Gemini behind a compat proxy) keep working.
|
|
419
452
|
// Only present when a previous turn captured one — other endpoints
|
|
420
|
-
// never see this field.
|
|
421
|
-
|
|
453
|
+
// never see this field. Q-18: echo only google/untagged signatures.
|
|
454
|
+
const storedProvider = (part as { thoughtSignatureProvider?: unknown }).thoughtSignatureProvider;
|
|
455
|
+
if (part.thoughtSignature && shouldEchoSignature(storedProvider, "google")) {
|
|
422
456
|
call.extra_content = { google: { thought_signature: part.thoughtSignature } };
|
|
423
457
|
}
|
|
424
458
|
calls.push(call);
|
|
@@ -426,21 +460,26 @@ async function fullHistoryMessages(context: ProviderContext): Promise<ChatMessag
|
|
|
426
460
|
texts.push(part.text);
|
|
427
461
|
}
|
|
428
462
|
}
|
|
463
|
+
// Skip empty assistant turns (no text + no tool calls): strict
|
|
464
|
+
// routes reject them, and they carry no signal (pi drop rule).
|
|
465
|
+
if (texts.length === 0 && calls.length === 0) continue;
|
|
429
466
|
messages.push({
|
|
430
467
|
role: "assistant",
|
|
431
468
|
content: texts.length > 0 ? texts.join("\n") : null,
|
|
432
469
|
...(calls.length > 0 ? { tool_calls: calls } : {}),
|
|
433
470
|
});
|
|
471
|
+
lastRole = "assistant";
|
|
434
472
|
} else if (m.role === "tool") {
|
|
435
473
|
if (!Array.isArray(m.content)) continue;
|
|
436
474
|
for (const part of m.content) {
|
|
437
475
|
if (part.type === "tool_result") {
|
|
438
476
|
messages.push({
|
|
439
477
|
role: "tool",
|
|
440
|
-
tool_call_id: pairing.get(part.id) || part.id,
|
|
478
|
+
tool_call_id: clampToolCallId(pairing.get(part.id) || part.id),
|
|
441
479
|
content: resultToOutput(part.result),
|
|
442
480
|
name: part.name,
|
|
443
481
|
});
|
|
482
|
+
lastRole = "tool";
|
|
444
483
|
}
|
|
445
484
|
}
|
|
446
485
|
}
|
|
@@ -473,27 +512,6 @@ function mapUsage(raw?: ChatResponse["usage"]): TokenUsage {
|
|
|
473
512
|
return usage;
|
|
474
513
|
}
|
|
475
514
|
|
|
476
|
-
function parseArguments(raw: string | undefined): Record<string, unknown> {
|
|
477
|
-
if (!raw) return {};
|
|
478
|
-
try {
|
|
479
|
-
const parsed: unknown = JSON.parse(raw);
|
|
480
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
481
|
-
return parsed as Record<string, unknown>;
|
|
482
|
-
}
|
|
483
|
-
return { raw };
|
|
484
|
-
} catch {
|
|
485
|
-
return { raw };
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
/** Trims/collapses assembled thinking parts (no edge-tripling). */
|
|
490
|
-
function normalizeThinkingParts(parts: string[]): string | undefined {
|
|
491
|
-
const cleaned = parts
|
|
492
|
-
.map((p) => p.replace(/\n{3,}/g, "\n\n").trim())
|
|
493
|
-
.filter((p) => p.length > 0);
|
|
494
|
-
return cleaned.length > 0 ? cleaned.join("\n") : undefined;
|
|
495
|
-
}
|
|
496
|
-
|
|
497
515
|
function extractSignature(rawTc: {
|
|
498
516
|
extra_content?: { google?: { thought_signature?: string } };
|
|
499
517
|
}): string | undefined {
|
|
@@ -501,6 +519,12 @@ function extractSignature(rawTc: {
|
|
|
501
519
|
return typeof sig === "string" && sig ? sig : undefined;
|
|
502
520
|
}
|
|
503
521
|
|
|
522
|
+
/** Tags a captured compat signature with its issuing provider (`google`, Q-18). */
|
|
523
|
+
function withCompatSignature(sig: string | undefined): { thoughtSignature?: string; thoughtSignatureProvider?: string } {
|
|
524
|
+
if (!sig) return {};
|
|
525
|
+
return { thoughtSignature: sig, thoughtSignatureProvider: "google" };
|
|
526
|
+
}
|
|
527
|
+
|
|
504
528
|
function throwResponseError(
|
|
505
529
|
response: ChatResponse,
|
|
506
530
|
prefix: string,
|
|
@@ -528,7 +552,9 @@ function parseResponse(
|
|
|
528
552
|
|
|
529
553
|
const choice = response.choices?.[0];
|
|
530
554
|
const message = choice?.message;
|
|
531
|
-
|
|
555
|
+
// Q-23: refusal content surfaces as text with a `refusal` finish reason.
|
|
556
|
+
const refusal = typeof message?.refusal === "string" && message.refusal ? message.refusal : "";
|
|
557
|
+
const text = typeof message?.content === "string" && message.content ? message.content : refusal;
|
|
532
558
|
// Thinking tolerance: plain `reasoning` plus per-block texts (deepseek-style
|
|
533
559
|
// `reasoning_details`). Details win when both ride along; both are
|
|
534
560
|
// display-only here — never resent.
|
|
@@ -547,25 +573,26 @@ function parseResponse(
|
|
|
547
573
|
const args = parseArguments(tc.function?.arguments);
|
|
548
574
|
const sig = extractSignature(tc);
|
|
549
575
|
toolCalls.push({
|
|
550
|
-
id: tc.id
|
|
576
|
+
id: tc.id && !tc.id.includes("|") && tc.id.length <= 40 ? tc.id : clampToolCallId(tc.id || newToolCallId()),
|
|
551
577
|
name: tc.function?.name || "unknown",
|
|
552
578
|
arguments: args,
|
|
553
579
|
rawArguments:
|
|
554
580
|
typeof tc.function?.arguments === "string"
|
|
555
581
|
? tc.function.arguments
|
|
556
582
|
: JSON.stringify(tc.function?.arguments ?? {}),
|
|
557
|
-
...(sig
|
|
558
|
-
});
|
|
583
|
+
...withCompatSignature(sig),
|
|
584
|
+
} as ToolCallRecord);
|
|
559
585
|
}
|
|
560
586
|
|
|
561
587
|
const wireReason = choice?.finish_reason ?? undefined;
|
|
588
|
+
const finishReason = toolCalls.length > 0 ? "tool_calls" : refusal ? "refusal" : wireReason || "stop";
|
|
562
589
|
return {
|
|
563
590
|
text,
|
|
564
591
|
thinking: normalizeThinkingParts(thinkingParts),
|
|
565
592
|
thoughtSignature: toolCalls.map((c) => c.thoughtSignature).find(Boolean),
|
|
566
593
|
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
|
567
594
|
usage: mapUsage(response.usage),
|
|
568
|
-
finishReason
|
|
595
|
+
finishReason,
|
|
569
596
|
responseId: response.id,
|
|
570
597
|
model: modelId,
|
|
571
598
|
provider: prefix as ProviderId,
|
|
@@ -574,28 +601,6 @@ function parseResponse(
|
|
|
574
601
|
};
|
|
575
602
|
}
|
|
576
603
|
|
|
577
|
-
function redactedHeaders(headers: Record<string, string>): Record<string, string> {
|
|
578
|
-
const out: Record<string, string> = {};
|
|
579
|
-
for (const [k, v] of Object.entries(headers)) {
|
|
580
|
-
out[k] = k.toLowerCase() === "authorization" ? "[REDACTED]" : v;
|
|
581
|
-
}
|
|
582
|
-
return out;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
function readErrorPayload(bodyText: string): { message: string; code?: string | number } {
|
|
586
|
-
try {
|
|
587
|
-
const parsed: unknown = JSON.parse(bodyText);
|
|
588
|
-
const first = Array.isArray(parsed) ? parsed[0] : parsed;
|
|
589
|
-
const err = (first as { error?: { message?: string; code?: string | number } })?.error;
|
|
590
|
-
if (err && typeof err.message === "string") {
|
|
591
|
-
return { message: err.message, code: err.code };
|
|
592
|
-
}
|
|
593
|
-
return { message: bodyText.slice(0, 300) };
|
|
594
|
-
} catch {
|
|
595
|
-
return { message: bodyText.slice(0, 300) };
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
604
|
// ---------------------------------------------------------------------------
|
|
600
605
|
// Provider
|
|
601
606
|
// ---------------------------------------------------------------------------
|
|
@@ -695,7 +700,32 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
695
700
|
}
|
|
696
701
|
applyCacheForCustom(this.prefix, options?.cache, `${this.prefix}/${modelId}`);
|
|
697
702
|
|
|
698
|
-
|
|
703
|
+
// Structured outputs: native `response_format` when supported, else
|
|
704
|
+
// emulated via system prompt (strict endpoints would 400 on json_schema
|
|
705
|
+
// for catalog `structured_output: false` models).
|
|
706
|
+
let effectiveContext = context;
|
|
707
|
+
let outputEmulated = false;
|
|
708
|
+
let outputSpec: import("../types/model.ts").StructuredOutputSpec | undefined;
|
|
709
|
+
try {
|
|
710
|
+
outputSpec = normalizeStructuredOutput(options?.output ?? (options as { outputSchema?: unknown })?.outputSchema);
|
|
711
|
+
} catch {
|
|
712
|
+
outputSpec = undefined;
|
|
713
|
+
}
|
|
714
|
+
if (outputSpec && !modelSupportsStructuredOutput(this.prefix, modelId)) {
|
|
715
|
+
outputEmulated = true;
|
|
716
|
+
effectiveContext = {
|
|
717
|
+
...context,
|
|
718
|
+
systemPrompt: augmentSystemPromptWithStructuredOutput(context.systemPrompt, outputSpec),
|
|
719
|
+
};
|
|
720
|
+
emitProviderWarning({
|
|
721
|
+
provider: this.prefix,
|
|
722
|
+
capability: "structured output",
|
|
723
|
+
requested: `${outputSpec.name} (${this.prefix}/${modelId})`,
|
|
724
|
+
reason: "the catalog reports structured_output: false for this model, so native response_format is unsupported.",
|
|
725
|
+
fallback: "emulated JSON instruction + client validation (no response_format is sent)",
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
const messages = await fullHistoryMessages(effectiveContext);
|
|
699
729
|
const body: ChatRequestBody = {
|
|
700
730
|
model: modelId,
|
|
701
731
|
messages,
|
|
@@ -714,8 +744,32 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
714
744
|
? { type: "function", function: { name: (toolChoice as { name: string }).name } }
|
|
715
745
|
: (toolChoice as ChatRequestBody["tool_choice"]);
|
|
716
746
|
}
|
|
717
|
-
//
|
|
718
|
-
//
|
|
747
|
+
// Structured outputs use the standard Chat Completions `response_format`
|
|
748
|
+
// (safe on strict endpoints). Skipped when emulated (instruction already
|
|
749
|
+
// in messages). No reasoning / service_tier / session /
|
|
750
|
+
// cache body primitives: strict endpoints reject unknown properties.
|
|
751
|
+
// Affinity stays headers-only.
|
|
752
|
+
if (outputSpec && !outputEmulated) {
|
|
753
|
+
try {
|
|
754
|
+
const mapped = mapOutputToCompatChat(outputSpec);
|
|
755
|
+
if (mapped) body.response_format = mapped.response_format;
|
|
756
|
+
} catch {
|
|
757
|
+
// why: invalid output shapes fail fast at Agent construction/loop validation.
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// Q-38: request usage on streams where supported so terminal accounting
|
|
761
|
+
// arrives on the final chunk. Strict servers that reject unknown fields
|
|
762
|
+
// opt out via `COMPAT_NO_STREAM_OPTIONS=1`; an explicit per-call
|
|
763
|
+
// `(options as any).streamOptions === false` also skips it.
|
|
764
|
+
if (stream) {
|
|
765
|
+
const noStreamOptions =
|
|
766
|
+
(options as { streamOptions?: unknown } | undefined)?.streamOptions === false ||
|
|
767
|
+
getEnv("COMPAT_NO_STREAM_OPTIONS") === "1" ||
|
|
768
|
+
(options?.env?.["COMPAT_NO_STREAM_OPTIONS"] === "1");
|
|
769
|
+
if (!noStreamOptions) {
|
|
770
|
+
(body as unknown as Record<string, unknown>)["stream_options"] = { include_usage: true };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
719
773
|
return body;
|
|
720
774
|
}
|
|
721
775
|
|
|
@@ -735,8 +789,26 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
735
789
|
for (const [k, v] of Object.entries(headers)) {
|
|
736
790
|
if (INTERNAL_HEADERS.has(k.toLowerCase())) delete headers[k];
|
|
737
791
|
}
|
|
738
|
-
|
|
739
|
-
|
|
792
|
+
// Strict-endpoint opt-out (pi sendSessionAffinityHeaders pattern):
|
|
793
|
+
// `COMPAT_NO_SESSION_HEADERS=1` or per-call `sendSessionHeaders:false`
|
|
794
|
+
// drops affinity headers that trigger CORS preflights / strict 400s.
|
|
795
|
+
// Body `session_id` is already omitted on compat (headers-only affinity).
|
|
796
|
+
try {
|
|
797
|
+
const noAffinity =
|
|
798
|
+
(options as { sendSessionHeaders?: unknown } | undefined)?.sendSessionHeaders === false ||
|
|
799
|
+
getEnv("COMPAT_NO_SESSION_HEADERS") === "1" ||
|
|
800
|
+
options?.env?.["COMPAT_NO_SESSION_HEADERS"] === "1";
|
|
801
|
+
if (noAffinity) {
|
|
802
|
+
for (const k of Object.keys(headers)) {
|
|
803
|
+
const lower = k.toLowerCase();
|
|
804
|
+
if (lower === "x-session-id" || lower === "x-client-request-id" || lower === "session_id") {
|
|
805
|
+
delete headers[k];
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
} catch {}
|
|
810
|
+
setHeaderCaseInsensitive(headers, "Content-Type", "application/json");
|
|
811
|
+
if (apiKey && apiKey !== "unused") setHeaderCaseInsensitive(headers, "Authorization", `Bearer ${apiKey}`);
|
|
740
812
|
return { method: "POST", headers, body: JSON.stringify(body), signal };
|
|
741
813
|
}
|
|
742
814
|
|
|
@@ -748,13 +820,18 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
748
820
|
sessionId: string | undefined,
|
|
749
821
|
signal?: AbortSignal
|
|
750
822
|
): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
headers
|
|
756
|
-
|
|
757
|
-
|
|
823
|
+
const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
|
|
824
|
+
try {
|
|
825
|
+
const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
|
|
826
|
+
const text = await res.text();
|
|
827
|
+
const headers: Record<string, string> = {};
|
|
828
|
+
res.headers.forEach((v, k) => {
|
|
829
|
+
headers[k] = v;
|
|
830
|
+
});
|
|
831
|
+
return { status: res.status, statusText: res.statusText, headers, text };
|
|
832
|
+
} finally {
|
|
833
|
+
cleanup();
|
|
834
|
+
}
|
|
758
835
|
}
|
|
759
836
|
|
|
760
837
|
private throwIfError(
|
|
@@ -764,13 +841,21 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
764
841
|
modelId: string
|
|
765
842
|
): void {
|
|
766
843
|
if (status >= 200 && status < 300) return;
|
|
767
|
-
const { message, code } = readErrorPayload(bodyText);
|
|
768
|
-
const
|
|
844
|
+
const { message, code, errorType, raw } = readErrorPayload(bodyText);
|
|
845
|
+
const hint =
|
|
846
|
+
status === 401
|
|
847
|
+
? ` Check ${envPrefixOf(this.prefix)}_API_KEY (no quotes/spaces, correct provider) and that the key is loaded (bun --env-file=.env.local).`
|
|
848
|
+
: "";
|
|
849
|
+
const fullMessage = `${message}${hint}`;
|
|
850
|
+
const err: Record<string, unknown> & Error = new Error(fullMessage) as Record<string, unknown> & Error;
|
|
769
851
|
(err as Record<string, unknown>)["statusCode"] = status;
|
|
770
852
|
(err as Record<string, unknown>)["status"] = status;
|
|
771
853
|
(err as Record<string, unknown>)["responseBody"] = bodyText.slice(0, 500);
|
|
772
854
|
(err as Record<string, unknown>)["url"] = url.split("?")[0];
|
|
855
|
+
(err as Record<string, unknown>)["data"] = { error: { message: fullMessage } };
|
|
773
856
|
if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
|
|
857
|
+
if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
|
|
858
|
+
if (raw) (err as Record<string, unknown>)["raw"] = raw;
|
|
774
859
|
throw toConciseProviderError(err, this.prefix, modelId);
|
|
775
860
|
}
|
|
776
861
|
|
|
@@ -904,7 +989,21 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
904
989
|
body,
|
|
905
990
|
};
|
|
906
991
|
|
|
907
|
-
const
|
|
992
|
+
const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
|
|
993
|
+
const streamTimeoutTimer =
|
|
994
|
+
streamTimeoutMs && streamTimeoutMs > 0
|
|
995
|
+
? setTimeout(() => {
|
|
996
|
+
try {
|
|
997
|
+
linked.abort();
|
|
998
|
+
} catch {}
|
|
999
|
+
}, streamTimeoutMs)
|
|
1000
|
+
: undefined;
|
|
1001
|
+
let res: Response;
|
|
1002
|
+
try {
|
|
1003
|
+
res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
|
|
1004
|
+
} finally {
|
|
1005
|
+
if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
|
|
1006
|
+
}
|
|
908
1007
|
if (!res.ok || !res.body) {
|
|
909
1008
|
const text = !res.ok ? await res.text().catch(() => "") : "";
|
|
910
1009
|
if (!res.ok) this.throwIfError(res.status, url, text, clean);
|
|
@@ -914,7 +1013,7 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
914
1013
|
res.headers.forEach((v, k) => {
|
|
915
1014
|
responseHeaders[k] = v;
|
|
916
1015
|
});
|
|
917
|
-
const responseMeta = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
1016
|
+
const responseMeta: Record<string, unknown> & { status: number; statusText: string; headers: Record<string, string> } = { status: res.status, statusText: res.statusText, headers: responseHeaders };
|
|
918
1017
|
eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
|
|
919
1018
|
|
|
920
1019
|
const parser = new SSEParser();
|
|
@@ -928,6 +1027,11 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
928
1027
|
const signatures = new Map<number, string>();
|
|
929
1028
|
let usage: TokenUsage = { inputTokens: 0, outputTokens: 0, totalTokens: 0 };
|
|
930
1029
|
let finishReason = "stop";
|
|
1030
|
+
let sawFinishReason = false;
|
|
1031
|
+
let sawUsage = false;
|
|
1032
|
+
let refusalSeen = false;
|
|
1033
|
+
let badFrames = 0;
|
|
1034
|
+
let framesSeen = 0;
|
|
931
1035
|
let responseId: string | undefined;
|
|
932
1036
|
let completedBody: unknown = undefined;
|
|
933
1037
|
let aborted = false;
|
|
@@ -943,11 +1047,17 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
943
1047
|
);
|
|
944
1048
|
|
|
945
1049
|
const handleMessage = (data: string): void => {
|
|
946
|
-
if (!data
|
|
1050
|
+
if (!data) return;
|
|
1051
|
+
if (data === "[DONE]") {
|
|
1052
|
+
sawFinishReason = sawFinishReason || completedBody !== undefined || sawUsage;
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
947
1055
|
let msg: Record<string, unknown>;
|
|
948
1056
|
try {
|
|
949
1057
|
msg = JSON.parse(data) as Record<string, unknown>;
|
|
1058
|
+
framesSeen += 1;
|
|
950
1059
|
} catch {
|
|
1060
|
+
badFrames += 1;
|
|
951
1061
|
return;
|
|
952
1062
|
}
|
|
953
1063
|
// Mid-stream provider error: top-level `error`, HTTP stays 200.
|
|
@@ -969,6 +1079,13 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
969
1079
|
const choice = (Array.isArray(msg["choices"]) ? (msg["choices"] as Record<string, unknown>[])[0] : undefined) ?? {};
|
|
970
1080
|
const delta = (choice["delta"] as Record<string, unknown>) ?? {};
|
|
971
1081
|
// Text delta (content-free accounting frames carry "" — ignored).
|
|
1082
|
+
// Q-23: refusal deltas surface as text with a `refusal` finish.
|
|
1083
|
+
const deltaRefusal = delta["refusal"];
|
|
1084
|
+
if (typeof deltaRefusal === "string" && deltaRefusal) {
|
|
1085
|
+
text += deltaRefusal;
|
|
1086
|
+
refusalSeen = true;
|
|
1087
|
+
eventStream.push({ type: "text_delta", delta: deltaRefusal, partialText: text });
|
|
1088
|
+
}
|
|
972
1089
|
if (typeof delta["content"] === "string" && delta["content"]) {
|
|
973
1090
|
text += delta["content"] as string;
|
|
974
1091
|
eventStream.push({ type: "text_delta", delta: delta["content"] as string, partialText: text });
|
|
@@ -1042,10 +1159,12 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1042
1159
|
throw toConciseProviderError(failure, prefix, clean);
|
|
1043
1160
|
}
|
|
1044
1161
|
finishReason = fr;
|
|
1162
|
+
sawFinishReason = true;
|
|
1045
1163
|
}
|
|
1046
1164
|
const chunkUsage = msg["usage"] as ChatResponse["usage"] | undefined;
|
|
1047
1165
|
if (chunkUsage) {
|
|
1048
1166
|
usage = mapUsage(chunkUsage);
|
|
1167
|
+
sawUsage = true;
|
|
1049
1168
|
completedBody = completedBody ?? msg;
|
|
1050
1169
|
eventStream.push({ type: "usage", usage });
|
|
1051
1170
|
}
|
|
@@ -1077,20 +1196,48 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1077
1196
|
const args = parseStreamedToolArguments(c.startArgs, c.deltaArgs);
|
|
1078
1197
|
const sig = signatures.get(index);
|
|
1079
1198
|
const record: ToolCallRecord = {
|
|
1080
|
-
id: c.id
|
|
1199
|
+
id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
|
|
1081
1200
|
name: c.name,
|
|
1082
1201
|
arguments: args,
|
|
1083
1202
|
rawArguments: c.startArgs + c.deltaArgs,
|
|
1084
|
-
...(sig
|
|
1085
|
-
};
|
|
1203
|
+
...withCompatSignature(sig),
|
|
1204
|
+
} as ToolCallRecord;
|
|
1086
1205
|
toolCalls.push(record);
|
|
1087
1206
|
eventStream.push({ type: "tool_call_complete", toolCall: record });
|
|
1088
1207
|
}
|
|
1089
1208
|
|
|
1090
1209
|
noteProviderTurn(sessionId, prefix);
|
|
1091
|
-
if (toolCalls.length > 0)
|
|
1210
|
+
if (toolCalls.length > 0) {
|
|
1211
|
+
finishReason = "tool_calls";
|
|
1212
|
+
sawFinishReason = true;
|
|
1213
|
+
} else if (refusalSeen) {
|
|
1214
|
+
finishReason = "refusal";
|
|
1215
|
+
sawFinishReason = true;
|
|
1216
|
+
}
|
|
1092
1217
|
|
|
1093
|
-
|
|
1218
|
+
// Q-23: terminal-state guard — fail `incomplete_stream` (retryable)
|
|
1219
|
+
// instead of resolving empty success. Skipped for non-SSE payloads
|
|
1220
|
+
// (JSON mocks) where no SSE frames were ever seen.
|
|
1221
|
+
const hasTerminal = completedBody !== undefined || sawFinishReason || sawUsage;
|
|
1222
|
+
const cleanThinkingEarly = thinking.replace(/\n{3,}/g, "\n\n").trim();
|
|
1223
|
+
const ctEntry = Object.entries(responseHeaders).find(([k]) => k.toLowerCase() === "content-type");
|
|
1224
|
+
const isSSEPayload =
|
|
1225
|
+
(typeof ctEntry?.[1] === "string" && ((ctEntry[1] as string).includes("event-stream") || (ctEntry[1] as string).startsWith("text/"))) ||
|
|
1226
|
+
framesSeen > 0 ||
|
|
1227
|
+
badFrames > 0;
|
|
1228
|
+
if (!hasTerminal && !text && !cleanThinkingEarly && toolCalls.length === 0 && isSSEPayload) {
|
|
1229
|
+
eventStream.fail(incompleteStreamError(prefix, clean));
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
// Refuse unfinished tool calls whose args never parsed (output_item.done
|
|
1233
|
+
// never arrived): truncated `{raw}` must not reach the executor (pi
|
|
1234
|
+
// api/openai-responses-shared.ts:766 rule). Non-SSE mocks preserve legacy.
|
|
1235
|
+
if (!hasTerminal && toolCalls.some((c) => hasTruncatedToolArguments(c.arguments)) && isSSEPayload) {
|
|
1236
|
+
eventStream.fail(incompleteStreamError(prefix, clean));
|
|
1237
|
+
return;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const cleanThinking = cleanThinkingEarly;
|
|
1094
1241
|
const finalThoughtSignature = toolCalls.map((c) => c.thoughtSignature).find(Boolean);
|
|
1095
1242
|
const finalResponse = new AgentResponse({
|
|
1096
1243
|
text,
|
|
@@ -1102,7 +1249,10 @@ export class OpenAICompatibleChatProvider implements Provider {
|
|
|
1102
1249
|
responseId,
|
|
1103
1250
|
model: clean,
|
|
1104
1251
|
provider: prefix as ProviderId,
|
|
1105
|
-
raw: {
|
|
1252
|
+
raw: {
|
|
1253
|
+
request: rawRequest,
|
|
1254
|
+
response: { ...responseMeta, body: completedBody, badFrames } as unknown as ProviderRawData["response"],
|
|
1255
|
+
},
|
|
1106
1256
|
durationMs: Date.now() - startTime,
|
|
1107
1257
|
});
|
|
1108
1258
|
eventStream.push({ type: "done", delta: "", usage, finishReason, responseId });
|