cartethyia 2.5.2 → 2.5.4

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 (277) hide show
  1. package/dist/dashboard/assets/{BannedPage-MsQaeQTs.js → BannedPage-C2orBsI2.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  3. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DfWHTJ7v.js → CliToolsPage-BV6pxVQo.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-BrCBQDAc.js → CombosPage-Bsan4679.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-C6Uh33er.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-8aS9hyJu.js → CustomizationPage-0p8uX7vB.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-C8hKyXBe.js → LoginPage-BvzCx8Kr.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  19. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
  20. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
  24. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  25. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
  26. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  27. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
  28. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  29. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
  30. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-CXjUlfqr.js → SetupPage-DDbMQsoo.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-C-_oD3F2.js → SortableList-CB3Z7VTf.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-u9IxmYOI.js → StudioPage-D95UCPHI.js} +3 -3
  36. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-BEVLVrwP.js → UsagePage-Cnae9SWA.js} +13 -13
  38. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  39. package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
  40. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  41. package/dist/dashboard/assets/app-COlK95oi.css +2 -0
  42. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  43. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
  44. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  45. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
  46. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  47. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
  48. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  49. package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
  50. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  51. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
  52. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  53. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
  54. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  55. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  56. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  57. package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
  58. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  59. package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
  60. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  61. package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
  62. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  63. package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
  64. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
  66. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  67. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
  68. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  69. package/dist/dashboard/assets/{index-DCBtROoK.js → index-js6TJvdr.js} +2 -2
  70. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  71. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
  72. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  73. package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
  74. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  75. package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
  76. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  77. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
  78. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  79. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
  80. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  81. package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
  82. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  83. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
  84. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  85. package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
  86. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  87. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
  88. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
  89. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
  90. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  91. package/dist/dashboard/assets/{select-DsX5QT8K.js → select-WUCXorXw.js} +2 -2
  92. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  93. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
  94. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  95. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
  96. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  97. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
  98. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  99. package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
  100. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  101. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  102. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  105. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  106. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  107. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  108. package/dist/dashboard/index.html +1 -1
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +54 -54
  111. package/package.json +1 -1
  112. package/scripts/cli/autostart.ts +237 -0
  113. package/scripts/cli/command/console-menu.ts +244 -246
  114. package/scripts/cli/command/tray.ts +208 -204
  115. package/scripts/cli/command/update.ts +27 -10
  116. package/scripts/cli/main.ts +28 -1
  117. package/src/boot/app.ts +693 -693
  118. package/src/boot/dependencies.ts +646 -616
  119. package/src/config/spec.ts +81 -2
  120. package/src/config/telemetry.ts +46 -0
  121. package/src/console/api-keys/bucket-spend.ts +15 -4
  122. package/src/console/backup/contracts.ts +15 -2
  123. package/src/console/backup/routes.ts +52 -1
  124. package/src/console/backup/service.ts +60 -4
  125. package/src/console/console-router.ts +8 -0
  126. package/src/console/data-management/contracts.ts +93 -0
  127. package/src/console/data-management/operations.ts +229 -0
  128. package/src/console/data-management/routes.ts +104 -0
  129. package/src/console/domain-registration.ts +39 -0
  130. package/src/console/observability/contracts.ts +524 -485
  131. package/src/console/observability/store.ts +1115 -1018
  132. package/src/console/providers/catalog/contracts.ts +6 -0
  133. package/src/console/providers/catalog/provider-operations.ts +20 -0
  134. package/src/console/providers/catalog/routes.ts +7 -0
  135. package/src/console/providers/catalog/store.ts +22 -1
  136. package/src/console/quota/account-quota-shared.ts +52 -39
  137. package/src/console/quota/refresh.ts +441 -429
  138. package/src/console/routing/pools/import-parse.ts +206 -0
  139. package/src/console/routing/pools/operations.ts +543 -501
  140. package/src/console/routing/pools/routes.ts +181 -164
  141. package/src/console/settings/contracts.ts +37 -0
  142. package/src/console/settings/store.ts +51 -0
  143. package/src/console/share/share-router.ts +6 -9
  144. package/src/console/updates/routes.ts +16 -0
  145. package/src/main.ts +10 -0
  146. package/src/observability/event-compaction.ts +315 -0
  147. package/src/observability/event-day-index.ts +442 -0
  148. package/src/observability/event-log.ts +152 -3
  149. package/src/observability/payload-capture.ts +373 -344
  150. package/src/observability/payload-store.ts +440 -366
  151. package/src/observability/telemetry-status.ts +28 -16
  152. package/src/persistence/health-event-flush.ts +36 -0
  153. package/src/persistence/schema.ts +1402 -1360
  154. package/src/persistence/stores/api-key-store.ts +6 -0
  155. package/src/persistence/stores/share-store.ts +583 -556
  156. package/src/persistence/stores/telemetry-store.ts +237 -214
  157. package/src/protocol/codecs.ts +1 -2
  158. package/src/protocol/image-clamp.ts +19 -1
  159. package/src/protocol/registry.ts +8 -0
  160. package/src/protocol/request/gemini.ts +9 -2
  161. package/src/protocol/request/messages.ts +14 -2
  162. package/src/protocol/response/gemini.ts +18 -4
  163. package/src/providers/adapters/compatible-adapter.ts +3 -0
  164. package/src/providers/bundled-discovery-providers.ts +122 -119
  165. package/src/providers/configured-openai-providers.ts +136 -134
  166. package/src/providers/default-registry.ts +5 -4
  167. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  168. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  169. package/src/providers/integrations/antigravity/antigravity-quota.ts +11 -3
  170. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +4 -4
  171. package/src/providers/integrations/buddy/workbuddy/shared.ts +201 -200
  172. package/src/providers/integrations/cline/cline-quota.ts +132 -128
  173. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  174. package/src/providers/integrations/codex/codex.ts +2 -29
  175. package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
  176. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  177. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  178. package/src/providers/integrations/cursor/cursor.ts +1485 -960
  179. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  180. package/src/providers/integrations/factory/factory.ts +8 -1
  181. package/src/providers/integrations/gemini.ts +6 -1
  182. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  183. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  184. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  185. package/src/providers/integrations/opencode/provider.ts +531 -532
  186. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  187. package/src/providers/integrations/tokenharbor.ts +2 -2
  188. package/src/providers/integrations/trae/trae-catalog.ts +5 -3
  189. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  190. package/src/providers/operations/account-health-service.ts +1523 -1457
  191. package/src/providers/operations/client-versions.ts +9 -2
  192. package/src/providers/provider-contracts.ts +7 -0
  193. package/src/providers/provider-metadata.ts +13 -1
  194. package/src/providers/quota/quota-support.ts +8 -0
  195. package/src/security/api-key-auth.ts +504 -494
  196. package/src/transport/dispatch/attempt-finalize.ts +46 -5
  197. package/src/transport/dispatch/generation.ts +6 -0
  198. package/src/transport/dispatch/proxy-request.ts +33 -1
  199. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  200. package/src/transport/dispatch/streaming/stream.ts +30 -2
  201. package/src/transport/dispatch/upstream.ts +9 -0
  202. package/src/transport/request/preparer.ts +7 -6
  203. package/src/transport/request/state.ts +9 -0
  204. package/src/transport/request/tenant-preferences.ts +60 -0
  205. package/src/transport/routing/route-catalog.ts +854 -799
  206. package/dist/dashboard/assets/BannedPage-MsQaeQTs.js.gz +0 -0
  207. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js +0 -1
  208. package/dist/dashboard/assets/CliToolDetailPage-BO9nKg9w.js.gz +0 -0
  209. package/dist/dashboard/assets/CliToolsPage-DfWHTJ7v.js.gz +0 -0
  210. package/dist/dashboard/assets/CombosPage-BrCBQDAc.js.gz +0 -0
  211. package/dist/dashboard/assets/ConsoleLogPage-C6Uh33er.js.gz +0 -0
  212. package/dist/dashboard/assets/CustomizationPage-8aS9hyJu.js.gz +0 -0
  213. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js +0 -1
  214. package/dist/dashboard/assets/HealthEventsModal-qomXThxX.js.gz +0 -0
  215. package/dist/dashboard/assets/LoginPage-C8hKyXBe.js.gz +0 -0
  216. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  217. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js +0 -1
  218. package/dist/dashboard/assets/OverviewPage-C-ExCy1V.js.gz +0 -0
  219. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js +0 -3
  220. package/dist/dashboard/assets/ProviderDetailPage-tr811uaF.js.gz +0 -0
  221. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js +0 -1
  222. package/dist/dashboard/assets/ProvidersPage-BS9IEpvv.js.gz +0 -0
  223. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js +0 -7
  224. package/dist/dashboard/assets/ProxyPage-Rn_qXScb.js.gz +0 -0
  225. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js +0 -1
  226. package/dist/dashboard/assets/QuotaPage-BihkgQSq.js.gz +0 -0
  227. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js +0 -1
  228. package/dist/dashboard/assets/SettingsPage-DhiEjm6T.js.gz +0 -0
  229. package/dist/dashboard/assets/SetupPage-CXjUlfqr.js.gz +0 -0
  230. package/dist/dashboard/assets/SortableList-C-_oD3F2.js.gz +0 -0
  231. package/dist/dashboard/assets/StudioPage-u9IxmYOI.js.gz +0 -0
  232. package/dist/dashboard/assets/UsagePage-BEVLVrwP.js.gz +0 -0
  233. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  234. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  235. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  236. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  237. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  238. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  239. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  240. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  241. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  242. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  243. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  244. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  245. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  246. package/dist/dashboard/assets/entry-Dj9AR0gY.js +0 -4
  247. package/dist/dashboard/assets/entry-Dj9AR0gY.js.gz +0 -0
  248. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  249. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  250. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  251. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  252. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  253. package/dist/dashboard/assets/index-DCBtROoK.js.gz +0 -0
  254. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  255. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  256. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  257. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  258. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  259. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  260. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  261. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  262. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  263. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  264. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  265. package/dist/dashboard/assets/select-DsX5QT8K.js.gz +0 -0
  266. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  267. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  268. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  269. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  270. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  271. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  272. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  273. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  274. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  275. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  276. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
  277. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
@@ -1,799 +1,854 @@
1
- // Routing snapshot: builds the RouteSnapshot from the persisted model catalog.
2
-
3
- import { asc, eq, ne } from "drizzle-orm";
4
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
5
- import {
6
- models,
7
- modelAliases,
8
- modelCombos,
9
- networkPools,
10
- poolRoutingSettings,
11
- providerAccounts,
12
- providerRoutingSettings,
13
- providers,
14
- tenantDisabledModels,
15
- cliToolMappings,
16
- cliToolSettings,
17
- } from "../../persistence/schema";
18
- import { DEFAULT_PROVIDER_MAX_INFLIGHT } from "../../persistence/schema";
19
- import { resolveTenantOverride } from "../../persistence/tenant-scope";
20
- import {
21
- type RouteCandidate,
22
- type ComboDefinition,
23
- type PoolRoutingSetting,
24
- type ProviderRoutingMap,
25
- type RouteSnapshot,
26
- type SnapshotBuilder,
27
- } from "./route-model";
28
- import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metadata";
29
- import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
30
- import { isBundledProviderId } from "../../providers/provider-contracts";
31
- import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
32
- import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
33
-
34
- const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
35
-
36
- export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
37
- if (toolId !== "claude") return [sourceModel];
38
- const normalized = sourceModel.trim().toLowerCase();
39
- const family =
40
- CLAUDE_MODEL_FAMILIES.has(normalized)
41
- ? normalized
42
- : /^claude-(opus|sonnet|haiku|fable|mythos)(?:-|$)/.exec(normalized)?.[1];
43
- if (normalized.includes("/") || family === undefined) return [sourceModel];
44
- return [...new Set([
45
- sourceModel,
46
- family,
47
- `claude-${family}-5`,
48
- `claude-${family}-5-1`,
49
- `claude-${family}-5-5`,
50
- `claude-${family}-4-6`,
51
- `claude-${family}-4-5`,
52
- ])];
53
- }
54
- /**
55
- * Builds the routing `RouteSnapshot` from the persisted catalog
56
- * (`providers`, `models`, `provider_accounts`). Consumed by
57
- * `InMemoryRouteSnapshotService` as its `SnapshotBuilder`.
58
- *
59
- * Built-in definitions are materialized before snapshots are read. The
60
- * `models` DB table is authoritative for routing; compiled definitions are
61
- * only startup seed data. Custom/BYOK and live-discovered models are stored
62
- * in the same table.
63
- *
64
- * Tenant-aware: a provider with `providers.tenant_id = NULL` is global
65
- * (every built-in, plus any pool-wide custom provider) and its candidates
66
- * carry `tenant_id: null`, matching every request regardless of tenant. A
67
- * provider with `providers.tenant_id` set (BYOK) only pairs with accounts
68
- * owned by that exact same tenant, and its candidates carry that tenant id
69
- * — `RoutingEngine.plan()` filters candidates by the requesting tenant.
70
- */
71
-
72
- type RouteCandidateWithHealth = RouteCandidate & {
73
- health_status?: "cooldown" | "model_cooldown" | "disabled";
74
- cooldown_kind?: "hard" | "soft";
75
- /**
76
- * Epoch milliseconds at which this candidate's cooling window ends, when one
77
- * is in force. Routing-internal: it is what lets `plan()` report a real reset
78
- * on the all-accounts-cooling answer, and it never reaches a client verbatim —
79
- * only the derived `retry-after` does.
80
- */
81
- cooldown_until?: number;
82
- };
83
-
84
- const HARD_COOLDOWN_CATEGORIES: Readonly<Record<string, true>> = {
85
- quota_exhausted: true,
86
- policy_blocked: true,
87
- auth_invalidated: true,
88
- };
89
-
90
- function hardCooldownCategory(category: string | null): boolean {
91
- return category !== null && HARD_COOLDOWN_CATEGORIES[category] === true;
92
- }
93
-
94
- /**
95
- * Projects one model row's metadata into the capability gates the router
96
- * filters candidates by. Exported so the projection contract is unit-testable
97
- * without a database.
98
- */
99
- export function buildCapabilityProfile(row: {
100
- modalities: unknown;
101
- reasoning: boolean;
102
- toolCall: boolean;
103
- providerId: string;
104
- }): Record<string, boolean> {
105
- const mods =
106
- row.modalities != null && typeof row.modalities === "object"
107
- ? (row.modalities as { input?: unknown[]; output?: unknown[] })
108
- : {};
109
- const inputMods = Array.isArray(mods.input) ? mods.input : [];
110
- const outputMods = Array.isArray(mods.output) ? mods.output : [];
111
- // The canonical chat and responses codecs encode `image`, `document`/`file`,
112
- // and `audio` parts; the messages codec encodes the first two but has no
113
- // audio block (see `AUDIO_CAPABLE_WIRE_FAMILIES`). A bespoke adapter (Cursor)
114
- // frames its own protocol and has no generic rich-content path.
115
- // Treating the codec routes as capable by default keeps pass-through behavior
116
- // instead of degrading a caller's attachment to text on the strength of a
117
- // metadata guess. An explicit modality still wins, so a catalog can declare
118
- // support for a bespoke adapter too. Whether a given upstream model accepts
119
- // the part is the upstream's call — a capability declared here only decides
120
- // whether the router strips the part before it ever sees it, and the audio
121
- // grant is narrowed per wire family at projection time.
122
- const codecEncodesRichContent = !providerUsesBespokeWire(row.providerId);
123
- // Reasoning and tools are never stripped here. A `false` flag — whether a
124
- // discovered row recorded it for lack of metadata, or a catalog row set it
125
- // explicitly — must not become a silent rewrite of a request the caller
126
- // asked for. The upstream decides whether it can serve them and returns its
127
- // own error if it cannot.
128
- return {
129
- // The route is not served by a canonical codec. Carried in the profile
130
- // because the capability predicates downstream see only this snapshot, not
131
- // the provider registry.
132
- bespokeWire: !codecEncodesRichContent,
133
- image: inputMods.includes("image") || codecEncodesRichContent,
134
- document: inputMods.includes("document") || codecEncodesRichContent,
135
- audio: inputMods.includes("audio") || codecEncodesRichContent,
136
- mediaGeneration: outputMods.includes("image"),
137
- tools: true,
138
- parallelToolCalls: true,
139
- reasoning: true,
140
- reasoningEncryptedContent: true,
141
- // Search is its own route: a `websearch` row answers `POST /v1/search`.
142
- // A chat row never claims it; a hosted declaration rides through `tools`.
143
- webSearch: false,
144
- responseJsonObject: true,
145
- responseJsonSchema: true,
146
- promptCaching: true,
147
- };
148
- }
149
-
150
- /**
151
- * The capability profile for one model row, memoized across rebuilds.
152
- *
153
- * Bounded LRU keyed by `providerId/modelId`: the value is a pure function of
154
- * the row's metadata, so two rebuilds that see the same row get the same object
155
- * instead of two identical ones. The bound keeps a long-lived process from
156
- * pinning one profile per row it has ever seen — a model deleted from the
157
- * catalog must not leave its profile behind forever.
158
- */
159
- const CAPABILITY_PROFILE_CACHE_MAX_ENTRIES = 4096;
160
- const capabilityProfileCache = new Map<string, Record<string, boolean>>();
161
-
162
- function capabilityProfileFor(row: {
163
- modalities: unknown;
164
- reasoning: boolean;
165
- toolCall: boolean;
166
- providerId: string;
167
- modelId: string;
168
- }): Record<string, boolean> {
169
- const key = `${row.providerId}/${row.modelId}`;
170
- const cached = capabilityProfileCache.get(key);
171
- if (cached !== undefined) return cached;
172
- const profile = buildCapabilityProfile(row);
173
- if (capabilityProfileCache.size >= CAPABILITY_PROFILE_CACHE_MAX_ENTRIES) {
174
- const oldest = capabilityProfileCache.keys().next().value;
175
- if (oldest !== undefined) capabilityProfileCache.delete(oldest);
176
- }
177
- capabilityProfileCache.set(key, profile);
178
- return profile;
179
- }
180
-
181
- /** Live memo size, for tests and the metrics gauge. */
182
- export function capabilityProfileCacheSize(): number {
183
- return capabilityProfileCache.size;
184
- }
185
-
186
- /** Resolves route identity only for bundled adapters without a native User-Agent builder. */
187
- export function resolveRouteUserAgent(
188
- providerId: string,
189
- tenantUserAgent: string | undefined,
190
- globalUserAgent: string | undefined,
191
- ): string | undefined {
192
- if (!isBundledProviderId(providerId) || providerHasAdapterUserAgent(providerId)) return undefined;
193
- // A provider whose operator set no identity of their own gets the Claude Code
194
- // CLI identity, resolved live so the stamped version tracks discovery. This
195
- // used to be a frozen `codex_cli_rs` literal, which named a different vendor's
196
- // client on requests that mostly reached neither.
197
- const selected = resolveTenantOverride(
198
- tenantUserAgent,
199
- globalUserAgent,
200
- buildClaudeCliUserAgent(),
201
- );
202
- return selected.trim() === "" ? undefined : selected;
203
- }
204
-
205
- /** One persisted model row ready for candidate construction. */
206
- interface MergedModelRow {
207
- providerId: string;
208
- modelId: string;
209
- wireFamily: WireFamily;
210
- serviceKind: ServiceKind;
211
- endpointPath: string;
212
- modalities: unknown;
213
- outputLimit: number | null;
214
- reasoning: boolean;
215
- toolCall: boolean;
216
- enabled: boolean;
217
- proOnly: boolean | null;
218
- /** Whether the provider publishes the row as free. Read for plan logging. */
219
- freeTier: boolean | null;
220
- }
221
- /**
222
- * Converts persisted model rows into candidate input. Seed materialization is
223
- * performed before this function runs, so no static-vs-DB merge is needed.
224
- */
225
- function mergeModelCatalog(
226
- dbRows: readonly ModelRouteRow[],
227
- ): MergedModelRow[] {
228
- return dbRows
229
- .filter((row) => row.enabled)
230
- .map((row) => ({
231
- providerId: row.providerId,
232
- modelId: row.modelId,
233
- wireFamily: row.wireFamily as WireFamily,
234
- serviceKind: row.serviceKind as ServiceKind,
235
- endpointPath: row.endpointPath,
236
- modalities: row.modalities,
237
- outputLimit: row.outputLimit,
238
- reasoning: row.reasoning,
239
- toolCall: row.toolCall,
240
- enabled: row.enabled,
241
- freeTier: row.freeTier ?? null,
242
- proOnly: row.proOnly ?? null,
243
- }));
244
- }
245
-
246
- /**
247
- * Column projections for the snapshot read.
248
- *
249
- * The builder reads a handful of fields from each table, but the read used to
250
- * be a bare `select()` — every column of all ten tables, including
251
- * `provider_accounts.credential_ciphertext` and `network_pools.
252
- * endpoint_config`, which the snapshot never touches. That ciphertext is the
253
- * largest thing the gateway stores per account, and the snapshot is rebuilt on
254
- * every catalog change, so the bytes were transferred and then discarded on
255
- * every rebuild. The projection keeps the query count at ten and drops the
256
- * payload to what is actually read.
257
- *
258
- * Each object doubles as the row type (`ModelRouteRow` and friends are derived
259
- * from it), so a column added here is automatically typed, and a column the
260
- * builder starts reading but is missing here fails to compile rather than
261
- * silently reading `undefined`.
262
- */
263
- const PROVIDER_COLUMNS = {
264
- id: providers.id,
265
- tenantId: providers.tenantId,
266
- requiresAccount: providers.requiresAccount,
267
- } as const;
268
-
269
- const MODEL_COLUMNS = {
270
- providerId: models.providerId,
271
- modelId: models.modelId,
272
- wireFamily: models.wireFamily,
273
- serviceKind: models.serviceKind,
274
- endpointPath: models.endpointPath,
275
- modalities: models.modalities,
276
- outputLimit: models.outputLimit,
277
- reasoning: models.reasoning,
278
- toolCall: models.toolCall,
279
- enabled: models.enabled,
280
- // Carried for the plan gate: an upstream may refuse a model on a free plan,
281
- // and only the row says which. See `planAllowsModel`.
282
- proOnly: models.proOnly,
283
- // Carried alongside it: the provider's own statement that the row is free,
284
- // which is the other half of the same plan question.
285
- freeTier: models.freeTier,
286
- } as const;
287
- const ACCOUNT_COLUMNS = {
288
- id: providerAccounts.id,
289
- providerId: providerAccounts.providerId,
290
- tenantId: providerAccounts.tenantId,
291
- label: providerAccounts.label,
292
- status: providerAccounts.status,
293
- cooldownUntil: providerAccounts.cooldownUntil,
294
- modelCooldowns: providerAccounts.modelCooldowns,
295
- lastErrorCategory: providerAccounts.lastErrorCategory,
296
- // The account's last reported plan. Read here rather than from the quota
297
- // cache, which is empty on a cold start — exactly when routing must not be
298
- // guessing whether an account is free.
299
- quotaPlan: providerAccounts.quotaPlan,
300
- } as const;
301
-
302
- const ALIAS_COLUMNS = {
303
- tenantId: modelAliases.tenantId,
304
- alias: modelAliases.alias,
305
- targetModel: modelAliases.targetModel,
306
- } as const;
307
-
308
- const COMBO_COLUMNS = {
309
- tenantId: modelCombos.tenantId,
310
- name: modelCombos.name,
311
- members: modelCombos.members,
312
- strategy: modelCombos.strategy,
313
- } as const;
314
-
315
- const ROUTING_COLUMNS = {
316
- providerId: providerRoutingSettings.providerId,
317
- tenantId: providerRoutingSettings.tenantId,
318
- strategy: providerRoutingSettings.strategy,
319
- rotateCount: providerRoutingSettings.rotateCount,
320
- maxInflight: providerRoutingSettings.maxInflight,
321
- maxInflightPerProxy: providerRoutingSettings.maxInflightPerProxy,
322
- usageFloor: providerRoutingSettings.usageFloor,
323
- usageFloorUnit: providerRoutingSettings.usageFloorUnit,
324
- enabled: providerRoutingSettings.enabled,
325
- skipProxy: providerRoutingSettings.skipProxy,
326
- userAgent: providerRoutingSettings.userAgent,
327
- } as const;
328
-
329
- const POOL_COLUMNS = {
330
- id: networkPools.id,
331
- tenantId: networkPools.tenantId,
332
- status: networkPools.status,
333
- maxInflight: networkPools.maxInflight,
334
- weight: networkPools.weight,
335
- } as const;
336
-
337
- const DISABLED_MODEL_COLUMNS = {
338
- tenantId: tenantDisabledModels.tenantId,
339
- providerId: tenantDisabledModels.providerId,
340
- modelId: tenantDisabledModels.modelId,
341
- endpointPath: tenantDisabledModels.endpointPath,
342
- } as const;
343
-
344
- const CLI_MAPPING_COLUMNS = {
345
- tenantId: cliToolMappings.tenantId,
346
- apiKeyId: cliToolMappings.apiKeyId,
347
- toolId: cliToolMappings.toolId,
348
- sourceModel: cliToolMappings.sourceModel,
349
- targetModel: cliToolMappings.targetModel,
350
- enabled: cliToolMappings.enabled,
351
- } as const;
352
-
353
- /** The per-(tenant, tool, key) Remote Routing switch. A mapping row only
354
- * routes when its bucket's settings row has `mappings_enabled` set. */
355
- const CLI_MAPPING_SETTING_COLUMNS = {
356
- tenantId: cliToolSettings.tenantId,
357
- apiKeyId: cliToolSettings.apiKeyId,
358
- toolId: cliToolSettings.toolId,
359
- mappingsEnabled: cliToolSettings.mappingsEnabled,
360
- } as const;
361
-
362
- const POOL_SETTING_COLUMNS = {
363
- tenantId: poolRoutingSettings.tenantId,
364
- strategy: poolRoutingSettings.strategy,
365
- rotateCount: poolRoutingSettings.rotateCount,
366
- } as const;
367
-
368
- /** A row of `models` as this module reads it. */
369
- type ModelRouteRow = { [K in keyof typeof MODEL_COLUMNS]: (typeof models.$inferSelect)[K] };
370
-
371
- /** Snapshot load result from the 10-table catalog read. */
372
- type RouteCatalogSnapshotResult = Omit<RouteSnapshot, "revision" | "created_at"> & { created_at?: number };
373
-
374
- /** Repository abstracting database queries for the route catalog snapshot. */
375
- class RouteCatalogRepository {
376
- constructor(private readonly db: CartethyiaDatabase) {}
377
-
378
- async loadRouteCatalogSnapshot(tenantId?: string): Promise<RouteCatalogSnapshotResult> {
379
- const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows] =
380
- await Promise.all([
381
- this.db.select(PROVIDER_COLUMNS).from(providers).where(eq(providers.enabled, true)),
382
- this.db.select(MODEL_COLUMNS).from(models),
383
- this.db
384
- .select(ACCOUNT_COLUMNS)
385
- .from(providerAccounts)
386
- .where(ne(providerAccounts.status, "disabled"))
387
- .orderBy(asc(providerAccounts.status), asc(providerAccounts.createdAt), asc(providerAccounts.id)),
388
- this.db.select(ALIAS_COLUMNS).from(modelAliases),
389
- this.db.select(COMBO_COLUMNS).from(modelCombos),
390
- this.db.select(ROUTING_COLUMNS).from(providerRoutingSettings),
391
- this.db.select(POOL_COLUMNS).from(networkPools),
392
- this.db.select(DISABLED_MODEL_COLUMNS).from(tenantDisabledModels),
393
- this.db.select(CLI_MAPPING_COLUMNS).from(cliToolMappings),
394
- this.db.select(CLI_MAPPING_SETTING_COLUMNS).from(cliToolSettings),
395
- this.db.select(POOL_SETTING_COLUMNS).from(poolRoutingSettings),
396
- ]);
397
- const mergedModelRows = mergeModelCatalog(modelRows);
398
-
399
- // Compile tenant-scoped disables into a lookup keyed by the same
400
- // composite identity the `models` table uses. A disabled key suppresses
401
- // the candidate whose account belongs to that tenant.
402
- const disabledModelKeys = new Set<string>(
403
- disabledModelRows.map(
404
- (row) => `${row.tenantId}:${row.providerId}:${row.modelId}:${row.endpointPath}`,
405
- ),
406
- );
407
-
408
- const providerTenantById = new Map<string, string | null>(
409
- providerRows.map((p) => [p.id, p.tenantId]),
410
- );
411
- const requiresAccountById = new Map<string, boolean>(
412
- providerRows.map((p) => [p.id, p.requiresAccount]),
413
- );
414
-
415
- // Every active (non-disabled) pool a tenant owns, available for
416
- // automatic per-request selection — see `resolveNetworkPools` below.
417
- const activePoolsByTenant = new Map<
418
- string,
419
- Array<{ id: string; maxInflight: number; weight: number }>
420
- >();
421
- const configuredPoolTenants = new Set<string>();
422
- for (const pool of poolRows) {
423
- configuredPoolTenants.add(pool.tenantId);
424
- if (pool.status !== "active") continue;
425
- if (!pool.tenantId) continue;
426
- const list = activePoolsByTenant.get(pool.tenantId) ?? [];
427
- list.push({ id: pool.id, maxInflight: pool.maxInflight ?? 10, weight: pool.weight ?? 100 });
428
- activePoolsByTenant.set(pool.tenantId, list);
429
- }
430
-
431
- // Per-tenant pool selection strategy; an absent row reads as the
432
- // `least_loaded` default at selection time.
433
- const poolRouting: Record<string, PoolRoutingSetting> = {};
434
- for (const row of poolSettingRows) {
435
- if (tenantId === undefined || row.tenantId === tenantId) {
436
- poolRouting[row.tenantId] = { strategy: row.strategy, rotateCount: row.rotateCount };
437
- }
438
- }
439
-
440
- // Build per-tenant per-provider routing preferences before the
441
- // candidates loop below, since skipProxy has to be known while
442
- // deciding each candidate's network pool set. Global settings
443
- // (tenant_id IS NULL) are stored under sentinel "__global__".
444
- const providerRouting: Record<string, Record<string, ProviderRoutingMap[string][string]>> = {};
445
- for (const row of routingRows) {
446
- const tenantKey = row.tenantId ?? "__global__";
447
- const bucket = (providerRouting[tenantKey] ??= {});
448
- bucket[row.providerId] = {
449
- strategy: row.strategy as ProviderRoutingMap[string][string]["strategy"],
450
- rotateCount: row.rotateCount ?? 1,
451
- maxInflight: row.maxInflight,
452
- maxInflightPerProxy: row.maxInflightPerProxy,
453
- usageFloor: row.usageFloor,
454
- usageFloorUnit: row.usageFloorUnit === "credit" ? "credit" : "percent",
455
- enabled: row.enabled,
456
- skipProxy: row.skipProxy,
457
- userAgent: row.userAgent,
458
- };
459
- }
460
- /** Tenant-specific setting wins over global; an unconfigured provider
461
- * falls back to `DEFAULT_PROXY_SKIP_PROVIDER_IDS`, mirroring the same
462
- * default `DrizzleProviderDetailStore.getRouting` reports to the API —
463
- * so the dashboard's displayed default and the real dispatch decision
464
- * never disagree. */
465
- function resolveSkipProxy(providerId: string, rowTenantId: string | null): boolean {
466
- const effectiveTenantId = rowTenantId;
467
- const tenantSetting = effectiveTenantId ? providerRouting[effectiveTenantId]?.[providerId] : undefined;
468
- const globalSetting = providerRouting.__global__?.[providerId];
469
- return resolveTenantOverride(
470
- tenantSetting?.skipProxy,
471
- globalSetting?.skipProxy,
472
- DEFAULT_PROXY_SKIP_PROVIDER_IDS.has(providerId),
473
- );
474
- }
475
- function resolveUserAgent(providerId: string, rowTenantId: string | null): string | undefined {
476
- const tenantSetting = rowTenantId ? providerRouting[rowTenantId]?.[providerId]?.userAgent : undefined;
477
- const globalSetting = providerRouting.__global__?.[providerId]?.userAgent;
478
- return resolveRouteUserAgent(providerId, tenantSetting, globalSetting);
479
- }
480
-
481
- /** Provider-wide concurrency ceiling shared by every account of the provider.
482
- * Tenant setting wins over global, mirroring skipProxy. A stored `null`
483
- * is the explicit unlimited choice; so is the default — a provider with no
484
- * routing row at all is unlimited too, because an operator who never
485
- * configured a ceiling asked for none. */
486
- function resolveMaxInflight(
487
- providerId: string,
488
- rowTenantId: string | null,
489
- ): number | undefined {
490
- const tenantSetting = rowTenantId
491
- ? providerRouting[rowTenantId]?.[providerId]?.maxInflight
492
- : undefined;
493
- const globalSetting = providerRouting.__global__?.[providerId]?.maxInflight;
494
- const resolved = tenantSetting ?? globalSetting;
495
- if (resolved === null) return undefined;
496
- return resolved ?? DEFAULT_PROVIDER_MAX_INFLIGHT ?? undefined;
497
- }
498
-
499
- /**
500
- * This provider's per-pool inflight ceiling, from Routing Strategy.
501
- * `undefined` means unlimited — the default, and the state of every
502
- * provider nobody has configured. A stored `null` is the same choice made
503
- * explicitly; the two are deliberately indistinguishable downstream so the
504
- * hot path never branches on which one it read.
505
- */
506
- function resolveMaxInflightPerProxy(
507
- providerId: string,
508
- rowTenantId: string | null,
509
- ): number | undefined {
510
- const tenantSetting = rowTenantId
511
- ? providerRouting[rowTenantId]?.[providerId]?.maxInflightPerProxy
512
- : undefined;
513
- const globalSetting = providerRouting.__global__?.[providerId]?.maxInflightPerProxy;
514
- return tenantSetting ?? globalSetting ?? undefined;
515
- }
516
-
517
- /** Every active pool the account's tenant owns — dispatch picks the
518
- * least-loaded/non-cooldown one per request (`tryAcquireAvailablePool`),
519
- * never an admin-pinned single pool. */
520
- function resolveNetworkPools(
521
- providerId: string,
522
- rowTenantId: string | null,
523
- ):
524
- | {
525
- ids: readonly string[];
526
- limits: Record<string, number>;
527
- weights: Record<string, number>;
528
- routing?: PoolRoutingSetting & { tenantId: string };
529
- }
530
- | undefined {
531
- if (!rowTenantId || resolveSkipProxy(providerId, rowTenantId)) return undefined;
532
- if (!configuredPoolTenants.has(rowTenantId)) return undefined;
533
- const pools = activePoolsByTenant.get(rowTenantId) ?? [];
534
- const limits: Record<string, number> = {};
535
- const weights: Record<string, number> = {};
536
- for (const pool of pools) {
537
- limits[pool.id] = pool.maxInflight;
538
- weights[pool.id] = pool.weight;
539
- }
540
- const routing = poolRouting[rowTenantId];
541
- return {
542
- ids: pools.map((pool) => pool.id),
543
- limits,
544
- weights,
545
- ...(routing ? { routing: { tenantId: rowTenantId, ...routing } } : {}),
546
- };
547
- }
548
-
549
- /** The candidate fragment for a provider ceiling, empty when unlimited —
550
- * so an unconfigured provider's candidate is byte-for-byte what it was. */
551
- function providerLimitFor(
552
- providerId: string,
553
- rowTenantId: string | null,
554
- ): { network_pool_provider_limit?: number } {
555
- const limit = resolveMaxInflightPerProxy(providerId, rowTenantId);
556
- return limit === undefined ? {} : { network_pool_provider_limit: limit };
557
- }
558
-
559
- // Group usable accounts per provider (not per provider-tenant). Built-in
560
- // providers are `tenant_id IS NULL` but their accounts are per-tenant, so
561
- // grouping by `providerTenantId` would make every global model look
562
- // disabled. Instead keep all accounts for a provider together and fan out
563
- // candidates per account tenant below — e.g. `opencodeze/muse-spark`
564
- // hits with `model: "opencodeze/muse-spark"` must resolve to the tenant's
565
- // own account even though the provider row is global.
566
- const accountsByProvider = new Map<string, typeof accountRows>();
567
- for (const account of accountRows) {
568
- if (account.status === "disabled") continue;
569
- const list = accountsByProvider.get(account.providerId) ?? [];
570
- list.push(account);
571
- accountsByProvider.set(account.providerId, list);
572
- }
573
-
574
- const candidates: RouteCandidateWithHealth[] = [];
575
- for (const model of mergedModelRows) {
576
- const providerTenantId = providerTenantById.get(model.providerId);
577
- if (providerTenantId === undefined) continue;
578
- const accounts = accountsByProvider.get(model.providerId) ?? [];
579
- // One profile per model row, memoized across snapshot rebuilds. The
580
- // profile is derived purely from the row's metadata, is never mutated
581
- // (grep finds no write to `capability_profile`), and is shared by every
582
- // candidate this row fans out to — so a rebuild no longer constructs a
583
- // fresh object per model on top of the fan-out it already had.
584
- const capabilityProfile = capabilityProfileFor(model);
585
- if (accounts.length === 0) {
586
- const providerRequiresAccount = requiresAccountById.get(model.providerId) ?? true;
587
- if (!providerRequiresAccount) {
588
- // Public models inherit active pools for each tenant that has active pools
589
- for (const tId of configuredPoolTenants) {
590
- const tenantPools = resolveNetworkPools(model.providerId, tId);
591
- if (tenantPools) {
592
- const tenantCandidate: RouteCandidateWithHealth = {
593
- provider_id: model.providerId,
594
- model_id: model.modelId,
595
- wire_family: model.wireFamily as WireFamily,
596
- service_kind: model.serviceKind,
597
- endpoint: model.endpointPath,
598
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
599
- capability_profile: capabilityProfile,
600
- tenant_id: tId,
601
- requires_account: false as const,
602
- network_pool_ids: tenantPools.ids,
603
- network_pool_required: true,
604
- network_pool_limits: tenantPools.limits,
605
- network_pool_weights: tenantPools.weights,
606
- ...(tenantPools.routing ? { network_pool_routing: tenantPools.routing } : {}),
607
- ...providerLimitFor(model.providerId, tId),
608
- };
609
- if (tenantId === undefined || tenantId === tId) {
610
- candidates.push(tenantCandidate);
611
- }
612
- }
613
- }
614
- }
615
- const networkPools = providerRequiresAccount
616
- ? undefined
617
- : resolveNetworkPools(model.providerId, providerTenantId);
618
- const candidate: RouteCandidateWithHealth = {
619
- provider_id: model.providerId,
620
- model_id: model.modelId,
621
- wire_family: model.wireFamily as WireFamily,
622
- service_kind: model.serviceKind,
623
- endpoint: model.endpointPath,
624
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
625
- capability_profile: capabilityProfile,
626
- tenant_id: providerTenantId,
627
- ...(providerRequiresAccount
628
- ? { health_status: "disabled" as const }
629
- : { requires_account: false as const }),
630
- ...(networkPools
631
- ? {
632
- network_pool_ids: networkPools.ids,
633
- network_pool_required: true,
634
- network_pool_limits: networkPools.limits,
635
- network_pool_weights: networkPools.weights,
636
- ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
637
- ...providerLimitFor(model.providerId, providerTenantId),
638
- }
639
- : {}),
640
- };
641
- if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
642
- candidates.push(candidate);
643
- }
644
- continue;
645
- }
646
- for (const account of accounts) {
647
- const rowTenantId = account.tenantId ?? providerTenantId;
648
- if (
649
- rowTenantId !== null &&
650
- disabledModelKeys.has(
651
- `${rowTenantId}:${model.providerId}:${model.modelId}:${model.endpointPath}`,
652
- )
653
- ) {
654
- continue;
655
- }
656
- const networkPools = resolveNetworkPools(model.providerId, rowTenantId);
657
- const routeUserAgent = resolveUserAgent(model.providerId, rowTenantId);
658
- const candidate: RouteCandidateWithHealth = {
659
- provider_id: model.providerId,
660
- model_id: model.modelId,
661
- wire_family: model.wireFamily as WireFamily,
662
- service_kind: model.serviceKind,
663
- endpoint: model.endpointPath,
664
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
665
- capability_profile: capabilityProfile,
666
- ...(routeUserAgent === undefined ? {} : { user_agent: routeUserAgent }),
667
- tenant_id: rowTenantId,
668
- provider_account_id: account.id,
669
- // The plan gate is per (account, model): the model may be refused on
670
- // a free plan and the account may be on one. Both ride here because
671
- // `plan()` sees the candidate and nothing else.
672
- ...(model.proOnly === true ? { pro_only: true } : {}),
673
- ...(account.quotaPlan === null || account.quotaPlan === undefined
674
- ? {}
675
- : { account_plan: account.quotaPlan }),
676
- // Provider-wide concurrency ceiling from Routing Strategy; legacy
677
- // account overrides are never read here.
678
- ...(() => {
679
- const resolved = resolveMaxInflight(model.providerId, rowTenantId);
680
- return resolved === undefined ? {} : { max_inflight: resolved };
681
- })(),
682
- ...(networkPools
683
- ? {
684
- network_pool_required: true,
685
- network_pool_ids: networkPools.ids,
686
- network_pool_limits: networkPools.limits,
687
- network_pool_weights: networkPools.weights,
688
- ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
689
- ...providerLimitFor(model.providerId, rowTenantId),
690
- }
691
- : {}),
692
- };
693
- if (account.status === "cooldown") {
694
- // Expired cooldowns read as healthy here without a fire-and-forget
695
- // write: AccountHealthSweeper materializes the recovery every 30s,
696
- // so the builder stays a pure read (no UPDATE racing the request).
697
- if (account.cooldownUntil && account.cooldownUntil.getTime() > Date.now()) {
698
- candidate.health_status = "cooldown";
699
- candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
700
- ? "hard"
701
- : "soft";
702
- // The deadline rides with the exclusion it explains. `plan()` reads
703
- // it to answer the all-cooling case with the real reset instead of
704
- // a bare 429, so a client backs off for the window that is actually
705
- // open rather than inventing one.
706
- candidate.cooldown_until = account.cooldownUntil.getTime();
707
- }
708
- }
709
-
710
- // Per-model cooldown: an account may be healthy globally but cooling down
711
- // for a specific model. A non-expired entry for the model THIS candidate
712
- // serves is a hard exclusion, not a deprioritization: the upstream said
713
- // this exact (account, model) pair is exhausted until a stated reset
714
- // ("Daily free limit reached … Try again in 11h"), so dialing it can only
715
- // reproduce the same refusal. Deprioritizing instead made failover spend
716
- // a full round trip on it after every healthy sibling had failed, and
717
- // recorded another `active → cooldown` row each time — the operator's
718
- // report of one account churning while ten siblings sat healthy.
719
- //
720
- // `model_cooldown` (distinct from `cooldown`) is what carries that
721
- // distinction to the evaluator: an account-wide `cooldown` stays
722
- // deprioritized so a single-account deployment still routes, while a
723
- // model-scoped one is dropped for this model only and the account keeps
724
- // serving every other model it holds.
725
- const modelCooldowns = account.modelCooldowns as Record<string, string> | null;
726
- const modelCooldownUntil = modelCooldowns?.[model.modelId];
727
- if (modelCooldownUntil && new Date(modelCooldownUntil).getTime() > Date.now()) {
728
- candidate.health_status = "model_cooldown";
729
- candidate.cooldown_until = new Date(modelCooldownUntil).getTime();
730
- }
731
- if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
732
- candidates.push(candidate);
733
- }
734
- }
735
- }
736
-
737
- const aliases: Record<string, Record<string, string>> = {};
738
- const cliAliases: Record<string, Record<string, string>> = {};
739
- for (const row of aliasRows) {
740
- if (tenantId === undefined || row.tenantId === tenantId) {
741
- (aliases[row.tenantId] ??= {})[row.alias] = row.targetModel;
742
- }
743
- }
744
- // CLI-tool mappings are per-(tenant, key): each API key can route the same
745
- // CLI slot to a different target. The snapshot keys the alias bucket by
746
- // `${tenantId}:${apiKeyId}` so the preparer can look up the exact key's
747
- // routes without merging across keys.
748
- // Remote Routing is opt-in per (tenant, tool, key): a mapping row routes
749
- // only when its bucket's settings row explicitly enabled it. The CLI tool
750
- // page and the API-key edit form both write that one flag, so either place
751
- // can turn the routes on or off.
752
- const enabledMappingBuckets = new Set<string>();
753
- for (const row of cliMappingSettingRows) {
754
- if (row.mappingsEnabled) enabledMappingBuckets.add(`${row.tenantId}:${row.apiKeyId}`);
755
- }
756
- for (const row of cliMappingRows) {
757
- if (!row.enabled) continue;
758
- if (!enabledMappingBuckets.has(`${row.tenantId}:${row.apiKeyId}`)) continue;
759
- if (tenantId === undefined || row.tenantId === tenantId) {
760
- const bucketKey = `${row.tenantId}:${row.apiKeyId}`;
761
- const aliasBucket = (cliAliases[bucketKey] ??= {});
762
- for (const sourceKey of cliMappingSourceKeys(row.toolId, row.sourceModel)) {
763
- aliasBucket[sourceKey] = row.targetModel;
764
- }
765
- }
766
- }
767
- const combos: Record<string, Record<string, ComboDefinition>> = {};
768
- for (const row of comboRows) {
769
- if (tenantId === undefined || row.tenantId === tenantId) {
770
- (combos[row.tenantId] ??= {})[row.name] = { members: row.members, strategy: row.strategy };
771
- }
772
- }
773
-
774
- const routingMap: ProviderRoutingMap | undefined =
775
- Object.keys(providerRouting).length > 0 ? (providerRouting as ProviderRoutingMap) : undefined;
776
-
777
- return {
778
- candidates,
779
- aliases,
780
- cli_aliases: cliAliases,
781
- combos,
782
- ...(routingMap ? { providerRouting: routingMap } : {}),
783
- ...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
784
- };
785
- }
786
- }
787
-
788
- /** Builds a `SnapshotBuilder` reading the live catalog from `db`. */
789
- export function createDatabaseSnapshotBuilder(
790
- db: CartethyiaDatabase,
791
- refreshActiveCatalogs?: () => Promise<void>,
792
- ): SnapshotBuilder {
793
- const repository = new RouteCatalogRepository(db);
794
- return async () => {
795
- await refreshActiveCatalogs?.();
796
- return repository.loadRouteCatalogSnapshot(undefined);
797
- };
798
- }
799
-
1
+ // Routing snapshot: builds the RouteSnapshot from the persisted model catalog.
2
+
3
+ import { asc, eq, ne } from "drizzle-orm";
4
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
5
+ import {
6
+ consoleSettings,
7
+ models,
8
+ modelAliases,
9
+ modelCombos,
10
+ networkPools,
11
+ poolRoutingSettings,
12
+ providerAccounts,
13
+ providerRoutingSettings,
14
+ providers,
15
+ tenantDisabledModels,
16
+ cliToolMappings,
17
+ cliToolSettings,
18
+ } from "../../persistence/schema";
19
+ import { DEFAULT_PROVIDER_MAX_INFLIGHT } from "../../persistence/schema";
20
+ import { resolveTenantOverride } from "../../persistence/tenant-scope";
21
+ import {
22
+ type RouteCandidate,
23
+ type ComboDefinition,
24
+ type PoolRoutingSetting,
25
+ type ProviderRoutingMap,
26
+ type RouteSnapshot,
27
+ type SnapshotBuilder,
28
+ } from "./route-model";
29
+ import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metadata";
30
+ import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
31
+ import { isBundledProviderId } from "../../providers/provider-contracts";
32
+ import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
33
+ import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
34
+
35
+ const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
36
+
37
+ export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
38
+ if (toolId !== "claude") return [sourceModel];
39
+ const normalized = sourceModel.trim().toLowerCase();
40
+ const family =
41
+ CLAUDE_MODEL_FAMILIES.has(normalized)
42
+ ? normalized
43
+ : /^claude-(opus|sonnet|haiku|fable|mythos)(?:-|$)/.exec(normalized)?.[1];
44
+ if (normalized.includes("/") || family === undefined) return [sourceModel];
45
+ return [...new Set([
46
+ sourceModel,
47
+ family,
48
+ `claude-${family}-5`,
49
+ `claude-${family}-5-1`,
50
+ `claude-${family}-5-5`,
51
+ `claude-${family}-4-6`,
52
+ `claude-${family}-4-5`,
53
+ ])];
54
+ }
55
+ /**
56
+ * Builds the routing `RouteSnapshot` from the persisted catalog
57
+ * (`providers`, `models`, `provider_accounts`). Consumed by
58
+ * `InMemoryRouteSnapshotService` as its `SnapshotBuilder`.
59
+ *
60
+ * Built-in definitions are materialized before snapshots are read. The
61
+ * `models` DB table is authoritative for routing; compiled definitions are
62
+ * only startup seed data. Custom/BYOK and live-discovered models are stored
63
+ * in the same table.
64
+ *
65
+ * Tenant-aware: a provider with `providers.tenant_id = NULL` is global
66
+ * (every built-in, plus any pool-wide custom provider) and its candidates
67
+ * carry `tenant_id: null`, matching every request regardless of tenant. A
68
+ * provider with `providers.tenant_id` set (BYOK) only pairs with accounts
69
+ * owned by that exact same tenant, and its candidates carry that tenant id
70
+ * — `RoutingEngine.plan()` filters candidates by the requesting tenant.
71
+ */
72
+
73
+ type RouteCandidateWithHealth = RouteCandidate & {
74
+ health_status?: "cooldown" | "model_cooldown" | "disabled";
75
+ cooldown_kind?: "hard" | "soft";
76
+ /**
77
+ * Epoch milliseconds at which this candidate's cooling window ends, when one
78
+ * is in force. Routing-internal: it is what lets `plan()` report a real reset
79
+ * on the all-accounts-cooling answer, and it never reaches a client verbatim —
80
+ * only the derived `retry-after` does.
81
+ */
82
+ cooldown_until?: number;
83
+ };
84
+
85
+ const HARD_COOLDOWN_CATEGORIES: Readonly<Record<string, true>> = {
86
+ quota_exhausted: true,
87
+ policy_blocked: true,
88
+ auth_invalidated: true,
89
+ };
90
+
91
+ function hardCooldownCategory(category: string | null): boolean {
92
+ return category !== null && HARD_COOLDOWN_CATEGORIES[category] === true;
93
+ }
94
+
95
+ /**
96
+ * Projects one model row's metadata into the capability gates the router
97
+ * filters candidates by. Exported so the projection contract is unit-testable
98
+ * without a database.
99
+ */
100
+ export function buildCapabilityProfile(row: {
101
+ modalities: unknown;
102
+ reasoning: boolean;
103
+ toolCall: boolean;
104
+ providerId: string;
105
+ }): Record<string, boolean> {
106
+ const mods =
107
+ row.modalities != null && typeof row.modalities === "object"
108
+ ? (row.modalities as { input?: unknown[]; output?: unknown[] })
109
+ : {};
110
+ const inputMods = Array.isArray(mods.input) ? mods.input : [];
111
+ const outputMods = Array.isArray(mods.output) ? mods.output : [];
112
+ // The canonical chat and responses codecs encode `image`, `document`/`file`,
113
+ // and `audio` parts; the messages codec encodes the first two but has no
114
+ // audio block (see `AUDIO_CAPABLE_WIRE_FAMILIES`). A bespoke adapter (Cursor)
115
+ // frames its own protocol and has no generic rich-content path.
116
+ //
117
+ // The codec grant is a **fallback for rows with no modality metadata**, not
118
+ // an override: `defineModel` always seeds `"text"`, so a non-empty array is
119
+ // an explicit declaration and its contents are authoritative — a row that
120
+ // declares `["text"]` is a text-only row and the router must strip the image
121
+ // rather than forward it to an upstream that rejects it. An empty array is a
122
+ // discovered or legacy row that carried no metadata, and pass-through keeps
123
+ // a caller's attachment from being degraded on the strength of a guess.
124
+ // Whether a given upstream model accepts the part is still the upstream's
125
+ // call — a capability declared here only decides whether the router strips
126
+ // the part before it ever sees it, and the audio grant is narrowed per wire
127
+ // family at projection time.
128
+ const codecEncodesRichContent = !providerUsesBespokeWire(row.providerId);
129
+ const declaredInput = inputMods.length > 0;
130
+ const richContentByCodec = codecEncodesRichContent && !declaredInput;
131
+ // Reasoning and tools are never stripped here. A `false` flag — whether a
132
+ // discovered row recorded it for lack of metadata, or a catalog row set it
133
+ // explicitly — must not become a silent rewrite of a request the caller
134
+ // asked for. The upstream decides whether it can serve them and returns its
135
+ // own error if it cannot.
136
+ return {
137
+ // The route is not served by a canonical codec. Carried in the profile
138
+ // because the capability predicates downstream see only this snapshot, not
139
+ // the provider registry.
140
+ bespokeWire: !codecEncodesRichContent,
141
+ image: inputMods.includes("image") || richContentByCodec,
142
+ document: inputMods.includes("document") || richContentByCodec,
143
+ audio: inputMods.includes("audio") || richContentByCodec,
144
+ mediaGeneration: outputMods.includes("image"),
145
+ tools: true,
146
+ parallelToolCalls: true,
147
+ reasoning: true,
148
+ reasoningEncryptedContent: true,
149
+ // Search is its own route: a `websearch` row answers `POST /v1/search`.
150
+ // A chat row never claims it; a hosted declaration rides through `tools`.
151
+ webSearch: false,
152
+ responseJsonObject: true,
153
+ responseJsonSchema: true,
154
+ promptCaching: true,
155
+ };
156
+ }
157
+
158
+ /**
159
+ * The capability profile for one model row, memoized across rebuilds.
160
+ *
161
+ * Bounded LRU keyed by `providerId/modelId`: the value is a pure function of
162
+ * the row's metadata, so two rebuilds that see the same row get the same object
163
+ * instead of two identical ones. The bound keeps a long-lived process from
164
+ * pinning one profile per row it has ever seen — a model deleted from the
165
+ * catalog must not leave its profile behind forever.
166
+ */
167
+ const CAPABILITY_PROFILE_CACHE_MAX_ENTRIES = 4096;
168
+ const capabilityProfileCache = new Map<string, Record<string, boolean>>();
169
+
170
+ function capabilityProfileFor(row: {
171
+ modalities: unknown;
172
+ reasoning: boolean;
173
+ toolCall: boolean;
174
+ providerId: string;
175
+ modelId: string;
176
+ }): Record<string, boolean> {
177
+ const key = `${row.providerId}/${row.modelId}`;
178
+ const cached = capabilityProfileCache.get(key);
179
+ if (cached !== undefined) return cached;
180
+ const profile = buildCapabilityProfile(row);
181
+ if (capabilityProfileCache.size >= CAPABILITY_PROFILE_CACHE_MAX_ENTRIES) {
182
+ const oldest = capabilityProfileCache.keys().next().value;
183
+ if (oldest !== undefined) capabilityProfileCache.delete(oldest);
184
+ }
185
+ capabilityProfileCache.set(key, profile);
186
+ return profile;
187
+ }
188
+
189
+ /** Live memo size, for tests and the metrics gauge. */
190
+ export function capabilityProfileCacheSize(): number {
191
+ return capabilityProfileCache.size;
192
+ }
193
+
194
+ /** Resolves route identity only for bundled adapters without a native User-Agent builder. */
195
+ export function resolveRouteUserAgent(
196
+ providerId: string,
197
+ tenantUserAgent: string | undefined,
198
+ globalUserAgent: string | undefined,
199
+ ): string | undefined {
200
+ if (!isBundledProviderId(providerId) || providerHasAdapterUserAgent(providerId)) return undefined;
201
+ // A provider whose operator set no identity of their own gets the Claude Code
202
+ // CLI identity, resolved live so the stamped version tracks discovery. This
203
+ // used to be a frozen `codex_cli_rs` literal, which named a different vendor's
204
+ // client on requests that mostly reached neither.
205
+ const selected = resolveTenantOverride(
206
+ tenantUserAgent,
207
+ globalUserAgent,
208
+ buildClaudeCliUserAgent(),
209
+ );
210
+ return selected.trim() === "" ? undefined : selected;
211
+ }
212
+
213
+ /** One persisted model row ready for candidate construction. */
214
+ interface MergedModelRow {
215
+ providerId: string;
216
+ modelId: string;
217
+ wireFamily: WireFamily;
218
+ serviceKind: ServiceKind;
219
+ endpointPath: string;
220
+ modalities: unknown;
221
+ outputLimit: number | null;
222
+ reasoning: boolean;
223
+ toolCall: boolean;
224
+ enabled: boolean;
225
+ proOnly: boolean | null;
226
+ /** Whether the provider publishes the row as free. Read for plan logging. */
227
+ freeTier: boolean | null;
228
+ }
229
+ /**
230
+ * Converts persisted model rows into candidate input. Seed materialization is
231
+ * performed before this function runs, so no static-vs-DB merge is needed.
232
+ */
233
+ function mergeModelCatalog(
234
+ dbRows: readonly ModelRouteRow[],
235
+ ): MergedModelRow[] {
236
+ return dbRows
237
+ .filter((row) => row.enabled)
238
+ .map((row) => ({
239
+ providerId: row.providerId,
240
+ modelId: row.modelId,
241
+ wireFamily: row.wireFamily as WireFamily,
242
+ serviceKind: row.serviceKind as ServiceKind,
243
+ endpointPath: row.endpointPath,
244
+ modalities: row.modalities,
245
+ outputLimit: row.outputLimit,
246
+ reasoning: row.reasoning,
247
+ toolCall: row.toolCall,
248
+ enabled: row.enabled,
249
+ freeTier: row.freeTier ?? null,
250
+ proOnly: row.proOnly ?? null,
251
+ }));
252
+ }
253
+
254
+ /**
255
+ * Column projections for the snapshot read.
256
+ *
257
+ * The builder reads a handful of fields from each table, but the read used to
258
+ * be a bare `select()` — every column of all ten tables, including
259
+ * `provider_accounts.credential_ciphertext` and `network_pools.
260
+ * endpoint_config`, which the snapshot never touches. That ciphertext is the
261
+ * largest thing the gateway stores per account, and the snapshot is rebuilt on
262
+ * every catalog change, so the bytes were transferred and then discarded on
263
+ * every rebuild. The projection keeps the query count at ten and drops the
264
+ * payload to what is actually read.
265
+ *
266
+ * Each object doubles as the row type (`ModelRouteRow` and friends are derived
267
+ * from it), so a column added here is automatically typed, and a column the
268
+ * builder starts reading but is missing here fails to compile rather than
269
+ * silently reading `undefined`.
270
+ */
271
+ const PROVIDER_COLUMNS = {
272
+ id: providers.id,
273
+ tenantId: providers.tenantId,
274
+ requiresAccount: providers.requiresAccount,
275
+ } as const;
276
+
277
+ const MODEL_COLUMNS = {
278
+ providerId: models.providerId,
279
+ modelId: models.modelId,
280
+ wireFamily: models.wireFamily,
281
+ serviceKind: models.serviceKind,
282
+ endpointPath: models.endpointPath,
283
+ modalities: models.modalities,
284
+ outputLimit: models.outputLimit,
285
+ reasoning: models.reasoning,
286
+ toolCall: models.toolCall,
287
+ enabled: models.enabled,
288
+ // Carried for the plan gate: an upstream may refuse a model on a free plan,
289
+ // and only the row says which. See `planAllowsModel`.
290
+ proOnly: models.proOnly,
291
+ // Carried alongside it: the provider's own statement that the row is free,
292
+ // which is the other half of the same plan question.
293
+ freeTier: models.freeTier,
294
+ } as const;
295
+ const ACCOUNT_COLUMNS = {
296
+ id: providerAccounts.id,
297
+ providerId: providerAccounts.providerId,
298
+ tenantId: providerAccounts.tenantId,
299
+ label: providerAccounts.label,
300
+ status: providerAccounts.status,
301
+ cooldownUntil: providerAccounts.cooldownUntil,
302
+ modelCooldowns: providerAccounts.modelCooldowns,
303
+ lastErrorCategory: providerAccounts.lastErrorCategory,
304
+ // The account's last reported plan. Read here rather than from the quota
305
+ // cache, which is empty on a cold start — exactly when routing must not be
306
+ // guessing whether an account is free.
307
+ quotaPlan: providerAccounts.quotaPlan,
308
+ } as const;
309
+
310
+ const ALIAS_COLUMNS = {
311
+ tenantId: modelAliases.tenantId,
312
+ alias: modelAliases.alias,
313
+ targetModel: modelAliases.targetModel,
314
+ } as const;
315
+
316
+ const COMBO_COLUMNS = {
317
+ tenantId: modelCombos.tenantId,
318
+ name: modelCombos.name,
319
+ members: modelCombos.members,
320
+ strategy: modelCombos.strategy,
321
+ } as const;
322
+
323
+ const ROUTING_COLUMNS = {
324
+ providerId: providerRoutingSettings.providerId,
325
+ tenantId: providerRoutingSettings.tenantId,
326
+ strategy: providerRoutingSettings.strategy,
327
+ rotateCount: providerRoutingSettings.rotateCount,
328
+ maxInflight: providerRoutingSettings.maxInflight,
329
+ maxInflightPerProxy: providerRoutingSettings.maxInflightPerProxy,
330
+ usageFloor: providerRoutingSettings.usageFloor,
331
+ usageFloorUnit: providerRoutingSettings.usageFloorUnit,
332
+ enabled: providerRoutingSettings.enabled,
333
+ skipProxy: providerRoutingSettings.skipProxy,
334
+ userAgent: providerRoutingSettings.userAgent,
335
+ } as const;
336
+
337
+ const POOL_COLUMNS = {
338
+ id: networkPools.id,
339
+ tenantId: networkPools.tenantId,
340
+ status: networkPools.status,
341
+ maxInflight: networkPools.maxInflight,
342
+ weight: networkPools.weight,
343
+ } as const;
344
+
345
+ const DISABLED_MODEL_COLUMNS = {
346
+ tenantId: tenantDisabledModels.tenantId,
347
+ providerId: tenantDisabledModels.providerId,
348
+ modelId: tenantDisabledModels.modelId,
349
+ endpointPath: tenantDisabledModels.endpointPath,
350
+ } as const;
351
+
352
+ const CLI_MAPPING_COLUMNS = {
353
+ tenantId: cliToolMappings.tenantId,
354
+ apiKeyId: cliToolMappings.apiKeyId,
355
+ toolId: cliToolMappings.toolId,
356
+ sourceModel: cliToolMappings.sourceModel,
357
+ targetModel: cliToolMappings.targetModel,
358
+ enabled: cliToolMappings.enabled,
359
+ } as const;
360
+
361
+ /** The per-(tenant, tool, key) Remote Routing switch. A mapping row only
362
+ * routes when its bucket's settings row has `mappings_enabled` set. */
363
+ const CLI_MAPPING_SETTING_COLUMNS = {
364
+ tenantId: cliToolSettings.tenantId,
365
+ apiKeyId: cliToolSettings.apiKeyId,
366
+ toolId: cliToolSettings.toolId,
367
+ mappingsEnabled: cliToolSettings.mappingsEnabled,
368
+ } as const;
369
+
370
+ const POOL_SETTING_COLUMNS = {
371
+ tenantId: poolRoutingSettings.tenantId,
372
+ strategy: poolRoutingSettings.strategy,
373
+ rotateCount: poolRoutingSettings.rotateCount,
374
+ } as const;
375
+
376
+ /** A row of `models` as this module reads it. */
377
+ type ModelRouteRow = { [K in keyof typeof MODEL_COLUMNS]: (typeof models.$inferSelect)[K] };
378
+
379
+ /** Snapshot load result from the 10-table catalog read. */
380
+ type RouteCatalogSnapshotResult = Omit<RouteSnapshot, "revision" | "created_at"> & { created_at?: number };
381
+
382
+ /** Repository abstracting database queries for the route catalog snapshot. */
383
+ class RouteCatalogRepository {
384
+ constructor(private readonly db: CartethyiaDatabase) {}
385
+
386
+ async loadRouteCatalogSnapshot(tenantId?: string): Promise<RouteCatalogSnapshotResult> {
387
+ const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows, settingsRows] =
388
+ await Promise.all([
389
+ this.db.select(PROVIDER_COLUMNS).from(providers).where(eq(providers.enabled, true)),
390
+ this.db.select(MODEL_COLUMNS).from(models),
391
+ this.db
392
+ .select(ACCOUNT_COLUMNS)
393
+ .from(providerAccounts)
394
+ .where(ne(providerAccounts.status, "disabled"))
395
+ .orderBy(asc(providerAccounts.status), asc(providerAccounts.createdAt), asc(providerAccounts.id)),
396
+ this.db.select(ALIAS_COLUMNS).from(modelAliases),
397
+ this.db.select(COMBO_COLUMNS).from(modelCombos),
398
+ this.db.select(ROUTING_COLUMNS).from(providerRoutingSettings),
399
+ this.db.select(POOL_COLUMNS).from(networkPools),
400
+ this.db.select(DISABLED_MODEL_COLUMNS).from(tenantDisabledModels),
401
+ this.db.select(CLI_MAPPING_COLUMNS).from(cliToolMappings),
402
+ this.db.select(CLI_MAPPING_SETTING_COLUMNS).from(cliToolSettings),
403
+ this.db.select(POOL_SETTING_COLUMNS).from(poolRoutingSettings),
404
+ // The cooldown override is a console_settings preference, read inside
405
+ // the same fan-out rather than per request: one more row set on a
406
+ // query that already runs, instead of a Postgres round trip on every
407
+ // admitted request the way `tenantConcurrencyLimit` once did.
408
+ this.db
409
+ .select({ tenantId: consoleSettings.tenantId, preferences: consoleSettings.preferences })
410
+ .from(consoleSettings),
411
+ ]);
412
+ const mergedModelRows = mergeModelCatalog(modelRows);
413
+ /**
414
+ * Tenants that asked to ignore an account-wide cooldown.
415
+ *
416
+ * Read-only override: the cooldown is still on the row, still shown in
417
+ * Health, and still recovered by the sweep. This only stops it from
418
+ * excluding the candidate, so turning the setting off needs no cleanup.
419
+ *
420
+ * Per-(account, model) cooldowns are deliberately NOT overridden — see the
421
+ * preference's own doc.
422
+ */
423
+ const cooldownOverrideTenants = new Set<string>();
424
+ for (const row of settingsRows ?? []) {
425
+ if (row.preferences?.forceDisableCooldown === true && row.tenantId) {
426
+ cooldownOverrideTenants.add(row.tenantId);
427
+ }
428
+ }
429
+ /**
430
+ * A shared account has no tenant of its own (`tenant_id IS NULL`), so a
431
+ * tenant-keyed override would never match it — yet a shared account is
432
+ * exactly the one every tenant dials, and parking it stops all of them.
433
+ * When any tenant asks, the override covers shared accounts too.
434
+ */
435
+ const anyTenantOverrides = cooldownOverrideTenants.size > 0;
436
+
437
+ // Compile tenant-scoped disables into a lookup keyed by the same
438
+ // composite identity the `models` table uses. A disabled key suppresses
439
+ // the candidate whose account belongs to that tenant.
440
+ const disabledModelKeys = new Set<string>(
441
+ disabledModelRows.map(
442
+ (row) => `${row.tenantId}:${row.providerId}:${row.modelId}:${row.endpointPath}`,
443
+ ),
444
+ );
445
+
446
+ const providerTenantById = new Map<string, string | null>(
447
+ providerRows.map((p) => [p.id, p.tenantId]),
448
+ );
449
+ const requiresAccountById = new Map<string, boolean>(
450
+ providerRows.map((p) => [p.id, p.requiresAccount]),
451
+ );
452
+
453
+ // Every active (non-disabled) pool a tenant owns, available for
454
+ // automatic per-request selection — see `resolveNetworkPools` below.
455
+ const activePoolsByTenant = new Map<
456
+ string,
457
+ Array<{ id: string; maxInflight: number; weight: number }>
458
+ >();
459
+ const configuredPoolTenants = new Set<string>();
460
+ for (const pool of poolRows) {
461
+ configuredPoolTenants.add(pool.tenantId);
462
+ if (pool.status !== "active") continue;
463
+ if (!pool.tenantId) continue;
464
+ const list = activePoolsByTenant.get(pool.tenantId) ?? [];
465
+ list.push({ id: pool.id, maxInflight: pool.maxInflight ?? 10, weight: pool.weight ?? 100 });
466
+ activePoolsByTenant.set(pool.tenantId, list);
467
+ }
468
+
469
+ // Per-tenant pool selection strategy; an absent row reads as the
470
+ // `least_loaded` default at selection time.
471
+ const poolRouting: Record<string, PoolRoutingSetting> = {};
472
+ for (const row of poolSettingRows) {
473
+ if (tenantId === undefined || row.tenantId === tenantId) {
474
+ poolRouting[row.tenantId] = { strategy: row.strategy, rotateCount: row.rotateCount };
475
+ }
476
+ }
477
+
478
+ // Build per-tenant per-provider routing preferences before the
479
+ // candidates loop below, since skipProxy has to be known while
480
+ // deciding each candidate's network pool set. Global settings
481
+ // (tenant_id IS NULL) are stored under sentinel "__global__".
482
+ const providerRouting: Record<string, Record<string, ProviderRoutingMap[string][string]>> = {};
483
+ for (const row of routingRows) {
484
+ const tenantKey = row.tenantId ?? "__global__";
485
+ const bucket = (providerRouting[tenantKey] ??= {});
486
+ bucket[row.providerId] = {
487
+ strategy: row.strategy as ProviderRoutingMap[string][string]["strategy"],
488
+ rotateCount: row.rotateCount ?? 1,
489
+ maxInflight: row.maxInflight,
490
+ maxInflightPerProxy: row.maxInflightPerProxy,
491
+ usageFloor: row.usageFloor,
492
+ usageFloorUnit: row.usageFloorUnit === "credit" ? "credit" : "percent",
493
+ enabled: row.enabled,
494
+ skipProxy: row.skipProxy,
495
+ userAgent: row.userAgent,
496
+ };
497
+ }
498
+ /** Tenant-specific setting wins over global; an unconfigured provider
499
+ * falls back to `DEFAULT_PROXY_SKIP_PROVIDER_IDS`, mirroring the same
500
+ * default `DrizzleProviderDetailStore.getRouting` reports to the API —
501
+ * so the dashboard's displayed default and the real dispatch decision
502
+ * never disagree. */
503
+ function resolveSkipProxy(providerId: string, rowTenantId: string | null): boolean {
504
+ const effectiveTenantId = rowTenantId;
505
+ const tenantSetting = effectiveTenantId ? providerRouting[effectiveTenantId]?.[providerId] : undefined;
506
+ const globalSetting = providerRouting.__global__?.[providerId];
507
+ return resolveTenantOverride(
508
+ tenantSetting?.skipProxy,
509
+ globalSetting?.skipProxy,
510
+ DEFAULT_PROXY_SKIP_PROVIDER_IDS.has(providerId),
511
+ );
512
+ }
513
+ function resolveUserAgent(providerId: string, rowTenantId: string | null): string | undefined {
514
+ const tenantSetting = rowTenantId ? providerRouting[rowTenantId]?.[providerId]?.userAgent : undefined;
515
+ const globalSetting = providerRouting.__global__?.[providerId]?.userAgent;
516
+ return resolveRouteUserAgent(providerId, tenantSetting, globalSetting);
517
+ }
518
+
519
+ /** Provider-wide concurrency ceiling shared by every account of the provider.
520
+ * Tenant setting wins over global, mirroring skipProxy. A stored `null`
521
+ * is the explicit unlimited choice; so is the default — a provider with no
522
+ * routing row at all is unlimited too, because an operator who never
523
+ * configured a ceiling asked for none. */
524
+ function resolveMaxInflight(
525
+ providerId: string,
526
+ rowTenantId: string | null,
527
+ ): number | undefined {
528
+ const tenantSetting = rowTenantId
529
+ ? providerRouting[rowTenantId]?.[providerId]?.maxInflight
530
+ : undefined;
531
+ const globalSetting = providerRouting.__global__?.[providerId]?.maxInflight;
532
+ const resolved = tenantSetting ?? globalSetting;
533
+ if (resolved === null) return undefined;
534
+ return resolved ?? DEFAULT_PROVIDER_MAX_INFLIGHT ?? undefined;
535
+ }
536
+
537
+ /**
538
+ * This provider's per-pool inflight ceiling, from Routing Strategy.
539
+ * `undefined` means unlimited — the default, and the state of every
540
+ * provider nobody has configured. A stored `null` is the same choice made
541
+ * explicitly; the two are deliberately indistinguishable downstream so the
542
+ * hot path never branches on which one it read.
543
+ */
544
+ function resolveMaxInflightPerProxy(
545
+ providerId: string,
546
+ rowTenantId: string | null,
547
+ ): number | undefined {
548
+ const tenantSetting = rowTenantId
549
+ ? providerRouting[rowTenantId]?.[providerId]?.maxInflightPerProxy
550
+ : undefined;
551
+ const globalSetting = providerRouting.__global__?.[providerId]?.maxInflightPerProxy;
552
+ return tenantSetting ?? globalSetting ?? undefined;
553
+ }
554
+
555
+ /** Every active pool the account's tenant owns — dispatch picks the
556
+ * least-loaded/non-cooldown one per request (`tryAcquireAvailablePool`),
557
+ * never an admin-pinned single pool. */
558
+ function resolveNetworkPools(
559
+ providerId: string,
560
+ rowTenantId: string | null,
561
+ ):
562
+ | {
563
+ ids: readonly string[];
564
+ limits: Record<string, number>;
565
+ weights: Record<string, number>;
566
+ routing?: PoolRoutingSetting & { tenantId: string };
567
+ }
568
+ | undefined {
569
+ if (!rowTenantId || resolveSkipProxy(providerId, rowTenantId)) return undefined;
570
+ if (!configuredPoolTenants.has(rowTenantId)) return undefined;
571
+ const pools = activePoolsByTenant.get(rowTenantId) ?? [];
572
+ const limits: Record<string, number> = {};
573
+ const weights: Record<string, number> = {};
574
+ for (const pool of pools) {
575
+ limits[pool.id] = pool.maxInflight;
576
+ weights[pool.id] = pool.weight;
577
+ }
578
+ const routing = poolRouting[rowTenantId];
579
+ return {
580
+ ids: pools.map((pool) => pool.id),
581
+ limits,
582
+ weights,
583
+ ...(routing ? { routing: { tenantId: rowTenantId, ...routing } } : {}),
584
+ };
585
+ }
586
+
587
+ /** The candidate fragment for a provider ceiling, empty when unlimited —
588
+ * so an unconfigured provider's candidate is byte-for-byte what it was. */
589
+ function providerLimitFor(
590
+ providerId: string,
591
+ rowTenantId: string | null,
592
+ ): { network_pool_provider_limit?: number } {
593
+ const limit = resolveMaxInflightPerProxy(providerId, rowTenantId);
594
+ return limit === undefined ? {} : { network_pool_provider_limit: limit };
595
+ }
596
+
597
+ // Group usable accounts per provider (not per provider-tenant). Built-in
598
+ // providers are `tenant_id IS NULL` but their accounts are per-tenant, so
599
+ // grouping by `providerTenantId` would make every global model look
600
+ // disabled. Instead keep all accounts for a provider together and fan out
601
+ // candidates per account tenant below — e.g. `opencodeze/muse-spark`
602
+ // hits with `model: "opencodeze/muse-spark"` must resolve to the tenant's
603
+ // own account even though the provider row is global.
604
+ const accountsByProvider = new Map<string, typeof accountRows>();
605
+ for (const account of accountRows) {
606
+ if (account.status === "disabled") continue;
607
+ const list = accountsByProvider.get(account.providerId) ?? [];
608
+ list.push(account);
609
+ accountsByProvider.set(account.providerId, list);
610
+ }
611
+
612
+ const candidates: RouteCandidateWithHealth[] = [];
613
+ for (const model of mergedModelRows) {
614
+ const providerTenantId = providerTenantById.get(model.providerId);
615
+ if (providerTenantId === undefined) continue;
616
+ const accounts = accountsByProvider.get(model.providerId) ?? [];
617
+ // One profile per model row, memoized across snapshot rebuilds. The
618
+ // profile is derived purely from the row's metadata, is never mutated
619
+ // (grep finds no write to `capability_profile`), and is shared by every
620
+ // candidate this row fans out to — so a rebuild no longer constructs a
621
+ // fresh object per model on top of the fan-out it already had.
622
+ const capabilityProfile = capabilityProfileFor(model);
623
+ if (accounts.length === 0) {
624
+ const providerRequiresAccount = requiresAccountById.get(model.providerId) ?? true;
625
+ if (!providerRequiresAccount) {
626
+ // Public models inherit active pools for each tenant that has active pools
627
+ for (const tId of configuredPoolTenants) {
628
+ const tenantPools = resolveNetworkPools(model.providerId, tId);
629
+ if (tenantPools) {
630
+ const tenantCandidate: RouteCandidateWithHealth = {
631
+ provider_id: model.providerId,
632
+ model_id: model.modelId,
633
+ wire_family: model.wireFamily as WireFamily,
634
+ service_kind: model.serviceKind,
635
+ endpoint: model.endpointPath,
636
+ ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
637
+ capability_profile: capabilityProfile,
638
+ tenant_id: tId,
639
+ requires_account: false as const,
640
+ network_pool_ids: tenantPools.ids,
641
+ network_pool_required: true,
642
+ network_pool_limits: tenantPools.limits,
643
+ network_pool_weights: tenantPools.weights,
644
+ ...(tenantPools.routing ? { network_pool_routing: tenantPools.routing } : {}),
645
+ ...providerLimitFor(model.providerId, tId),
646
+ };
647
+ if (tenantId === undefined || tenantId === tId) {
648
+ candidates.push(tenantCandidate);
649
+ }
650
+ }
651
+ }
652
+ }
653
+ const networkPools = providerRequiresAccount
654
+ ? undefined
655
+ : resolveNetworkPools(model.providerId, providerTenantId);
656
+ const candidate: RouteCandidateWithHealth = {
657
+ provider_id: model.providerId,
658
+ model_id: model.modelId,
659
+ wire_family: model.wireFamily as WireFamily,
660
+ service_kind: model.serviceKind,
661
+ endpoint: model.endpointPath,
662
+ ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
663
+ capability_profile: capabilityProfile,
664
+ tenant_id: providerTenantId,
665
+ ...(providerRequiresAccount
666
+ ? { health_status: "disabled" as const }
667
+ : { requires_account: false as const }),
668
+ ...(networkPools
669
+ ? {
670
+ network_pool_ids: networkPools.ids,
671
+ network_pool_required: true,
672
+ network_pool_limits: networkPools.limits,
673
+ network_pool_weights: networkPools.weights,
674
+ ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
675
+ ...providerLimitFor(model.providerId, providerTenantId),
676
+ }
677
+ : {}),
678
+ };
679
+ if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
680
+ candidates.push(candidate);
681
+ }
682
+ continue;
683
+ }
684
+ for (const account of accounts) {
685
+ const rowTenantId = account.tenantId ?? providerTenantId;
686
+ if (
687
+ rowTenantId !== null &&
688
+ disabledModelKeys.has(
689
+ `${rowTenantId}:${model.providerId}:${model.modelId}:${model.endpointPath}`,
690
+ )
691
+ ) {
692
+ continue;
693
+ }
694
+ const networkPools = resolveNetworkPools(model.providerId, rowTenantId);
695
+ const routeUserAgent = resolveUserAgent(model.providerId, rowTenantId);
696
+ const candidate: RouteCandidateWithHealth = {
697
+ provider_id: model.providerId,
698
+ model_id: model.modelId,
699
+ wire_family: model.wireFamily as WireFamily,
700
+ service_kind: model.serviceKind,
701
+ endpoint: model.endpointPath,
702
+ ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
703
+ capability_profile: capabilityProfile,
704
+ ...(routeUserAgent === undefined ? {} : { user_agent: routeUserAgent }),
705
+ tenant_id: rowTenantId,
706
+ provider_account_id: account.id,
707
+ // The plan gate is per (account, model): the model may be refused on
708
+ // a free plan and the account may be on one. Both ride here because
709
+ // `plan()` sees the candidate and nothing else.
710
+ ...(model.proOnly === true ? { pro_only: true } : {}),
711
+ ...(account.quotaPlan === null || account.quotaPlan === undefined
712
+ ? {}
713
+ : { account_plan: account.quotaPlan }),
714
+ // Provider-wide concurrency ceiling from Routing Strategy; legacy
715
+ // account overrides are never read here.
716
+ ...(() => {
717
+ const resolved = resolveMaxInflight(model.providerId, rowTenantId);
718
+ return resolved === undefined ? {} : { max_inflight: resolved };
719
+ })(),
720
+ ...(networkPools
721
+ ? {
722
+ network_pool_required: true,
723
+ network_pool_ids: networkPools.ids,
724
+ network_pool_limits: networkPools.limits,
725
+ network_pool_weights: networkPools.weights,
726
+ ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
727
+ ...providerLimitFor(model.providerId, rowTenantId),
728
+ }
729
+ : {}),
730
+ };
731
+ if (account.status === "cooldown") {
732
+ // Expired cooldowns read as healthy here without a fire-and-forget
733
+ // write: AccountHealthSweeper materializes the recovery every 30s,
734
+ // so the builder stays a pure read (no UPDATE racing the request).
735
+ //
736
+ // `forceDisableCooldown` skips the exclusion entirely. The cooldown
737
+ // is still real and still recorded — it simply stops deciding
738
+ // routing. That is what makes the setting reversible: nothing was
739
+ // written, so turning it off needs no cleanup, and Health still
740
+ // reports the truth about the account.
741
+ if (account.cooldownUntil && account.cooldownUntil.getTime() > Date.now()) {
742
+ const overrideActive =
743
+ rowTenantId === null
744
+ ? anyTenantOverrides
745
+ : cooldownOverrideTenants.has(rowTenantId);
746
+ if (overrideActive) {
747
+ // The account is still cooling — this only stops it from being
748
+ // excluded. `plan()` reads `cooldown_until` to answer the
749
+ // all-cooling case, so it stays populated either way.
750
+ candidate.cooldown_until = account.cooldownUntil.getTime();
751
+ } else {
752
+ candidate.health_status = "cooldown";
753
+ candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
754
+ ? "hard"
755
+ : "soft";
756
+ // The deadline rides with the exclusion it explains. `plan()`
757
+ // reads it to answer the all-cooling case with the real reset
758
+ // instead of a bare 429, so a client backs off for the window
759
+ // that is actually open rather than inventing one.
760
+ candidate.cooldown_until = account.cooldownUntil.getTime();
761
+ }
762
+ }
763
+ }
764
+
765
+ // Per-model cooldown: an account may be healthy globally but cooling down
766
+ // for a specific model. A non-expired entry for the model THIS candidate
767
+ // serves is a hard exclusion, not a deprioritization: the upstream said
768
+ // this exact (account, model) pair is exhausted until a stated reset
769
+ // ("Daily free limit reached … Try again in 11h"), so dialing it can only
770
+ // reproduce the same refusal. Deprioritizing instead made failover spend
771
+ // a full round trip on it after every healthy sibling had failed, and
772
+ // recorded another `active → cooldown` row each time — the operator's
773
+ // report of one account churning while ten siblings sat healthy.
774
+ //
775
+ // `model_cooldown` (distinct from `cooldown`) is what carries that
776
+ // distinction to the evaluator: an account-wide `cooldown` stays
777
+ // deprioritized so a single-account deployment still routes, while a
778
+ // model-scoped one is dropped for this model only and the account keeps
779
+ // serving every other model it holds.
780
+ const modelCooldowns = account.modelCooldowns as Record<string, string> | null;
781
+ const modelCooldownUntil = modelCooldowns?.[model.modelId];
782
+ if (modelCooldownUntil && new Date(modelCooldownUntil).getTime() > Date.now()) {
783
+ candidate.health_status = "model_cooldown";
784
+ candidate.cooldown_until = new Date(modelCooldownUntil).getTime();
785
+ }
786
+ if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
787
+ candidates.push(candidate);
788
+ }
789
+ }
790
+ }
791
+
792
+ const aliases: Record<string, Record<string, string>> = {};
793
+ const cliAliases: Record<string, Record<string, string>> = {};
794
+ for (const row of aliasRows) {
795
+ if (tenantId === undefined || row.tenantId === tenantId) {
796
+ (aliases[row.tenantId] ??= {})[row.alias] = row.targetModel;
797
+ }
798
+ }
799
+ // CLI-tool mappings are per-(tenant, key): each API key can route the same
800
+ // CLI slot to a different target. The snapshot keys the alias bucket by
801
+ // `${tenantId}:${apiKeyId}` so the preparer can look up the exact key's
802
+ // routes without merging across keys.
803
+ // Remote Routing is opt-in per (tenant, tool, key): a mapping row routes
804
+ // only when its bucket's settings row explicitly enabled it. The CLI tool
805
+ // page and the API-key edit form both write that one flag, so either place
806
+ // can turn the routes on or off.
807
+ const enabledMappingBuckets = new Set<string>();
808
+ for (const row of cliMappingSettingRows) {
809
+ if (row.mappingsEnabled) enabledMappingBuckets.add(`${row.tenantId}:${row.apiKeyId}`);
810
+ }
811
+ for (const row of cliMappingRows) {
812
+ if (!row.enabled) continue;
813
+ if (!enabledMappingBuckets.has(`${row.tenantId}:${row.apiKeyId}`)) continue;
814
+ if (tenantId === undefined || row.tenantId === tenantId) {
815
+ const bucketKey = `${row.tenantId}:${row.apiKeyId}`;
816
+ const aliasBucket = (cliAliases[bucketKey] ??= {});
817
+ for (const sourceKey of cliMappingSourceKeys(row.toolId, row.sourceModel)) {
818
+ aliasBucket[sourceKey] = row.targetModel;
819
+ }
820
+ }
821
+ }
822
+ const combos: Record<string, Record<string, ComboDefinition>> = {};
823
+ for (const row of comboRows) {
824
+ if (tenantId === undefined || row.tenantId === tenantId) {
825
+ (combos[row.tenantId] ??= {})[row.name] = { members: row.members, strategy: row.strategy };
826
+ }
827
+ }
828
+
829
+ const routingMap: ProviderRoutingMap | undefined =
830
+ Object.keys(providerRouting).length > 0 ? (providerRouting as ProviderRoutingMap) : undefined;
831
+
832
+ return {
833
+ candidates,
834
+ aliases,
835
+ cli_aliases: cliAliases,
836
+ combos,
837
+ ...(routingMap ? { providerRouting: routingMap } : {}),
838
+ ...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
839
+ };
840
+ }
841
+ }
842
+
843
+ /** Builds a `SnapshotBuilder` reading the live catalog from `db`. */
844
+ export function createDatabaseSnapshotBuilder(
845
+ db: CartethyiaDatabase,
846
+ refreshActiveCatalogs?: () => Promise<void>,
847
+ ): SnapshotBuilder {
848
+ const repository = new RouteCatalogRepository(db);
849
+ return async () => {
850
+ await refreshActiveCatalogs?.();
851
+ return repository.loadRouteCatalogSnapshot(undefined);
852
+ };
853
+ }
854
+