cartethyia 2.4.5 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (415) hide show
  1. package/.env.example +4 -1
  2. package/README.md +63 -13
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  5. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  27. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  31. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  36. package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
  37. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  38. package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
  39. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  40. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
  41. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  42. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  43. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  44. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
  46. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  47. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  48. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  49. package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
  50. package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  52. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  53. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  54. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  55. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  56. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
  57. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  58. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
  59. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  60. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  61. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
  62. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  63. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  64. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  66. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  67. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
  68. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  69. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
  70. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  71. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  72. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  73. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
  74. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  75. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  76. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  77. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  78. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
  79. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  80. package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
  81. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  82. package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
  83. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  84. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  85. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  87. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  88. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  89. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  90. package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
  91. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  92. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  93. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
  94. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  95. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
  96. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  97. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
  98. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  99. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  100. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  101. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  102. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
  103. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  104. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
  105. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  106. package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
  107. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  108. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  109. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  110. package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
  111. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  113. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  114. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  115. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  116. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  118. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  120. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  121. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  130. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  137. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  138. package/dist/dashboard/assets/providers/trae.webp +0 -0
  139. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  140. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  141. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  142. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  143. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  144. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  145. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  146. package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
  147. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  148. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  149. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  150. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  151. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  152. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  153. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  154. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  155. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  156. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
  157. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  158. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
  159. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  160. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  161. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
  162. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  163. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  164. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  165. package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
  166. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  167. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  168. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  169. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  170. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  171. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  172. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  173. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
  174. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  175. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  176. package/dist/dashboard/index.html +1 -1
  177. package/dist/dashboard/index.html.gz +0 -0
  178. package/dist/dashboard/precompressed-assets.json +122 -0
  179. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  180. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  181. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  182. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  183. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  184. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  185. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  186. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  187. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  188. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  189. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  190. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  191. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  192. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  193. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  194. package/package.json +26 -30
  195. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  196. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  197. package/scripts/cli/command/admin.ts +65 -0
  198. package/scripts/cli/command/console-menu.ts +246 -0
  199. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  200. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  201. package/scripts/cli/command/start.ts +77 -0
  202. package/scripts/cli/command/tray.ts +165 -0
  203. package/scripts/cli/command/update.ts +23 -0
  204. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  205. package/scripts/cli/gateway/instance-lock.ts +105 -0
  206. package/scripts/cli/gateway/spawn.ts +173 -0
  207. package/scripts/cli/gateway/stop.ts +51 -0
  208. package/scripts/cli/main.ts +94 -0
  209. package/scripts/cli/support/bun.ts +27 -0
  210. package/scripts/cli/support/env.ts +99 -0
  211. package/scripts/cli/support/platform.ts +64 -0
  212. package/scripts/cli/support/services.ts +88 -0
  213. package/scripts/cli/support/startup-signal.ts +23 -0
  214. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  215. package/scripts/cli/tray/hidden-window.vbs +28 -0
  216. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  217. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  218. package/scripts/cli/ui/background.ts +110 -0
  219. package/src/boot/app.ts +23 -7
  220. package/src/boot/dependencies.ts +55 -13
  221. package/src/boot/elysia-typebox-bridge.ts +37 -0
  222. package/src/boot/lifecycle.ts +2 -0
  223. package/src/config/cooldowns.ts +138 -138
  224. package/src/config/providers.ts +31 -5
  225. package/src/config/runtime.ts +19 -3
  226. package/src/config/spec.ts +20 -0
  227. package/src/console/api-keys/contracts.ts +414 -391
  228. package/src/console/api-keys/routes.ts +880 -870
  229. package/src/console/auth/account-reset-store.ts +14 -0
  230. package/src/console/auth/account-reset.ts +3 -2
  231. package/src/console/auth/setup-service.ts +6 -3
  232. package/src/console/backup/routes.ts +8 -0
  233. package/src/console/cli-tools/contracts.ts +6 -6
  234. package/src/console/console-router.ts +5 -3
  235. package/src/console/dashboard-assets.ts +81 -11
  236. package/src/console/domain-registration.ts +9 -2
  237. package/src/console/observability/contracts.ts +475 -423
  238. package/src/console/observability/routes.ts +31 -6
  239. package/src/console/observability/store.ts +1015 -912
  240. package/src/console/providers/catalog/contracts.ts +31 -0
  241. package/src/console/providers/catalog/provider-operations.ts +8 -0
  242. package/src/console/providers/catalog/public-model-store.ts +91 -60
  243. package/src/console/providers/catalog/store.ts +1532 -1521
  244. package/src/console/providers/oauth/callback-listener.ts +18 -3
  245. package/src/console/providers/oauth/routes.ts +38 -15
  246. package/src/console/quota/account-quota-tenant.ts +9 -6
  247. package/src/console/quota/refresh.ts +19 -0
  248. package/src/console/response-compression.ts +69 -2
  249. package/src/console/routing/pools/probe-verdict.ts +72 -0
  250. package/src/console/share/share-router.ts +7 -4
  251. package/src/console/share/share-stats.ts +12 -5
  252. package/src/console/shared/read-through-cache.ts +133 -7
  253. package/src/console/updates/repository-updates.ts +39 -34
  254. package/src/console/updates/routes.ts +162 -4
  255. package/src/main.ts +3 -0
  256. package/src/network/http2-bidi.ts +85 -0
  257. package/src/network/outbound-fetch.ts +87 -24
  258. package/src/network/pool/agent.ts +478 -460
  259. package/src/network/pool/resolver.ts +1104 -544
  260. package/src/network/pool-health-machine.ts +12 -5
  261. package/src/observability/event-log.ts +29 -8
  262. package/src/observability/metrics.ts +40 -13
  263. package/src/observability/payload-capture.ts +344 -324
  264. package/src/observability/runtime-metrics.ts +7 -0
  265. package/src/persistence/db-pg.ts +9 -3
  266. package/src/persistence/db-sqlite.ts +497 -495
  267. package/src/persistence/health-event-flush.ts +137 -0
  268. package/src/persistence/migrate.ts +181 -129
  269. package/src/persistence/schema.ts +1360 -1257
  270. package/src/persistence/settings-revision.ts +25 -0
  271. package/src/persistence/shared-cache.ts +217 -0
  272. package/src/persistence/stores/share-store.ts +556 -528
  273. package/src/persistence/usage-flush.ts +61 -1
  274. package/src/protocol/canonical-model.ts +23 -1
  275. package/src/protocol/response/chat.ts +143 -10
  276. package/src/protocol/response/codex.ts +9 -75
  277. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  278. package/src/protocol/tool-identity.ts +43 -9
  279. package/src/providers/authentication/jwt-validator.ts +130 -187
  280. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  281. package/src/providers/default-registry.ts +177 -1
  282. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  283. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  284. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  285. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  286. package/src/providers/discovery/probe-wire.ts +29 -5
  287. package/src/providers/discovery/probing-service.ts +1022 -1001
  288. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
  289. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  290. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  291. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
  292. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  293. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  294. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  295. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  296. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  297. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  298. package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
  299. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  300. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  301. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  302. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  303. package/src/providers/integrations/claude/claude.ts +606 -582
  304. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  305. package/src/providers/integrations/cline/cline.ts +539 -468
  306. package/src/providers/integrations/codex/codex.ts +5 -8
  307. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  308. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  309. package/src/providers/integrations/commandcode/quota.ts +160 -0
  310. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  311. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  312. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  313. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  314. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  315. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  316. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  317. package/src/providers/integrations/cursor/cursor.ts +960 -0
  318. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  319. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  320. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  321. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  322. package/src/providers/integrations/github/github.ts +4 -4
  323. package/src/providers/integrations/hermes.ts +79 -8
  324. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  325. package/src/providers/integrations/kilo/kilo.ts +153 -146
  326. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  327. package/src/providers/integrations/opencode/provider.ts +532 -499
  328. package/src/providers/integrations/openrouter/provider.ts +139 -281
  329. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  330. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
  331. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  332. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  333. package/src/providers/integrations/qoder/qoder.ts +865 -730
  334. package/src/providers/integrations/shared/install-id.ts +29 -2
  335. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  336. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  337. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  338. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  339. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  340. package/src/providers/integrations/trae/trae.ts +458 -0
  341. package/src/providers/operations/account-health-service.ts +107 -10
  342. package/src/providers/operations/client-versions.ts +143 -6
  343. package/src/providers/operations/model-catalog-cache.ts +18 -3
  344. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  345. package/src/providers/operations/provider-catalog-service.ts +76 -0
  346. package/src/providers/operations/provider-credential-service.ts +46 -7
  347. package/src/providers/provider-contracts.ts +76 -0
  348. package/src/providers/provider-metadata.ts +78 -6
  349. package/src/providers/provider-model-definition.ts +373 -349
  350. package/src/security/api-key-auth.ts +126 -8
  351. package/src/transport/contracts.ts +3 -0
  352. package/src/transport/dispatch/attempt-finalize.ts +39 -2
  353. package/src/transport/dispatch/attempt-loop.ts +530 -513
  354. package/src/transport/dispatch/empty-stream.ts +17 -26
  355. package/src/transport/dispatch/generation.ts +88 -21
  356. package/src/transport/dispatch/leases.ts +99 -2
  357. package/src/transport/dispatch/proxy-request.ts +60 -3
  358. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  359. package/src/transport/dispatch/streaming/stream.ts +22 -2
  360. package/src/transport/dispatch/upstream.ts +19 -1
  361. package/src/transport/failure-policy.ts +20 -0
  362. package/src/transport/guards/auth-guard.ts +26 -1
  363. package/src/transport/guards/body-policy.ts +5 -4
  364. package/src/transport/request/preparer.ts +103 -0
  365. package/src/transport/request/state.ts +14 -0
  366. package/src/transport/routing/plan-gate.ts +45 -0
  367. package/src/transport/routing/route-catalog.ts +799 -738
  368. package/src/transport/routing/route-model.ts +52 -1
  369. package/src/transport/routing/router.ts +51 -0
  370. package/src/transport/surface/chat/encode.ts +18 -31
  371. package/src/transport/surface/messages/stream.ts +3 -2
  372. package/src/transport/translation/tool-repair.ts +37 -16
  373. package/src/workers/account-identity-sweep.ts +14 -12
  374. package/src/workers/daily-checkin.ts +62 -0
  375. package/src/workers/quota-refresh-sweep.ts +27 -2
  376. package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
  377. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  378. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  379. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  380. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  381. package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
  382. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  383. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  384. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  385. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  386. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  387. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  388. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  389. package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
  390. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  391. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  392. package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
  393. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  394. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  395. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  396. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  397. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  398. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  399. package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
  400. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  401. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  402. package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
  403. package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
  404. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  405. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  406. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  407. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  408. package/scripts/cli/cli.ts +0 -771
  409. package/scripts/operations/probe-egress.ts +0 -335
  410. package/scripts/operations/restart.ts +0 -120
  411. package/scripts/shared/env.ts +0 -269
  412. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  413. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  414. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  415. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -1,738 +1,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
- 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 "../../providers/integrations/claude/claude-oauth";
20
- import "../../providers/integrations/codex/codex-oauth";
21
- import { resolveTenantOverride } from "../../persistence/tenant-scope";
22
- import {
23
- type RouteCandidate,
24
- type ComboDefinition,
25
- type PoolRoutingSetting,
26
- type ProviderRoutingMap,
27
- type RouteSnapshot,
28
- type SnapshotBuilder,
29
- } from "./route-model";
30
- import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metadata";
31
- import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
32
- import { isBundledProviderId } from "../../providers/provider-contracts";
33
- import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
34
- import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
35
-
36
- const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
37
-
38
- export function cliMappingSourceKeys(toolId: string, sourceModel: string): readonly string[] {
39
- if (toolId !== "claude") return [sourceModel];
40
- const normalized = sourceModel.trim().toLowerCase();
41
- const family =
42
- CLAUDE_MODEL_FAMILIES.has(normalized)
43
- ? normalized
44
- : /^claude-(opus|sonnet|haiku|fable|mythos)(?:-|$)/.exec(normalized)?.[1];
45
- if (normalized.includes("/") || family === undefined) return [sourceModel];
46
- return [...new Set([
47
- sourceModel,
48
- family,
49
- `claude-${family}-5`,
50
- `claude-${family}-5-1`,
51
- `claude-${family}-5-5`,
52
- `claude-${family}-4-6`,
53
- `claude-${family}-4-5`,
54
- ])];
55
- }
56
- /**
57
- * Builds the routing `RouteSnapshot` from the persisted catalog
58
- * (`providers`, `models`, `provider_accounts`). Consumed by
59
- * `InMemoryRouteSnapshotService` as its `SnapshotBuilder`.
60
- *
61
- * Built-in definitions are materialized before snapshots are read. The
62
- * `models` DB table is authoritative for routing; compiled definitions are
63
- * only startup seed data. Custom/BYOK and live-discovered models are stored
64
- * in the same table.
65
- *
66
- * Tenant-aware: a provider with `providers.tenant_id = NULL` is global
67
- * (every built-in, plus any pool-wide custom provider) and its candidates
68
- * carry `tenant_id: null`, matching every request regardless of tenant. A
69
- * provider with `providers.tenant_id` set (BYOK) only pairs with accounts
70
- * owned by that exact same tenant, and its candidates carry that tenant id
71
- * — `RoutingEngine.plan()` filters candidates by the requesting tenant.
72
- */
73
-
74
- type RouteCandidateWithHealth = RouteCandidate & {
75
- health_status?: "cooldown" | "model_cooldown" | "disabled";
76
- cooldown_kind?: "hard" | "soft";
77
- /**
78
- * Epoch milliseconds at which this candidate's cooling window ends, when one
79
- * is in force. Routing-internal: it is what lets `plan()` report a real reset
80
- * on the all-accounts-cooling answer, and it never reaches a client verbatim —
81
- * only the derived `retry-after` does.
82
- */
83
- cooldown_until?: number;
84
- };
85
-
86
- const HARD_COOLDOWN_CATEGORIES: Readonly<Record<string, true>> = {
87
- quota_exhausted: true,
88
- policy_blocked: true,
89
- auth_invalidated: true,
90
- };
91
-
92
- function hardCooldownCategory(category: string | null): boolean {
93
- return category !== null && HARD_COOLDOWN_CATEGORIES[category] === true;
94
- }
95
-
96
- /**
97
- * Projects one model row's metadata into the capability gates the router
98
- * filters candidates by. Exported so the projection contract is unit-testable
99
- * without a database.
100
- */
101
- export function buildCapabilityProfile(row: {
102
- modalities: unknown;
103
- reasoning: boolean;
104
- toolCall: boolean;
105
- providerId: string;
106
- }): Record<string, boolean> {
107
- const mods =
108
- row.modalities != null && typeof row.modalities === "object"
109
- ? (row.modalities as { input?: unknown[]; output?: unknown[] })
110
- : {};
111
- const inputMods = Array.isArray(mods.input) ? mods.input : [];
112
- const outputMods = Array.isArray(mods.output) ? mods.output : [];
113
- // The canonical chat and responses codecs encode `image`, `document`/`file`,
114
- // and `audio` parts; the messages codec encodes the first two but has no
115
- // audio block (see `AUDIO_CAPABLE_WIRE_FAMILIES`). A bespoke adapter (Cursor)
116
- // frames its own protocol and has no generic rich-content path.
117
- // Treating the codec routes as capable by default keeps pass-through behavior
118
- // instead of degrading a caller's attachment to text on the strength of a
119
- // metadata guess. An explicit modality still wins, so a catalog can declare
120
- // support for a bespoke adapter too. Whether a given upstream model accepts
121
- // the part is the upstream's call — a capability declared here only decides
122
- // whether the router strips the part before it ever sees it, and the audio
123
- // grant is narrowed per wire family at projection time.
124
- const codecEncodesRichContent = !providerUsesBespokeWire(row.providerId);
125
- // Reasoning and tools are never stripped here. A `false` flag — whether a
126
- // discovered row recorded it for lack of metadata, or a catalog row set it
127
- // explicitly — must not become a silent rewrite of a request the caller
128
- // asked for. The upstream decides whether it can serve them and returns its
129
- // own error if it cannot.
130
- return {
131
- // The route is not served by a canonical codec. Carried in the profile
132
- // because the capability predicates downstream see only this snapshot, not
133
- // the provider registry.
134
- bespokeWire: !codecEncodesRichContent,
135
- image: inputMods.includes("image") || codecEncodesRichContent,
136
- document: inputMods.includes("document") || codecEncodesRichContent,
137
- audio: inputMods.includes("audio") || codecEncodesRichContent,
138
- mediaGeneration: outputMods.includes("image"),
139
- tools: true,
140
- parallelToolCalls: true,
141
- reasoning: true,
142
- reasoningEncryptedContent: true,
143
- // Search is its own route: a `websearch` row answers `POST /v1/search`.
144
- // A chat row never claims it; a hosted declaration rides through `tools`.
145
- webSearch: false,
146
- responseJsonObject: true,
147
- responseJsonSchema: true,
148
- promptCaching: true,
149
- };
150
- }
151
-
152
- /** Resolves route identity only for bundled adapters without a native User-Agent builder. */
153
- export function resolveRouteUserAgent(
154
- providerId: string,
155
- tenantUserAgent: string | undefined,
156
- globalUserAgent: string | undefined,
157
- ): string | undefined {
158
- if (!isBundledProviderId(providerId) || providerHasAdapterUserAgent(providerId)) return undefined;
159
- // A provider whose operator set no identity of their own gets the Claude Code
160
- // CLI identity, resolved live so the stamped version tracks discovery. This
161
- // used to be a frozen `codex_cli_rs` literal, which named a different vendor's
162
- // client on requests that mostly reached neither.
163
- const selected = resolveTenantOverride(
164
- tenantUserAgent,
165
- globalUserAgent,
166
- buildClaudeCliUserAgent(),
167
- );
168
- return selected.trim() === "" ? undefined : selected;
169
- }
170
-
171
- /** One persisted model row ready for candidate construction. */
172
- interface MergedModelRow {
173
- providerId: string;
174
- modelId: string;
175
- wireFamily: WireFamily;
176
- serviceKind: ServiceKind;
177
- endpointPath: string;
178
- modalities: unknown;
179
- outputLimit: number | null;
180
- reasoning: boolean;
181
- toolCall: boolean;
182
- enabled: boolean;
183
- }
184
- /**
185
- * Converts persisted model rows into candidate input. Seed materialization is
186
- * performed before this function runs, so no static-vs-DB merge is needed.
187
- */
188
- function mergeModelCatalog(
189
- dbRows: readonly ModelRouteRow[],
190
- ): MergedModelRow[] {
191
- return dbRows
192
- .filter((row) => row.enabled)
193
- .map((row) => ({
194
- providerId: row.providerId,
195
- modelId: row.modelId,
196
- wireFamily: row.wireFamily as WireFamily,
197
- serviceKind: row.serviceKind as ServiceKind,
198
- endpointPath: row.endpointPath,
199
- modalities: row.modalities,
200
- outputLimit: row.outputLimit,
201
- reasoning: row.reasoning,
202
- toolCall: row.toolCall,
203
- enabled: row.enabled,
204
- }));
205
- }
206
-
207
- /**
208
- * Column projections for the snapshot read.
209
- *
210
- * The builder reads a handful of fields from each table, but the read used to
211
- * be a bare `select()` — every column of all ten tables, including
212
- * `provider_accounts.credential_ciphertext` and `network_pools.
213
- * endpoint_config`, which the snapshot never touches. That ciphertext is the
214
- * largest thing the gateway stores per account, and the snapshot is rebuilt on
215
- * every catalog change, so the bytes were transferred and then discarded on
216
- * every rebuild. The projection keeps the query count at ten and drops the
217
- * payload to what is actually read.
218
- *
219
- * Each object doubles as the row type (`ModelRouteRow` and friends are derived
220
- * from it), so a column added here is automatically typed, and a column the
221
- * builder starts reading but is missing here fails to compile rather than
222
- * silently reading `undefined`.
223
- */
224
- const PROVIDER_COLUMNS = {
225
- id: providers.id,
226
- tenantId: providers.tenantId,
227
- requiresAccount: providers.requiresAccount,
228
- } as const;
229
-
230
- const MODEL_COLUMNS = {
231
- providerId: models.providerId,
232
- modelId: models.modelId,
233
- wireFamily: models.wireFamily,
234
- serviceKind: models.serviceKind,
235
- endpointPath: models.endpointPath,
236
- modalities: models.modalities,
237
- outputLimit: models.outputLimit,
238
- reasoning: models.reasoning,
239
- toolCall: models.toolCall,
240
- enabled: models.enabled,
241
- } as const;
242
- const ACCOUNT_COLUMNS = {
243
- id: providerAccounts.id,
244
- providerId: providerAccounts.providerId,
245
- tenantId: providerAccounts.tenantId,
246
- label: providerAccounts.label,
247
- status: providerAccounts.status,
248
- cooldownUntil: providerAccounts.cooldownUntil,
249
- modelCooldowns: providerAccounts.modelCooldowns,
250
- lastErrorCategory: providerAccounts.lastErrorCategory,
251
- } as const;
252
-
253
- const ALIAS_COLUMNS = {
254
- tenantId: modelAliases.tenantId,
255
- alias: modelAliases.alias,
256
- targetModel: modelAliases.targetModel,
257
- } as const;
258
-
259
- const COMBO_COLUMNS = {
260
- tenantId: modelCombos.tenantId,
261
- name: modelCombos.name,
262
- members: modelCombos.members,
263
- strategy: modelCombos.strategy,
264
- } as const;
265
-
266
- const ROUTING_COLUMNS = {
267
- providerId: providerRoutingSettings.providerId,
268
- tenantId: providerRoutingSettings.tenantId,
269
- strategy: providerRoutingSettings.strategy,
270
- rotateCount: providerRoutingSettings.rotateCount,
271
- maxInflight: providerRoutingSettings.maxInflight,
272
- maxInflightPerProxy: providerRoutingSettings.maxInflightPerProxy,
273
- usageFloor: providerRoutingSettings.usageFloor,
274
- usageFloorUnit: providerRoutingSettings.usageFloorUnit,
275
- enabled: providerRoutingSettings.enabled,
276
- skipProxy: providerRoutingSettings.skipProxy,
277
- userAgent: providerRoutingSettings.userAgent,
278
- } as const;
279
-
280
- const POOL_COLUMNS = {
281
- id: networkPools.id,
282
- tenantId: networkPools.tenantId,
283
- status: networkPools.status,
284
- maxInflight: networkPools.maxInflight,
285
- weight: networkPools.weight,
286
- } as const;
287
-
288
- const DISABLED_MODEL_COLUMNS = {
289
- tenantId: tenantDisabledModels.tenantId,
290
- providerId: tenantDisabledModels.providerId,
291
- modelId: tenantDisabledModels.modelId,
292
- endpointPath: tenantDisabledModels.endpointPath,
293
- } as const;
294
-
295
- const CLI_MAPPING_COLUMNS = {
296
- tenantId: cliToolMappings.tenantId,
297
- apiKeyId: cliToolMappings.apiKeyId,
298
- toolId: cliToolMappings.toolId,
299
- sourceModel: cliToolMappings.sourceModel,
300
- targetModel: cliToolMappings.targetModel,
301
- enabled: cliToolMappings.enabled,
302
- } as const;
303
-
304
- /** The per-(tenant, tool, key) Remote Routing switch. A mapping row only
305
- * routes when its bucket's settings row has `mappings_enabled` set. */
306
- const CLI_MAPPING_SETTING_COLUMNS = {
307
- tenantId: cliToolSettings.tenantId,
308
- apiKeyId: cliToolSettings.apiKeyId,
309
- toolId: cliToolSettings.toolId,
310
- mappingsEnabled: cliToolSettings.mappingsEnabled,
311
- } as const;
312
-
313
- const POOL_SETTING_COLUMNS = {
314
- tenantId: poolRoutingSettings.tenantId,
315
- strategy: poolRoutingSettings.strategy,
316
- rotateCount: poolRoutingSettings.rotateCount,
317
- } as const;
318
-
319
- /** A row of `models` as this module reads it. */
320
- type ModelRouteRow = { [K in keyof typeof MODEL_COLUMNS]: (typeof models.$inferSelect)[K] };
321
-
322
- /** Snapshot load result from the 10-table catalog read. */
323
- type RouteCatalogSnapshotResult = Omit<RouteSnapshot, "revision" | "created_at"> & { created_at?: number };
324
-
325
- /** Repository abstracting database queries for the route catalog snapshot. */
326
- class RouteCatalogRepository {
327
- constructor(private readonly db: CartethyiaDatabase) {}
328
-
329
- async loadRouteCatalogSnapshot(tenantId?: string): Promise<RouteCatalogSnapshotResult> {
330
- const [providerRows, modelRows, accountRows, aliasRows, comboRows, routingRows, poolRows, disabledModelRows, cliMappingRows, cliMappingSettingRows, poolSettingRows] =
331
- await Promise.all([
332
- this.db.select(PROVIDER_COLUMNS).from(providers).where(eq(providers.enabled, true)),
333
- this.db.select(MODEL_COLUMNS).from(models),
334
- this.db
335
- .select(ACCOUNT_COLUMNS)
336
- .from(providerAccounts)
337
- .where(ne(providerAccounts.status, "disabled"))
338
- .orderBy(asc(providerAccounts.status), asc(providerAccounts.createdAt), asc(providerAccounts.id)),
339
- this.db.select(ALIAS_COLUMNS).from(modelAliases),
340
- this.db.select(COMBO_COLUMNS).from(modelCombos),
341
- this.db.select(ROUTING_COLUMNS).from(providerRoutingSettings),
342
- this.db.select(POOL_COLUMNS).from(networkPools),
343
- this.db.select(DISABLED_MODEL_COLUMNS).from(tenantDisabledModels),
344
- this.db.select(CLI_MAPPING_COLUMNS).from(cliToolMappings),
345
- this.db.select(CLI_MAPPING_SETTING_COLUMNS).from(cliToolSettings),
346
- this.db.select(POOL_SETTING_COLUMNS).from(poolRoutingSettings),
347
- ]);
348
- const mergedModelRows = mergeModelCatalog(modelRows);
349
-
350
- // Compile tenant-scoped disables into a lookup keyed by the same
351
- // composite identity the `models` table uses. A disabled key suppresses
352
- // the candidate whose account belongs to that tenant.
353
- const disabledModelKeys = new Set<string>(
354
- disabledModelRows.map(
355
- (row) => `${row.tenantId}:${row.providerId}:${row.modelId}:${row.endpointPath}`,
356
- ),
357
- );
358
-
359
- const providerTenantById = new Map<string, string | null>(
360
- providerRows.map((p) => [p.id, p.tenantId]),
361
- );
362
- const requiresAccountById = new Map<string, boolean>(
363
- providerRows.map((p) => [p.id, p.requiresAccount]),
364
- );
365
-
366
- // Every active (non-disabled) pool a tenant owns, available for
367
- // automatic per-request selection — see `resolveNetworkPools` below.
368
- const activePoolsByTenant = new Map<
369
- string,
370
- Array<{ id: string; maxInflight: number; weight: number }>
371
- >();
372
- const configuredPoolTenants = new Set<string>();
373
- for (const pool of poolRows) {
374
- configuredPoolTenants.add(pool.tenantId);
375
- if (pool.status !== "active") continue;
376
- if (!pool.tenantId) continue;
377
- const list = activePoolsByTenant.get(pool.tenantId) ?? [];
378
- list.push({ id: pool.id, maxInflight: pool.maxInflight ?? 10, weight: pool.weight ?? 100 });
379
- activePoolsByTenant.set(pool.tenantId, list);
380
- }
381
-
382
- // Per-tenant pool selection strategy; an absent row reads as the
383
- // `least_loaded` default at selection time.
384
- const poolRouting: Record<string, PoolRoutingSetting> = {};
385
- for (const row of poolSettingRows) {
386
- if (tenantId === undefined || row.tenantId === tenantId) {
387
- poolRouting[row.tenantId] = { strategy: row.strategy, rotateCount: row.rotateCount };
388
- }
389
- }
390
-
391
- // Build per-tenant per-provider routing preferences before the
392
- // candidates loop below, since skipProxy has to be known while
393
- // deciding each candidate's network pool set. Global settings
394
- // (tenant_id IS NULL) are stored under sentinel "__global__".
395
- const providerRouting: Record<string, Record<string, ProviderRoutingMap[string][string]>> = {};
396
- for (const row of routingRows) {
397
- const tenantKey = row.tenantId ?? "__global__";
398
- const bucket = (providerRouting[tenantKey] ??= {});
399
- bucket[row.providerId] = {
400
- strategy: row.strategy as ProviderRoutingMap[string][string]["strategy"],
401
- rotateCount: row.rotateCount ?? 1,
402
- maxInflight: row.maxInflight,
403
- maxInflightPerProxy: row.maxInflightPerProxy,
404
- usageFloor: row.usageFloor,
405
- usageFloorUnit: row.usageFloorUnit === "credit" ? "credit" : "percent",
406
- enabled: row.enabled,
407
- skipProxy: row.skipProxy,
408
- userAgent: row.userAgent,
409
- };
410
- }
411
- /** Tenant-specific setting wins over global; an unconfigured provider
412
- * falls back to `DEFAULT_PROXY_SKIP_PROVIDER_IDS`, mirroring the same
413
- * default `DrizzleProviderDetailStore.getRouting` reports to the API —
414
- * so the dashboard's displayed default and the real dispatch decision
415
- * never disagree. */
416
- function resolveSkipProxy(providerId: string, rowTenantId: string | null): boolean {
417
- const effectiveTenantId = rowTenantId;
418
- const tenantSetting = effectiveTenantId ? providerRouting[effectiveTenantId]?.[providerId] : undefined;
419
- const globalSetting = providerRouting.__global__?.[providerId];
420
- return resolveTenantOverride(
421
- tenantSetting?.skipProxy,
422
- globalSetting?.skipProxy,
423
- DEFAULT_PROXY_SKIP_PROVIDER_IDS.has(providerId),
424
- );
425
- }
426
- function resolveUserAgent(providerId: string, rowTenantId: string | null): string | undefined {
427
- const tenantSetting = rowTenantId ? providerRouting[rowTenantId]?.[providerId]?.userAgent : undefined;
428
- const globalSetting = providerRouting.__global__?.[providerId]?.userAgent;
429
- return resolveRouteUserAgent(providerId, tenantSetting, globalSetting);
430
- }
431
-
432
- /** Provider-wide concurrency ceiling shared by every account of the provider.
433
- * Tenant setting wins over global, mirroring skipProxy. A stored `null`
434
- * is the explicit unlimited choice; so is the default — a provider with no
435
- * routing row at all is unlimited too, because an operator who never
436
- * configured a ceiling asked for none. */
437
- function resolveMaxInflight(
438
- providerId: string,
439
- rowTenantId: string | null,
440
- ): number | undefined {
441
- const tenantSetting = rowTenantId
442
- ? providerRouting[rowTenantId]?.[providerId]?.maxInflight
443
- : undefined;
444
- const globalSetting = providerRouting.__global__?.[providerId]?.maxInflight;
445
- const resolved = tenantSetting ?? globalSetting;
446
- if (resolved === null) return undefined;
447
- return resolved ?? DEFAULT_PROVIDER_MAX_INFLIGHT ?? undefined;
448
- }
449
-
450
- /**
451
- * This provider's per-pool inflight ceiling, from Routing Strategy.
452
- * `undefined` means unlimited — the default, and the state of every
453
- * provider nobody has configured. A stored `null` is the same choice made
454
- * explicitly; the two are deliberately indistinguishable downstream so the
455
- * hot path never branches on which one it read.
456
- */
457
- function resolveMaxInflightPerProxy(
458
- providerId: string,
459
- rowTenantId: string | null,
460
- ): number | undefined {
461
- const tenantSetting = rowTenantId
462
- ? providerRouting[rowTenantId]?.[providerId]?.maxInflightPerProxy
463
- : undefined;
464
- const globalSetting = providerRouting.__global__?.[providerId]?.maxInflightPerProxy;
465
- return tenantSetting ?? globalSetting ?? undefined;
466
- }
467
-
468
- /** Every active pool the account's tenant owns — dispatch picks the
469
- * least-loaded/non-cooldown one per request (`tryAcquireAvailablePool`),
470
- * never an admin-pinned single pool. */
471
- function resolveNetworkPools(
472
- providerId: string,
473
- rowTenantId: string | null,
474
- ):
475
- | {
476
- ids: readonly string[];
477
- limits: Record<string, number>;
478
- weights: Record<string, number>;
479
- routing?: PoolRoutingSetting & { tenantId: string };
480
- }
481
- | undefined {
482
- if (!rowTenantId || resolveSkipProxy(providerId, rowTenantId)) return undefined;
483
- if (!configuredPoolTenants.has(rowTenantId)) return undefined;
484
- const pools = activePoolsByTenant.get(rowTenantId) ?? [];
485
- const limits: Record<string, number> = {};
486
- const weights: Record<string, number> = {};
487
- for (const pool of pools) {
488
- limits[pool.id] = pool.maxInflight;
489
- weights[pool.id] = pool.weight;
490
- }
491
- const routing = poolRouting[rowTenantId];
492
- return {
493
- ids: pools.map((pool) => pool.id),
494
- limits,
495
- weights,
496
- ...(routing ? { routing: { tenantId: rowTenantId, ...routing } } : {}),
497
- };
498
- }
499
-
500
- /** The candidate fragment for a provider ceiling, empty when unlimited —
501
- * so an unconfigured provider's candidate is byte-for-byte what it was. */
502
- function providerLimitFor(
503
- providerId: string,
504
- rowTenantId: string | null,
505
- ): { network_pool_provider_limit?: number } {
506
- const limit = resolveMaxInflightPerProxy(providerId, rowTenantId);
507
- return limit === undefined ? {} : { network_pool_provider_limit: limit };
508
- }
509
-
510
- // Group usable accounts per provider (not per provider-tenant). Built-in
511
- // providers are `tenant_id IS NULL` but their accounts are per-tenant, so
512
- // grouping by `providerTenantId` would make every global model look
513
- // disabled. Instead keep all accounts for a provider together and fan out
514
- // candidates per account tenant below — e.g. `opencodeze/muse-spark`
515
- // hits with `model: "opencodeze/muse-spark"` must resolve to the tenant's
516
- // own account even though the provider row is global.
517
- const accountsByProvider = new Map<string, typeof accountRows>();
518
- for (const account of accountRows) {
519
- if (account.status === "disabled") continue;
520
- const list = accountsByProvider.get(account.providerId) ?? [];
521
- list.push(account);
522
- accountsByProvider.set(account.providerId, list);
523
- }
524
-
525
- const candidates: RouteCandidateWithHealth[] = [];
526
- for (const model of mergedModelRows) {
527
- const providerTenantId = providerTenantById.get(model.providerId);
528
- if (providerTenantId === undefined) continue;
529
- const accounts = accountsByProvider.get(model.providerId) ?? [];
530
- const capabilityProfile = buildCapabilityProfile(model);
531
- if (accounts.length === 0) {
532
- const providerRequiresAccount = requiresAccountById.get(model.providerId) ?? true;
533
- if (!providerRequiresAccount) {
534
- // Public models inherit active pools for each tenant that has active pools
535
- for (const tId of configuredPoolTenants) {
536
- const tenantPools = resolveNetworkPools(model.providerId, tId);
537
- if (tenantPools) {
538
- const tenantCandidate: RouteCandidateWithHealth = {
539
- provider_id: model.providerId,
540
- model_id: model.modelId,
541
- wire_family: model.wireFamily as WireFamily,
542
- service_kind: model.serviceKind,
543
- endpoint: model.endpointPath,
544
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
545
- capability_profile: capabilityProfile,
546
- tenant_id: tId,
547
- requires_account: false as const,
548
- network_pool_ids: tenantPools.ids,
549
- network_pool_required: true,
550
- network_pool_limits: tenantPools.limits,
551
- network_pool_weights: tenantPools.weights,
552
- ...(tenantPools.routing ? { network_pool_routing: tenantPools.routing } : {}),
553
- ...providerLimitFor(model.providerId, tId),
554
- };
555
- if (tenantId === undefined || tenantId === tId) {
556
- candidates.push(tenantCandidate);
557
- }
558
- }
559
- }
560
- }
561
- const networkPools = providerRequiresAccount
562
- ? undefined
563
- : resolveNetworkPools(model.providerId, providerTenantId);
564
- const candidate: RouteCandidateWithHealth = {
565
- provider_id: model.providerId,
566
- model_id: model.modelId,
567
- wire_family: model.wireFamily as WireFamily,
568
- service_kind: model.serviceKind,
569
- endpoint: model.endpointPath,
570
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
571
- capability_profile: capabilityProfile,
572
- tenant_id: providerTenantId,
573
- ...(providerRequiresAccount
574
- ? { health_status: "disabled" as const }
575
- : { requires_account: false as const }),
576
- ...(networkPools
577
- ? {
578
- network_pool_ids: networkPools.ids,
579
- network_pool_required: true,
580
- network_pool_limits: networkPools.limits,
581
- network_pool_weights: networkPools.weights,
582
- ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
583
- ...providerLimitFor(model.providerId, providerTenantId),
584
- }
585
- : {}),
586
- };
587
- if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
588
- candidates.push(candidate);
589
- }
590
- continue;
591
- }
592
- for (const account of accounts) {
593
- const rowTenantId = account.tenantId ?? providerTenantId;
594
- if (
595
- rowTenantId !== null &&
596
- disabledModelKeys.has(
597
- `${rowTenantId}:${model.providerId}:${model.modelId}:${model.endpointPath}`,
598
- )
599
- ) {
600
- continue;
601
- }
602
- const networkPools = resolveNetworkPools(model.providerId, rowTenantId);
603
- const routeUserAgent = resolveUserAgent(model.providerId, rowTenantId);
604
- const candidate: RouteCandidateWithHealth = {
605
- provider_id: model.providerId,
606
- model_id: model.modelId,
607
- wire_family: model.wireFamily as WireFamily,
608
- service_kind: model.serviceKind,
609
- endpoint: model.endpointPath,
610
- ...(model.outputLimit == null ? {} : { output_limit: model.outputLimit }),
611
- capability_profile: capabilityProfile,
612
- ...(routeUserAgent === undefined ? {} : { user_agent: routeUserAgent }),
613
- tenant_id: rowTenantId,
614
- provider_account_id: account.id,
615
- // Provider-wide concurrency ceiling from Routing Strategy; legacy
616
- // account overrides are never read here.
617
- ...(() => {
618
- const resolved = resolveMaxInflight(model.providerId, rowTenantId);
619
- return resolved === undefined ? {} : { max_inflight: resolved };
620
- })(),
621
- ...(networkPools
622
- ? {
623
- network_pool_required: true,
624
- network_pool_ids: networkPools.ids,
625
- network_pool_limits: networkPools.limits,
626
- network_pool_weights: networkPools.weights,
627
- ...(networkPools.routing ? { network_pool_routing: networkPools.routing } : {}),
628
- ...providerLimitFor(model.providerId, rowTenantId),
629
- }
630
- : {}),
631
- };
632
- if (account.status === "cooldown") {
633
- // Expired cooldowns read as healthy here without a fire-and-forget
634
- // write: AccountHealthSweeper materializes the recovery every 30s,
635
- // so the builder stays a pure read (no UPDATE racing the request).
636
- if (account.cooldownUntil && account.cooldownUntil.getTime() > Date.now()) {
637
- candidate.health_status = "cooldown";
638
- candidate.cooldown_kind = hardCooldownCategory(account.lastErrorCategory)
639
- ? "hard"
640
- : "soft";
641
- // The deadline rides with the exclusion it explains. `plan()` reads
642
- // it to answer the all-cooling case with the real reset instead of
643
- // a bare 429, so a client backs off for the window that is actually
644
- // open rather than inventing one.
645
- candidate.cooldown_until = account.cooldownUntil.getTime();
646
- }
647
- }
648
-
649
- // Per-model cooldown: an account may be healthy globally but cooling down
650
- // for a specific model. A non-expired entry for the model THIS candidate
651
- // serves is a hard exclusion, not a deprioritization: the upstream said
652
- // this exact (account, model) pair is exhausted until a stated reset
653
- // ("Daily free limit reached … Try again in 11h"), so dialing it can only
654
- // reproduce the same refusal. Deprioritizing instead made failover spend
655
- // a full round trip on it after every healthy sibling had failed, and
656
- // recorded another `active → cooldown` row each time — the operator's
657
- // report of one account churning while ten siblings sat healthy.
658
- //
659
- // `model_cooldown` (distinct from `cooldown`) is what carries that
660
- // distinction to the evaluator: an account-wide `cooldown` stays
661
- // deprioritized so a single-account deployment still routes, while a
662
- // model-scoped one is dropped for this model only and the account keeps
663
- // serving every other model it holds.
664
- const modelCooldowns = account.modelCooldowns as Record<string, string> | null;
665
- const modelCooldownUntil = modelCooldowns?.[model.modelId];
666
- if (modelCooldownUntil && new Date(modelCooldownUntil).getTime() > Date.now()) {
667
- candidate.health_status = "model_cooldown";
668
- candidate.cooldown_until = new Date(modelCooldownUntil).getTime();
669
- }
670
- if (tenantId === undefined || candidate.tenant_id === null || candidate.tenant_id === tenantId) {
671
- candidates.push(candidate);
672
- }
673
- }
674
- }
675
-
676
- const aliases: Record<string, Record<string, string>> = {};
677
- const cliAliases: Record<string, Record<string, string>> = {};
678
- for (const row of aliasRows) {
679
- if (tenantId === undefined || row.tenantId === tenantId) {
680
- (aliases[row.tenantId] ??= {})[row.alias] = row.targetModel;
681
- }
682
- }
683
- // CLI-tool mappings are per-(tenant, key): each API key can route the same
684
- // CLI slot to a different target. The snapshot keys the alias bucket by
685
- // `${tenantId}:${apiKeyId}` so the preparer can look up the exact key's
686
- // routes without merging across keys.
687
- // Remote Routing is opt-in per (tenant, tool, key): a mapping row routes
688
- // only when its bucket's settings row explicitly enabled it. The CLI tool
689
- // page and the API-key edit form both write that one flag, so either place
690
- // can turn the routes on or off.
691
- const enabledMappingBuckets = new Set<string>();
692
- for (const row of cliMappingSettingRows) {
693
- if (row.mappingsEnabled) enabledMappingBuckets.add(`${row.tenantId}:${row.apiKeyId}`);
694
- }
695
- for (const row of cliMappingRows) {
696
- if (!row.enabled) continue;
697
- if (!enabledMappingBuckets.has(`${row.tenantId}:${row.apiKeyId}`)) continue;
698
- if (tenantId === undefined || row.tenantId === tenantId) {
699
- const bucketKey = `${row.tenantId}:${row.apiKeyId}`;
700
- const aliasBucket = (cliAliases[bucketKey] ??= {});
701
- for (const sourceKey of cliMappingSourceKeys(row.toolId, row.sourceModel)) {
702
- aliasBucket[sourceKey] = row.targetModel;
703
- }
704
- }
705
- }
706
- const combos: Record<string, Record<string, ComboDefinition>> = {};
707
- for (const row of comboRows) {
708
- if (tenantId === undefined || row.tenantId === tenantId) {
709
- (combos[row.tenantId] ??= {})[row.name] = { members: row.members, strategy: row.strategy };
710
- }
711
- }
712
-
713
- const routingMap: ProviderRoutingMap | undefined =
714
- Object.keys(providerRouting).length > 0 ? (providerRouting as ProviderRoutingMap) : undefined;
715
-
716
- return {
717
- candidates,
718
- aliases,
719
- cli_aliases: cliAliases,
720
- combos,
721
- ...(routingMap ? { providerRouting: routingMap } : {}),
722
- ...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
723
- };
724
- }
725
- }
726
-
727
- /** Builds a `SnapshotBuilder` reading the live catalog from `db`. */
728
- export function createDatabaseSnapshotBuilder(
729
- db: CartethyiaDatabase,
730
- refreshActiveCatalogs?: () => Promise<void>,
731
- ): SnapshotBuilder {
732
- const repository = new RouteCatalogRepository(db);
733
- return async () => {
734
- await refreshActiveCatalogs?.();
735
- return repository.loadRouteCatalogSnapshot(undefined);
736
- };
737
- }
738
-
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
+