agent-accelerator 0.4.5 → 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 (45) 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 +401 -120
  5. package/src/agent/context.ts +11 -16
  6. package/src/agent/delegation.ts +111 -274
  7. package/src/agent/loop.ts +253 -98
  8. package/src/agent/subagent.ts +4 -12
  9. package/src/agent/worker.ts +435 -0
  10. package/src/index.ts +39 -5
  11. package/src/models/catalog-cache.ts +65 -25
  12. package/src/models/catalog.ts +21 -2
  13. package/src/providers/google.ts +282 -74
  14. package/src/providers/openai-compat.ts +61 -41
  15. package/src/providers/openai.ts +46 -31
  16. package/src/providers/openrouter.ts +42 -31
  17. package/src/providers/shared.ts +110 -16
  18. package/src/providers.ts +119 -6
  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 +211 -104
  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 +57 -20
  31. package/src/types/model.ts +18 -0
  32. package/src/types/response.ts +2 -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/errors.ts +11 -0
  38. package/src/utils/headers.ts +15 -15
  39. package/src/utils/home.ts +30 -0
  40. package/src/utils/media.ts +57 -12
  41. package/src/utils/raw-capture.ts +76 -0
  42. package/src/utils/retry.ts +11 -3
  43. package/src/utils/safe-io.ts +252 -0
  44. package/src/utils/session.ts +7 -8
  45. 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,
@@ -72,6 +74,10 @@ import {
72
74
  timeoutFor,
73
75
  hasAuthHeader,
74
76
  clampToolCallId,
77
+ openStreamResponse,
78
+ retryOptionsFrom,
79
+ headersToRecord,
80
+ parseRetryAfterMs,
75
81
  } from "./shared.ts";
76
82
  import { getModelFromCatalog, createGenericModelSpec, modelSupportsStructuredOutput } from "../models/catalog.ts";
77
83
  import {
@@ -80,7 +86,9 @@ import {
80
86
  emitProviderWarning,
81
87
  noteProviderTurn,
82
88
  parseStreamedToolArguments,
89
+ selectRawArguments,
83
90
  mapOutputToCompatChat,
91
+ mapGenerationParamsToChat,
84
92
  normalizeStructuredOutput,
85
93
  augmentSystemPromptWithStructuredOutput,
86
94
  } from "../providers.ts";
@@ -174,8 +182,6 @@ interface ChatResponse {
174
182
  [k: string]: unknown;
175
183
  }
176
184
 
177
- const DEFAULT_BASE_URL = "https://api.openai.com/v1";
178
-
179
185
  /** Headers that must never leak onto native REST requests. */
180
186
  const INTERNAL_HEADERS = new Set([
181
187
  "x-thought-signature-map",
@@ -197,7 +203,7 @@ function resolveBaseUrl(
197
203
  options?: ProviderRequestOptions
198
204
  ): string {
199
205
  const envPrefix = envPrefixOf(prefix);
200
- return (
206
+ const resolved =
201
207
  options?.baseUrl ||
202
208
  configuredBaseUrl ||
203
209
  options?.env?.[`${envPrefix}_BASE_URL`] ||
@@ -205,9 +211,15 @@ function resolveBaseUrl(
205
211
  options?.env?.[`${envPrefix}_API_BASE`] ||
206
212
  getEnv(`${envPrefix}_BASE_URL`) ||
207
213
  getEnv(`${envPrefix}_BASEURL`) ||
208
- getEnv(`${envPrefix}_API_BASE`) ||
209
- DEFAULT_BASE_URL
210
- ).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(/\/+$/, "");
211
223
  }
212
224
 
213
225
  function resolveApiKey(
@@ -612,7 +624,7 @@ export interface CustomProviderOptions {
612
624
  baseUrl?: string;
613
625
  /** Static apiKey — overrides env. Explicit per-call `apiKey` still wins. */
614
626
  apiKey?: string;
615
- /** 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. */
616
628
  defaultBaseUrl?: string;
617
629
  }
618
630
 
@@ -733,6 +745,8 @@ export class OpenAICompatibleChatProvider implements Provider {
733
745
  };
734
746
  const compatTools = toCompatTools(tools);
735
747
  if (compatTools) body.tools = compatTools;
748
+ // Standard Chat Completions generation fields, only when the caller set them.
749
+ Object.assign(body, mapGenerationParamsToChat(options));
736
750
  const toolChoice = mapToolChoiceToOpenAI(
737
751
  options?.toolChoice as "auto" | "none" | "required" | { type: "function"; function: { name: string } } | undefined
738
752
  );
@@ -822,7 +836,7 @@ export class OpenAICompatibleChatProvider implements Provider {
822
836
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
823
837
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
824
838
  try {
825
- 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));
826
840
  const text = await res.text();
827
841
  const headers: Record<string, string> = {};
828
842
  res.headers.forEach((v, k) => {
@@ -838,7 +852,8 @@ export class OpenAICompatibleChatProvider implements Provider {
838
852
  status: number,
839
853
  url: string,
840
854
  bodyText: string,
841
- modelId: string
855
+ modelId: string,
856
+ headers?: Record<string, string>
842
857
  ): void {
843
858
  if (status >= 200 && status < 300) return;
844
859
  const { message, code, errorType, raw } = readErrorPayload(bodyText);
@@ -856,6 +871,8 @@ export class OpenAICompatibleChatProvider implements Provider {
856
871
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
857
872
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
858
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;
859
876
  throw toConciseProviderError(err, this.prefix, modelId);
860
877
  }
861
878
 
@@ -866,13 +883,13 @@ export class OpenAICompatibleChatProvider implements Provider {
866
883
  ): Promise<ProviderGenerateResult> {
867
884
  const startTime = Date.now();
868
885
  const clean = cleanModelId(this.prefix, model);
869
- const baseUrl = this.baseUrl(options);
870
- const apiKey = this.requireApiKey(baseUrl, options);
871
886
  // Fail fast before any network call when the catalog knows the model
872
887
  // lacks the requested modality. Unknown models skip the guard; the
873
- // endpoint verdict surfaces concisely.
888
+ // endpoint verdict surfaces concisely. Cheap local checks run first.
874
889
  assertModalitiesSupported(context, this.prefix, clean);
875
890
  assertNoVideoOrFileParts(context, this.prefix, clean);
891
+ const baseUrl = this.baseUrl(options);
892
+ const apiKey = this.requireApiKey(baseUrl, options);
876
893
  const url = `${baseUrl}/chat/completions`;
877
894
  const sessionId = options?.sessionId || options?.cache?.sessionId;
878
895
  const tools = options?.tools as StandardToolDeclaration[] | undefined;
@@ -895,7 +912,7 @@ export class OpenAICompatibleChatProvider implements Provider {
895
912
 
896
913
  const doCall = async (): Promise<ProviderGenerateResult> => {
897
914
  const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
898
- this.throwIfError(res.status, url, res.text, clean);
915
+ this.throwIfError(res.status, url, res.text, clean, res.headers);
899
916
  let response: ChatResponse;
900
917
  try {
901
918
  response = JSON.parse(res.text) as ChatResponse;
@@ -924,8 +941,7 @@ export class OpenAICompatibleChatProvider implements Provider {
924
941
 
925
942
  try {
926
943
  return await withRetries(doCall, {
927
- maxRetries: options?.maxRetries,
928
- maxRetryDelayMs: options?.maxRetryDelayMs,
944
+ ...retryOptionsFrom(options),
929
945
  signal: options?.signal,
930
946
  label: { providerId: this.prefix, modelId: clean },
931
947
  });
@@ -965,10 +981,10 @@ export class OpenAICompatibleChatProvider implements Provider {
965
981
 
966
982
  (async () => {
967
983
  try {
968
- const baseUrl = this.baseUrl(options);
969
- const apiKey = this.requireApiKey(baseUrl, options);
970
984
  assertModalitiesSupported(context, prefix, clean);
971
985
  assertNoVideoOrFileParts(context, prefix, clean);
986
+ const baseUrl = this.baseUrl(options);
987
+ const apiKey = this.requireApiKey(baseUrl, options);
972
988
  const url = `${baseUrl}/chat/completions`;
973
989
  const sessionId = options?.sessionId || options?.cache?.sessionId;
974
990
  const tools = options?.tools as StandardToolDeclaration[] | undefined;
@@ -990,25 +1006,30 @@ export class OpenAICompatibleChatProvider implements Provider {
990
1006
  };
991
1007
 
992
1008
  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
- }
1007
- if (!res.ok || !res.body) {
1008
- const text = !res.ok ? await res.text().catch(() => "") : "";
1009
- if (!res.ok) this.throwIfError(res.status, url, text, clean);
1010
- throw toConciseProviderError(new Error("OpenAI-compatible streaming response had no body"), prefix, clean);
1011
- }
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
+ });
1012
1033
  const responseHeaders: Record<string, string> = {};
1013
1034
  res.headers.forEach((v, k) => {
1014
1035
  responseHeaders[k] = v;
@@ -1017,7 +1038,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1017
1038
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
1018
1039
 
1019
1040
  const parser = new SSEParser();
1020
- const reader = res.body.getReader();
1041
+ const reader = res.body!.getReader(); // openStreamResponse guarantees a body
1021
1042
  const decoder = new TextDecoder();
1022
1043
  let text = "";
1023
1044
  let thinking = "";
@@ -1199,7 +1220,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1199
1220
  id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
1200
1221
  name: c.name,
1201
1222
  arguments: args,
1202
- rawArguments: c.startArgs + c.deltaArgs,
1223
+ rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
1203
1224
  ...withCompatSignature(sig),
1204
1225
  } as ToolCallRecord;
1205
1226
  toolCalls.push(record);
@@ -1262,8 +1283,7 @@ export class OpenAICompatibleChatProvider implements Provider {
1262
1283
  const isAbort =
1263
1284
  linked.signal.aborted ||
1264
1285
  eventStream.isCancelled() ||
1265
- (raw as { name?: string }).name === "AbortError" ||
1266
- /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
1286
+ (raw as { name?: string }).name === "AbortError";
1267
1287
  eventStream.fail(
1268
1288
  isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1269
1289
  );
@@ -12,10 +12,11 @@
12
12
  * effort, service_tier flex/priority, prompt_cache_key affinity, usage,
13
13
  * finish reasons, switching, abort, errors.
14
14
  *
15
+ * Also implemented: `text.format` structured output, `max_output_tokens` (stop + seed have no Responses equivalent: warn+drop).
16
+ *
15
17
  * Out of scope (never sent): background, conversation, previous_response_id
16
- * chaining, include, metadata, temperature/top_p, max_output_tokens,
17
- * truncation, safety_identifier, prompt_cache_options explicit breakpoints,
18
- * text.format structured output, built-in/MCP tools. Those remain
18
+ * chaining, include, metadata, truncation, safety_identifier,
19
+ * prompt_cache_options explicit breakpoints, built-in/MCP tools. Those remain
19
20
  * UNSUPPORTED and are handled via warn+drop in the canonical layer where
20
21
  * applicable.
21
22
  *
@@ -64,6 +65,10 @@ import {
64
65
  timeoutFor,
65
66
  hasAuthHeader,
66
67
  clampToolCallId,
68
+ openStreamResponse,
69
+ retryOptionsFrom,
70
+ headersToRecord,
71
+ parseRetryAfterMs,
67
72
  } from "./shared.ts";
68
73
  import { createGenericModelSpec } from "../models/catalog.ts";
69
74
  import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
@@ -74,7 +79,9 @@ import {
74
79
  mapToolChoiceToOpenAI,
75
80
  noteProviderTurn,
76
81
  parseStreamedToolArguments,
82
+ selectRawArguments,
77
83
  mapOutputToOpenAI,
84
+ mapGenerationParamsToOpenAI,
78
85
  normalizeStructuredOutput,
79
86
  augmentSystemPromptWithStructuredOutput,
80
87
  emitProviderWarning,
@@ -570,6 +577,8 @@ export class OpenAIResponsesProvider implements Provider {
570
577
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
571
578
  if (effort) body.reasoning = { effort };
572
579
  if (serviceTier) body.service_tier = serviceTier;
580
+ // max_output_tokens (stop + seed warn+drop: not on this wire).
581
+ Object.assign(body, mapGenerationParamsToOpenAI(options, `openai/${modelId}`));
573
582
  // Structured outputs: Responses `text.format{type:json_schema,name,schema,strict}`.
574
583
  // Skipped when emulated (instruction already in `instructions`).
575
584
  if (outputSpec && !outputEmulated) {
@@ -651,7 +660,7 @@ export class OpenAIResponsesProvider implements Provider {
651
660
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
652
661
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
653
662
  try {
654
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
663
+ const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
655
664
  const text = await res.text();
656
665
  const headers: Record<string, string> = {};
657
666
  res.headers.forEach((v, k) => {
@@ -667,7 +676,8 @@ export class OpenAIResponsesProvider implements Provider {
667
676
  status: number,
668
677
  url: string,
669
678
  bodyText: string,
670
- modelId: string
679
+ modelId: string,
680
+ headers?: Record<string, string>
671
681
  ): void {
672
682
  if (status >= 200 && status < 300) return;
673
683
  const { message, code, errorType, raw } = readErrorPayload(bodyText);
@@ -687,6 +697,8 @@ export class OpenAIResponsesProvider implements Provider {
687
697
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
688
698
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
689
699
  if (raw) (err as Record<string, unknown>)["raw"] = raw;
700
+ const retryAfterMs = parseRetryAfterMs(headers);
701
+ if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
690
702
  throw toConciseProviderError(err, "openai", modelId);
691
703
  }
692
704
 
@@ -736,7 +748,7 @@ export class OpenAIResponsesProvider implements Provider {
736
748
 
737
749
  const doCall = async (): Promise<ProviderGenerateResult> => {
738
750
  const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
739
- this.throwIfError(res.status, url, res.text, clean);
751
+ this.throwIfError(res.status, url, res.text, clean, res.headers);
740
752
  let response: ResponsesObject;
741
753
  try {
742
754
  response = JSON.parse(res.text) as ResponsesObject;
@@ -764,8 +776,7 @@ export class OpenAIResponsesProvider implements Provider {
764
776
 
765
777
  try {
766
778
  return await withRetries(doCall, {
767
- maxRetries: options?.maxRetries,
768
- maxRetryDelayMs: options?.maxRetryDelayMs,
779
+ ...retryOptionsFrom(options),
769
780
  signal: options?.signal,
770
781
  label: { providerId: "openai", modelId: clean },
771
782
  });
@@ -835,25 +846,30 @@ export class OpenAIResponsesProvider implements Provider {
835
846
 
836
847
  // Q-17: optional per-call timeout races the linked abort signal.
837
848
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
838
- const streamTimeoutTimer =
839
- streamTimeoutMs && streamTimeoutMs > 0
840
- ? setTimeout(() => {
841
- try {
842
- linked.abort();
843
- } catch {}
844
- }, streamTimeoutMs)
845
- : undefined;
846
- let res: Response;
847
- try {
848
- res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
849
- } finally {
850
- if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
851
- }
852
- if (!res.ok || !res.body) {
853
- const text = !res.ok ? await res.text().catch(() => "") : "";
854
- if (!res.ok) this.throwIfError(res.status, url, text, clean);
855
- throw toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean);
856
- }
849
+ // Transient failures (429/5xx/network) are retried here, before the
850
+ // first byte; once the stream is live it is never retried.
851
+ const res = await openStreamResponse({
852
+ fetchOnce: async () => {
853
+ const timer =
854
+ streamTimeoutMs && streamTimeoutMs > 0
855
+ ? setTimeout(() => {
856
+ try {
857
+ linked.abort();
858
+ } catch {}
859
+ }, streamTimeoutMs)
860
+ : undefined;
861
+ try {
862
+ return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
863
+ } finally {
864
+ if (timer) clearTimeout(timer);
865
+ }
866
+ },
867
+ onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
868
+ noBodyError: () => toConciseProviderError(new Error("OpenAI streaming response had no body"), "openai", clean),
869
+ retry: retryOptionsFrom(options),
870
+ signal: linked.signal,
871
+ label: { providerId: "openai", modelId: clean },
872
+ });
857
873
  const responseHeaders: Record<string, string> = {};
858
874
  res.headers.forEach((v, k) => {
859
875
  responseHeaders[k] = v;
@@ -862,7 +878,7 @@ export class OpenAIResponsesProvider implements Provider {
862
878
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
863
879
 
864
880
  const parser = new SSEParser();
865
- const reader = res.body.getReader();
881
+ const reader = res.body!.getReader(); // openStreamResponse guarantees a body
866
882
  const decoder = new TextDecoder();
867
883
  let text = "";
868
884
  let thinking = "";
@@ -1070,7 +1086,7 @@ export class OpenAIResponsesProvider implements Provider {
1070
1086
  callId: c.callId || undefined,
1071
1087
  name: c.name,
1072
1088
  arguments: args,
1073
- rawArguments: c.startArgs + c.deltaArgs,
1089
+ rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
1074
1090
  };
1075
1091
  toolCalls.push(record);
1076
1092
  eventStream.push({ type: "tool_call_complete", toolCall: record });
@@ -1134,8 +1150,7 @@ export class OpenAIResponsesProvider implements Provider {
1134
1150
  const isAbort =
1135
1151
  linked.signal.aborted ||
1136
1152
  eventStream.isCancelled() ||
1137
- (raw as { name?: string }).name === "AbortError" ||
1138
- /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
1153
+ (raw as { name?: string }).name === "AbortError";
1139
1154
  eventStream.fail(
1140
1155
  isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1141
1156
  );
@@ -57,6 +57,10 @@ import {
57
57
  timeoutFor,
58
58
  hasAuthHeader,
59
59
  clampToolCallId,
60
+ openStreamResponse,
61
+ retryOptionsFrom,
62
+ headersToRecord,
63
+ parseRetryAfterMs,
60
64
  } from "./shared.ts";
61
65
  import { createGenericModelSpec } from "../models/catalog.ts";
62
66
  import { getModelFromCatalog, getModelsForProvider, modelSupportsStructuredOutput } from "../models/catalog.ts";
@@ -67,7 +71,9 @@ import {
67
71
  mapToolChoiceToOpenRouterChat,
68
72
  noteProviderTurn,
69
73
  parseStreamedToolArguments,
74
+ selectRawArguments,
70
75
  mapOutputToOpenRouterChat,
76
+ mapGenerationParamsToChat,
71
77
  normalizeStructuredOutput,
72
78
  augmentSystemPromptWithStructuredOutput,
73
79
  emitProviderWarning,
@@ -622,6 +628,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
622
628
  if (toolChoice !== undefined) body.tool_choice = toolChoice;
623
629
  if (effort) body.reasoning = { effort };
624
630
  if (serviceTier) body.service_tier = serviceTier;
631
+ // Documented Chat Completions generation controls (only those the caller set).
632
+ Object.assign(body, mapGenerationParamsToChat(options));
625
633
  // Structured outputs: Chat Completions `response_format{type:json_schema,json_schema}`.
626
634
  // Skipped when emulated (unsupported model): instruction already in messages.
627
635
  if (outputSpec && !outputEmulated) {
@@ -635,9 +643,6 @@ export class OpenRouterChatCompletionsProvider implements Provider {
635
643
  // PDF ingestion is a documented plugin, not a message shape: enable it
636
644
  // only when file parts are present (never by default).
637
645
  if (hasFiles) body.plugins = [{ id: "file-parser" }];
638
- // No temperature/top_p/max_tokens/stop/user/seed knobs
639
- // (no canonical options exist; omitting also keeps provider cache keys
640
- // stable). `response_format` above is the sole structured-output exception.
641
646
  // No store/previous_response_id/conversation: they do not exist
642
647
  // on this endpoint.
643
648
  // Session affinity: top-level `session_id` is the documented sticky-routing
@@ -682,7 +687,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
682
687
  ): Promise<{ status: number; statusText: string; headers: Record<string, string>; text: string }> {
683
688
  const { signal: effective, cleanup } = combinedSignal(signal, timeoutFor(options as { timeoutMs?: number } | undefined));
684
689
  try {
685
- const res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, effective));
690
+ const res = await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, effective));
686
691
  const text = await res.text();
687
692
  const headers: Record<string, string> = {};
688
693
  res.headers.forEach((v, k) => {
@@ -698,7 +703,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
698
703
  status: number,
699
704
  url: string,
700
705
  bodyText: string,
701
- modelId: string
706
+ modelId: string,
707
+ headers?: Record<string, string>
702
708
  ): void {
703
709
  if (status >= 200 && status < 300) return;
704
710
  const { message, code, errorType, raw } = readErrorPayload(bodyText);
@@ -716,6 +722,8 @@ export class OpenRouterChatCompletionsProvider implements Provider {
716
722
  if (code !== undefined) (err as Record<string, unknown>)["code"] = code;
717
723
  if (errorType) (err as Record<string, unknown>)["errorType"] = errorType;
718
724
  if (raw) (err as Record<string, unknown>)["raw"] = raw;
725
+ const retryAfterMs = parseRetryAfterMs(headers);
726
+ if (retryAfterMs !== undefined) (err as Record<string, unknown>)["retryAfterMs"] = retryAfterMs;
719
727
  throw toConciseProviderError(err, "openrouter", modelId);
720
728
  }
721
729
 
@@ -762,7 +770,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
762
770
 
763
771
  const doCall = async (): Promise<ProviderGenerateResult> => {
764
772
  const res = await this.doFetch(url, body, options, apiKey, sessionId, options?.signal);
765
- this.throwIfError(res.status, url, res.text, clean);
773
+ this.throwIfError(res.status, url, res.text, clean, res.headers);
766
774
  let response: ChatResponse;
767
775
  try {
768
776
  response = JSON.parse(res.text) as ChatResponse;
@@ -790,8 +798,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
790
798
 
791
799
  try {
792
800
  return await withRetries(doCall, {
793
- maxRetries: options?.maxRetries,
794
- maxRetryDelayMs: options?.maxRetryDelayMs,
801
+ ...retryOptionsFrom(options),
795
802
  signal: options?.signal,
796
803
  label: { providerId: "openrouter", modelId: clean },
797
804
  });
@@ -859,25 +866,30 @@ export class OpenRouterChatCompletionsProvider implements Provider {
859
866
  };
860
867
 
861
868
  const streamTimeoutMs = timeoutFor(options as { timeoutMs?: number } | undefined);
862
- const streamTimeoutTimer =
863
- streamTimeoutMs && streamTimeoutMs > 0
864
- ? setTimeout(() => {
865
- try {
866
- linked.abort();
867
- } catch {}
868
- }, streamTimeoutMs)
869
- : undefined;
870
- let res: Response;
871
- try {
872
- res = await fetch(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
873
- } finally {
874
- if (streamTimeoutTimer) clearTimeout(streamTimeoutTimer);
875
- }
876
- if (!res.ok || !res.body) {
877
- const text = !res.ok ? await res.text().catch(() => "") : "";
878
- if (!res.ok) this.throwIfError(res.status, url, text, clean);
879
- throw toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean);
880
- }
869
+ // Transient failures (429/5xx/network) are retried here, before the
870
+ // first byte; once the stream is live it is never retried.
871
+ const res = await openStreamResponse({
872
+ fetchOnce: async () => {
873
+ const timer =
874
+ streamTimeoutMs && streamTimeoutMs > 0
875
+ ? setTimeout(() => {
876
+ try {
877
+ linked.abort();
878
+ } catch {}
879
+ }, streamTimeoutMs)
880
+ : undefined;
881
+ try {
882
+ return await (options?.fetch ?? fetch)(url, this.requestInit(body, options, apiKey, sessionId, linked.signal));
883
+ } finally {
884
+ if (timer) clearTimeout(timer);
885
+ }
886
+ },
887
+ onHttpError: (r, text) => this.throwIfError(r.status, url, text, clean, headersToRecord(r.headers)),
888
+ noBodyError: () => toConciseProviderError(new Error("OpenRouter streaming response had no body"), "openrouter", clean),
889
+ retry: retryOptionsFrom(options),
890
+ signal: linked.signal,
891
+ label: { providerId: "openrouter", modelId: clean },
892
+ });
881
893
  const responseHeaders: Record<string, string> = {};
882
894
  res.headers.forEach((v, k) => {
883
895
  responseHeaders[k] = v;
@@ -886,7 +898,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
886
898
  eventStream.push({ type: "start", raw: { request: rawRequest } } as never);
887
899
 
888
900
  const parser = new SSEParser();
889
- const reader = res.body.getReader();
901
+ const reader = res.body!.getReader(); // openStreamResponse guarantees a body
890
902
  const decoder = new TextDecoder();
891
903
  let text = "";
892
904
  let thinking = "";
@@ -1065,7 +1077,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1065
1077
  id: c.id && !c.id.includes("|") && c.id.length <= 40 ? c.id : clampToolCallId(c.id || newToolCallId()),
1066
1078
  name: c.name,
1067
1079
  arguments: args,
1068
- rawArguments: c.startArgs + c.deltaArgs,
1080
+ rawArguments: selectRawArguments(c.startArgs, c.deltaArgs),
1069
1081
  };
1070
1082
  toolCalls.push(record);
1071
1083
  eventStream.push({ type: "tool_call_complete", toolCall: record });
@@ -1126,8 +1138,7 @@ export class OpenRouterChatCompletionsProvider implements Provider {
1126
1138
  const isAbort =
1127
1139
  linked.signal.aborted ||
1128
1140
  eventStream.isCancelled() ||
1129
- (raw as { name?: string }).name === "AbortError" ||
1130
- /abort|cancell?ed/i.test(String((raw as { message?: string }).message ?? raw));
1141
+ (raw as { name?: string }).name === "AbortError";
1131
1142
  eventStream.fail(
1132
1143
  isAbort ? Object.assign(new Error("Stream aborted"), { name: "AbortError" }) : raw
1133
1144
  );