cartethyia 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -206,6 +206,17 @@ export interface AccountFailureEvidence {
206
206
  * count it reads itself.
207
207
  */
208
208
  readonly modelBlockHits?: number;
209
+ /**
210
+ * The upstream's own error body, when the caller has it.
211
+ *
212
+ * Providers state a reset window in structured fields far more reliably than
213
+ * in prose — a body carrying `resets_at`, `resets_in_seconds` or
214
+ * `limit_window_minutes` is an exact statement, while the same body's
215
+ * `message` ("The usage limit has been reached") names no window at all.
216
+ * Without this the classifier falls back to a fixed 15-minute cooldown for a
217
+ * limit the provider said resets in 23 days.
218
+ */
219
+ readonly upstreamBody?: unknown;
209
220
  }
210
221
 
211
222
  export interface AccountErrorClassification {
@@ -327,11 +338,157 @@ function accountDeadlineMs(
327
338
  return latest > 0 ? new Date(latest) : null;
328
339
  }
329
340
 
341
+ /**
342
+ * Bounded to a day, matching `MAX_COOLDOWN_MS` in the failure policy: a stated
343
+ * window longer than that is a monthly or plan-level limit, and parking an
344
+ * account for a month on one upstream field is a decision for the operator,
345
+ * not an automatic one.
346
+ */
347
+ const MAX_UPSTREAM_STATED_COOLDOWN_MS = 24 * 3600 * 1000;
330
348
 
331
- export function classifyAccountError(
332
- error: unknown,
333
- options?: AccountFailureEvidence,
334
- ): AccountErrorClassification {
349
+ /** Parses the stored upstream text as JSON. Returns `undefined` when it is not. */
350
+ function tryParseUpstreamBody(text: string): unknown {
351
+ try {
352
+ const parsed: unknown = JSON.parse(text);
353
+ return typeof parsed === "object" && parsed !== null ? parsed : undefined;
354
+ } catch {
355
+ return undefined;
356
+ }
357
+ }
358
+
359
+ /**
360
+ * Reads a reset window the upstream stated in structured fields.
361
+ *
362
+ * A body like `{"error":{"type":"usage_limit_reached","resets_at":1795019113,
363
+ * "resets_in_seconds":2072028}}` states the window exactly, while its own
364
+ * `message` ("The usage limit has been reached") states none — so parsing only
365
+ * prose leaves a 23-day limit cooling for the 15-minute fallback, and the
366
+ * account re-enters rotation hours before it can serve.
367
+ *
368
+ * Both spellings are read because providers use both: an absolute epoch
369
+ * (`resets_at`, seconds or milliseconds) and a relative delay
370
+ * (`resets_in_seconds`). The later of the two wins, since they are the same
371
+ * claim computed at slightly different instants.
372
+ */
373
+ function parseUpstreamBodyReset(body: unknown): number | null {
374
+ if (typeof body !== "object" || body === null) return null;
375
+ const record = body as Record<string, unknown>;
376
+ // Some providers nest under `error`, some return it at the top level.
377
+ const candidates = [record, (record["error"] as Record<string, unknown> | undefined) ?? undefined];
378
+ let best: number | null = null;
379
+ for (const candidate of candidates) {
380
+ if (typeof candidate !== "object" || candidate === null) continue;
381
+ const now = Date.now();
382
+
383
+ const absolute = candidate["resets_at"] ?? candidate["reset_at"];
384
+ if (typeof absolute === "number" && Number.isFinite(absolute) && absolute > 0) {
385
+ // Seconds vs milliseconds: a plausible epoch in seconds is ~10 digits, in
386
+ // milliseconds ~13, and the two are four orders of magnitude apart — so
387
+ // the smaller is scaled up rather than guessed from a threshold date.
388
+ const epochMs = absolute < 1e11 ? absolute * 1000 : absolute;
389
+ const delay = epochMs - now;
390
+ if (delay > 0) best = best === null ? delay : Math.max(best, delay);
391
+ }
392
+
393
+ const relative =
394
+ candidate["resets_in_seconds"] ??
395
+ candidate["reset_in_seconds"] ??
396
+ candidate["retry_after_seconds"];
397
+ if (typeof relative === "number" && Number.isFinite(relative) && relative > 0) {
398
+ const delay = relative * 1000;
399
+ best = best === null ? delay : Math.max(best, delay);
400
+ }
401
+
402
+ const minutes = candidate["limit_window_minutes"] ?? candidate["reset_in_minutes"];
403
+ if (typeof minutes === "number" && Number.isFinite(minutes) && minutes > 0) {
404
+ const delay = minutes * 60_000;
405
+ best = best === null ? delay : Math.max(best, delay);
406
+ }
407
+ }
408
+ return best === null ? null : Math.min(MAX_UPSTREAM_STATED_COOLDOWN_MS, Math.round(best));
409
+ }
410
+
411
+
412
+ interface AccountErrorContext {
413
+ readonly message: string;
414
+ readonly lower: string;
415
+ readonly folded: string;
416
+ readonly statusCode: number | null;
417
+ readonly origin: AccountFailureOrigin;
418
+ readonly scope: AccountFailureScope;
419
+ readonly revocationNamed: boolean;
420
+ readonly accountEvidence: boolean;
421
+ readonly canMutate: boolean;
422
+ readonly providerCode: string;
423
+ readonly statedCooldownMs: number | null;
424
+ readonly reason: (prefix: string) => string;
425
+ readonly result: (
426
+ category: AccountErrorCategory,
427
+ status: AccountErrorClassification["status"],
428
+ cooldownMs: number,
429
+ retryAt: Date | null,
430
+ explanation: string,
431
+ mutatesAccount?: boolean,
432
+ modelUnsupported?: boolean,
433
+ ) => AccountErrorClassification;
434
+ }
435
+
436
+ /**
437
+ * One arm of the classifier: an ordered `(match, decide)` pair.
438
+ *
439
+ * The arms are **not** independent predicates. Later arms are deliberately
440
+ * broader than earlier ones — `quotaSignal` matches every price refusal, and
441
+ * `authSignal` matches a superset of the request-scoped codes — so a match
442
+ * only wins because it is tried first. That is why they live in one ordered
443
+ * list rather than behind a dispatch map or a set of independent `if`s: the
444
+ * list is the precedence, and reading it top to bottom is reading the
445
+ * decision.
446
+ *
447
+ * `match` is a pure function of the shared signals (no closure over the
448
+ * classifier's locals, no allocation per classification): the table is built
449
+ * once per call from values already computed for every arm.
450
+ */
451
+ interface AccountErrorRule {
452
+ readonly name: string;
453
+ readonly match: (signals: AccountErrorSignals) => boolean;
454
+ readonly decide: (signals: AccountErrorSignals) => AccountErrorClassification;
455
+ }
456
+
457
+ /** The shared signals every arm reads, computed once per classification. */
458
+ interface AccountErrorSignals {
459
+ readonly message: string;
460
+ readonly lower: string;
461
+ readonly folded: string;
462
+ readonly statusCode: number | null;
463
+ readonly origin: AccountFailureOrigin;
464
+ readonly scope: AccountFailureScope;
465
+ readonly providerCode: string;
466
+ readonly statedCooldownMs: number | null;
467
+ readonly reason: (prefix: string) => string;
468
+ readonly result: AccountErrorContext["result"];
469
+ readonly canMutate: boolean;
470
+ readonly accountEvidence: boolean;
471
+ readonly revocationNamed: boolean;
472
+ /** Echo of the caller's evidence; read only by the arms that need it. */
473
+ readonly modelId: string | undefined;
474
+ readonly modelBlockHits: number;
475
+ readonly staticToken: boolean;
476
+ readonly credentialKind: string | undefined;
477
+ /** Buddy-family provider: the only family with provider-specific codes below. */
478
+ readonly buddy: boolean;
479
+ readonly priceRefusal: boolean;
480
+ readonly deploymentInfraError: boolean;
481
+ readonly quotaSignal: boolean;
482
+ readonly policyRejection: boolean;
483
+ readonly requestScoped: boolean;
484
+ readonly channelRejection: boolean;
485
+ readonly hostedToolFailure: boolean;
486
+ readonly modelEntitlementFailure: boolean;
487
+ readonly queuedUpstream: boolean;
488
+ }
489
+
490
+ /** Build the request-independent evidence consumed by the ordered classifier. */
491
+ function accountErrorContext(error: unknown, options?: AccountFailureEvidence): AccountErrorContext {
335
492
  const errorRecord = isRecord(error) ? error : undefined;
336
493
  const message =
337
494
  error instanceof Error
@@ -380,15 +537,33 @@ export function classifyAccountError(
380
537
  options?.retryAfterMs ??
381
538
  parseUpstreamBackoff({ get: getHeader });
382
539
  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
540
  const rawUpstreamResponse =
387
541
  origin === "upstream" &&
388
542
  error instanceof GatewayError &&
389
543
  typeof error.details.raw === "string"
390
544
  ? error.details.raw
391
545
  : undefined;
546
+ // The body's own fields, when they can be reached. Checked alongside the
547
+ // header and the prose rather than instead of them: a provider may state the
548
+ // window in any of the three, and the longest honest statement wins.
549
+ //
550
+ // `details.raw` is tried even when no caller passed a parsed body, because
551
+ // that is where the upstream's JSON actually reaches this function — the
552
+ // dispatch path stores the response text there. A structured window read from
553
+ // it is the difference between a 15-minute fallback and the 23 days the
554
+ // provider stated.
555
+ const bodyCooldown =
556
+ parseUpstreamBodyReset(options?.upstreamBody) ??
557
+ (rawUpstreamResponse === undefined ? null : parseUpstreamBodyReset(tryParseUpstreamBody(rawUpstreamResponse)));
558
+ // Whether the provider itself named a window. Decided once, from the
559
+ // evidence, so every branch of this classification agrees about it.
560
+ const providerStated =
561
+ headerCooldown !== null || messageCooldown !== null || bodyCooldown !== null;
562
+ const statedCooldownMs =
563
+ [headerCooldown, messageCooldown, bodyCooldown].reduce<number | null>(
564
+ (best, value) => (value === null ? best : best === null ? value : Math.max(best, value)),
565
+ null,
566
+ );
392
567
  const reason = (prefix: string): string => `${prefix}: ${message.slice(0, 160)}`;
393
568
  const result = (
394
569
  category: AccountErrorCategory,
@@ -414,6 +589,49 @@ export function classifyAccountError(
414
589
  modelUnsupported,
415
590
  });
416
591
 
592
+ return {
593
+ message,
594
+ lower,
595
+ folded,
596
+ statusCode,
597
+ origin,
598
+ scope,
599
+ revocationNamed,
600
+ accountEvidence,
601
+ canMutate,
602
+ providerCode,
603
+ statedCooldownMs,
604
+ reason,
605
+ result,
606
+ };
607
+ }
608
+
609
+ export function classifyAccountError(
610
+ error: unknown,
611
+ options?: AccountFailureEvidence,
612
+ ): AccountErrorClassification {
613
+ const {
614
+ message,
615
+ lower,
616
+ folded,
617
+ statusCode,
618
+ origin,
619
+ scope,
620
+ revocationNamed,
621
+ accountEvidence,
622
+ canMutate,
623
+ providerCode,
624
+
625
+ statedCooldownMs,
626
+ reason,
627
+ result,
628
+ } = accountErrorContext(error, options);
629
+ const modelId = options?.modelId;
630
+ const modelBlockHits = Math.max(1, Math.floor(options?.modelBlockHits ?? 1));
631
+ const staticToken = options?.staticToken === true;
632
+ const credentialKind = options?.credentialKind;
633
+ const buddy = BUDDY_PROVIDER_IDS.has(options?.providerId?.toLowerCase() ?? "");
634
+
417
635
  // A price refusal is a verdict on the *request*, not on the account. inferhub
418
636
  // answers 402 with `no provider's ask matches your max-per-mtok bid`: the
419
637
  // caller's own price ceiling was below every upstream ask, which says nothing
@@ -458,7 +676,7 @@ export function classifyAccountError(
458
676
  const modelFrequencyWindow =
459
677
  lower.includes("frequency limit") && lower.includes("switch to the other models");
460
678
  if (modelFrequencyWindow) {
461
- const cooldownMs = messageCooldown ?? headerCooldown ?? DEFAULT_QUOTA_COOLDOWN_MS();
679
+ const cooldownMs = statedCooldownMs ?? DEFAULT_QUOTA_COOLDOWN_MS();
462
680
  return result(
463
681
  "rate_limit_transient",
464
682
  "cooldown",
@@ -669,278 +887,39 @@ export function classifyAccountError(
669
887
  lower.includes("queuecount") ||
670
888
  lower.includes("queuetype") ||
671
889
  (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
890
 
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
- }
891
+ const signals: AccountErrorSignals = {
892
+ message,
893
+ lower,
894
+ folded,
895
+ statusCode,
896
+ origin,
897
+ scope,
898
+ providerCode,
899
+ statedCooldownMs,
900
+ reason,
901
+ result,
902
+ canMutate,
903
+ accountEvidence,
904
+ revocationNamed,
905
+ modelId,
906
+ modelBlockHits,
907
+ staticToken,
908
+ credentialKind,
909
+ buddy,
910
+ priceRefusal,
911
+ deploymentInfraError,
912
+ quotaSignal,
913
+ policyRejection,
914
+ requestScoped,
915
+ channelRejection,
916
+ hostedToolFailure,
917
+ modelEntitlementFailure,
918
+ queuedUpstream,
919
+ };
916
920
 
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
- );
921
+ for (const rule of ACCOUNT_ERROR_RULES) {
922
+ if (rule.match(signals)) return rule.decide(signals);
944
923
  }
945
924
 
946
925
  // Same reasoning as above, with the shortest backoff: an unmapped failure
@@ -956,6 +935,326 @@ export function classifyAccountError(
956
935
  );
957
936
  }
958
937
 
938
+ /**
939
+ * The ordered arms of {@link classifyAccountError}, tried top to bottom; the
940
+ * first match decides. Order is behavior, not presentation — see
941
+ * {@link AccountErrorRule}.
942
+ */
943
+ const ACCOUNT_ERROR_RULES: readonly AccountErrorRule[] = [
944
+ {
945
+ name: "deployment-infrastructure",
946
+ match: (s) => s.deploymentInfraError,
947
+ decide: (s) =>
948
+ s.result(
949
+ "server_error",
950
+ "cooldown",
951
+ 60_000,
952
+ null,
953
+ `Proxy deployment error: ${s.message.slice(0, 160)}`,
954
+ false,
955
+ ),
956
+ },
957
+ {
958
+ name: "model-frequency-window",
959
+ match: (s) => s.lower.includes("frequency limit") && s.lower.includes("switch to the other models"),
960
+ decide: (s) => {
961
+ const cooldownMs = s.statedCooldownMs ?? DEFAULT_QUOTA_COOLDOWN_MS();
962
+ return s.result(
963
+ "rate_limit_transient",
964
+ "cooldown",
965
+ cooldownMs,
966
+ new Date(Date.now() + cooldownMs),
967
+ s.reason("Provider frequency limit for this model"),
968
+ // Provider-scoped evidence still has to reach the writer: a stream frame
969
+ // carries no credential detail, so `canMutate` is false and without this
970
+ // the pair would be retried for the whole window it was closed for.
971
+ s.origin === "upstream" && s.scope === "provider",
972
+ );
973
+ },
974
+ },
975
+ {
976
+ name: "content-policy-block",
977
+ match: (s) => s.policyRejection && s.buddy,
978
+ decide: (s) => {
979
+ const policyBlockMs = resolveAccountPolicyBlockCooldownMs();
980
+ return s.result(
981
+ "policy_blocked",
982
+ "cooldown",
983
+ policyBlockMs,
984
+ new Date(Date.now() + policyBlockMs),
985
+ s.reason("Provider content-policy block"),
986
+ s.origin === "upstream",
987
+ );
988
+ },
989
+ },
990
+ {
991
+ name: "request-scoped-rejection",
992
+ match: (s) => s.requestScoped && s.buddy,
993
+ decide: (s) =>
994
+ s.result("unknown", "cooldown", 0, null, s.reason("Request-scoped upstream rejection"), false),
995
+ },
996
+ {
997
+ name: "channel-rejection",
998
+ match: (s) => s.channelRejection && s.buddy,
999
+ decide: (s) => {
1000
+ const channelBlockMs = resolveAccountPolicyBlockCooldownMs();
1001
+ return s.result(
1002
+ "policy_blocked",
1003
+ "cooldown",
1004
+ channelBlockMs,
1005
+ new Date(Date.now() + channelBlockMs),
1006
+ s.reason("Provider channel rejection"),
1007
+ s.origin === "upstream",
1008
+ );
1009
+ },
1010
+ },
1011
+ {
1012
+ name: "credential-rejected",
1013
+ match: (s) =>
1014
+ !s.quotaSignal &&
1015
+ !s.policyRejection &&
1016
+ !s.hostedToolFailure &&
1017
+ !s.modelEntitlementFailure &&
1018
+ !s.queuedUpstream &&
1019
+ (s.providerCode === "authentication_failed" ||
1020
+ s.folded.includes("invalid api key") ||
1021
+ s.folded.includes("incorrect api key") ||
1022
+ s.folded.includes("invalid token") ||
1023
+ s.folded.includes("token revoked") ||
1024
+ s.folded.includes("invalidated oauth token") ||
1025
+ s.folded.includes("reauthorization required") ||
1026
+ s.folded.includes("account deactivated") ||
1027
+ ((s.statusCode === 401 || s.statusCode === 403) && s.accountEvidence)),
1028
+ decide: (s) => {
1029
+ // A static token has no refresh to run, so an auth rejection must not park
1030
+ // the account: disabling it throws away a token that may still work (a
1031
+ // single upstream 401 can be transient). A cooldown lets the next attempt
1032
+ // retry the same token without losing the account.
1033
+ const oauthRecovery =
1034
+ !s.staticToken &&
1035
+ s.credentialKind === "oauth" &&
1036
+ (s.lower.includes("expired") || s.lower.includes("refresh"));
1037
+ // A *named* revocation is the one auth outcome that still parks the account.
1038
+ // The upstream said the credential itself is dead — not that it expired, not
1039
+ // that this request was refused — so there is no window to wait out and no
1040
+ // refresh to run, and a cooldown would only return it to rotation to fail
1041
+ // again. Every other auth rejection is weak evidence (clock skew, a key
1042
+ // rotated a moment ago, an edge answering before the credential loads) and
1043
+ // cools down instead.
1044
+ //
1045
+ // This distinction is the whole fix. Reaching `disabled` from the generic
1046
+ // `401`/`403` branch would be unrecoverable by any sweep, so one transient
1047
+ // rejection would remove the account from rotation until an operator
1048
+ // noticed — the exact outcome the comment above says a static token must
1049
+ // not suffer.
1050
+ const revoked = s.revocationNamed;
1051
+ const recoverable = s.staticToken || oauthRecovery || !revoked;
1052
+ // An automatic disable is **not** terminal any more, but the deadline is what
1053
+ // makes that safe: `sweepExpiredCooldowns` recovers a disabled row only when
1054
+ // its `cooldownUntil` has elapsed, and an operator-disabled row keeps that
1055
+ // column null. So the machine can undo its own verdict while an operator's
1056
+ // decision still outranks it — see `resolveAccountDisabledRecoveryMs`.
1057
+ const disabledRecoveryMs = resolveAccountDisabledRecoveryMs();
1058
+ const disabledRetryAt =
1059
+ disabledRecoveryMs > 0 ? new Date(Date.now() + disabledRecoveryMs) : null;
1060
+ const cooldownMs = recoverable ? resolveAccountAuthRejectedCooldownMs() : disabledRecoveryMs;
1061
+ return s.result(
1062
+ "auth_invalidated",
1063
+ recoverable ? "cooldown" : "disabled",
1064
+ cooldownMs,
1065
+ recoverable ? new Date(Date.now() + cooldownMs) : disabledRetryAt,
1066
+ s.reason(
1067
+ !revoked
1068
+ ? "Provider credential rejected; retrying after a backoff"
1069
+ : s.staticToken
1070
+ ? "Static token rejected; retrying without a refresh"
1071
+ : oauthRecovery
1072
+ ? "OAuth credential requires refresh"
1073
+ : "Provider credential revoked",
1074
+ ),
1075
+ s.canMutate,
1076
+ );
1077
+ },
1078
+ },
1079
+ {
1080
+ name: "price-refusal",
1081
+ match: (s) => s.priceRefusal,
1082
+ decide: (s) =>
1083
+ s.result("quota_exhausted", "cooldown", 0, null, s.reason("Request price refused by the provider"), false),
1084
+ },
1085
+ {
1086
+ name: "model-plan-gated",
1087
+ match: (s) => s.quotaSignal && s.providerCode === "112",
1088
+ decide: (s) => {
1089
+ const cooldownMs = unsupportedModelCooldownMs(s.modelBlockHits);
1090
+ return s.result(
1091
+ "model_unreachable",
1092
+ "cooldown",
1093
+ cooldownMs,
1094
+ new Date(Date.now() + cooldownMs),
1095
+ s.reason("Model not included in this account's plan"),
1096
+ s.origin === "upstream" && s.modelId !== undefined,
1097
+ true,
1098
+ );
1099
+ },
1100
+ },
1101
+ {
1102
+ name: "quota-exhausted",
1103
+ match: (s) => s.quotaSignal,
1104
+ decide: (s) => {
1105
+ // xAI Grok Build's free tier resets on a rolling window. Use a duration
1106
+ // stated by the provider when present; otherwise park for the conservative
1107
+ // 24-hour fallback.
1108
+ const grokFreeTier =
1109
+ s.providerCode === "subscription:free-usage-exhausted" ||
1110
+ s.providerCode.startsWith("subscription:") ||
1111
+ s.lower.includes("free-usage") ||
1112
+ s.lower.includes("free usage") ||
1113
+ s.lower.includes("included free usage") ||
1114
+ s.lower.includes("rolling 24-hour") ||
1115
+ s.lower.includes("rolling 24 hour");
1116
+ const buddyCreditsExhausted =
1117
+ s.providerCode === "14018" || s.lower.includes("credits exhausted");
1118
+ const cooldownMs = grokFreeTier
1119
+ ? (s.statedCooldownMs ?? GROK_QUOTA_COOLDOWN_MS)
1120
+ : buddyCreditsExhausted
1121
+ ? (s.statedCooldownMs ?? 12 * 60 * 60 * 1000)
1122
+ : (s.statedCooldownMs ?? DEFAULT_QUOTA_COOLDOWN_MS());
1123
+ // The caller records against the concrete account that just failed
1124
+ // upstream, so a provider-scoped quota error still cools THAT account
1125
+ // down (enables fallback to the next account). Local-origin quota must
1126
+ // never mutate an upstream account.
1127
+ return s.result(
1128
+ "quota_exhausted",
1129
+ "cooldown",
1130
+ cooldownMs,
1131
+ new Date(Date.now() + cooldownMs),
1132
+ s.reason("Provider quota exhausted"),
1133
+ s.origin === "upstream",
1134
+ );
1135
+ },
1136
+ },
1137
+ {
1138
+ name: "rate-limit",
1139
+ match: (s) =>
1140
+ s.statusCode === 429 ||
1141
+ s.providerCode === "rate_limit_exceeded" ||
1142
+ s.lower.includes("rate_limit") ||
1143
+ s.lower.includes("rate limit") ||
1144
+ s.lower.includes("too many requests") ||
1145
+ s.lower.includes("requests per minute") ||
1146
+ s.lower.includes("tokens per minute") ||
1147
+ s.lower.includes("tpm limit") ||
1148
+ s.lower.includes("rpm limit"),
1149
+ decide: (s) => {
1150
+ const cooldownMs = s.statedCooldownMs ?? DEFAULT_RATE_LIMIT_COOLDOWN_MS();
1151
+ // Same per-account reasoning as quota above: the failing account (or
1152
+ // its model, when modelId is present) backs off so routing tries the
1153
+ // next candidate instead of hammering the same key.
1154
+ return s.result(
1155
+ "rate_limit_transient",
1156
+ "cooldown",
1157
+ cooldownMs,
1158
+ new Date(Date.now() + cooldownMs),
1159
+ s.reason("Provider rate limit"),
1160
+ s.origin === "upstream",
1161
+ );
1162
+ },
1163
+ },
1164
+ {
1165
+ name: "model-capacity",
1166
+ match: (s) =>
1167
+ s.statusCode === 503 ||
1168
+ s.statusCode === 529 ||
1169
+ s.providerCode === "model_capacity" ||
1170
+ s.queuedUpstream ||
1171
+ s.lower.includes("model overloaded") ||
1172
+ s.lower.includes("capacity exceeded") ||
1173
+ s.lower.includes("temporarily unavailable"),
1174
+ decide: (s) => {
1175
+ const cooldownMs = s.statedCooldownMs ?? MODEL_CAPACITY_COOLDOWN_MS();
1176
+ const modelScopedMutation = s.origin === "upstream" && s.modelId !== undefined;
1177
+ return s.result(
1178
+ "model_capacity",
1179
+ "cooldown",
1180
+ cooldownMs,
1181
+ new Date(Date.now() + cooldownMs),
1182
+ s.reason("Provider model capacity"),
1183
+ modelScopedMutation,
1184
+ );
1185
+ },
1186
+ },
1187
+ {
1188
+ name: "model-unsupported",
1189
+ match: (s) => isUnsupportedModelEvidence(s.statusCode, s.providerCode, s.folded),
1190
+ decide: (s) => {
1191
+ const cooldownMs = unsupportedModelCooldownMs(s.modelBlockHits);
1192
+ return s.result(
1193
+ "model_unreachable",
1194
+ "cooldown",
1195
+ cooldownMs,
1196
+ new Date(Date.now() + cooldownMs),
1197
+ s.reason("Model not supported by this upstream"),
1198
+ s.origin === "upstream" && s.modelId !== undefined,
1199
+ true,
1200
+ );
1201
+ },
1202
+ },
1203
+ {
1204
+ name: "model-unreachable",
1205
+ match: (s) => s.providerCode === "not_found" || s.modelEntitlementFailure,
1206
+ decide: (s) => {
1207
+ const cooldownMs = MODEL_UNREACHABLE_COOLDOWN_MS;
1208
+ // Not credential evidence, so `classifyUpstreamFailure` scopes this to the
1209
+ // provider and the `scope === "account"` gate the throttles use would
1210
+ // forbid the mutation outright — leaving the probe reporting a failure that
1211
+ // cooled nothing. The verdict is still about one (account, model) pair:
1212
+ // `recordAccountFailure` writes `modelCooldowns[modelId]` and leaves the
1213
+ // account's own status untouched, so all that is required is a model to
1214
+ // write against.
1215
+ const modelScopedMutation = s.origin === "upstream" && s.modelId !== undefined;
1216
+ return s.result(
1217
+ "model_unreachable",
1218
+ "cooldown",
1219
+ cooldownMs,
1220
+ new Date(Date.now() + cooldownMs),
1221
+ s.reason("Model unreachable for this account"),
1222
+ modelScopedMutation,
1223
+ );
1224
+ },
1225
+ },
1226
+ {
1227
+ name: "transport-fault",
1228
+ match: (s) =>
1229
+ (s.statusCode !== null && s.statusCode >= 500 && s.statusCode <= 504) ||
1230
+ s.lower.includes("timeout") ||
1231
+ s.lower.includes("abort") ||
1232
+ s.lower.includes("econnrefused") ||
1233
+ s.lower.includes("fetch failed"),
1234
+ decide: (s) => {
1235
+ // A transport-shaped fault clears on its own, so it is a cooldown with a
1236
+ // deadline rather than a state needing an operator. The deadline matters:
1237
+ // `sweepExpiredCooldowns` selects on `cooldownUntil IS NOT NULL`, so a row
1238
+ // parked without one is never recovered and stays out of rotation.
1239
+ //
1240
+ // `canMutate` rather than a flat `false`: a 5xx whose failure the caller
1241
+ // scoped to the account (an edge/WAF challenge answered to one account's
1242
+ // requests while its siblings on the same egress served fine) must cool
1243
+ // that account down, or it stays `active` and is re-dialled for as long as
1244
+ // the block lasts. Provider-scoped 5xx does not: the upstream is
1245
+ // indiscriminately failing and no single account is at fault.
1246
+ const transientMs = TRANSIENT_ERROR_COOLDOWN_MS();
1247
+ return s.result(
1248
+ s.lower.includes("timeout") ? "timeout" : "server_error",
1249
+ "cooldown",
1250
+ transientMs,
1251
+ new Date(Date.now() + transientMs),
1252
+ s.reason(`Provider ${s.statusCode ?? "network"} failure`),
1253
+ s.canMutate,
1254
+ );
1255
+ },
1256
+ },
1257
+ ];
959
1258
 
960
1259
  /** Persists one classified failure to the account row and appends a health event. */
961
1260
  export async function recordAccountFailure(
@@ -965,7 +1264,34 @@ export async function recordAccountFailure(
965
1264
  options?: AccountFailureEvidence,
966
1265
  ): Promise<AccountErrorClassification | null> {
967
1266
  const classification = classifyAccountError(error, options);
968
- if (!classification.mutatesAccount) return classification;
1267
+ /**
1268
+ * Logged even when the classification does not mutate the account.
1269
+ *
1270
+ * `rate_limit_transient` seen on a stream frame carries no credential
1271
+ * detail, so it classifies at provider scope and `mutatesAccount` is false
1272
+ * — nothing is cooled and the account stays `active`. That part is correct:
1273
+ * the upstream's own remedy ("switch to the other models") only works if the
1274
+ * account keeps serving. But this returned without writing anything, so a
1275
+ * rate-limited request showed up in the request log while the account's
1276
+ * health log stayed empty — a symptom with no recorded cause.
1277
+ *
1278
+ * The row is the whole point of this branch: it records the failure without
1279
+ * moving the account. Both status sides are the classification's own, so the
1280
+ * dialog names the cause without claiming a transition that never happened.
1281
+ */
1282
+ if (!classification.mutatesAccount) {
1283
+ void writeHealthEvent(db, {
1284
+ entityKind: "account",
1285
+ accountId,
1286
+ fromStatus: classification.status,
1287
+ toStatus: classification.status,
1288
+ reason: classification.reason,
1289
+ errorCategory: classification.category,
1290
+ modelId: options?.modelId ?? null,
1291
+ createdAt: new Date(),
1292
+ }).catch(() => undefined);
1293
+ return classification;
1294
+ }
969
1295
 
970
1296
  const persist = async (client: CartethyiaDatabase): Promise<AccountErrorClassification | null> => {
971
1297
  // Projected, not a bare `select()`: this runs on *every* attempt outcome,