cartethyia 2.7.9 → 2.8.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 (375) hide show
  1. package/.env.example +3 -2
  2. package/dist/dashboard/assets/{BannedPage-BxoG1MrL.js → BannedPage-Cc0vKpMG.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-ixwgKk53.js → CliToolDetailPage-Bqj2aa1T.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-BmttNV3U.js → CliToolsPage-CXsaOAO2.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-BE3qwwfC.js → CombosPage-BeLpJ4ma.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CRZdmMIX.js → ConsoleLogPage-DnV0uZ3m.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-Dv_FJ6Er.js → CustomizationPage-CBxAjo63.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
  16. package/dist/dashboard/assets/{InjectSystemPromptPanel-BOoO8rpz.js → InjectSystemPromptPanel-BWFLECmz.js} +2 -2
  17. package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-ChJuNNVN.js → LoginPage-CgoPc5B9.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
  20. package/dist/dashboard/assets/{ModelPicker-BZAJtPg1.js → ModelPicker-D9bFQern.js} +1 -1
  21. package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
  22. package/dist/dashboard/assets/{OverviewPage-BE40s3u5.js → OverviewPage-B_p4tL8k.js} +1 -1
  23. package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +8 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProviderIcon-CQ6bPUf_.js → ProviderIcon-D_0PiZtg.js} +1 -1
  27. package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
  30. package/dist/dashboard/assets/{ProxyPage-BJ87Pp0U.js → ProxyPage-BwNC_-Cx.js} +1 -1
  31. package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-C5HPWqEv.js → SettingsPage-CctTga2C.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
  36. package/dist/dashboard/assets/{SetupPage-CFNcATJJ.js → SetupPage-febLU4r1.js} +1 -1
  37. package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-YoD4tY5l.js → StudioPage-M6fvR5Wz.js} +1 -1
  39. package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
  40. package/dist/dashboard/assets/{UsagePage-DxjaAhb_.js → UsagePage-nyeuRqz9.js} +1 -1
  41. package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BTlkq_Uk.js → api-keys-BFOlvrJC.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
  44. package/dist/dashboard/assets/app-Dx2QgsNj.css +2 -0
  45. package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-CqTLw5e7.js +1 -0
  47. package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DdbT_7Am.js → badge-C83EqLB0.js} +1 -1
  49. package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-CsGN-nbm.js → banner-D1xn7pIe.js} +1 -1
  51. package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-mgZ_TLLZ.js → card-DU3Lq5Lm.js} +1 -1
  53. package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-A1-v8-03.js → cli-tools-Dw1U_drK.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-CJ4Zty89.js → clipboard-button-CEL5Sg_J.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-B5L2jPQ_.js → dialog-BjFR5UHy.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-DkjUouAj.js → drawer-HvBW0AjI.js} +1 -1
  61. package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BSu3gFlt.js +4 -0
  63. package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-B-LVLV6o.js → entry-CECVELyy.js} +1 -1
  65. package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-CAhdYl3B.js → entry-DYYofQ7W.js} +1 -1
  67. package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
  68. package/dist/dashboard/assets/{formatters-CrsdLVsn.js → formatters-DLW4i8zK.js} +1 -1
  69. package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-BU64Lqvp.js → index-1-fCqe64.js} +2 -2
  71. package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-B2aDLyyg.js → inline-DGvU2oLK.js} +1 -1
  73. package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BCVkdR9d.js → input-iNnO1X2K.js} +1 -1
  75. package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-DNcAdMK-.js → layout-0q8UGypb.js} +1 -1
  77. package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-DgrIH0hw.js → list-row-C0w2bQjJ.js} +1 -1
  79. package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-Cae01iu8.js → live-M5aJ_xCa.js} +1 -1
  81. package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
  82. package/dist/dashboard/assets/{network-CRib7H_2.js → network-BKeQGT3K.js} +1 -1
  83. package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-BKQE8Xpz.js → numbers-CivAk3da.js} +1 -1
  85. package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
  86. package/dist/dashboard/assets/{pagination-ecPvG_8g.js → pagination-CpzvI5ep.js} +1 -1
  87. package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
  88. package/dist/dashboard/assets/providers/zro.svg +3 -0
  89. package/dist/dashboard/assets/providers/zro.svg.gz +0 -0
  90. package/dist/dashboard/assets/quota-DiPD6UKH.js +1 -0
  91. package/dist/dashboard/assets/quota-DiPD6UKH.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-Ckm1UXuC.js → routing-BFQX2VBc.js} +1 -1
  93. package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-ChoFWyKz.js → select-CXbck0jG.js} +2 -2
  95. package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
  96. package/dist/dashboard/assets/{settings-CKjFPlKW.js → settings-C_XvMHh9.js} +1 -1
  97. package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
  98. package/dist/dashboard/assets/{stack-DWCwU3fh.js → stack-DggRiCxE.js} +1 -1
  99. package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
  100. package/dist/dashboard/assets/{state-Cg3VL5J_.js → state-ClFzfbYp.js} +1 -1
  101. package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
  102. package/dist/dashboard/assets/{switch-Db70BMix.js → switch-CTIFxKqS.js} +1 -1
  103. package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
  104. package/dist/dashboard/assets/{useMutation-BqdE0LuM.js → useMutation-BClG3yWj.js} +1 -1
  105. package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/dashboard/index.html.gz +0 -0
  108. package/dist/dashboard/precompressed-assets.json +54 -53
  109. package/migrations/full-postgres/0046_add_accounts_quota_plan_fetched_at.sql +7 -0
  110. package/migrations/lite-sqlite3/0046_add_accounts_quota_plan_fetched_at.sql +4 -0
  111. package/package.json +1 -1
  112. package/src/boot/app.ts +10 -13
  113. package/src/boot/dependencies.ts +1 -1
  114. package/src/boot/flush.ts +20 -0
  115. package/src/boot/lifecycle.ts +7 -5
  116. package/src/config/routing.ts +26 -0
  117. package/src/config/spec-metadata.ts +72 -0
  118. package/src/config/spec.ts +20 -0
  119. package/src/console/api-keys/contracts.ts +12 -1
  120. package/src/console/api-keys/routes.ts +40 -28
  121. package/src/console/auth/account-reset.ts +7 -9
  122. package/src/console/backup/contracts.ts +4 -0
  123. package/src/console/backup/export-endpoint.ts +7 -5
  124. package/src/console/cli-tools/service.ts +6 -12
  125. package/src/console/console-router-guards.ts +23 -16
  126. package/src/console/domain-registration.ts +0 -2
  127. package/src/console/inferhub/normalize.ts +24 -28
  128. package/src/console/inferhub/types.ts +3 -0
  129. package/src/console/ip-bans/routes.ts +7 -13
  130. package/src/console/observability/store.ts +0 -53
  131. package/src/console/providers/catalog/contracts.ts +2 -2
  132. package/src/console/providers/catalog/discover-constants.ts +6 -0
  133. package/src/console/providers/catalog/routes.ts +1 -0
  134. package/src/console/providers/catalog/store.ts +21 -16
  135. package/src/console/quota/account-quota-global.ts +14 -24
  136. package/src/console/quota/account-quota-shared.ts +49 -0
  137. package/src/console/quota/account-quota-tenant.ts +6 -18
  138. package/src/console/quota/quota-view.ts +19 -3
  139. package/src/console/routing/pools/operations.ts +31 -46
  140. package/src/console/shared/errors.ts +33 -6
  141. package/src/main.ts +2 -3
  142. package/src/network/pool/byte-accounting.ts +3 -2
  143. package/src/network/pool/selector.ts +182 -39
  144. package/src/network/pool-cooldown-store.ts +89 -14
  145. package/src/network/pool-health.ts +49 -61
  146. package/src/network/rate-limit-verdict.ts +1 -1
  147. package/src/network/ssrf.ts +6 -6
  148. package/src/observability/event-log.ts +7 -361
  149. package/src/observability/performance-metrics.ts +9 -0
  150. package/src/observability/runtime-metrics.ts +14 -9
  151. package/src/observability/telemetry-buffer.ts +15 -25
  152. package/src/observability/telemetry-reconciliation.ts +9 -37
  153. package/src/observability/telemetry-row.ts +125 -0
  154. package/src/persistence/db-mode.ts +4 -1
  155. package/src/persistence/dialect.ts +18 -0
  156. package/src/persistence/migrate.ts +6 -3
  157. package/src/persistence/schema.ts +8 -0
  158. package/src/persistence/ttl-cache.ts +20 -2
  159. package/src/protocol/codecs.ts +1 -1
  160. package/src/protocol/dsml.ts +34 -86
  161. package/src/protocol/markup-stream-parser.ts +195 -0
  162. package/src/protocol/reasoning-text.ts +19 -0
  163. package/src/protocol/request/chat.ts +10 -10
  164. package/src/protocol/request/gemini.ts +8 -2
  165. package/src/protocol/request/messages.ts +3 -4
  166. package/src/protocol/response/chat.ts +12 -20
  167. package/src/protocol/response/omp-tool-call-markup.ts +43 -86
  168. package/src/protocol/response/responses.ts +18 -2
  169. package/src/protocol/stop-reason.ts +23 -1
  170. package/src/protocol/think-split.ts +112 -0
  171. package/src/protocol/translation/dialect/qwen.ts +26 -58
  172. package/src/providers/adapters/compatible-adapter.ts +71 -12
  173. package/src/providers/authentication/oauth-client.ts +1 -1
  174. package/src/providers/authentication/oauth-device-flow.ts +98 -8
  175. package/src/providers/authentication/oauth-refresh-service.ts +30 -49
  176. package/src/providers/configured-provider.ts +148 -0
  177. package/src/providers/default-registry.ts +61 -36
  178. package/src/providers/discovery/probe-phases.ts +14 -4
  179. package/src/providers/discovery/probing-service.ts +15 -2
  180. package/src/providers/integrations/alibabacp/alibabacp-models.ts +1 -2
  181. package/src/providers/integrations/{dashscope-models.ts → alibabacp/dashscope-models.ts} +2 -2
  182. package/src/providers/integrations/antigravity/antigravity-protocol.ts +5 -1
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +173 -17
  184. package/src/providers/integrations/buddy/buddy-identity.ts +19 -6
  185. package/src/providers/integrations/buddy/capabilities.ts +2 -2
  186. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +3 -0
  187. package/src/providers/integrations/buddy/codebuddy/provider.ts +4 -0
  188. package/src/providers/integrations/buddy/codebuddy/shared.ts +37 -22
  189. package/src/providers/integrations/claude/claude-quota.ts +14 -17
  190. package/src/providers/integrations/cline/cline.ts +2 -2
  191. package/src/providers/integrations/commandcode/quota.ts +22 -28
  192. package/src/providers/integrations/cursor/cursor-auth.ts +0 -8
  193. package/src/providers/integrations/cursor/cursor-exec.ts +9 -2
  194. package/src/providers/integrations/cursor/cursor-request.ts +26 -12
  195. package/src/providers/integrations/cursor/cursor-shared.ts +7 -4
  196. package/src/providers/integrations/cursor/cursor.ts +89 -40
  197. package/src/providers/integrations/cursor/protocol/messages.ts +22 -29
  198. package/src/providers/integrations/cursor/protocol/protobuf-schema.ts +37 -0
  199. package/src/providers/integrations/devin/protocol/devin-proto.ts +1 -27
  200. package/src/providers/integrations/factory/factory-oauth.ts +17 -44
  201. package/src/providers/integrations/freebuff/assets/official-system-manager.txt +88 -0
  202. package/src/providers/integrations/freebuff/assets/official-system-worker.txt +57 -0
  203. package/src/providers/integrations/freebuff/assets/official-tools.json +1070 -0
  204. package/src/providers/integrations/freebuff/freebuff-catalog.ts +120 -0
  205. package/src/providers/integrations/freebuff/freebuff-constants.ts +1233 -0
  206. package/src/providers/integrations/freebuff/freebuff-discovery.ts +108 -0
  207. package/src/providers/integrations/freebuff/freebuff-oauth.ts +240 -0
  208. package/src/providers/integrations/freebuff/freebuff-quota.ts +260 -0
  209. package/src/providers/integrations/freebuff/freebuff-virtual.ts +89 -0
  210. package/src/providers/integrations/freebuff/freebuff.ts +682 -0
  211. package/src/providers/integrations/grok/grok-oauth.ts +48 -45
  212. package/src/providers/integrations/grok/grok-ratelimit-store.ts +10 -17
  213. package/src/providers/integrations/grok/grok-turn-index.ts +11 -16
  214. package/src/providers/integrations/{hermes.ts → hermes/hermes.ts} +9 -9
  215. package/src/providers/integrations/hermes/index.ts +2 -0
  216. package/src/providers/integrations/kiro/kiro-catalog.ts +17 -1
  217. package/src/providers/integrations/kiro/kiro-discovery.ts +10 -12
  218. package/src/providers/integrations/kiro/kiro-quota.ts +8 -4
  219. package/src/providers/integrations/kiro/kiro.ts +15 -19
  220. package/src/providers/integrations/ollamacloud/index.ts +2 -0
  221. package/src/providers/integrations/{ollamacloud-models.ts → ollamacloud/models.ts} +2 -2
  222. package/src/providers/integrations/{ollamacloud-quota.ts → ollamacloud/quota.ts} +3 -3
  223. package/src/providers/integrations/opencode/opencode-go-quota.ts +2 -8
  224. package/src/providers/integrations/perch/perch-credential.ts +25 -0
  225. package/src/providers/integrations/perch/perch-protocol.ts +0 -3
  226. package/src/providers/integrations/perch/perch-quota.ts +2 -16
  227. package/src/providers/integrations/perch/perch.ts +3 -13
  228. package/src/providers/integrations/qoder/claim-qoder-credits.ts +18 -59
  229. package/src/providers/integrations/qoder/qoder-adapter.ts +1 -12
  230. package/src/providers/integrations/qoder/qoder-auth.ts +101 -29
  231. package/src/providers/integrations/qoder/qoder-catalog.ts +20 -5
  232. package/src/providers/integrations/qoder/qoder-chat.ts +7 -21
  233. package/src/providers/integrations/qoder/qoder-checkin.ts +4 -4
  234. package/src/providers/integrations/qoder/qoder-grpc.ts +0 -1
  235. package/src/providers/integrations/qoder/qoder-ide.ts +0 -107
  236. package/src/providers/integrations/qoder/qoder.ts +0 -1
  237. package/src/providers/integrations/trae/trae-protocol.ts +9 -5
  238. package/src/providers/integrations/trae/trae-quota.ts +6 -28
  239. package/src/providers/integrations/trae/trae-realm.ts +38 -0
  240. package/src/providers/integrations/trae/trae.ts +7 -24
  241. package/src/providers/integrations/zcode-start-plan/provider.ts +226 -23
  242. package/src/providers/integrations/zcode-start-plan/quota.ts +19 -2
  243. package/src/providers/integrations/zro/zro.ts +204 -0
  244. package/src/providers/operations/account-error-classification.ts +199 -307
  245. package/src/providers/operations/account-health-service.ts +82 -1
  246. package/src/providers/operations/account-refresh-status.ts +22 -37
  247. package/src/providers/operations/account-tier.ts +15 -16
  248. package/src/providers/operations/client-version-resolver.ts +1 -1
  249. package/src/providers/operations/client-versions.ts +5 -51
  250. package/src/providers/operations/cooldown-rules.ts +11 -16
  251. package/src/providers/operations/{model-catalog-cache.ts → provider-caches.ts} +68 -16
  252. package/src/providers/operations/provider-catalog-service.ts +1 -1
  253. package/src/providers/operations/provider-credential-service.ts +102 -32
  254. package/src/providers/operations/provider-rules/github.ts +48 -0
  255. package/src/providers/operations/provider-rules/grok.ts +33 -0
  256. package/src/providers/operations/provider-rules/inception.ts +49 -0
  257. package/src/providers/operations/provider-rules/index.ts +32 -0
  258. package/src/providers/operations/provider-rules/perch.ts +37 -0
  259. package/src/providers/operations/provider-rules/qoder.ts +70 -0
  260. package/src/providers/operations/provider-rules/trae.ts +81 -0
  261. package/src/providers/provider-contracts.ts +4 -2
  262. package/src/providers/{provider-credential-resolver.ts → provider-credentials.ts} +40 -3
  263. package/src/providers/provider-metadata.ts +40 -10
  264. package/src/providers/provider-probe.ts +15 -1
  265. package/src/providers/provider-registry.ts +42 -33
  266. package/src/providers/quota/quota-limit-windows.ts +16 -5
  267. package/src/providers/quota/quota-support.ts +1 -1
  268. package/src/providers/quota/quota-window-parser.ts +90 -18
  269. package/src/providers/quota/refresh.ts +75 -18
  270. package/src/security/api-key-auth-cache.ts +1 -1
  271. package/src/security/api-key-auth.ts +6 -2
  272. package/src/transport/dispatch/attempt-finalize.ts +14 -13
  273. package/src/transport/dispatch/attempt-loop.ts +71 -17
  274. package/src/transport/dispatch/generation.ts +2 -8
  275. package/src/transport/dispatch/native-shared.ts +25 -0
  276. package/src/transport/dispatch/proxy-request.ts +8 -46
  277. package/src/transport/dispatch/responses-compact.ts +2 -8
  278. package/src/transport/dispatch/streaming/finalize.ts +9 -6
  279. package/src/transport/dispatch/streaming/stream.ts +8 -36
  280. package/src/transport/dispatch/systemone.ts +2 -8
  281. package/src/transport/dispatch/token-count.ts +2 -6
  282. package/src/transport/dispatch/upstream.ts +63 -0
  283. package/src/transport/drain-endpoint.ts +31 -4
  284. package/src/transport/failure-policy.ts +16 -30
  285. package/src/transport/guards/body-policy.ts +7 -1
  286. package/src/transport/guards/error-lifecycle.ts +13 -1
  287. package/src/transport/guards/request-context.ts +22 -14
  288. package/src/transport/request/preparer.ts +81 -71
  289. package/src/transport/routing/bounded-lru.ts +14 -0
  290. package/src/transport/routing/drain-reserve.ts +129 -0
  291. package/src/transport/routing/plan-gate.ts +7 -1
  292. package/src/transport/routing/round-robin.ts +60 -0
  293. package/src/transport/routing/route-catalog.ts +123 -111
  294. package/src/transport/routing/route-model.ts +51 -9
  295. package/src/transport/routing/router.ts +393 -32
  296. package/src/transport/surface/adapters.ts +11 -2
  297. package/src/transport/surface/chat/adapter.ts +2 -2
  298. package/src/transport/surface/chat/encode.ts +47 -4
  299. package/src/transport/surface/chat/parse.ts +2 -12
  300. package/src/transport/surface/completion.ts +44 -33
  301. package/src/transport/surface/content-parts.ts +21 -0
  302. package/src/transport/surface/messages/adapter.ts +7 -1
  303. package/src/transport/surface/messages/encode.ts +11 -3
  304. package/src/transport/surface/messages/parse.ts +2 -1
  305. package/src/transport/surface/messages/stream.ts +33 -17
  306. package/src/transport/surface/responses/encode.ts +22 -5
  307. package/src/transport/surface/responses/parse.ts +3 -11
  308. package/dist/dashboard/assets/BannedPage-BxoG1MrL.js.gz +0 -0
  309. package/dist/dashboard/assets/CliToolDetailPage-ixwgKk53.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolsPage-BmttNV3U.js.gz +0 -0
  311. package/dist/dashboard/assets/CombosPage-BE3qwwfC.js.gz +0 -0
  312. package/dist/dashboard/assets/ConsoleLogPage-CRZdmMIX.js.gz +0 -0
  313. package/dist/dashboard/assets/CustomizationPage-Dv_FJ6Er.js.gz +0 -0
  314. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js +0 -1
  315. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js.gz +0 -0
  316. package/dist/dashboard/assets/InjectSystemPromptPanel-BOoO8rpz.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-ChJuNNVN.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-BZAJtPg1.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-BE40s3u5.js.gz +0 -0
  320. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js +0 -8
  321. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderIcon-CQ6bPUf_.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js +0 -1
  324. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BJ87Pp0U.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-C5HPWqEv.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-CFNcATJJ.js.gz +0 -0
  330. package/dist/dashboard/assets/StudioPage-YoD4tY5l.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-DxjaAhb_.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-BTlkq_Uk.js.gz +0 -0
  333. package/dist/dashboard/assets/app-BX2nnKqs.css +0 -2
  334. package/dist/dashboard/assets/app-BX2nnKqs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-CndthXFQ.js +0 -1
  336. package/dist/dashboard/assets/assert-CndthXFQ.js.gz +0 -0
  337. package/dist/dashboard/assets/badge-DdbT_7Am.js.gz +0 -0
  338. package/dist/dashboard/assets/banner-CsGN-nbm.js.gz +0 -0
  339. package/dist/dashboard/assets/card-mgZ_TLLZ.js.gz +0 -0
  340. package/dist/dashboard/assets/cli-tools-A1-v8-03.js.gz +0 -0
  341. package/dist/dashboard/assets/clipboard-button-CJ4Zty89.js.gz +0 -0
  342. package/dist/dashboard/assets/dialog-B5L2jPQ_.js.gz +0 -0
  343. package/dist/dashboard/assets/drawer-DkjUouAj.js.gz +0 -0
  344. package/dist/dashboard/assets/entry-B-LVLV6o.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-CAhdYl3B.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-D2xD0Tmd.js +0 -4
  347. package/dist/dashboard/assets/entry-D2xD0Tmd.js.gz +0 -0
  348. package/dist/dashboard/assets/formatters-CrsdLVsn.js.gz +0 -0
  349. package/dist/dashboard/assets/index-BU64Lqvp.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-B2aDLyyg.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BCVkdR9d.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-DNcAdMK-.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-DgrIH0hw.js.gz +0 -0
  354. package/dist/dashboard/assets/live-Cae01iu8.js.gz +0 -0
  355. package/dist/dashboard/assets/network-CRib7H_2.js.gz +0 -0
  356. package/dist/dashboard/assets/numbers-BKQE8Xpz.js.gz +0 -0
  357. package/dist/dashboard/assets/pagination-ecPvG_8g.js.gz +0 -0
  358. package/dist/dashboard/assets/quota-DJ1QlAOq.js +0 -1
  359. package/dist/dashboard/assets/quota-DJ1QlAOq.js.gz +0 -0
  360. package/dist/dashboard/assets/routing-Ckm1UXuC.js.gz +0 -0
  361. package/dist/dashboard/assets/select-ChoFWyKz.js.gz +0 -0
  362. package/dist/dashboard/assets/settings-CKjFPlKW.js.gz +0 -0
  363. package/dist/dashboard/assets/stack-DWCwU3fh.js.gz +0 -0
  364. package/dist/dashboard/assets/state-Cg3VL5J_.js.gz +0 -0
  365. package/dist/dashboard/assets/switch-Db70BMix.js.gz +0 -0
  366. package/dist/dashboard/assets/useMutation-BqdE0LuM.js.gz +0 -0
  367. package/src/providers/bundled-discovery-providers.ts +0 -98
  368. package/src/providers/configured-openai-providers.ts +0 -120
  369. package/src/providers/operations/model-discovery-cache.ts +0 -34
  370. package/src/providers/operations/probe-fetch.ts +0 -15
  371. package/src/providers/operations/provider-version-cache.ts +0 -30
  372. package/src/providers/provider-capabilities.ts +0 -33
  373. package/src/providers/provider-credential-envelope.ts +0 -43
  374. /package/dist/dashboard/assets/{app-B7kIWFm5.js → app-DIXLjojg.js} +0 -0
  375. /package/dist/dashboard/assets/{app-B7kIWFm5.js.gz → app-DIXLjojg.js.gz} +0 -0
@@ -22,7 +22,7 @@ import { mkdirSync, writeFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { join } from "node:path";
24
24
  import { GatewayError } from "../../protocol/gateway-error";
25
- import { isLoopbackPeer, tokenMatches } from "../../transport/drain-endpoint";
25
+ import { requireLoopbackAdmin } from "../../transport/drain-endpoint";
26
26
  import { tablesForSection } from "./contracts";
27
27
  import { compressBackupStream, exportBackupStream } from "./store";
28
28
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
@@ -52,10 +52,12 @@ export function createBackupExportHandler(
52
52
  // both: neither refusal is about the body. The first is where the request
53
53
  // came from, the second is the credential it carried — and `invalid_request`
54
54
  // told an operator to fix a body that was never the problem.
55
- if (!isLoopbackPeer(deps.resolvePeerAddress(request)))
56
- throw new GatewayError("policy_rejected", 403, "backup export is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂");
57
- if (!tokenMatches(request.headers.get("x-drain-token"), deps.token))
58
- throw new GatewayError("authentication_failed", 401, "invalid export token (request {requestId}). Wrong key, right door — check the token 🔑");
55
+ requireLoopbackAdmin(request, {
56
+ peer: deps.resolvePeerAddress(request),
57
+ token: deps.token,
58
+ policyRejectedMessage: "backup export is only available from loopback (request {requestId}). This operation is only allowed from a trusted loopback connection — no hard feelings 🫂",
59
+ authenticationFailedMessage: "invalid export token (request {requestId}). Wrong key, right door — check the token 🔑",
60
+ });
59
61
 
60
62
  const directory =
61
63
  body !== null && typeof body === "object" && "directory" in body
@@ -8,7 +8,6 @@ import type {
8
8
  CliMappingSettings,
9
9
  DownloadResult,
10
10
  ToolDef,
11
- ToolInjector,
12
11
  ToolStatus,
13
12
  } from "./contracts";
14
13
  import { TOOL_IDS, TOOL_REGISTRY, type ToolId, type ToolRegistryEntry } from "./contracts";
@@ -47,11 +46,6 @@ function toToolRegistryEntry(def: ToolDef): ToolRegistryEntry {
47
46
  };
48
47
  }
49
48
 
50
- function injectorFor(toolId: string): ToolInjector | null {
51
- const injector = INJECTORS[toolId as ToolId];
52
- return injector ?? null;
53
- }
54
-
55
49
  /** Operator-facing summary of what an apply actually did. */
56
50
  function applyMessage(outcome: ApplyOutcome, toolId: string, fileMessage: string): string {
57
51
  switch (outcome) {
@@ -139,8 +133,8 @@ export class CliToolService {
139
133
  }
140
134
 
141
135
  async getStatus(toolId: string): Promise<ToolStatus | null> {
142
- const injector = injectorFor(toolId);
143
- if (injector === null) return null;
136
+ const injector = INJECTORS[toolId as ToolId];
137
+ if (injector === undefined) return null;
144
138
  return injector.getStatus();
145
139
  }
146
140
 
@@ -249,8 +243,8 @@ export class CliToolService {
249
243
  }
250
244
 
251
245
  async downloadConfig(toolId: string, input: ApplyInput): Promise<DownloadResult | null> {
252
- const injector = injectorFor(toolId);
253
- if (injector === null) return null;
246
+ const injector = INJECTORS[toolId as ToolId];
247
+ if (injector === undefined) return null;
254
248
  if (!input.endpoint || !input.apiKey) return null;
255
249
  try {
256
250
  return await injector.download(input);
@@ -281,8 +275,8 @@ export class CliToolService {
281
275
  apiKeyId: string,
282
276
  input: ApplyInput & { mode?: "file" | "remote" | "both" },
283
277
  ): Promise<ApplyConfigResult | null> {
284
- const injector = injectorFor(toolId);
285
- if (injector === null) return null;
278
+ const injector = INJECTORS[toolId as ToolId];
279
+ if (injector === undefined) return null;
286
280
  const mode = input.mode ?? "both";
287
281
  const wantsFile = mode === "file" || mode === "both";
288
282
  const wantsRemote = mode === "remote" || mode === "both";
@@ -11,6 +11,27 @@ import { GatewayError } from "../protocol/gateway-error";
11
11
  import { fastPathname } from "../transport/pathname";
12
12
  import { parseCookieValue, SESSION_COOKIE_NAME, isCsrfValid } from "../security/csrf";
13
13
 
14
+ /**
15
+ * Console auth routes are excluded from CSRF and mutation-rate limiting: they
16
+ * run before a session exists or are the very requests that mint the session,
17
+ * so they must not require a CSRF token or count against the session's budget.
18
+ */
19
+ const AUTH_ROUTE_SUFFIXES = ["/auth/login", "/auth/setup", "/auth/setup/restore", "/auth/session"] as const;
20
+
21
+ /**
22
+ * Whether `request` is a protected console mutation: a `/console/api/*` route
23
+ * in an unsafe method, excluding the auth-route suffixes. Shared by the CSRF
24
+ * and mutation-limiter guards so a change to the protected set lands in both.
25
+ */
26
+ function isProtectedConsoleMutation(request: Request): boolean {
27
+ const path = fastPathname(request.url);
28
+ return (
29
+ path.startsWith("/console/api/") &&
30
+ ["POST", "PUT", "PATCH", "DELETE"].includes(request.method) &&
31
+ !AUTH_ROUTE_SUFFIXES.some((suffix) => path.endsWith(suffix))
32
+ );
33
+ }
34
+
14
35
  /**
15
36
  * Stateless double-submit CSRF guard for unsafe `/console/api/*` mutations.
16
37
  * Compares the `x-csrf-token` header against the readable `csrf_token`
@@ -19,16 +40,9 @@ import { parseCookieValue, SESSION_COOKIE_NAME, isCsrfValid } from "../security/
19
40
  * store whose outage could masquerade as an auth failure.
20
41
  */
21
42
  export function createConsoleCsrfMiddleware(): Elysia {
22
- const excluded = ["/auth/login", "/auth/setup", "/auth/setup/restore", "/auth/session"];
23
43
  return new Elysia()
24
44
  .beforeHandle(async ({ request }) => {
25
- const path = fastPathname(request.url);
26
- if (
27
- !path.startsWith("/console/api/") ||
28
- !["POST", "PUT", "PATCH", "DELETE"].includes(request.method) ||
29
- excluded.some((suffix) => path.endsWith(suffix))
30
- )
31
- return;
45
+ if (!isProtectedConsoleMutation(request)) return;
32
46
  const token = parseCookieValue(request, SESSION_COOKIE_NAME);
33
47
  // No session cookie means no ambient authority to forge: logout and
34
48
  // other mutations without a session fail authentication downstream.
@@ -75,16 +89,9 @@ function pruneConsoleMutationHits(now: number): void {
75
89
  }
76
90
 
77
91
  export function createConsoleMutationLimiterMiddleware(): Elysia {
78
- const excluded = ["/auth/login", "/auth/setup", "/auth/setup/restore", "/auth/session"];
79
92
  return new Elysia()
80
93
  .beforeHandle(async ({ request, set }) => {
81
- const path = fastPathname(request.url);
82
- if (
83
- !path.startsWith("/console/api/") ||
84
- !["POST", "PUT", "PATCH", "DELETE"].includes(request.method) ||
85
- excluded.some((suffix) => path.endsWith(suffix))
86
- )
87
- return;
94
+ if (!isProtectedConsoleMutation(request)) return;
88
95
  const key = consoleMutationKey(request);
89
96
  // No session cookie means no ambient authority to abuse: the request
90
97
  // fails authentication downstream. Only tracked sessions are throttled.
@@ -38,7 +38,6 @@ import { createAuditRoutes } from "./audit/routes";
38
38
  import { DrizzleAuditReadStore } from "./audit/store";
39
39
  import { createIpBanRoutes } from "./ip-bans/routes";
40
40
  import type { IpAbuseStore } from "../security/abuse";
41
- import { createPerformanceRoutes } from "./observability/performance";
42
41
  import { createUpdateRoutes } from "./updates/routes";
43
42
  import { createRemoteSupportRoutes } from "./remote-support/routes";
44
43
  import { createRemoteSupportService } from "./remote-support/service";
@@ -278,7 +277,6 @@ export function registerConsoleDomains(
278
277
  ...(ctx.stateStore ? { stateStore: ctx.stateStore } : {}),
279
278
  }));
280
279
  console.use(createLogRoutes({ accessResolver: ctx.accessResolver, auditSink: ctx.auditRecorder }));
281
- console.use(createPerformanceRoutes({ accessResolver: ctx.accessResolver }));
282
280
  console.use(createUpdateRoutes({
283
281
  accessResolver: ctx.accessResolver,
284
282
  observabilityStore,
@@ -35,19 +35,12 @@ const FAMILY_STATES: readonly FamilyState[] = [
35
35
  "low_traffic",
36
36
  ];
37
37
 
38
- /** Reads a finite number, or null. Never coerces a missing metric to zero. */
39
- function num(value: unknown): number | null {
40
- if (typeof value === "number" && Number.isFinite(value)) return value;
41
- // Catalog and budgets carry decimal strings; market carries numbers. Both
42
- // are read here so one helper covers the wire's own inconsistency.
43
- if (typeof value === "string" && value.trim().length > 0) {
44
- const parsed = Number(value);
45
- if (Number.isFinite(parsed)) return parsed;
46
- }
47
- return null;
48
- }
49
-
50
- /** Reads a finite number, or null. Never coerces a missing metric to zero. */
38
+ /**
39
+ * Reads a finite number, or null. Never coerces a missing metric to zero.
40
+ *
41
+ * Catalog and budgets carry decimal strings; market carries numbers. Both
42
+ * are read here so one helper covers the wire's own inconsistency.
43
+ */
51
44
  export function numOrNull(value: unknown): number | null {
52
45
  if (typeof value === "number" && Number.isFinite(value)) return value;
53
46
  if (typeof value === "string" && value.trim().length > 0) {
@@ -122,8 +115,8 @@ function pricePointsToTiers(
122
115
  const seen = new Map<number, Tier>();
123
116
  for (const point of points) {
124
117
  if (!Array.isArray(point) || point.length < 2) continue;
125
- const price = num(point[0]);
126
- const avail = num(point[1]);
118
+ const price = numOrNull(point[0]);
119
+ const avail = numOrNull(point[1]);
127
120
  if (price === null || avail === null) continue;
128
121
  const count = Math.max(0, Math.trunc(avail));
129
122
  const key = Math.round(price * 10 ** PRICE_PRECISION) / 10 ** PRICE_PRECISION;
@@ -208,8 +201,8 @@ export function normalizeCatalogModel(
208
201
  const prefix = str(upstream.prefix) ?? "";
209
202
  if (modelSlug === null || prefix.length === 0) return null;
210
203
  const id = `${prefix}/${modelSlug}`;
211
- const officialIn = num(raw.officialIn);
212
- const officialOut = num(raw.officialOut);
204
+ const officialIn = numOrNull(raw.officialIn);
205
+ const officialOut = numOrNull(raw.officialOut);
213
206
  const tiersIn = pricePointsToTiers(raw.pricePointsIn, officialIn);
214
207
  const tiersOut = pricePointsToTiers(raw.pricePointsOut, officialOut);
215
208
  // Live providers is the capacity behind the cheapest tier, not a row count:
@@ -234,10 +227,13 @@ export function normalizeCatalogModel(
234
227
  minAskOut: tiersOut[0]?.price ?? null,
235
228
  maxAskIn: tiersIn.at(-1)?.price ?? null,
236
229
  maxAskOut: tiersOut.at(-1)?.price ?? null,
230
+ // Computed by the route layer before sending; starts null in internal models.
231
+ capacityPriceIn: null,
232
+ capacityPriceOut: null,
237
233
  providersLive,
238
234
  // Not carried by the catalog; merged from `/api/market` by the store.
239
235
  lastRate: null,
240
- cacheHitRate: num(raw.cacheHitRate),
236
+ cacheHitRate: numOrNull(raw.cacheHitRate),
241
237
  health: {
242
238
  successRatePct: null,
243
239
  avgTtftMs: null,
@@ -245,8 +241,8 @@ export function normalizeCatalogModel(
245
241
  familyState: null,
246
242
  },
247
243
  capabilities: {
248
- contextWindow: num(raw.contextWindow),
249
- maxOutputTokens: num(raw.maxOutputTokens),
244
+ contextWindow: numOrNull(raw.contextWindow),
245
+ maxOutputTokens: numOrNull(raw.maxOutputTokens),
250
246
  modalities: stringArray(raw.modalities),
251
247
  outputModality:
252
248
  outputModality === "image" || outputModality === "video" ? outputModality : "text",
@@ -274,13 +270,13 @@ export function mergeMarketPrices(
274
270
  if (entry === undefined) return model;
275
271
  return {
276
272
  ...model,
277
- lastRate: num(entry.lastRate),
273
+ lastRate: numOrNull(entry.lastRate),
278
274
  // Market is the cheaper source for the extremes, but the catalog's tier
279
275
  // grouping is what gives them capacity, so only the nulls are filled.
280
- minAskIn: model.minAskIn ?? num(entry.minAskIn),
281
- minAskOut: model.minAskOut ?? num(entry.minAskOut),
282
- maxAskIn: model.maxAskIn ?? num(entry.maxAskIn),
283
- maxAskOut: model.maxAskOut ?? num(entry.maxAskOut),
276
+ minAskIn: model.minAskIn ?? numOrNull(entry.minAskIn),
277
+ minAskOut: model.minAskOut ?? numOrNull(entry.minAskOut),
278
+ maxAskIn: model.maxAskIn ?? numOrNull(entry.maxAskIn),
279
+ maxAskOut: model.maxAskOut ?? numOrNull(entry.maxAskOut),
284
280
  };
285
281
  });
286
282
  }
@@ -306,9 +302,9 @@ export function mergeModelHealth(
306
302
  ...model,
307
303
  health: {
308
304
  ...model.health,
309
- successRatePct: num(entry.successRatePct),
310
- avgTtftMs: num(entry.avgTtftMs),
311
- avgTps: num(entry.avgTps),
305
+ successRatePct: numOrNull(entry.successRatePct),
306
+ avgTtftMs: numOrNull(entry.avgTtftMs),
307
+ avgTps: numOrNull(entry.avgTps),
312
308
  },
313
309
  };
314
310
  });
@@ -61,6 +61,9 @@ export interface ModelMarket {
61
61
  readonly minAskOut: number | null;
62
62
  readonly maxAskIn: number | null;
63
63
  readonly maxAskOut: number | null;
64
+ /** Capacity-weighted price computed by the route layer; null when no tiers. */
65
+ readonly capacityPriceIn: number | null;
66
+ readonly capacityPriceOut: number | null;
64
67
  /** Zero means no live provider — listed, but never routed to. */
65
68
  readonly providersLive: number;
66
69
  readonly lastRate: number | null;
@@ -10,6 +10,7 @@ import { Elysia, t, type AnyElysia } from "elysia";
10
10
  import { errorResponse, requireGlobalAdmin } from "../shared/errors";
11
11
  import type { IpAbuseStore } from "../../security/abuse";
12
12
  import type { ConsoleAccessResolver } from "../auth/access";
13
+ import { BAN_DURATION_CHOICES } from "../api-keys/ban-constants";
13
14
 
14
15
  /** One active IP ban, as the console returns it. */
15
16
  export interface IpBanResponse {
@@ -26,17 +27,8 @@ export interface IpBanRoutesConfig {
26
27
  readonly store: IpAbuseStore;
27
28
  }
28
29
 
29
- const BAN_DURATION_CHOICES: readonly { readonly minutes: number | null; readonly label: string }[] = [
30
- { minutes: 60, label: "1 hour" },
31
- { minutes: 6 * 60, label: "6 hours" },
32
- { minutes: 12 * 60, label: "12 hours" },
33
- { minutes: 24 * 60, label: "1 day" },
34
- { minutes: 7 * 24 * 60, label: "7 days" },
35
- { minutes: null, label: "Permanent" },
36
- ] as const;
37
-
38
- /** Parses a duration choice into minutes; null = permanent. */
39
- export function parseBanDuration(value: unknown): number | null | undefined {
30
+ /** Parses a duration choice into minutes; null = permanent; undefined = invalid. */
31
+ function parseBanDuration(value: unknown): number | null | undefined {
40
32
  if (value === null || value === undefined) return value === null ? null : undefined;
41
33
  const minutes = Number(value);
42
34
  if (!Number.isFinite(minutes) || minutes <= 0) return undefined;
@@ -68,8 +60,10 @@ export function createIpBanRoutes(config: IpBanRoutesConfig): AnyElysia {
68
60
  const ip = body.ip.trim();
69
61
  if (ip.length === 0)
70
62
  return errorResponse(new Error("ip is required"), set, "Invalid IP ban request");
71
- const minutes = body.minutes === undefined || body.minutes === null ? null : Math.round(body.minutes);
72
- if (minutes !== null && (!Number.isFinite(minutes) || minutes <= 0))
63
+ // Absent is permanent (the default), exactly like an explicit null;
64
+ // parseBanDuration maps those to null and rejects anything else.
65
+ const minutes = body.minutes === undefined ? null : parseBanDuration(body.minutes);
66
+ if (minutes === undefined)
73
67
  return errorResponse(new Error("minutes must be a positive integer or null"), set, "Invalid IP ban request");
74
68
  await store.ban(ip, minutes === null ? 0 : minutes * 60_000);
75
69
  return {
@@ -196,59 +196,6 @@ const TOP_N = 50;
196
196
  */
197
197
  const USAGE_READ_THROUGH_TTL_SECONDS = 30;
198
198
 
199
- /**
200
- * Nearest-rank percentile over a sorted array — kept for the JS-side
201
- * `summarizeUsagePerformance` (still used by callers that already hold a
202
- * bounded event list). The usage endpoints read percentiles from SQL via
203
- * `summarizeUsagePerformanceSql`, which is what the dashboard polls.
204
- */
205
- function percentile(sorted: readonly number[], fraction: number): number {
206
- if (sorted.length === 0) return 0;
207
- const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(fraction * sorted.length) - 1));
208
- return Math.round(sorted[index] ?? 0);
209
- }
210
-
211
- interface UsagePerformanceSummary {
212
- avgResolveMs: number;
213
- avgDurationMs: number;
214
- latencyP50Ms: number;
215
- latencyP95Ms: number;
216
- latencyP99Ms: number;
217
- /** Events each series was computed from; see `UsageSummaryTotals`. */
218
- latencySampleCount: number;
219
- resolveSampleCount: number;
220
- }
221
-
222
- /** Summarizes routing delay and request latency for an already-filtered period. */
223
- export function summarizeUsagePerformance(
224
- events: readonly Pick<TelemetryEventRecord, "latencyMs" | "resolveMs">[],
225
- ): UsagePerformanceSummary {
226
- const latencies: number[] = [];
227
- let latencySum = 0;
228
- let resolveSum = 0;
229
- let resolveCount = 0;
230
- for (const event of events) {
231
- if (event.latencyMs !== null) {
232
- latencies.push(event.latencyMs);
233
- latencySum += event.latencyMs;
234
- }
235
- if (event.resolveMs !== null) {
236
- resolveSum += event.resolveMs;
237
- resolveCount += 1;
238
- }
239
- }
240
- latencies.sort((a, b) => a - b);
241
- return {
242
- avgResolveMs: resolveCount > 0 ? resolveSum / resolveCount : 0,
243
- avgDurationMs: latencies.length > 0 ? latencySum / latencies.length : 0,
244
- latencyP50Ms: percentile(latencies, 0.5),
245
- latencyP95Ms: percentile(latencies, 0.95),
246
- latencyP99Ms: percentile(latencies, 0.99),
247
- latencySampleCount: latencies.length,
248
- resolveSampleCount: resolveCount,
249
- };
250
- }
251
-
252
199
  /**
253
200
  * The console dimension as the SQL reader's grouping key.
254
201
  *
@@ -83,8 +83,8 @@ export interface ProviderAccountBulkActionResult {
83
83
  readonly globalSkipped: number;
84
84
  }
85
85
 
86
- /** Cap on one discovery-commit request (D29). */
87
- export const DISCOVER_MODEL_COMMIT_MAX = 100;
86
+ /** Cap on one discovery-commit request (D29). Kept pure (no `node:*`) so the dashboard can import it. */
87
+ export { DISCOVER_MODEL_COMMIT_MAX } from "./discover-constants";
88
88
 
89
89
  /**
90
90
  * One model the provider's own listing advertised, projected for the Add-Model
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Pure discovery constants, kept free of any `node:*` imports so the
3
+ * dashboard can import them without dragging Node crypto into the browser
4
+ * bundle — the same reason `ban-constants.ts` and `usage-dimensions.ts` exist.
5
+ */
6
+ export const DISCOVER_MODEL_COMMIT_MAX = 100;
@@ -44,6 +44,7 @@ const compatibilityProfileSchema = t.Object({
44
44
  }),
45
45
  ),
46
46
  ),
47
+ service_kinds: t.Optional(t.Array(literalUnion(SERVICE_KINDS))),
47
48
  streaming_usage_mode: t.Optional(t.Union([t.Literal("include_usage"), t.Literal("none")])),
48
49
  structured_output: t.Optional(
49
50
  t.Object({
@@ -1,6 +1,6 @@
1
1
  // Drizzle-backed provider, account, model, routing, and API-key repositories.
2
- import { and, asc, eq, gte, inArray, isNull, or, sql, type SQL } from "drizzle-orm";
3
- import { isSqliteDatabase } from "../../../persistence/dialect";
2
+ import { and, asc, eq, gte, inArray, isNull, ne, or, sql, type SQL } from "drizzle-orm";
3
+ import { liveModelCooldownExistsSql } from "../../../persistence/dialect";
4
4
  import { ConsoleDomainError } from "../../shared/errors";
5
5
  import { parseCustomProviderId, isBundledProviderId } from "../../../providers/provider-contracts";
6
6
  import { providerRateLimitIsIpScoped } from "../../../providers/provider-metadata";
@@ -1697,7 +1697,9 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
1697
1697
  * is evaluated in SQL, so the whole provider is handled in one statement
1698
1698
  * without reading rows into the process. `cooldown_until` and
1699
1699
  * `model_cooldowns` are left untouched: they are the evidence the operator
1700
- * sees on a disabled row until an explicit Recover clears them.
1700
+ * sees on a disabled row until an explicit Recover clears them. A row that
1701
+ * is already `disabled` is not a target — those columns surviving the
1702
+ * status change would otherwise keep matching the predicate forever.
1701
1703
  *
1702
1704
  * Pool-as-account rows live in `provider_pool_accounts`, not this table, so
1703
1705
  * they are excluded by construction. Shared (`tenant_id IS NULL`) rows are
@@ -1790,21 +1792,24 @@ export class DrizzleProviderCatalogStore implements ProviderCatalogStore {
1790
1792
  }
1791
1793
 
1792
1794
  /**
1793
- * `status='cooldown' OR cooldown_until > now OR model_cooldowns` carries a
1794
- * live entry. The JSON check is dialect-specific: PostgreSQL uses
1795
- * `jsonb_each_text` (with a shape guard), SQLite `json_each` on its `text`
1796
- * column. Only entries whose ISO deadline is still in the future count, mirroring
1797
- * `liveModelCooldowns`. Timestamps are stored as ISO strings on both backends,
1798
- * so the lexical comparison against `nowIso` is stable.
1795
+ * Not already disabled, and either `status='cooldown'`, `cooldown_until` is
1796
+ * still in the future, or `model_cooldowns` carries a live entry. Scoped to
1797
+ * the caller's own rows (the tenant path never touches `tenant_id IS NULL`
1798
+ * rows). The shared JSON subquery lives in the dialect module; only the
1799
+ * surrounding scoping is the store's.
1800
+ *
1801
+ * The disabled exclusion is load-bearing. Disable preserves the deadline
1802
+ * columns as evidence, so without it a row this action just wrote matches
1803
+ * the predicate again and the operator's count never drops to zero.
1799
1804
  */
1800
1805
  private liveCooldownPredicate(nowIso: string): SQL {
1801
- const liveModelCooldown = isSqliteDatabase(this.db)
1802
- ? sql`EXISTS (SELECT 1 FROM json_each(${providerAccounts.modelCooldowns}) AS entry WHERE json_valid(${providerAccounts.modelCooldowns}) AND json_type(${providerAccounts.modelCooldowns}) = 'object' AND entry.value > ${nowIso})`
1803
- : sql`EXISTS (SELECT 1 FROM jsonb_each_text(${providerAccounts.modelCooldowns}) AS entry WHERE jsonb_typeof(${providerAccounts.modelCooldowns}) = 'object' AND entry.value > ${nowIso})`;
1804
- return or(
1805
- eq(providerAccounts.status, "cooldown"),
1806
- sql`${providerAccounts.cooldownUntil} > ${nowIso}`,
1807
- liveModelCooldown,
1806
+ return and(
1807
+ ne(providerAccounts.status, "disabled"),
1808
+ or(
1809
+ eq(providerAccounts.status, "cooldown"),
1810
+ sql`${providerAccounts.cooldownUntil} > ${nowIso}`,
1811
+ liveModelCooldownExistsSql(this.db, nowIso),
1812
+ ),
1808
1813
  ) as SQL;
1809
1814
  }
1810
1815
 
@@ -1,7 +1,7 @@
1
1
  import { t } from "elysia";
2
2
  import type { Elysia } from "elysia";
3
3
  import { and, eq, inArray, isNull, sql } from "drizzle-orm";
4
- import { isSqliteDatabase } from "../../persistence/dialect";
4
+ import { liveModelCooldownExistsSql } from "../../persistence/dialect";
5
5
  import { adminAuditLog, providerAccounts } from "../../persistence/schema";
6
6
  import { requireGlobalAdmin } from "../shared/errors";
7
7
  import {
@@ -23,9 +23,8 @@ import {
23
23
  } from "../../providers/quota/refresh";
24
24
  import { sanitizeGlobalAccount } from "./quota-view";
25
25
  import {
26
- ACCOUNT_STATUSES,
26
+ resolveNextAccountStatus,
27
27
  type AccountQuotaRoutesDeps,
28
- type AccountStatus,
29
28
  } from "./account-quota-shared";
30
29
  import type { ProviderAccountBulkActionResult } from "../providers/catalog/contracts";
31
30
 
@@ -33,16 +32,18 @@ import type { ProviderAccountBulkActionResult } from "../providers/catalog/contr
33
32
  * The same live-cooldown predicate as the tenant-scoped store, evaluated in
34
33
  * SQL so one statement covers the whole shared-account set. `status='cooldown'`
35
34
  * counts even when its deadline elapsed; a live `model_cooldowns` entry counts
36
- * even while `status` is still `active` (D07).
35
+ * even while `status` is still `active` (D07). A row already `disabled` does
36
+ * not: disable keeps the deadline columns as evidence, and counting them would
37
+ * make a second pass look like it still has work. Scoped strictly to shared
38
+ * rows (`tenant_id IS NULL`); the shared JSON subquery lives in the dialect
39
+ * module.
37
40
  */
38
41
  function globalLiveCooldownPredicate(database: unknown): ReturnType<typeof and> {
39
42
  const nowIso = new Date().toISOString();
40
- const liveModelCooldown = isSqliteDatabase(database)
41
- ? sql`EXISTS (SELECT 1 FROM json_each(${providerAccounts.modelCooldowns}) AS entry WHERE json_valid(${providerAccounts.modelCooldowns}) AND json_type(${providerAccounts.modelCooldowns}) = 'object' AND entry.value > ${nowIso})`
42
- : sql`EXISTS (SELECT 1 FROM jsonb_each_text(${providerAccounts.modelCooldowns}) AS entry WHERE jsonb_typeof(${providerAccounts.modelCooldowns}) = 'object' AND entry.value > ${nowIso})`;
43
43
  return and(
44
44
  isNull(providerAccounts.tenantId),
45
- sql`${providerAccounts.status} = 'cooldown' OR ${providerAccounts.cooldownUntil} > ${nowIso} OR ${liveModelCooldown}`,
45
+ sql`${providerAccounts.status} <> 'disabled'`,
46
+ sql`${providerAccounts.status} = 'cooldown' OR ${providerAccounts.cooldownUntil} > ${nowIso} OR ${liveModelCooldownExistsSql(database, nowIso)}`,
46
47
  )!;
47
48
  }
48
49
 
@@ -117,23 +118,12 @@ export function registerAccountQuotaGlobalRoutes(
117
118
  },
118
119
  async ({ request, params, body, set }) => {
119
120
  const access = requireGlobalAdmin(accessResolver(request));
120
- if (body.status !== undefined && !ACCOUNT_STATUSES.includes(body.status as AccountStatus)) {
121
- set.status = 400;
122
- return {
123
- error: `status must be one of: ${ACCOUNT_STATUSES.join(", ")}`,
124
- code: "invalid_status",
125
- };
126
- }
127
- const nextStatus: AccountStatus | undefined =
128
- body.active !== undefined
129
- ? body.active
130
- ? "active"
131
- : "disabled"
132
- : (body.status as AccountStatus | undefined);
133
- if (!nextStatus) {
134
- set.status = 400;
135
- return { error: "active or status is required", code: "invalid_request" };
121
+ const resolved = resolveNextAccountStatus(body);
122
+ if (!resolved.ok) {
123
+ set.status = resolved.status;
124
+ return resolved.body;
136
125
  }
126
+ const nextStatus = resolved.nextStatus;
137
127
  const updated = await db
138
128
  .update(providerAccounts)
139
129
  .set({ status: nextStatus })
@@ -20,6 +20,55 @@ export interface AccountQuotaRoutesDeps extends QuotaRefreshDeps {
20
20
  export const ACCOUNT_STATUSES = ["active", "cooldown", "disabled"] as const;
21
21
  export type AccountStatus = (typeof ACCOUNT_STATUSES)[number];
22
22
 
23
+ /**
24
+ * The merged result of interpreting a PATCH account-status body. On success it
25
+ * carries the resolved `AccountStatus`; on failure it carries the exact 400
26
+ * response the tenant and global routes both return.
27
+ */
28
+ export type ResolveNextAccountStatusResult =
29
+ | { readonly ok: true; readonly nextStatus: AccountStatus }
30
+ | {
31
+ readonly ok: false;
32
+ readonly status: 400;
33
+ readonly body: { readonly error: string; readonly code: string };
34
+ };
35
+
36
+ /**
37
+ * Resolves the next account status from a PATCH body: an explicit `active`
38
+ * boolean wins over `status`, and a missing `status` (or an invalid one) is
39
+ * rejected with the shared 400 shape. Both account PATCH handlers interpret
40
+ * the same input contract, so it lives here once.
41
+ */
42
+ export function resolveNextAccountStatus(body: {
43
+ readonly active?: boolean;
44
+ readonly status?: string;
45
+ }): ResolveNextAccountStatusResult {
46
+ if (body.status !== undefined && !ACCOUNT_STATUSES.includes(body.status as AccountStatus)) {
47
+ return {
48
+ ok: false,
49
+ status: 400,
50
+ body: {
51
+ error: `status must be one of: ${ACCOUNT_STATUSES.join(", ")}`,
52
+ code: "invalid_status",
53
+ },
54
+ };
55
+ }
56
+ const nextStatus: AccountStatus | undefined =
57
+ body.active !== undefined
58
+ ? body.active
59
+ ? "active"
60
+ : "disabled"
61
+ : (body.status as AccountStatus | undefined);
62
+ if (nextStatus === undefined) {
63
+ return {
64
+ ok: false,
65
+ status: 400,
66
+ body: { error: "active or status is required", code: "invalid_request" },
67
+ };
68
+ }
69
+ return { ok: true, nextStatus };
70
+ }
71
+
23
72
  /**
24
73
  * Wraps a stored api_key credential in the envelope its collector expects.
25
74
  *
@@ -72,9 +72,8 @@ import {
72
72
  toQuotaView,
73
73
  } from "./quota-view";
74
74
  import {
75
- ACCOUNT_STATUSES,
75
+ resolveNextAccountStatus,
76
76
  type AccountQuotaRoutesDeps,
77
- type AccountStatus,
78
77
  } from "./account-quota-shared";
79
78
  import {
80
79
  DAILY_GROWTH_LEG_TIMEOUT_MS,
@@ -745,23 +744,12 @@ export function registerAccountQuotaTenantRoutes(
745
744
  },
746
745
  async ({ request, params, body, set }) => {
747
746
  const access = requireTenantScope(accessResolver(request), "dashboard:write");
748
- if (body.status !== undefined && !ACCOUNT_STATUSES.includes(body.status as AccountStatus)) {
749
- set.status = 400;
750
- return {
751
- error: `status must be one of: ${ACCOUNT_STATUSES.join(", ")}`,
752
- code: "invalid_status",
753
- };
754
- }
755
- const nextStatus: AccountStatus | undefined =
756
- body.active !== undefined
757
- ? body.active
758
- ? "active"
759
- : "disabled"
760
- : (body.status as AccountStatus | undefined);
761
- if (!nextStatus) {
762
- set.status = 400;
763
- return { error: "active or status is required", code: "invalid_request" };
747
+ const resolved = resolveNextAccountStatus(body);
748
+ if (!resolved.ok) {
749
+ set.status = resolved.status;
750
+ return resolved.body;
764
751
  }
752
+ const nextStatus = resolved.nextStatus;
765
753
  const updated = await db
766
754
  .update(providerAccounts)
767
755
  .set({ status: nextStatus })