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
@@ -1,441 +1,441 @@
1
- // Shared account-quota refresh: one home for "resolve credential → fetch quota →
2
- // cache under the right lens → stamp the account row".
3
- //
4
- // Four callers need exactly this sequence — the console's single/bulk refresh
5
- // routes, the overview's background fill, the periodic sweep worker, and the
6
- // global-admin refresh — so it lives here rather than inside the route module.
7
- // A drift between them would mean the page and the worker disagree about what
8
- // "refreshed" means.
9
- import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
10
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
11
- import { providerAccounts } from "../../persistence/schema";
12
- import type { RedisClient } from "../../persistence/redis";
13
- import { log } from "../../observability/logger";
14
- import { fetchProviderQuota } from "../../providers/quota/quota-support";
15
- import { type FetchLike, type ProviderQuotaResult } from "../../providers/quota/quota-contracts";
16
- import type { ProviderId } from "../../providers/provider-contracts";
17
- import type { ProviderRegistry } from "../../providers/provider-registry";
18
- import { GLOBAL_QUOTA_LENS, setCachedQuota } from "./cache";
19
- import { record } from "../../providers/authentication/oauth-flow-store";
20
-
21
- /** Why an account quota refresh failed; `null` means the fetch succeeded. */
22
- export type QuotaRefreshFailureCode = "missing_credential" | "quota_fetch_failed";
23
-
24
- export interface QuotaRefreshOutcome {
25
- readonly quota: ProviderQuotaResult;
26
- readonly failure: QuotaRefreshFailureCode | null;
27
- /** Display identity the quota surface reported, when it reported one. */
28
- readonly accountLabel?: string;
29
- }
30
-
31
- /** Abort budget for one upstream quota fetch. */
32
- export const QUOTA_REFRESH_TIMEOUT_MS = 15_000;
33
-
34
- /**
35
- * Consecutive quota refusals before an account is parked. One refusal can be
36
- * the provider's own hiccup; three in a row is the quota surface not coming
37
- * back, and the account should stop being dialed until the cooldown elapses.
38
- */
39
- export const QUOTA_FAILURE_PARK_THRESHOLD = 3;
40
- /** How long a quota-parked account stays out of rotation. */
41
- export const QUOTA_FAILURE_COOLDOWN_MS = 6 * 60 * 60 * 1000;
42
-
43
- export interface QuotaRefreshDeps {
44
- readonly db: CartethyiaDatabase;
45
- readonly redis: RedisClient | undefined;
46
- readonly providerRegistry: ProviderRegistry;
47
- /**
48
- * Resolves a stored credential through the refresh-aware path (OAuth tokens
49
- * are refreshed before use), returning `""` when the account has none.
50
- * API-key accounts are wrapped in the provider's key envelope when one
51
- * exists, so a key-only credential never reaches an OAuth-only surface.
52
- */
53
- readonly resolveCredential: (providerId: ProviderId, accountId: string) => Promise<string>;
54
- /** Marks a stored api_key credential with the upstream-distinguishable envelope. */
55
- readonly markApiKeyCredential?: (providerId: string, credential: string) => string;
56
- /** Invalidates the route snapshot when a stamped figure may exclude an account. */
57
- readonly snapshotInvalidator?: { invalidate(): unknown } | undefined;
58
- }
59
-
60
- /** Bound on concurrently tracked single-flight quota refreshes; keys are account ids, so the real bound is the account count. */
61
- const MAX_INFLIGHT = 1_000;
62
-
63
- /**
64
- * In-flight promise deduplication map for account quota refreshes. Keyed by
65
- * accountId so concurrent callers (a manual refresh plus the sweep, or two
66
- * rapid clicks) share one upstream fetch instead of hammering the provider.
67
- */
68
- export const inFlightAccountQuotaRefreshes = new Map<string, Promise<QuotaRefreshOutcome>>();
69
-
70
- /**
71
- * Binds an abort signal to outbound quota fetches while preserving Bun's
72
- * `fetch.preconnect` static, so the wrapper satisfies `FetchLike`.
73
- */
74
- export function signalFetch(signal: AbortSignal): typeof fetch {
75
- const wrapped = (
76
- url: Parameters<typeof fetch>[0],
77
- init?: Parameters<typeof fetch>[1],
78
- ): Promise<Response> => fetch(url, { ...init, signal });
79
- return Object.assign(wrapped, {
80
- preconnect: (...args: Parameters<typeof fetch.preconnect>): void =>
81
- fetch.preconnect(...args),
82
- });
83
- }
84
-
85
- /** Combines the caller's abort signal with the fetch budget. */
86
- export function timeoutSignal(requestSignal: AbortSignal, ms: number): AbortSignal {
87
- return AbortSignal.any([requestSignal, AbortSignal.timeout(ms)]);
88
- }
89
-
90
- /**
91
- * Records one quota-refresh outcome on the account row so the dashboard's
92
- * "Last check" reflects what the fetch actually found. Success stamps
93
- * `lastSuccessAt` and clears the error fields; failure stamps the error
94
- * fields and keeps the last success.
95
- *
96
- * A *counted* failure also advances `consecutiveFailures`, and once an account
97
- * has `QUOTA_FAILURE_PARK_THRESHOLD` of them in a row it is parked — this is
98
- * the one place a quota result may move `status`, because a quota surface that
99
- * refuses three times is an account problem rather than a transient one. A
100
- * transient timeout is annotated but never counted, so it cannot park
101
- * (see `isTransientTimeout`).
102
- *
103
- * The error fields are written only while the account is `active`, because for
104
- * a parked account (cooldown/disabled) those fields carry the reason
105
- * the health machine parked it. A periodic quota sweep runs against every OAuth
106
- * account, and a failing quota endpoint reported "Invalid or expired
107
- * credentials" for an entire provider — overwriting the `auth_invalidated`
108
- * reason a dispatch 401 had just recorded with `quota_fetch_failed`, so the
109
- * dashboard blamed the wrong thing. A check cannot reclassify a parked account;
110
- * its own failure is still visible through the quota cache the page renders.
111
- */
112
- /**
113
- * A failure that says nothing about the account.
114
- *
115
- * A quota fetch that ran out of its own abort budget timed out against *our*
116
- * egress, not against the credential: the upstream may never have seen the
117
- * request. Counting it like a refusal meant three slow passes parked a healthy
118
- * account for six hours — and the buddy family is the worst case, because its
119
- * quota and check-in legs share one pass and rotate egress per account, so a
120
- * single slow pool produced three timeouts in a row.
121
- *
122
- * Matched on the message the timeout paths produce. Deliberately narrow: a
123
- * refusal that merely mentions a timeout is still a refusal.
124
- */
125
- function isTransientTimeout(error: string | null | undefined): boolean {
126
- if (!error) return false;
127
- const lower = error.toLowerCase();
128
- return (
129
- lower.includes("the operation timed out") ||
130
- lower.includes("operation timed out") ||
131
- lower.includes("timeout") && lower.includes("abort")
132
- );
133
- }
134
-
135
- export async function recordAccountCheck(
136
- db: CartethyiaDatabase,
137
- accountId: string,
138
- outcome: { ok: boolean; error?: string | null; category?: string | null },
139
- ): Promise<void> {
140
- const activeOnly = and(
141
- eq(providerAccounts.id, accountId),
142
- eq(providerAccounts.status, "active"),
143
- );
144
- if (outcome.ok) {
145
- await db
146
- .update(providerAccounts)
147
- .set({
148
- lastSuccessAt: new Date(),
149
- lastError: null,
150
- lastErrorCategory: null,
151
- lastErrorAt: null,
152
- consecutiveFailures: 0,
153
- })
154
- .where(activeOnly);
155
- return;
156
- }
157
- const transient = isTransientTimeout(outcome.error);
158
- // Count the failure in the same statement — unless it is a transient
159
- // timeout (see below). A quota endpoint that keeps refusing is an account
160
- // problem, and the row has to say so: the dashboard reads
161
- // `consecutiveFailures` for the pill, and a check that only wrote the error
162
- // text left the account looking healthy while every refresh failed.
163
- //
164
- // A timeout is annotated but NOT counted: it is evidence about the network,
165
- // not the credential, and letting it advance the streak is what parked
166
- // healthy accounts for six hours over three slow passes.
167
- const failing = await db
168
- .update(providerAccounts)
169
- .set({
170
- lastError: (outcome.error ?? "Account check failed").slice(0, 500),
171
- lastErrorCategory: outcome.category ?? "check_failed",
172
- lastErrorAt: new Date(),
173
- ...(transient ? {} : { consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1` }),
174
- })
175
- .where(activeOnly)
176
- .returning({ consecutiveFailures: providerAccounts.consecutiveFailures });
177
- if (transient) return;
178
- const failures = failing[0]?.consecutiveFailures ?? 0;
179
- // Past the threshold the account is parked, not just annotated. Three
180
- // consecutive refusals is a quota surface that is not coming back on its
181
- // own, and leaving the account in rotation only repeats the failure every
182
- // pass. The cooldown clears itself; a success before it elapses resets the
183
- // count above.
184
- if (failures >= QUOTA_FAILURE_PARK_THRESHOLD) {
185
- await db
186
- .update(providerAccounts)
187
- .set({
188
- status: "cooldown",
189
- cooldownUntil: new Date(Date.now() + QUOTA_FAILURE_COOLDOWN_MS),
190
- })
191
- .where(activeOnly);
192
- }
193
- }
194
-
195
- /** One account that a quota refresh can be attempted for. */
196
- export interface QuotaRefreshTarget {
197
- readonly accountId: string;
198
- readonly providerId: string;
199
- /** Owning tenant, or `null` for a global/shared account. */
200
- readonly tenantId: string | null;
201
- /**
202
- * Stored credential kind. Key-only credentials never reach an OAuth-only
203
- * quota surface: the collector routes `api_key` to the `/models`
204
- * connectivity probe instead, while OAuth keeps `users/me`.
205
- */
206
- readonly credentialKind?: "api_key" | "oauth" | "none" | null;
207
- /**
208
- * Human identity for logs. OAuth rows carry `Name <email@host>` because the
209
- * account label is seeded from the token's own identity claims, so sweep
210
- * lines can name the account instead of dumping a uuid.
211
- */
212
- readonly label?: string | null;
213
- /**
214
- * Non-secret per-account auth configuration. A collector whose billing
215
- * surface is region- or profile-scoped needs it; most ignore it.
216
- */
217
- readonly authState?: unknown;
218
- }
219
-
220
- /** Cache lens an account's quota belongs under. */
221
- export function targetLens(target: QuotaRefreshTarget): string {
222
- return target.tenantId ?? GLOBAL_QUOTA_LENS;
223
- }
224
-
225
- const TARGET_COLUMNS = {
226
- accountId: providerAccounts.id,
227
- providerId: providerAccounts.providerId,
228
- tenantId: providerAccounts.tenantId,
229
- credentialKind: providerAccounts.credentialKind,
230
- label: providerAccounts.label,
231
- authState: providerAccounts.authState,
232
- };
233
-
234
- /**
235
- * Credential-bearing accounts eligible for the periodic quota sweep.
236
- *
237
- * OAuth accounts use the refresh-aware resolver; api_key accounts (including
238
- * pasted JWTs) use the stored bearer exactly as issued. Both need quota refresh
239
- * and Buddy daily check-in, while `none` has no credential to probe.
240
- *
241
- * An account already marked `auth_invalidated` is excluded: its credential was
242
- * rejected outright and does not repair itself, so every sweep is a guaranteed
243
- * 401 that only re-confirms the row and floods the log — the operator needs a
244
- * re-login, not another probe. It returns to the sweep once it is re-authed or
245
- * recovered, which is when its credential is expected to work again.
246
- *
247
- * Every other credential-bearing account is swept, `disabled` included. A
248
- * disabled account is not necessarily revoked — it may have been parked for a
249
- * reason unrelated to its credential — and skipping it would let a credential
250
- * die silently while the row still reads healthy.
251
- */
252
- export async function listQuotaRefreshTargets(
253
- db: CartethyiaDatabase,
254
- ): Promise<readonly QuotaRefreshTarget[]> {
255
- return db
256
- .select(TARGET_COLUMNS)
257
- .from(providerAccounts)
258
- .where(
259
- and(
260
- inArray(providerAccounts.credentialKind, ["oauth", "api_key"]),
261
- or(
262
- isNull(providerAccounts.lastErrorCategory),
263
- ne(providerAccounts.lastErrorCategory, "auth_invalidated"),
264
- ),
265
- ),
266
- );
267
- }
268
-
269
- /**
270
- * Loads one account scoped to its owner.
271
- *
272
- * `tenantId` undefined means "any owner"; `null` means a shared/global row,
273
- * and a supplied tenant id matches only that tenant's own rows. Explicitly
274
- * selecting `null` keeps global-admin refreshes from addressing tenant-owned
275
- * accounts by id.
276
- */
277
- export async function loadQuotaTarget(
278
- db: CartethyiaDatabase,
279
- accountId: string,
280
- tenantId?: string | null,
281
- ): Promise<QuotaRefreshTarget | undefined> {
282
- const ownerPredicate =
283
- tenantId === undefined
284
- ? undefined
285
- : tenantId === null
286
- ? isNull(providerAccounts.tenantId)
287
- : eq(providerAccounts.tenantId, tenantId);
288
- const where = ownerPredicate
289
- ? and(eq(providerAccounts.id, accountId), ownerPredicate)
290
- : eq(providerAccounts.id, accountId);
291
- const rows = await db.select(TARGET_COLUMNS).from(providerAccounts).where(where).limit(1);
292
- return rows[0];
293
- }
294
-
295
- /**
296
- * Fetches one account's quota through the refresh-aware credential path, then
297
- * persists the outcome: the result is cached under the account's lens and the
298
- * account row is stamped with the check result. Concurrent callers for the same
299
- * account share one upstream fetch via `inFlightAccountQuotaRefreshes`.
300
- */
301
- export async function refreshAccountQuota(
302
- deps: QuotaRefreshDeps,
303
- target: QuotaRefreshTarget,
304
- fetcher: FetchLike,
305
- ): Promise<QuotaRefreshOutcome> {
306
- const existing = inFlightAccountQuotaRefreshes.get(target.accountId);
307
- if (existing) return existing;
308
- // Eviction guard: keys are account ids (bounded by the accounts table), but a
309
- // runaway caller could still fan out unique keys; drop the oldest in-flight
310
- // task rather than grow without bound. An evicted task's `.finally` delete is
311
- // a no-op for a key no longer present, and the caller still awaits its own
312
- // promise — eviction only costs it deduplication, never its result.
313
- if (inFlightAccountQuotaRefreshes.size >= MAX_INFLIGHT) {
314
- const oldestKey = inFlightAccountQuotaRefreshes.keys().next().value;
315
- if (oldestKey !== undefined) inFlightAccountQuotaRefreshes.delete(oldestKey);
316
- }
317
- const task = runQuotaRefresh(deps, target, fetcher).finally(() => {
318
- inFlightAccountQuotaRefreshes.delete(target.accountId);
319
- });
320
- inFlightAccountQuotaRefreshes.set(target.accountId, task);
321
- return task;
322
- }
323
-
324
- async function runQuotaRefresh(
325
- deps: QuotaRefreshDeps,
326
- target: QuotaRefreshTarget,
327
- fetcher: FetchLike,
328
- ): Promise<QuotaRefreshOutcome> {
329
- let credential: string | undefined;
330
- let resolveError: string | undefined;
331
- try {
332
- credential = await deps.resolveCredential(target.providerId as ProviderId, target.accountId);
333
- } catch (error) {
334
- resolveError = error instanceof Error ? error.message : "Failed to resolve credential";
335
- }
336
-
337
- let quota: ProviderQuotaResult;
338
- let failure: QuotaRefreshFailureCode | null = null;
339
- if (resolveError !== undefined) {
340
- quota = { source: target.providerId, plan: null, windows: [], error: resolveError };
341
- failure = "quota_fetch_failed";
342
- } else if (!credential) {
343
- quota = {
344
- source: target.providerId,
345
- plan: null,
346
- windows: [],
347
- error: "Account has no stored credential",
348
- };
349
- failure = "missing_credential";
350
- } else {
351
- // OAuth accounts must never see the api-key mark: the marker routes the
352
- // collector to the key-only probe, and OAuth credentials carry no key.
353
- const marked =
354
- target.credentialKind === "api_key"
355
- ? (deps.markApiKeyCredential?.(target.providerId, credential) ?? credential)
356
- : credential;
357
- try {
358
- const authState = record(target.authState);
359
- quota = await fetchProviderQuota(deps.providerRegistry, target.providerId, marked, fetcher, {
360
- // Carried so a collector can reuse a reading captured from real
361
- // traffic rather than spending a request to rediscover it.
362
- ...(target.accountId === undefined ? {} : { accountId: target.accountId }),
363
- ...(authState === undefined ? {} : { auth_state: authState }),
364
- ...(target.credentialKind === null || target.credentialKind === undefined
365
- ? {}
366
- : { credential_kind: target.credentialKind }),
367
- });
368
- } catch (error) {
369
- quota = {
370
- source: target.providerId,
371
- plan: null,
372
- windows: [],
373
- error: error instanceof Error ? error.message : "Failed to refresh quota",
374
- };
375
- }
376
- failure = quota.error === null ? null : "quota_fetch_failed";
377
- }
378
-
379
- // Cache even a failed fetch: the error view ("credential expired", "quota
380
- // unsupported") is itself the answer the page must render, and caching it
381
- // stops every page open from re-hitting a provider that just refused us.
382
- await setCachedQuota(targetLens(target), target.accountId, quota, deps.redis);
383
- // Persist the plan the provider reported, because routing gates on it: some
384
- // upstreams refuse a model on a free plan and nothing but the account's own
385
- // plan says which. The cache above is a short in-process TTL and empty after
386
- // a restart, so it cannot be the only copy — the row is what survives one.
387
- //
388
- // A failed fetch leaves the last known plan in place rather than clearing
389
- // it. The refresh failing is evidence about the network or the credential,
390
- // not evidence that the account's plan changed, and clearing on failure
391
- // would drop the router back to "unknown" on every transient error.
392
- if (quota.plan !== null) {
393
- try {
394
- await deps.db
395
- .update(providerAccounts)
396
- .set({ quotaPlan: quota.plan })
397
- .where(eq(providerAccounts.id, target.accountId));
398
- } catch (error) {
399
- log.warn(`[quota] failed to record plan for account=${target.accountId}`, error as Error);
400
- }
401
- }
402
- // A quota surface that names the account adopts the identity — but only over
403
- // a default label. An operator's custom rename is never overwritten, and the
404
- // CASE keeps the read and the write in one statement so a concurrent rename
405
- // cannot lose to the background sweep.
406
- if (quota.accountLabel !== undefined && quota.accountLabel.trim().length > 0) {
407
- try {
408
- await deps.db
409
- .update(providerAccounts)
410
- .set({
411
- // Adopted when the stored label is still the provider id *or* still
412
- // the credential's own subject. A collector that reduces an identity
413
- // to an opaque tag must be able to replace that identity, and the
414
- // provider-id test alone left the real subject in place — which is
415
- // the thing the tag exists to remove.
416
- label: sql`CASE
417
- WHEN lower(trim(${providerAccounts.label})) = lower(trim(${providerAccounts.providerId})) THEN ${quota.accountLabel}
418
- WHEN ${providerAccounts.label} LIKE '%|%' THEN ${quota.accountLabel}
419
- ELSE ${providerAccounts.label}
420
- END`,
421
- })
422
- .where(eq(providerAccounts.id, target.accountId));
423
- } catch (error) {
424
- log.warn(`[quota] failed to adopt account label for account=${target.accountId}`, error as Error);
425
- }
426
- }
427
- try {
428
- await recordAccountCheck(deps.db, target.accountId, {
429
- ok: quota.error === null,
430
- ...(quota.error === null ? {} : { error: quota.error, category: failure ?? "quota_fetch_failed" }),
431
- });
432
- } catch (error) {
433
- // A stamp failure must not lose the freshly fetched quota.
434
- log.warn(`[quota] failed to stamp check outcome for account=${target.accountId}`, error as Error);
435
- }
436
- return {
437
- quota,
438
- failure: quota.error === null ? null : (failure ?? "quota_fetch_failed"),
439
- ...(quota.accountLabel === undefined ? {} : { accountLabel: quota.accountLabel }),
440
- };
441
- }
1
+ // Shared account-quota refresh: one home for "resolve credential → fetch quota →
2
+ // cache under the right lens → stamp the account row".
3
+ //
4
+ // Four callers need exactly this sequence — the console's single/bulk refresh
5
+ // routes, the overview's background fill, the periodic sweep worker, and the
6
+ // global-admin refresh — so it lives here rather than inside the route module.
7
+ // A drift between them would mean the page and the worker disagree about what
8
+ // "refreshed" means.
9
+ import { and, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
10
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
11
+ import { providerAccounts } from "../../persistence/schema";
12
+ import type { RedisClient } from "../../persistence/redis";
13
+ import { log } from "../../observability/logger";
14
+ import { fetchProviderQuota } from "../../providers/quota/quota-support";
15
+ import { type FetchLike, type ProviderQuotaResult } from "../../providers/quota/quota-contracts";
16
+ import type { ProviderId } from "../../providers/provider-contracts";
17
+ import type { ProviderRegistry } from "../../providers/provider-registry";
18
+ import { GLOBAL_QUOTA_LENS, setCachedQuota } from "./cache";
19
+ import { record } from "../../providers/authentication/oauth-flow-store";
20
+
21
+ /** Why an account quota refresh failed; `null` means the fetch succeeded. */
22
+ export type QuotaRefreshFailureCode = "missing_credential" | "quota_fetch_failed";
23
+
24
+ export interface QuotaRefreshOutcome {
25
+ readonly quota: ProviderQuotaResult;
26
+ readonly failure: QuotaRefreshFailureCode | null;
27
+ /** Display identity the quota surface reported, when it reported one. */
28
+ readonly accountLabel?: string;
29
+ }
30
+
31
+ /** Abort budget for one upstream quota fetch. */
32
+ export const QUOTA_REFRESH_TIMEOUT_MS = 15_000;
33
+
34
+ /**
35
+ * Consecutive quota refusals before an account is parked. One refusal can be
36
+ * the provider's own hiccup; three in a row is the quota surface not coming
37
+ * back, and the account should stop being dialed until the cooldown elapses.
38
+ */
39
+ export const QUOTA_FAILURE_PARK_THRESHOLD = 3;
40
+ /** How long a quota-parked account stays out of rotation. */
41
+ export const QUOTA_FAILURE_COOLDOWN_MS = 6 * 60 * 60 * 1000;
42
+
43
+ export interface QuotaRefreshDeps {
44
+ readonly db: CartethyiaDatabase;
45
+ readonly redis: RedisClient | undefined;
46
+ readonly providerRegistry: ProviderRegistry;
47
+ /**
48
+ * Resolves a stored credential through the refresh-aware path (OAuth tokens
49
+ * are refreshed before use), returning `""` when the account has none.
50
+ * API-key accounts are wrapped in the provider's key envelope when one
51
+ * exists, so a key-only credential never reaches an OAuth-only surface.
52
+ */
53
+ readonly resolveCredential: (providerId: ProviderId, accountId: string) => Promise<string>;
54
+ /** Marks a stored api_key credential with the upstream-distinguishable envelope. */
55
+ readonly markApiKeyCredential?: (providerId: string, credential: string) => string;
56
+ /** Invalidates the route snapshot when a stamped figure may exclude an account. */
57
+ readonly snapshotInvalidator?: { invalidate(): unknown } | undefined;
58
+ }
59
+
60
+ /** Bound on concurrently tracked single-flight quota refreshes; keys are account ids, so the real bound is the account count. */
61
+ const MAX_INFLIGHT = 1_000;
62
+
63
+ /**
64
+ * In-flight promise deduplication map for account quota refreshes. Keyed by
65
+ * accountId so concurrent callers (a manual refresh plus the sweep, or two
66
+ * rapid clicks) share one upstream fetch instead of hammering the provider.
67
+ */
68
+ export const inFlightAccountQuotaRefreshes = new Map<string, Promise<QuotaRefreshOutcome>>();
69
+
70
+ /**
71
+ * Binds an abort signal to outbound quota fetches while preserving Bun's
72
+ * `fetch.preconnect` static, so the wrapper satisfies `FetchLike`.
73
+ */
74
+ export function signalFetch(signal: AbortSignal): typeof fetch {
75
+ const wrapped = (
76
+ url: Parameters<typeof fetch>[0],
77
+ init?: Parameters<typeof fetch>[1],
78
+ ): Promise<Response> => fetch(url, { ...init, signal });
79
+ return Object.assign(wrapped, {
80
+ preconnect: (...args: Parameters<typeof fetch.preconnect>): void =>
81
+ fetch.preconnect(...args),
82
+ });
83
+ }
84
+
85
+ /** Combines the caller's abort signal with the fetch budget. */
86
+ export function timeoutSignal(requestSignal: AbortSignal, ms: number): AbortSignal {
87
+ return AbortSignal.any([requestSignal, AbortSignal.timeout(ms)]);
88
+ }
89
+
90
+ /**
91
+ * Records one quota-refresh outcome on the account row so the dashboard's
92
+ * "Last check" reflects what the fetch actually found. Success stamps
93
+ * `lastSuccessAt` and clears the error fields; failure stamps the error
94
+ * fields and keeps the last success.
95
+ *
96
+ * A *counted* failure also advances `consecutiveFailures`, and once an account
97
+ * has `QUOTA_FAILURE_PARK_THRESHOLD` of them in a row it is parked — this is
98
+ * the one place a quota result may move `status`, because a quota surface that
99
+ * refuses three times is an account problem rather than a transient one. A
100
+ * transient timeout is annotated but never counted, so it cannot park
101
+ * (see `isTransientTimeout`).
102
+ *
103
+ * The error fields are written only while the account is `active`, because for
104
+ * a parked account (cooldown/disabled) those fields carry the reason
105
+ * the health machine parked it. A periodic quota sweep runs against every OAuth
106
+ * account, and a failing quota endpoint reported "Invalid or expired
107
+ * credentials" for an entire provider — overwriting the `auth_invalidated`
108
+ * reason a dispatch 401 had just recorded with `quota_fetch_failed`, so the
109
+ * dashboard blamed the wrong thing. A check cannot reclassify a parked account;
110
+ * its own failure is still visible through the quota cache the page renders.
111
+ */
112
+ /**
113
+ * A failure that says nothing about the account.
114
+ *
115
+ * A quota fetch that ran out of its own abort budget timed out against *our*
116
+ * egress, not against the credential: the upstream may never have seen the
117
+ * request. Counting it like a refusal meant three slow passes parked a healthy
118
+ * account for six hours — and the buddy family is the worst case, because its
119
+ * quota and check-in legs share one pass and rotate egress per account, so a
120
+ * single slow pool produced three timeouts in a row.
121
+ *
122
+ * Matched on the message the timeout paths produce. Deliberately narrow: a
123
+ * refusal that merely mentions a timeout is still a refusal.
124
+ */
125
+ function isTransientTimeout(error: string | null | undefined): boolean {
126
+ if (!error) return false;
127
+ const lower = error.toLowerCase();
128
+ return (
129
+ lower.includes("the operation timed out") ||
130
+ lower.includes("operation timed out") ||
131
+ lower.includes("timeout") && lower.includes("abort")
132
+ );
133
+ }
134
+
135
+ export async function recordAccountCheck(
136
+ db: CartethyiaDatabase,
137
+ accountId: string,
138
+ outcome: { ok: boolean; error?: string | null; category?: string | null },
139
+ ): Promise<void> {
140
+ const activeOnly = and(
141
+ eq(providerAccounts.id, accountId),
142
+ eq(providerAccounts.status, "active"),
143
+ );
144
+ if (outcome.ok) {
145
+ await db
146
+ .update(providerAccounts)
147
+ .set({
148
+ lastSuccessAt: new Date(),
149
+ lastError: null,
150
+ lastErrorCategory: null,
151
+ lastErrorAt: null,
152
+ consecutiveFailures: 0,
153
+ })
154
+ .where(activeOnly);
155
+ return;
156
+ }
157
+ const transient = isTransientTimeout(outcome.error);
158
+ // Count the failure in the same statement — unless it is a transient
159
+ // timeout (see below). A quota endpoint that keeps refusing is an account
160
+ // problem, and the row has to say so: the dashboard reads
161
+ // `consecutiveFailures` for the pill, and a check that only wrote the error
162
+ // text left the account looking healthy while every refresh failed.
163
+ //
164
+ // A timeout is annotated but NOT counted: it is evidence about the network,
165
+ // not the credential, and letting it advance the streak is what parked
166
+ // healthy accounts for six hours over three slow passes.
167
+ const failing = await db
168
+ .update(providerAccounts)
169
+ .set({
170
+ lastError: (outcome.error ?? "Account check failed").slice(0, 500),
171
+ lastErrorCategory: outcome.category ?? "check_failed",
172
+ lastErrorAt: new Date(),
173
+ ...(transient ? {} : { consecutiveFailures: sql`${providerAccounts.consecutiveFailures} + 1` }),
174
+ })
175
+ .where(activeOnly)
176
+ .returning({ consecutiveFailures: providerAccounts.consecutiveFailures });
177
+ if (transient) return;
178
+ const failures = failing[0]?.consecutiveFailures ?? 0;
179
+ // Past the threshold the account is parked, not just annotated. Three
180
+ // consecutive refusals is a quota surface that is not coming back on its
181
+ // own, and leaving the account in rotation only repeats the failure every
182
+ // pass. The cooldown clears itself; a success before it elapses resets the
183
+ // count above.
184
+ if (failures >= QUOTA_FAILURE_PARK_THRESHOLD) {
185
+ await db
186
+ .update(providerAccounts)
187
+ .set({
188
+ status: "cooldown",
189
+ cooldownUntil: new Date(Date.now() + QUOTA_FAILURE_COOLDOWN_MS),
190
+ })
191
+ .where(activeOnly);
192
+ }
193
+ }
194
+
195
+ /** One account that a quota refresh can be attempted for. */
196
+ export interface QuotaRefreshTarget {
197
+ readonly accountId: string;
198
+ readonly providerId: string;
199
+ /** Owning tenant, or `null` for a global/shared account. */
200
+ readonly tenantId: string | null;
201
+ /**
202
+ * Stored credential kind. Key-only credentials never reach an OAuth-only
203
+ * quota surface: the collector routes `api_key` to the `/models`
204
+ * connectivity probe instead, while OAuth keeps `users/me`.
205
+ */
206
+ readonly credentialKind?: "api_key" | "oauth" | "none" | null;
207
+ /**
208
+ * Human identity for logs. OAuth rows carry `Name <email@host>` because the
209
+ * account label is seeded from the token's own identity claims, so sweep
210
+ * lines can name the account instead of dumping a uuid.
211
+ */
212
+ readonly label?: string | null;
213
+ /**
214
+ * Non-secret per-account auth configuration. A collector whose billing
215
+ * surface is region- or profile-scoped needs it; most ignore it.
216
+ */
217
+ readonly authState?: unknown;
218
+ }
219
+
220
+ /** Cache lens an account's quota belongs under. */
221
+ export function targetLens(target: QuotaRefreshTarget): string {
222
+ return target.tenantId ?? GLOBAL_QUOTA_LENS;
223
+ }
224
+
225
+ const TARGET_COLUMNS = {
226
+ accountId: providerAccounts.id,
227
+ providerId: providerAccounts.providerId,
228
+ tenantId: providerAccounts.tenantId,
229
+ credentialKind: providerAccounts.credentialKind,
230
+ label: providerAccounts.label,
231
+ authState: providerAccounts.authState,
232
+ };
233
+
234
+ /**
235
+ * Credential-bearing accounts eligible for the periodic quota sweep.
236
+ *
237
+ * OAuth accounts use the refresh-aware resolver; api_key accounts (including
238
+ * pasted JWTs) use the stored bearer exactly as issued. Both need quota refresh
239
+ * and Buddy daily check-in, while `none` has no credential to probe.
240
+ *
241
+ * An account already marked `auth_invalidated` is excluded: its credential was
242
+ * rejected outright and does not repair itself, so every sweep is a guaranteed
243
+ * 401 that only re-confirms the row and floods the log — the operator needs a
244
+ * re-login, not another probe. It returns to the sweep once it is re-authed or
245
+ * recovered, which is when its credential is expected to work again.
246
+ *
247
+ * Every other credential-bearing account is swept, `disabled` included. A
248
+ * disabled account is not necessarily revoked — it may have been parked for a
249
+ * reason unrelated to its credential — and skipping it would let a credential
250
+ * die silently while the row still reads healthy.
251
+ */
252
+ export async function listQuotaRefreshTargets(
253
+ db: CartethyiaDatabase,
254
+ ): Promise<readonly QuotaRefreshTarget[]> {
255
+ return db
256
+ .select(TARGET_COLUMNS)
257
+ .from(providerAccounts)
258
+ .where(
259
+ and(
260
+ inArray(providerAccounts.credentialKind, ["oauth", "api_key"]),
261
+ or(
262
+ isNull(providerAccounts.lastErrorCategory),
263
+ ne(providerAccounts.lastErrorCategory, "auth_invalidated"),
264
+ ),
265
+ ),
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Loads one account scoped to its owner.
271
+ *
272
+ * `tenantId` undefined means "any owner"; `null` means a shared/global row,
273
+ * and a supplied tenant id matches only that tenant's own rows. Explicitly
274
+ * selecting `null` keeps global-admin refreshes from addressing tenant-owned
275
+ * accounts by id.
276
+ */
277
+ export async function loadQuotaTarget(
278
+ db: CartethyiaDatabase,
279
+ accountId: string,
280
+ tenantId?: string | null,
281
+ ): Promise<QuotaRefreshTarget | undefined> {
282
+ const ownerPredicate =
283
+ tenantId === undefined
284
+ ? undefined
285
+ : tenantId === null
286
+ ? isNull(providerAccounts.tenantId)
287
+ : eq(providerAccounts.tenantId, tenantId);
288
+ const where = ownerPredicate
289
+ ? and(eq(providerAccounts.id, accountId), ownerPredicate)
290
+ : eq(providerAccounts.id, accountId);
291
+ const rows = await db.select(TARGET_COLUMNS).from(providerAccounts).where(where).limit(1);
292
+ return rows[0];
293
+ }
294
+
295
+ /**
296
+ * Fetches one account's quota through the refresh-aware credential path, then
297
+ * persists the outcome: the result is cached under the account's lens and the
298
+ * account row is stamped with the check result. Concurrent callers for the same
299
+ * account share one upstream fetch via `inFlightAccountQuotaRefreshes`.
300
+ */
301
+ export async function refreshAccountQuota(
302
+ deps: QuotaRefreshDeps,
303
+ target: QuotaRefreshTarget,
304
+ fetcher: FetchLike,
305
+ ): Promise<QuotaRefreshOutcome> {
306
+ const existing = inFlightAccountQuotaRefreshes.get(target.accountId);
307
+ if (existing) return existing;
308
+ // Eviction guard: keys are account ids (bounded by the accounts table), but a
309
+ // runaway caller could still fan out unique keys; drop the oldest in-flight
310
+ // task rather than grow without bound. An evicted task's `.finally` delete is
311
+ // a no-op for a key no longer present, and the caller still awaits its own
312
+ // promise — eviction only costs it deduplication, never its result.
313
+ if (inFlightAccountQuotaRefreshes.size >= MAX_INFLIGHT) {
314
+ const oldestKey = inFlightAccountQuotaRefreshes.keys().next().value;
315
+ if (oldestKey !== undefined) inFlightAccountQuotaRefreshes.delete(oldestKey);
316
+ }
317
+ const task = runQuotaRefresh(deps, target, fetcher).finally(() => {
318
+ inFlightAccountQuotaRefreshes.delete(target.accountId);
319
+ });
320
+ inFlightAccountQuotaRefreshes.set(target.accountId, task);
321
+ return task;
322
+ }
323
+
324
+ async function runQuotaRefresh(
325
+ deps: QuotaRefreshDeps,
326
+ target: QuotaRefreshTarget,
327
+ fetcher: FetchLike,
328
+ ): Promise<QuotaRefreshOutcome> {
329
+ let credential: string | undefined;
330
+ let resolveError: string | undefined;
331
+ try {
332
+ credential = await deps.resolveCredential(target.providerId as ProviderId, target.accountId);
333
+ } catch (error) {
334
+ resolveError = error instanceof Error ? error.message : "Failed to resolve credential";
335
+ }
336
+
337
+ let quota: ProviderQuotaResult;
338
+ let failure: QuotaRefreshFailureCode | null = null;
339
+ if (resolveError !== undefined) {
340
+ quota = { source: target.providerId, plan: null, windows: [], error: resolveError };
341
+ failure = "quota_fetch_failed";
342
+ } else if (!credential) {
343
+ quota = {
344
+ source: target.providerId,
345
+ plan: null,
346
+ windows: [],
347
+ error: "Account has no stored credential",
348
+ };
349
+ failure = "missing_credential";
350
+ } else {
351
+ // OAuth accounts must never see the api-key mark: the marker routes the
352
+ // collector to the key-only probe, and OAuth credentials carry no key.
353
+ const marked =
354
+ target.credentialKind === "api_key"
355
+ ? (deps.markApiKeyCredential?.(target.providerId, credential) ?? credential)
356
+ : credential;
357
+ try {
358
+ const authState = record(target.authState);
359
+ quota = await fetchProviderQuota(deps.providerRegistry, target.providerId, marked, fetcher, {
360
+ // Carried so a collector can reuse a reading captured from real
361
+ // traffic rather than spending a request to rediscover it.
362
+ ...(target.accountId === undefined ? {} : { accountId: target.accountId }),
363
+ ...(authState === undefined ? {} : { auth_state: authState }),
364
+ ...(target.credentialKind === null || target.credentialKind === undefined
365
+ ? {}
366
+ : { credential_kind: target.credentialKind }),
367
+ });
368
+ } catch (error) {
369
+ quota = {
370
+ source: target.providerId,
371
+ plan: null,
372
+ windows: [],
373
+ error: error instanceof Error ? error.message : "Failed to refresh quota",
374
+ };
375
+ }
376
+ failure = quota.error === null ? null : "quota_fetch_failed";
377
+ }
378
+
379
+ // Cache even a failed fetch: the error view ("credential expired", "quota
380
+ // unsupported") is itself the answer the page must render, and caching it
381
+ // stops every page open from re-hitting a provider that just refused us.
382
+ await setCachedQuota(targetLens(target), target.accountId, quota, deps.redis);
383
+ // Persist the plan the provider reported, because routing gates on it: some
384
+ // upstreams refuse a model on a free plan and nothing but the account's own
385
+ // plan says which. The cache above is a short in-process TTL and empty after
386
+ // a restart, so it cannot be the only copy — the row is what survives one.
387
+ //
388
+ // A failed fetch leaves the last known plan in place rather than clearing
389
+ // it. The refresh failing is evidence about the network or the credential,
390
+ // not evidence that the account's plan changed, and clearing on failure
391
+ // would drop the router back to "unknown" on every transient error.
392
+ if (quota.plan !== null) {
393
+ try {
394
+ await deps.db
395
+ .update(providerAccounts)
396
+ .set({ quotaPlan: quota.plan })
397
+ .where(eq(providerAccounts.id, target.accountId));
398
+ } catch (error) {
399
+ log.warn(`[quota] failed to record plan for account=${target.accountId}`, error as Error);
400
+ }
401
+ }
402
+ // A quota surface that names the account adopts the identity — but only over
403
+ // a default label. An operator's custom rename is never overwritten, and the
404
+ // CASE keeps the read and the write in one statement so a concurrent rename
405
+ // cannot lose to the background sweep.
406
+ if (quota.accountLabel !== undefined && quota.accountLabel.trim().length > 0) {
407
+ try {
408
+ await deps.db
409
+ .update(providerAccounts)
410
+ .set({
411
+ // Adopted when the stored label is still the provider id *or* still
412
+ // the credential's own subject. A collector that reduces an identity
413
+ // to an opaque tag must be able to replace that identity, and the
414
+ // provider-id test alone left the real subject in place — which is
415
+ // the thing the tag exists to remove.
416
+ label: sql`CASE
417
+ WHEN lower(trim(${providerAccounts.label})) = lower(trim(${providerAccounts.providerId})) THEN ${quota.accountLabel}
418
+ WHEN ${providerAccounts.label} LIKE '%|%' THEN ${quota.accountLabel}
419
+ ELSE ${providerAccounts.label}
420
+ END`,
421
+ })
422
+ .where(eq(providerAccounts.id, target.accountId));
423
+ } catch (error) {
424
+ log.warn(`[quota] failed to adopt account label for account=${target.accountId}`, error as Error);
425
+ }
426
+ }
427
+ try {
428
+ await recordAccountCheck(deps.db, target.accountId, {
429
+ ok: quota.error === null,
430
+ ...(quota.error === null ? {} : { error: quota.error, category: failure ?? "quota_fetch_failed" }),
431
+ });
432
+ } catch (error) {
433
+ // A stamp failure must not lose the freshly fetched quota.
434
+ log.warn(`[quota] failed to stamp check outcome for account=${target.accountId}`, error as Error);
435
+ }
436
+ return {
437
+ quota,
438
+ failure: quota.error === null ? null : (failure ?? "quota_fetch_failed"),
439
+ ...(quota.accountLabel === undefined ? {} : { accountLabel: quota.accountLabel }),
440
+ };
441
+ }