@pentoshi/clai 4.0.3 → 4.2.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 (314) hide show
  1. package/README.md +45 -9
  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/confirm-port.js +6 -3
  6. package/dist/agent/confirm-port.js.map +1 -1
  7. package/dist/agent/context-breakdown.js +2 -0
  8. package/dist/agent/context-breakdown.js.map +1 -1
  9. package/dist/agent/context-manager.d.ts +23 -9
  10. package/dist/agent/context-manager.js +72 -43
  11. package/dist/agent/context-manager.js.map +1 -1
  12. package/dist/agent/events.d.ts +3 -0
  13. package/dist/agent/loop-guard.d.ts +2 -0
  14. package/dist/agent/loop-guard.js +48 -3
  15. package/dist/agent/loop-guard.js.map +1 -1
  16. package/dist/agent/prompt-composer.js +6 -6
  17. package/dist/agent/prompt-composer.js.map +1 -1
  18. package/dist/agent/reliability-policy.d.ts +1 -0
  19. package/dist/agent/reliability-policy.js +6 -3
  20. package/dist/agent/reliability-policy.js.map +1 -1
  21. package/dist/agent/request-accounting.d.ts +97 -0
  22. package/dist/agent/request-accounting.js +207 -0
  23. package/dist/agent/request-accounting.js.map +1 -0
  24. package/dist/agent/request-budget.d.ts +2 -2
  25. package/dist/agent/request-budget.js +2 -4
  26. package/dist/agent/request-budget.js.map +1 -1
  27. package/dist/agent/runner.d.ts +8 -2
  28. package/dist/agent/runner.js +412 -181
  29. package/dist/agent/runner.js.map +1 -1
  30. package/dist/agent/session-title.d.ts +0 -26
  31. package/dist/agent/session-title.js +0 -80
  32. package/dist/agent/session-title.js.map +1 -1
  33. package/dist/agent/tool-history.d.ts +2 -2
  34. package/dist/agent/tool-history.js +19 -6
  35. package/dist/agent/tool-history.js.map +1 -1
  36. package/dist/agent/turn-outcome.d.ts +1 -1
  37. package/dist/agent/turn-outcome.js +2 -12
  38. package/dist/agent/turn-outcome.js.map +1 -1
  39. package/dist/app/adapters/agent-event-adapter.js +19 -0
  40. package/dist/app/adapters/agent-event-adapter.js.map +1 -1
  41. package/dist/app/adapters/current-agent-adapter.js +4 -0
  42. package/dist/app/adapters/current-agent-adapter.js.map +1 -1
  43. package/dist/app/commands/catalog.js +23 -16
  44. package/dist/app/commands/catalog.js.map +1 -1
  45. package/dist/app/controllers/cancel-coordinator.d.ts +51 -0
  46. package/dist/app/controllers/cancel-coordinator.js +48 -0
  47. package/dist/app/controllers/cancel-coordinator.js.map +1 -0
  48. package/dist/app/controllers/session-compact-helper.d.ts +19 -2
  49. package/dist/app/controllers/session-compact-helper.js +100 -127
  50. package/dist/app/controllers/session-compact-helper.js.map +1 -1
  51. package/dist/app/controllers/session-context-usage.d.ts +32 -15
  52. package/dist/app/controllers/session-context-usage.js +186 -51
  53. package/dist/app/controllers/session-context-usage.js.map +1 -1
  54. package/dist/app/controllers/session-controller.d.ts +22 -11
  55. package/dist/app/controllers/session-controller.js +137 -92
  56. package/dist/app/controllers/session-controller.js.map +1 -1
  57. package/dist/app/controllers/session-naming.d.ts +31 -0
  58. package/dist/app/controllers/session-naming.js +173 -0
  59. package/dist/app/controllers/session-naming.js.map +1 -0
  60. package/dist/app/controllers/session-persistence.d.ts +2 -2
  61. package/dist/app/controllers/session-persistence.js +3 -6
  62. package/dist/app/controllers/session-persistence.js.map +1 -1
  63. package/dist/app/controllers/session-turn-request.d.ts +2 -1
  64. package/dist/app/controllers/session-turn-request.js +3 -0
  65. package/dist/app/controllers/session-turn-request.js.map +1 -1
  66. package/dist/app/controllers/turn-continuation.d.ts +0 -6
  67. package/dist/app/controllers/turn-continuation.js +4 -21
  68. package/dist/app/controllers/turn-continuation.js.map +1 -1
  69. package/dist/app/controllers/turn-controller.d.ts +2 -1
  70. package/dist/app/controllers/turn-controller.js +1 -0
  71. package/dist/app/controllers/turn-controller.js.map +1 -1
  72. package/dist/app/events/app-event.d.ts +9 -0
  73. package/dist/app/events/app-event.js.map +1 -1
  74. package/dist/app/ports/agent-port.d.ts +8 -1
  75. package/dist/app/ports/transcript-item.d.ts +9 -1
  76. package/dist/classic/app/ClassicApp.js +3 -3
  77. package/dist/classic/app/ClassicApp.js.map +1 -1
  78. package/dist/classic/app/app-wiring.js +1 -2
  79. package/dist/classic/app/app-wiring.js.map +1 -1
  80. package/dist/classic/app/wiring-interactions.js +4 -9
  81. package/dist/classic/app/wiring-interactions.js.map +1 -1
  82. package/dist/classic/blocks/TurnSummaryBlock.d.ts +3 -0
  83. package/dist/classic/blocks/TurnSummaryBlock.js +6 -0
  84. package/dist/classic/blocks/TurnSummaryBlock.js.map +1 -0
  85. package/dist/classic/blocks/tool-lines.js +2 -1
  86. package/dist/classic/blocks/tool-lines.js.map +1 -1
  87. package/dist/classic/blocks/turn-summary-lines.d.ts +3 -0
  88. package/dist/classic/blocks/turn-summary-lines.js +7 -0
  89. package/dist/classic/blocks/turn-summary-lines.js.map +1 -0
  90. package/dist/classic/chrome/status-rows.d.ts +0 -1
  91. package/dist/classic/chrome/status-rows.js +0 -12
  92. package/dist/classic/chrome/status-rows.js.map +1 -1
  93. package/dist/classic/feed/Feed.js +2 -0
  94. package/dist/classic/feed/Feed.js.map +1 -1
  95. package/dist/classic/feed/feed-blocks.d.ts +1 -1
  96. package/dist/classic/feed/feed-blocks.js +3 -0
  97. package/dist/classic/feed/feed-blocks.js.map +1 -1
  98. package/dist/classic/input/cancel-ladder.d.ts +2 -19
  99. package/dist/classic/input/cancel-ladder.js +13 -20
  100. package/dist/classic/input/cancel-ladder.js.map +1 -1
  101. package/dist/commands/providers.js +2 -0
  102. package/dist/commands/providers.js.map +1 -1
  103. package/dist/llm/adapters/anthropic-tools.d.ts +31 -14
  104. package/dist/llm/adapters/anthropic-tools.js +124 -29
  105. package/dist/llm/adapters/anthropic-tools.js.map +1 -1
  106. package/dist/llm/adapters/gemini-tools.d.ts +16 -4
  107. package/dist/llm/adapters/gemini-tools.js +117 -16
  108. package/dist/llm/adapters/gemini-tools.js.map +1 -1
  109. package/dist/llm/adapters/openai-tools.d.ts +13 -2
  110. package/dist/llm/adapters/openai-tools.js +58 -11
  111. package/dist/llm/adapters/openai-tools.js.map +1 -1
  112. package/dist/llm/agentrouter.js +68 -45
  113. package/dist/llm/agentrouter.js.map +1 -1
  114. package/dist/llm/anthropic.js +69 -36
  115. package/dist/llm/anthropic.js.map +1 -1
  116. package/dist/llm/aws-mantle.js +70 -32
  117. package/dist/llm/aws-mantle.js.map +1 -1
  118. package/dist/llm/bynara.js +26 -69
  119. package/dist/llm/bynara.js.map +1 -1
  120. package/dist/llm/capabilities.d.ts +2 -1
  121. package/dist/llm/capabilities.js +56 -1
  122. package/dist/llm/capabilities.js.map +1 -1
  123. package/dist/llm/context-snapshot.d.ts +100 -0
  124. package/dist/llm/context-snapshot.js +277 -0
  125. package/dist/llm/context-snapshot.js.map +1 -0
  126. package/dist/llm/context-windows.d.ts +10 -0
  127. package/dist/llm/context-windows.js +129 -0
  128. package/dist/llm/context-windows.js.map +1 -0
  129. package/dist/llm/custom-provider-profile.d.ts +75 -0
  130. package/dist/llm/custom-provider-profile.js +558 -0
  131. package/dist/llm/custom-provider-profile.js.map +1 -0
  132. package/dist/llm/custom-providers.d.ts +13 -1
  133. package/dist/llm/custom-providers.js +77 -25
  134. package/dist/llm/custom-providers.js.map +1 -1
  135. package/dist/llm/effort-fallback.d.ts +44 -0
  136. package/dist/llm/effort-fallback.js +101 -0
  137. package/dist/llm/effort-fallback.js.map +1 -0
  138. package/dist/llm/fireworks.d.ts +3 -0
  139. package/dist/llm/fireworks.js +103 -0
  140. package/dist/llm/fireworks.js.map +1 -0
  141. package/dist/llm/free.js +4 -0
  142. package/dist/llm/free.js.map +1 -1
  143. package/dist/llm/gemini.d.ts +1 -1
  144. package/dist/llm/gemini.js +89 -51
  145. package/dist/llm/gemini.js.map +1 -1
  146. package/dist/llm/groq.js +4 -0
  147. package/dist/llm/groq.js.map +1 -1
  148. package/dist/llm/hetzner.d.ts +3 -0
  149. package/dist/llm/hetzner.js +98 -0
  150. package/dist/llm/hetzner.js.map +1 -0
  151. package/dist/llm/http.d.ts +60 -10
  152. package/dist/llm/http.js +465 -156
  153. package/dist/llm/http.js.map +1 -1
  154. package/dist/llm/kimchi.js +4 -0
  155. package/dist/llm/kimchi.js.map +1 -1
  156. package/dist/llm/lightning.js +4 -0
  157. package/dist/llm/lightning.js.map +1 -1
  158. package/dist/llm/meta.d.ts +2 -0
  159. package/dist/llm/meta.js +839 -624
  160. package/dist/llm/meta.js.map +1 -1
  161. package/dist/llm/modal.js +4 -0
  162. package/dist/llm/modal.js.map +1 -1
  163. package/dist/llm/model-families.d.ts +9 -0
  164. package/dist/llm/model-families.js +39 -0
  165. package/dist/llm/model-families.js.map +1 -0
  166. package/dist/llm/nvidia.js +4 -0
  167. package/dist/llm/nvidia.js.map +1 -1
  168. package/dist/llm/ollama.js +44 -40
  169. package/dist/llm/ollama.js.map +1 -1
  170. package/dist/llm/openai.js +4 -0
  171. package/dist/llm/openai.js.map +1 -1
  172. package/dist/llm/openrouter.js +4 -0
  173. package/dist/llm/openrouter.js.map +1 -1
  174. package/dist/llm/operation-ledger.d.ts +60 -0
  175. package/dist/llm/operation-ledger.js +136 -0
  176. package/dist/llm/operation-ledger.js.map +1 -0
  177. package/dist/llm/operation-usage.d.ts +32 -0
  178. package/dist/llm/operation-usage.js +172 -0
  179. package/dist/llm/operation-usage.js.map +1 -0
  180. package/dist/llm/orcarouter.d.ts +7 -0
  181. package/dist/llm/orcarouter.js +166 -0
  182. package/dist/llm/orcarouter.js.map +1 -0
  183. package/dist/llm/provider-profile-layers.d.ts +7 -0
  184. package/dist/llm/provider-profile-layers.js +961 -0
  185. package/dist/llm/provider-profile-layers.js.map +1 -0
  186. package/dist/llm/provider-profile.d.ts +181 -0
  187. package/dist/llm/provider-profile.js +329 -0
  188. package/dist/llm/provider-profile.js.map +1 -0
  189. package/dist/llm/provider-profiles.d.ts +13 -0
  190. package/dist/llm/provider-profiles.js +86 -0
  191. package/dist/llm/provider-profiles.js.map +1 -0
  192. package/dist/llm/provider.d.ts +2 -0
  193. package/dist/llm/provider.js +198 -21
  194. package/dist/llm/provider.js.map +1 -1
  195. package/dist/llm/qwen-cloud.js +4 -0
  196. package/dist/llm/qwen-cloud.js.map +1 -1
  197. package/dist/llm/reasoning-artifacts.d.ts +101 -0
  198. package/dist/llm/reasoning-artifacts.js +440 -0
  199. package/dist/llm/reasoning-artifacts.js.map +1 -0
  200. package/dist/llm/request-fingerprint.d.ts +10 -0
  201. package/dist/llm/request-fingerprint.js +313 -0
  202. package/dist/llm/request-fingerprint.js.map +1 -0
  203. package/dist/llm/request-plan.d.ts +119 -0
  204. package/dist/llm/request-plan.js +279 -0
  205. package/dist/llm/request-plan.js.map +1 -0
  206. package/dist/llm/router.d.ts +19 -1
  207. package/dist/llm/router.js +504 -102
  208. package/dist/llm/router.js.map +1 -1
  209. package/dist/llm/stream-events.d.ts +47 -0
  210. package/dist/llm/stream-events.js +55 -0
  211. package/dist/llm/stream-events.js.map +1 -0
  212. package/dist/llm/stream-terminal.d.ts +30 -0
  213. package/dist/llm/stream-terminal.js +51 -0
  214. package/dist/llm/stream-terminal.js.map +1 -0
  215. package/dist/llm/token-estimate-calibration.d.ts +23 -0
  216. package/dist/llm/token-estimate-calibration.js +140 -0
  217. package/dist/llm/token-estimate-calibration.js.map +1 -0
  218. package/dist/llm/token-usage.d.ts +35 -5
  219. package/dist/llm/token-usage.js +212 -162
  220. package/dist/llm/token-usage.js.map +1 -1
  221. package/dist/llm/tokenrouter.js +13 -6
  222. package/dist/llm/tokenrouter.js.map +1 -1
  223. package/dist/modes/ask.js +11 -5
  224. package/dist/modes/ask.js.map +1 -1
  225. package/dist/noninteractive/start-noninteractive.js +31 -2
  226. package/dist/noninteractive/start-noninteractive.js.map +1 -1
  227. package/dist/noninteractive/stream-blocks.js +15 -4
  228. package/dist/noninteractive/stream-blocks.js.map +1 -1
  229. package/dist/prompts/embedded.js +1 -1
  230. package/dist/prompts/embedded.js.map +1 -1
  231. package/dist/prompts/system.agent.md +6 -0
  232. package/dist/store/config.js +7 -1
  233. package/dist/store/config.js.map +1 -1
  234. package/dist/store/history.d.ts +2 -0
  235. package/dist/store/history.js +19 -10
  236. package/dist/store/history.js.map +1 -1
  237. package/dist/store/plan.js +20 -14
  238. package/dist/store/plan.js.map +1 -1
  239. package/dist/tools/elevated-shell.d.ts +1 -1
  240. package/dist/tools/elevated-shell.js +24 -72
  241. package/dist/tools/elevated-shell.js.map +1 -1
  242. package/dist/tools/fs.d.ts +5 -3
  243. package/dist/tools/fs.js +8 -5
  244. package/dist/tools/fs.js.map +1 -1
  245. package/dist/tools/nmap-runner.js +30 -36
  246. package/dist/tools/nmap-runner.js.map +1 -1
  247. package/dist/tools/sudo-session.d.ts +83 -0
  248. package/dist/tools/sudo-session.js +124 -0
  249. package/dist/tools/sudo-session.js.map +1 -0
  250. package/dist/tui-v2/app/App.js +9 -24
  251. package/dist/tui-v2/app/App.js.map +1 -1
  252. package/dist/tui-v2/components/plan/plan-view.js +5 -3
  253. package/dist/tui-v2/components/plan/plan-view.js.map +1 -1
  254. package/dist/tui-v2/components/status/context-limit-chip.js +2 -2
  255. package/dist/tui-v2/components/status/context-limit-chip.js.map +1 -1
  256. package/dist/tui-v2/components/status/status-line.js +1 -14
  257. package/dist/tui-v2/components/status/status-line.js.map +1 -1
  258. package/dist/tui-v2/components/transcript/intro-card.js +2 -1
  259. package/dist/tui-v2/components/transcript/intro-card.js.map +1 -1
  260. package/dist/tui-v2/components/transcript/notice-row.d.ts +3 -1
  261. package/dist/tui-v2/components/transcript/notice-row.js +28 -3
  262. package/dist/tui-v2/components/transcript/notice-row.js.map +1 -1
  263. package/dist/tui-v2/components/transcript/thinking-block.d.ts +0 -5
  264. package/dist/tui-v2/components/transcript/thinking-block.js +3 -8
  265. package/dist/tui-v2/components/transcript/thinking-block.js.map +1 -1
  266. package/dist/tui-v2/components/transcript/tool-card.js +16 -7
  267. package/dist/tui-v2/components/transcript/tool-card.js.map +1 -1
  268. package/dist/tui-v2/components/transcript/transcript-row.d.ts +0 -2
  269. package/dist/tui-v2/components/transcript/transcript-row.js +7 -3
  270. package/dist/tui-v2/components/transcript/transcript-row.js.map +1 -1
  271. package/dist/tui-v2/components/transcript/transcript-view.js +1 -3
  272. package/dist/tui-v2/components/transcript/transcript-view.js.map +1 -1
  273. package/dist/tui-v2/composer/composer-editor.js +2 -1
  274. package/dist/tui-v2/composer/composer-editor.js.map +1 -1
  275. package/dist/types.d.ts +156 -1
  276. package/dist/types.js +3 -0
  277. package/dist/types.js.map +1 -1
  278. package/dist/ui-core/bootstrap/composition-root.d.ts +2 -0
  279. package/dist/ui-core/bootstrap/composition-root.js +26 -3
  280. package/dist/ui-core/bootstrap/composition-root.js.map +1 -1
  281. package/dist/ui-core/commands/picker-commands.js +11 -5
  282. package/dist/ui-core/commands/picker-commands.js.map +1 -1
  283. package/dist/ui-core/commands/session-commands.js +41 -5
  284. package/dist/ui-core/commands/session-commands.js.map +1 -1
  285. package/dist/ui-core/plan/plan-lifecycle.js +3 -2
  286. package/dist/ui-core/plan/plan-lifecycle.js.map +1 -1
  287. package/dist/ui-core/rendering/context-limit.js +4 -1
  288. package/dist/ui-core/rendering/context-limit.js.map +1 -1
  289. package/dist/ui-core/rendering/duration.d.ts +5 -0
  290. package/dist/ui-core/rendering/duration.js +43 -0
  291. package/dist/ui-core/rendering/duration.js.map +1 -0
  292. package/dist/ui-core/rendering/intro-header.js +2 -2
  293. package/dist/ui-core/rendering/intro-header.js.map +1 -1
  294. package/dist/ui-core/rendering/theme.d.ts +1 -0
  295. package/dist/ui-core/rendering/theme.js +2 -0
  296. package/dist/ui-core/rendering/theme.js.map +1 -1
  297. package/dist/ui-core/rendering/transcript-semantic.js +2 -0
  298. package/dist/ui-core/rendering/transcript-semantic.js.map +1 -1
  299. package/dist/ui-core/state/transcript-compaction.js +2 -0
  300. package/dist/ui-core/state/transcript-compaction.js.map +1 -1
  301. package/dist/ui-core/state/transcript-hydrate.js +34 -4
  302. package/dist/ui-core/state/transcript-hydrate.js.map +1 -1
  303. package/dist/ui-core/state/transcript-reducer.d.ts +5 -0
  304. package/dist/ui-core/state/transcript-reducer.js +96 -8
  305. package/dist/ui-core/state/transcript-reducer.js.map +1 -1
  306. package/dist/ui-core/state/transcript-store.d.ts +10 -1
  307. package/dist/ui-core/state/transcript-store.js +22 -1
  308. package/dist/ui-core/state/transcript-store.js.map +1 -1
  309. package/dist/ui-core/state/transcript-types.d.ts +9 -1
  310. package/dist/ui-core/state/transcript-types.js +3 -1
  311. package/dist/ui-core/state/transcript-types.js.map +1 -1
  312. package/dist/version.generated.d.ts +2 -2
  313. package/dist/version.generated.js +2 -2
  314. 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, isImageInputUnsupportedError, isReasoningUnsupportedError, stripImagesFromMessages, 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";
@@ -22,6 +24,11 @@ import { modalProvider } from "./modal.js";
22
24
  import { lightningProvider } from "./lightning.js";
23
25
  import { tokenrouterProvider } from "./tokenrouter.js";
24
26
  import { metaProvider } from "./meta.js";
27
+ import { fireworksProvider } from "./fireworks.js";
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";
25
32
  import { maskSecretTail } from "./provider.js";
26
33
  import { getCustomProviderSync } from "./custom-providers.js";
27
34
  import { isToolsUnsupportedError, markTextOnlyModel, } from "./tool-protocol.js";
@@ -30,6 +37,36 @@ const MAX_RETRIES = 6;
30
37
  const MAX_RETRY_WAIT_MS = 120_000;
31
38
  /** Toast replace-key so key rotation never stacks notifications. */
32
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
+ }
33
70
  async function sleep(ms, signal) {
34
71
  if (signal?.aborted)
35
72
  throw signal.reason ?? new Error("Aborted");
@@ -54,6 +91,47 @@ async function sleep(ms, signal) {
54
91
  function isRateLimited(error) {
55
92
  return error instanceof ProviderError && error.status === 429;
56
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
+ }
57
135
  function isTransientNetworkError(error) {
58
136
  const message = error instanceof Error ? error.message : String(error);
59
137
  const msg = message.toLowerCase();
@@ -114,6 +192,24 @@ function failureReason(error) {
114
192
  }
115
193
  return "connection glitch";
116
194
  }
195
+ /**
196
+ * Append the raw response body a provider returned when it is not already part
197
+ * of `text`. Several paths (SSE error frames, non-OpenAI providers) keep the
198
+ * body on the error without embedding it in the message, and users need the
199
+ * full error received from the request to diagnose failures.
200
+ */
201
+ function appendFullProviderBody(text, error) {
202
+ if (!(error instanceof ProviderError))
203
+ return text;
204
+ const body = (error.body ?? "").trim();
205
+ if (!body || !bodyAddsInformation(body, text))
206
+ return text;
207
+ const shown = collapseWhitespace(body);
208
+ const capped = shown.length > MAX_ERROR_BODY_IN_MESSAGE_CHARS
209
+ ? `${shown.slice(0, MAX_ERROR_BODY_IN_MESSAGE_CHARS)}…`
210
+ : shown;
211
+ return `${text}\nFull response from provider: ${capped}`;
212
+ }
117
213
  /**
118
214
  * User-facing classification of provider failures (auth, capacity, disconnect,
119
215
  * empty admission, context limit). Used by the fallback table and tests.
@@ -125,54 +221,63 @@ export function formatProviderFailureForUser(error) {
125
221
  // it needlessly hard to tell OpenAI and Gemini failures apart.
126
222
  const exactError = error.message.replace(/\s+/g, " ").trim();
127
223
  const withExactError = (guidance) => exactError ? `${guidance}\nExact provider error: ${exactError}` : guidance;
224
+ const withFullBody = (text) => appendFullProviderBody(text, error);
128
225
  const status = error.status ?? 0;
129
226
  if (status === 429) {
130
- return withExactError("Model is rate limited (429). Try another provider/model or switch to a paid plan.");
227
+ return withFullBody(withExactError("Model is rate limited (429). Try another provider/model or switch to a paid plan."));
131
228
  }
132
229
  if (status === 401 || status === 403) {
133
- return withExactError(`Authentication/authorization failed (${status}). Check the API key with \`clai providers\` or set the provider env var.`);
230
+ return withFullBody(withExactError(`Authentication/authorization failed (${status}). Check the API key with \`clai providers\` or set the provider env var.`));
134
231
  }
135
232
  if (status === 402) {
136
- return withExactError("Insufficient credits / payment required (402). Try another API key for this provider, top up the account, or switch provider.");
233
+ return withFullBody(withExactError("Insufficient credits / payment required (402). Try another API key for this provider, top up the account, or switch provider."));
137
234
  }
138
235
  if (status === 404) {
139
- return withExactError("Model or endpoint not found (404). Run `/model list` or pick another model.");
236
+ return withFullBody(withExactError("Model or endpoint not found (404). Run `/model list` or pick another model."));
140
237
  }
141
238
  if (status === 413) {
142
- return withExactError("Request exceeded the provider input limit (413). Wait for auto-compact, run `/compact`, or continue with a smaller turn.");
239
+ return withFullBody(withExactError("Request exceeded the provider input limit (413). Wait for auto-compact, run `/compact`, or continue with a smaller turn."));
143
240
  }
144
241
  if (status === 422) {
145
- return withExactError("Provider rejected the request body (422). Model name or parameters may be incompatible — try another model.");
242
+ return withFullBody(withExactError("Provider rejected the request body (422). Model name or parameters may be incompatible — try another model."));
146
243
  }
147
244
  if (status === 503 || status === 502 || status === 504) {
148
- return withExactError(`Upstream provider unavailable (${status}). Retry shortly or switch provider/model; free-tier models are often capacity-constrained.`);
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
+ }
248
+ return withFullBody(withExactError(`Upstream provider unavailable (${status}). Retry shortly or switch provider/model; free-tier models are often capacity-constrained.`));
149
249
  }
150
250
  if (status >= 500 && status < 600) {
151
- return withExactError(`Upstream provider error (${status}). Retry or switch with \`/provider\` / \`/model\`.`);
251
+ return withFullBody(withExactError(`Upstream provider error (${status}). Retry or switch with \`/provider\` / \`/model\`.`));
152
252
  }
153
253
  }
154
254
  const message = (error instanceof Error ? error.message : String(error))
155
255
  .replace(/\s+/g, " ")
156
256
  .trim();
157
- if (!message) {
158
- return "Provider returned an empty failure (no tokens billed). Model may be unavailable, overloaded, or rejected before admission — try another model.";
159
- }
160
- if (/socket connection was closed|econnreset|premature close|unexpected end of file/i.test(message)) {
161
- return `${message} connection dropped mid-request (common on free/unstable routes). Retry or switch model; long contexts increase disconnect risk.`;
162
- }
163
- if (new RegExp(STREAM_STALL_MARKER, "i").test(message)) {
164
- return `${message}`;
165
- }
166
- if (/stream stalled|request timed out before any response/i.test(message)) {
167
- return `${message} no tokens arrived in time. Free models and large contexts fail more often; retry or use a more reliable model.`;
168
- }
169
- if (/no completion text|response was empty|empty response|returned no text/i.test(message)) {
170
- return `${message} provider accepted the request but returned no content. Retry once; if it persists, switch model.`;
171
- }
172
- if (/fetch failed|network error|etimedout|enotfound|econnrefused/i.test(message)) {
173
- return `${message} network/DNS failure reaching the provider. Check connectivity and provider base URL.`;
174
- }
175
- return message;
257
+ const generic = (() => {
258
+ if (!message) {
259
+ return "Provider returned an empty failure (no tokens billed). Model may be unavailable, overloaded, or rejected before admission — try another model.";
260
+ }
261
+ if (/socket connection was closed|econnreset|premature close|unexpected end of file/i.test(message)) {
262
+ return `${message} — connection dropped mid-request (common on free/unstable routes). Retry or switch model; long contexts increase disconnect risk.`;
263
+ }
264
+ if (new RegExp(STREAM_STALL_MARKER, "i").test(message)) {
265
+ return `${message}`;
266
+ }
267
+ if (/stream stalled|request timed out before any response/i.test(message)) {
268
+ return `${message} — no tokens arrived in time. Free models and large contexts fail more often; retry or use a more reliable model.`;
269
+ }
270
+ if (/no completion text|response was empty|empty response|returned no text/i.test(message)) {
271
+ return `${message} — provider accepted the request but returned no content. Retry once; if it persists, switch model.`;
272
+ }
273
+ if (/fetch failed|network error|etimedout|enotfound|econnrefused/i.test(message)) {
274
+ return `${message} — network/DNS failure reaching the provider. Check connectivity and provider base URL.`;
275
+ }
276
+ return message;
277
+ })();
278
+ // Statuses without a dedicated branch above (e.g. 400, or stream errors
279
+ // with no HTTP status) still deserve the full response body.
280
+ return appendFullProviderBody(generic, error);
176
281
  }
177
282
  function summarizeProviderError(error) {
178
283
  return formatProviderFailureForUser(error);
@@ -262,6 +367,9 @@ export const providers = {
262
367
  lightning: lightningProvider,
263
368
  tokenrouter: tokenrouterProvider,
264
369
  meta: metaProvider,
370
+ fireworks: fireworksProvider,
371
+ hetzner: hetznerProvider,
372
+ orcarouter: orcarouterProvider,
265
373
  };
266
374
  const fallbackOrder = [
267
375
  "free",
@@ -281,6 +389,9 @@ const fallbackOrder = [
281
389
  "lightning",
282
390
  "tokenrouter",
283
391
  "meta",
392
+ "fireworks",
393
+ "hetzner",
394
+ "orcarouter",
284
395
  ];
285
396
  /**
286
397
  * Built-in provider ids in fallback preference order. Custom (user-defined)
@@ -357,6 +468,9 @@ function authForSlot(providerId, value) {
357
468
  function withoutReasoning(request) {
358
469
  return { ...request, thinking: undefined };
359
470
  }
471
+ function reasoningWireKey(thinking, style, model, providerId) {
472
+ return JSON.stringify(buildReasoningPayload(thinking, style, model, providerId));
473
+ }
360
474
  function makeKeyEmitter(onStatus, onKeyEvent) {
361
475
  return (event) => {
362
476
  onKeyEvent?.(event);
@@ -370,39 +484,102 @@ function makeKeyEmitter(onStatus, onKeyEvent) {
370
484
  }
371
485
  };
372
486
  }
373
- 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) {
374
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
+ };
375
515
  try {
376
- const result = await provider.complete(activeRequest, auth);
516
+ const result = await runAttempt(activeRequest, reason);
377
517
  if (hasImageInput(activeRequest)) {
378
518
  learnModelVisionCapability(providerId, model, true);
379
519
  }
380
520
  return result;
381
521
  }
382
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.
383
525
  if (activeRequest.tools?.length && isToolsUnsupportedError(error)) {
384
526
  markTextOnlyModel(providerId, model);
527
+ if (singleDispatch)
528
+ throw error;
385
529
  const textRequest = {
386
530
  ...activeRequest,
387
531
  tools: undefined,
388
532
  toolChoice: undefined,
389
533
  parallelToolCalls: undefined,
390
534
  };
391
- return await provider.complete(textRequest, auth);
535
+ return await runAttempt(textRequest, "adaptation");
392
536
  }
393
- // Model rejected a reasoning/thinking knob mark it and retry once
394
- // without reasoning so an unsupported option never fails the request.
395
- 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
+ }
396
568
  markReasoningUnsupported(providerId, model);
397
- return await provider.complete(withoutReasoning(activeRequest), auth);
569
+ onStatus?.(`ℹ ${providerId}/${model} rejected reasoning options — retrying without them`);
570
+ return await runAttempt(withoutReasoning(activeRequest), "adaptation");
398
571
  }
399
572
  if (hasImageInput(activeRequest) && isImageInputUnsupportedError(error)) {
400
573
  learnModelVisionCapability(providerId, model, false);
401
- return await provider.complete(withoutImages(activeRequest), auth);
574
+ if (singleDispatch)
575
+ throw error;
576
+ return await runAttempt(withoutImages(activeRequest), "adaptation");
402
577
  }
403
- const restored = revertVisionSubstitution(providerId, model, activeRequest, error);
404
- if (restored) {
405
- 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
+ }
406
583
  }
407
584
  throw error;
408
585
  }
@@ -472,27 +649,57 @@ function revertVisionSubstitution(providerId, model, request, error) {
472
649
  request: requestForRoute(restoredRequest, providerId, original),
473
650
  };
474
651
  }
475
- 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) {
476
653
  let emittedBytes = 0;
477
654
  let emittedToolArgumentBytes = 0;
478
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
+ };
479
666
  const activeRequest = {
480
667
  ...request,
481
668
  provider: providerId,
482
669
  model,
483
- ...(onToolCallDelta
670
+ ...(onToolCallDelta || downstreamEvents
484
671
  ? {
485
672
  onToolCallDelta: (delta) => {
486
673
  const argumentBytes = delta.argumentsBytes ?? 0;
487
674
  emittedBytes += Math.max(delta.name?.length ?? 0, argumentBytes - emittedToolArgumentBytes, 1);
488
675
  emittedToolArgumentBytes = Math.max(emittedToolArgumentBytes, argumentBytes);
489
- 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);
490
693
  },
491
694
  }
492
695
  : {}),
696
+ ...(downstreamEvents ? { onStreamEvent: emitEvent } : {}),
493
697
  };
494
698
  const emit = (token) => {
699
+ if (!token)
700
+ return;
495
701
  emittedBytes += token.length;
702
+ emitEvent({ type: "answer_delta", text: token });
496
703
  onToken(token);
497
704
  };
498
705
  const learnVisionOnSuccess = () => {
@@ -500,14 +707,62 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
500
707
  learnModelVisionCapability(providerId, model, true);
501
708
  }
502
709
  };
503
- try {
504
- if (provider.stream) {
505
- const streamed = await provider.stream(activeRequest, auth, emit);
506
- learnVisionOnSuccess();
507
- 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));
508
760
  }
509
- const result = await provider.complete(activeRequest, auth);
510
- emit(result.text);
761
+ catch { }
762
+ return result;
763
+ };
764
+ try {
765
+ const result = await runAttempt(activeRequest, reason);
511
766
  learnVisionOnSuccess();
512
767
  return result;
513
768
  }
@@ -516,6 +771,8 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
516
771
  activeRequest.tools?.length &&
517
772
  isToolsUnsupportedError(error)) {
518
773
  markTextOnlyModel(providerId, model);
774
+ if (singleDispatch)
775
+ throw markStreamEmittedBytes(error, emittedBytes);
519
776
  onStatus?.(`ℹ ${providerId}/${model} does not support native tools — falling back to text protocol`);
520
777
  const textRequest = {
521
778
  ...activeRequest,
@@ -524,69 +781,84 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
524
781
  parallelToolCalls: undefined,
525
782
  };
526
783
  try {
527
- if (provider.stream) {
528
- return await provider.stream(textRequest, auth, emit);
529
- }
530
- const result = await provider.complete(textRequest, auth);
531
- emit(result.text);
532
- return result;
784
+ return await runAttempt(textRequest, "adaptation");
533
785
  }
534
786
  catch (retryError) {
535
- throw markStreamEmittedBytes(retryError, emittedBytes);
787
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
536
788
  }
537
789
  }
538
790
  // Model rejected a reasoning/thinking knob (e.g. chat_template_kwargs on a
539
- // NIM chat template that does not accept it). Mark it so buildChatBody stops
540
- // sending reasoning, then retry once without it. A parameter rejection is a
541
- // request-time 4xx, so no tokens have streamed yet the retry is clean.
542
- 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
+ }
543
828
  markReasoningUnsupported(providerId, model);
544
829
  onStatus?.(`ℹ ${providerId}/${model} rejected reasoning options — retrying without them`);
545
830
  const retryRequest = withoutReasoning(activeRequest);
546
831
  try {
547
- if (provider.stream) {
548
- return await provider.stream(retryRequest, auth, emit);
549
- }
550
- const result = await provider.complete(retryRequest, auth);
551
- emit(result.text);
552
- return result;
832
+ return await runAttempt(retryRequest, "adaptation");
553
833
  }
554
834
  catch (retryError) {
555
- throw markStreamEmittedBytes(retryError, emittedBytes);
835
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
556
836
  }
557
837
  }
558
838
  if (emittedBytes === 0 &&
559
839
  hasImageInput(activeRequest) &&
560
840
  isImageInputUnsupportedError(error)) {
561
841
  learnModelVisionCapability(providerId, model, false);
842
+ if (singleDispatch)
843
+ throw markStreamEmittedBytes(error, emittedBytes);
562
844
  onStatus?.(`ℹ ${providerId}/${model} rejected image input — retrying without the attached image(s)`);
563
845
  const textOnlyRequest = withoutImages(activeRequest);
564
846
  try {
565
- if (provider.stream) {
566
- return await provider.stream(textOnlyRequest, auth, emit);
567
- }
568
- const result = await provider.complete(textOnlyRequest, auth);
569
- emit(result.text);
570
- return result;
847
+ return await runAttempt(textOnlyRequest, "adaptation");
571
848
  }
572
849
  catch (retryError) {
573
- throw markStreamEmittedBytes(retryError, emittedBytes);
850
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
574
851
  }
575
852
  }
576
- if (emittedBytes === 0) {
853
+ if (emittedBytes === 0 && !singleDispatch) {
577
854
  const restored = revertVisionSubstitution(providerId, model, activeRequest, error);
578
855
  if (restored) {
579
856
  onStatus?.(`ℹ ${providerId}/${model} is not available on this account — falling back to ${restored.original}`);
580
857
  try {
581
- if (provider.stream) {
582
- return await provider.stream(restored.request, auth, emit);
583
- }
584
- const result = await provider.complete(restored.request, auth);
585
- emit(result.text);
586
- return result;
858
+ return await runAttempt(restored.request, "adaptation");
587
859
  }
588
860
  catch (retryError) {
589
- throw markStreamEmittedBytes(retryError, emittedBytes);
861
+ throw markStreamEmittedBytes(preservedFailure(retryError, error), emittedBytes);
590
862
  }
591
863
  }
592
864
  }
@@ -600,12 +872,16 @@ async function tryStreamOnce(provider, providerId, request, model, auth, onToken
600
872
  */
601
873
  async function runWithKeyRotation(opts) {
602
874
  const { providerId, provider, request, model, emitKey } = opts;
875
+ const singleDispatch = opts.singleDispatch === true;
603
876
  const multi = await getProviderKeys(providerId);
604
877
  const slots = multi.keys;
605
878
  if (slots.length === 0) {
606
879
  throw new Error("no API key configured");
607
880
  }
608
- 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;
609
885
  if (plan.length === 0) {
610
886
  throw new Error(`all ${slots.length} API key${slots.length === 1 ? "" : "s"} for ${providerId} are disabled — re-enable one to use this provider`);
611
887
  }
@@ -613,12 +889,14 @@ async function runWithKeyRotation(opts) {
613
889
  const multiKey = enabledCount > 1;
614
890
  // Single key: time-increasing retries (MAX_RETRIES+1 attempts).
615
891
  // Multi key: 2 attempts per key (initial + one retry), then next key.
616
- const maxPerKey = attemptsPerKey(enabledCount, (opts.maxRetries ?? MAX_RETRIES) + 1);
892
+ const maxPerKey = singleDispatch
893
+ ? 1
894
+ : attemptsPerKey(enabledCount, (opts.maxRetries ?? MAX_RETRIES) + 1);
617
895
  let lastError;
618
896
  // Endpoint failover: a provider can carry several base URLs (e.g. Modal
619
897
  // workspaces). On an auth/quota error the active endpoint may simply be the
620
898
  // wrong workspace for the key, so rotate the endpoint too before giving up.
621
- const storedEndpoints = providerUsesEndpoints(providerId)
899
+ const storedEndpoints = providerUsesEndpoints(providerId) && !singleDispatch
622
900
  ? getProviderEndpoints(providerId)
623
901
  : undefined;
624
902
  const endpointUrls = (storedEndpoints?.urls ?? []).filter((url) => !(storedEndpoints?.disabledUrls ?? []).includes(url));
@@ -654,13 +932,14 @@ async function runWithKeyRotation(opts) {
654
932
  }
655
933
  for (let attempt = 0; attempt < maxPerKey; attempt++) {
656
934
  request.signal?.throwIfAborted();
935
+ const attemptReason = planIdx === 0 && attempt === 0 ? opts.initialAttemptReason : "retry";
657
936
  try {
658
937
  let result;
659
938
  if (opts.mode === "stream") {
660
- 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);
661
940
  }
662
941
  else {
663
- result = await tryCompleteOnce(provider, providerId, request, model, auth);
942
+ result = await tryCompleteOnce(provider, providerId, request, model, auth, attemptReason, opts.onStatus, singleDispatch);
664
943
  }
665
944
  // Sticky success only for stored multi-key (not env-only synthetic).
666
945
  if (multi.source !== "env" && multi.source !== "local") {
@@ -682,7 +961,13 @@ async function runWithKeyRotation(opts) {
682
961
  return result;
683
962
  }
684
963
  catch (error) {
964
+ const previousError = lastError;
685
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
+ }
686
971
  // 404/422: other keys for the same model will not help.
687
972
  if (isKeyCircleStopError(error)) {
688
973
  throw error;
@@ -716,7 +1001,7 @@ async function runWithKeyRotation(opts) {
716
1001
  }
717
1002
  const canRetrySame = attempt + 1 < maxPerKey;
718
1003
  if (canRetrySame) {
719
- const wait = isRateLimited(error)
1004
+ const wait = isRateLimited(error) || isServerUnavailable(error)
720
1005
  ? retryWaitMs(error, attempt)
721
1006
  : networkRetryWaitMs(attempt);
722
1007
  if (wait > MAX_RETRY_WAIT_MS) {
@@ -777,15 +1062,19 @@ async function runWithKeyRotation(opts) {
777
1062
  const err = lastError ?? new Error("all API keys failed");
778
1063
  throw err;
779
1064
  }
780
- export async function completeWithProvider(request, options) {
1065
+ async function completeWithProviderOperation(request, options, ledger) {
781
1066
  const config = getConfig();
1067
+ const singleDispatch = options.singleDispatch === true;
782
1068
  const requested = request.provider ?? config.defaultProvider;
783
1069
  const providerImpl = getProvider(requested);
784
1070
  const isDefaultModel = !request.model || request.model === providerImpl.defaultModel;
785
- const fallbackEnabled = config.providerFallback &&
1071
+ const fallbackEnabled = !singleDispatch &&
1072
+ config.providerFallback &&
786
1073
  (await requestedRealKeyCount(requested)) !== 1 &&
787
1074
  (isDefaultModel || request.allowModelFallback === true);
788
- 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);
789
1078
  const failures = [];
790
1079
  const emitKey = makeKeyEmitter(options?.onStatus, options?.onKeyEvent);
791
1080
  for (const providerId of order) {
@@ -800,7 +1089,10 @@ export async function completeWithProvider(request, options) {
800
1089
  const model = providerId === requested
801
1090
  ? (request.model ?? provider.defaultModel)
802
1091
  : provider.defaultModel;
803
- const routeRequest = requestForRoute(request, providerId, model);
1092
+ const routeRequest = {
1093
+ ...requestForRoute(request, providerId, model),
1094
+ attemptUsage: ledger,
1095
+ };
804
1096
  try {
805
1097
  const result = await runWithKeyRotation({
806
1098
  providerId,
@@ -809,17 +1101,26 @@ export async function completeWithProvider(request, options) {
809
1101
  model,
810
1102
  emitKey,
811
1103
  mode: "complete",
1104
+ initialAttemptReason: providerId === requested ? "initial" : "fallback",
812
1105
  ...(options?.onStatus ? { onStatus: options.onStatus } : {}),
813
1106
  ...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
1107
+ ...(singleDispatch ? { singleDispatch: true } : {}),
814
1108
  });
815
1109
  if (providerId !== requested) {
816
- setDefaultProvider(providerId);
817
- setProviderModel(providerId, result.model || model);
1110
+ if (options?.adoptFallback === true) {
1111
+ setDefaultProvider(providerId);
1112
+ setProviderModel(providerId, result.model || model);
1113
+ }
818
1114
  options?.onStatus?.(`switching to ${providerId}/${result.model || model} after ${requested} failed`);
819
1115
  }
820
1116
  return result;
821
1117
  }
822
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
+ }
823
1124
  failures.push({
824
1125
  provider: providerId,
825
1126
  message: failureMessageFor(providerId, error),
@@ -833,18 +1134,68 @@ export async function completeWithProvider(request, options) {
833
1134
  }
834
1135
  throw aggregateProviderError(`No provider could complete the request.${formatFailures(failures)}`, failures);
835
1136
  }
836
- 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) {
837
1178
  const options = typeof onStatusOrOptions === "function"
838
1179
  ? { onStatus: onStatusOrOptions }
839
1180
  : onStatusOrOptions ?? {};
1181
+ const operation = ledger ?? resolveOperationLedger(options);
1182
+ const relayToken = (token) => {
1183
+ operation.noteSemanticOutput();
1184
+ onToken(token);
1185
+ };
1186
+ const downstreamToolDelta = request.onToolCallDelta;
840
1187
  const config = getConfig();
1188
+ const singleDispatch = options.singleDispatch === true;
841
1189
  const requested = request.provider ?? config.defaultProvider;
842
1190
  const providerImpl = getProvider(requested);
843
1191
  const isDefaultModel = !request.model || request.model === providerImpl.defaultModel;
844
- const fallbackEnabled = config.providerFallback &&
1192
+ const fallbackEnabled = !singleDispatch &&
1193
+ config.providerFallback &&
845
1194
  (await requestedRealKeyCount(requested)) !== 1 &&
846
1195
  (isDefaultModel || request.allowModelFallback === true);
847
- 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);
848
1199
  const failures = [];
849
1200
  const emitStatus = options.onStatus ?? ((message) => onToken(message));
850
1201
  const emitKey = makeKeyEmitter(emitStatus, options.onKeyEvent);
@@ -859,7 +1210,28 @@ export async function streamWithProvider(request, onToken, onStatusOrOptions) {
859
1210
  const model = providerId === requested
860
1211
  ? (request.model ?? provider.defaultModel)
861
1212
  : provider.defaultModel;
862
- 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
+ };
863
1235
  try {
864
1236
  const result = await runWithKeyRotation({
865
1237
  providerId,
@@ -868,18 +1240,30 @@ export async function streamWithProvider(request, onToken, onStatusOrOptions) {
868
1240
  model,
869
1241
  emitKey,
870
1242
  mode: "stream",
871
- onToken,
1243
+ initialAttemptReason: providerId === requested ? "initial" : "fallback",
1244
+ onToken: relayToken,
872
1245
  onStatus: emitStatus,
873
1246
  ...(options.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
1247
+ ...(singleDispatch ? { singleDispatch: true } : {}),
1248
+ ...(options.onSuccessfulRequest
1249
+ ? { onSuccessfulRequest: options.onSuccessfulRequest }
1250
+ : {}),
874
1251
  });
875
1252
  if (providerId !== requested) {
876
- setDefaultProvider(providerId);
877
- setProviderModel(providerId, result.model || model);
1253
+ if (options.adoptFallback === true) {
1254
+ setDefaultProvider(providerId);
1255
+ setProviderModel(providerId, result.model || model);
1256
+ }
878
1257
  options.onStatus?.(`switching to ${providerId}/${result.model || model} after ${requested} failed`);
879
1258
  }
880
1259
  return result;
881
1260
  }
882
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
+ }
883
1267
  failures.push({
884
1268
  provider: providerId,
885
1269
  message: failureMessageFor(providerId, error),
@@ -897,6 +1281,24 @@ export async function streamWithProvider(request, onToken, onStatusOrOptions) {
897
1281
  }
898
1282
  throw aggregateProviderError(`No provider could stream the request.${formatFailures(failures)}`, failures);
899
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
+ }
900
1302
  export async function pingProvider(providerId, secretOverride) {
901
1303
  const provider = getProvider(providerId);
902
1304
  const resolved = await providerAuth(providerId);