@pentoshi/clai 4.1.0 → 4.2.1

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 (266) hide show
  1. package/README.md +14 -0
  2. package/dist/agent/compaction-executor.d.ts +68 -0
  3. package/dist/agent/compaction-executor.js +343 -0
  4. package/dist/agent/compaction-executor.js.map +1 -0
  5. package/dist/agent/context-breakdown.js +2 -0
  6. package/dist/agent/context-breakdown.js.map +1 -1
  7. package/dist/agent/context-manager.d.ts +23 -9
  8. package/dist/agent/context-manager.js +72 -43
  9. package/dist/agent/context-manager.js.map +1 -1
  10. package/dist/agent/events.d.ts +3 -0
  11. package/dist/agent/loop-guard.d.ts +2 -0
  12. package/dist/agent/loop-guard.js +48 -3
  13. package/dist/agent/loop-guard.js.map +1 -1
  14. package/dist/agent/prompt-composer.js +6 -6
  15. package/dist/agent/prompt-composer.js.map +1 -1
  16. package/dist/agent/reliability-policy.d.ts +1 -0
  17. package/dist/agent/reliability-policy.js +6 -3
  18. package/dist/agent/reliability-policy.js.map +1 -1
  19. package/dist/agent/request-accounting.d.ts +97 -0
  20. package/dist/agent/request-accounting.js +207 -0
  21. package/dist/agent/request-accounting.js.map +1 -0
  22. package/dist/agent/request-budget.d.ts +2 -2
  23. package/dist/agent/request-budget.js +2 -4
  24. package/dist/agent/request-budget.js.map +1 -1
  25. package/dist/agent/runner.d.ts +8 -2
  26. package/dist/agent/runner.js +409 -178
  27. package/dist/agent/runner.js.map +1 -1
  28. package/dist/agent/session-title.d.ts +0 -26
  29. package/dist/agent/session-title.js +0 -80
  30. package/dist/agent/session-title.js.map +1 -1
  31. package/dist/agent/tool-history.d.ts +2 -2
  32. package/dist/agent/tool-history.js +19 -6
  33. package/dist/agent/tool-history.js.map +1 -1
  34. package/dist/app/adapters/agent-event-adapter.js +19 -0
  35. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  36. package/dist/app/adapters/current-agent-adapter.js +4 -0
  37. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  38. package/dist/app/commands/catalog.js +24 -0
  39. package/dist/app/commands/catalog.js.map +1 -1
  40. package/dist/app/controllers/cancel-coordinator.d.ts +51 -0
  41. package/dist/app/controllers/cancel-coordinator.js +48 -0
  42. package/dist/app/controllers/cancel-coordinator.js.map +1 -0
  43. package/dist/app/controllers/session-compact-helper.d.ts +19 -2
  44. package/dist/app/controllers/session-compact-helper.js +100 -127
  45. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  46. package/dist/app/controllers/session-context-usage.d.ts +32 -15
  47. package/dist/app/controllers/session-context-usage.js +186 -51
  48. package/dist/app/controllers/session-context-usage.js.map +1 -1
  49. package/dist/app/controllers/session-controller.d.ts +20 -11
  50. package/dist/app/controllers/session-controller.js +111 -90
  51. package/dist/app/controllers/session-controller.js.map +1 -1
  52. package/dist/app/controllers/session-naming.d.ts +31 -0
  53. package/dist/app/controllers/session-naming.js +173 -0
  54. package/dist/app/controllers/session-naming.js.map +1 -0
  55. package/dist/app/controllers/session-persistence.d.ts +2 -2
  56. package/dist/app/controllers/session-persistence.js +3 -6
  57. package/dist/app/controllers/session-persistence.js.map +1 -1
  58. package/dist/app/controllers/session-turn-request.d.ts +2 -1
  59. package/dist/app/controllers/session-turn-request.js +3 -0
  60. package/dist/app/controllers/session-turn-request.js.map +1 -1
  61. package/dist/app/controllers/turn-controller.d.ts +2 -1
  62. package/dist/app/controllers/turn-controller.js +1 -0
  63. package/dist/app/controllers/turn-controller.js.map +1 -1
  64. package/dist/app/events/app-event.d.ts +9 -0
  65. package/dist/app/events/app-event.js.map +1 -1
  66. package/dist/app/ports/agent-port.d.ts +8 -1
  67. package/dist/classic/app/ClassicApp.js +2 -1
  68. package/dist/classic/app/ClassicApp.js.map +1 -1
  69. package/dist/classic/app/app-wiring.js +1 -2
  70. package/dist/classic/app/app-wiring.js.map +1 -1
  71. package/dist/classic/app/wiring-interactions.js +2 -7
  72. package/dist/classic/app/wiring-interactions.js.map +1 -1
  73. package/dist/classic/blocks/tool-lines.js +2 -1
  74. package/dist/classic/blocks/tool-lines.js.map +1 -1
  75. package/dist/classic/input/cancel-ladder.d.ts +2 -19
  76. package/dist/classic/input/cancel-ladder.js +13 -20
  77. package/dist/classic/input/cancel-ladder.js.map +1 -1
  78. package/dist/commands/providers.js +2 -0
  79. package/dist/commands/providers.js.map +1 -1
  80. package/dist/llm/adapters/anthropic-tools.d.ts +31 -14
  81. package/dist/llm/adapters/anthropic-tools.js +124 -29
  82. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  83. package/dist/llm/adapters/gemini-tools.d.ts +16 -4
  84. package/dist/llm/adapters/gemini-tools.js +117 -16
  85. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  86. package/dist/llm/adapters/openai-tools.d.ts +13 -2
  87. package/dist/llm/adapters/openai-tools.js +58 -11
  88. package/dist/llm/adapters/openai-tools.js.map +1 -1
  89. package/dist/llm/agentrouter.js +68 -45
  90. package/dist/llm/agentrouter.js.map +1 -1
  91. package/dist/llm/anthropic.js +69 -36
  92. package/dist/llm/anthropic.js.map +1 -1
  93. package/dist/llm/aws-mantle.js +70 -32
  94. package/dist/llm/aws-mantle.js.map +1 -1
  95. package/dist/llm/bynara.js +26 -69
  96. package/dist/llm/bynara.js.map +1 -1
  97. package/dist/llm/capabilities.d.ts +2 -1
  98. package/dist/llm/capabilities.js +47 -0
  99. package/dist/llm/capabilities.js.map +1 -1
  100. package/dist/llm/context-snapshot.d.ts +100 -0
  101. package/dist/llm/context-snapshot.js +277 -0
  102. package/dist/llm/context-snapshot.js.map +1 -0
  103. package/dist/llm/context-windows.d.ts +10 -0
  104. package/dist/llm/context-windows.js +129 -0
  105. package/dist/llm/context-windows.js.map +1 -0
  106. package/dist/llm/custom-provider-profile.d.ts +75 -0
  107. package/dist/llm/custom-provider-profile.js +558 -0
  108. package/dist/llm/custom-provider-profile.js.map +1 -0
  109. package/dist/llm/custom-providers.d.ts +13 -1
  110. package/dist/llm/custom-providers.js +77 -25
  111. package/dist/llm/custom-providers.js.map +1 -1
  112. package/dist/llm/effort-fallback.d.ts +44 -0
  113. package/dist/llm/effort-fallback.js +101 -0
  114. package/dist/llm/effort-fallback.js.map +1 -0
  115. package/dist/llm/fireworks.js +4 -0
  116. package/dist/llm/fireworks.js.map +1 -1
  117. package/dist/llm/free.js +4 -0
  118. package/dist/llm/free.js.map +1 -1
  119. package/dist/llm/gemini.d.ts +1 -1
  120. package/dist/llm/gemini.js +89 -51
  121. package/dist/llm/gemini.js.map +1 -1
  122. package/dist/llm/groq.js +4 -0
  123. package/dist/llm/groq.js.map +1 -1
  124. package/dist/llm/hetzner.js +4 -0
  125. package/dist/llm/hetzner.js.map +1 -1
  126. package/dist/llm/http.d.ts +49 -10
  127. package/dist/llm/http.js +417 -153
  128. package/dist/llm/http.js.map +1 -1
  129. package/dist/llm/kimchi.js +4 -0
  130. package/dist/llm/kimchi.js.map +1 -1
  131. package/dist/llm/lightning.js +4 -0
  132. package/dist/llm/lightning.js.map +1 -1
  133. package/dist/llm/meta.d.ts +2 -0
  134. package/dist/llm/meta.js +811 -655
  135. package/dist/llm/meta.js.map +1 -1
  136. package/dist/llm/modal.js +4 -0
  137. package/dist/llm/modal.js.map +1 -1
  138. package/dist/llm/model-families.d.ts +9 -0
  139. package/dist/llm/model-families.js +39 -0
  140. package/dist/llm/model-families.js.map +1 -0
  141. package/dist/llm/nvidia.js +4 -0
  142. package/dist/llm/nvidia.js.map +1 -1
  143. package/dist/llm/ollama.js +44 -40
  144. package/dist/llm/ollama.js.map +1 -1
  145. package/dist/llm/openai.js +4 -0
  146. package/dist/llm/openai.js.map +1 -1
  147. package/dist/llm/openrouter.js +4 -0
  148. package/dist/llm/openrouter.js.map +1 -1
  149. package/dist/llm/operation-ledger.d.ts +60 -0
  150. package/dist/llm/operation-ledger.js +136 -0
  151. package/dist/llm/operation-ledger.js.map +1 -0
  152. package/dist/llm/operation-usage.d.ts +32 -0
  153. package/dist/llm/operation-usage.js +172 -0
  154. package/dist/llm/operation-usage.js.map +1 -0
  155. package/dist/llm/orcarouter.d.ts +7 -0
  156. package/dist/llm/orcarouter.js +166 -0
  157. package/dist/llm/orcarouter.js.map +1 -0
  158. package/dist/llm/provider-profile-layers.d.ts +7 -0
  159. package/dist/llm/provider-profile-layers.js +961 -0
  160. package/dist/llm/provider-profile-layers.js.map +1 -0
  161. package/dist/llm/provider-profile.d.ts +181 -0
  162. package/dist/llm/provider-profile.js +329 -0
  163. package/dist/llm/provider-profile.js.map +1 -0
  164. package/dist/llm/provider-profiles.d.ts +13 -0
  165. package/dist/llm/provider-profiles.js +86 -0
  166. package/dist/llm/provider-profiles.js.map +1 -0
  167. package/dist/llm/provider.d.ts +2 -0
  168. package/dist/llm/provider.js +67 -0
  169. package/dist/llm/provider.js.map +1 -1
  170. package/dist/llm/qwen-cloud.js +4 -0
  171. package/dist/llm/qwen-cloud.js.map +1 -1
  172. package/dist/llm/reasoning-artifacts.d.ts +101 -0
  173. package/dist/llm/reasoning-artifacts.js +440 -0
  174. package/dist/llm/reasoning-artifacts.js.map +1 -0
  175. package/dist/llm/request-fingerprint.d.ts +10 -0
  176. package/dist/llm/request-fingerprint.js +313 -0
  177. package/dist/llm/request-fingerprint.js.map +1 -0
  178. package/dist/llm/request-plan.d.ts +119 -0
  179. package/dist/llm/request-plan.js +279 -0
  180. package/dist/llm/request-plan.js.map +1 -0
  181. package/dist/llm/router.d.ts +19 -1
  182. package/dist/llm/router.js +447 -75
  183. package/dist/llm/router.js.map +1 -1
  184. package/dist/llm/stream-events.d.ts +47 -0
  185. package/dist/llm/stream-events.js +55 -0
  186. package/dist/llm/stream-events.js.map +1 -0
  187. package/dist/llm/stream-terminal.d.ts +30 -0
  188. package/dist/llm/stream-terminal.js +51 -0
  189. package/dist/llm/stream-terminal.js.map +1 -0
  190. package/dist/llm/token-estimate-calibration.d.ts +23 -0
  191. package/dist/llm/token-estimate-calibration.js +140 -0
  192. package/dist/llm/token-estimate-calibration.js.map +1 -0
  193. package/dist/llm/token-usage.d.ts +35 -5
  194. package/dist/llm/token-usage.js +212 -162
  195. package/dist/llm/token-usage.js.map +1 -1
  196. package/dist/llm/tokenrouter.js +8 -2
  197. package/dist/llm/tokenrouter.js.map +1 -1
  198. package/dist/modes/ask.js +11 -5
  199. package/dist/modes/ask.js.map +1 -1
  200. package/dist/noninteractive/start-noninteractive.js +31 -2
  201. package/dist/noninteractive/start-noninteractive.js.map +1 -1
  202. package/dist/noninteractive/stream-blocks.js +15 -4
  203. package/dist/noninteractive/stream-blocks.js.map +1 -1
  204. package/dist/prompts/embedded.js +1 -1
  205. package/dist/prompts/embedded.js.map +1 -1
  206. package/dist/prompts/system.agent.md +6 -0
  207. package/dist/store/config.js +5 -1
  208. package/dist/store/config.js.map +1 -1
  209. package/dist/store/history.d.ts +2 -0
  210. package/dist/store/history.js +19 -10
  211. package/dist/store/history.js.map +1 -1
  212. package/dist/store/keys.js +11 -1
  213. package/dist/store/keys.js.map +1 -1
  214. package/dist/store/plan.js +20 -14
  215. package/dist/store/plan.js.map +1 -1
  216. package/dist/tools/elevated-shell.d.ts +1 -1
  217. package/dist/tools/elevated-shell.js +24 -72
  218. package/dist/tools/elevated-shell.js.map +1 -1
  219. package/dist/tools/nmap-runner.js +30 -36
  220. package/dist/tools/nmap-runner.js.map +1 -1
  221. package/dist/tools/sudo-session.d.ts +83 -0
  222. package/dist/tools/sudo-session.js +124 -0
  223. package/dist/tools/sudo-session.js.map +1 -0
  224. package/dist/tui-v2/app/App.js +9 -24
  225. package/dist/tui-v2/app/App.js.map +1 -1
  226. package/dist/tui-v2/components/status/context-limit-chip.js +2 -2
  227. package/dist/tui-v2/components/status/context-limit-chip.js.map +1 -1
  228. package/dist/tui-v2/components/transcript/intro-card.js +2 -1
  229. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  230. package/dist/tui-v2/composer/composer-editor.js +2 -1
  231. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  232. package/dist/types.d.ts +156 -1
  233. package/dist/types.js +1 -0
  234. package/dist/types.js.map +1 -1
  235. package/dist/ui-core/bootstrap/composition-root.d.ts +2 -0
  236. package/dist/ui-core/bootstrap/composition-root.js +26 -3
  237. package/dist/ui-core/bootstrap/composition-root.js.map +1 -1
  238. package/dist/ui-core/commands/picker-commands.js +11 -5
  239. package/dist/ui-core/commands/picker-commands.js.map +1 -1
  240. package/dist/ui-core/commands/session-commands.js +41 -5
  241. package/dist/ui-core/commands/session-commands.js.map +1 -1
  242. package/dist/ui-core/plan/plan-lifecycle.js +3 -2
  243. package/dist/ui-core/plan/plan-lifecycle.js.map +1 -1
  244. package/dist/ui-core/rendering/context-limit.js +4 -1
  245. package/dist/ui-core/rendering/context-limit.js.map +1 -1
  246. package/dist/ui-core/rendering/duration.d.ts +2 -1
  247. package/dist/ui-core/rendering/duration.js +13 -5
  248. package/dist/ui-core/rendering/duration.js.map +1 -1
  249. package/dist/ui-core/rendering/intro-header.js +2 -2
  250. package/dist/ui-core/rendering/intro-header.js.map +1 -1
  251. package/dist/ui-core/rendering/theme.d.ts +1 -0
  252. package/dist/ui-core/rendering/theme.js +2 -0
  253. package/dist/ui-core/rendering/theme.js.map +1 -1
  254. package/dist/ui-core/state/transcript-hydrate.js +3 -3
  255. package/dist/ui-core/state/transcript-hydrate.js.map +1 -1
  256. package/dist/ui-core/state/transcript-reducer.d.ts +5 -0
  257. package/dist/ui-core/state/transcript-reducer.js +76 -3
  258. package/dist/ui-core/state/transcript-reducer.js.map +1 -1
  259. package/dist/ui-core/state/transcript-store.d.ts +10 -1
  260. package/dist/ui-core/state/transcript-store.js +22 -1
  261. package/dist/ui-core/state/transcript-store.js.map +1 -1
  262. package/dist/ui-core/state/transcript-types.d.ts +2 -0
  263. package/dist/ui-core/state/transcript-types.js.map +1 -1
  264. package/dist/version.generated.d.ts +2 -2
  265. package/dist/version.generated.js +2 -2
  266. package/package.json +6 -1
@@ -3,10 +3,12 @@ import { getProviderKeys, getProviderSecret, markProviderKeySuccess, } from "../
3
3
  import { anthropicProvider } from "./anthropic.js";
4
4
  import { geminiProvider } from "./gemini.js";
5
5
  import { groqProvider } from "./groq.js";
6
- import { ProviderError, bodyAddsInformation, collapseWhitespace, isImageInputUnsupportedError, isReasoningUnsupportedError, stripImagesFromMessages, MAX_ERROR_BODY_IN_MESSAGE_CHARS, STREAM_STALL_MARKER, } from "./http.js";
7
- import { learnModelVisionCapability, markModelUnavailable, markReasoningUnsupported, modelAcceptsImages, modelSupportsVision, visionSubstitutionOrigin, } from "./capabilities.js";
6
+ import { ProviderError, bodyAddsInformation, buildReasoningPayload, collapseWhitespace, isImageInputUnsupportedError, isReasoningUnsupportedError, stripImagesFromMessages, MAX_ERROR_BODY_IN_MESSAGE_CHARS, STREAM_STALL_MARKER, } from "./http.js";
7
+ import { isReasoningUnsupported, learnModelVisionCapability, markModelUnavailable, markReasoningUnsupported, modelAcceptsImages, modelSupportsVision, visionSubstitutionOrigin, } from "./capabilities.js";
8
8
  import { applyImageViewAvailability } from "../prompts/index.js";
9
+ import { fallbackEffortsFor } from "./effort-fallback.js";
9
10
  import { markStreamEmittedBytes, streamAlreadyEmitted, streamEmittedBytes, } from "./stream-progress.js";
11
+ import { createStreamEventGuard, isSemanticStreamOutputEvent, } from "./stream-events.js";
10
12
  import { attemptsPerKey, buildKeyAttemptPlan, formatKeyEventStatus, isImmediateKeySwitchError, isKeyCircleStopError, isKeyRotatableError, isQuotaKeyError, } from "./key-rotation.js";
11
13
  import { freeProvider } from "./free.js";
12
14
  import { nvidiaProvider } from "./nvidia.js";
@@ -24,6 +26,9 @@ import { tokenrouterProvider } from "./tokenrouter.js";
24
26
  import { metaProvider } from "./meta.js";
25
27
  import { fireworksProvider } from "./fireworks.js";
26
28
  import { hetznerProvider } from "./hetzner.js";
29
+ import { orcarouterProvider } from "./orcarouter.js";
30
+ import { OperationUsageRecorder, runGenerationAttempt, } from "./operation-usage.js";
31
+ import { isOperationPolicyError, OperationLedger, operationTerminalOutcome, turnOperationPolicy, } from "./operation-ledger.js";
27
32
  import { maskSecretTail } from "./provider.js";
28
33
  import { getCustomProviderSync } from "./custom-providers.js";
29
34
  import { isToolsUnsupportedError, markTextOnlyModel, } from "./tool-protocol.js";
@@ -32,6 +37,36 @@ const MAX_RETRIES = 6;
32
37
  const MAX_RETRY_WAIT_MS = 120_000;
33
38
  /** Toast replace-key so key rotation never stacks notifications. */
34
39
  export const API_KEY_TOAST_KEY = "api-key-rotation";
40
+ /**
41
+ * A budget/policy error raised while recovering from a real failure describes
42
+ * the guard, not the cause. Surface the failure the caller actually needs.
43
+ */
44
+ function successfulRequestSnapshot(provider, model, request) {
45
+ return structuredClone({
46
+ provider,
47
+ model,
48
+ messages: request.messages,
49
+ ...(request.temperature !== undefined
50
+ ? { temperature: request.temperature }
51
+ : {}),
52
+ ...(request.thinking ? { thinking: request.thinking } : {}),
53
+ ...(request.tools ? { tools: request.tools } : {}),
54
+ ...(request.toolChoice !== undefined
55
+ ? { toolChoice: request.toolChoice }
56
+ : {}),
57
+ ...(request.parallelToolCalls !== undefined
58
+ ? { parallelToolCalls: request.parallelToolCalls }
59
+ : {}),
60
+ });
61
+ }
62
+ function preservedFailure(recoveryError, originalError) {
63
+ return isOperationPolicyError(recoveryError) ? originalError : recoveryError;
64
+ }
65
+ function resolveOperationLedger(options) {
66
+ if (options.operation)
67
+ return options.operation;
68
+ return new OperationLedger(turnOperationPolicy(), options.attemptUsage ?? new OperationUsageRecorder());
69
+ }
35
70
  async function sleep(ms, signal) {
36
71
  if (signal?.aborted)
37
72
  throw signal.reason ?? new Error("Aborted");
@@ -56,6 +91,47 @@ async function sleep(ms, signal) {
56
91
  function isRateLimited(error) {
57
92
  return error instanceof ProviderError && error.status === 429;
58
93
  }
94
+ function isServerUnavailable(error) {
95
+ if (!(error instanceof ProviderError))
96
+ return false;
97
+ const status = error.status ?? 0;
98
+ return status === 502 || status === 503 || status === 504;
99
+ }
100
+ function isServerError(error) {
101
+ if (!(error instanceof ProviderError))
102
+ return false;
103
+ const status = error.status ?? 0;
104
+ return status >= 500 && status <= 504;
105
+ }
106
+ function isReasoningRelatedServerError(error, providerId) {
107
+ if (!isServerError(error))
108
+ return false;
109
+ if (providerId === "tokenrouter")
110
+ return true;
111
+ const message = error instanceof Error ? error.message : String(error);
112
+ const body = error instanceof ProviderError ? (error.body ?? "") : "";
113
+ return /reasoning|effort|thinking/i.test(`${message} ${body}`);
114
+ }
115
+ function shouldContinueEffortLadder(error, providerId) {
116
+ return (isReasoningUnsupportedError(error) ||
117
+ isReasoningRelatedServerError(error, providerId));
118
+ }
119
+ function shouldEnterEffortLadder(error, thinking, providerId, model, singleDispatch) {
120
+ if (isReasoningUnsupportedError(error))
121
+ return true;
122
+ if (singleDispatch)
123
+ return false;
124
+ if (!thinking?.enabled)
125
+ return false;
126
+ if (isReasoningUnsupported(providerId, model))
127
+ return false;
128
+ return isReasoningRelatedServerError(error, providerId);
129
+ }
130
+ function isCacheOnlyColdError(error) {
131
+ const message = error instanceof Error ? error.message : String(error);
132
+ const body = error instanceof ProviderError ? (error.body ?? "") : "";
133
+ return /cache_only_cold|cache-only admission/i.test(`${message} ${body}`);
134
+ }
59
135
  function isTransientNetworkError(error) {
60
136
  const message = error instanceof Error ? error.message : String(error);
61
137
  const msg = message.toLowerCase();
@@ -166,6 +242,9 @@ export function formatProviderFailureForUser(error) {
166
242
  return withFullBody(withExactError("Provider rejected the request body (422). Model name or parameters may be incompatible — try another model."));
167
243
  }
168
244
  if (status === 503 || status === 502 || status === 504) {
245
+ if (isCacheOnlyColdError(error)) {
246
+ return withFullBody(withExactError(`Gateway cache admission rejected (${status}; cache_only_cold): the route is cold or overloaded and retried automatically with backoff. If it persists, retry shortly or switch provider/model.`));
247
+ }
169
248
  return withFullBody(withExactError(`Upstream provider unavailable (${status}). Retry shortly or switch provider/model; free-tier models are often capacity-constrained.`));
170
249
  }
171
250
  if (status >= 500 && status < 600) {
@@ -290,6 +369,7 @@ export const providers = {
290
369
  meta: metaProvider,
291
370
  fireworks: fireworksProvider,
292
371
  hetzner: hetznerProvider,
372
+ orcarouter: orcarouterProvider,
293
373
  };
294
374
  const fallbackOrder = [
295
375
  "free",
@@ -311,6 +391,7 @@ const fallbackOrder = [
311
391
  "meta",
312
392
  "fireworks",
313
393
  "hetzner",
394
+ "orcarouter",
314
395
  ];
315
396
  /**
316
397
  * Built-in provider ids in fallback preference order. Custom (user-defined)
@@ -387,6 +468,9 @@ function authForSlot(providerId, value) {
387
468
  function withoutReasoning(request) {
388
469
  return { ...request, thinking: undefined };
389
470
  }
471
+ function reasoningWireKey(thinking, style, model, providerId) {
472
+ return JSON.stringify(buildReasoningPayload(thinking, style, model, providerId));
473
+ }
390
474
  function makeKeyEmitter(onStatus, onKeyEvent) {
391
475
  return (event) => {
392
476
  onKeyEvent?.(event);
@@ -400,39 +484,102 @@ function makeKeyEmitter(onStatus, onKeyEvent) {
400
484
  }
401
485
  };
402
486
  }
403
- async function tryCompleteOnce(provider, providerId, request, model, auth) {
487
+ const SELF_RECORDED_PROVIDERS = new Set([
488
+ "agentrouter",
489
+ "bynara",
490
+ "meta",
491
+ ]);
492
+ async function runRecordedProviderAttempt(input) {
493
+ if (SELF_RECORDED_PROVIDERS.has(input.providerId))
494
+ return input.run();
495
+ return runGenerationAttempt(input.request, {
496
+ provider: input.providerId,
497
+ model: input.model,
498
+ mode: input.mode,
499
+ reason: input.reason,
500
+ }, input.run);
501
+ }
502
+ async function tryCompleteOnce(provider, providerId, request, model, auth, reason, onStatus, singleDispatch = false) {
404
503
  const activeRequest = { ...request, provider: providerId, model };
504
+ const runAttempt = (candidate, attemptReason) => {
505
+ const attemptRequest = { ...candidate, attemptReason };
506
+ return runRecordedProviderAttempt({
507
+ providerId,
508
+ model: attemptRequest.model ?? model,
509
+ mode: "complete",
510
+ reason: attemptReason,
511
+ request: attemptRequest,
512
+ run: () => provider.complete(attemptRequest, auth),
513
+ });
514
+ };
405
515
  try {
406
- const result = await provider.complete(activeRequest, auth);
516
+ const result = await runAttempt(activeRequest, reason);
407
517
  if (hasImageInput(activeRequest)) {
408
518
  learnModelVisionCapability(providerId, model, true);
409
519
  }
410
520
  return result;
411
521
  }
412
522
  catch (error) {
523
+ // A pinned single-dispatch operation records the capability verdict for the
524
+ // next operation but never spends a second physical request on it.
413
525
  if (activeRequest.tools?.length && isToolsUnsupportedError(error)) {
414
526
  markTextOnlyModel(providerId, model);
527
+ if (singleDispatch)
528
+ throw error;
415
529
  const textRequest = {
416
530
  ...activeRequest,
417
531
  tools: undefined,
418
532
  toolChoice: undefined,
419
533
  parallelToolCalls: undefined,
420
534
  };
421
- return await provider.complete(textRequest, auth);
535
+ return await runAttempt(textRequest, "adaptation");
422
536
  }
423
- // Model rejected a reasoning/thinking knob mark it and retry once
424
- // without reasoning so an unsupported option never fails the request.
425
- if (isReasoningUnsupportedError(error)) {
537
+ if (shouldEnterEffortLadder(error, activeRequest.thinking, providerId, model, singleDispatch)) {
538
+ if (singleDispatch) {
539
+ markReasoningUnsupported(providerId, model);
540
+ throw error;
541
+ }
542
+ const thinking = activeRequest.thinking;
543
+ if (thinking?.enabled) {
544
+ const style = provider.reasoningStyle ?? "none";
545
+ const seen = new Set([
546
+ reasoningWireKey(thinking, style, model, providerId),
547
+ ]);
548
+ for (const effort of fallbackEffortsFor(thinking.effort)) {
549
+ const candidate = { ...thinking, effort };
550
+ const key = reasoningWireKey(candidate, style, model, providerId);
551
+ if (seen.has(key))
552
+ continue;
553
+ seen.add(key);
554
+ onStatus?.(`ℹ ${providerId}/${model} rejected reasoning effort — retrying with ${effort}`);
555
+ const retryRequest = {
556
+ ...activeRequest,
557
+ thinking: candidate,
558
+ };
559
+ try {
560
+ return await runAttempt(retryRequest, "adaptation");
561
+ }
562
+ catch (retryError) {
563
+ if (!shouldContinueEffortLadder(retryError, providerId))
564
+ throw retryError;
565
+ }
566
+ }
567
+ }
426
568
  markReasoningUnsupported(providerId, model);
427
- return await provider.complete(withoutReasoning(activeRequest), auth);
569
+ onStatus?.(`ℹ ${providerId}/${model} rejected reasoning options — retrying without them`);
570
+ return await runAttempt(withoutReasoning(activeRequest), "adaptation");
428
571
  }
429
572
  if (hasImageInput(activeRequest) && isImageInputUnsupportedError(error)) {
430
573
  learnModelVisionCapability(providerId, model, false);
431
- return await provider.complete(withoutImages(activeRequest), auth);
574
+ if (singleDispatch)
575
+ throw error;
576
+ return await runAttempt(withoutImages(activeRequest), "adaptation");
432
577
  }
433
- const restored = revertVisionSubstitution(providerId, model, activeRequest, error);
434
- if (restored) {
435
- return await provider.complete(restored.request, auth);
578
+ if (!singleDispatch) {
579
+ const restored = revertVisionSubstitution(providerId, model, activeRequest, error);
580
+ if (restored) {
581
+ return await runAttempt(restored.request, "adaptation");
582
+ }
436
583
  }
437
584
  throw error;
438
585
  }
@@ -502,27 +649,57 @@ function revertVisionSubstitution(providerId, model, request, error) {
502
649
  request: requestForRoute(restoredRequest, providerId, original),
503
650
  };
504
651
  }
505
- async function tryStreamOnce(provider, providerId, request, model, auth, onToken, onStatus) {
652
+ async function tryStreamOnce(provider, providerId, request, model, auth, onToken, onStatus, reason, singleDispatch = false, onSuccessfulRequest) {
506
653
  let emittedBytes = 0;
507
654
  let emittedToolArgumentBytes = 0;
508
655
  const onToolCallDelta = request.onToolCallDelta;
656
+ const downstreamEvents = request.onStreamEvent;
657
+ let guard = createStreamEventGuard();
658
+ const startedToolCallIndexes = new Set();
659
+ const emitEvent = (event) => {
660
+ guard.accept(event);
661
+ if (event.type === "reasoning_delta" || event.type === "commentary_delta") {
662
+ emittedBytes += event.text.length;
663
+ }
664
+ downstreamEvents?.(event);
665
+ };
509
666
  const activeRequest = {
510
667
  ...request,
511
668
  provider: providerId,
512
669
  model,
513
- ...(onToolCallDelta
670
+ ...(onToolCallDelta || downstreamEvents
514
671
  ? {
515
672
  onToolCallDelta: (delta) => {
516
673
  const argumentBytes = delta.argumentsBytes ?? 0;
517
674
  emittedBytes += Math.max(delta.name?.length ?? 0, argumentBytes - emittedToolArgumentBytes, 1);
518
675
  emittedToolArgumentBytes = Math.max(emittedToolArgumentBytes, argumentBytes);
519
- onToolCallDelta(delta);
676
+ if (delta.name !== undefined &&
677
+ !startedToolCallIndexes.has(delta.index)) {
678
+ startedToolCallIndexes.add(delta.index);
679
+ emitEvent({
680
+ type: "tool_call_started",
681
+ index: delta.index,
682
+ ...(delta.id ? { id: delta.id } : {}),
683
+ name: delta.name,
684
+ });
685
+ }
686
+ emitEvent({
687
+ type: "tool_arguments_delta",
688
+ index: delta.index,
689
+ ...(delta.id ? { id: delta.id } : {}),
690
+ argumentsBytes: argumentBytes,
691
+ });
692
+ onToolCallDelta?.(delta);
520
693
  },
521
694
  }
522
695
  : {}),
696
+ ...(downstreamEvents ? { onStreamEvent: emitEvent } : {}),
523
697
  };
524
698
  const emit = (token) => {
699
+ if (!token)
700
+ return;
525
701
  emittedBytes += token.length;
702
+ emitEvent({ type: "answer_delta", text: token });
526
703
  onToken(token);
527
704
  };
528
705
  const learnVisionOnSuccess = () => {
@@ -530,14 +707,62 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
530
707
  learnModelVisionCapability(providerId, model, true);
531
708
  }
532
709
  };
533
- try {
534
- if (provider.stream) {
535
- const streamed = await provider.stream(activeRequest, auth, emit);
536
- learnVisionOnSuccess();
537
- return streamed;
710
+ const runAttempt = async (candidate, attemptReason) => {
711
+ guard = createStreamEventGuard();
712
+ startedToolCallIndexes.clear();
713
+ const attemptRequest = { ...candidate, attemptReason };
714
+ const result = await runRecordedProviderAttempt({
715
+ providerId,
716
+ model: attemptRequest.model ?? model,
717
+ mode: "stream",
718
+ reason: attemptReason,
719
+ request: attemptRequest,
720
+ run: async () => {
721
+ let result;
722
+ if (provider.stream) {
723
+ result = await provider.stream(attemptRequest, auth, emit);
724
+ }
725
+ else {
726
+ result = await provider.complete(attemptRequest, auth);
727
+ emit(result.text);
728
+ }
729
+ for (const [index, call] of (result.toolCalls ?? []).entries()) {
730
+ if (!startedToolCallIndexes.has(index)) {
731
+ startedToolCallIndexes.add(index);
732
+ emitEvent({
733
+ type: "tool_call_started",
734
+ index,
735
+ ...(call.id ? { id: call.id } : {}),
736
+ name: call.name,
737
+ });
738
+ }
739
+ emitEvent({
740
+ type: "tool_call_completed",
741
+ index,
742
+ ...(call.id ? { id: call.id } : {}),
743
+ name: call.name,
744
+ });
745
+ }
746
+ if (result.usage) {
747
+ emitEvent({ type: "usage_observed", usage: result.usage });
748
+ }
749
+ emitEvent({
750
+ type: "provider_terminal",
751
+ ...(result.finishReason
752
+ ? { finishReason: result.finishReason }
753
+ : {}),
754
+ });
755
+ return result;
756
+ },
757
+ });
758
+ try {
759
+ onSuccessfulRequest?.(successfulRequestSnapshot(result.provider || providerId, result.model || attemptRequest.model || model, attemptRequest));
538
760
  }
539
- const result = await provider.complete(activeRequest, auth);
540
- emit(result.text);
761
+ catch { }
762
+ return result;
763
+ };
764
+ try {
765
+ const result = await runAttempt(activeRequest, reason);
541
766
  learnVisionOnSuccess();
542
767
  return result;
543
768
  }
@@ -546,6 +771,8 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
546
771
  activeRequest.tools?.length &&
547
772
  isToolsUnsupportedError(error)) {
548
773
  markTextOnlyModel(providerId, model);
774
+ if (singleDispatch)
775
+ throw markStreamEmittedBytes(error, emittedBytes);
549
776
  onStatus?.(`ℹ ${providerId}/${model} does not support native tools — falling back to text protocol`);
550
777
  const textRequest = {
551
778
  ...activeRequest,
@@ -554,69 +781,84 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
554
781
  parallelToolCalls: undefined,
555
782
  };
556
783
  try {
557
- if (provider.stream) {
558
- return await provider.stream(textRequest, auth, emit);
559
- }
560
- const result = await provider.complete(textRequest, auth);
561
- emit(result.text);
562
- return result;
784
+ return await runAttempt(textRequest, "adaptation");
563
785
  }
564
786
  catch (retryError) {
565
- throw markStreamEmittedBytes(retryError, emittedBytes);
787
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
566
788
  }
567
789
  }
568
790
  // Model rejected a reasoning/thinking knob (e.g. chat_template_kwargs on a
569
- // NIM chat template that does not accept it). Mark it so buildChatBody stops
570
- // sending reasoning, then retry once without it. A parameter rejection is a
571
- // request-time 4xx, so no tokens have streamed yet the retry is clean.
572
- if (emittedBytes === 0 && isReasoningUnsupportedError(error)) {
791
+ // NIM chat template that does not accept it). A parameter rejection is a
792
+ // request-time 4xx, so no tokens have streamed yet retries are clean.
793
+ // Walk down the effort ladder first (max xhigh high medium → low) so
794
+ // a model that merely rejects the highest requested depth keeps reasoning;
795
+ // only strip reasoning entirely once every candidate has been rejected.
796
+ if (emittedBytes === 0 && shouldEnterEffortLadder(error, activeRequest.thinking, providerId, model, singleDispatch)) {
797
+ if (singleDispatch) {
798
+ markReasoningUnsupported(providerId, model);
799
+ throw markStreamEmittedBytes(error, emittedBytes);
800
+ }
801
+ const thinking = activeRequest.thinking;
802
+ if (thinking?.enabled) {
803
+ const style = provider.reasoningStyle ?? "none";
804
+ const seen = new Set([
805
+ reasoningWireKey(thinking, style, model, providerId),
806
+ ]);
807
+ for (const effort of fallbackEffortsFor(thinking.effort)) {
808
+ const candidate = { ...thinking, effort };
809
+ const key = reasoningWireKey(candidate, style, model, providerId);
810
+ if (seen.has(key))
811
+ continue;
812
+ seen.add(key);
813
+ onStatus?.(`ℹ ${providerId}/${model} rejected reasoning effort — retrying with ${effort}`);
814
+ const retryRequest = {
815
+ ...activeRequest,
816
+ thinking: candidate,
817
+ };
818
+ try {
819
+ return await runAttempt(retryRequest, "adaptation");
820
+ }
821
+ catch (retryError) {
822
+ if (!shouldContinueEffortLadder(retryError, providerId)) {
823
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
824
+ }
825
+ }
826
+ }
827
+ }
573
828
  markReasoningUnsupported(providerId, model);
574
829
  onStatus?.(`ℹ ${providerId}/${model} rejected reasoning options — retrying without them`);
575
830
  const retryRequest = withoutReasoning(activeRequest);
576
831
  try {
577
- if (provider.stream) {
578
- return await provider.stream(retryRequest, auth, emit);
579
- }
580
- const result = await provider.complete(retryRequest, auth);
581
- emit(result.text);
582
- return result;
832
+ return await runAttempt(retryRequest, "adaptation");
583
833
  }
584
834
  catch (retryError) {
585
- throw markStreamEmittedBytes(retryError, emittedBytes);
835
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
586
836
  }
587
837
  }
588
838
  if (emittedBytes === 0 &&
589
839
  hasImageInput(activeRequest) &&
590
840
  isImageInputUnsupportedError(error)) {
591
841
  learnModelVisionCapability(providerId, model, false);
842
+ if (singleDispatch)
843
+ throw markStreamEmittedBytes(error, emittedBytes);
592
844
  onStatus?.(`ℹ ${providerId}/${model} rejected image input — retrying without the attached image(s)`);
593
845
  const textOnlyRequest = withoutImages(activeRequest);
594
846
  try {
595
- if (provider.stream) {
596
- return await provider.stream(textOnlyRequest, auth, emit);
597
- }
598
- const result = await provider.complete(textOnlyRequest, auth);
599
- emit(result.text);
600
- return result;
847
+ return await runAttempt(textOnlyRequest, "adaptation");
601
848
  }
602
849
  catch (retryError) {
603
- throw markStreamEmittedBytes(retryError, emittedBytes);
850
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
604
851
  }
605
852
  }
606
- if (emittedBytes === 0) {
853
+ if (emittedBytes === 0 && !singleDispatch) {
607
854
  const restored = revertVisionSubstitution(providerId, model, activeRequest, error);
608
855
  if (restored) {
609
856
  onStatus?.(`ℹ ${providerId}/${model} is not available on this account — falling back to ${restored.original}`);
610
857
  try {
611
- if (provider.stream) {
612
- return await provider.stream(restored.request, auth, emit);
613
- }
614
- const result = await provider.complete(restored.request, auth);
615
- emit(result.text);
616
- return result;
858
+ return await runAttempt(restored.request, "adaptation");
617
859
  }
618
860
  catch (retryError) {
619
- throw markStreamEmittedBytes(retryError, emittedBytes);
861
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
620
862
  }
621
863
  }
622
864
  }
@@ -630,12 +872,16 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
630
872
  */
631
873
  async function runWithKeyRotation(opts) {
632
874
  const { providerId, provider, request, model, emitKey } = opts;
875
+ const singleDispatch = opts.singleDispatch === true;
633
876
  const multi = await getProviderKeys(providerId);
634
877
  const slots = multi.keys;
635
878
  if (slots.length === 0) {
636
879
  throw new Error("no API key configured");
637
880
  }
638
- const plan = buildKeyAttemptPlan(slots.length, multi.activeIndex).filter((index) => !slots[index].disabled);
881
+ const fullPlan = buildKeyAttemptPlan(slots.length, multi.activeIndex).filter((index) => !slots[index].disabled);
882
+ // A pinned operation uses the sticky key only: rotating would send the same
883
+ // large prompt again under a different credential.
884
+ const plan = singleDispatch ? fullPlan.slice(0, 1) : fullPlan;
639
885
  if (plan.length === 0) {
640
886
  throw new Error(`all ${slots.length} API key${slots.length === 1 ? "" : "s"} for ${providerId} are disabled — re-enable one to use this provider`);
641
887
  }
@@ -643,12 +889,14 @@ async function runWithKeyRotation(opts) {
643
889
  const multiKey = enabledCount > 1;
644
890
  // Single key: time-increasing retries (MAX_RETRIES+1 attempts).
645
891
  // Multi key: 2 attempts per key (initial + one retry), then next key.
646
- const maxPerKey = attemptsPerKey(enabledCount, (opts.maxRetries ?? MAX_RETRIES) + 1);
892
+ const maxPerKey = singleDispatch
893
+ ? 1
894
+ : attemptsPerKey(enabledCount, (opts.maxRetries ?? MAX_RETRIES) + 1);
647
895
  let lastError;
648
896
  // Endpoint failover: a provider can carry several base URLs (e.g. Modal
649
897
  // workspaces). On an auth/quota error the active endpoint may simply be the
650
898
  // wrong workspace for the key, so rotate the endpoint too before giving up.
651
- const storedEndpoints = providerUsesEndpoints(providerId)
899
+ const storedEndpoints = providerUsesEndpoints(providerId) && !singleDispatch
652
900
  ? getProviderEndpoints(providerId)
653
901
  : undefined;
654
902
  const endpointUrls = (storedEndpoints?.urls ?? []).filter((url) => !(storedEndpoints?.disabledUrls ?? []).includes(url));
@@ -684,13 +932,14 @@ async function runWithKeyRotation(opts) {
684
932
  }
685
933
  for (let attempt = 0; attempt < maxPerKey; attempt++) {
686
934
  request.signal?.throwIfAborted();
935
+ const attemptReason = planIdx === 0 && attempt === 0 ? opts.initialAttemptReason : "retry";
687
936
  try {
688
937
  let result;
689
938
  if (opts.mode === "stream") {
690
- result = await tryStreamOnce(provider, providerId, request, model, auth, opts.onToken ?? (() => { }), opts.onStatus);
939
+ result = await tryStreamOnce(provider, providerId, request, model, auth, opts.onToken ?? (() => { }), opts.onStatus, attemptReason, singleDispatch, opts.onSuccessfulRequest);
691
940
  }
692
941
  else {
693
- result = await tryCompleteOnce(provider, providerId, request, model, auth);
942
+ result = await tryCompleteOnce(provider, providerId, request, model, auth, attemptReason, opts.onStatus, singleDispatch);
694
943
  }
695
944
  // Sticky success only for stored multi-key (not env-only synthetic).
696
945
  if (multi.source !== "env" && multi.source !== "local") {
@@ -712,7 +961,13 @@ async function runWithKeyRotation(opts) {
712
961
  return result;
713
962
  }
714
963
  catch (error) {
964
+ const previousError = lastError;
715
965
  lastError = error;
966
+ // An admission guard that fires while recovering from a real failure
967
+ // hides that failure. Report the cause the user can act on.
968
+ if (isOperationPolicyError(error) && previousError !== undefined) {
969
+ throw previousError;
970
+ }
716
971
  // 404/422: other keys for the same model will not help.
717
972
  if (isKeyCircleStopError(error)) {
718
973
  throw error;
@@ -746,7 +1001,7 @@ async function runWithKeyRotation(opts) {
746
1001
  }
747
1002
  const canRetrySame = attempt + 1 < maxPerKey;
748
1003
  if (canRetrySame) {
749
- const wait = isRateLimited(error)
1004
+ const wait = isRateLimited(error) || isServerUnavailable(error)
750
1005
  ? retryWaitMs(error, attempt)
751
1006
  : networkRetryWaitMs(attempt);
752
1007
  if (wait > MAX_RETRY_WAIT_MS) {
@@ -807,15 +1062,19 @@ async function runWithKeyRotation(opts) {
807
1062
  const err = lastError ?? new Error("all API keys failed");
808
1063
  throw err;
809
1064
  }
810
- export async function completeWithProvider(request, options) {
1065
+ async function completeWithProviderOperation(request, options, ledger) {
811
1066
  const config = getConfig();
1067
+ const singleDispatch = options.singleDispatch === true;
812
1068
  const requested = request.provider ?? config.defaultProvider;
813
1069
  const providerImpl = getProvider(requested);
814
1070
  const isDefaultModel = !request.model || request.model === providerImpl.defaultModel;
815
- const fallbackEnabled = config.providerFallback &&
1071
+ const fallbackEnabled = !singleDispatch &&
1072
+ config.providerFallback &&
816
1073
  (await requestedRealKeyCount(requested)) !== 1 &&
817
1074
  (isDefaultModel || request.allowModelFallback === true);
818
- const order = buildFallbackChain(requested, config.freeOnly, fallbackEnabled, request.preferModelFallback === true);
1075
+ const order = singleDispatch
1076
+ ? [requested]
1077
+ : buildFallbackChain(requested, config.freeOnly, fallbackEnabled, request.preferModelFallback === true);
819
1078
  const failures = [];
820
1079
  const emitKey = makeKeyEmitter(options?.onStatus, options?.onKeyEvent);
821
1080
  for (const providerId of order) {
@@ -830,7 +1089,10 @@ export async function completeWithProvider(request, options) {
830
1089
  const model = providerId === requested
831
1090
  ? (request.model ?? provider.defaultModel)
832
1091
  : provider.defaultModel;
833
- const routeRequest = requestForRoute(request, providerId, model);
1092
+ const routeRequest = {
1093
+ ...requestForRoute(request, providerId, model),
1094
+ attemptUsage: ledger,
1095
+ };
834
1096
  try {
835
1097
  const result = await runWithKeyRotation({
836
1098
  providerId,
@@ -839,17 +1101,26 @@ export async function completeWithProvider(request, options) {
839
1101
  model,
840
1102
  emitKey,
841
1103
  mode: "complete",
1104
+ initialAttemptReason: providerId === requested ? "initial" : "fallback",
842
1105
  ...(options?.onStatus ? { onStatus: options.onStatus } : {}),
843
1106
  ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
1107
+ ...(singleDispatch ? { singleDispatch: true } : {}),
844
1108
  });
845
1109
  if (providerId !== requested) {
846
- setDefaultProvider(providerId);
847
- setProviderModel(providerId, result.model || model);
1110
+ if (options?.adoptFallback === true) {
1111
+ setDefaultProvider(providerId);
1112
+ setProviderModel(providerId, result.model || model);
1113
+ }
848
1114
  options?.onStatus?.(`switching to ${providerId}/${result.model || model} after ${requested} failed`);
849
1115
  }
850
1116
  return result;
851
1117
  }
852
1118
  catch (error) {
1119
+ if (isOperationPolicyError(error)) {
1120
+ if (failures.length === 0)
1121
+ throw error;
1122
+ throw aggregateProviderError(`No provider could complete the request.${formatFailures(failures)}`, failures);
1123
+ }
853
1124
  failures.push({
854
1125
  provider: providerId,
855
1126
  message: failureMessageFor(providerId, error),
@@ -863,18 +1134,68 @@ export async function completeWithProvider(request, options) {
863
1134
  }
864
1135
  throw aggregateProviderError(`No provider could complete the request.${formatFailures(failures)}`, failures);
865
1136
  }
866
- export async function streamWithProvider(request, onToken, onStatusOrOptions) {
1137
+ function attachOperationUsageToError(error, snapshot) {
1138
+ if (error && typeof error === "object" && Object.isExtensible(error)) {
1139
+ try {
1140
+ Object.defineProperty(error, "operationUsage", {
1141
+ configurable: true,
1142
+ enumerable: false,
1143
+ value: snapshot,
1144
+ });
1145
+ return error;
1146
+ }
1147
+ catch { }
1148
+ }
1149
+ const message = error instanceof Error
1150
+ ? error.message
1151
+ : typeof error === "string" && error.trim()
1152
+ ? error
1153
+ : "Provider operation failed";
1154
+ const wrapped = new Error(message, { cause: error });
1155
+ Object.defineProperty(wrapped, "operationUsage", {
1156
+ configurable: true,
1157
+ enumerable: false,
1158
+ value: snapshot,
1159
+ });
1160
+ return wrapped;
1161
+ }
1162
+ export async function completeWithProvider(request, options = {}) {
1163
+ const ledger = resolveOperationLedger(options);
1164
+ try {
1165
+ const result = await completeWithProviderOperation(request, options, ledger);
1166
+ ledger.settle("completed");
1167
+ return { ...result, operationUsage: ledger.snapshot() };
1168
+ }
1169
+ catch (error) {
1170
+ ledger.settle(operationTerminalOutcome(error, request.signal));
1171
+ throw attachOperationUsageToError(error, ledger.snapshot());
1172
+ }
1173
+ finally {
1174
+ options.onOperationUsage?.(ledger.snapshot());
1175
+ }
1176
+ }
1177
+ async function streamWithProviderOperation(request, onToken, onStatusOrOptions, ledger) {
867
1178
  const options = typeof onStatusOrOptions === "function"
868
1179
  ? { onStatus: onStatusOrOptions }
869
1180
  : onStatusOrOptions ?? {};
1181
+ const operation = ledger ?? resolveOperationLedger(options);
1182
+ const relayToken = (token) => {
1183
+ operation.noteSemanticOutput();
1184
+ onToken(token);
1185
+ };
1186
+ const downstreamToolDelta = request.onToolCallDelta;
870
1187
  const config = getConfig();
1188
+ const singleDispatch = options.singleDispatch === true;
871
1189
  const requested = request.provider ?? config.defaultProvider;
872
1190
  const providerImpl = getProvider(requested);
873
1191
  const isDefaultModel = !request.model || request.model === providerImpl.defaultModel;
874
- const fallbackEnabled = config.providerFallback &&
1192
+ const fallbackEnabled = !singleDispatch &&
1193
+ config.providerFallback &&
875
1194
  (await requestedRealKeyCount(requested)) !== 1 &&
876
1195
  (isDefaultModel || request.allowModelFallback === true);
877
- const order = buildFallbackChain(requested, config.freeOnly, fallbackEnabled, request.preferModelFallback === true);
1196
+ const order = singleDispatch
1197
+ ? [requested]
1198
+ : buildFallbackChain(requested, config.freeOnly, fallbackEnabled, request.preferModelFallback === true);
878
1199
  const failures = [];
879
1200
  const emitStatus = options.onStatus ?? ((message) => onToken(message));
880
1201
  const emitKey = makeKeyEmitter(emitStatus, options.onKeyEvent);
@@ -889,7 +1210,28 @@ export async function streamWithProvider(request, onToken, onStatusOrOptions) {
889
1210
  const model = providerId === requested
890
1211
  ? (request.model ?? provider.defaultModel)
891
1212
  : provider.defaultModel;
892
- const routeRequest = requestForRoute(request, providerId, model);
1213
+ const routeRequest = {
1214
+ ...requestForRoute(request, providerId, model),
1215
+ attemptUsage: operation,
1216
+ ...(downstreamToolDelta
1217
+ ? {
1218
+ onToolCallDelta: (delta) => {
1219
+ operation.noteSemanticOutput();
1220
+ downstreamToolDelta(delta);
1221
+ },
1222
+ }
1223
+ : {}),
1224
+ ...(options.onStreamEvent
1225
+ ? {
1226
+ onStreamEvent: (event) => {
1227
+ if (isSemanticStreamOutputEvent(event)) {
1228
+ operation.noteSemanticOutput();
1229
+ }
1230
+ options.onStreamEvent(event);
1231
+ },
1232
+ }
1233
+ : {}),
1234
+ };
893
1235
  try {
894
1236
  const result = await runWithKeyRotation({
895
1237
  providerId,
@@ -898,18 +1240,30 @@ export async function streamWithProvider(request, onToken, onStatusOrOptions) {
898
1240
  model,
899
1241
  emitKey,
900
1242
  mode: "stream",
901
- onToken,
1243
+ initialAttemptReason: providerId === requested ? "initial" : "fallback",
1244
+ onToken: relayToken,
902
1245
  onStatus: emitStatus,
903
1246
  ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
1247
+ ...(singleDispatch ? { singleDispatch: true } : {}),
1248
+ ...(options.onSuccessfulRequest
1249
+ ? { onSuccessfulRequest: options.onSuccessfulRequest }
1250
+ : {}),
904
1251
  });
905
1252
  if (providerId !== requested) {
906
- setDefaultProvider(providerId);
907
- setProviderModel(providerId, result.model || model);
1253
+ if (options.adoptFallback === true) {
1254
+ setDefaultProvider(providerId);
1255
+ setProviderModel(providerId, result.model || model);
1256
+ }
908
1257
  options.onStatus?.(`switching to ${providerId}/${result.model || model} after ${requested} failed`);
909
1258
  }
910
1259
  return result;
911
1260
  }
912
1261
  catch (error) {
1262
+ if (isOperationPolicyError(error)) {
1263
+ if (failures.length === 0)
1264
+ throw error;
1265
+ throw aggregateProviderError(`No provider could stream the request.${formatFailures(failures)}`, failures, streamEmittedBytes(error));
1266
+ }
913
1267
  failures.push({
914
1268
  provider: providerId,
915
1269
  message: failureMessageFor(providerId, error),
@@ -927,6 +1281,24 @@ export async function streamWithProvider(request, onToken, onStatusOrOptions) {
927
1281
  }
928
1282
  throw aggregateProviderError(`No provider could stream the request.${formatFailures(failures)}`, failures);
929
1283
  }
1284
+ export async function streamWithProvider(request, onToken, onStatusOrOptions) {
1285
+ const options = typeof onStatusOrOptions === "function"
1286
+ ? { onStatus: onStatusOrOptions }
1287
+ : onStatusOrOptions ?? {};
1288
+ const ledger = resolveOperationLedger(options);
1289
+ try {
1290
+ const result = await streamWithProviderOperation(request, onToken, options, ledger);
1291
+ ledger.settle("completed");
1292
+ return { ...result, operationUsage: ledger.snapshot() };
1293
+ }
1294
+ catch (error) {
1295
+ ledger.settle(operationTerminalOutcome(error, request.signal));
1296
+ throw attachOperationUsageToError(error, ledger.snapshot());
1297
+ }
1298
+ finally {
1299
+ options.onOperationUsage?.(ledger.snapshot());
1300
+ }
1301
+ }
930
1302
  export async function pingProvider(providerId, secretOverride) {
931
1303
  const provider = getProvider(providerId);
932
1304
  const resolved = await providerAuth(providerId);