cartethyia 2.5.4 → 2.5.6

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-CADDq4IU.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-CADDq4IU.js.gz +0 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js +1 -0
  6. package/dist/dashboard/assets/CliToolDetailPage-_z1hID7E.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BV6pxVQo.js → CliToolsPage-DuO1Z7dq.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-DuO1Z7dq.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-DNEPi3xU.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-CW-wPmJ6.js → ConsoleLogPage-BVdpfiz1.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-BVdpfiz1.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-0p8uX7vB.js → CustomizationPage-BR5HHvbi.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-BR5HHvbi.js.gz +0 -0
  15. package/dist/dashboard/assets/{HealthEventsModal-BjqSMfko.js → HealthEventsModal-mUfcknqm.js} +1 -1
  16. package/dist/dashboard/assets/HealthEventsModal-mUfcknqm.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-BvzCx8Kr.js → LoginPage-V5faV9V2.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-V5faV9V2.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-BDu20Jdx.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-BDu20Jdx.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-Dllr_aFO.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-Js9jUPSb.js → ProvidersPage-uPiGVTTK.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-uPiGVTTK.js.gz +0 -0
  27. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js +7 -0
  28. package/dist/dashboard/assets/ProxyPage-BaUkO-4O.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-o0IFAsFb.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-CYWqbXjp.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-DDbMQsoo.js → SetupPage-UB690SE2.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-UB690SE2.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-CB3Z7VTf.js → SortableList-BLujdX7N.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-BLujdX7N.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-D95UCPHI.js → StudioPage-D5C2raAc.js} +3 -3
  38. package/dist/dashboard/assets/StudioPage-D5C2raAc.js.gz +0 -0
  39. package/dist/dashboard/assets/UsagePage-8uIcxa1W.js +56 -0
  40. package/dist/dashboard/assets/UsagePage-8uIcxa1W.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-CTP8AyiS.js +1 -0
  66. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-DxBHnauy.js +4 -0
  68. package/dist/dashboard/assets/entry-DxBHnauy.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-CmxZr14F.js} +2 -2
  72. package/dist/dashboard/assets/index-CmxZr14F.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-BYR_5K2c.js} +1 -1
  94. package/dist/dashboard/assets/select-BYR_5K2c.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 +96 -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,1523 +1,1528 @@
1
- import { healthEventBuffer } from "../../persistence/health-event-flush";
2
- // Account health: error classification, failure/success recording, recovery and cooldown sweeps.
3
- import { and, count, desc, eq, inArray, isNotNull, lte, or, sql } from "drizzle-orm";
4
- import { affectedRows, pruneExpiredCooldowns } from "../../persistence/dialect";
5
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
6
- import { isRecord } from "../../protocol/codecs";
7
- import { GatewayError } from "../../protocol/gateway-error";
8
- import { BUDDY_PROVIDER_IDS } from "../provider-metadata";
9
- import { healthEvents, providerAccounts } from "../../persistence/schema";
10
-
11
- import { parseProviderResetDuration, parseUpstreamBackoff } from "../../transport/failure-policy";
12
- import {
13
- resolveAccountAuthRejectedCooldownMs,
14
- resolveAccountCooldownEscalationMaxMs,
15
- resolveAccountDisabledRecoveryMs,
16
- resolveAccountModelCapacityCooldownMs,
17
- resolveAccountPolicyBlockCooldownMs,
18
- resolveAccountQuotaCooldownMs,
19
- resolveAccountRateLimitCooldownMs,
20
- resolveAccountTransientCooldownMs,
21
- resolveAccountUnclassifiedCooldownMs,
22
- } from "../../config/cooldowns";
23
- // ===== health/constants.ts =====
24
- /**
25
- * Account status is authoritative for routing: `disabled` is a hard exclusion
26
- * until an operator restores it, while `cooldown` is a *soft* one — the account
27
- * is deprioritized behind every healthy sibling and reached only when nothing
28
- * better is left, so a cooling credential still serves rather than parking the
29
- * model entirely.
30
- *
31
- * Every delay below is a *fallback*: an upstream `Retry-After`/reset header or
32
- * a duration stated in the provider message always wins. They are read through
33
- * `src/config/cooldowns.ts` so an operator can tune the health machine without a code
34
- * change (`CARTETHYIA_ACCOUNT_*_COOLDOWN_MS`).
35
- */
36
- const DEFAULT_RATE_LIMIT_COOLDOWN_MS = resolveAccountRateLimitCooldownMs;
37
- // Fallback when the error states no usable duration: deliberately short (1h,
38
- // not 24h) — quota state is re-probed on the next failure, so an unknown
39
- // reset cadence self-corrects instead of parking the account for a day.
40
- const DEFAULT_QUOTA_COOLDOWN_MS = resolveAccountQuotaCooldownMs;
41
- const MODEL_CAPACITY_COOLDOWN_MS = resolveAccountModelCapacityCooldownMs;
42
- const TRANSIENT_ERROR_COOLDOWN_MS = resolveAccountTransientCooldownMs;
43
- /**
44
- * Accounts one cooldown-recovery pass brings back. The sweep ticks every 30s,
45
- * so a larger backlog drains over successive passes rather than one statement
46
- * covering every expired row.
47
- */
48
- const COOLDOWN_RECOVERY_BATCH = 500;
49
- /**
50
- * Backoff for a failure no rule matched. Short on purpose: an unclassified
51
- * error is more likely a new upstream shape than a broken account, so the
52
- * account returns to rotation quickly.
53
- */
54
- const UNCLASSIFIED_COOLDOWN_MS = resolveAccountUnclassifiedCooldownMs;
55
- /**
56
- * xAI Grok Build's free tier resets on a rolling 24-hour window, so its
57
- * exhaustion is a full-day quota cooldown — never the generic 1h fallback,
58
- * which let the account re-enter rotation inside the window.
59
- */
60
- const GROK_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000;
61
- /**
62
- * The buddy family's policy/channel block (`11140`, `11128`) is a *base* backoff,
63
- * not a flat penalty — see {@link resolveAccountPolicyBlockCooldownMs}. It used
64
- * to be two constants, 24h for the content block and 6h for the channel one,
65
- * both applied on the first observation. That was wrong in the direction that
66
- * costs most: `11140` is also what a wrong desktop `User-Agent` brand answers
67
- * (`workbuddy/shared.ts`), so after we fixed a header regression every buddy
68
- * account stayed dead for up to a further day, and one prompt that failed a
69
- * safety review cost a whole account a day. As a base it escalates through the
70
- * consecutive-failure ladder, which tells the two causes apart by what they do
71
- * next — a genuinely blocked account is refused again immediately and parks
72
- * itself within minutes.
73
- */
74
- /**
75
- * Backoff for a model the upstream says does not exist for this account
76
- * (Cursor answers Connect `not_found` for a model the credential's plan does
77
- * not reach). Long, because the verdict is a property of the plan and will not
78
- * change on the next request — but bounded, so a plan upgrade is picked up
79
- * without an operator clearing it by hand.
80
- */
81
- const MODEL_UNREACHABLE_COOLDOWN_MS = 6 * 60 * 60 * 1000;
82
- /**
83
- * Backoff for a model the upstream does not serve at all (buddy family
84
- * business code `11102` — `service info not found` — or a `model ... not
85
- * found/supported` message). Mirrors the reference gateways'
86
- * `MODEL_SITE_BLOCK_S` → `MODEL_SITE_BLOCK_MAX_S`.
87
- *
88
- * Escalating rather than flat: the pair is re-dialled after the first window
89
- * and answers the same thing, so a flat 6h let a permanently unsupported model
90
- * consume a request every six hours forever. The ladder tops out at a day,
91
- * which is long enough to stop the redial and short enough that a backend that
92
- * starts serving the model is picked up without an operator clearing it.
93
- *
94
- * This is the one place the references' *strict* behaviour is adopted as a
95
- * backoff rather than as a 400: nothing currently succeeds through it, so no
96
- * client is broken by it.
97
- */
98
- const UNSUPPORTED_MODEL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
99
- const UNSUPPORTED_MODEL_COOLDOWN_MAX_MS = 24 * 60 * 60 * 1000;
100
- /** How many doublings an unsupported-model cooldown may take before the cap. */
101
- const UNSUPPORTED_MODEL_MAX_LEVEL = 8;
102
-
103
- /**
104
- * The cooldown for the *nth* consecutive unsupported-model report (`hits` is
105
- * the new total, so the first report is `hits === 1`).
106
- *
107
- * Exported because the writer, not the classifier, is the only place that
108
- * reads the authoritative hit count — the classifier sees only the count the
109
- * caller supplied, which is absent on the first report of a pair.
110
- */
111
- export function unsupportedModelCooldownMs(hits: number): number {
112
- const level = Math.min(Math.max(0, Math.round(hits) - 1), UNSUPPORTED_MODEL_MAX_LEVEL);
113
- return Math.min(
114
- UNSUPPORTED_MODEL_COOLDOWN_MS * 2 ** level,
115
- UNSUPPORTED_MODEL_COOLDOWN_MAX_MS,
116
- );
117
- }
118
-
119
- /**
120
- * The wording an upstream uses for "we do not serve this model at all", as
121
- * opposed to "this account cannot reach it" (403 entitlement, above) or "this
122
- * route does not exist" (404 `upstream_not_found`).
123
- */
124
- const UNSUPPORTED_MODEL_MESSAGE =
125
- /service info not found|model\s.{0,80}?\bnot\s+(?:found|supported)\b/;
126
-
127
- /**
128
- * True when the evidence says the upstream does not serve the model.
129
- *
130
- * Narrow on purpose, and narrower than `model_unreachable`: only a 400/404
131
- * carrying the buddy family's `11102` or one of the two phrasings above. A
132
- * 4xx that merely *mentions* a model is far more often a payload the caller
133
- * got wrong, and cooling a pair for six hours over that takes a working model
134
- * out of rotation for a request bug.
135
- */
136
- function isUnsupportedModelEvidence(
137
- statusCode: number | null,
138
- providerCode: string,
139
- lower: string,
140
- ): boolean {
141
- if (statusCode !== 400 && statusCode !== 404) return false;
142
- return providerCode === "11102" || UNSUPPORTED_MODEL_MESSAGE.test(lower);
143
- }
144
-
145
- // ===== health/account-recorder.ts =====
146
- export interface AccountHealthEventRecord {
147
- readonly id: string;
148
- readonly accountId: string;
149
- readonly fromStatus: string | null;
150
- readonly toStatus: string;
151
- readonly reason: string | null;
152
- readonly errorCategory: string | null;
153
- readonly modelId: string | null;
154
- readonly createdAt: string;
155
- }
156
-
157
- export type AccountErrorCategory =
158
- | "quota_exhausted"
159
- | "rate_limit_transient"
160
- | "model_capacity"
161
- | "model_unreachable"
162
- | "auth_invalidated"
163
- | "policy_blocked"
164
- | "server_error"
165
- | "timeout"
166
- | "unknown";
167
- export type AccountFailureOrigin = "cartethyia" | "upstream" | "network";
168
- export type AccountFailureScope =
169
- | "account"
170
- | "provider"
171
- | "model"
172
- | "pool"
173
- | "tenant"
174
- | "request"
175
- | "network"
176
- | "unknown";
177
-
178
- export interface AccountFailureEvidence {
179
- readonly origin?: AccountFailureOrigin;
180
- readonly scope?: AccountFailureScope;
181
- readonly credentialKind?: string;
182
- /**
183
- * The account's credential is a static bearer token used as issued and never
184
- * refreshed. An auth rejection against a static token is not a refreshable
185
- * fault: refreshing cannot help, and disabling the account discards a token
186
- * that may still be valid for other requests. Such a failure cools the
187
- * account down instead of parking it.
188
- */
189
- readonly staticToken?: boolean;
190
- readonly providerCode?: string;
191
- readonly credentialEvidence?: boolean;
192
- readonly retryAfterMs?: number;
193
- readonly statusCode?: number | null;
194
- readonly headers?: Headers | Record<string, string> | null;
195
- readonly modelId?: string;
196
- /**
197
- * Provider that produced the failure. Used only for the one
198
- * provider-family-specific policy rule (the buddy family's `11140`
199
- * content-policy block), never to broaden what disables an account.
200
- */
201
- readonly providerId?: string;
202
- /**
203
- * How many times this (account, model) pair has already been reported
204
- * unsupported, when the caller has read them. Best-effort: the classifier
205
- * states the base window without it, and the writer recomputes against the
206
- * count it reads itself.
207
- */
208
- readonly modelBlockHits?: number;
209
- }
210
-
211
- export interface AccountErrorClassification {
212
- readonly category: AccountErrorCategory;
213
- readonly status: "cooldown" | "disabled";
214
- readonly cooldownMs: number;
215
- readonly reason: string;
216
- readonly retryAt: Date | null;
217
- readonly mutatesAccount: boolean;
218
- /**
219
- * True when this write changed a field the routing snapshot actually reads.
220
- *
221
- * The snapshot projects `provider_accounts.status` and
222
- * `.cooldownUntil` and nothing else (`route-catalog.ts:286-287`), so only a
223
- * write that moves one of those has to drop it. Everything else a failure
224
- * persists — the failure streak, the error text, the category, a
225
- * model-scoped cooldown — is invisible to routing, and invalidating for it
226
- * forced the next request to rebuild an 11-SELECT snapshot for no reason.
227
- *
228
- * That made a failure wave self-sustaining: N failed attempts produced N
229
- * invalidations, and every following request paid the full rebuild.
230
- */
231
- readonly routesVisible: boolean;
232
- /**
233
- * True when `cooldownMs` came from the provider's own statement — a parsed
234
- * `Retry-After`-family header, or a reset quoted in its message — rather than
235
- * from one of this module's fallback constants.
236
- *
237
- * A property of the *evidence*, not of the category, so it is the same for
238
- * every branch of one classification. Only {@link escalateFallbackCooldown}
239
- * reads it: a provider-stated window is the truth and is never multiplied.
240
- */
241
- readonly providerStated?: boolean;
242
- /**
243
- * True when the upstream reported the model as one it does not serve (the
244
- * buddy family's `11102`), rather than one this account merely cannot reach.
245
- *
246
- * A property of the classification, because both verdicts land in the same
247
- * `model_unreachable` category and the writer cannot otherwise tell them
248
- * apart — it needs to know whether to count the hit and escalate the window.
249
- */
250
- readonly modelUnsupported?: boolean;
251
- }
252
-
253
- /**
254
- * How many doublings a fallback cooldown may take before the cap takes over.
255
- *
256
- * Four is enough to reach the cap from every fallback default (the longest is
257
- * the 1h quota fallback, which reaches it on the first doubling) while keeping
258
- * the ladder short enough to reason about.
259
- */
260
- const COOLDOWN_ESCALATION_MAX_LEVEL = 4;
261
-
262
- /**
263
- * Escalates a *fallback* cooldown for an account that keeps failing.
264
- *
265
- * `consecutiveFailures` has been incremented on every account failure since the
266
- * health machine was written, but nothing ever read it — so a broken upstream
267
- * was re-dialled once a minute forever, and an account whose quota had an
268
- * unstated reset came straight back into rotation every time its short fallback
269
- * lapsed. The count is now a backoff level: each further consecutive failure
270
- * doubles the fallback, clamped to {@link resolveAccountCooldownEscalationMaxMs}.
271
- *
272
- * Two guards keep it from overriding the provider. A duration the provider
273
- * *stated* is never multiplied — that window is the truth. And the caller writes
274
- * the result through a monotonic maximum, so a burst of concurrent in-flight
275
- * failures advances the deadline to the longest of them instead of compounding
276
- * once per failure.
277
- */
278
- function escalateFallbackCooldown(
279
- cooldownMs: number,
280
- providerStated: boolean,
281
- consecutiveFailures: number,
282
- ): number {
283
- if (providerStated || cooldownMs <= 0) return cooldownMs;
284
- const level = Math.min(Math.max(0, consecutiveFailures), COOLDOWN_ESCALATION_MAX_LEVEL);
285
- return Math.min(cooldownMs * 2 ** level, resolveAccountCooldownEscalationMaxMs());
286
- }
287
-
288
- /**
289
- * The later of a candidate deadline and the one already recorded.
290
- *
291
- * Every fallback duration is recomputed from *now* on each failure, so writing
292
- * it unconditionally let a fresh short failure *shorten* a longer window already
293
- * in force: a 60-second unclassified cooldown overwrote the provider's own
294
- * "Try again in 11h" and released the pair back into rotation inside a window
295
- * the upstream had closed, where it failed again — once a minute, forever.
296
- * A cooling deadline may only ever move later.
297
- */
298
- function latestDeadlineMs(...candidates: readonly (number | undefined | null)[]): number {
299
- let latest = 0;
300
- for (const value of candidates) {
301
- if (typeof value !== "number" || !Number.isFinite(value)) continue;
302
- if (value > latest) latest = value;
303
- }
304
- return latest;
305
- }
306
-
307
- /**
308
- * The account-wide deadline to write, or `null` for "no deadline".
309
- *
310
- * {@link latestDeadlineMs} cannot express *no* deadline — it floors at 0 — and
311
- * the account-wide write has one state that needs it. With
312
- * `CARTETHYIA_ACCOUNT_DISABLED_RECOVERY_MS=0` an automatic disable is meant to
313
- * be terminal again, and the classifier correctly reports `retryAt: null`; the
314
- * write used to derive the deadline from `Date.now() + cooldownMs` anyway,
315
- * which stamps *now*. The recovery sweep then treats that elapsed deadline as
316
- * an automatic disable awaiting recovery and returns the row to rotation on its
317
- * next tick — the escape hatch enabled exactly the behavior it documents
318
- * itself as disabling.
319
- *
320
- * A candidate only exists when there is a real window to state. Monotonicity is
321
- * preserved: a deadline already in force still wins over a new `null`.
322
- */
323
- function accountDeadlineMs(
324
- candidates: readonly (number | undefined | null)[],
325
- ): Date | null {
326
- const latest = latestDeadlineMs(...candidates);
327
- return latest > 0 ? new Date(latest) : null;
328
- }
329
-
330
-
331
- export function classifyAccountError(
332
- error: unknown,
333
- options?: AccountFailureEvidence,
334
- ): AccountErrorClassification {
335
- const errorRecord = isRecord(error) ? error : undefined;
336
- const message =
337
- error instanceof Error
338
- ? error.message
339
- : typeof error === "string"
340
- ? error
341
- : typeof errorRecord?.message === "string"
342
- ? errorRecord.message
343
- : String(error ?? "Unknown error");
344
- const lower = message.toLowerCase();
345
- const statusCode =
346
- options?.statusCode ??
347
- (typeof errorRecord?.status === "number" ? errorRecord.status : null);
348
- const origin = options?.origin ?? "cartethyia";
349
- const scope = options?.scope ?? "unknown";
350
- // Providers write the same phrase with different separators: codex reports
351
- // `token_revoked`, others `token revoked` or `token-revoked`. Folding the
352
- // separators once lets every match below see one spelling.
353
- const folded = lower.replace(/[_-]+/g, " ");
354
- // A revocation named in the message is evidence about the credential itself,
355
- // whichever scope the caller could establish. A stream error frame carries
356
- // no `credentialEvidence` detail and classifies at provider scope, so without
357
- // this the account stays in rotation: the phrase is recognised and then
358
- // discarded because nothing mutates on provider scope.
359
- const revocationNamed = folded.includes("token revoked") || folded.includes("invalidated oauth token");
360
- const accountEvidence =
361
- options?.credentialEvidence === true ||
362
- revocationNamed ||
363
- (options?.origin === "upstream" && options.scope === "account");
364
- // A named revocation mutates even at provider scope: the message is the
365
- // evidence, and provider scope is what a frame with no credential detail
366
- // classifies as. Every other signal still needs account scope.
367
- const canMutate = origin === "upstream" && (revocationNamed || (scope === "account" && accountEvidence));
368
- const providerCode = options?.providerCode?.toLowerCase() ?? "";
369
-
370
- const getHeader = (name: string): string | null => {
371
- const headers = options?.headers;
372
- if (!headers) return null;
373
- if ("get" in headers && typeof headers.get === "function") return headers.get(name);
374
- const wanted = name.toLowerCase();
375
- const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === wanted);
376
- return entry?.[1] ?? null;
377
- };
378
-
379
- const headerCooldown =
380
- options?.retryAfterMs ??
381
- parseUpstreamBackoff({ get: getHeader });
382
- const messageCooldown = parseProviderResetDuration(message);
383
- // Whether the provider itself named a window. Decided once, from the
384
- // evidence, so every branch of this classification agrees about it.
385
- const providerStated = headerCooldown !== null || messageCooldown !== null;
386
- const rawUpstreamResponse =
387
- origin === "upstream" &&
388
- error instanceof GatewayError &&
389
- typeof error.details.raw === "string"
390
- ? error.details.raw
391
- : undefined;
392
- const reason = (prefix: string): string => `${prefix}: ${message.slice(0, 160)}`;
393
- const result = (
394
- category: AccountErrorCategory,
395
- status: AccountErrorClassification["status"],
396
- cooldownMs: number,
397
- retryAt: Date | null,
398
- explanation: string,
399
- mutatesAccount = canMutate,
400
- modelUnsupported = false,
401
- ): AccountErrorClassification => ({
402
- category,
403
- status,
404
- cooldownMs,
405
- reason: origin === "upstream" ? rawUpstreamResponse ?? message.slice(0, 160) : explanation,
406
- retryAt,
407
- mutatesAccount,
408
- // Set below by the caller that knows which columns it wrote, not here:
409
- // the same classification feeds both the account-wide path (status +
410
- // cooldownUntil — routing-visible) and the model-scoped path (neither —
411
- // invisible). Defaulting true keeps existing callers conservative.
412
- routesVisible: true,
413
- providerStated,
414
- modelUnsupported,
415
- });
416
-
417
- // A price refusal is a verdict on the *request*, not on the account. inferhub
418
- // answers 402 with `no provider's ask matches your max-per-mtok bid`: the
419
- // caller's own price ceiling was below every upstream ask, which says nothing
420
- // about the credential or its balance. Classified as quota it cooled a
421
- // fully-credited account down for an hour and the operator reported it as
422
- // "blocked, unusable at all". Detected before the quota branch and recorded
423
- // without mutating the account, so only that request fails.
424
- const priceRefusal =
425
- lower.includes("max-per-mtok") ||
426
- lower.includes("no provider's ask") ||
427
- (lower.includes("ask") && lower.includes("bid"));
428
- // Deployment/proxy infrastructure errors (DEPLOYMENT_DISABLED, etc.) are
429
- // transport-level proxy pool failures, not account quota exhaustion.
430
- // Mutating the account parks it for an hour while the pool itself stays
431
- // healthy — the next request picks the same dying pool and the same error
432
- // repeats. These must not cooldown the account.
433
- const deploymentInfraError =
434
- lower.includes("deployment_disabled") ||
435
- lower.includes("deployment disabled") ||
436
- (lower.includes("sin1::") && lower.includes("deployment"));
437
- if (deploymentInfraError) {
438
- return result("server_error", "cooldown", 60_000, null, `Proxy deployment error: ${message.slice(0, 160)}`, false);
439
- }
440
- // A (CodeBuddy/WorkBuddy) *frequency* window is a verdict on one
441
- // (account, model) pair, not on the account: `{"code":"6004","msg":"usage
442
- // exceeds frequency limit ... your usage will reset at <stamp> UTC+8,
443
- // alternatively, you can switch to the other models to continue using it."}`.
444
- // The provider names the remedy itself — use a different model — which is
445
- // only a remedy if the account still serves every other model. Taken as
446
- // account-wide `quota_exhausted` it parked the whole account until the reset,
447
- // so the switch the upstream offered was refused by our own gateway and every
448
- // sibling model went dark with it; the account also read COOLDOWN /
449
- // quota_exhausted while only that pair was throttled.
450
- //
451
- // Classified `rate_limit_transient` so the writer scopes it to the model:
452
- // `recordAccountFailure` writes `modelCooldowns[modelId]` and leaves the
453
- // account's own status alone. Kept ahead of `quotaSignal`, which matches a
454
- // *broader* slice of the same wording (`usage exceeds` + `frequency limit`)
455
- // and would otherwise claim this message as an account-wide quota window.
456
- // Narrow on purpose — the extra phrase is what says the other models still
457
- // work; without it the failure stays account-wide.
458
- const modelFrequencyWindow =
459
- lower.includes("frequency limit") && lower.includes("switch to the other models");
460
- if (modelFrequencyWindow) {
461
- const cooldownMs = messageCooldown ?? headerCooldown ?? DEFAULT_QUOTA_COOLDOWN_MS();
462
- return result(
463
- "rate_limit_transient",
464
- "cooldown",
465
- cooldownMs,
466
- new Date(Date.now() + cooldownMs),
467
- reason("Provider frequency limit for this model"),
468
- // Provider-scoped evidence still has to reach the writer: a stream frame
469
- // carries no credential detail, so `canMutate` is false and without this
470
- // the pair would be retried for the whole window it was closed for.
471
- origin === "upstream" && scope === "provider",
472
- );
473
- }
474
-
475
- // Quota-shaped provider codes/messages (e.g. xAI's
476
- const quotaSignal =
477
- !priceRefusal &&
478
- !deploymentInfraError &&
479
- (statusCode === 402 ||
480
- // Qoder answers an exhausted credit balance with
481
- // `{"code":"112","message":"{\"pricingUrl\":...}"}` — the body points at
482
- // the pricing page, i.e. "buy more", which is a balance verdict, not an
483
- // auth one. It fell through to `auth_invalidated` and the console showed
484
- // "Re-login required" on an account whose credential was fine, sending
485
- // operators to re-authenticate instead of topping up.
486
- // Recognising it here keeps it out of `auth_invalidated`; the branch
487
- // below scopes the resulting cooldown to the model, because the account
488
- // keeps serving its other models.
489
- providerCode === "112" ||
490
- providerCode === "insufficient_quota" ||
491
- providerCode === "quota_exceeded" ||
492
- providerCode === "subscription:free-usage-exhausted" ||
493
- providerCode === "14018" ||
494
- // CodeBuddy/WorkBuddy rate limit: `{"code":"6004","msg":"usage exceeds
495
- // frequency limit ... your usage will reset at <stamp> UTC+8"}`. The
496
- // message names a reset, so this is a parked window, not a transient
497
- // 429 — without it the account fell through to `unknown` with
498
- // `mutates=false`, stayed in rotation, and was re-picked for every
499
- // request inside the window the provider had actually named.
500
- //
501
- // Matched on the message's own wording, not on the `6004` code: that code
502
- // is not unique to this surface (the artifact-release API reuses it), so a
503
- // code match would sweep an unrelated failure into a quota cooldown. The
504
- // phrase is the reliable key, and `usage exceeds` + `frequency limit` is
505
- // the pair this family actually prints.
506
- (lower.includes("usage exceeds") && lower.includes("frequency limit")) ||
507
- lower.includes("insufficient_quota") ||
508
- lower.includes("quota_exceeded") ||
509
- lower.includes("usage_limit_reached") ||
510
- lower.includes("usage-exhausted") ||
511
- lower.includes("usage_exhausted") ||
512
- lower.includes("usage exhausted") ||
513
- lower.includes("usage exceeds") ||
514
- lower.includes("frequency limit") ||
515
- lower.includes("free-usage") ||
516
- lower.includes("free usage") ||
517
- lower.includes("exceeded your current quota") ||
518
- // Trae answers an exhausted quota with **HTTP 502** and the body `Your
519
- // requests have exceeded the quota.` The status is what misled the
520
- // classifier: 502 read as a gateway failure, so this fell through to
521
- // `server_error` — a 30s cooldown that does not mutate the account, so
522
- // the account stayed in rotation and every other model on it kept being
523
- // invoked inside a window the provider had already closed. The body is
524
- // the verdict; the 502 is incidental. Matched on the phrase, not on the
525
- // status, which is why this sits in `quotaSignal` ahead of the 5xx arm.
526
- lower.includes("have exceeded the quota") ||
527
- lower.includes("balance exhausted") ||
528
- lower.includes("insufficient balance") ||
529
- lower.includes("out of credits") ||
530
- lower.includes("credits exhausted") ||
531
- lower.includes("spending limit") ||
532
- // Same Qoder family reached by body text when the code is not parsed out.
533
- (lower.includes("pricingurl") && lower.includes("qoder.com/pricing")));
534
- // A deterministic content-policy rejection is NOT credential invalidation.
535
- // CodeBuddy returns HTTP 403 with provider code 11140 ("request illegal",
536
- // "did not pass the safety review") — refreshing the token cannot change
537
- // that outcome and the account itself is healthy, so it must never flip the
538
- // account to auth_invalidated/disabled. Only real auth signals do.
539
- const policyRejection =
540
- providerCode === "11140" ||
541
- lower.includes("safety review") ||
542
- lower.includes("content did not pass") ||
543
- lower.includes("request illegal") ||
544
- lower.includes("content blocked") ||
545
- lower.includes("account suspended");
546
- const policyBlock =
547
- policyRejection &&
548
- options?.providerId !== undefined &&
549
- BUDDY_PROVIDER_IDS.has(options.providerId.toLowerCase());
550
- if (policyBlock) {
551
- return result(
552
- "policy_blocked",
553
- "cooldown",
554
- resolveAccountPolicyBlockCooldownMs(),
555
- new Date(Date.now() + resolveAccountPolicyBlockCooldownMs()),
556
- reason("Provider content-policy block"),
557
- origin === "upstream",
558
- );
559
- }
560
- // These codes are a verdict on the request, not the account. Cooling the
561
- // account for them burns a healthy credential on a body that will fail the
562
- // same way on every other account:
563
- //
564
- // - `11148` tool-call/result mismatch — the transcript is broken; the
565
- // pre-payload repair is supposed to prevent it, and a slip is still the
566
- // body's fault.
567
- // - `11135` the image cannot be processed, `11133` the model rejected a
568
- // parameter, `11101` the body failed to parse — deterministic for that
569
- // payload.
570
- // - `12153` the session is dead — a refresh problem, not a reason to park
571
- // the account on the first observation.
572
- // - `14017` trial not activated — the account may recover once registration
573
- // finishes, so a hard cooldown would keep it out after it becomes usable.
574
- //
575
- // Recognised before the auth and quota branches: several of these arrive as
576
- // 401/403/429, which those branches would otherwise treat as account evidence.
577
- const requestScoped =
578
- providerCode === "11148" ||
579
- providerCode === "11135" ||
580
- providerCode === "11133" ||
581
- providerCode === "11101" ||
582
- providerCode === "12153" ||
583
- providerCode === "14017" ||
584
- lower.includes("tool_call_sequence_broken") ||
585
- lower.includes("unmarshal chat params failed") ||
586
- lower.includes("invalid image_url content") ||
587
- lower.includes("offline user session not found") ||
588
- lower.includes("trial not activated") ||
589
- lower.includes("trial version is not yet activated");
590
- if (requestScoped && BUDDY_PROVIDER_IDS.has(options?.providerId?.toLowerCase() ?? "")) {
591
- return result(
592
- "unknown",
593
- "cooldown",
594
- 0,
595
- null,
596
- reason("Request-scoped upstream rejection"),
597
- false,
598
- );
599
- }
600
- // A channel rejection (`11128`) is the same shape: the upstream is refusing
601
- // the caller for this account, not this particular prompt, and it keeps
602
- // refusing on every invocation. It used to fall through to `unknown` with
603
- // `mutates=false`, so the account stayed in rotation and failed every
604
- // request — and since every account failed the same way, the whole pool
605
- // looked dead with nothing ever parked.
606
- const channelRejection =
607
- providerCode === "11128" || lower.includes("unapproved channel");
608
- const channelBlock =
609
- channelRejection &&
610
- options?.providerId !== undefined &&
611
- BUDDY_PROVIDER_IDS.has(options.providerId.toLowerCase());
612
- if (channelBlock) {
613
- return result(
614
- "policy_blocked",
615
- "cooldown",
616
- resolveAccountPolicyBlockCooldownMs(),
617
- new Date(Date.now() + resolveAccountPolicyBlockCooldownMs()),
618
- reason("Provider channel rejection"),
619
- origin === "upstream",
620
- );
621
- }
622
- // server-side tool (web search, x search, web fetch), not evidence about the
623
- // credential or the account's quota. Some providers surface it as 403, which
624
- // would otherwise look like an auth signal — refreshing the token cannot fix
625
- // a search backend, so it must never disable the account.
626
- const hostedToolFailure =
627
- lower.includes("web_search") ||
628
- lower.includes("web search") ||
629
- lower.includes("web_search_preview") ||
630
- lower.includes("x_search") ||
631
- lower.includes("web_fetch") ||
632
- lower.includes("tool invocation") ||
633
- lower.includes("tool call failed") ||
634
- lower.includes("search backend");
635
- // A model-scoped license or entitlement rejection is NOT credential invalidation.
636
- // Google Cloud Code / Antigravity answers HTTP 403 with "You do not have a valid
637
- // license of this product" when an account lacks entitlement for one specific model
638
- // (e.g. claude-opus-4-6), while the account itself and its tokens remain fully valid
639
- // for all other models. Disabling the whole account breaks healthy models.
640
- const modelEntitlementFailure =
641
- statusCode === 403 &&
642
- (lower.includes("not have a valid license") ||
643
- lower.includes("valid license of this product") ||
644
- lower.includes("model not supported") ||
645
- lower.includes("not entitled to") ||
646
- lower.includes("no access to model") ||
647
- lower.includes("model_not_supported") ||
648
- lower.includes("not available in your region"));
649
- /**
650
- * The upstream accepted the request and put it in a queue behind other
651
- * callers — it is busy, not rejecting the caller's credential.
652
- *
653
- * Qoder answers `403 {"code":"10605","message":"{\"isQueued\":true,
654
- * \"modelKey\":\"dfmodel\",\"queueCount\":1395,...}"}`. The 403 is what made
655
- * this misclassify: the generic `(401 || 403) && accountEvidence` arm claimed
656
- * it as `auth_invalidated`, so a healthy account was parked as "Re-login
657
- * required" purely because the provider was busy, and an operator was sent
658
- * to re-authenticate a credential that was never rejected.
659
- *
660
- * Matched on the queue evidence rather than on the bare `10605`, so an
661
- * unrelated 403 that happens to share the code is not swept in. The bodies
662
- * are nested JSON, so the tell is the key, not a parse of the envelope.
663
- */
664
- const queuedUpstream =
665
- providerCode === "10605" ||
666
- lower.includes("isqueued") ||
667
- lower.includes("queuecount") ||
668
- lower.includes("queuetype") ||
669
- (lower.includes("queue") && lower.includes("modelkey"));
670
- const authSignal =
671
- !quotaSignal &&
672
- !policyRejection &&
673
- !hostedToolFailure &&
674
- !modelEntitlementFailure &&
675
- !queuedUpstream &&
676
- (providerCode === "authentication_failed" ||
677
- folded.includes("invalid api key") ||
678
- folded.includes("incorrect api key") ||
679
- folded.includes("invalid token") ||
680
- folded.includes("token revoked") ||
681
- folded.includes("invalidated oauth token") ||
682
- folded.includes("reauthorization required") ||
683
- folded.includes("account deactivated") ||
684
- ((statusCode === 401 || statusCode === 403) && accountEvidence));
685
- if (authSignal) {
686
- // A static token has no refresh to run, so an auth rejection must not park
687
- // the account: disabling it throws away a token that may still work (a
688
- // single upstream 401 can be transient). A cooldown lets the next attempt
689
- // retry the same token without losing the account.
690
- const staticTokenRecovery = options?.staticToken === true;
691
- const oauthRecovery =
692
- !staticTokenRecovery &&
693
- options?.credentialKind === "oauth" &&
694
- (lower.includes("expired") || lower.includes("refresh"));
695
- // A *named* revocation is the one auth outcome that still parks the account.
696
- // The upstream said the credential itself is dead — not that it expired, not
697
- // that this request was refused — so there is no window to wait out and no
698
- // refresh to run, and a cooldown would only return it to rotation to fail
699
- // again. Every other auth rejection is weak evidence (clock skew, a key
700
- // rotated a moment ago, an edge answering before the credential loads) and
701
- // cools down instead.
702
- //
703
- // This distinction is the whole fix. The generic `401`/`403` branch used to
704
- // reach `disabled` too, which no sweep recovers, so one transient rejection
705
- // removed the account from rotation until an operator noticed — the exact
706
- // outcome the comment above says a static token must not suffer.
707
- const revoked = revocationNamed;
708
- const recoverable = staticTokenRecovery || oauthRecovery || !revoked;
709
- // An automatic disable is **not** terminal any more, but the deadline is what
710
- // makes that safe: `sweepExpiredCooldowns` recovers a disabled row only when
711
- // its `cooldownUntil` has elapsed, and an operator-disabled row keeps that
712
- // column null. So the machine can undo its own verdict while an operator's
713
- // decision still outranks it — see `resolveAccountDisabledRecoveryMs`.
714
- const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
715
- const disabledRetryAt =
716
- disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null;
717
- const cooldownMs = recoverable ? resolveAccountAuthRejectedCooldownMs() : disabledRecoveryMs;
718
- return result(
719
- "auth_invalidated",
720
- recoverable ? "cooldown" : "disabled",
721
- cooldownMs,
722
- recoverable ? new Date(Date.now() + cooldownMs) : disabledRetryAt,
723
- reason(
724
- !revoked
725
- ? "Provider credential rejected; retrying after a backoff"
726
- : staticTokenRecovery
727
- ? "Static token rejected; retrying without a refresh"
728
- : oauthRecovery
729
- ? "OAuth credential requires refresh"
730
- : "Provider credential revoked",
731
- ),
732
- canMutate,
733
- );
734
- }
735
-
736
- // A price refusal says the caller's bid was below every upstream ask, so the
737
- // account is not at fault and must not be mutated: `mutatesAccount` is false
738
- // and the deadline is left null. The category is `quota_exhausted` because the
739
- // request could not be afforded — not because the account ran out — and the
740
- // caller can fix it by raising its own ceiling.
741
- if (priceRefusal) {
742
- return result(
743
- "quota_exhausted",
744
- "cooldown",
745
- 0,
746
- null,
747
- reason("Request price refused by the provider"),
748
- false,
749
- );
750
- }
751
-
752
- if (quotaSignal) {
753
- /**
754
- * Qoder's `112` is a verdict on the (account, model) pair, not on the
755
- * credential or the balance as a whole.
756
- *
757
- * The body is `{"code":"112","message":"{\"pricingUrl\":…}"}` — a link to
758
- * the pricing page, i.e. "this model is not in your plan". Observed
759
- * behaviour agrees: the account keeps serving every other model after the
760
- * refusal. Parking the whole account therefore took healthy models down
761
- * with the one that is not entitled, which is the same mistake the
762
- * `model_unreachable` branch already avoids for Cursor and buddy.
763
- */
764
- const qoderPlanGated = providerCode === "112";
765
- if (qoderPlanGated) {
766
- const hits = Math.max(1, Math.floor(options?.modelBlockHits ?? 1));
767
- const cooldownMs = unsupportedModelCooldownMs(hits);
768
- return result(
769
- "model_unreachable",
770
- "cooldown",
771
- cooldownMs,
772
- new Date(Date.now() + cooldownMs),
773
- reason("Model not included in this account's plan"),
774
- origin === "upstream" && options?.modelId !== undefined,
775
- true,
776
- );
777
- }
778
- // xAI Grok Build's free tier resets on a rolling window. Use a duration
779
- // stated by the provider when present; otherwise park for the conservative
780
- // 24-hour fallback.
781
- const grokFreeTier =
782
- providerCode === "subscription:free-usage-exhausted" ||
783
- providerCode.startsWith("subscription:") ||
784
- lower.includes("free-usage") ||
785
- lower.includes("free usage") ||
786
- lower.includes("included free usage") ||
787
- lower.includes("rolling 24-hour") ||
788
- lower.includes("rolling 24 hour");
789
- const statedCooldown = messageCooldown ?? headerCooldown;
790
- const buddyCreditsExhausted = providerCode === "14018" || lower.includes("credits exhausted");
791
- const cooldownMs = grokFreeTier
792
- ? (statedCooldown ?? GROK_QUOTA_COOLDOWN_MS)
793
- : buddyCreditsExhausted
794
- ? (statedCooldown ?? 12 * 60 * 60 * 1000)
795
- : (statedCooldown ?? DEFAULT_QUOTA_COOLDOWN_MS());
796
- // The caller records against the concrete account that just failed
797
- // upstream, so a provider-scoped quota error still cools THAT account
798
- // down (enables fallback to the next account). Local-origin quota must
799
- // never mutate an upstream account.
800
- return result(
801
- "quota_exhausted",
802
- "cooldown",
803
- cooldownMs,
804
- new Date(Date.now() + cooldownMs),
805
- reason("Provider quota exhausted"),
806
- origin === "upstream",
807
- );
808
- }
809
-
810
- const rateSignal =
811
- statusCode === 429 ||
812
- providerCode === "rate_limit_exceeded" ||
813
- lower.includes("rate_limit") ||
814
- lower.includes("rate limit") ||
815
- lower.includes("too many requests") ||
816
- lower.includes("requests per minute") ||
817
- lower.includes("tokens per minute") ||
818
- lower.includes("tpm limit") ||
819
- lower.includes("rpm limit");
820
- if (rateSignal) {
821
- const cooldownMs = headerCooldown ?? messageCooldown ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS();
822
- // Same per-account reasoning as quota above: the failing account (or
823
- // its model, when modelId is present) backs off so routing tries the
824
- // next candidate instead of hammering the same key.
825
- return result(
826
- "rate_limit_transient",
827
- "cooldown",
828
- cooldownMs,
829
- new Date(Date.now() + cooldownMs),
830
- reason("Provider rate limit"),
831
- origin === "upstream",
832
- );
833
- }
834
-
835
- const capacitySignal =
836
- statusCode === 503 ||
837
- statusCode === 529 ||
838
- providerCode === "model_capacity" ||
839
- queuedUpstream ||
840
- lower.includes("model overloaded") ||
841
- lower.includes("capacity exceeded") ||
842
- lower.includes("temporarily unavailable");
843
- if (capacitySignal) {
844
- const cooldownMs = headerCooldown ?? messageCooldown ?? MODEL_CAPACITY_COOLDOWN_MS();
845
- const modelScopedMutation = origin === "upstream" && options?.modelId !== undefined;
846
- return result(
847
- "model_capacity",
848
- "cooldown",
849
- cooldownMs,
850
- new Date(Date.now() + cooldownMs),
851
- reason("Provider model capacity"),
852
- modelScopedMutation,
853
- );
854
- }
855
-
856
- // A model the upstream does not serve at all, detected before
857
- // `model_unreachable`: the buddy family answers `400 {"code":"11102","msg":
858
- // "service info not found"}` for an id its backend has never heard of, and
859
- // the reference gateways treat that as a durable verdict on the (account,
860
- // model) pair rather than as a request bug. Same category as
861
- // `model_unreachable`, so the writer already does the right thing — a
862
- // model-scoped cooldown that leaves the account's status alone — and what
863
- // differs is the duration, which escalates per hit.
864
- //
865
- // The hit count the caller supplies is best-effort (absent on a pair's first
866
- // report), so the classifier states the base window and the writer recomputes
867
- // it from the authoritative count it reads.
868
- if (isUnsupportedModelEvidence(statusCode, providerCode, folded)) {
869
- const hits = Math.max(1, Math.floor(options?.modelBlockHits ?? 1));
870
- const cooldownMs = unsupportedModelCooldownMs(hits);
871
- return result(
872
- "model_unreachable",
873
- "cooldown",
874
- cooldownMs,
875
- new Date(Date.now() + cooldownMs),
876
- reason("Model not supported by this upstream"),
877
- origin === "upstream" && options?.modelId !== undefined,
878
- true,
879
- );
880
- }
881
-
882
- // A model the upstream says does not exist for this account is a verdict on
883
- // the (account, model) pair, not on the credential. Cursor answers Connect
884
- // `not_found` for a model the plan does not reach (`claude-opus-5` on a plan
885
- // without it) while every other model on that same account serves normally,
886
- // so parking the account would take healthy models down with it. Detected
887
- // before the 5xx branch: the adapter surfaces it as a 404 carrying
888
- // `providerCode: "not_found"`, and nothing else in the 5xx band is
889
- // model-scoped.
890
- // Keyed on the provider's own code rather than on the 404: a 404 also means
891
- // an upstream *route* that does not exist (`upstream_not_found`), which says
892
- // nothing about any model and must not cool one.
893
- const modelUnreachable = providerCode === "not_found" || modelEntitlementFailure;
894
- if (modelUnreachable) {
895
- const cooldownMs = MODEL_UNREACHABLE_COOLDOWN_MS;
896
- // Not credential evidence, so `classifyUpstreamFailure` scopes this to the
897
- // provider and the `scope === "account"` gate the throttles use would
898
- // forbid the mutation outright — leaving the probe reporting a failure that
899
- // cooled nothing. The verdict is still about one (account, model) pair:
900
- // `recordAccountFailure` writes `modelCooldowns[modelId]` and leaves the
901
- // account's own status untouched, so all that is required is a model to
902
- // write against.
903
- const modelScopedMutation = origin === "upstream" && options?.modelId !== undefined;
904
- return result(
905
- "model_unreachable",
906
- "cooldown",
907
- cooldownMs,
908
- new Date(Date.now() + cooldownMs),
909
- reason("Model unreachable for this account"),
910
- modelScopedMutation,
911
- );
912
- }
913
-
914
- const serverSignal =
915
- (statusCode !== null && statusCode >= 500 && statusCode <= 504) ||
916
- lower.includes("timeout") ||
917
- lower.includes("abort") ||
918
- lower.includes("econnrefused") ||
919
- lower.includes("fetch failed");
920
- if (serverSignal) {
921
- // A transport-shaped fault clears on its own, so it is a cooldown with a
922
- // deadline rather than a state needing an operator. The deadline matters:
923
- // `sweepExpiredCooldowns` selects on `cooldownUntil IS NOT NULL`, so a row
924
- // parked without one is never recovered and stays out of rotation.
925
- //
926
- // `canMutate` rather than a flat `false`: a 5xx whose failure the caller
927
- // scoped to the account (an edge/WAF challenge answered to one account's
928
- // requests while its siblings on the same egress served fine) must cool
929
- // that account down, or it stays `active` and is re-dialled for as long as
930
- // the block lasts. Provider-scoped 5xx keeps the old behavior: the upstream
931
- // is indiscriminately failing and no single account is at fault.
932
- return result(
933
- lower.includes("timeout") ? "timeout" : "server_error",
934
- "cooldown",
935
- TRANSIENT_ERROR_COOLDOWN_MS(),
936
- new Date(Date.now() + TRANSIENT_ERROR_COOLDOWN_MS()),
937
- reason(`Provider ${statusCode ?? "network"} failure`),
938
- canMutate,
939
- );
940
- }
941
-
942
- // Same reasoning as above, with the shortest backoff: an unmapped failure
943
- // must still be retried automatically rather than parked forever.
944
- return result(
945
- "unknown",
946
- "cooldown",
947
- UNCLASSIFIED_COOLDOWN_MS(),
948
- new Date(Date.now() + UNCLASSIFIED_COOLDOWN_MS()),
949
- reason("Unclassified provider failure"),
950
- false,
951
- );
952
- }
953
-
954
-
955
- /** Persists one classified failure to the account row and appends a health event. */
956
- export async function recordAccountFailure(
957
- db: CartethyiaDatabase,
958
- accountId: string,
959
- error: unknown,
960
- options?: AccountFailureEvidence,
961
- ): Promise<AccountErrorClassification | null> {
962
- const classification = classifyAccountError(error, options);
963
- if (!classification.mutatesAccount) return classification;
964
-
965
- const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
966
- // Projected, not a bare `select()`: this runs on *every* attempt outcome,
967
- // and a full row drags `credential_ciphertext` (the largest thing stored
968
- // per account) and `auth_state` over the wire to read a status and a
969
- // counter. `RouteCatalogRepository` already projects for the same reason.
970
- const accountRows = await client
971
- .select({
972
- status: providerAccounts.status,
973
- staticToken: providerAccounts.staticToken,
974
- consecutiveFailures: providerAccounts.consecutiveFailures,
975
- modelCooldowns: providerAccounts.modelCooldowns,
976
- // Read only for the unsupported-model ladder; NULL means "never
977
- // counted", which is the common case and must stay cheap.
978
- modelBlockHits: providerAccounts.modelBlockHits,
979
- cooldownUntil: providerAccounts.cooldownUntil,
980
- })
981
- .from(providerAccounts)
982
- .where(eq(providerAccounts.id, accountId))
983
- .limit(1);
984
- const account = accountRows[0];
985
- if (!account || account.status === "disabled") return null;
986
-
987
- // Re-classify with the account's own static-token flag. The caller's
988
- // evidence is request-scoped and does not carry it, but an auth rejection
989
- // against a static token must cool the account down rather than park it —
990
- // there is no refresh to run, and the token may still be valid.
991
- const effective = account.staticToken
992
- ? classifyAccountError(error, { ...options, staticToken: true })
993
- : classification;
994
-
995
- const fromStatus = account.status;
996
- const now = new Date();
997
-
998
- const modelId = options?.modelId;
999
- // Every category here is a verdict about one (account, model) pair rather
1000
- // than about the credential, so all three back off that pair and leave the
1001
- // account routable for every other model. `model_unreachable` joins the two
1002
- // throttles on the same reasoning: an upstream that reports the model does
1003
- // not exist for this account will report it again on the next request.
1004
- const isThrottle =
1005
- effective.category === "rate_limit_transient" ||
1006
- effective.category === "model_capacity" ||
1007
- effective.category === "model_unreachable";
1008
- const shouldModelCooldown = modelId !== undefined && isThrottle;
1009
- // The account-wide streak, escalated for a provider-stated window. Read once
1010
- // so the model-scoped and account-wide paths below cannot disagree about it:
1011
- // this is the count *before* this failure, which the UPDATE below increments.
1012
- const streak = Math.max(0, account.consecutiveFailures ?? 0);
1013
- const escalatedCooldownMs = escalateFallbackCooldown(
1014
- effective.cooldownMs,
1015
- effective.providerStated === true,
1016
- streak,
1017
- );
1018
-
1019
- // The unsupported-model ladder, computed before the branch: the classifier
1020
- // could only state the base window because the authoritative hit count
1021
- // lives on the row, and it is read here. `hits` is the new total for this
1022
- // pair, so the first report is 1 and gets the base window.
1023
- const blockHits = (account.modelBlockHits as Record<string, number> | null) ?? {};
1024
- const unsupportedHits =
1025
- effective.modelUnsupported === true && modelId !== undefined
1026
- ? Math.max(1, (blockHits[modelId] ?? 0) + 1)
1027
- : undefined;
1028
- // An unsupported-model window is stated by *us*, not by the provider, so it
1029
- // would otherwise be multiplied a second time by `escalateFallbackCooldown`
1030
- // (which reads `consecutiveFailures`, an account-wide counter). The two
1031
- // ladders are different things: one counts how many times this pair was
1032
- // reported unsupported, the other how many failures this account produced.
1033
- const unsupportedCooldownMs =
1034
- unsupportedHits === undefined ? undefined : unsupportedModelCooldownMs(unsupportedHits);
1035
-
1036
- if (shouldModelCooldown) {
1037
- const existing = (account.modelCooldowns as Record<string, string> | null) ?? {};
1038
- // Monotonic: never move an in-force window earlier (see `latestDeadlineMs`).
1039
- // A provider-stated deadline wins outright because it is already the later
1040
- // of the two whenever the provider really parked the pair longer.
1041
- const nextUntil = new Date(
1042
- latestDeadlineMs(
1043
- effective.retryAt?.getTime(),
1044
- Date.now() + (unsupportedCooldownMs ?? escalatedCooldownMs),
1045
- existing[modelId] ? Date.parse(existing[modelId]!) : undefined,
1046
- ),
1047
- ).toISOString();
1048
- const previousUntil = existing[modelId];
1049
- // A re-statement is not an event: what the audit row reports is the
1050
- // *entry* into a cooling episode, and the deadline itself is not a
1051
- // column — so a row can only ever say "this pair started cooling for
1052
- // this reason". The upstream answers the same "Try again in 11h" on
1053
- // every retry (and an unstated reset is recomputed from now each time),
1054
- // so writing per failure produced seven identical rows in twenty minutes
1055
- // and read as an account flapping when nothing had changed. The entry is
1056
- // still refreshed below — its error fields are what the dialog explains
1057
- // the cooldown with — while an entry that is still in force suppresses
1058
- // the duplicate row.
1059
- const alreadyCooling =
1060
- previousUntil !== undefined && new Date(previousUntil).getTime() > Date.now();
1061
- // `status`/`cooldownUntil` stay untouched: only this (account, model)
1062
- // pair backs off, and the account remains routable for every other model.
1063
- // The error fields ARE written, because they are what provider detail and
1064
- // the health dialog read to explain *why* a model is cooling — without
1065
- // them the row showed "1 model cooling" with no reason at all.
1066
- //
1067
- // The streak advances here too. Without it the most common throttle — a
1068
- // model-scoped rate limit — never escalated, because the counter was only
1069
- // incremented on the account-wide path below, and `consecutiveFailures`
1070
- // stays honest about what it always claimed to count: how many failures in
1071
- // a row this account has just produced.
1072
- await client
1073
- .update(providerAccounts)
1074
- .set({
1075
- modelCooldowns: { ...existing, [modelId]: nextUntil },
1076
- // Counted only for the unsupported-model ladder, and only when this
1077
- // failure is that verdict — so a rate limit or a capacity refusal
1078
- // never advances it. `model_cooldowns`' own shape is untouched, so
1079
- // routing and the console keep parsing it exactly as before.
1080
- ...(unsupportedHits === undefined
1081
- ? {}
1082
- : { modelBlockHits: { ...blockHits, [modelId]: unsupportedHits } }),
1083
- consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
1084
- lastError: effective.reason,
1085
- lastErrorCategory: effective.category,
1086
- lastErrorAt: now,
1087
- })
1088
- .where(eq(providerAccounts.id, accountId));
1089
- if (!alreadyCooling) {
1090
- await writeHealthEvent(client, {
1091
- entityKind: "account",
1092
- accountId,
1093
- // The account's own status, on both sides: a model-scoped throttle
1094
- // never moved it, and writing the classification's status here
1095
- // claimed an `active → cooldown` transition that never happened — the
1096
- // dialog showed "Account status: ACTIVE" above a list of transitions
1097
- // to `cooldown`. The row still names the model and the reason, which
1098
- // is what makes it worth keeping.
1099
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1100
- toStatus: fromStatus as "active" | "cooldown" | "disabled",
1101
- reason: effective.reason,
1102
- errorCategory: effective.category,
1103
- modelId: modelId ?? null,
1104
- createdAt: now,
1105
- });
1106
- }
1107
- // `status` and `cooldownUntil` are untouched on this path — only the
1108
- // (account, model) pair backed off — so the snapshot is still accurate
1109
- // and must not be dropped.
1110
- return { ...classification, routesVisible: false };
1111
- }
1112
-
1113
- await client
1114
- .update(providerAccounts)
1115
- .set({
1116
- status: effective.status,
1117
- consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
1118
- lastError: effective.reason,
1119
- lastErrorCategory: effective.category,
1120
- lastErrorAt: now,
1121
- // Monotonic against the account's own in-force deadline, for the same
1122
- // reason as the model path: an unclassified 60-second failure must not
1123
- // release an account the provider parked for hours.
1124
- //
1125
- // A `0` duration contributes no candidate rather than stamping *now*
1126
- // (see `accountDeadlineMs`): the one state that asks for it is a
1127
- // terminal automatic disable, and an elapsed deadline there would hand
1128
- // the row straight back to the recovery sweep.
1129
- cooldownUntil: accountDeadlineMs([
1130
- effective.retryAt?.getTime(),
1131
- escalatedCooldownMs > 0 ? Date.now() + escalatedCooldownMs : undefined,
1132
- account.cooldownUntil?.getTime?.(),
1133
- ]),
1134
- })
1135
- .where(eq(providerAccounts.id, accountId));
1136
-
1137
- {
1138
- await writeHealthEvent(client, {
1139
- entityKind: "account",
1140
- accountId,
1141
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1142
- toStatus: effective.status,
1143
- reason: effective.reason,
1144
- errorCategory: effective.category,
1145
- modelId: modelId ?? null,
1146
- createdAt: now,
1147
- });
1148
- }
1149
- // This path wrote `status` and `cooldownUntil`, both of which the snapshot
1150
- // projects, so routing has to see it.
1151
- return { ...classification, routesVisible: true };
1152
- };
1153
-
1154
- try {
1155
- // Kept as a capability probe, not a correctness guard: this function is
1156
- // reachable with a partially-implemented database handle (the dispatch
1157
- // tests pass a narrow double), and a transaction is an optimization here,
1158
- // not a requirement — the same writes are correct without one.
1159
- if (typeof db.transaction === "function") {
1160
- return await db.transaction((tx) => persist(tx as CartethyiaDatabase));
1161
- }
1162
- return await persist(db);
1163
- } catch {
1164
- return null;
1165
- }
1166
- }
1167
-
1168
- /** Clears failure state after a successful dispatch; true when the account changed. */
1169
- export async function recordAccountSuccess(
1170
- db: CartethyiaDatabase,
1171
- accountId: string,
1172
- ): Promise<boolean> {
1173
- const mutate = async (client: CartethyiaDatabase): Promise<boolean> => {
1174
- // `recordAccountSuccess` runs on every successful dispatch, and for a
1175
- // healthy account `hadFailures` is false — the row was fetched purely to
1176
- // decide nothing. Projected so that no-op path does not pull the
1177
- // credential ciphertext across the wire.
1178
- const rows = await client
1179
- .select({
1180
- status: providerAccounts.status,
1181
- consecutiveFailures: providerAccounts.consecutiveFailures,
1182
- // Read only to decide whether there is anything to clear: the common
1183
- // case (no unsupported-model hit ever recorded) must not write.
1184
- modelBlockHits: providerAccounts.modelBlockHits,
1185
- })
1186
- .from(providerAccounts)
1187
- .where(eq(providerAccounts.id, accountId))
1188
- .limit(1);
1189
- const account = rows[0];
1190
- if (!account || account.status === "disabled") return false;
1191
- const fromStatus = account.status;
1192
- const hadFailures = (account.consecutiveFailures ?? 0) > 0 || account.status !== "active";
1193
- const now = new Date();
1194
- // The unsupported-model escalation restarts after a success: a model that
1195
- // just served is not "not supported", so the next report of it must start
1196
- // from the base window again rather than from wherever the ladder had
1197
- // reached. `modelCooldowns` is deliberately NOT cleared — an in-force
1198
- // window the provider stated survives, which is the existing behavior and
1199
- // the reference gateways' too.
1200
- const blockHits = account.modelBlockHits as Record<string, number> | null;
1201
- const hasBlockHits = blockHits !== null && Object.keys(blockHits).length > 0;
1202
- await client
1203
- .update(providerAccounts)
1204
- .set({
1205
- status: "active",
1206
- consecutiveFailures: 0,
1207
- cooldownUntil: null,
1208
- lastSuccessAt: now,
1209
- ...(hadFailures ? { lastRecoveredAt: now } : {}),
1210
- ...(hasBlockHits ? { modelBlockHits: {} } : {}),
1211
- })
1212
- .where(eq(providerAccounts.id, accountId));
1213
- if (hadFailures && typeof client.insert === "function") {
1214
- await client.insert(healthEvents).values({
1215
- entityKind: "account",
1216
- accountId,
1217
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1218
- toStatus: "active",
1219
- reason: "Request dispatched successfully",
1220
- errorCategory: null,
1221
- createdAt: now,
1222
- });
1223
- }
1224
- return hadFailures;
1225
- };
1226
- try {
1227
- if (typeof db.transaction === "function") {
1228
- return await db.transaction((tx) => mutate(tx as CartethyiaDatabase));
1229
- }
1230
- return await mutate(db);
1231
- } catch {
1232
- return false;
1233
- }
1234
- }
1235
-
1236
- /** Operator-initiated recovery: forces the account back to active and logs the transition. */
1237
- export async function recoverAccount(
1238
- db: CartethyiaDatabase,
1239
- accountId: string,
1240
- reason = "manual_operator_recovery",
1241
- ): Promise<boolean> {
1242
- const recover = async (client: CartethyiaDatabase): Promise<boolean> => {
1243
- const accountRows = await client
1244
- .select()
1245
- .from(providerAccounts)
1246
- .where(eq(providerAccounts.id, accountId))
1247
- .limit(1);
1248
- const account = accountRows[0];
1249
- if (!account) return false;
1250
- const fromStatus = account.status;
1251
- const now = new Date();
1252
- await client
1253
- .update(providerAccounts)
1254
- .set({
1255
- status: "active",
1256
- consecutiveFailures: 0,
1257
- cooldownUntil: null,
1258
- lastRecoveredAt: now,
1259
- // Recovery is the operator asserting the account works now, so it must
1260
- // clear EVERY routing exclusion, not just the account-wide ones. A
1261
- // per-model cooldown entry excludes the (account, model) pair in
1262
- // `route-catalog.ts`, so leaving it behind kept the account blocked for
1263
- // that model through `/v1` even though the account read `active` and a
1264
- // direct probe (which addresses the account by id, bypassing routing
1265
- // eligibility) succeeded. Clearing the error fields for the same reason:
1266
- // a stale reason on a recovered row misreports the current state.
1267
- modelCooldowns: {},
1268
- lastError: null,
1269
- lastErrorCategory: null,
1270
- lastErrorAt: null,
1271
- })
1272
- .where(eq(providerAccounts.id, accountId));
1273
- {
1274
- await writeHealthEvent(client, {
1275
- entityKind: "account",
1276
- accountId,
1277
- fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1278
- toStatus: "active",
1279
- reason,
1280
- errorCategory: null,
1281
- createdAt: now,
1282
- });
1283
- }
1284
- return true;
1285
- };
1286
- try {
1287
- if (typeof db.transaction === "function") {
1288
- return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
1289
- }
1290
- return await recover(db);
1291
- } catch {
1292
- return false;
1293
- }
1294
- }
1295
-
1296
- /** Recovers accounts whose cooldown elapsed and prunes expired per-model keys. */
1297
- export async function sweepExpiredCooldowns(db: CartethyiaDatabase): Promise<number> {
1298
- const recover = async (client: CartethyiaDatabase): Promise<number> => {
1299
- const now = new Date();
1300
- const nowIso = now.toISOString();
1301
- const expired = await client
1302
- .select({
1303
- id: providerAccounts.id,
1304
- status: providerAccounts.status,
1305
- })
1306
- .from(providerAccounts)
1307
- .where(
1308
- or(
1309
- // An ordinary cooldown, recovered as soon as its window elapses.
1310
- and(
1311
- eq(providerAccounts.status, "cooldown"),
1312
- isNotNull(providerAccounts.cooldownUntil),
1313
- lte(providerAccounts.cooldownUntil, now),
1314
- ),
1315
- // An **automatic** disable, which used to be terminal. Selected only
1316
- // when it carries a deadline: an operator's disable leaves
1317
- // `cooldownUntil` null, so a human's decision still outranks the
1318
- // machine and is never swept away. The deadline is written by the two
1319
- // paths that disable on evidence — the classifier's revocation branch
1320
- // and a definitively rejected refresh grant.
1321
- and(
1322
- eq(providerAccounts.status, "disabled"),
1323
- isNotNull(providerAccounts.cooldownUntil),
1324
- lte(providerAccounts.cooldownUntil, now),
1325
- ),
1326
- ),
1327
- )
1328
- // One pass recovers a bounded batch. The sweep runs every 30s, so a
1329
- // backlog larger than this drains over successive ticks instead of one
1330
- // statement covering every expired row — that statement holds its locks
1331
- // for as long as the backlog takes.
1332
- .limit(COOLDOWN_RECOVERY_BATCH);
1333
- if (expired.length > 0) {
1334
- await client
1335
- .update(providerAccounts)
1336
- .set({
1337
- status: "active",
1338
- consecutiveFailures: 0,
1339
- cooldownUntil: null,
1340
- lastRecoveredAt: now,
1341
- // Cleared with the state it described. A recovered row that kept
1342
- // `auth_invalidated` still rendered the console's "Re-login required"
1343
- // pill above an `active` status, so the account read healthy to
1344
- // routing and broken to the operator at the same time.
1345
- lastErrorCategory: null,
1346
- })
1347
- .where(inArray(providerAccounts.id, expired.map((account) => account.id)));
1348
- if (typeof client.insert === "function") {
1349
- for (let offset = 0; offset < expired.length; offset += 500) {
1350
- await client.insert(healthEvents).values(
1351
- expired.slice(offset, offset + 500).map((account) => ({
1352
- entityKind: "account" as const,
1353
- accountId: account.id,
1354
- fromStatus: account.status,
1355
- toStatus: "active" as const,
1356
- reason: "Cooldown period elapsed — auto-recovered",
1357
- errorCategory: null,
1358
- createdAt: now,
1359
- })),
1360
- );
1361
- }
1362
- }
1363
- }
1364
-
1365
- // Prune per-model cooldowns: remove entries whose ISO timestamp has passed.
1366
- //
1367
- // One set-based UPDATE rather than a SELECT followed by an UPDATE per row.
1368
- // The per-row form issued one statement per account holding any cooldown —
1369
- // on a deployment where many accounts are throttled at once that is the
1370
- // sweep's dominant cost, and it all runs inside this transaction, holding
1371
- // its locks for the whole pass. Recomputing the object in SQL does the same
1372
- // work in one statement.
1373
- //
1374
- // `jsonb_typeof(...) = 'object'` guards the shape the same way the old
1375
- // `typeof raw !== "object"` check did: a malformed value must not abort the
1376
- // whole sweep. `IS DISTINCT FROM` keeps the update from rewriting rows it
1377
- // did not change, so the returned row count is the number of accounts whose
1378
- // cooldown set actually shrank — the same thing the loop counted.
1379
- const pruning = pruneExpiredCooldowns(sql`${providerAccounts.modelCooldowns}`, sql`${nowIso}`);
1380
- const pruned = await client
1381
- .update(providerAccounts)
1382
- .set({ modelCooldowns: pruning.value })
1383
- .where(pruning.changed);
1384
- return expired.length + affectedRows(pruned);
1385
- };
1386
- try {
1387
- if (typeof db.transaction === "function") {
1388
- return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
1389
- }
1390
- return await recover(db);
1391
- } catch {
1392
- return 0;
1393
- }
1394
- }
1395
-
1396
- /**
1397
- * Single attempt health reporter: one home for the success/failure writes
1398
- * every dispatch attempt ends with. Success records recovery; failure
1399
- * records with caller-supplied evidence (origin/scope classification plus
1400
- * model id). A mutating write invalidates the route snapshot so the next
1401
- * plan sees fresh health. Never throws — health must not fail requests.
1402
- */
1403
- export interface AttemptHealthReport {
1404
- readonly accountId: string | undefined;
1405
- readonly modelId?: string;
1406
- readonly error?: unknown;
1407
- readonly evidence?: AccountFailureEvidence;
1408
- readonly snapshotService?: { invalidate(): unknown };
1409
- }
1410
-
1411
- /**
1412
- * Writes one health audit row, off the request path when a buffer is installed.
1413
- *
1414
- * `health_events` is append-only and read by nobody in the request path, so it
1415
- * does not need to land before the request finishes — unlike the
1416
- * `provider_accounts` UPDATE beside it, which routing reads and which therefore
1417
- * stays synchronous and transactional.
1418
- */
1419
- async function writeHealthEvent(
1420
- client: CartethyiaDatabase,
1421
- row: {
1422
- readonly entityKind: "account";
1423
- readonly accountId: string;
1424
- readonly fromStatus: string;
1425
- readonly toStatus: string;
1426
- readonly reason: string;
1427
- readonly errorCategory: string | null;
1428
- readonly modelId?: string | null;
1429
- readonly createdAt: Date;
1430
- },
1431
- ): Promise<void> {
1432
- const buffer = healthEventBuffer();
1433
- if (buffer !== undefined) {
1434
- buffer.enqueue({ ...row, modelId: row.modelId ?? null });
1435
- return;
1436
- }
1437
- if (typeof client.insert !== "function") return;
1438
- await client.insert(healthEvents).values(row as never);
1439
- }
1440
-
1441
- export async function reportAttemptOutcome(
1442
- db: CartethyiaDatabase,
1443
- report: AttemptHealthReport,
1444
- ): Promise<void> {
1445
- if (!report.accountId) return;
1446
- if (report.error === undefined) {
1447
- const recovered = await recordAccountSuccess(db, report.accountId).catch(() => false);
1448
- if (recovered) await report.snapshotService?.invalidate();
1449
- return;
1450
- }
1451
- const classification = await recordAccountFailure(db, report.accountId, report.error, {
1452
- ...report.evidence,
1453
- ...(report.modelId === undefined ? {} : { modelId: report.modelId }),
1454
- }).catch(() => null);
1455
- // Gated on `routesVisible`, not on `mutatesAccount`: a failure that only
1456
- // cooled one (account, model) pair, or only advanced the streak, changed
1457
- // nothing routing reads. Invalidating for those rebuilt an 11-SELECT snapshot
1458
- // on the next request, and under a failure wave each rebuild fed the next
1459
- // invalidation.
1460
- if (classification?.routesVisible) await report.snapshotService?.invalidate();
1461
- }
1462
-
1463
- export async function listAccountHealthEvents(
1464
- db: CartethyiaDatabase,
1465
- accountId: string,
1466
- limit = 50,
1467
- ): Promise<readonly AccountHealthEventRecord[]> {
1468
- try {
1469
- const rows = await db
1470
- .select()
1471
- .from(healthEvents)
1472
- .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
1473
- .orderBy(desc(healthEvents.createdAt))
1474
- .limit(limit);
1475
-
1476
- return rows.map((r) => ({
1477
- id: r.id,
1478
- accountId: r.accountId as string,
1479
- fromStatus: r.fromStatus,
1480
- toStatus: r.toStatus,
1481
- modelId: r.modelId ?? null,
1482
- reason: r.reason,
1483
- errorCategory: r.errorCategory,
1484
- createdAt: r.createdAt.toISOString(),
1485
- }));
1486
- } catch {
1487
- return [];
1488
- }
1489
- }
1490
-
1491
- /** Every stored row across accounts and pools — the size the purge offers to delete. */
1492
- export async function countAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
1493
- try {
1494
- const rows = await db.select({ total: count() }).from(healthEvents);
1495
- return rows[0]?.total ?? 0;
1496
- } catch {
1497
- return 0;
1498
- }
1499
- }
1500
-
1501
- /**
1502
- * Deletes stored health & error rows.
1503
- *
1504
- * Unlike the read above, a failed delete surfaces: an operator who purged
1505
- * believes the trail is gone, so "it failed" must not read as "it succeeded
1506
- * with zero rows". Callers own the flush-buffer barrier (`drain`) so a queued
1507
- * write cannot land after these rows are gone.
1508
- */
1509
- export async function deleteAccountHealthEvents(
1510
- db: CartethyiaDatabase,
1511
- accountId: string,
1512
- ): Promise<number> {
1513
- const rows = await db
1514
- .delete(healthEvents)
1515
- .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
1516
- .returning({ id: healthEvents.id });
1517
- return rows.length;
1518
- }
1519
-
1520
- export async function deleteAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
1521
- const rows = await db.delete(healthEvents).returning({ id: healthEvents.id });
1522
- return rows.length;
1523
- }
1
+ import { healthEventBuffer } from "../../persistence/health-event-flush";
2
+ // Account health: error classification, failure/success recording, recovery and cooldown sweeps.
3
+ import { and, count, desc, eq, inArray, isNotNull, lte, or, sql } from "drizzle-orm";
4
+ import { affectedRows, pruneExpiredCooldowns } from "../../persistence/dialect";
5
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
6
+ import { isRecord } from "../../protocol/codecs";
7
+ import { GatewayError } from "../../protocol/gateway-error";
8
+ import { BUDDY_PROVIDER_IDS } from "../provider-metadata";
9
+ import { healthEvents, providerAccounts } from "../../persistence/schema";
10
+
11
+ import { parseProviderResetDuration, parseUpstreamBackoff } from "../../transport/failure-policy";
12
+ import {
13
+ resolveAccountAuthRejectedCooldownMs,
14
+ resolveAccountCooldownEscalationMaxMs,
15
+ resolveAccountDisabledRecoveryMs,
16
+ resolveAccountModelCapacityCooldownMs,
17
+ resolveAccountPolicyBlockCooldownMs,
18
+ resolveAccountQuotaCooldownMs,
19
+ resolveAccountRateLimitCooldownMs,
20
+ resolveAccountTransientCooldownMs,
21
+ resolveAccountUnclassifiedCooldownMs,
22
+ } from "../../config/cooldowns";
23
+ // ===== health/constants.ts =====
24
+ /**
25
+ * Account status is authoritative for routing: `disabled` is a hard exclusion
26
+ * until an operator restores it, while `cooldown` is a *soft* one — the account
27
+ * is deprioritized behind every healthy sibling and reached only when nothing
28
+ * better is left, so a cooling credential still serves rather than parking the
29
+ * model entirely.
30
+ *
31
+ * Every delay below is a *fallback*: an upstream `Retry-After`/reset header or
32
+ * a duration stated in the provider message always wins. They are read through
33
+ * `src/config/cooldowns.ts` so an operator can tune the health machine without a code
34
+ * change (`CARTETHYIA_ACCOUNT_*_COOLDOWN_MS`).
35
+ */
36
+ const DEFAULT_RATE_LIMIT_COOLDOWN_MS = resolveAccountRateLimitCooldownMs;
37
+ // Fallback when the error states no usable duration: deliberately short (1h,
38
+ // not 24h) — quota state is re-probed on the next failure, so an unknown
39
+ // reset cadence self-corrects instead of parking the account for a day.
40
+ const DEFAULT_QUOTA_COOLDOWN_MS = resolveAccountQuotaCooldownMs;
41
+ const MODEL_CAPACITY_COOLDOWN_MS = resolveAccountModelCapacityCooldownMs;
42
+ const TRANSIENT_ERROR_COOLDOWN_MS = resolveAccountTransientCooldownMs;
43
+ /**
44
+ * Accounts one cooldown-recovery pass brings back. The sweep ticks every 30s,
45
+ * so a larger backlog drains over successive passes rather than one statement
46
+ * covering every expired row.
47
+ */
48
+ const COOLDOWN_RECOVERY_BATCH = 500;
49
+ /**
50
+ * Backoff for a failure no rule matched. Short on purpose: an unclassified
51
+ * error is more likely a new upstream shape than a broken account, so the
52
+ * account returns to rotation quickly.
53
+ */
54
+ const UNCLASSIFIED_COOLDOWN_MS = resolveAccountUnclassifiedCooldownMs;
55
+ /**
56
+ * xAI Grok Build's free tier resets on a rolling 24-hour window, so its
57
+ * exhaustion is a full-day quota cooldown — never the generic 1h fallback,
58
+ * which let the account re-enter rotation inside the window.
59
+ */
60
+ const GROK_QUOTA_COOLDOWN_MS = 24 * 60 * 60 * 1000;
61
+ /**
62
+ * The buddy family's policy/channel block (`11140`, `11128`) is a *base* backoff,
63
+ * not a flat penalty — see {@link resolveAccountPolicyBlockCooldownMs}. It used
64
+ * to be two constants, 24h for the content block and 6h for the channel one,
65
+ * both applied on the first observation. That was wrong in the direction that
66
+ * costs most: `11140` is also what a wrong desktop `User-Agent` brand answers
67
+ * (`workbuddy/shared.ts`), so after we fixed a header regression every buddy
68
+ * account stayed dead for up to a further day, and one prompt that failed a
69
+ * safety review cost a whole account a day. As a base it escalates through the
70
+ * consecutive-failure ladder, which tells the two causes apart by what they do
71
+ * next — a genuinely blocked account is refused again immediately and parks
72
+ * itself within minutes.
73
+ */
74
+ /**
75
+ * Backoff for a model the upstream says does not exist for this account
76
+ * (Cursor answers Connect `not_found` for a model the credential's plan does
77
+ * not reach). Long, because the verdict is a property of the plan and will not
78
+ * change on the next request — but bounded, so a plan upgrade is picked up
79
+ * without an operator clearing it by hand.
80
+ */
81
+ const MODEL_UNREACHABLE_COOLDOWN_MS = 6 * 60 * 60 * 1000;
82
+ /**
83
+ * Backoff for a model the upstream does not serve at all (buddy family
84
+ * business code `11102` — `service info not found` — or a `model ... not
85
+ * found/supported` message). Mirrors the reference gateways'
86
+ * `MODEL_SITE_BLOCK_S` → `MODEL_SITE_BLOCK_MAX_S`.
87
+ *
88
+ * Escalating rather than flat: the pair is re-dialled after the first window
89
+ * and answers the same thing, so a flat 6h let a permanently unsupported model
90
+ * consume a request every six hours forever. The ladder tops out at a day,
91
+ * which is long enough to stop the redial and short enough that a backend that
92
+ * starts serving the model is picked up without an operator clearing it.
93
+ *
94
+ * This is the one place the references' *strict* behaviour is adopted as a
95
+ * backoff rather than as a 400: nothing currently succeeds through it, so no
96
+ * client is broken by it.
97
+ */
98
+ const UNSUPPORTED_MODEL_COOLDOWN_MS = 6 * 60 * 60 * 1000;
99
+ const UNSUPPORTED_MODEL_COOLDOWN_MAX_MS = 24 * 60 * 60 * 1000;
100
+ /** How many doublings an unsupported-model cooldown may take before the cap. */
101
+ const UNSUPPORTED_MODEL_MAX_LEVEL = 8;
102
+
103
+ /**
104
+ * The cooldown for the *nth* consecutive unsupported-model report (`hits` is
105
+ * the new total, so the first report is `hits === 1`).
106
+ *
107
+ * Exported because the writer, not the classifier, is the only place that
108
+ * reads the authoritative hit count — the classifier sees only the count the
109
+ * caller supplied, which is absent on the first report of a pair.
110
+ */
111
+ export function unsupportedModelCooldownMs(hits: number): number {
112
+ const level = Math.min(Math.max(0, Math.round(hits) - 1), UNSUPPORTED_MODEL_MAX_LEVEL);
113
+ return Math.min(
114
+ UNSUPPORTED_MODEL_COOLDOWN_MS * 2 ** level,
115
+ UNSUPPORTED_MODEL_COOLDOWN_MAX_MS,
116
+ );
117
+ }
118
+
119
+ /**
120
+ * The wording an upstream uses for "we do not serve this model at all", as
121
+ * opposed to "this account cannot reach it" (403 entitlement, above) or "this
122
+ * route does not exist" (404 `upstream_not_found`).
123
+ */
124
+ const UNSUPPORTED_MODEL_MESSAGE =
125
+ /service info not found|model\s.{0,80}?\bnot\s+(?:found|supported)\b/;
126
+
127
+ /**
128
+ * True when the evidence says the upstream does not serve the model.
129
+ *
130
+ * Narrow on purpose, and narrower than `model_unreachable`: only a 400/404
131
+ * carrying the buddy family's `11102` or one of the two phrasings above. A
132
+ * 4xx that merely *mentions* a model is far more often a payload the caller
133
+ * got wrong, and cooling a pair for six hours over that takes a working model
134
+ * out of rotation for a request bug.
135
+ */
136
+ function isUnsupportedModelEvidence(
137
+ statusCode: number | null,
138
+ providerCode: string,
139
+ lower: string,
140
+ ): boolean {
141
+ if (statusCode !== 400 && statusCode !== 404) return false;
142
+ return providerCode === "11102" || UNSUPPORTED_MODEL_MESSAGE.test(lower);
143
+ }
144
+
145
+ // ===== health/account-recorder.ts =====
146
+ export interface AccountHealthEventRecord {
147
+ readonly id: string;
148
+ readonly accountId: string;
149
+ readonly fromStatus: string | null;
150
+ readonly toStatus: string;
151
+ readonly reason: string | null;
152
+ readonly errorCategory: string | null;
153
+ readonly modelId: string | null;
154
+ readonly createdAt: string;
155
+ }
156
+
157
+ export type AccountErrorCategory =
158
+ | "quota_exhausted"
159
+ | "rate_limit_transient"
160
+ | "model_capacity"
161
+ | "model_unreachable"
162
+ | "auth_invalidated"
163
+ | "policy_blocked"
164
+ | "server_error"
165
+ | "timeout"
166
+ | "unknown";
167
+ export type AccountFailureOrigin = "cartethyia" | "upstream" | "network";
168
+ export type AccountFailureScope =
169
+ | "account"
170
+ | "provider"
171
+ | "model"
172
+ | "pool"
173
+ | "tenant"
174
+ | "request"
175
+ | "network"
176
+ | "unknown";
177
+
178
+ export interface AccountFailureEvidence {
179
+ readonly origin?: AccountFailureOrigin;
180
+ readonly scope?: AccountFailureScope;
181
+ readonly credentialKind?: string;
182
+ /**
183
+ * The account's credential is a static bearer token used as issued and never
184
+ * refreshed. An auth rejection against a static token is not a refreshable
185
+ * fault: refreshing cannot help, and disabling the account discards a token
186
+ * that may still be valid for other requests. Such a failure cools the
187
+ * account down instead of parking it.
188
+ */
189
+ readonly staticToken?: boolean;
190
+ readonly providerCode?: string;
191
+ readonly credentialEvidence?: boolean;
192
+ readonly retryAfterMs?: number;
193
+ readonly statusCode?: number | null;
194
+ readonly headers?: Headers | Record<string, string> | null;
195
+ readonly modelId?: string;
196
+ /**
197
+ * Provider that produced the failure. Used only for the one
198
+ * provider-family-specific policy rule (the buddy family's `11140`
199
+ * content-policy block), never to broaden what disables an account.
200
+ */
201
+ readonly providerId?: string;
202
+ /**
203
+ * How many times this (account, model) pair has already been reported
204
+ * unsupported, when the caller has read them. Best-effort: the classifier
205
+ * states the base window without it, and the writer recomputes against the
206
+ * count it reads itself.
207
+ */
208
+ readonly modelBlockHits?: number;
209
+ }
210
+
211
+ export interface AccountErrorClassification {
212
+ readonly category: AccountErrorCategory;
213
+ readonly status: "cooldown" | "disabled";
214
+ readonly cooldownMs: number;
215
+ readonly reason: string;
216
+ readonly retryAt: Date | null;
217
+ readonly mutatesAccount: boolean;
218
+ /**
219
+ * True when this write changed a field the routing snapshot actually reads.
220
+ *
221
+ * The snapshot projects `provider_accounts.status` and
222
+ * `.cooldownUntil` and nothing else (`route-catalog.ts:286-287`), so only a
223
+ * write that moves one of those has to drop it. Everything else a failure
224
+ * persists — the failure streak, the error text, the category, a
225
+ * model-scoped cooldown — is invisible to routing, and invalidating for it
226
+ * forced the next request to rebuild an 11-SELECT snapshot for no reason.
227
+ *
228
+ * That made a failure wave self-sustaining: N failed attempts produced N
229
+ * invalidations, and every following request paid the full rebuild.
230
+ */
231
+ readonly routesVisible: boolean;
232
+ /**
233
+ * True when `cooldownMs` came from the provider's own statement — a parsed
234
+ * `Retry-After`-family header, or a reset quoted in its message — rather than
235
+ * from one of this module's fallback constants.
236
+ *
237
+ * A property of the *evidence*, not of the category, so it is the same for
238
+ * every branch of one classification. Only {@link escalateFallbackCooldown}
239
+ * reads it: a provider-stated window is the truth and is never multiplied.
240
+ */
241
+ readonly providerStated?: boolean;
242
+ /**
243
+ * True when the upstream reported the model as one it does not serve (the
244
+ * buddy family's `11102`), rather than one this account merely cannot reach.
245
+ *
246
+ * A property of the classification, because both verdicts land in the same
247
+ * `model_unreachable` category and the writer cannot otherwise tell them
248
+ * apart — it needs to know whether to count the hit and escalate the window.
249
+ */
250
+ readonly modelUnsupported?: boolean;
251
+ }
252
+
253
+ /**
254
+ * How many doublings a fallback cooldown may take before the cap takes over.
255
+ *
256
+ * Four is enough to reach the cap from every fallback default (the longest is
257
+ * the 1h quota fallback, which reaches it on the first doubling) while keeping
258
+ * the ladder short enough to reason about.
259
+ */
260
+ const COOLDOWN_ESCALATION_MAX_LEVEL = 4;
261
+
262
+ /**
263
+ * Escalates a *fallback* cooldown for an account that keeps failing.
264
+ *
265
+ * `consecutiveFailures` has been incremented on every account failure since the
266
+ * health machine was written, but nothing ever read it — so a broken upstream
267
+ * was re-dialled once a minute forever, and an account whose quota had an
268
+ * unstated reset came straight back into rotation every time its short fallback
269
+ * lapsed. The count is now a backoff level: each further consecutive failure
270
+ * doubles the fallback, clamped to {@link resolveAccountCooldownEscalationMaxMs}.
271
+ *
272
+ * Two guards keep it from overriding the provider. A duration the provider
273
+ * *stated* is never multiplied — that window is the truth. And the caller writes
274
+ * the result through a monotonic maximum, so a burst of concurrent in-flight
275
+ * failures advances the deadline to the longest of them instead of compounding
276
+ * once per failure.
277
+ */
278
+ function escalateFallbackCooldown(
279
+ cooldownMs: number,
280
+ providerStated: boolean,
281
+ consecutiveFailures: number,
282
+ ): number {
283
+ if (providerStated || cooldownMs <= 0) return cooldownMs;
284
+ const level = Math.min(Math.max(0, consecutiveFailures), COOLDOWN_ESCALATION_MAX_LEVEL);
285
+ return Math.min(cooldownMs * 2 ** level, resolveAccountCooldownEscalationMaxMs());
286
+ }
287
+
288
+ /**
289
+ * The later of a candidate deadline and the one already recorded.
290
+ *
291
+ * Every fallback duration is recomputed from *now* on each failure, so writing
292
+ * it unconditionally let a fresh short failure *shorten* a longer window already
293
+ * in force: a 60-second unclassified cooldown overwrote the provider's own
294
+ * "Try again in 11h" and released the pair back into rotation inside a window
295
+ * the upstream had closed, where it failed again — once a minute, forever.
296
+ * A cooling deadline may only ever move later.
297
+ */
298
+ function latestDeadlineMs(...candidates: readonly (number | undefined | null)[]): number {
299
+ let latest = 0;
300
+ for (const value of candidates) {
301
+ if (typeof value !== "number" || !Number.isFinite(value)) continue;
302
+ if (value > latest) latest = value;
303
+ }
304
+ return latest;
305
+ }
306
+
307
+ /**
308
+ * The account-wide deadline to write, or `null` for "no deadline".
309
+ *
310
+ * {@link latestDeadlineMs} cannot express *no* deadline — it floors at 0 — and
311
+ * the account-wide write has one state that needs it. With
312
+ * `CARTETHYIA_ACCOUNT_DISABLED_RECOVERY_MS=0` an automatic disable is meant to
313
+ * be terminal again, and the classifier correctly reports `retryAt: null`.
314
+ * Deriving the deadline from `Date.now() + cooldownMs` instead stamps *now*.
315
+ * The recovery sweep then treats that elapsed deadline as
316
+ * an automatic disable awaiting recovery and returns the row to rotation on its
317
+ * next tick — the escape hatch enabled exactly the behavior it documents
318
+ * itself as disabling.
319
+ *
320
+ * A candidate only exists when there is a real window to state. Monotonicity is
321
+ * preserved: a deadline already in force still wins over a new `null`.
322
+ */
323
+ function accountDeadlineMs(
324
+ candidates: readonly (number | undefined | null)[],
325
+ ): Date | null {
326
+ const latest = latestDeadlineMs(...candidates);
327
+ return latest > 0 ? new Date(latest) : null;
328
+ }
329
+
330
+
331
+ export function classifyAccountError(
332
+ error: unknown,
333
+ options?: AccountFailureEvidence,
334
+ ): AccountErrorClassification {
335
+ const errorRecord = isRecord(error) ? error : undefined;
336
+ const message =
337
+ error instanceof Error
338
+ ? error.message
339
+ : typeof error === "string"
340
+ ? error
341
+ : typeof errorRecord?.message === "string"
342
+ ? errorRecord.message
343
+ : String(error ?? "Unknown error");
344
+ const lower = message.toLowerCase();
345
+ const statusCode =
346
+ options?.statusCode ??
347
+ (typeof errorRecord?.status === "number" ? errorRecord.status : null);
348
+ const origin = options?.origin ?? "cartethyia";
349
+ const scope = options?.scope ?? "unknown";
350
+ // Providers write the same phrase with different separators: codex reports
351
+ // `token_revoked`, others `token revoked` or `token-revoked`. Folding the
352
+ // separators once lets every match below see one spelling.
353
+ const folded = lower.replace(/[_-]+/g, " ");
354
+ // A revocation named in the message is evidence about the credential itself,
355
+ // whichever scope the caller could establish. A stream error frame carries
356
+ // no `credentialEvidence` detail and classifies at provider scope, so without
357
+ // this the account stays in rotation: the phrase is recognised and then
358
+ // discarded because nothing mutates on provider scope.
359
+ const revocationNamed = folded.includes("token revoked") || folded.includes("invalidated oauth token");
360
+ const accountEvidence =
361
+ options?.credentialEvidence === true ||
362
+ revocationNamed ||
363
+ (options?.origin === "upstream" && options.scope === "account");
364
+ // A named revocation mutates even at provider scope: the message is the
365
+ // evidence, and provider scope is what a frame with no credential detail
366
+ // classifies as. Every other signal still needs account scope.
367
+ const canMutate = origin === "upstream" && (revocationNamed || (scope === "account" && accountEvidence));
368
+ const providerCode = options?.providerCode?.toLowerCase() ?? "";
369
+
370
+ const getHeader = (name: string): string | null => {
371
+ const headers = options?.headers;
372
+ if (!headers) return null;
373
+ if ("get" in headers && typeof headers.get === "function") return headers.get(name);
374
+ const wanted = name.toLowerCase();
375
+ const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === wanted);
376
+ return entry?.[1] ?? null;
377
+ };
378
+
379
+ const headerCooldown =
380
+ options?.retryAfterMs ??
381
+ parseUpstreamBackoff({ get: getHeader });
382
+ const messageCooldown = parseProviderResetDuration(message);
383
+ // Whether the provider itself named a window. Decided once, from the
384
+ // evidence, so every branch of this classification agrees about it.
385
+ const providerStated = headerCooldown !== null || messageCooldown !== null;
386
+ const rawUpstreamResponse =
387
+ origin === "upstream" &&
388
+ error instanceof GatewayError &&
389
+ typeof error.details.raw === "string"
390
+ ? error.details.raw
391
+ : undefined;
392
+ const reason = (prefix: string): string => `${prefix}: ${message.slice(0, 160)}`;
393
+ const result = (
394
+ category: AccountErrorCategory,
395
+ status: AccountErrorClassification["status"],
396
+ cooldownMs: number,
397
+ retryAt: Date | null,
398
+ explanation: string,
399
+ mutatesAccount = canMutate,
400
+ modelUnsupported = false,
401
+ ): AccountErrorClassification => ({
402
+ category,
403
+ status,
404
+ cooldownMs,
405
+ reason: origin === "upstream" ? rawUpstreamResponse ?? message.slice(0, 160) : explanation,
406
+ retryAt,
407
+ mutatesAccount,
408
+ // Set below by the caller that knows which columns it wrote, not here:
409
+ // the same classification feeds both the account-wide path (status +
410
+ // cooldownUntil — routing-visible) and the model-scoped path (neither —
411
+ // invisible). Defaulting true keeps existing callers conservative.
412
+ routesVisible: true,
413
+ providerStated,
414
+ modelUnsupported,
415
+ });
416
+
417
+ // A price refusal is a verdict on the *request*, not on the account. inferhub
418
+ // answers 402 with `no provider's ask matches your max-per-mtok bid`: the
419
+ // caller's own price ceiling was below every upstream ask, which says nothing
420
+ // about the credential or its balance. Classified as quota it cooled a
421
+ // fully-credited account down for an hour and the operator reported it as
422
+ // "blocked, unusable at all". Detected before the quota branch and recorded
423
+ // without mutating the account, so only that request fails.
424
+ const priceRefusal =
425
+ lower.includes("max-per-mtok") ||
426
+ lower.includes("no provider's ask") ||
427
+ (lower.includes("ask") && lower.includes("bid"));
428
+ // Deployment/proxy infrastructure errors (DEPLOYMENT_DISABLED, etc.) are
429
+ // transport-level proxy pool failures, not account quota exhaustion.
430
+ // Mutating the account parks it for an hour while the pool itself stays
431
+ // healthy — the next request picks the same dying pool and the same error
432
+ // repeats. These must not cooldown the account.
433
+ const deploymentInfraError =
434
+ lower.includes("deployment_disabled") ||
435
+ lower.includes("deployment disabled") ||
436
+ (lower.includes("sin1::") && lower.includes("deployment"));
437
+ if (deploymentInfraError) {
438
+ return result("server_error", "cooldown", 60_000, null, `Proxy deployment error: ${message.slice(0, 160)}`, false);
439
+ }
440
+ // A (CodeBuddy/WorkBuddy) *frequency* window is a verdict on one
441
+ // (account, model) pair, not on the account: `{"code":"6004","msg":"usage
442
+ // exceeds frequency limit ... your usage will reset at <stamp> UTC+8,
443
+ // alternatively, you can switch to the other models to continue using it."}`.
444
+ // The provider names the remedy itself — use a different model — which is
445
+ // only a remedy if the account still serves every other model. Taken as
446
+ // account-wide `quota_exhausted` it parked the whole account until the reset,
447
+ // so the switch the upstream offered was refused by our own gateway and every
448
+ // sibling model went dark with it; the account also read COOLDOWN /
449
+ // quota_exhausted while only that pair was throttled.
450
+ //
451
+ // Classified `rate_limit_transient` so the writer scopes it to the model:
452
+ // `recordAccountFailure` writes `modelCooldowns[modelId]` and leaves the
453
+ // account's own status alone. Kept ahead of `quotaSignal`, which matches a
454
+ // *broader* slice of the same wording (`usage exceeds` + `frequency limit`)
455
+ // and would otherwise claim this message as an account-wide quota window.
456
+ // Narrow on purpose — the extra phrase is what says the other models still
457
+ // work; without it the failure stays account-wide.
458
+ const modelFrequencyWindow =
459
+ lower.includes("frequency limit") && lower.includes("switch to the other models");
460
+ if (modelFrequencyWindow) {
461
+ const cooldownMs = messageCooldown ?? headerCooldown ?? DEFAULT_QUOTA_COOLDOWN_MS();
462
+ return result(
463
+ "rate_limit_transient",
464
+ "cooldown",
465
+ cooldownMs,
466
+ new Date(Date.now() + cooldownMs),
467
+ reason("Provider frequency limit for this model"),
468
+ // Provider-scoped evidence still has to reach the writer: a stream frame
469
+ // carries no credential detail, so `canMutate` is false and without this
470
+ // the pair would be retried for the whole window it was closed for.
471
+ origin === "upstream" && scope === "provider",
472
+ );
473
+ }
474
+
475
+ // Quota-shaped provider codes/messages (e.g. xAI's
476
+ const quotaSignal =
477
+ !priceRefusal &&
478
+ !deploymentInfraError &&
479
+ (statusCode === 402 ||
480
+ // Qoder answers an exhausted credit balance with
481
+ // `{"code":"112","message":"{\"pricingUrl\":...}"}` — the body points at
482
+ // the pricing page, i.e. "buy more", which is a balance verdict, not an
483
+ // auth one. It fell through to `auth_invalidated` and the console showed
484
+ // "Re-login required" on an account whose credential was fine, sending
485
+ // operators to re-authenticate instead of topping up.
486
+ // Recognising it here keeps it out of `auth_invalidated`; the branch
487
+ // below scopes the resulting cooldown to the model, because the account
488
+ // keeps serving its other models.
489
+ providerCode === "112" ||
490
+ providerCode === "insufficient_quota" ||
491
+ providerCode === "quota_exceeded" ||
492
+ providerCode === "subscription:free-usage-exhausted" ||
493
+ providerCode === "14018" ||
494
+ // CodeBuddy/WorkBuddy rate limit: `{"code":"6004","msg":"usage exceeds
495
+ // frequency limit ... your usage will reset at <stamp> UTC+8"}`. The
496
+ // message names a reset, so this is a parked window, not a transient
497
+ // 429 — without it the account fell through to `unknown` with
498
+ // `mutates=false`, stayed in rotation, and was re-picked for every
499
+ // request inside the window the provider had actually named.
500
+ //
501
+ // Matched on the message's own wording, not on the `6004` code: that code
502
+ // is not unique to this surface (the artifact-release API reuses it), so a
503
+ // code match would sweep an unrelated failure into a quota cooldown. The
504
+ // phrase is the reliable key, and `usage exceeds` + `frequency limit` is
505
+ // the pair this family actually prints.
506
+ (lower.includes("usage exceeds") && lower.includes("frequency limit")) ||
507
+ lower.includes("insufficient_quota") ||
508
+ lower.includes("quota_exceeded") ||
509
+ lower.includes("usage_limit_reached") ||
510
+ lower.includes("usage-exhausted") ||
511
+ lower.includes("usage_exhausted") ||
512
+ lower.includes("usage exhausted") ||
513
+ lower.includes("usage exceeds") ||
514
+ lower.includes("frequency limit") ||
515
+ lower.includes("free-usage") ||
516
+ lower.includes("free usage") ||
517
+ lower.includes("exceeded your current quota") ||
518
+ // Trae answers an exhausted quota with **HTTP 502** and the body `Your
519
+ // requests have exceeded the quota.` The status is what misled the
520
+ // classifier: 502 read as a gateway failure, so this fell through to
521
+ // `server_error` — a 30s cooldown that does not mutate the account, so
522
+ // the account stayed in rotation and every other model on it kept being
523
+ // invoked inside a window the provider had already closed. The body is
524
+ // the verdict; the 502 is incidental. Matched on the phrase, not on the
525
+ // status, which is why this sits in `quotaSignal` ahead of the 5xx arm.
526
+ lower.includes("have exceeded the quota") ||
527
+ lower.includes("balance exhausted") ||
528
+ lower.includes("insufficient balance") ||
529
+ lower.includes("out of credits") ||
530
+ lower.includes("credits exhausted") ||
531
+ lower.includes("spending limit") ||
532
+ // Same Qoder family reached by body text when the code is not parsed out.
533
+ (lower.includes("pricingurl") && lower.includes("qoder.com/pricing")));
534
+ // A deterministic content-policy rejection is NOT credential invalidation.
535
+ // CodeBuddy returns HTTP 403 with provider code 11140 ("request illegal",
536
+ // "did not pass the safety review") — refreshing the token cannot change
537
+ // that outcome and the account itself is healthy, so it must never flip the
538
+ // account to auth_invalidated/disabled. Only real auth signals do.
539
+ const policyRejection =
540
+ providerCode === "11140" ||
541
+ lower.includes("safety review") ||
542
+ lower.includes("content did not pass") ||
543
+ lower.includes("request illegal") ||
544
+ lower.includes("content blocked") ||
545
+ lower.includes("account suspended");
546
+ const policyBlock =
547
+ policyRejection &&
548
+ options?.providerId !== undefined &&
549
+ BUDDY_PROVIDER_IDS.has(options.providerId.toLowerCase());
550
+ if (policyBlock) {
551
+ const policyBlockMs = resolveAccountPolicyBlockCooldownMs();
552
+ return result(
553
+ "policy_blocked",
554
+ "cooldown",
555
+ policyBlockMs,
556
+ new Date(Date.now() + policyBlockMs),
557
+ reason("Provider content-policy block"),
558
+ origin === "upstream",
559
+ );
560
+ }
561
+ // These codes are a verdict on the request, not the account. Cooling the
562
+ // account for them burns a healthy credential on a body that will fail the
563
+ // same way on every other account:
564
+ //
565
+ // - `11148` tool-call/result mismatch — the transcript is broken; the
566
+ // pre-payload repair is supposed to prevent it, and a slip is still the
567
+ // body's fault.
568
+ // - `11135` the image cannot be processed, `11133` the model rejected a
569
+ // parameter, `11101` the body failed to parse — deterministic for that
570
+ // payload.
571
+ // - `12153` the session is dead — a refresh problem, not a reason to park
572
+ // the account on the first observation.
573
+ // - `14017` trial not activated — the account may recover once registration
574
+ // finishes, so a hard cooldown would keep it out after it becomes usable.
575
+ //
576
+ // Recognised before the auth and quota branches: several of these arrive as
577
+ // 401/403/429, which those branches would otherwise treat as account evidence.
578
+ const requestScoped =
579
+ providerCode === "11148" ||
580
+ providerCode === "11135" ||
581
+ providerCode === "11133" ||
582
+ providerCode === "11101" ||
583
+ providerCode === "12153" ||
584
+ providerCode === "14017" ||
585
+ lower.includes("tool_call_sequence_broken") ||
586
+ lower.includes("unmarshal chat params failed") ||
587
+ lower.includes("invalid image_url content") ||
588
+ lower.includes("offline user session not found") ||
589
+ lower.includes("trial not activated") ||
590
+ lower.includes("trial version is not yet activated");
591
+ if (requestScoped && BUDDY_PROVIDER_IDS.has(options?.providerId?.toLowerCase() ?? "")) {
592
+ return result(
593
+ "unknown",
594
+ "cooldown",
595
+ 0,
596
+ null,
597
+ reason("Request-scoped upstream rejection"),
598
+ false,
599
+ );
600
+ }
601
+ // A channel rejection (`11128`) is the same shape: the upstream is refusing
602
+ // the caller for this account, not this particular prompt, and it keeps
603
+ // refusing on every invocation. Classifying it as `unknown` with
604
+ // `mutates=false` would leave the account in rotation failing every request —
605
+ // and since every account fails the same way, the whole pool looks dead with
606
+ // nothing ever parked.
607
+ const channelRejection =
608
+ providerCode === "11128" || lower.includes("unapproved channel");
609
+ const channelBlock =
610
+ channelRejection &&
611
+ options?.providerId !== undefined &&
612
+ BUDDY_PROVIDER_IDS.has(options.providerId.toLowerCase());
613
+ if (channelBlock) {
614
+ const channelBlockMs = resolveAccountPolicyBlockCooldownMs();
615
+ return result(
616
+ "policy_blocked",
617
+ "cooldown",
618
+ channelBlockMs,
619
+ new Date(Date.now() + channelBlockMs),
620
+ reason("Provider channel rejection"),
621
+ origin === "upstream",
622
+ );
623
+ }
624
+ // server-side tool (web search, x search, web fetch), not evidence about the
625
+ // credential or the account's quota. Some providers surface it as 403, which
626
+ // would otherwise look like an auth signal — refreshing the token cannot fix
627
+ // a search backend, so it must never disable the account.
628
+ const hostedToolFailure =
629
+ lower.includes("web_search") ||
630
+ lower.includes("web search") ||
631
+ lower.includes("web_search_preview") ||
632
+ lower.includes("x_search") ||
633
+ lower.includes("web_fetch") ||
634
+ lower.includes("tool invocation") ||
635
+ lower.includes("tool call failed") ||
636
+ lower.includes("search backend");
637
+ // A model-scoped license or entitlement rejection is NOT credential invalidation.
638
+ // Google Cloud Code / Antigravity answers HTTP 403 with "You do not have a valid
639
+ // license of this product" when an account lacks entitlement for one specific model
640
+ // (e.g. claude-opus-4-6), while the account itself and its tokens remain fully valid
641
+ // for all other models. Disabling the whole account breaks healthy models.
642
+ const modelEntitlementFailure =
643
+ statusCode === 403 &&
644
+ (lower.includes("not have a valid license") ||
645
+ lower.includes("valid license of this product") ||
646
+ lower.includes("model not supported") ||
647
+ lower.includes("not entitled to") ||
648
+ lower.includes("no access to model") ||
649
+ lower.includes("model_not_supported") ||
650
+ lower.includes("not available in your region"));
651
+ /**
652
+ * The upstream accepted the request and put it in a queue behind other
653
+ * callers — it is busy, not rejecting the caller's credential.
654
+ *
655
+ * Qoder answers `403 {"code":"10605","message":"{\"isQueued\":true,
656
+ * \"modelKey\":\"dfmodel\",\"queueCount\":1395,...}"}`. The 403 is what made
657
+ * this misclassify: the generic `(401 || 403) && accountEvidence` arm claimed
658
+ * it as `auth_invalidated`, so a healthy account was parked as "Re-login
659
+ * required" purely because the provider was busy, and an operator was sent
660
+ * to re-authenticate a credential that was never rejected.
661
+ *
662
+ * Matched on the queue evidence rather than on the bare `10605`, so an
663
+ * unrelated 403 that happens to share the code is not swept in. The bodies
664
+ * are nested JSON, so the tell is the key, not a parse of the envelope.
665
+ */
666
+ const queuedUpstream =
667
+ providerCode === "10605" ||
668
+ lower.includes("isqueued") ||
669
+ lower.includes("queuecount") ||
670
+ lower.includes("queuetype") ||
671
+ (lower.includes("queue") && lower.includes("modelkey"));
672
+ const authSignal =
673
+ !quotaSignal &&
674
+ !policyRejection &&
675
+ !hostedToolFailure &&
676
+ !modelEntitlementFailure &&
677
+ !queuedUpstream &&
678
+ (providerCode === "authentication_failed" ||
679
+ folded.includes("invalid api key") ||
680
+ folded.includes("incorrect api key") ||
681
+ folded.includes("invalid token") ||
682
+ folded.includes("token revoked") ||
683
+ folded.includes("invalidated oauth token") ||
684
+ folded.includes("reauthorization required") ||
685
+ folded.includes("account deactivated") ||
686
+ ((statusCode === 401 || statusCode === 403) && accountEvidence));
687
+ if (authSignal) {
688
+ // A static token has no refresh to run, so an auth rejection must not park
689
+ // the account: disabling it throws away a token that may still work (a
690
+ // single upstream 401 can be transient). A cooldown lets the next attempt
691
+ // retry the same token without losing the account.
692
+ const staticTokenRecovery = options?.staticToken === true;
693
+ const oauthRecovery =
694
+ !staticTokenRecovery &&
695
+ options?.credentialKind === "oauth" &&
696
+ (lower.includes("expired") || lower.includes("refresh"));
697
+ // A *named* revocation is the one auth outcome that still parks the account.
698
+ // The upstream said the credential itself is dead — not that it expired, not
699
+ // that this request was refused — so there is no window to wait out and no
700
+ // refresh to run, and a cooldown would only return it to rotation to fail
701
+ // again. Every other auth rejection is weak evidence (clock skew, a key
702
+ // rotated a moment ago, an edge answering before the credential loads) and
703
+ // cools down instead.
704
+ //
705
+ // This distinction is the whole fix. Reaching `disabled` from the generic
706
+ // `401`/`403` branch would be unrecoverable by any sweep, so one transient
707
+ // rejection would remove the account from rotation until an operator
708
+ // noticed — the exact outcome the comment above says a static token must
709
+ // not suffer.
710
+ const revoked = revocationNamed;
711
+ const recoverable = staticTokenRecovery || oauthRecovery || !revoked;
712
+ // An automatic disable is **not** terminal any more, but the deadline is what
713
+ // makes that safe: `sweepExpiredCooldowns` recovers a disabled row only when
714
+ // its `cooldownUntil` has elapsed, and an operator-disabled row keeps that
715
+ // column null. So the machine can undo its own verdict while an operator's
716
+ // decision still outranks it — see `resolveAccountDisabledRecoveryMs`.
717
+ const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
718
+ const disabledRetryAt =
719
+ disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null;
720
+ const cooldownMs = recoverable ? resolveAccountAuthRejectedCooldownMs() : disabledRecoveryMs;
721
+ return result(
722
+ "auth_invalidated",
723
+ recoverable ? "cooldown" : "disabled",
724
+ cooldownMs,
725
+ recoverable ? new Date(Date.now() + cooldownMs) : disabledRetryAt,
726
+ reason(
727
+ !revoked
728
+ ? "Provider credential rejected; retrying after a backoff"
729
+ : staticTokenRecovery
730
+ ? "Static token rejected; retrying without a refresh"
731
+ : oauthRecovery
732
+ ? "OAuth credential requires refresh"
733
+ : "Provider credential revoked",
734
+ ),
735
+ canMutate,
736
+ );
737
+ }
738
+
739
+ // A price refusal says the caller's bid was below every upstream ask, so the
740
+ // account is not at fault and must not be mutated: `mutatesAccount` is false
741
+ // and the deadline is left null. The category is `quota_exhausted` because the
742
+ // request could not be afforded — not because the account ran out — and the
743
+ // caller can fix it by raising its own ceiling.
744
+ if (priceRefusal) {
745
+ return result(
746
+ "quota_exhausted",
747
+ "cooldown",
748
+ 0,
749
+ null,
750
+ reason("Request price refused by the provider"),
751
+ false,
752
+ );
753
+ }
754
+
755
+ if (quotaSignal) {
756
+ /**
757
+ * Qoder's `112` is a verdict on the (account, model) pair, not on the
758
+ * credential or the balance as a whole.
759
+ *
760
+ * The body is `{"code":"112","message":"{\"pricingUrl\":…}"}` — a link to
761
+ * the pricing page, i.e. "this model is not in your plan". Observed
762
+ * behaviour agrees: the account keeps serving every other model after the
763
+ * refusal. Parking the whole account therefore took healthy models down
764
+ * with the one that is not entitled, which is the same mistake the
765
+ * `model_unreachable` branch already avoids for Cursor and buddy.
766
+ */
767
+ const qoderPlanGated = providerCode === "112";
768
+ if (qoderPlanGated) {
769
+ const hits = Math.max(1, Math.floor(options?.modelBlockHits ?? 1));
770
+ const cooldownMs = unsupportedModelCooldownMs(hits);
771
+ return result(
772
+ "model_unreachable",
773
+ "cooldown",
774
+ cooldownMs,
775
+ new Date(Date.now() + cooldownMs),
776
+ reason("Model not included in this account's plan"),
777
+ origin === "upstream" && options?.modelId !== undefined,
778
+ true,
779
+ );
780
+ }
781
+ // xAI Grok Build's free tier resets on a rolling window. Use a duration
782
+ // stated by the provider when present; otherwise park for the conservative
783
+ // 24-hour fallback.
784
+ const grokFreeTier =
785
+ providerCode === "subscription:free-usage-exhausted" ||
786
+ providerCode.startsWith("subscription:") ||
787
+ lower.includes("free-usage") ||
788
+ lower.includes("free usage") ||
789
+ lower.includes("included free usage") ||
790
+ lower.includes("rolling 24-hour") ||
791
+ lower.includes("rolling 24 hour");
792
+ const statedCooldown = messageCooldown ?? headerCooldown;
793
+ const buddyCreditsExhausted = providerCode === "14018" || lower.includes("credits exhausted");
794
+ const cooldownMs = grokFreeTier
795
+ ? (statedCooldown ?? GROK_QUOTA_COOLDOWN_MS)
796
+ : buddyCreditsExhausted
797
+ ? (statedCooldown ?? 12 * 60 * 60 * 1000)
798
+ : (statedCooldown ?? DEFAULT_QUOTA_COOLDOWN_MS());
799
+ // The caller records against the concrete account that just failed
800
+ // upstream, so a provider-scoped quota error still cools THAT account
801
+ // down (enables fallback to the next account). Local-origin quota must
802
+ // never mutate an upstream account.
803
+ return result(
804
+ "quota_exhausted",
805
+ "cooldown",
806
+ cooldownMs,
807
+ new Date(Date.now() + cooldownMs),
808
+ reason("Provider quota exhausted"),
809
+ origin === "upstream",
810
+ );
811
+ }
812
+
813
+ const rateSignal =
814
+ statusCode === 429 ||
815
+ providerCode === "rate_limit_exceeded" ||
816
+ lower.includes("rate_limit") ||
817
+ lower.includes("rate limit") ||
818
+ lower.includes("too many requests") ||
819
+ lower.includes("requests per minute") ||
820
+ lower.includes("tokens per minute") ||
821
+ lower.includes("tpm limit") ||
822
+ lower.includes("rpm limit");
823
+ if (rateSignal) {
824
+ const cooldownMs = headerCooldown ?? messageCooldown ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS();
825
+ // Same per-account reasoning as quota above: the failing account (or
826
+ // its model, when modelId is present) backs off so routing tries the
827
+ // next candidate instead of hammering the same key.
828
+ return result(
829
+ "rate_limit_transient",
830
+ "cooldown",
831
+ cooldownMs,
832
+ new Date(Date.now() + cooldownMs),
833
+ reason("Provider rate limit"),
834
+ origin === "upstream",
835
+ );
836
+ }
837
+
838
+ const capacitySignal =
839
+ statusCode === 503 ||
840
+ statusCode === 529 ||
841
+ providerCode === "model_capacity" ||
842
+ queuedUpstream ||
843
+ lower.includes("model overloaded") ||
844
+ lower.includes("capacity exceeded") ||
845
+ lower.includes("temporarily unavailable");
846
+ if (capacitySignal) {
847
+ const cooldownMs = headerCooldown ?? messageCooldown ?? MODEL_CAPACITY_COOLDOWN_MS();
848
+ const modelScopedMutation = origin === "upstream" && options?.modelId !== undefined;
849
+ return result(
850
+ "model_capacity",
851
+ "cooldown",
852
+ cooldownMs,
853
+ new Date(Date.now() + cooldownMs),
854
+ reason("Provider model capacity"),
855
+ modelScopedMutation,
856
+ );
857
+ }
858
+
859
+ // A model the upstream does not serve at all, detected before
860
+ // `model_unreachable`: the buddy family answers `400 {"code":"11102","msg":
861
+ // "service info not found"}` for an id its backend has never heard of, and
862
+ // the reference gateways treat that as a durable verdict on the (account,
863
+ // model) pair rather than as a request bug. Same category as
864
+ // `model_unreachable`, so the writer already does the right thing — a
865
+ // model-scoped cooldown that leaves the account's status alone — and what
866
+ // differs is the duration, which escalates per hit.
867
+ //
868
+ // The hit count the caller supplies is best-effort (absent on a pair's first
869
+ // report), so the classifier states the base window and the writer recomputes
870
+ // it from the authoritative count it reads.
871
+ if (isUnsupportedModelEvidence(statusCode, providerCode, folded)) {
872
+ const hits = Math.max(1, Math.floor(options?.modelBlockHits ?? 1));
873
+ const cooldownMs = unsupportedModelCooldownMs(hits);
874
+ return result(
875
+ "model_unreachable",
876
+ "cooldown",
877
+ cooldownMs,
878
+ new Date(Date.now() + cooldownMs),
879
+ reason("Model not supported by this upstream"),
880
+ origin === "upstream" && options?.modelId !== undefined,
881
+ true,
882
+ );
883
+ }
884
+
885
+ // A model the upstream says does not exist for this account is a verdict on
886
+ // the (account, model) pair, not on the credential. Cursor answers Connect
887
+ // `not_found` for a model the plan does not reach (`claude-opus-5` on a plan
888
+ // without it) while every other model on that same account serves normally,
889
+ // so parking the account would take healthy models down with it. Detected
890
+ // before the 5xx branch: the adapter surfaces it as a 404 carrying
891
+ // `providerCode: "not_found"`, and nothing else in the 5xx band is
892
+ // model-scoped.
893
+ // Keyed on the provider's own code rather than on the 404: a 404 also means
894
+ // an upstream *route* that does not exist (`upstream_not_found`), which says
895
+ // nothing about any model and must not cool one.
896
+ const modelUnreachable = providerCode === "not_found" || modelEntitlementFailure;
897
+ if (modelUnreachable) {
898
+ const cooldownMs = MODEL_UNREACHABLE_COOLDOWN_MS;
899
+ // Not credential evidence, so `classifyUpstreamFailure` scopes this to the
900
+ // provider and the `scope === "account"` gate the throttles use would
901
+ // forbid the mutation outright — leaving the probe reporting a failure that
902
+ // cooled nothing. The verdict is still about one (account, model) pair:
903
+ // `recordAccountFailure` writes `modelCooldowns[modelId]` and leaves the
904
+ // account's own status untouched, so all that is required is a model to
905
+ // write against.
906
+ const modelScopedMutation = origin === "upstream" && options?.modelId !== undefined;
907
+ return result(
908
+ "model_unreachable",
909
+ "cooldown",
910
+ cooldownMs,
911
+ new Date(Date.now() + cooldownMs),
912
+ reason("Model unreachable for this account"),
913
+ modelScopedMutation,
914
+ );
915
+ }
916
+
917
+ const serverSignal =
918
+ (statusCode !== null && statusCode >= 500 && statusCode <= 504) ||
919
+ lower.includes("timeout") ||
920
+ lower.includes("abort") ||
921
+ lower.includes("econnrefused") ||
922
+ lower.includes("fetch failed");
923
+ if (serverSignal) {
924
+ // A transport-shaped fault clears on its own, so it is a cooldown with a
925
+ // deadline rather than a state needing an operator. The deadline matters:
926
+ // `sweepExpiredCooldowns` selects on `cooldownUntil IS NOT NULL`, so a row
927
+ // parked without one is never recovered and stays out of rotation.
928
+ //
929
+ // `canMutate` rather than a flat `false`: a 5xx whose failure the caller
930
+ // scoped to the account (an edge/WAF challenge answered to one account's
931
+ // requests while its siblings on the same egress served fine) must cool
932
+ // that account down, or it stays `active` and is re-dialled for as long as
933
+ // the block lasts. Provider-scoped 5xx does not: the upstream is
934
+ // indiscriminately failing and no single account is at fault.
935
+ const transientMs = TRANSIENT_ERROR_COOLDOWN_MS();
936
+ return result(
937
+ lower.includes("timeout") ? "timeout" : "server_error",
938
+ "cooldown",
939
+ transientMs,
940
+ new Date(Date.now() + transientMs),
941
+ reason(`Provider ${statusCode ?? "network"} failure`),
942
+ canMutate,
943
+ );
944
+ }
945
+
946
+ // Same reasoning as above, with the shortest backoff: an unmapped failure
947
+ // must still be retried automatically rather than parked forever.
948
+ const unclassifiedMs = UNCLASSIFIED_COOLDOWN_MS();
949
+ return result(
950
+ "unknown",
951
+ "cooldown",
952
+ unclassifiedMs,
953
+ new Date(Date.now() + unclassifiedMs),
954
+ reason("Unclassified provider failure"),
955
+ false,
956
+ );
957
+ }
958
+
959
+
960
+ /** Persists one classified failure to the account row and appends a health event. */
961
+ export async function recordAccountFailure(
962
+ db: CartethyiaDatabase,
963
+ accountId: string,
964
+ error: unknown,
965
+ options?: AccountFailureEvidence,
966
+ ): Promise<AccountErrorClassification | null> {
967
+ const classification = classifyAccountError(error, options);
968
+ if (!classification.mutatesAccount) return classification;
969
+
970
+ const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
971
+ // Projected, not a bare `select()`: this runs on *every* attempt outcome,
972
+ // and a full row drags `credential_ciphertext` (the largest thing stored
973
+ // per account) and `auth_state` over the wire to read a status and a
974
+ // counter. `RouteCatalogRepository` already projects for the same reason.
975
+ const accountRows = await client
976
+ .select({
977
+ status: providerAccounts.status,
978
+ staticToken: providerAccounts.staticToken,
979
+ consecutiveFailures: providerAccounts.consecutiveFailures,
980
+ modelCooldowns: providerAccounts.modelCooldowns,
981
+ // Read only for the unsupported-model ladder; NULL means "never
982
+ // counted", which is the common case and must stay cheap.
983
+ modelBlockHits: providerAccounts.modelBlockHits,
984
+ cooldownUntil: providerAccounts.cooldownUntil,
985
+ })
986
+ .from(providerAccounts)
987
+ .where(eq(providerAccounts.id, accountId))
988
+ .limit(1);
989
+ const account = accountRows[0];
990
+ if (!account || account.status === "disabled") return null;
991
+
992
+ // Re-classify with the account's own static-token flag. The caller's
993
+ // evidence is request-scoped and does not carry it, but an auth rejection
994
+ // against a static token must cool the account down rather than park it —
995
+ // there is no refresh to run, and the token may still be valid.
996
+ const effective = account.staticToken
997
+ ? classifyAccountError(error, { ...options, staticToken: true })
998
+ : classification;
999
+
1000
+ const fromStatus = account.status;
1001
+ const now = new Date();
1002
+
1003
+ const modelId = options?.modelId;
1004
+ // Every category here is a verdict about one (account, model) pair rather
1005
+ // than about the credential, so all three back off that pair and leave the
1006
+ // account routable for every other model. `model_unreachable` joins the two
1007
+ // throttles on the same reasoning: an upstream that reports the model does
1008
+ // not exist for this account will report it again on the next request.
1009
+ const isThrottle =
1010
+ effective.category === "rate_limit_transient" ||
1011
+ effective.category === "model_capacity" ||
1012
+ effective.category === "model_unreachable";
1013
+ const shouldModelCooldown = modelId !== undefined && isThrottle;
1014
+ // The account-wide streak, escalated for a provider-stated window. Read once
1015
+ // so the model-scoped and account-wide paths below cannot disagree about it:
1016
+ // this is the count *before* this failure, which the UPDATE below increments.
1017
+ const streak = Math.max(0, account.consecutiveFailures ?? 0);
1018
+ const escalatedCooldownMs = escalateFallbackCooldown(
1019
+ effective.cooldownMs,
1020
+ effective.providerStated === true,
1021
+ streak,
1022
+ );
1023
+
1024
+ // The unsupported-model ladder, computed before the branch: the classifier
1025
+ // could only state the base window because the authoritative hit count
1026
+ // lives on the row, and it is read here. `hits` is the new total for this
1027
+ // pair, so the first report is 1 and gets the base window.
1028
+ const blockHits = (account.modelBlockHits as Record<string, number> | null) ?? {};
1029
+ const unsupportedHits =
1030
+ effective.modelUnsupported === true && modelId !== undefined
1031
+ ? Math.max(1, (blockHits[modelId] ?? 0) + 1)
1032
+ : undefined;
1033
+ // An unsupported-model window is stated by *us*, not by the provider, so it
1034
+ // would otherwise be multiplied a second time by `escalateFallbackCooldown`
1035
+ // (which reads `consecutiveFailures`, an account-wide counter). The two
1036
+ // ladders are different things: one counts how many times this pair was
1037
+ // reported unsupported, the other how many failures this account produced.
1038
+ const unsupportedCooldownMs =
1039
+ unsupportedHits === undefined ? undefined : unsupportedModelCooldownMs(unsupportedHits);
1040
+
1041
+ if (shouldModelCooldown) {
1042
+ const existing = (account.modelCooldowns as Record<string, string> | null) ?? {};
1043
+ // Monotonic: never move an in-force window earlier (see `latestDeadlineMs`).
1044
+ // A provider-stated deadline wins outright because it is already the later
1045
+ // of the two whenever the provider really parked the pair longer.
1046
+ const nextUntil = new Date(
1047
+ latestDeadlineMs(
1048
+ effective.retryAt?.getTime(),
1049
+ Date.now() + (unsupportedCooldownMs ?? escalatedCooldownMs),
1050
+ existing[modelId] ? Date.parse(existing[modelId]!) : undefined,
1051
+ ),
1052
+ ).toISOString();
1053
+ const previousUntil = existing[modelId];
1054
+ // A re-statement is not an event: what the audit row reports is the
1055
+ // *entry* into a cooling episode, and the deadline itself is not a
1056
+ // column — so a row can only ever say "this pair started cooling for
1057
+ // this reason". The upstream answers the same "Try again in 11h" on
1058
+ // every retry (and an unstated reset is recomputed from now each time),
1059
+ // so writing per failure produced seven identical rows in twenty minutes
1060
+ // and read as an account flapping when nothing had changed. The entry is
1061
+ // still refreshed below — its error fields are what the dialog explains
1062
+ // the cooldown with — while an entry that is still in force suppresses
1063
+ // the duplicate row.
1064
+ const alreadyCooling =
1065
+ previousUntil !== undefined && new Date(previousUntil).getTime() > Date.now();
1066
+ // `status`/`cooldownUntil` stay untouched: only this (account, model)
1067
+ // pair backs off, and the account remains routable for every other model.
1068
+ // The error fields ARE written, because they are what provider detail and
1069
+ // the health dialog read to explain *why* a model is cooling — without
1070
+ // them the row showed "1 model cooling" with no reason at all.
1071
+ //
1072
+ // The streak advances here too. Without it the most common throttle — a
1073
+ // model-scoped rate limit — never escalated, because the counter was only
1074
+ // incremented on the account-wide path below, and `consecutiveFailures`
1075
+ // stays honest about what it always claimed to count: how many failures in
1076
+ // a row this account has just produced.
1077
+ await client
1078
+ .update(providerAccounts)
1079
+ .set({
1080
+ modelCooldowns: { ...existing, [modelId]: nextUntil },
1081
+ // Counted only for the unsupported-model ladder, and only when this
1082
+ // failure is that verdict — so a rate limit or a capacity refusal
1083
+ // never advances it. `model_cooldowns`' own shape is untouched, so
1084
+ // routing and the console keep parsing it exactly as before.
1085
+ ...(unsupportedHits === undefined
1086
+ ? {}
1087
+ : { modelBlockHits: { ...blockHits, [modelId]: unsupportedHits } }),
1088
+ consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
1089
+ lastError: effective.reason,
1090
+ lastErrorCategory: effective.category,
1091
+ lastErrorAt: now,
1092
+ })
1093
+ .where(eq(providerAccounts.id, accountId));
1094
+ if (!alreadyCooling) {
1095
+ await writeHealthEvent(client, {
1096
+ entityKind: "account",
1097
+ accountId,
1098
+ // The account's own status, on both sides: a model-scoped throttle
1099
+ // never moved it, and writing the classification's status here
1100
+ // claimed an `active → cooldown` transition that never happened — the
1101
+ // dialog showed "Account status: ACTIVE" above a list of transitions
1102
+ // to `cooldown`. The row still names the model and the reason, which
1103
+ // is what makes it worth keeping.
1104
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1105
+ toStatus: fromStatus as "active" | "cooldown" | "disabled",
1106
+ reason: effective.reason,
1107
+ errorCategory: effective.category,
1108
+ modelId: modelId ?? null,
1109
+ createdAt: now,
1110
+ });
1111
+ }
1112
+ // `status` and `cooldownUntil` are untouched on this path — only the
1113
+ // (account, model) pair backed off — so the snapshot is still accurate
1114
+ // and must not be dropped.
1115
+ return { ...classification, routesVisible: false };
1116
+ }
1117
+
1118
+ await client
1119
+ .update(providerAccounts)
1120
+ .set({
1121
+ status: effective.status,
1122
+ consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1`,
1123
+ lastError: effective.reason,
1124
+ lastErrorCategory: effective.category,
1125
+ lastErrorAt: now,
1126
+ // Monotonic against the account's own in-force deadline, for the same
1127
+ // reason as the model path: an unclassified 60-second failure must not
1128
+ // release an account the provider parked for hours.
1129
+ //
1130
+ // A `0` duration contributes no candidate rather than stamping *now*
1131
+ // (see `accountDeadlineMs`): the one state that asks for it is a
1132
+ // terminal automatic disable, and an elapsed deadline there would hand
1133
+ // the row straight back to the recovery sweep.
1134
+ cooldownUntil: accountDeadlineMs([
1135
+ effective.retryAt?.getTime(),
1136
+ escalatedCooldownMs > 0 ? Date.now() + escalatedCooldownMs : undefined,
1137
+ account.cooldownUntil?.getTime?.(),
1138
+ ]),
1139
+ })
1140
+ .where(eq(providerAccounts.id, accountId));
1141
+
1142
+ {
1143
+ await writeHealthEvent(client, {
1144
+ entityKind: "account",
1145
+ accountId,
1146
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1147
+ toStatus: effective.status,
1148
+ reason: effective.reason,
1149
+ errorCategory: effective.category,
1150
+ modelId: modelId ?? null,
1151
+ createdAt: now,
1152
+ });
1153
+ }
1154
+ // This path wrote `status` and `cooldownUntil`, both of which the snapshot
1155
+ // projects, so routing has to see it.
1156
+ return { ...classification, routesVisible: true };
1157
+ };
1158
+
1159
+ try {
1160
+ // Kept as a capability probe, not a correctness guard: this function is
1161
+ // reachable with a partially-implemented database handle (the dispatch
1162
+ // tests pass a narrow double), and a transaction is an optimization here,
1163
+ // not a requirement — the same writes are correct without one.
1164
+ if (typeof db.transaction === "function") {
1165
+ return await db.transaction((tx) => persist(tx as CartethyiaDatabase));
1166
+ }
1167
+ return await persist(db);
1168
+ } catch {
1169
+ return null;
1170
+ }
1171
+ }
1172
+
1173
+ /** Clears failure state after a successful dispatch; true when the account changed. */
1174
+ export async function recordAccountSuccess(
1175
+ db: CartethyiaDatabase,
1176
+ accountId: string,
1177
+ ): Promise<boolean> {
1178
+ const mutate = async (client: CartethyiaDatabase): Promise<boolean> => {
1179
+ // `recordAccountSuccess` runs on every successful dispatch, and for a
1180
+ // healthy account `hadFailures` is false — the row was fetched purely to
1181
+ // decide nothing. Projected so that no-op path does not pull the
1182
+ // credential ciphertext across the wire.
1183
+ const rows = await client
1184
+ .select({
1185
+ status: providerAccounts.status,
1186
+ consecutiveFailures: providerAccounts.consecutiveFailures,
1187
+ // Read only to decide whether there is anything to clear: the common
1188
+ // case (no unsupported-model hit ever recorded) must not write.
1189
+ modelBlockHits: providerAccounts.modelBlockHits,
1190
+ })
1191
+ .from(providerAccounts)
1192
+ .where(eq(providerAccounts.id, accountId))
1193
+ .limit(1);
1194
+ const account = rows[0];
1195
+ if (!account || account.status === "disabled") return false;
1196
+ const fromStatus = account.status;
1197
+ const hadFailures = (account.consecutiveFailures ?? 0) > 0 || account.status !== "active";
1198
+ const now = new Date();
1199
+ // The unsupported-model escalation restarts after a success: a model that
1200
+ // just served is not "not supported", so the next report of it must start
1201
+ // from the base window again rather than from wherever the ladder had
1202
+ // reached. `modelCooldowns` is deliberately NOT cleared — an in-force
1203
+ // window the provider stated survives, which is the existing behavior and
1204
+ // the reference gateways' too.
1205
+ const blockHits = account.modelBlockHits as Record<string, number> | null;
1206
+ const hasBlockHits = blockHits !== null && Object.keys(blockHits).length > 0;
1207
+ await client
1208
+ .update(providerAccounts)
1209
+ .set({
1210
+ status: "active",
1211
+ consecutiveFailures: 0,
1212
+ cooldownUntil: null,
1213
+ lastSuccessAt: now,
1214
+ ...(hadFailures ? { lastRecoveredAt: now } : {}),
1215
+ ...(hasBlockHits ? { modelBlockHits: {} } : {}),
1216
+ })
1217
+ .where(eq(providerAccounts.id, accountId));
1218
+ if (hadFailures && typeof client.insert === "function") {
1219
+ await client.insert(healthEvents).values({
1220
+ entityKind: "account",
1221
+ accountId,
1222
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1223
+ toStatus: "active",
1224
+ reason: "Request dispatched successfully",
1225
+ errorCategory: null,
1226
+ createdAt: now,
1227
+ });
1228
+ }
1229
+ return hadFailures;
1230
+ };
1231
+ try {
1232
+ if (typeof db.transaction === "function") {
1233
+ return await db.transaction((tx) => mutate(tx as CartethyiaDatabase));
1234
+ }
1235
+ return await mutate(db);
1236
+ } catch {
1237
+ return false;
1238
+ }
1239
+ }
1240
+
1241
+ /** Operator-initiated recovery: forces the account back to active and logs the transition. */
1242
+ export async function recoverAccount(
1243
+ db: CartethyiaDatabase,
1244
+ accountId: string,
1245
+ reason = "manual_operator_recovery",
1246
+ ): Promise<boolean> {
1247
+ const recover = async (client: CartethyiaDatabase): Promise<boolean> => {
1248
+ const accountRows = await client
1249
+ .select()
1250
+ .from(providerAccounts)
1251
+ .where(eq(providerAccounts.id, accountId))
1252
+ .limit(1);
1253
+ const account = accountRows[0];
1254
+ if (!account) return false;
1255
+ const fromStatus = account.status;
1256
+ const now = new Date();
1257
+ await client
1258
+ .update(providerAccounts)
1259
+ .set({
1260
+ status: "active",
1261
+ consecutiveFailures: 0,
1262
+ cooldownUntil: null,
1263
+ lastRecoveredAt: now,
1264
+ // Recovery is the operator asserting the account works now, so it must
1265
+ // clear EVERY routing exclusion, not just the account-wide ones. A
1266
+ // per-model cooldown entry excludes the (account, model) pair in
1267
+ // `route-catalog.ts`, so leaving it behind kept the account blocked for
1268
+ // that model through `/v1` even though the account read `active` and a
1269
+ // direct probe (which addresses the account by id, bypassing routing
1270
+ // eligibility) succeeded. Clearing the error fields for the same reason:
1271
+ // a stale reason on a recovered row misreports the current state.
1272
+ modelCooldowns: {},
1273
+ lastError: null,
1274
+ lastErrorCategory: null,
1275
+ lastErrorAt: null,
1276
+ })
1277
+ .where(eq(providerAccounts.id, accountId));
1278
+ {
1279
+ await writeHealthEvent(client, {
1280
+ entityKind: "account",
1281
+ accountId,
1282
+ fromStatus: fromStatus as "active" | "cooldown" | "disabled",
1283
+ toStatus: "active",
1284
+ reason,
1285
+ errorCategory: null,
1286
+ createdAt: now,
1287
+ });
1288
+ }
1289
+ return true;
1290
+ };
1291
+ try {
1292
+ if (typeof db.transaction === "function") {
1293
+ return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
1294
+ }
1295
+ return await recover(db);
1296
+ } catch {
1297
+ return false;
1298
+ }
1299
+ }
1300
+
1301
+ /** Recovers accounts whose cooldown elapsed and prunes expired per-model keys. */
1302
+ export async function sweepExpiredCooldowns(db: CartethyiaDatabase): Promise<number> {
1303
+ const recover = async (client: CartethyiaDatabase): Promise<number> => {
1304
+ const now = new Date();
1305
+ const nowIso = now.toISOString();
1306
+ const expired = await client
1307
+ .select({
1308
+ id: providerAccounts.id,
1309
+ status: providerAccounts.status,
1310
+ })
1311
+ .from(providerAccounts)
1312
+ .where(
1313
+ or(
1314
+ // An ordinary cooldown, recovered as soon as its window elapses.
1315
+ and(
1316
+ eq(providerAccounts.status, "cooldown"),
1317
+ isNotNull(providerAccounts.cooldownUntil),
1318
+ lte(providerAccounts.cooldownUntil, now),
1319
+ ),
1320
+ // An **automatic** disable. Selected only when it carries a deadline:
1321
+ // an operator's disable leaves
1322
+ // `cooldownUntil` null, so a human's decision still outranks the
1323
+ // machine and is never swept away. The deadline is written by the two
1324
+ // paths that disable on evidence — the classifier's revocation branch
1325
+ // and a definitively rejected refresh grant.
1326
+ and(
1327
+ eq(providerAccounts.status, "disabled"),
1328
+ isNotNull(providerAccounts.cooldownUntil),
1329
+ lte(providerAccounts.cooldownUntil, now),
1330
+ ),
1331
+ ),
1332
+ )
1333
+ // One pass recovers a bounded batch. The sweep runs every 30s, so a
1334
+ // backlog larger than this drains over successive ticks instead of one
1335
+ // statement covering every expired row — that statement holds its locks
1336
+ // for as long as the backlog takes.
1337
+ .limit(COOLDOWN_RECOVERY_BATCH);
1338
+ if (expired.length > 0) {
1339
+ await client
1340
+ .update(providerAccounts)
1341
+ .set({
1342
+ status: "active",
1343
+ consecutiveFailures: 0,
1344
+ cooldownUntil: null,
1345
+ lastRecoveredAt: now,
1346
+ // Cleared with the state it described. A recovered row that kept
1347
+ // `auth_invalidated` still rendered the console's "Re-login required"
1348
+ // pill above an `active` status, so the account read healthy to
1349
+ // routing and broken to the operator at the same time.
1350
+ lastErrorCategory: null,
1351
+ })
1352
+ .where(inArray(providerAccounts.id, expired.map((account) => account.id)));
1353
+ if (typeof client.insert === "function") {
1354
+ for (let offset = 0; offset < expired.length; offset += 500) {
1355
+ await client.insert(healthEvents).values(
1356
+ expired.slice(offset, offset + 500).map((account) => ({
1357
+ entityKind: "account" as const,
1358
+ accountId: account.id,
1359
+ fromStatus: account.status,
1360
+ toStatus: "active" as const,
1361
+ reason: "Cooldown period elapsed — auto-recovered",
1362
+ errorCategory: null,
1363
+ createdAt: now,
1364
+ })),
1365
+ );
1366
+ }
1367
+ }
1368
+ }
1369
+
1370
+ // Prune per-model cooldowns: remove entries whose ISO timestamp has passed.
1371
+ //
1372
+ // One set-based UPDATE rather than a SELECT followed by an UPDATE per row.
1373
+ // The per-row form issued one statement per account holding any cooldown —
1374
+ // on a deployment where many accounts are throttled at once that is the
1375
+ // sweep's dominant cost, and it all runs inside this transaction, holding
1376
+ // its locks for the whole pass. Recomputing the object in SQL does the same
1377
+ // work in one statement.
1378
+ //
1379
+ // `jsonb_typeof(...) = 'object'` guards the shape: a malformed value must
1380
+ // not abort the whole sweep. `IS DISTINCT FROM` keeps the update from
1381
+ // rewriting rows it
1382
+ // did not change, so the returned row count is the number of accounts whose
1383
+ // cooldown set actually shrank — the same thing the loop counted.
1384
+ const pruning = pruneExpiredCooldowns(sql`${providerAccounts.modelCooldowns}`, sql`${nowIso}`);
1385
+ const pruned = await client
1386
+ .update(providerAccounts)
1387
+ .set({ modelCooldowns: pruning.value })
1388
+ .where(pruning.changed);
1389
+ return expired.length + affectedRows(pruned);
1390
+ };
1391
+ try {
1392
+ if (typeof db.transaction === "function") {
1393
+ return await db.transaction((tx) => recover(tx as CartethyiaDatabase));
1394
+ }
1395
+ return await recover(db);
1396
+ } catch {
1397
+ return 0;
1398
+ }
1399
+ }
1400
+
1401
+ /**
1402
+ * Single attempt health reporter: one home for the success/failure writes
1403
+ * every dispatch attempt ends with. Success records recovery; failure
1404
+ * records with caller-supplied evidence (origin/scope classification plus
1405
+ * model id). A mutating write invalidates the route snapshot so the next
1406
+ * plan sees fresh health. Never throws — health must not fail requests.
1407
+ */
1408
+ export interface AttemptHealthReport {
1409
+ readonly accountId: string | undefined;
1410
+ readonly modelId?: string;
1411
+ readonly error?: unknown;
1412
+ readonly evidence?: AccountFailureEvidence;
1413
+ readonly snapshotService?: { invalidate(): unknown };
1414
+ }
1415
+
1416
+ /**
1417
+ * Writes one health audit row, off the request path when a buffer is installed.
1418
+ *
1419
+ * `health_events` is append-only and read by nobody in the request path, so it
1420
+ * does not need to land before the request finishes — unlike the
1421
+ * `provider_accounts` UPDATE beside it, which routing reads and which therefore
1422
+ * stays synchronous and transactional.
1423
+ */
1424
+ async function writeHealthEvent(
1425
+ client: CartethyiaDatabase,
1426
+ row: {
1427
+ readonly entityKind: "account";
1428
+ readonly accountId: string;
1429
+ readonly fromStatus: string;
1430
+ readonly toStatus: string;
1431
+ readonly reason: string;
1432
+ readonly errorCategory: string | null;
1433
+ readonly modelId?: string | null;
1434
+ readonly createdAt: Date;
1435
+ },
1436
+ ): Promise<void> {
1437
+ const buffer = healthEventBuffer();
1438
+ if (buffer !== undefined) {
1439
+ buffer.enqueue({ ...row, modelId: row.modelId ?? null });
1440
+ return;
1441
+ }
1442
+ if (typeof client.insert !== "function") return;
1443
+ await client.insert(healthEvents).values(row as never);
1444
+ }
1445
+
1446
+ export async function reportAttemptOutcome(
1447
+ db: CartethyiaDatabase,
1448
+ report: AttemptHealthReport,
1449
+ ): Promise<void> {
1450
+ if (!report.accountId) return;
1451
+ if (report.error === undefined) {
1452
+ const recovered = await recordAccountSuccess(db, report.accountId).catch(() => false);
1453
+ if (recovered) await report.snapshotService?.invalidate();
1454
+ return;
1455
+ }
1456
+ const classification = await recordAccountFailure(db, report.accountId, report.error, {
1457
+ ...report.evidence,
1458
+ ...(report.modelId === undefined ? {} : { modelId: report.modelId }),
1459
+ }).catch(() => null);
1460
+ // Gated on `routesVisible`, not on `mutatesAccount`: a failure that only
1461
+ // cooled one (account, model) pair, or only advanced the streak, changed
1462
+ // nothing routing reads. Invalidating for those rebuilt an 11-SELECT snapshot
1463
+ // on the next request, and under a failure wave each rebuild fed the next
1464
+ // invalidation.
1465
+ if (classification?.routesVisible) await report.snapshotService?.invalidate();
1466
+ }
1467
+
1468
+ export async function listAccountHealthEvents(
1469
+ db: CartethyiaDatabase,
1470
+ accountId: string,
1471
+ limit = 50,
1472
+ ): Promise<readonly AccountHealthEventRecord[]> {
1473
+ try {
1474
+ const rows = await db
1475
+ .select()
1476
+ .from(healthEvents)
1477
+ .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
1478
+ .orderBy(desc(healthEvents.createdAt))
1479
+ .limit(limit);
1480
+
1481
+ return rows.map((r) => ({
1482
+ id: r.id,
1483
+ accountId: r.accountId as string,
1484
+ fromStatus: r.fromStatus,
1485
+ toStatus: r.toStatus,
1486
+ modelId: r.modelId ?? null,
1487
+ reason: r.reason,
1488
+ errorCategory: r.errorCategory,
1489
+ createdAt: r.createdAt.toISOString(),
1490
+ }));
1491
+ } catch {
1492
+ return [];
1493
+ }
1494
+ }
1495
+
1496
+ /** Every stored row across accounts and pools — the size the purge offers to delete. */
1497
+ export async function countAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
1498
+ try {
1499
+ const rows = await db.select({ total: count() }).from(healthEvents);
1500
+ return rows[0]?.total ?? 0;
1501
+ } catch {
1502
+ return 0;
1503
+ }
1504
+ }
1505
+
1506
+ /**
1507
+ * Deletes stored health & error rows.
1508
+ *
1509
+ * Unlike the read above, a failed delete surfaces: an operator who purged
1510
+ * believes the trail is gone, so "it failed" must not read as "it succeeded
1511
+ * with zero rows". Callers own the flush-buffer barrier (`drain`) so a queued
1512
+ * write cannot land after these rows are gone.
1513
+ */
1514
+ export async function deleteAccountHealthEvents(
1515
+ db: CartethyiaDatabase,
1516
+ accountId: string,
1517
+ ): Promise<number> {
1518
+ const rows = await db
1519
+ .delete(healthEvents)
1520
+ .where(and(eq(healthEvents.entityKind, "account"), eq(healthEvents.accountId, accountId)))
1521
+ .returning({ id: healthEvents.id });
1522
+ return rows.length;
1523
+ }
1524
+
1525
+ export async function deleteAllHealthEvents(db: CartethyiaDatabase): Promise<number> {
1526
+ const rows = await db.delete(healthEvents).returning({ id: healthEvents.id });
1527
+ return rows.length;
1528
+ }