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.
Files changed (46) hide show
  1. package/README.md +149 -57
  2. package/bunfig.toml +1 -0
  3. package/package.json +2 -3
  4. package/src/agent/agent.ts +414 -118
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +327 -99
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +58 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +47 -2
  13. package/src/providers/google.ts +338 -81
  14. package/src/providers/openai-compat.ts +171 -61
  15. package/src/providers/openai.ts +174 -44
  16. package/src/providers/openrouter.ts +129 -44
  17. package/src/providers/shared.ts +169 -17
  18. package/src/providers.ts +375 -11
  19. package/src/scheduler/scheduled-actions.md +41 -0
  20. package/src/scheduler/scheduler.ts +814 -0
  21. package/src/scheduler/store.ts +263 -0
  22. package/src/scheduler/time.ts +148 -0
  23. package/src/scheduler/tool.ts +151 -0
  24. package/src/session/store.ts +229 -113
  25. package/src/streaming/event-stream.ts +6 -4
  26. package/src/telemetry-file.ts +31 -0
  27. package/src/telemetry.ts +65 -0
  28. package/src/tools/executor.ts +163 -38
  29. package/src/tools/subagent-result.ts +60 -0
  30. package/src/types/agent.ts +74 -22
  31. package/src/types/model.ts +39 -0
  32. package/src/types/response.ts +9 -0
  33. package/src/types/scheduler.ts +81 -0
  34. package/src/types/tool.ts +15 -0
  35. package/src/utils/cache.ts +14 -5
  36. package/src/utils/documents.ts +112 -52
  37. package/src/utils/env.ts +21 -7
  38. package/src/utils/errors.ts +26 -1
  39. package/src/utils/headers.ts +15 -15
  40. package/src/utils/home.ts +30 -0
  41. package/src/utils/media.ts +57 -12
  42. package/src/utils/raw-capture.ts +76 -0
  43. package/src/utils/retry.ts +11 -3
  44. package/src/utils/safe-io.ts +252 -0
  45. package/src/utils/session.ts +7 -8
  46. 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`). No `reasoning`, `service_tier`,
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
- return (
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
- DEFAULT_BASE_URL
203
- ).replace(/\/+$/, "");
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
- return (
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. Defaults to OpenAI cloud. */
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
- const messages = await fullHistoryMessages(context);
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
- // No reasoning / service_tier / session / cache body primitives: strict
701
- // endpoints reject unknown properties. Affinity is headers-only.
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 err: Record<string, unknown> & Error = new Error(message) as Record<string, unknown> & Error;
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
- maxRetries: options?.maxRetries,
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
- const streamTimeoutTimer =
911
- streamTimeoutMs && streamTimeoutMs > 0
912
- ? setTimeout(() => {
913
- try {
914
- linked.abort();
915
- } catch {}
916
- }, streamTimeoutMs)
917
- : undefined;
918
- let res: Response;
919
- try {
920
- res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
921
- } finally {
922
- if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
923
- }
924
- if (!res.ok || !res.body) {
925
- const text = !res.ok ? await res.text().catch(() => "") : "";
926
- if (!res.ok) this.throwIfError(res.status, url, text, clean);
927
- throw toConciseProviderError(new Error("OpenAI-compatible streaming response had no body"), prefix, clean);
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.getReader();
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 + c.deltaArgs,
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
  );