cartethyia 2.5.10 → 2.6.1

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 (369) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-wr370U7a.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-wr370U7a.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-D1sCezWa.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-D1sCezWa.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-DVWbJPEQ.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-DVWbJPEQ.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-CZc7fyMe.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-CZc7fyMe.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-OPN3qRCa.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-OPN3qRCa.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-DGTSqmwm.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-DGTSqmwm.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-C_Jd_KgP.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C_Jd_KgP.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-DbQnexQd.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-DbQnexQd.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-ByprzADo.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-ByprzADo.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-Bg9DcG-G.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-Bg9DcG-G.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C2PWPzJm.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C2PWPzJm.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-Dky1zZMt.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-Dky1zZMt.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-C5Vdpd_c.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-C5Vdpd_c.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BNFjUjQ0.js +4 -0
  63. package/dist/dashboard/assets/entry-BNFjUjQ0.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  65. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-DyllNVCY.js} +2 -2
  71. package/dist/dashboard/assets/index-DyllNVCY.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-DAhoTabv.js} +1 -1
  93. package/dist/dashboard/assets/select-DAhoTabv.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +123 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +177 -167
  122. package/scripts/cli/console-menu.ts +45 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +195 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/hidden-window.vbs +16 -2
  136. package/scripts/cli/tray/index.ts +263 -263
  137. package/scripts/cli/tray/windows.ps1 +107 -107
  138. package/scripts/cli/tray.ts +29 -4
  139. package/scripts/cli/update.ts +39 -39
  140. package/scripts/cli/version.ts +67 -67
  141. package/scripts/cli/wordmark.ts +58 -58
  142. package/src/boot/app.ts +16 -1
  143. package/src/boot/dependencies.ts +128 -25
  144. package/src/config/resources.ts +137 -0
  145. package/src/config/runtime.ts +133 -76
  146. package/src/config/spec.ts +30 -29
  147. package/src/config/telemetry.ts +0 -25
  148. package/src/console/api-keys/bucket-spend.ts +14 -17
  149. package/src/console/api-keys/contracts.ts +410 -410
  150. package/src/console/api-keys/routes.ts +12 -0
  151. package/src/console/audit/store.ts +2 -3
  152. package/src/console/auth/setup-service.ts +8 -1
  153. package/src/console/backup/routes.ts +7 -0
  154. package/src/console/backup/service.ts +463 -463
  155. package/src/console/backup/store.ts +16 -11
  156. package/src/console/backup/validate.ts +1 -4
  157. package/src/console/cli-tools/fs-ops.ts +18 -2
  158. package/src/console/data-management/contracts.ts +1 -8
  159. package/src/console/data-management/operations.ts +61 -48
  160. package/src/console/data-management/routes.ts +4 -2
  161. package/src/console/domain-registration.ts +25 -3
  162. package/src/console/inferhub/client.ts +139 -0
  163. package/src/console/inferhub/key-store.ts +129 -0
  164. package/src/console/inferhub/normalize.ts +347 -0
  165. package/src/console/inferhub/routes.ts +411 -0
  166. package/src/console/inferhub/store.ts +109 -0
  167. package/src/console/inferhub/types.ts +183 -0
  168. package/src/console/observability/contracts.ts +2 -41
  169. package/src/console/observability/store.ts +176 -371
  170. package/src/console/providers/catalog/public-model-store.ts +0 -0
  171. package/src/console/providers/catalog/store.ts +23 -36
  172. package/src/console/quota/account-quota-shared.ts +52 -52
  173. package/src/console/quota/account-quota-tenant.ts +16 -24
  174. package/src/console/quota/refresh.ts +441 -441
  175. package/src/console/routing/pools/import-parse.ts +206 -206
  176. package/src/console/routing/pools/operations.ts +543 -543
  177. package/src/console/routing/pools/probe-verdict.ts +132 -132
  178. package/src/console/routing/pools/routes.ts +181 -181
  179. package/src/console/settings/contracts.ts +42 -40
  180. package/src/console/settings/store.ts +25 -25
  181. package/src/console/share/share-stats.ts +22 -4
  182. package/src/console/share/share-usage.ts +30 -4
  183. package/src/console/shared/cursor.ts +15 -0
  184. package/src/console/updates/routes.ts +12 -0
  185. package/src/main.ts +13 -5
  186. package/src/network/pool/resolver.ts +1104 -1104
  187. package/src/observability/client-name.ts +22 -0
  188. package/src/observability/event-log.ts +5 -29
  189. package/src/observability/event-query.ts +690 -0
  190. package/src/observability/log-ring.ts +26 -2
  191. package/src/observability/log-sanitize.ts +118 -0
  192. package/src/observability/logger.ts +17 -13
  193. package/src/observability/metrics.ts +35 -0
  194. package/src/observability/payload-capture.ts +114 -41
  195. package/src/observability/payload-store.ts +249 -24
  196. package/src/observability/raw-event-writer.ts +118 -0
  197. package/src/observability/runtime-metrics.ts +71 -1
  198. package/src/observability/telemetry-buffer.ts +100 -17
  199. package/src/observability/telemetry-reconciliation.ts +417 -0
  200. package/src/persistence/boot.ts +20 -9
  201. package/src/persistence/db-pg.ts +13 -0
  202. package/src/persistence/db-sqlite.ts +569 -497
  203. package/src/persistence/dialect.ts +3 -2
  204. package/src/persistence/migrate.ts +181 -181
  205. package/src/persistence/schema.ts +1496 -1406
  206. package/src/persistence/shared-cache.ts +5 -1
  207. package/src/persistence/stores/api-key-store.ts +581 -581
  208. package/src/persistence/stores/telemetry-store.ts +124 -63
  209. package/src/protocol/reasoning-text.ts +30 -0
  210. package/src/protocol/response/chat.ts +33 -7
  211. package/src/protocol/response/codex.ts +14 -2
  212. package/src/protocol/response/messages.ts +5 -2
  213. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  214. package/src/providers/bundled-discovery-providers.ts +122 -122
  215. package/src/providers/default-registry.ts +10 -0
  216. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  217. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  218. package/src/providers/discovery/probe-phases.ts +43 -7
  219. package/src/providers/discovery/probing-service.ts +70 -32
  220. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  221. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  222. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  223. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  224. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  225. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  226. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  227. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  228. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  229. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  230. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  231. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  232. package/src/providers/integrations/claude/claude.ts +3 -4
  233. package/src/providers/integrations/cline/cline.ts +94 -8
  234. package/src/providers/integrations/codex/codex.ts +61 -37
  235. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  236. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  237. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  238. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  239. package/src/providers/integrations/cursor/cursor.ts +29 -17
  240. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  241. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  242. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  243. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  244. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  245. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  246. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  247. package/src/providers/integrations/hermes.ts +7 -1
  248. package/src/providers/integrations/inferhub.ts +201 -73
  249. package/src/providers/integrations/kilo/kilo.ts +152 -152
  250. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  251. package/src/providers/integrations/openrouter/provider.ts +129 -129
  252. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  253. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  254. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  255. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  256. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  257. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  258. package/src/providers/integrations/zcode/oauth.ts +1 -1
  259. package/src/providers/operations/account-health-service.ts +605 -279
  260. package/src/providers/provider-metadata.ts +12 -1
  261. package/src/providers/provider-model-definition.ts +373 -373
  262. package/src/providers/quota/quota-contracts.ts +3 -7
  263. package/src/providers/search/search-provider.ts +2 -6
  264. package/src/security/admission/contracts.ts +10 -0
  265. package/src/security/admission/in-memory-store.ts +89 -14
  266. package/src/security/admission/lua.ts +191 -182
  267. package/src/security/admission/redis-store.ts +30 -0
  268. package/src/security/admission/service.ts +18 -0
  269. package/src/security/api-key-auth-cache.ts +96 -0
  270. package/src/security/api-key-auth.ts +11 -100
  271. package/src/security/client-router-fingerprint.ts +3 -7
  272. package/src/security/ip-boundary.ts +7 -3
  273. package/src/shared/coerce.ts +67 -0
  274. package/src/shared/hash.ts +6 -0
  275. package/src/shared/time.ts +7 -0
  276. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  277. package/src/transport/dispatch/attempt-loop.ts +103 -81
  278. package/src/transport/dispatch/capture-policy.ts +78 -0
  279. package/src/transport/dispatch/proxy-request.ts +4 -2
  280. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  281. package/src/transport/dispatch/streaming/stream.ts +19 -2
  282. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  283. package/src/transport/failure-policy.ts +27 -0
  284. package/src/transport/guards/abuse-guard.ts +2 -2
  285. package/src/transport/guards/auth-guard.ts +17 -0
  286. package/src/transport/guards/body-policy.ts +3 -2
  287. package/src/transport/guards/concurrency-guard.ts +89 -13
  288. package/src/transport/guards/early-rejection-capture.ts +43 -3
  289. package/src/transport/guards/error-lifecycle.ts +10 -2
  290. package/src/transport/guards/guard-errors.ts +23 -0
  291. package/src/transport/guards/headroom-guard.ts +41 -7
  292. package/src/transport/guards/pipeline.ts +24 -0
  293. package/src/transport/guards/request-context.ts +2 -1
  294. package/src/transport/routing/admission.ts +51 -9
  295. package/src/transport/routing/bounded-lru.ts +49 -0
  296. package/src/transport/routing/candidate-index.ts +88 -0
  297. package/src/transport/routing/reservations.ts +121 -7
  298. package/src/transport/routing/round-robin.ts +10 -22
  299. package/src/transport/routing/route-catalog.ts +26 -13
  300. package/src/transport/routing/route-model.ts +22 -2
  301. package/src/transport/routing/router.ts +53 -31
  302. package/src/transport/surface/chat/encode.ts +2 -3
  303. package/src/transport/surface/chat/parse.ts +2 -1
  304. package/src/transport/surface/dialects.ts +11 -3
  305. package/src/transport/surface/messages/encode.ts +2 -1
  306. package/src/transport/surface/messages/parse.ts +3 -1
  307. package/src/transport/surface/messages/stream.ts +2 -6
  308. package/src/workers/maintenance-lease.ts +200 -0
  309. package/src/workers/scheduler.ts +101 -1
  310. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  312. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  313. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  314. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  315. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  317. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  318. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  319. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  321. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  323. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  324. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  329. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  330. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  331. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  332. package/dist/dashboard/assets/StudioPage-CM92fgJf.js.gz +0 -0
  333. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  334. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  335. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  336. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  337. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  338. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  339. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  340. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  341. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  342. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  343. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  344. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  346. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  347. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  348. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  349. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  350. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  351. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  352. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  353. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  354. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  355. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  356. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  357. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  358. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  359. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  360. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  361. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  362. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  363. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  364. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  365. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  366. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  367. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  368. package/src/observability/event-compaction.ts +0 -317
  369. package/src/observability/event-day-index.ts +0 -439
@@ -0,0 +1,88 @@
1
+ // Immutable per-snapshot candidate index for model resolution.
2
+ import type { RouteCandidate, RouteSnapshot } from "./route-model";
3
+
4
+ /** One parsed model reference: `provider/model` or a bare `model`. */
5
+ export interface ParsedModel {
6
+ readonly providerId: string | null;
7
+ readonly modelId: string;
8
+ }
9
+
10
+ /** Parse one model reference into its provider and bare model id. */
11
+ export function parseModelReference(model: string): ParsedModel {
12
+ const slash = model.indexOf("/");
13
+ if (slash === -1) return { providerId: null, modelId: model };
14
+ return { providerId: model.slice(0, slash), modelId: model.slice(slash + 1) };
15
+ }
16
+
17
+ /**
18
+ * An immutable, per-snapshot index of route candidates.
19
+ *
20
+ * Snapshots are immutable by contract (rebuilt wholesale on catalog change),
21
+ * so the index is built once per snapshot — O(C) — and reused by every plan,
22
+ * instead of the previous behavior of rescanning the full candidate list per
23
+ * resolved model inside one plan: a combo with K unique members over C
24
+ * candidates cost K×C `candidateMatches` calls (each re-parsing the model
25
+ * string), repeated on every request.
26
+ */
27
+ export interface RouteCandidateIndex {
28
+ /** Candidates keyed by `provider_id/model_id`, snapshot order preserved. */
29
+ readonly qualified: ReadonlyMap<string, readonly RouteCandidate[]>;
30
+ /** Candidates keyed by bare `model_id`, snapshot order preserved. */
31
+ readonly bare: ReadonlyMap<string, readonly RouteCandidate[]>;
32
+ }
33
+
34
+ /** WeakMap cache: an evicted/rebuilt snapshot releases its index automatically. */
35
+ const indexBySnapshot = new WeakMap<RouteSnapshot, RouteCandidateIndex>();
36
+
37
+ /**
38
+ * Builds (once per snapshot) the lookup index for `resolveMatchingCandidates`.
39
+ *
40
+ * Each candidate is visited exactly once and filed under both its qualified and
41
+ * bare key, so a lookup later is one `Map.get` regardless of catalog size.
42
+ * Snapshot order is preserved within each bucket because `Map` iterates in
43
+ * insertion order and candidates are appended in one pass.
44
+ */
45
+ export function routeCandidateIndex(snapshot: RouteSnapshot): RouteCandidateIndex {
46
+ const cached = indexBySnapshot.get(snapshot);
47
+ if (cached) return cached;
48
+ const qualified = new Map<string, RouteCandidate[]>();
49
+ const bare = new Map<string, RouteCandidate[]>();
50
+ for (const candidate of snapshot.candidates) {
51
+ const qualifiedKey = `${candidate.provider_id}/${candidate.model_id}`;
52
+ const qualifiedList = qualified.get(qualifiedKey);
53
+ if (qualifiedList) qualifiedList.push(candidate);
54
+ else qualified.set(qualifiedKey, [candidate]);
55
+
56
+ const bareList = bare.get(candidate.model_id);
57
+ if (bareList) bareList.push(candidate);
58
+ else bare.set(candidate.model_id, [candidate]);
59
+ }
60
+ const index: RouteCandidateIndex = { qualified, bare };
61
+ indexBySnapshot.set(snapshot, index);
62
+ return index;
63
+ }
64
+
65
+ /**
66
+ * Looks up the candidates a parsed model reference resolves to, filtered by
67
+ * tenant.
68
+ *
69
+ * A qualified reference (`provider/model`) reads the exact bucket and requires
70
+ * the provider to match; a bare reference reads every provider's bucket for
71
+ * that model. The tenant rule is the resolver's existing one, unchanged:
72
+ * a candidate is visible only when it is global (`tenant_id == null`) or
73
+ * belongs to the requesting tenant.
74
+ */
75
+ export function lookupRouteCandidates(
76
+ index: RouteCandidateIndex,
77
+ parsedModel: ParsedModel,
78
+ tenantId: string | null,
79
+ ): readonly RouteCandidate[] {
80
+ const group =
81
+ parsedModel.providerId === null
82
+ ? index.bare.get(parsedModel.modelId)
83
+ : index.qualified.get(`${parsedModel.providerId}/${parsedModel.modelId}`);
84
+ if (group === undefined || group.length === 0) return [];
85
+ return group.filter(
86
+ (candidate) => candidate.tenant_id == null || candidate.tenant_id === tenantId,
87
+ );
88
+ }
@@ -1,25 +1,136 @@
1
- // In-memory reservation lease store with lazy TTL cleanup.
1
+ // In-memory reservation lease store with expiry-ordered cleanup.
2
2
  import type { Reservation, RouteCandidate, RoutingRevision } from "./route-model";
3
3
 
4
+ /** One heap node: when a lease expires, and which lease it names. */
5
+ interface ExpiryNode {
6
+ readonly expiresAt: number;
7
+ readonly leaseId: string;
8
+ }
9
+
4
10
  /**
5
- * In-memory reservation lease store with lazy TTL cleanup. The bounded sweep
6
- * runs before acquisition so abandoned local metadata cannot grow forever.
11
+ * Binary min-heap on `expiresAt`.
12
+ *
13
+ * The sweep only ever needs the *earliest* expiring reservations, so a heap
14
+ * answers "which nodes are expired right now" by examining heads only — the
15
+ * previous implementation walked the entire lease map per sweep interval.
16
+ * Stale nodes (a lease released before its expiry) are harmless: the sweep
17
+ * verifies against the map before deleting and drops the node.
18
+ */
19
+ class ExpiryHeap {
20
+ private nodes: ExpiryNode[] = [];
21
+
22
+ get size(): number {
23
+ return this.nodes.length;
24
+ }
25
+
26
+ insert(node: ExpiryNode): void {
27
+ this.nodes.push(node);
28
+ this.bubbleUp(this.nodes.length - 1);
29
+ }
30
+
31
+ /** The earliest-expiring node, without removing it. */
32
+ peek(): ExpiryNode | undefined {
33
+ return this.nodes[0];
34
+ }
35
+
36
+ pop(): ExpiryNode | undefined {
37
+ const top = this.nodes[0];
38
+ const last = this.nodes.pop();
39
+ if (this.nodes.length > 0 && last !== undefined) {
40
+ this.nodes[0] = last;
41
+ this.bubbleDown(0);
42
+ }
43
+ return top;
44
+ }
45
+
46
+ private bubbleUp(index: number): void {
47
+ let current = index;
48
+ while (current > 0) {
49
+ const parent = (current - 1) >> 1;
50
+ if (this.nodes[current]!.expiresAt >= this.nodes[parent]!.expiresAt) break;
51
+ [this.nodes[current], this.nodes[parent]] = [this.nodes[parent]!, this.nodes[current]!];
52
+ current = parent;
53
+ }
54
+ }
55
+
56
+ private bubbleDown(index: number): void {
57
+ let current = index;
58
+ for (;;) {
59
+ const left = current * 2 + 1;
60
+ const right = left + 1;
61
+ let smallest = current;
62
+ if (left < this.nodes.length && this.nodes[left]!.expiresAt < this.nodes[smallest]!.expiresAt) {
63
+ smallest = left;
64
+ }
65
+ if (
66
+ right < this.nodes.length &&
67
+ this.nodes[right]!.expiresAt < this.nodes[smallest]!.expiresAt
68
+ ) {
69
+ smallest = right;
70
+ }
71
+ if (smallest === current) break;
72
+ [this.nodes[current], this.nodes[smallest]] = [this.nodes[smallest]!, this.nodes[current]!];
73
+ current = smallest;
74
+ }
75
+ }
76
+ }
77
+
78
+ /**
79
+ * In-memory reservation lease store with expiry-ordered cleanup.
80
+ *
81
+ * Acquisitions insert into an expiry min-heap; the bounded sweep examines only
82
+ * expired heads and verifies the map still holds that exact reservation before
83
+ * deleting (a released entry leaves a harmless stale node behind, dropped on
84
+ * the next sweep that reaches it or by periodic compaction). Stale nodes are
85
+ * compacted when they dominate the heap, so a long stream of short-lived
86
+ * released reservations cannot grow the heap without bound.
87
+ *
88
+ * The sweep interval stays 30 s and the lease TTL stays 30 s by default. Live
89
+ * reservations are not capped — capacity is admission's job, not the lease
90
+ * bookkeeping's. `expires_at` is NOT used to skip admission release: the
91
+ * admission counters protect the real limits and carry their own TTL
92
+ * (`INFLIGHT_TTL_SECONDS`); this map is local bookkeeping only.
7
93
  */
8
94
  export class ReservationManager {
9
95
  private leases = new Map<string, Reservation>();
96
+ private expiry = new ExpiryHeap();
10
97
  private lastSweepAt = 0;
11
98
  private static readonly SWEEP_INTERVAL_MS = 30_000;
12
99
 
100
+ constructor(private readonly now: () => number = Date.now) {}
101
+
13
102
  private sweepExpired(now: number): void {
14
103
  if (now - this.lastSweepAt < ReservationManager.SWEEP_INTERVAL_MS) return;
15
104
  this.lastSweepAt = now;
16
- for (const [leaseId, reservation] of this.leases) {
17
- if (reservation.expires_at < now) this.leases.delete(leaseId);
105
+ // Only expired heads are examined: once the head expires later than `now`,
106
+ // every remaining node does too, so the loop stops without touching them.
107
+ while (this.expiry.size > 0) {
108
+ const head = this.expiry.peek()!;
109
+ if (head.expiresAt >= now) break;
110
+ this.expiry.pop();
111
+ const lease = this.leases.get(head.leaseId);
112
+ // A released reservation is a harmless stale node: nothing to delete.
113
+ // (Lease identity is the unique lease id, so a reused id cannot alias —
114
+ // the id embeds acquisition time plus randomness.)
115
+ if (lease && lease.expires_at === head.expiresAt && lease.expires_at < now) {
116
+ this.leases.delete(head.leaseId);
117
+ }
118
+ }
119
+ // Periodic compaction of stale nodes (entries whose lease was released or
120
+ // re-keyed before expiry): rebuild the heap only when stale nodes make up
121
+ // the bulk of it, so the common all-live case pays nothing.
122
+ if (this.expiry.size > this.leases.size * 2) {
123
+ const live: ExpiryNode[] = [];
124
+ for (const [leaseId, lease] of this.leases) {
125
+ live.push({ expiresAt: lease.expires_at, leaseId });
126
+ }
127
+ this.expiry = new ExpiryHeap();
128
+ for (const node of live) this.expiry.insert(node);
18
129
  }
19
130
  }
20
131
 
21
132
  acquire(candidate: RouteCandidate, _revision: RoutingRevision, ttlMs = 30_000): Reservation {
22
- const now = Date.now();
133
+ const now = this.now();
23
134
  this.sweepExpired(now);
24
135
  const lease_id = `${candidate.provider_id}:${candidate.model_id}:${now}:${Math.random().toString(16).slice(2)}`;
25
136
  const r: Reservation = {
@@ -29,17 +140,20 @@ export class ReservationManager {
29
140
  expires_at: now + ttlMs,
30
141
  };
31
142
  this.leases.set(lease_id, r);
143
+ this.expiry.insert({ expiresAt: r.expires_at, leaseId: lease_id });
32
144
  return r;
33
145
  }
34
146
 
35
147
  release(lease_id: string): void {
148
+ // The heap node stays until its expiry passes — the sweep treats it as
149
+ // stale and drops it. Cheaper than a heap search per release.
36
150
  this.leases.delete(lease_id);
37
151
  }
38
152
 
39
153
  get(lease_id: string): Reservation | undefined {
40
154
  const r = this.leases.get(lease_id);
41
155
  if (!r) return undefined;
42
- if (r.expires_at < Date.now()) {
156
+ if (r.expires_at < this.now()) {
43
157
  this.leases.delete(lease_id);
44
158
  return undefined;
45
159
  }
@@ -1,5 +1,6 @@
1
1
  // Round-robin rotation state and its bounded per-key LRU.
2
2
  import type { RouteCandidate } from "./route-model";
3
+ import { BoundedLruMap } from "./bounded-lru";
3
4
 
4
5
  export class RoundRobinState {
5
6
  private idx = 0;
@@ -22,29 +23,16 @@ export class RoundRobinState {
22
23
  * combinations would retain one entry forever. 1000 is far above the working
23
24
  * set of any real catalog, so eviction only ever targets genuinely cold keys.
24
25
  */
25
- const MAX_ROUND_ROBIN_ENTRIES = 1000;
26
+ export const MAX_ROUND_ROBIN_ENTRIES = 1000;
26
27
 
27
28
  /**
28
- * Bounded LRU accessor for round-robin state: an existing key is refreshed to
29
- * the tail (most-recently-used), and inserting past the cap evicts the head
30
- * (least-recently-used). Keeps the hot routing working set while guaranteeing
31
- * the map cannot grow without bound.
29
+ * Creates the bounded LRU map the router keeps round-robin state in.
30
+ *
31
+ * The bound is the same 1000-entry cap the hand-rolled version enforced;
32
+ * `BoundedLruMap` is now the single implementation of that behavior, so the
33
+ * eviction rule (get refreshes recency, overflow evicts the LRU head) cannot
34
+ * drift between this cache and the capability-profile cache.
32
35
  */
33
- export function getOrCreateRoundRobin(
34
- map: Map<string, RoundRobinState>,
35
- key: string,
36
- ): RoundRobinState {
37
- const existing = map.get(key);
38
- if (existing) {
39
- map.delete(key);
40
- map.set(key, existing);
41
- return existing;
42
- }
43
- if (map.size >= MAX_ROUND_ROBIN_ENTRIES) {
44
- const oldest = map.keys().next().value;
45
- if (oldest !== undefined) map.delete(oldest);
46
- }
47
- const created = new RoundRobinState();
48
- map.set(key, created);
49
- return created;
36
+ export function createRoundRobinMap(): BoundedLruMap<string, RoundRobinState> {
37
+ return new BoundedLruMap<string, RoundRobinState>(MAX_ROUND_ROBIN_ENTRIES);
50
38
  }
@@ -30,6 +30,7 @@ import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metada
30
30
  import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
31
31
  import { isBundledProviderId } from "../../providers/provider-contracts";
32
32
  import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
33
+ import { BoundedLruMap } from "./bounded-lru";
33
34
  import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
34
35
 
35
36
  const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
@@ -156,17 +157,28 @@ export function buildCapabilityProfile(row: {
156
157
  }
157
158
 
158
159
  /**
159
- * The capability profile for one model row, memoized across rebuilds.
160
- *
161
- * Bounded LRU keyed by `providerId/modelId`: the value is a pure function of
162
- * the row's metadata, so two rebuilds that see the same row get the same object
163
- * instead of two identical ones. The bound keeps a long-lived process from
164
- * pinning one profile per row it has ever seen — a model deleted from the
165
- * catalog must not leave its profile behind forever.
160
+ * Upper bound on the capability-profile memo. Same value as the hand-rolled
161
+ * bound it replaces: far above any real catalog, so eviction only ever targets
162
+ * rows deleted from the catalog.
166
163
  */
167
164
  const CAPABILITY_PROFILE_CACHE_MAX_ENTRIES = 4096;
168
- const capabilityProfileCache = new Map<string, Record<string, boolean>>();
165
+ const capabilityProfileCache = new BoundedLruMap<string, Record<string, boolean>>(
166
+ CAPABILITY_PROFILE_CACHE_MAX_ENTRIES,
167
+ );
169
168
 
169
+ /**
170
+ * The capability profile for one model row, memoized across rebuilds.
171
+ *
172
+ * The value is a pure function of the row's metadata, so two rebuilds that see
173
+ * the same row get the same object instead of two identical ones. The cache is
174
+ * a {@link BoundedLruMap} keyed by `providerId/modelId` **plus the row's
175
+ * modalities**: the key must change when the operator edits a row's modalities
176
+ * (text-only → vision), otherwise the rebuild would hand routing the stale
177
+ * pre-edit profile until the entry aged out of the LRU. A row that has never
178
+ * carried modality metadata keys with `null`, which only collides with another
179
+ * metadata-less row for the same provider/model — the same profile by
180
+ * construction.
181
+ */
170
182
  function capabilityProfileFor(row: {
171
183
  modalities: unknown;
172
184
  reasoning: boolean;
@@ -174,14 +186,10 @@ function capabilityProfileFor(row: {
174
186
  providerId: string;
175
187
  modelId: string;
176
188
  }): Record<string, boolean> {
177
- const key = `${row.providerId}/${row.modelId}`;
189
+ const key = `${row.providerId}/${row.modelId}/${JSON.stringify(row.modalities ?? null)}`;
178
190
  const cached = capabilityProfileCache.get(key);
179
191
  if (cached !== undefined) return cached;
180
192
  const profile = buildCapabilityProfile(row);
181
- if (capabilityProfileCache.size >= CAPABILITY_PROFILE_CACHE_MAX_ENTRIES) {
182
- const oldest = capabilityProfileCache.keys().next().value;
183
- if (oldest !== undefined) capabilityProfileCache.delete(oldest);
184
- }
185
193
  capabilityProfileCache.set(key, profile);
186
194
  return profile;
187
195
  }
@@ -421,10 +429,14 @@ class RouteCatalogRepository {
421
429
  * preference's own doc.
422
430
  */
423
431
  const cooldownOverrideTenants = new Set<string>();
432
+ const invokeDisabledTenants = new Set<string>();
424
433
  for (const row of settingsRows ?? []) {
425
434
  if (row.preferences?.forceDisableCooldown === true && row.tenantId) {
426
435
  cooldownOverrideTenants.add(row.tenantId);
427
436
  }
437
+ if (row.preferences?.invokeDisabled === true && row.tenantId) {
438
+ invokeDisabledTenants.add(row.tenantId);
439
+ }
428
440
  }
429
441
  /**
430
442
  * A shared account has no tenant of its own (`tenant_id IS NULL`), so a
@@ -843,6 +855,7 @@ class RouteCatalogRepository {
843
855
  combos,
844
856
  ...(routingMap ? { providerRouting: routingMap } : {}),
845
857
  ...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
858
+ ...(invokeDisabledTenants.size > 0 ? { invokeDisabledTenants } : {}),
846
859
  };
847
860
  }
848
861
  }
@@ -199,6 +199,15 @@ export interface RouteSnapshot {
199
199
  readonly providerRouting?: ProviderRoutingMap;
200
200
  /** Optional per-tenant pool selection strategy (absent = `least_loaded`). */
201
201
  readonly poolRouting?: Readonly<Record<string, PoolRoutingSetting>>;
202
+ /**
203
+ * Tenants whose invoke switch is off, so `/v1/*` must be refused outright.
204
+ *
205
+ * Read from the cached snapshot rather than per request: it is a console
206
+ * preference write, and the snapshot is already invalidated when one lands, so
207
+ * the switch takes effect on the next request without adding a query to the
208
+ * proxy path.
209
+ */
210
+ readonly invokeDisabledTenants?: ReadonlySet<string>;
202
211
  }
203
212
 
204
213
  export type EligibilityReason =
@@ -247,8 +256,19 @@ export interface AdmissionDecision {
247
256
  export interface AdmissionController {
248
257
  admit(candidate: RouteCandidate): Promise<AdmissionDecision>;
249
258
  release(reservation: Reservation): Promise<void>;
250
- /** Observable current counters by admission bucket (`provider:model[:account]`). */
251
- snapshotAccountInflight?(): Promise<ReadonlyMap<string, number>> | ReadonlyMap<string, number>;
259
+ /**
260
+ * Current inflight counts for exactly the buckets the given candidates draw
261
+ * from, keyed by the unprefixed admission bucket (`provider:model[:account]`).
262
+ *
263
+ * Required, not optional: the previous fleet-wide
264
+ * `snapshotAccountInflight()` was the only reason `plan()` could issue a
265
+ * Redis `KEYS` on the request path. Scoped reads make the ordering cost
266
+ * proportional to the candidates being ordered, and an empty input must be
267
+ * answerable with zero backend commands.
268
+ */
269
+ readAccountInflight(
270
+ candidates: readonly RouteCandidate[],
271
+ ): Promise<ReadonlyMap<string, number>> | ReadonlyMap<string, number>;
252
272
  }
253
273
 
254
274
  /**
@@ -25,24 +25,17 @@ import {
25
25
  import { planAllowsCandidate } from "./plan-gate";
26
26
  import { InMemoryAdmissionController, admissionKey } from "./admission";
27
27
  import { ReservationManager } from "./reservations";
28
- import { getOrCreateRoundRobin, RoundRobinState } from "./round-robin";
28
+ import { createRoundRobinMap, RoundRobinState } from "./round-robin";
29
+ import {
30
+ lookupRouteCandidates,
31
+ parseModelReference,
32
+ routeCandidateIndex,
33
+ } from "./candidate-index";
29
34
  import { totalRemainingCredit, totalRemainingPercent, type ProviderQuotaWindow } from "../../providers/quota/quota-contracts";
30
35
  import { getCachedQuotaEntry, quotaLens } from "../../console/quota/cache";
31
36
  import type { RedisClient } from "../../persistence/redis";
32
37
  import { sharedClaudeSlowLanes } from "../../providers/integrations/claude/claude-slow-lane";
33
38
 
34
- function parseModel(model: string): { providerId: string | null; bare: string } {
35
- const slash = model.indexOf("/");
36
- if (slash === -1) return { providerId: null, bare: model };
37
- return { providerId: model.slice(0, slash), bare: model.slice(slash + 1) };
38
- }
39
-
40
- function candidateMatches(candidate: RouteCandidate, model: string): boolean {
41
- const parsed = parseModel(model);
42
- if (parsed.providerId === null) return candidate.model_id === parsed.bare;
43
- return candidate.model_id === parsed.bare && candidate.provider_id === parsed.providerId;
44
- }
45
-
46
39
  /**
47
40
  * True when the account's remaining allowance is at or below its provider's
48
41
  * floor. Credit mode sums the absolute remaining credit; percent mode uses the
@@ -352,8 +345,8 @@ export class EligibilityEvaluator {
352
345
  }
353
346
 
354
347
  export class RoutingEngine {
355
- private readonly _roundRobinByCombo = new Map<string, RoundRobinState>();
356
- private readonly _roundRobinByProvider = new Map<string, RoundRobinState>();
348
+ private readonly _roundRobinByCombo = createRoundRobinMap();
349
+ private readonly _roundRobinByProvider = createRoundRobinMap();
357
350
  private readonly eligibility = new EligibilityEvaluator();
358
351
  private readonly reservations = new ReservationManager();
359
352
 
@@ -363,11 +356,19 @@ export class RoutingEngine {
363
356
  ) {}
364
357
 
365
358
  private getRoundRobin(key: string): RoundRobinState {
366
- return getOrCreateRoundRobin(this._roundRobinByCombo, key);
359
+ const existing = this._roundRobinByCombo.get(key);
360
+ if (existing) return existing;
361
+ const created = new RoundRobinState();
362
+ this._roundRobinByCombo.set(key, created);
363
+ return created;
367
364
  }
368
365
 
369
366
  private getProviderRoundRobin(key: string): RoundRobinState {
370
- return getOrCreateRoundRobin(this._roundRobinByProvider, key);
367
+ const existing = this._roundRobinByProvider.get(key);
368
+ if (existing) return existing;
369
+ const created = new RoundRobinState();
370
+ this._roundRobinByProvider.set(key, created);
371
+ return created;
371
372
  }
372
373
 
373
374
  /** Observable bounded-collection sizes for the runtime metrics sampler. */
@@ -375,11 +376,20 @@ export class RoutingEngine {
375
376
  return { combo: this._roundRobinByCombo.size, provider: this._roundRobinByProvider.size };
376
377
  }
377
378
 
378
- /** Live admission counters by `provider:model[:account]` bucket. */
379
- async accountInflightSnapshot(): Promise<ReadonlyMap<string, number>> {
380
- // Both controllers hand back a fresh map, so no defensive copy here —
381
- // this feeds an admin read, not a mutation site.
382
- return (await this.admission.snapshotAccountInflight?.()) ?? new Map();
379
+ /**
380
+ * Live admission counters for the buckets the given candidates draw from.
381
+ *
382
+ * Takes candidates rather than returning a fleet-wide snapshot: the scoped
383
+ * read is what keeps `plan()` off Redis `KEYS` (the fleet-wide read had no
384
+ * other implementation) and bounds the console's per-provider read to the
385
+ * accounts it will actually report on. Both controllers hand back a fresh
386
+ * map, so no defensive copy here — this feeds an admin read, not a mutation
387
+ * site.
388
+ */
389
+ async accountInflightSnapshot(
390
+ candidates: readonly RouteCandidate[],
391
+ ): Promise<ReadonlyMap<string, number>> {
392
+ return this.admission.readAccountInflight(candidates);
383
393
  }
384
394
 
385
395
  /**
@@ -402,7 +412,12 @@ export class RoutingEngine {
402
412
  (candidate) => typeof candidate.max_inflight === "number" && candidate.max_inflight > 0,
403
413
  );
404
414
  if (bounded.length === 0) return new Set();
405
- const snapshot = await this.accountInflightSnapshot().catch(() => new Map<string, number>());
415
+ // Only the bounded candidates can be saturated, so only their buckets are
416
+ // read. A failed ordering read must never fail routing — the admission
417
+ // attempt itself is the real gate; this read only optimizes attempt order.
418
+ const snapshot = await this.accountInflightSnapshot(bounded).catch(
419
+ () => new Map<string, number>(),
420
+ );
406
421
  if (snapshot.size === 0) return new Set();
407
422
  const saturated = new Set<RouteCandidate>();
408
423
  for (const candidate of bounded) {
@@ -792,16 +807,23 @@ export class RoutingEngine {
792
807
  ? combo.members.flatMap((name) => expandMember(name, new Set([resolved.model])))
793
808
  : [resolved.model];
794
809
  const modelIds = [...new Set(rawModelIds)];
795
- let matching = snapshot.candidates.filter(
796
- (candidate) =>
797
- modelIds.some((id) => candidateMatches(candidate, id)) &&
798
- (candidate.tenant_id == null || candidate.tenant_id === tid),
799
- );
810
+ // One index build per snapshot (cached on the immutable snapshot), one
811
+ // lookup per unique resolved model. The previous shape rescanned every
812
+ // candidate per model — O(C×K) string parses per plan — which was the
813
+ // quadratic cost the catalog rebuild had already paid once and the request
814
+ // path was paying again on every plan.
815
+ const index = routeCandidateIndex(snapshot);
816
+ const parsedModels = modelIds.map((id) => ({ id, parsed: parseModelReference(id) }));
817
+ let matching = parsedModels.flatMap((entry) => lookupRouteCandidates(index, entry.parsed, tid));
818
+ // Preserve the resolver's flatten-then-dedupe contract: one candidate can
819
+ // serve two member groups only via a combo listing the same model twice,
820
+ // and the plan must not dial it twice.
821
+ matching = [...new Set(matching)];
800
822
  let unmatchedMembers: readonly string[] = [];
801
823
  if (combo) {
802
- const perId = modelIds.map((id) => ({
803
- id,
804
- group: matching.filter((c) => candidateMatches(c, id)),
824
+ const perId = parsedModels.map((entry) => ({
825
+ id: entry.id,
826
+ group: lookupRouteCandidates(index, entry.parsed, tid),
805
827
  }));
806
828
  unmatchedMembers = perId.filter((entry) => entry.group.length === 0).map((entry) => entry.id);
807
829
  const groups = perId.map((entry) => entry.group).filter((g) => g.length > 0);
@@ -1,6 +1,7 @@
1
1
  import type { CanonicalEvent, CanonicalStopReason } from "../../../protocol/canonical-model";
2
2
  import { jsonrepair } from "jsonrepair";
3
3
  import { reasoningSummaryIdentity, reasoningSummarySeparator, type ReasoningSummaryIdentity } from "../reasoning-summary";
4
+ import { reasoningTextOf } from "../../../protocol/reasoning-text";
4
5
  import { eventText } from "../adapters";
5
6
  import { isRecord } from "../../../protocol/codecs";
6
7
  import {
@@ -113,9 +114,7 @@ export function eventAudio(event: CanonicalEvent): Record<string, unknown> | und
113
114
 
114
115
  export function eventReasoningText(event: CanonicalEvent): string | undefined {
115
116
  if (event.type !== "content_delta" || event.content.kind !== "reasoning") return undefined;
116
- if (event.content.opaque === true) return undefined;
117
- if (typeof event.content.payload === "string") return event.content.payload;
118
- return typeof event.content.summary === "string" ? event.content.summary : undefined;
117
+ return reasoningTextOf(event.content);
119
118
  }
120
119
 
121
120
  function mergedReasoningText(events: readonly CanonicalEvent[]): string {
@@ -1,6 +1,7 @@
1
1
  import type { CanonicalMessage, ContentPart, GenerationControls } from "../../../protocol/canonical-model";
2
2
  import type { SurfaceInput } from "../adapters";
3
3
  import { isRecord } from "../../../protocol/codecs";
4
+ import { asNumber } from "../../../shared/coerce";
4
5
  import { textPart, normalizeInlineFile } from "../content-parts";
5
6
  import { SERVICE_TIERS } from "../dialects";
6
7
  import { readNumber, readString } from "../../../protocol/codecs";
@@ -75,7 +76,7 @@ export function requestBody(input: unknown): unknown {
75
76
  }
76
77
 
77
78
  export function nonNegativeNumber(value: unknown): number | undefined {
78
- const number = typeof value === "number" && Number.isFinite(value) ? value : undefined;
79
+ const number = asNumber(value);
79
80
  return number !== undefined && number >= 0 ? number : undefined;
80
81
  }
81
82
 
@@ -71,11 +71,14 @@ function parseToolObject(item: Record<string, unknown>, dialect: ToolDialect): T
71
71
  } else {
72
72
  const functionValue = item["function"];
73
73
  schema = isRecord(functionValue)
74
- ? functionValue["parameters"] ?? functionValue["jsonSchema"] ?? {}
74
+ ? functionValue["parameters"] ??
75
+ functionValue["jsonSchema"] ??
76
+ functionValue["parametersJsonSchema"] ??
77
+ {}
75
78
  : // The Responses flat spelling (`{type:"function", parameters:…}`) is
76
79
  // common here too; without this a tool arrived with an empty schema
77
80
  // and the upstream rejected it.
78
- item["parameters"] ?? item["input_schema"] ?? {};
81
+ item["parameters"] ?? item["input_schema"] ?? item["parametersJsonSchema"] ?? {};
79
82
  }
80
83
  } else if (dialect === "responses") {
81
84
  // A Responses caller may still send the Chat-shaped envelope — the two
@@ -88,8 +91,13 @@ function parseToolObject(item: Record<string, unknown>, dialect: ToolDialect): T
88
91
  schema =
89
92
  item["input_schema"] ??
90
93
  item["parameters"] ??
94
+ item["parametersJsonSchema"] ??
91
95
  (wrapped !== undefined
92
- ? wrapped["parameters"] ?? wrapped["input_schema"] ?? wrapped["jsonSchema"] ?? {}
96
+ ? wrapped["parameters"] ??
97
+ wrapped["input_schema"] ??
98
+ wrapped["jsonSchema"] ??
99
+ wrapped["parametersJsonSchema"] ??
100
+ {}
93
101
  : item);
94
102
  } else {
95
103
  schema = item["input_schema"] ?? item["inputSchema"] ?? {};
@@ -1,6 +1,7 @@
1
1
  import type { CanonicalEvent, CanonicalStopReason, ContentPart, UsageRecord } from "../../../protocol/canonical-model";
2
2
  import { assertBoundedJsonDepth, isRecord } from "../../../protocol/codecs";
3
3
  import { messagesStopReason } from "../../../protocol/stop-reason";
4
+ import { reasoningTextOf } from "../../../protocol/reasoning-text";
4
5
  import {
5
6
  reasoningSummaryHasBoundary,
6
7
  reasoningSummaryIdentity,
@@ -111,7 +112,7 @@ export function blockFromPart(part: ContentPart): MessagesWireObject | undefined
111
112
  : { type: "redacted_thinking", data: part.payload };
112
113
  return {
113
114
  type: "thinking",
114
- thinking: part.summary ?? (typeof part.payload === "string" ? part.payload : ""),
115
+ thinking: reasoningTextOf(part) ?? "",
115
116
  ...(part.signature === undefined ? {} : { signature: part.signature }),
116
117
  };
117
118
  }
@@ -145,8 +145,10 @@ export function parseBlock(value: unknown): ContentPart {
145
145
  const thinking = stringValue(value.thinking) ? value.thinking : "";
146
146
  return {
147
147
  kind: "reasoning",
148
+ // Readable thinking lives in `payload` only. Duplicating it into
149
+ // `summary` too emitted the same text twice on surfaces that carry both
150
+ // channels (Responses `reasoning_text` + `reasoning_summary_text`).
148
151
  payload: thinking,
149
- summary: thinking,
150
152
  ...(stringValue(value.signature) ? { signature: value.signature } : {}),
151
153
  };
152
154
  }