cartethyia 2.5.4 → 2.5.7

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 (395) hide show
  1. package/.env.example +13 -4
  2. package/README.md +2 -2
  3. package/dist/dashboard/assets/{BannedPage-C2orBsI2.js → BannedPage-DE2607Tb.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-DE2607Tb.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-Duv6mfrl.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-C2PNYWzk.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-C2PNYWzk.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-TXP9OfAM.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-B-9xnAQN.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-B-9xnAQN.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-xeax9nJt.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-xeax9nJt.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-Df4jjaHJ.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-Df4jjaHJ.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-Dyojsm84.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-Dyojsm84.js.gz +0 -0
  19. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js +1 -0
  20. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BJfg4ow6.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-ChCX1gmT.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-DeWSOEUO.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-DeWSOEUO.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-CkpWln2S.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-B3VOgQmt.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-C_klIZom.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-C_klIZom.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-BYph-InK.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-BYph-InK.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-CvyQCYWJ.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-CvyQCYWJ.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-Cm1JYilI.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-Cm1JYilI.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-DlFnc3_S.js.gz +0 -0
  41. package/dist/dashboard/assets/api-keys-DQYLQxpW.js +1 -0
  42. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  43. package/dist/dashboard/assets/app-CX_Z8cMs.css +2 -0
  44. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  45. package/dist/dashboard/assets/{assert-Chi7pCnt.js → assert-C9FD_JwJ.js} +1 -1
  46. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  47. package/dist/dashboard/assets/{badge-D4pQapKj.js → badge-CR72dasT.js} +1 -1
  48. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  49. package/dist/dashboard/assets/{banner-DH6cg-_B.js → banner-CI9_eSfO.js} +1 -1
  50. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  51. package/dist/dashboard/assets/{card-BKqlUOe4.js → card-DS7CQEsO.js} +1 -1
  52. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  53. package/dist/dashboard/assets/{cli-tools-fTgljTGA.js → cli-tools-CNqzrfdb.js} +1 -1
  54. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  55. package/dist/dashboard/assets/{clipboard-button-D5Ys_sM0.js → clipboard-button-611pSsaX.js} +1 -1
  56. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  57. package/dist/dashboard/assets/{fingerprint-pattern-BWuHtbW4.js → concurrency-BDF0ok7y.js} +1 -1
  58. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  59. package/dist/dashboard/assets/dialog-k37exjNt.js +1 -0
  60. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  61. package/dist/dashboard/assets/drawer-CX7N8ucR.js +1 -0
  62. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CQBr6U9c.js → entry-B9Xvtnfj.js} +1 -1
  64. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-Bog0p925.js +4 -0
  66. package/dist/dashboard/assets/entry-Bog0p925.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-CTP8AyiS.js +1 -0
  68. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-BlptL8Uf.js → github-badge-sltk1qUi.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-js6TJvdr.js → index-CZQIrdoX.js} +2 -2
  72. package/dist/dashboard/assets/index-CZQIrdoX.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-DUWDSmDV.js → inline-BhY2sSnO.js} +1 -1
  74. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-LVjgZq4f.js → input-V1umFiBk.js} +1 -1
  76. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  77. package/dist/dashboard/assets/layout-BBOYycyS.js +1 -0
  78. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-DupvLfDx.js → list-row-1zzZYM0B.js} +1 -1
  80. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-C4pEJUqI.js → live-BivGfuUs.js} +1 -1
  82. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-BRiuufkB.js → model-bans-CmffjLuG.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-B4ROwk8L.js → mutation-46P0oWMe.js} +1 -1
  86. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  87. package/dist/dashboard/assets/numbers-DkfMR5Qs.js +2 -0
  88. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-gmjzC3RS.js → query-keys-BFhGpwR1.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-C7tBT5o-.js → routing-CAb4F37R.js} +1 -1
  92. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-WUCXorXw.js → select-BPpSsBgL.js} +1 -1
  94. package/dist/dashboard/assets/select-BPpSsBgL.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-C_zKl1GS.js → settings-DNX9sydi.js} +1 -1
  96. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-_3O0F8Xn.js → stack-BHTfXdz_.js} +1 -1
  98. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-BgTxHcJS.js → state-mPDRlQkl.js} +1 -1
  100. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DEjTtOWf.js → switch-DOmJxg7H.js} +1 -1
  102. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  103. package/dist/dashboard/assets/tokens-COzario9.js +1 -0
  104. package/dist/dashboard/assets/tokens-COzario9.js.gz +0 -0
  105. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js +1 -0
  106. package/dist/dashboard/assets/use-modal-focus-By7oJoGd.js.gz +0 -0
  107. package/dist/dashboard/assets/{useMutation-BWSC0UAS.js → useMutation-Dem0lXQj.js} +1 -1
  108. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  109. package/dist/dashboard/index.html +1 -1
  110. package/dist/dashboard/index.html.gz +0 -0
  111. package/dist/dashboard/precompressed-assets.json +54 -54
  112. package/migrations/README.md +52 -52
  113. package/migrations/lite-sqlite3/0000_baseline.sql +2 -2
  114. package/package.json +95 -95
  115. package/scripts/cli/admin.ts +125 -0
  116. package/scripts/cli/{ui/background.ts → background.ts} +1 -1
  117. package/scripts/cli/{command/console-menu.ts → console-menu.ts} +275 -244
  118. package/scripts/cli/doctor.ts +37 -0
  119. package/scripts/cli/{support/env.ts → env.ts} +99 -99
  120. package/scripts/cli/{gateway/spawn.ts → gateway.ts} +112 -24
  121. package/scripts/cli/instance.ts +224 -0
  122. package/scripts/cli/main.ts +61 -18
  123. package/scripts/cli/{support/platform.ts → platform.ts} +64 -64
  124. package/scripts/cli/port.ts +100 -0
  125. package/scripts/cli/{support/services.ts → services.ts} +88 -88
  126. package/scripts/cli/{command/setup.ts → setup.ts} +35 -34
  127. package/scripts/cli/{command/start.ts → start.ts} +10 -5
  128. package/scripts/cli/{support/startup-signal.ts → startup-signal.ts} +23 -23
  129. package/scripts/cli/tray/index.ts +62 -12
  130. package/scripts/cli/{command/tray.ts → tray.ts} +208 -208
  131. package/scripts/cli/{command/update.ts → update.ts} +39 -39
  132. package/scripts/cli/version.ts +67 -0
  133. package/scripts/cli/{ui/wordmark.ts → wordmark.ts} +58 -58
  134. package/src/boot/app.ts +697 -693
  135. package/src/boot/dependencies.ts +647 -646
  136. package/src/boot/timeout.ts +2 -13
  137. package/src/config/cooldowns.ts +186 -138
  138. package/src/config/network.ts +8 -14
  139. package/src/config/providers.ts +7 -10
  140. package/src/config/runtime.ts +76 -76
  141. package/src/config/security.ts +15 -0
  142. package/src/config/spec.ts +20 -30
  143. package/src/config/telemetry.ts +0 -20
  144. package/src/config/timeouts.ts +0 -6
  145. package/src/console/admin/model-abuse/contracts.ts +28 -6
  146. package/src/console/admin/model-abuse/routes.ts +56 -3
  147. package/src/console/api-keys/contracts.ts +410 -414
  148. package/src/console/api-keys/routes.ts +881 -880
  149. package/src/console/auth/credentials.ts +3 -3
  150. package/src/console/auth/setup-service.ts +2 -2
  151. package/src/console/backup/export-endpoint.ts +6 -2
  152. package/src/console/backup/nine-router.ts +2 -5
  153. package/src/console/backup/service.ts +4 -0
  154. package/src/console/backup/store.ts +192 -29
  155. package/src/console/backup/validate.ts +3 -45
  156. package/src/console/cli-tools/fs-ops.ts +487 -487
  157. package/src/console/console-router-guards.ts +1 -1
  158. package/src/console/data-management/contracts.ts +13 -6
  159. package/src/console/data-management/operations.ts +23 -18
  160. package/src/console/data-management/routes.ts +0 -1
  161. package/src/console/domain-registration.ts +10 -5
  162. package/src/console/observability/live.ts +22 -0
  163. package/src/console/observability/store.ts +39 -16
  164. package/src/console/observability/usage-dimensions.ts +4 -4
  165. package/src/console/providers/catalog/catalog-projections.ts +6 -7
  166. package/src/console/providers/catalog/model-operations.ts +5 -2
  167. package/src/console/providers/catalog/provider-operations.ts +4 -4
  168. package/src/console/providers/catalog/public-model-store.ts +8 -11
  169. package/src/console/providers/catalog/store.ts +1553 -1553
  170. package/src/console/providers/oauth/routes.ts +8 -12
  171. package/src/console/quota/account-quota-tenant.ts +6 -6
  172. package/src/console/quota/quota-view.ts +1 -1
  173. package/src/console/remote-support/contracts.ts +61 -0
  174. package/src/console/remote-support/routes.ts +65 -0
  175. package/src/console/remote-support/service.ts +207 -0
  176. package/src/console/response-compression.ts +0 -3
  177. package/src/console/routing/pools/probe-result.ts +3 -2
  178. package/src/console/routing/pools/probe-verdict.ts +132 -132
  179. package/src/console/routing/pools/store.ts +8 -8
  180. package/src/console/settings/contracts.ts +58 -2
  181. package/src/console/settings/store.ts +30 -3
  182. package/src/console/shared/cursor.ts +8 -8
  183. package/src/console/shared/errors.ts +1 -3
  184. package/src/network/http2-bidi.ts +2 -4
  185. package/src/network/pool/agent.ts +478 -478
  186. package/src/network/pool/selector.ts +5 -4
  187. package/src/network/retry.ts +1 -2
  188. package/src/network/ssrf.ts +5 -4
  189. package/src/observability/event-compaction.ts +80 -78
  190. package/src/observability/event-day-index.ts +13 -15
  191. package/src/observability/event-log.ts +4 -4
  192. package/src/observability/logger.ts +6 -6
  193. package/src/observability/payload-capture.ts +151 -33
  194. package/src/observability/payload-store.ts +397 -440
  195. package/src/observability/redaction.ts +5 -6
  196. package/src/observability/request-notify.ts +43 -0
  197. package/src/observability/telemetry-status.ts +8 -9
  198. package/src/observability/token-speed.ts +5 -4
  199. package/src/persistence/db-mode.ts +7 -7
  200. package/src/persistence/readiness.ts +1 -1
  201. package/src/persistence/schema.ts +1406 -1402
  202. package/src/persistence/shared-cache.ts +1 -2
  203. package/src/persistence/stores/api-key-store.ts +18 -6
  204. package/src/persistence/stores/share-store.ts +4 -3
  205. package/src/persistence/stores/telemetry-store.ts +236 -237
  206. package/src/protocol/canonical-model.ts +11 -3
  207. package/src/protocol/codecs.ts +138 -4
  208. package/src/protocol/gateway-error.ts +6 -4
  209. package/src/protocol/request/chat.ts +21 -1
  210. package/src/protocol/request/codex.ts +10 -1
  211. package/src/protocol/request/messages.ts +4 -5
  212. package/src/protocol/request/responses.ts +9 -1
  213. package/src/protocol/response/chat.ts +34 -4
  214. package/src/protocol/response/codex.ts +7 -4
  215. package/src/protocol/response/dsml.ts +140 -28
  216. package/src/protocol/response/responses.ts +11 -5
  217. package/src/protocol/stream-error-frames.ts +4 -5
  218. package/src/providers/authentication/jwt-validator.ts +9 -10
  219. package/src/providers/authentication/oauth-refresh-service.ts +9 -11
  220. package/src/providers/configured-openai-providers.ts +134 -136
  221. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  222. package/src/providers/discovery/catalog/catalog-format.ts +3 -6
  223. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  224. package/src/providers/discovery/probe-phases.ts +4 -4
  225. package/src/providers/discovery/probe-wire.ts +6 -13
  226. package/src/providers/discovery/probing-service.ts +1117 -1045
  227. package/src/providers/integrations/alibabacp/alibabacp.ts +1 -1
  228. package/src/providers/integrations/antigravity/antigravity-quota.ts +5 -7
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +23 -2
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +1 -4
  231. package/src/providers/integrations/buddy/buddy-cli-headers.ts +23 -6
  232. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +0 -1
  233. package/src/providers/integrations/buddy/buddy-identity.ts +52 -0
  234. package/src/providers/integrations/buddy/buddy-media.ts +2 -2
  235. package/src/providers/integrations/buddy/buddy-usage-store.ts +3 -4
  236. package/src/providers/integrations/buddy/codebuddy/quota.ts +2 -13
  237. package/src/providers/integrations/buddy/codebuddy/shared.ts +118 -115
  238. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -11
  239. package/src/providers/integrations/buddy/workbuddy/shared.ts +5 -2
  240. package/src/providers/integrations/claude/claude.ts +608 -608
  241. package/src/providers/integrations/cline/cline-quota.ts +132 -132
  242. package/src/providers/integrations/cline/cline.ts +537 -539
  243. package/src/providers/integrations/codex/codex-oauth.ts +1 -1
  244. package/src/providers/integrations/commandcode/commandcode.ts +674 -675
  245. package/src/providers/integrations/commandcode/discovery.ts +117 -116
  246. package/src/providers/integrations/cursor/cursor-auth.ts +418 -418
  247. package/src/providers/integrations/cursor/cursor-quota.ts +309 -310
  248. package/src/providers/integrations/cursor/cursor.ts +11 -163
  249. package/src/providers/integrations/factory/factory-models.ts +3 -5
  250. package/src/providers/integrations/grok/grok-install-id.ts +2 -3
  251. package/src/providers/integrations/grok/grok.ts +6 -7
  252. package/src/providers/integrations/hermes.ts +14 -18
  253. package/src/providers/integrations/kiro/kiro-discovery.ts +1 -1
  254. package/src/providers/integrations/kiro/kiro.ts +1 -1
  255. package/src/providers/integrations/mistral.ts +6 -6
  256. package/src/providers/integrations/opencode/fingerprint.ts +3 -3
  257. package/src/providers/integrations/opencode/provider.ts +8 -8
  258. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -450
  259. package/src/providers/integrations/qoder/qoder-oauth.ts +388 -310
  260. package/src/providers/integrations/qoder/qoder.ts +884 -865
  261. package/src/providers/integrations/shared/connect.ts +6 -6
  262. package/src/providers/integrations/trae/trae-catalog.ts +1 -2
  263. package/src/providers/integrations/trae/trae.ts +2 -2
  264. package/src/providers/integrations/zcode/oauth.ts +6 -1
  265. package/src/providers/operations/account-health-service.ts +1528 -1523
  266. package/src/providers/operations/account-reset-service.ts +1 -1
  267. package/src/providers/operations/client-versions.ts +6 -6
  268. package/src/providers/operations/custom-cli-headers.ts +3 -4
  269. package/src/providers/operations/provider-catalog-seeder.ts +95 -95
  270. package/src/providers/operations/provider-catalog-service.ts +3 -4
  271. package/src/providers/provider-metadata.ts +4 -8
  272. package/src/providers/provider-registry.ts +6 -7
  273. package/src/providers/provider-usage.ts +1 -1
  274. package/src/security/abuse.ts +15 -10
  275. package/src/security/access-control.ts +144 -146
  276. package/src/security/admission/contracts.ts +11 -0
  277. package/src/security/admission/in-memory-store.ts +4 -3
  278. package/src/security/admission/lease-sweep.ts +6 -6
  279. package/src/security/admission/reasons.ts +5 -1
  280. package/src/security/admission/redis-store.ts +211 -210
  281. package/src/security/admission/service.ts +4 -0
  282. package/src/security/api-key-auth.ts +504 -504
  283. package/src/security/ip-boundary.ts +155 -15
  284. package/src/security/model-abuse.ts +366 -94
  285. package/src/security/outbound-headers.ts +3 -4
  286. package/src/shared/sleep.ts +4 -0
  287. package/src/transport/dispatch/attempt-finalize.ts +47 -5
  288. package/src/transport/dispatch/attempt-loop.ts +531 -530
  289. package/src/transport/dispatch/stream-bridge.ts +1 -3
  290. package/src/transport/dispatch/streaming/finalize.ts +1 -1
  291. package/src/transport/dispatch/streaming/stream.ts +5 -6
  292. package/src/transport/failure-policy.ts +162 -8
  293. package/src/transport/guards/auth-guard.ts +7 -4
  294. package/src/transport/guards/concurrency-guard.ts +0 -5
  295. package/src/transport/guards/error-lifecycle.ts +13 -6
  296. package/src/transport/guards/pipeline.ts +15 -3
  297. package/src/transport/guards/request-context.ts +8 -2
  298. package/src/transport/request/inflight.ts +5 -6
  299. package/src/transport/request/preparer.ts +2 -2
  300. package/src/transport/request/state.ts +12 -8
  301. package/src/transport/routing/admission.ts +4 -0
  302. package/src/transport/routing/route-catalog.ts +8 -1
  303. package/src/transport/routing/route-model.ts +46 -14
  304. package/src/transport/routing/router.ts +21 -14
  305. package/src/transport/surface/completion.ts +2 -3
  306. package/src/transport/surface/dialects.ts +5 -6
  307. package/src/transport/surface/responses/encode.ts +38 -0
  308. package/src/transport/surface/responses/parse.ts +4 -0
  309. package/src/transport/translation/capabilities.ts +2 -3
  310. package/src/transport/translation/quirks.ts +3 -3
  311. package/src/workers/buddy-growth-tasks.ts +5 -10
  312. package/src/workers/daily-checkin.ts +17 -23
  313. package/src/workers/quota-refresh-sweep.ts +26 -26
  314. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  315. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +0 -1
  316. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  317. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  318. package/dist/dashboard/assets/CombosPage-Bsan4679.js +0 -1
  319. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  320. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  321. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  322. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  323. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  324. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js +0 -1
  325. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  326. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +0 -1
  327. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  328. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +0 -3
  329. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  330. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  331. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +0 -7
  332. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  333. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +0 -1
  334. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  335. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +0 -1
  336. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  337. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  338. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  339. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  340. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js +0 -56
  341. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  342. package/dist/dashboard/assets/api-keys-VY68Mhd1.js +0 -1
  343. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  344. package/dist/dashboard/assets/app-COlK95oi.css +0 -2
  345. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  346. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  347. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  348. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  349. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  350. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  351. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  352. package/dist/dashboard/assets/dialog-BYj-8WuK.js +0 -1
  353. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  354. package/dist/dashboard/assets/drawer-CmXIZ6H1.js +0 -1
  355. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  356. package/dist/dashboard/assets/entry-CBg5zeu8.js +0 -1
  357. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  358. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-CgdSAzEV.js +0 -4
  360. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  361. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  363. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  365. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-CPfxpsfy.js +0 -1
  367. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  368. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  369. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  370. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  371. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  372. package/dist/dashboard/assets/numbers-YNcHvRY7.js +0 -2
  373. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  374. package/dist/dashboard/assets/query-keys-gmjzC3RS.js.gz +0 -0
  375. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  376. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  377. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  378. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  379. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  380. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  381. package/dist/dashboard/assets/tokens-DDxMbSRL.js +0 -1
  382. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  383. package/dist/dashboard/assets/use-presence-BSZEBx7U.js +0 -1
  384. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  385. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  386. package/scripts/cli/command/admin.ts +0 -65
  387. package/scripts/cli/command/doctor.ts +0 -257
  388. package/scripts/cli/gateway/drain-token.ts +0 -78
  389. package/scripts/cli/gateway/instance-lock.ts +0 -105
  390. package/scripts/cli/gateway/stop.ts +0 -51
  391. package/scripts/cli/support/bun.ts +0 -27
  392. package/scripts/cli/support/version.ts +0 -37
  393. /package/dist/dashboard/assets/{app-DCQZRmHY.js → app-BIt446yE.js} +0 -0
  394. /package/dist/dashboard/assets/{app-DCQZRmHY.js.gz → app-BIt446yE.js.gz} +0 -0
  395. /package/scripts/cli/{ui/prompt.ts → prompt.ts} +0 -0
@@ -1,530 +1,531 @@
1
- /**
2
- * Shared per-candidate attempt lifecycle for every dispatch route.
3
- *
4
- * Admission lease → pool slot → routing reservation, the route-specific
5
- * upstream operation, then one `completeAttempt` for the attempt's accounting.
6
- * Retry, backoff, pool-cooldown flagging, and reactive OAuth refresh are one
7
- * policy here, so the canonical proxy routes and the native Responses-compact
8
- * route cannot drift. Route-specific behavior arrives as the `prepare` and
9
- * `attempt` closures; this module never forks retry policy.
10
- */
11
- import { GatewayError } from "../../protocol/gateway-error";
12
- import type { ResolvedCredential } from "../../providers/provider-contracts";
13
- import {
14
- classifyTerminalOutcome,
15
- fallbackRetryDelayMs,
16
- isRetryableFailure,
17
- MIN_RETRY_BUDGET_MS,
18
- sleep,
19
- } from "../failure-policy";
20
- import { resolveRouteMaxAttempts } from "../../config/timeouts";
21
- import type { OAuthTokenRefresher } from "../../providers/authentication/oauth-refresh-service";
22
- import type { OAuthRefreshService } from "../../providers/authentication/oauth-refresh-service";
23
- import type { ValidatedNetworkBindingFactory } from "../../network/pool/resolver";
24
- import type { ByokUpstreamHost } from "../../providers/operations/provider-catalog-service";
25
- import type { AdmissionLease } from "../../security/admission/contracts";
26
- import type { RouteCandidate, Reservation, RouteSnapshotService } from "../routing/route-model";
27
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
28
- import { acquireAttemptLeases, releaseAttemptLeases } from "./leases";
29
- import type { AttemptLeaseSource, AttemptLeases } from "./leases";
30
- import type { AttemptLeaseOwnership } from "./leases";
31
- import { metrics } from "../../observability/metrics";
32
- import type { NetworkPoolSelector } from "../../network/pool/selector";
33
- import type { ProxyRequestState } from "../request/state";
34
- import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
35
- import { flagPoolBlocked, flagPoolCooldown } from "../../network/pool-health";
36
- import { completeAttempt, errorClientResponseBody, estimatedUsage, type ProviderExchangeCapture } from "./attempt-finalize";
37
- import { repriceUsage } from "../../providers/provider-usage";
38
- import { isFreeTierIpBlock, isOAuthCredentialInvalidated, shouldCooldownPool } from "./retry-policy";
39
- import { drainAbortReason } from "../shutdown-notice";
40
-
41
- /**
42
- * Identity of one combo member for the request-scoped failure flag.
43
- *
44
- * Account, model, and endpoint distinguish siblings that share a provider.
45
- * The flag lives only for this request: it stops a failed member from being
46
- * retried while another member remains, without writing a durable cooldown.
47
- */
48
- function comboCandidateKey(candidate: RouteCandidate): string {
49
- return [
50
- candidate.provider_id,
51
- candidate.provider_account_id ?? "global",
52
- candidate.model_id,
53
- candidate.endpoint,
54
- ].join(":");
55
- }
56
-
57
- /**
58
- * Which combo member a candidate belongs to.
59
- *
60
- * A combo's members are distinct models (usually one per provider), so the
61
- * model id is what separates them — the accounts of one member are siblings
62
- * that share it. Used only to give each member its own share of the attempt
63
- * budget; a candidate list that is not a combo collapses to one member, which
64
- * keeps the ordinary single-model path untouched.
65
- */
66
- function comboMemberKey(candidate: RouteCandidate): string {
67
- return `${candidate.provider_id}:${candidate.model_id}:${candidate.endpoint}`;
68
- }
69
-
70
- /**
71
- * One member's ordinal, resolved by *identity* rather than by position.
72
- *
73
- * Deliberately not a run-length scan. `plan()` reorders the eligible list — its
74
- * cooling partition emits every healthy candidate before every cooling one — so
75
- * one member's accounts can be non-contiguous and even interleaved with another
76
- * member's. A run-length scan then reports more "members" than actually exist:
77
- * `[cb, wb, cb, wb]` counts four, which pushed each member's skip threshold to
78
- * `cap * 4` — well past the whole budget — and silently disabled the cap on
79
- * exactly the degraded lists it exists for.
80
- *
81
- * Keyed off the same string the member count is derived from, so the two can
82
- * never disagree.
83
- */
84
- function buildMemberOrdinals(
85
- candidates: readonly RouteCandidate[],
86
- ): ReadonlyMap<string, number> {
87
- const ordinals = new Map<string, number>();
88
- for (const candidate of candidates) {
89
- const key = comboMemberKey(candidate);
90
- if (!ordinals.has(key)) ordinals.set(key, ordinals.size);
91
- }
92
- return ordinals;
93
- }
94
-
95
- /** Route-specific preconditions resolved for one candidate before its leases are taken. */
96
- interface PreparedAttempt<TAdapter> {
97
- readonly credential: ResolvedCredential;
98
- readonly adapter: TAdapter;
99
- }
100
-
101
- /** One candidate attempt with its admission lease, pool slot, and reservation held. */
102
- interface AttemptContext<TAdapter> {
103
- readonly candidate: RouteCandidate;
104
- readonly credential: ResolvedCredential;
105
- readonly adapter: TAdapter;
106
- readonly leases: AttemptLeases;
107
- /** Zero-based candidate index; also this attempt's failover count. */
108
- readonly attemptIndex: number;
109
- readonly providerCapture: ProviderExchangeCapture;
110
- /**
111
- * Ownership of this attempt's account/network-pool release, when the route
112
- * acquired a pool slot. A provider session that outlives this request (a
113
- * Cursor Run held open for the client's tool result) calls
114
- * `transferToProviderSession()` and takes it over; otherwise the request
115
- * releases it through `releaseByResponse()`.
116
- */
117
- readonly leaseOwnership?: AttemptLeaseOwnership | undefined;
118
- /**
119
- * Hands lease ownership to the value this attempt returns (a streaming
120
- * response releases its own lease on stream completion/cancel). Without it
121
- * the loop releases the leases in `finally`.
122
- */
123
- retainLeases(): void;
124
- }
125
-
126
- /** Collaborators shared by every dispatch route. */
127
- interface AttemptLoopDeps {
128
- readonly db: CartethyiaDatabase;
129
- readonly poolSelector?: NetworkPoolSelector;
130
- readonly networkBindingFactory?: ValidatedNetworkBindingFactory;
131
- readonly snapshotService?: RouteSnapshotService;
132
- readonly telemetryBuffer?: TelemetryBatchBuffer;
133
- readonly resolveOAuthRefresher?: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
134
- readonly oauthRefreshService?: OAuthRefreshService;
135
- }
136
-
137
- interface AttemptLoopInput<TResult, TAdapter> {
138
- readonly state: ProxyRequestState;
139
- readonly deps: AttemptLoopDeps;
140
- readonly leaseSource: AttemptLeaseSource;
141
- readonly candidates: readonly RouteCandidate[];
142
- readonly tenantId: string | null;
143
- /** Strict pool selection rejects direct egress when every pool is at capacity. */
144
- readonly strictPoolSelection: boolean;
145
- /** BYOK host for a candidate, when this route validates network bindings. */
146
- readonly resolveHost?: (candidate: RouteCandidate) => ByokUpstreamHost | undefined;
147
- readonly prepare: (candidate: RouteCandidate) => Promise<PreparedAttempt<TAdapter>>;
148
- readonly attempt: (context: AttemptContext<TAdapter>) => Promise<TResult>;
149
- /** Raised when the candidate list is empty and no attempt ever ran. */
150
- readonly exhaustedError: GatewayError;
151
- }
152
-
153
- /**
154
- * Shared per-candidate attempt lifecycle for the canonical proxy routes and
155
- * the native Responses-compact route: admission lease → pool slot → routing
156
- * reservation, the route-specific upstream operation, then one
157
- * `completeAttempt` for the attempt's accounting. Retry, backoff,
158
- * pool-cooldown flagging, and reactive OAuth refresh are one policy here, so
159
- * the two routes cannot drift.
160
- */
161
- export async function runAttemptLoop<TResult, TAdapter>(
162
- input: AttemptLoopInput<TResult, TAdapter>,
163
- ): Promise<TResult> {
164
- const { state, deps, leaseSource } = input;
165
- const candidates = [...input.candidates];
166
- let lastError: unknown = input.exhaustedError;
167
- const refreshedCandidates = new Set<string>();
168
- const failedCandidates = new Set<string>();
169
- // Every attempt owns a fresh capture sink, but only the terminal attempt's
170
- // sink reaches the payload store. When the terminal attempt fails before it
171
- // fires an outbound request (lease/acquire/prepare threw, or failover
172
- // exhausted), its sink is empty and the drawer reads "—" for a request that
173
- // DID reach an upstream on an earlier attempt. Keep the fired exchanges so
174
- // the terminal completion can fall back to the last one that has a request.
175
- const firedCaptures: ProviderExchangeCapture[] = [];
176
- const maxAttempts = resolveRouteMaxAttempts();
177
- // One combo member must not be able to spend the whole budget.
178
- //
179
- // A combo's member list is flattened into this candidate list, so the first
180
- // member's accounts occupy its head. With one member holding more accounts
181
- // than `maxAttempts` (default 8), every attempt was spent inside that member
182
- // and a later, perfectly healthy member was never dialed — a `deepseek` combo
183
- // of [cb, wb] stayed stuck on cb while wb sat idle, because cb's twelve
184
- // accounts exhausted all eight attempts before the list reached wb.
185
- //
186
- // Each member gets an equal share of the budget, counted **per member** — not
187
- // as a cumulative watermark over total attempts, or one member that over-runs
188
- // silently steals the tail's share. The share is a ceiling, not a reservation:
189
- // a member that used less than its share simply never reaches it, and the
190
- // unused budget stays available to the members behind it.
191
- const memberOrdinals = buildMemberOrdinals(candidates);
192
- const memberCount = memberOrdinals.size;
193
- const perMemberCap =
194
- memberCount > 1 ? Math.max(1, Math.ceil(maxAttempts / memberCount)) : maxAttempts;
195
- const attemptsByMember = new Map<string, number>();
196
- // The budget itself is owned by failure-policy, so this loop, the
197
- // empty-stream replay, and the Codex retry budget cannot drift apart.
198
- let index = 0;
199
- let attempts = 0;
200
- while (index < candidates.length && attempts < maxAttempts) {
201
- const candidate = candidates[index];
202
- if (!candidate) break;
203
- const memberKey = comboMemberKey(candidate);
204
- // A member that has spent its share is skipped: the loop advances past the
205
- // rest of its accounts to the next member instead of burning the budget
206
- // they would have consumed.
207
- if (
208
- memberCount > 1 &&
209
- (attemptsByMember.get(memberKey) ?? 0) >= perMemberCap
210
- ) {
211
- index += 1;
212
- continue;
213
- }
214
- // Do not start an attempt that cannot finish. The measured pre-dispatch
215
- // cost of the previous attempt (`resolveMs` ran 60s+ on a slow upstream)
216
- // is what a follow-on attempt has to spend again before the upstream is
217
- // even called, so a retry that begins with less than that left is a retry
218
- // the deadline will kill mid-flight. Stopping here surfaces the first
219
- // attempt's own error instead of a second one's timeout.
220
- if (attempts > 0) {
221
- const remainingMs = state.deadlineMs - Date.now();
222
- if (remainingMs < MIN_RETRY_BUDGET_MS) {
223
- if (lastError !== undefined && !state.completed) {
224
- const terminal = classifyTerminalOutcome(lastError, state.abortController.signal);
225
- const lastFired = [...firedCaptures]
226
- .reverse()
227
- .find((capture) => capture.request !== null);
228
- await completeAttempt(state, {
229
- status: terminal.status,
230
- providerId: candidate.provider_id,
231
- ...(candidate.provider_account_id
232
- ? { accountId: candidate.provider_account_id }
233
- : {}),
234
- ...(candidate.provider_account_label
235
- ? { accountLabel: candidate.provider_account_label }
236
- : {}),
237
- errorCategory: terminal.errorCategory,
238
- errorOrigin: terminal.errorOrigin,
239
- modelId: candidate.model_id,
240
- error: lastError,
241
- terminal: true,
242
- tenantId: input.tenantId,
243
- ingressBody: state.ingressBody,
244
- responseBody: null,
245
- ...(terminal.status !== "cancelled"
246
- ? { clientResponseText: errorClientResponseBody(lastError) }
247
- : {}),
248
- // Same fallback the terminal catch uses: a request that did fire
249
- // upstream keeps that exchange in Request Detail.
250
- ...(lastFired ? { providerCapture: lastFired } : {}),
251
- db: deps.db,
252
- ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
253
- ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
254
- });
255
- metrics.proxy_requests_total.inc(1, {
256
- status: terminal.status === "cancelled" ? "cancelled" : "failed",
257
- });
258
- }
259
- throw lastError;
260
- }
261
- }
262
- let lease: AdmissionLease | undefined;
263
- let reservation: Reservation | undefined;
264
- let proxySlot: { poolId: string; release: () => void } | undefined;
265
- let networkPoolId: string | undefined;
266
- let leaseOwnership: AttemptLeaseOwnership | undefined;
267
- let bindingEstablished = false;
268
- let leasesRetained = false;
269
- let retryDelayMs = 0;
270
- const providerCapture: ProviderExchangeCapture = {
271
- request: null,
272
- response: Promise.resolve(null),
273
- };
274
- try {
275
- if (state.abortController.signal.aborted)
276
- throw new GatewayError("transport_closed", 499, "request was cancelled");
277
- const { credential, adapter } = await input.prepare(candidate);
278
- const host = input.resolveHost?.(candidate);
279
- // Single attempt-lease manager owns acquisition order (admission →
280
- // pool slot → reservation) with reverse-order unwind. Assigned only on
281
- // success, so a mid-acquire throw leaves these undefined for `finally`.
282
- const attemptLeases = await acquireAttemptLeases(leaseSource, candidate, {
283
- signal: state.abortController.signal,
284
- ...(deps.poolSelector ? { poolSelector: deps.poolSelector } : {}),
285
- ...(deps.networkBindingFactory ? { networkBindingFactory: deps.networkBindingFactory } : {}),
286
- ...(host ? { host } : {}),
287
- strictPoolSelection: input.strictPoolSelection,
288
- });
289
- lease = attemptLeases.lease;
290
- reservation = attemptLeases.reservation;
291
- proxySlot = attemptLeases.proxySlot;
292
- networkPoolId = attemptLeases.networkPoolId;
293
- leaseOwnership = attemptLeases.ownership;
294
- // Downstream error paths gate `commitUsage(estimated)` on this flag so
295
- // the estimate is only charged once the request truly consumed
296
- // upstream resources; the lease and reservation are held from here on.
297
- bindingEstablished = true;
298
- state.startProviderFlight();
299
- return await input.attempt({
300
- candidate,
301
- credential,
302
- adapter,
303
- leases: attemptLeases,
304
- attemptIndex: index,
305
- providerCapture,
306
- ...(leaseOwnership === undefined ? {} : { leaseOwnership }),
307
- retainLeases: () => {
308
- leasesRetained = true;
309
- },
310
- });
311
- } catch (error) {
312
- // A drain aborts the controller, so a mid-flight attempt unwinds here as
313
- // an abort. Surface the typed shutdown error (503 shutting_down /
314
- // restart_for_update) instead of an opaque `transport_closed`, so the
315
- // client's JSON error — not just the SSE path — names the real cause.
316
- const drain = drainAbortReason(state.abortController.signal.reason);
317
- if (drain !== undefined) error = drain;
318
- lastError = error;
319
- // A drain is a *server* close, not a client cancel: it must be recorded
320
- // as a failure with the shutdown code, and it is never retryable.
321
- // Status, category, and origin come from one classifier so they cannot
322
- // disagree (a `cancelled` row used to carry `transport_unavailable`/502).
323
- const terminal = classifyTerminalOutcome(error, state.abortController.signal);
324
- const cancelled = terminal.status === "cancelled";
325
- // A failure the *pool* caused retires that pool, not the candidate: the
326
- // next attempt keeps the same provider/model/account and selects a
327
- // different egress. That is a genuinely new attempt even though the
328
- // candidate index does not move, so it must not be judged terminal by
329
- // "this was the last candidate" — that rule is about candidates, and
330
- // opencode has exactly one (it is credential-less, so the plan holds a
331
- // single candidate carrying every pool). Judging it there is what made a
332
- // refused egress end the request instead of failing over to a healthy
333
- // one.
334
- const rateLimitedPool = !cancelled && shouldCooldownPool(error, candidate.provider_id);
335
- // A free-tier refusal is only ever this provider's: the guard is gated on
336
- // `ipScopedRateLimit` in the provider metadata, so a 403 from any other
337
- // upstream still means what it always did and never retires an egress.
338
- const blockedPool = !cancelled && isFreeTierIpBlock(error, candidate.provider_id);
339
- const poolScopedFailure =
340
- (rateLimitedPool || blockedPool) &&
341
- networkPoolId !== undefined &&
342
- deps.poolSelector !== undefined;
343
- // Flag before the decision below, so the retry that follows already sees
344
- // the pool excluded. Both flag helpers write the local entry
345
- // synchronously, so the ordering holds even though the Redis mirror is
346
- // fire-and-forget.
347
- if (networkPoolId && deps.poolSelector && error instanceof GatewayError) {
348
- if (rateLimitedPool) {
349
- flagPoolCooldown(deps.poolSelector, deps.db, networkPoolId, candidate.provider_id, error);
350
- } else if (blockedPool) {
351
- // The address itself is refused, not the request: retire the egress
352
- // so the next attempt leaves by a different one instead of retrying
353
- // a proxy the upstream has already flagged.
354
- flagPoolBlocked(deps.poolSelector, deps.db, networkPoolId, candidate.provider_id, error);
355
- }
356
- }
357
- const terminalAttempt =
358
- cancelled ||
359
- !isRetryableFailure(error) ||
360
- (index === candidates.length - 1 && !poolScopedFailure);
361
- if (providerCapture.request !== null) firedCaptures.push(providerCapture);
362
- // The payload store only keeps the terminal attempt's exchange. When the
363
- // terminal attempt never fired (failover walked past the attempt that
364
- // did, or prepare/lease threw first), fall back to the last fired
365
- // exchange so Request Detail still shows the translated provider
366
- // request instead of "—" for a request that reached an upstream.
367
- const terminalCapture =
368
- providerCapture.request !== null
369
- ? providerCapture
370
- : [...firedCaptures].reverse().find((capture) => capture.request !== null) ??
371
- providerCapture;
372
- await completeAttempt(state, {
373
- status: terminal.status,
374
- providerId: candidate.provider_id,
375
- ...(candidate.provider_account_id ? { accountId: candidate.provider_account_id } : {}),
376
- ...(candidate.provider_account_label ? { accountLabel: candidate.provider_account_label } : {}),
377
- errorCategory: terminal.errorCategory,
378
- errorOrigin: terminal.errorOrigin,
379
- modelId: candidate.model_id,
380
- ...(networkPoolId ? { networkPoolId } : {}),
381
- error,
382
- ...(lease ? { lease } : {}),
383
- // A failed attempt may have sent bytes upstream, so it is not free —
384
- // but it must not be charged the full input estimate plus a full
385
- // output reserve either, because a request that walks N candidates
386
- // would then leave N estimates on the counters for one client request.
387
- // Commit the input estimate only: nothing was generated, and the
388
- // `finally` release then refunds the (uncommitted) output reserve.
389
- ...(!cancelled && bindingEstablished
390
- ? {
391
- commitUsage: repriceUsage(
392
- estimatedUsage(leaseSource.estimatedInputTokens, 0),
393
- candidate.provider_id,
394
- candidate.model_id,
395
- ),
396
- }
397
- : {}),
398
- terminal: terminalAttempt,
399
- tenantId: input.tenantId,
400
- ingressBody: state.ingressBody,
401
- responseBody: null,
402
- // The client still receives the public error envelope on the wire —
403
- // store it so Request Detail's Client Response panel traces failures
404
- // exactly as the client saw them. Cancelled requests have no client
405
- // response (the caller is gone).
406
- ...(terminalAttempt && !cancelled
407
- ? { clientResponseText: errorClientResponseBody(error) }
408
- : {}),
409
- providerCapture: terminalCapture,
410
- db: deps.db,
411
- ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
412
- ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
413
- });
414
- // A 403 can be a deterministic provider policy rejection, not an
415
- // invalid OAuth credential. Only the evidence-based classifier above
416
- // may trigger a refresh.
417
- const candidateKey = `${candidate.provider_id}:${candidate.provider_account_id ?? "global"}:${candidate.model_id}:${candidate.endpoint}`;
418
- const authInvalidated = isOAuthCredentialInvalidated(error);
419
- if (
420
- !cancelled &&
421
- !refreshedCandidates.has(candidateKey) &&
422
- authInvalidated &&
423
- candidate.provider_account_id &&
424
- deps.oauthRefreshService &&
425
- deps.resolveOAuthRefresher
426
- ) {
427
- const refresher = await deps.resolveOAuthRefresher(candidate.provider_id);
428
- if (refresher) {
429
- const fresh = await deps.oauthRefreshService.ensureFreshAccessToken(
430
- candidate.provider_account_id,
431
- refresher,
432
- { force: true },
433
- );
434
- if (fresh) {
435
- refreshedCandidates.add(candidateKey);
436
- continue;
437
- }
438
- }
439
- }
440
- if (terminalAttempt) {
441
- metrics.proxy_requests_total.inc(1, { status: cancelled ? "cancelled" : "failed" });
442
- throw error;
443
- }
444
- // A failed combo member stays flagged for this request. Move it behind
445
- // every candidate that has not failed yet, so the next attempt cannot
446
- // stick to it while a sibling remains. It becomes eligible again only
447
- // after that sibling list is exhausted.
448
- //
449
- // A pool-scoped failure is exempt: the candidate never got its turn, so
450
- // marking it failed would spend its only remaining chance on the pool
451
- // that just refused it. The candidate stays at the head, and the next
452
- // attempt selects a different pool for it. The attempt budget still
453
- // bounds the loop, and once every pool is retired the selection itself
454
- // fails with `proxy_pool_blocked`, which is terminal.
455
- const failureKey = comboCandidateKey(candidate);
456
- if (poolScopedFailure) {
457
- // Deliberately no index movement and no `failedCandidates` entry.
458
- } else if (!failedCandidates.has(failureKey)) {
459
- failedCandidates.add(failureKey);
460
- candidates.push(candidates.splice(index, 1)[0]!);
461
- } else {
462
- index += 1;
463
- }
464
- retryDelayMs = fallbackRetryDelayMs(attempts);
465
- } finally {
466
- if (!leasesRetained)
467
- await releaseAttemptLeases(
468
- { lease, reservation, proxySlot, ...(leaseOwnership === undefined ? {} : { ownership: leaseOwnership }) },
469
- leaseSource.routingEngine,
470
- );
471
- }
472
- attempts += 1;
473
- // Charged to the member that spent it, so one member's own attempts are
474
- // what exhaust its share — not the running total across every member.
475
- attemptsByMember.set(memberKey, (attemptsByMember.get(memberKey) ?? 0) + 1);
476
- if (retryDelayMs > 0) {
477
- try {
478
- await sleep(retryDelayMs, state.abortController.signal);
479
- } catch {
480
- // Abort during backoff used to escape as the sleep's own error with
481
- // no counter increment: attempts already recorded their telemetry,
482
- // but `proxy_requests_total` missed the request. Fail with the last
483
- // attempt's error so the client sees the real cause, counted once.
484
- metrics.proxy_requests_total.inc(1, { status: "cancelled" });
485
- throw lastError;
486
- }
487
- }
488
- }
489
- // The loop can fall out with a retryable error still in hand (every attempt
490
- // was retryable, so the terminal branch in the catch never fired). That used
491
- // to throw with no completion: no telemetry row, no payload capture, and
492
- // `state.completed` left clear — so a request that genuinely exhausted its
493
- // attempts was invisible in Request Detail. Record it as terminal here.
494
- if (!state.completed) {
495
- const terminal = classifyTerminalOutcome(lastError, state.abortController.signal);
496
- const lastCandidate = candidates[Math.min(index, candidates.length - 1)];
497
- const terminalCapture = [...firedCaptures]
498
- .reverse()
499
- .find((capture) => capture.request !== null);
500
- await completeAttempt(state, {
501
- status: terminal.status,
502
- providerId: lastCandidate?.provider_id ?? "",
503
- ...(lastCandidate?.provider_account_id
504
- ? { accountId: lastCandidate.provider_account_id }
505
- : {}),
506
- ...(lastCandidate?.provider_account_label
507
- ? { accountLabel: lastCandidate.provider_account_label }
508
- : {}),
509
- errorCategory: terminal.errorCategory,
510
- errorOrigin: terminal.errorOrigin,
511
- ...(lastCandidate?.model_id === undefined ? {} : { modelId: lastCandidate.model_id }),
512
- error: lastError,
513
- terminal: true,
514
- tenantId: input.tenantId,
515
- ingressBody: state.ingressBody,
516
- responseBody: null,
517
- // The client receives the public error envelope on the wire — store it
518
- // so the Client Response panel traces the failure as the client saw it.
519
- ...(terminal.status === "cancelled"
520
- ? {}
521
- : { clientResponseText: errorClientResponseBody(lastError) }),
522
- ...(terminalCapture === undefined ? {} : { providerCapture: terminalCapture }),
523
- db: deps.db,
524
- ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
525
- ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
526
- });
527
- }
528
- metrics.proxy_requests_total.inc(1, { status: "failed" });
529
- throw lastError;
530
- }
1
+ /**
2
+ * Shared per-candidate attempt lifecycle for every dispatch route.
3
+ *
4
+ * Admission lease → pool slot → routing reservation, the route-specific
5
+ * upstream operation, then one `completeAttempt` for the attempt's accounting.
6
+ * Retry, backoff, pool-cooldown flagging, and reactive OAuth refresh are one
7
+ * policy here, so the canonical proxy routes and the native Responses-compact
8
+ * route cannot drift. Route-specific behavior arrives as the `prepare` and
9
+ * `attempt` closures; this module never forks retry policy.
10
+ */
11
+ import { GatewayError } from "../../protocol/gateway-error";
12
+ import type { ResolvedCredential } from "../../providers/provider-contracts";
13
+ import {
14
+ classifyTerminalOutcome,
15
+ fallbackRetryDelayMs,
16
+ isRetryableFailure,
17
+ MIN_RETRY_BUDGET_MS,
18
+ sleep,
19
+ } from "../failure-policy";
20
+ import { resolveRouteMaxAttempts } from "../../config/timeouts";
21
+ import type { OAuthTokenRefresher } from "../../providers/authentication/oauth-refresh-service";
22
+ import type { OAuthRefreshService } from "../../providers/authentication/oauth-refresh-service";
23
+ import type { ValidatedNetworkBindingFactory } from "../../network/pool/resolver";
24
+ import type { ByokUpstreamHost } from "../../providers/operations/provider-catalog-service";
25
+ import type { AdmissionLease } from "../../security/admission/contracts";
26
+ import type { RouteCandidate, Reservation, RouteSnapshotService } from "../routing/route-model";
27
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
28
+ import { acquireAttemptLeases, releaseAttemptLeases } from "./leases";
29
+ import type { AttemptLeaseSource, AttemptLeases } from "./leases";
30
+ import type { AttemptLeaseOwnership } from "./leases";
31
+ import { metrics } from "../../observability/metrics";
32
+ import type { NetworkPoolSelector } from "../../network/pool/selector";
33
+ import type { ProxyRequestState } from "../request/state";
34
+ import type { TelemetryBatchBuffer } from "../../observability/telemetry-buffer";
35
+ import { flagPoolBlocked, flagPoolCooldown } from "../../network/pool-health";
36
+ import { completeAttempt, errorClientResponseBody, estimatedUsage, type ProviderExchangeCapture } from "./attempt-finalize";
37
+ import { repriceUsage } from "../../providers/provider-usage";
38
+ import { isFreeTierIpBlock, isOAuthCredentialInvalidated, shouldCooldownPool } from "./retry-policy";
39
+ import { drainAbortReason } from "../shutdown-notice";
40
+
41
+ /**
42
+ * Identity of one combo member for the request-scoped failure flag.
43
+ *
44
+ * Account, model, and endpoint distinguish siblings that share a provider.
45
+ * The flag lives only for this request: it stops a failed member from being
46
+ * retried while another member remains, without writing a durable cooldown.
47
+ */
48
+ function comboCandidateKey(candidate: RouteCandidate): string {
49
+ return [
50
+ candidate.provider_id,
51
+ candidate.provider_account_id ?? "global",
52
+ candidate.model_id,
53
+ candidate.endpoint,
54
+ ].join(":");
55
+ }
56
+
57
+ /**
58
+ * Which combo member a candidate belongs to.
59
+ *
60
+ * A combo's members are distinct models (usually one per provider), so the
61
+ * model id is what separates them — the accounts of one member are siblings
62
+ * that share it. Used only to give each member its own share of the attempt
63
+ * budget; a candidate list that is not a combo collapses to one member, which
64
+ * keeps the ordinary single-model path untouched.
65
+ */
66
+ function comboMemberKey(candidate: RouteCandidate): string {
67
+ return `${candidate.provider_id}:${candidate.model_id}:${candidate.endpoint}`;
68
+ }
69
+
70
+ /**
71
+ * One member's ordinal, resolved by *identity* rather than by position.
72
+ *
73
+ * Deliberately not a run-length scan. `plan()` reorders the eligible list — its
74
+ * cooling partition emits every healthy candidate before every cooling one — so
75
+ * one member's accounts can be non-contiguous and even interleaved with another
76
+ * member's. A run-length scan then reports more "members" than actually exist:
77
+ * `[cb, wb, cb, wb]` counts four, which pushed each member's skip threshold to
78
+ * `cap * 4` — well past the whole budget — and silently disabled the cap on
79
+ * exactly the degraded lists it exists for.
80
+ *
81
+ * Keyed off the same string the member count is derived from, so the two can
82
+ * never disagree.
83
+ */
84
+ function buildMemberOrdinals(
85
+ candidates: readonly RouteCandidate[],
86
+ ): ReadonlyMap<string, number> {
87
+ const ordinals = new Map<string, number>();
88
+ for (const candidate of candidates) {
89
+ const key = comboMemberKey(candidate);
90
+ if (!ordinals.has(key)) ordinals.set(key, ordinals.size);
91
+ }
92
+ return ordinals;
93
+ }
94
+
95
+ /** Route-specific preconditions resolved for one candidate before its leases are taken. */
96
+ interface PreparedAttempt<TAdapter> {
97
+ readonly credential: ResolvedCredential;
98
+ readonly adapter: TAdapter;
99
+ }
100
+
101
+ /** One candidate attempt with its admission lease, pool slot, and reservation held. */
102
+ interface AttemptContext<TAdapter> {
103
+ readonly candidate: RouteCandidate;
104
+ readonly credential: ResolvedCredential;
105
+ readonly adapter: TAdapter;
106
+ readonly leases: AttemptLeases;
107
+ /** Zero-based candidate index; also this attempt's failover count. */
108
+ readonly attemptIndex: number;
109
+ readonly providerCapture: ProviderExchangeCapture;
110
+ /**
111
+ * Ownership of this attempt's account/network-pool release, when the route
112
+ * acquired a pool slot. A provider session that outlives this request (a
113
+ * Cursor Run held open for the client's tool result) calls
114
+ * `transferToProviderSession()` and takes it over; otherwise the request
115
+ * releases it through `releaseByResponse()`.
116
+ */
117
+ readonly leaseOwnership?: AttemptLeaseOwnership | undefined;
118
+ /**
119
+ * Hands lease ownership to the value this attempt returns (a streaming
120
+ * response releases its own lease on stream completion/cancel). Without it
121
+ * the loop releases the leases in `finally`.
122
+ */
123
+ retainLeases(): void;
124
+ }
125
+
126
+ /** Collaborators shared by every dispatch route. */
127
+ interface AttemptLoopDeps {
128
+ readonly db: CartethyiaDatabase;
129
+ readonly poolSelector?: NetworkPoolSelector;
130
+ readonly networkBindingFactory?: ValidatedNetworkBindingFactory;
131
+ readonly snapshotService?: RouteSnapshotService;
132
+ readonly telemetryBuffer?: TelemetryBatchBuffer;
133
+ readonly resolveOAuthRefresher?: (providerId: string) => Promise<OAuthTokenRefresher | undefined>;
134
+ readonly oauthRefreshService?: OAuthRefreshService;
135
+ }
136
+
137
+ interface AttemptLoopInput<TResult, TAdapter> {
138
+ readonly state: ProxyRequestState;
139
+ readonly deps: AttemptLoopDeps;
140
+ readonly leaseSource: AttemptLeaseSource;
141
+ readonly candidates: readonly RouteCandidate[];
142
+ readonly tenantId: string | null;
143
+ /** Strict pool selection rejects direct egress when every pool is at capacity. */
144
+ readonly strictPoolSelection: boolean;
145
+ /** BYOK host for a candidate, when this route validates network bindings. */
146
+ readonly resolveHost?: (candidate: RouteCandidate) => ByokUpstreamHost | undefined;
147
+ readonly prepare: (candidate: RouteCandidate) => Promise<PreparedAttempt<TAdapter>>;
148
+ readonly attempt: (context: AttemptContext<TAdapter>) => Promise<TResult>;
149
+ /** Raised when the candidate list is empty and no attempt ever ran. */
150
+ readonly exhaustedError: GatewayError;
151
+ }
152
+
153
+ /**
154
+ * Shared per-candidate attempt lifecycle for the canonical proxy routes and
155
+ * the native Responses-compact route: admission lease → pool slot → routing
156
+ * reservation, the route-specific upstream operation, then one
157
+ * `completeAttempt` for the attempt's accounting. Retry, backoff,
158
+ * pool-cooldown flagging, and reactive OAuth refresh are one policy here, so
159
+ * the two routes cannot drift.
160
+ */
161
+ export async function runAttemptLoop<TResult, TAdapter>(
162
+ input: AttemptLoopInput<TResult, TAdapter>,
163
+ ): Promise<TResult> {
164
+ const { state, deps, leaseSource } = input;
165
+ const candidates = [...input.candidates];
166
+ let lastError: unknown = input.exhaustedError;
167
+ const refreshedCandidates = new Set<string>();
168
+ const failedCandidates = new Set<string>();
169
+ // Every attempt owns a fresh capture sink, but only the terminal attempt's
170
+ // sink reaches the payload store. When the terminal attempt fails before it
171
+ // fires an outbound request (lease/acquire/prepare threw, or failover
172
+ // exhausted), its sink is empty and the drawer reads "—" for a request that
173
+ // DID reach an upstream on an earlier attempt. Keep the fired exchanges so
174
+ // the terminal completion can fall back to the last one that has a request.
175
+ const firedCaptures: ProviderExchangeCapture[] = [];
176
+ const maxAttempts = resolveRouteMaxAttempts();
177
+ // One combo member must not be able to spend the whole budget.
178
+ //
179
+ // A combo's member list is flattened into this candidate list, so the first
180
+ // member's accounts occupy its head. With one member holding more accounts
181
+ // than `maxAttempts` (default 8), every attempt was spent inside that member
182
+ // and a later, perfectly healthy member was never dialed — a `deepseek` combo
183
+ // of [cb, wb] stayed stuck on cb while wb sat idle, because cb's twelve
184
+ // accounts exhausted all eight attempts before the list reached wb.
185
+ //
186
+ // Each member gets an equal share of the budget, counted **per member** — not
187
+ // as a cumulative watermark over total attempts, or one member that over-runs
188
+ // silently steals the tail's share. The share is a ceiling, not a reservation:
189
+ // a member that used less than its share simply never reaches it, and the
190
+ // unused budget stays available to the members behind it.
191
+ const memberOrdinals = buildMemberOrdinals(candidates);
192
+ const memberCount = memberOrdinals.size;
193
+ const perMemberCap =
194
+ memberCount > 1 ? Math.max(1, Math.ceil(maxAttempts / memberCount)) : maxAttempts;
195
+ const attemptsByMember = new Map<string, number>();
196
+ // The budget itself is owned by failure-policy, so this loop, the
197
+ // empty-stream replay, and the Codex retry budget cannot drift apart.
198
+ let index = 0;
199
+ let attempts = 0;
200
+ while (index < candidates.length && attempts < maxAttempts) {
201
+ const candidate = candidates[index];
202
+ if (!candidate) break;
203
+ const memberKey = comboMemberKey(candidate);
204
+ // A member that has spent its share is skipped: the loop advances past the
205
+ // rest of its accounts to the next member instead of burning the budget
206
+ // they would have consumed.
207
+ if (
208
+ memberCount > 1 &&
209
+ (attemptsByMember.get(memberKey) ?? 0) >= perMemberCap
210
+ ) {
211
+ index += 1;
212
+ continue;
213
+ }
214
+ // Do not start an attempt that cannot finish. The measured pre-dispatch
215
+ // cost of the previous attempt (`resolveMs` ran 60s+ on a slow upstream)
216
+ // is what a follow-on attempt has to spend again before the upstream is
217
+ // even called, so a retry that begins with less than that left is a retry
218
+ // the deadline will kill mid-flight. Stopping here surfaces the first
219
+ // attempt's own error instead of a second one's timeout.
220
+ if (attempts > 0) {
221
+ const remainingMs = state.deadlineMs - Date.now();
222
+ if (remainingMs < MIN_RETRY_BUDGET_MS) {
223
+ if (lastError !== undefined && !state.completed) {
224
+ const terminal = classifyTerminalOutcome(lastError, state.abortController.signal);
225
+ const lastFired = [...firedCaptures]
226
+ .reverse()
227
+ .find((capture) => capture.request !== null);
228
+ await completeAttempt(state, {
229
+ status: terminal.status,
230
+ providerId: candidate.provider_id,
231
+ ...(candidate.provider_account_id
232
+ ? { accountId: candidate.provider_account_id }
233
+ : {}),
234
+ ...(candidate.provider_account_label
235
+ ? { accountLabel: candidate.provider_account_label }
236
+ : {}),
237
+ errorCategory: terminal.errorCategory,
238
+ errorOrigin: terminal.errorOrigin,
239
+ modelId: candidate.model_id,
240
+ error: lastError,
241
+ terminal: true,
242
+ tenantId: input.tenantId,
243
+ ingressBody: state.ingressBody,
244
+ responseBody: null,
245
+ ...(terminal.status !== "cancelled"
246
+ ? { clientResponseText: errorClientResponseBody(lastError) }
247
+ : {}),
248
+ // Same fallback the terminal catch uses: a request that did fire
249
+ // upstream keeps that exchange in Request Detail.
250
+ ...(lastFired ? { providerCapture: lastFired } : {}),
251
+ db: deps.db,
252
+ ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
253
+ ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
254
+ });
255
+ metrics.proxy_requests_total.inc(1, {
256
+ status: terminal.status === "cancelled" ? "cancelled" : "failed",
257
+ });
258
+ }
259
+ throw lastError;
260
+ }
261
+ }
262
+ let lease: AdmissionLease | undefined;
263
+ let reservation: Reservation | undefined;
264
+ let proxySlot: { poolId: string; release: () => void } | undefined;
265
+ let networkPoolId: string | undefined;
266
+ let leaseOwnership: AttemptLeaseOwnership | undefined;
267
+ let bindingEstablished = false;
268
+ let leasesRetained = false;
269
+ let retryDelayMs = 0;
270
+ const providerCapture: ProviderExchangeCapture = {
271
+ request: null,
272
+ response: Promise.resolve(null),
273
+ };
274
+ try {
275
+ if (state.abortController.signal.aborted)
276
+ throw new GatewayError("transport_closed", 499, "request was cancelled");
277
+ const { credential, adapter } = await input.prepare(candidate);
278
+ const host = input.resolveHost?.(candidate);
279
+ // Single attempt-lease manager owns acquisition order (admission →
280
+ // pool slot → reservation) with reverse-order unwind. Assigned only on
281
+ // success, so a mid-acquire throw leaves these undefined for `finally`.
282
+ const attemptLeases = await acquireAttemptLeases(leaseSource, candidate, {
283
+ signal: state.abortController.signal,
284
+ ...(deps.poolSelector ? { poolSelector: deps.poolSelector } : {}),
285
+ ...(deps.networkBindingFactory ? { networkBindingFactory: deps.networkBindingFactory } : {}),
286
+ ...(host ? { host } : {}),
287
+ strictPoolSelection: input.strictPoolSelection,
288
+ });
289
+ lease = attemptLeases.lease;
290
+ reservation = attemptLeases.reservation;
291
+ proxySlot = attemptLeases.proxySlot;
292
+ networkPoolId = attemptLeases.networkPoolId;
293
+ leaseOwnership = attemptLeases.ownership;
294
+ // Downstream error paths gate `commitUsage(estimated)` on this flag so
295
+ // the estimate is only charged once the request truly consumed
296
+ // upstream resources; the lease and reservation are held from here on.
297
+ bindingEstablished = true;
298
+ state.startProviderFlight();
299
+ return await input.attempt({
300
+ candidate,
301
+ credential,
302
+ adapter,
303
+ leases: attemptLeases,
304
+ attemptIndex: index,
305
+ providerCapture,
306
+ ...(leaseOwnership === undefined ? {} : { leaseOwnership }),
307
+ retainLeases: () => {
308
+ leasesRetained = true;
309
+ },
310
+ });
311
+ } catch (error) {
312
+ // A drain aborts the controller, so a mid-flight attempt unwinds here as
313
+ // an abort. Surface the typed shutdown error (503 shutting_down /
314
+ // restart_for_update) instead of an opaque `transport_closed`, so the
315
+ // client's JSON error — not just the SSE path — names the real cause.
316
+ const drain = drainAbortReason(state.abortController.signal.reason);
317
+ if (drain !== undefined) error = drain;
318
+ lastError = error;
319
+ // A drain is a *server* close, not a client cancel: it must be recorded
320
+ // as a failure with the shutdown code, and it is never retryable.
321
+ // Status, category, and origin come from one classifier so they cannot
322
+ // disagree and mark a `cancelled` row `transport_unavailable`/502.
323
+ const terminal = classifyTerminalOutcome(error, state.abortController.signal);
324
+ const cancelled = terminal.status === "cancelled";
325
+ // A failure the *pool* caused retires that pool, not the candidate: the
326
+ // next attempt keeps the same provider/model/account and selects a
327
+ // different egress. That is a genuinely new attempt even though the
328
+ // candidate index does not move, so it must not be judged terminal by
329
+ // "this was the last candidate" — that rule is about candidates, and
330
+ // opencode has exactly one (it is credential-less, so the plan holds a
331
+ // single candidate carrying every pool). Judging it there is what made a
332
+ // refused egress end the request instead of failing over to a healthy
333
+ // one.
334
+ const rateLimitedPool = !cancelled && shouldCooldownPool(error, candidate.provider_id);
335
+ // A free-tier refusal is only ever this provider's: the guard is gated on
336
+ // `ipScopedRateLimit` in the provider metadata, so a 403 from any other
337
+ // upstream still means what it always did and never retires an egress.
338
+ const blockedPool = !cancelled && isFreeTierIpBlock(error, candidate.provider_id);
339
+ const poolScopedFailure =
340
+ (rateLimitedPool || blockedPool) &&
341
+ networkPoolId !== undefined &&
342
+ deps.poolSelector !== undefined;
343
+ // Flag before the decision below, so the retry that follows already sees
344
+ // the pool excluded. Both flag helpers write the local entry
345
+ // synchronously, so the ordering holds even though the Redis mirror is
346
+ // fire-and-forget.
347
+ if (networkPoolId && deps.poolSelector && error instanceof GatewayError) {
348
+ if (rateLimitedPool) {
349
+ flagPoolCooldown(deps.poolSelector, deps.db, networkPoolId, candidate.provider_id, error);
350
+ } else if (blockedPool) {
351
+ // The address itself is refused, not the request: retire the egress
352
+ // so the next attempt leaves by a different one instead of retrying
353
+ // a proxy the upstream has already flagged.
354
+ flagPoolBlocked(deps.poolSelector, deps.db, networkPoolId, candidate.provider_id, error);
355
+ }
356
+ }
357
+ const terminalAttempt =
358
+ cancelled ||
359
+ !isRetryableFailure(error) ||
360
+ (index === candidates.length - 1 && !poolScopedFailure);
361
+ if (providerCapture.request !== null) firedCaptures.push(providerCapture);
362
+ // The payload store only keeps the terminal attempt's exchange. When the
363
+ // terminal attempt never fired (failover walked past the attempt that
364
+ // did, or prepare/lease threw first), fall back to the last fired
365
+ // exchange so Request Detail still shows the translated provider
366
+ // request instead of "—" for a request that reached an upstream.
367
+ const terminalCapture =
368
+ providerCapture.request !== null
369
+ ? providerCapture
370
+ : [...firedCaptures].reverse().find((capture) => capture.request !== null) ??
371
+ providerCapture;
372
+ await completeAttempt(state, {
373
+ status: terminal.status,
374
+ providerId: candidate.provider_id,
375
+ ...(candidate.provider_account_id ? { accountId: candidate.provider_account_id } : {}),
376
+ ...(candidate.provider_account_label ? { accountLabel: candidate.provider_account_label } : {}),
377
+ errorCategory: terminal.errorCategory,
378
+ errorOrigin: terminal.errorOrigin,
379
+ modelId: candidate.model_id,
380
+ ...(networkPoolId ? { networkPoolId } : {}),
381
+ error,
382
+ ...(lease ? { lease } : {}),
383
+ // A failed attempt may have sent bytes upstream, so it is not free —
384
+ // but it must not be charged the full input estimate plus a full
385
+ // output reserve either, because a request that walks N candidates
386
+ // would then leave N estimates on the counters for one client request.
387
+ // Commit the input estimate only: nothing was generated, and the
388
+ // `finally` release then refunds the (uncommitted) output reserve.
389
+ ...(!cancelled && bindingEstablished
390
+ ? {
391
+ commitUsage: repriceUsage(
392
+ estimatedUsage(leaseSource.estimatedInputTokens, 0),
393
+ candidate.provider_id,
394
+ candidate.model_id,
395
+ ),
396
+ }
397
+ : {}),
398
+ terminal: terminalAttempt,
399
+ tenantId: input.tenantId,
400
+ ingressBody: state.ingressBody,
401
+ responseBody: null,
402
+ // The client still receives the public error envelope on the wire —
403
+ // store it so Request Detail's Client Response panel traces failures
404
+ // exactly as the client saw them. Cancelled requests have no client
405
+ // response (the caller is gone).
406
+ ...(terminalAttempt && !cancelled
407
+ ? { clientResponseText: errorClientResponseBody(error) }
408
+ : {}),
409
+ providerCapture: terminalCapture,
410
+ db: deps.db,
411
+ ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
412
+ ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
413
+ });
414
+ // A 403 can be a deterministic provider policy rejection, not an
415
+ // invalid OAuth credential. Only the evidence-based classifier above
416
+ // may trigger a refresh.
417
+ const candidateKey = `${candidate.provider_id}:${candidate.provider_account_id ?? "global"}:${candidate.model_id}:${candidate.endpoint}`;
418
+ const authInvalidated = isOAuthCredentialInvalidated(error);
419
+ if (
420
+ !cancelled &&
421
+ !refreshedCandidates.has(candidateKey) &&
422
+ authInvalidated &&
423
+ candidate.provider_account_id &&
424
+ deps.oauthRefreshService &&
425
+ deps.resolveOAuthRefresher
426
+ ) {
427
+ const refresher = await deps.resolveOAuthRefresher(candidate.provider_id);
428
+ if (refresher) {
429
+ const fresh = await deps.oauthRefreshService.ensureFreshAccessToken(
430
+ candidate.provider_account_id,
431
+ refresher,
432
+ { force: true },
433
+ );
434
+ if (fresh) {
435
+ refreshedCandidates.add(candidateKey);
436
+ continue;
437
+ }
438
+ }
439
+ }
440
+ if (terminalAttempt) {
441
+ metrics.proxy_requests_total.inc(1, { status: cancelled ? "cancelled" : "failed" });
442
+ throw error;
443
+ }
444
+ // A failed combo member stays flagged for this request. Move it behind
445
+ // every candidate that has not failed yet, so the next attempt cannot
446
+ // stick to it while a sibling remains. It becomes eligible again only
447
+ // after that sibling list is exhausted.
448
+ //
449
+ // A pool-scoped failure is exempt: the candidate never got its turn, so
450
+ // marking it failed would spend its only remaining chance on the pool
451
+ // that just refused it. The candidate stays at the head, and the next
452
+ // attempt selects a different pool for it. The attempt budget still
453
+ // bounds the loop, and once every pool is retired the selection itself
454
+ // fails with `proxy_pool_blocked`, which is terminal.
455
+ const failureKey = comboCandidateKey(candidate);
456
+ if (poolScopedFailure) {
457
+ // Deliberately no index movement and no `failedCandidates` entry.
458
+ } else if (!failedCandidates.has(failureKey)) {
459
+ failedCandidates.add(failureKey);
460
+ candidates.push(candidates.splice(index, 1)[0]!);
461
+ } else {
462
+ index += 1;
463
+ }
464
+ retryDelayMs = fallbackRetryDelayMs(attempts);
465
+ } finally {
466
+ if (!leasesRetained)
467
+ await releaseAttemptLeases(
468
+ { lease, reservation, proxySlot, ...(leaseOwnership === undefined ? {} : { ownership: leaseOwnership }) },
469
+ leaseSource.routingEngine,
470
+ );
471
+ }
472
+ attempts += 1;
473
+ // Charged to the member that spent it, so one member's own attempts are
474
+ // what exhaust its share — not the running total across every member.
475
+ attemptsByMember.set(memberKey, (attemptsByMember.get(memberKey) ?? 0) + 1);
476
+ if (retryDelayMs > 0) {
477
+ try {
478
+ await sleep(retryDelayMs, state.abortController.signal);
479
+ } catch {
480
+ // Fail with the last attempt's error so the client sees the real cause,
481
+ // counted once: attempts already recorded their telemetry, but escaping
482
+ // as the sleep's own error would leave `proxy_requests_total` missing
483
+ // the request.
484
+ metrics.proxy_requests_total.inc(1, { status: "cancelled" });
485
+ throw lastError;
486
+ }
487
+ }
488
+ }
489
+ // The loop can fall out with a retryable error still in hand (every attempt
490
+ // was retryable, so the terminal branch in the catch never fired). Throwing
491
+ // here instead of recording it would leave no telemetry row, no payload
492
+ // capture, and `state.completed` clear — so a request that genuinely
493
+ // exhausted its attempts would be invisible in Request Detail. Record it as
494
+ // terminal here.
495
+ if (!state.completed) {
496
+ const terminal = classifyTerminalOutcome(lastError, state.abortController.signal);
497
+ const lastCandidate = candidates[Math.min(index, candidates.length - 1)];
498
+ const terminalCapture = [...firedCaptures]
499
+ .reverse()
500
+ .find((capture) => capture.request !== null);
501
+ await completeAttempt(state, {
502
+ status: terminal.status,
503
+ providerId: lastCandidate?.provider_id ?? "",
504
+ ...(lastCandidate?.provider_account_id
505
+ ? { accountId: lastCandidate.provider_account_id }
506
+ : {}),
507
+ ...(lastCandidate?.provider_account_label
508
+ ? { accountLabel: lastCandidate.provider_account_label }
509
+ : {}),
510
+ errorCategory: terminal.errorCategory,
511
+ errorOrigin: terminal.errorOrigin,
512
+ ...(lastCandidate?.model_id === undefined ? {} : { modelId: lastCandidate.model_id }),
513
+ error: lastError,
514
+ terminal: true,
515
+ tenantId: input.tenantId,
516
+ ingressBody: state.ingressBody,
517
+ responseBody: null,
518
+ // The client receives the public error envelope on the wire — store it
519
+ // so the Client Response panel traces the failure as the client saw it.
520
+ ...(terminal.status === "cancelled"
521
+ ? {}
522
+ : { clientResponseText: errorClientResponseBody(lastError) }),
523
+ ...(terminalCapture === undefined ? {} : { providerCapture: terminalCapture }),
524
+ db: deps.db,
525
+ ...(deps.telemetryBuffer ? { telemetryBuffer: deps.telemetryBuffer } : {}),
526
+ ...(deps.snapshotService ? { snapshotService: deps.snapshotService } : {}),
527
+ });
528
+ }
529
+ metrics.proxy_requests_total.inc(1, { status: "failed" });
530
+ throw lastError;
531
+ }