cartethyia 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -1,73 +1,73 @@
1
- // Where a Grok rate-limit reading lives between dispatch and display.
2
- //
3
- // The bucket headers come back on **every** upstream response, so the cheap
4
- // place to learn them is the traffic the gateway is already sending — not a
5
- // probe of its own. A dispatch that reads and stores them costs nothing extra;
6
- // a probe costs a request every time quota is shown.
7
- //
8
- // So this store is fed by dispatch (manual test or automatic routing alike) and
9
- // read by the quota collector. The collector only probes when there is nothing
10
- // stored, which is the first read after a start and never after that.
11
- //
12
- // Keyed by account id: two Grok accounts carry different buckets, and a reading
13
- // filed against the wrong one is worse than no reading at all.
14
-
15
- import type { ProviderQuotaWindow } from "../../quota/quota-contracts";
16
- import { grokHeaderRequestsWindow, grokHeaderWindow, type GrokRateLimitReading } from "./grok-ratelimit";
17
-
18
- /**
19
- * How long a stored reading stays useful.
20
- *
21
- * The header is eventually consistent, so a fresh value is not meaningfully
22
- * better than one from a few minutes ago — but a value from last week says
23
- * nothing about now. Five minutes is short enough to follow real traffic and
24
- * long enough that a burst of console refreshes costs no requests.
25
- */
26
- const SNAPSHOT_TTL_MS = 5 * 60 * 1000;
27
-
28
- interface Snapshot {
29
- readonly windows: readonly ProviderQuotaWindow[];
30
- readonly capturedAt: number;
31
- }
32
-
33
- const snapshots = new Map<string, Snapshot>();
34
-
35
- /** Files a reading captured from a real dispatch against one account. */
36
- export function rememberGrokRateLimit(
37
- accountId: string,
38
- reading: GrokRateLimitReading,
39
- now = Date.now(),
40
- ): void {
41
- const windows = [grokHeaderWindow(reading), grokHeaderRequestsWindow(reading)].filter(
42
- (value): value is ProviderQuotaWindow => value !== null,
43
- );
44
- if (windows.length === 0) return;
45
- snapshots.set(accountId, { windows, capturedAt: now });
46
- if (snapshots.size > 4096) {
47
- // Bounded: an unbounded map keyed by account is a leak dressed as a cache.
48
- for (const [key, value] of snapshots) {
49
- if (now - value.capturedAt > SNAPSHOT_TTL_MS) snapshots.delete(key);
50
- }
51
- }
52
- }
53
-
54
- /** The stored window for one account, or `null` when none is fresh enough. */
55
- export function storedGrokRateLimit(accountId: string, now = Date.now()): readonly ProviderQuotaWindow[] | null {
56
- const snapshot = snapshots.get(accountId);
57
- if (!snapshot) return null;
58
- if (now - snapshot.capturedAt > SNAPSHOT_TTL_MS) {
59
- snapshots.delete(accountId);
60
- return null;
61
- }
62
- return snapshot.windows;
63
- }
64
-
65
- /** Whether a fresh reading exists, so the collector can skip its probe. */
66
- export function hasFreshGrokRateLimit(accountId: string, now = Date.now()): boolean {
67
- return storedGrokRateLimit(accountId, now) !== null;
68
- }
69
-
70
- /** Test seam. */
71
- export function clearGrokRateLimitSnapshots(): void {
72
- snapshots.clear();
73
- }
1
+ // Where a Grok rate-limit reading lives between dispatch and display.
2
+ //
3
+ // The bucket headers come back on **every** upstream response, so the cheap
4
+ // place to learn them is the traffic the gateway is already sending — not a
5
+ // probe of its own. A dispatch that reads and stores them costs nothing extra;
6
+ // a probe costs a request every time quota is shown.
7
+ //
8
+ // So this store is fed by dispatch (manual test or automatic routing alike) and
9
+ // read by the quota collector. The collector only probes when there is nothing
10
+ // stored, which is the first read after a start and never after that.
11
+ //
12
+ // Keyed by account id: two Grok accounts carry different buckets, and a reading
13
+ // filed against the wrong one is worse than no reading at all.
14
+
15
+ import type { ProviderQuotaWindow } from "../../quota/quota-contracts";
16
+ import { grokHeaderRequestsWindow, grokHeaderWindow, type GrokRateLimitReading } from "./grok-ratelimit";
17
+
18
+ /**
19
+ * How long a stored reading stays useful.
20
+ *
21
+ * The header is eventually consistent, so a fresh value is not meaningfully
22
+ * better than one from a few minutes ago — but a value from last week says
23
+ * nothing about now. Five minutes is short enough to follow real traffic and
24
+ * long enough that a burst of console refreshes costs no requests.
25
+ */
26
+ const SNAPSHOT_TTL_MS = 5 * 60 * 1000;
27
+
28
+ interface Snapshot {
29
+ readonly windows: readonly ProviderQuotaWindow[];
30
+ readonly capturedAt: number;
31
+ }
32
+
33
+ const snapshots = new Map<string, Snapshot>();
34
+
35
+ /** Files a reading captured from a real dispatch against one account. */
36
+ export function rememberGrokRateLimit(
37
+ accountId: string,
38
+ reading: GrokRateLimitReading,
39
+ now = Date.now(),
40
+ ): void {
41
+ const windows = [grokHeaderWindow(reading), grokHeaderRequestsWindow(reading)].filter(
42
+ (value): value is ProviderQuotaWindow => value !== null,
43
+ );
44
+ if (windows.length === 0) return;
45
+ snapshots.set(accountId, { windows, capturedAt: now });
46
+ if (snapshots.size > 4096) {
47
+ // Bounded: an unbounded map keyed by account is a leak dressed as a cache.
48
+ for (const [key, value] of snapshots) {
49
+ if (now - value.capturedAt > SNAPSHOT_TTL_MS) snapshots.delete(key);
50
+ }
51
+ }
52
+ }
53
+
54
+ /** The stored window for one account, or `null` when none is fresh enough. */
55
+ export function storedGrokRateLimit(accountId: string, now = Date.now()): readonly ProviderQuotaWindow[] | null {
56
+ const snapshot = snapshots.get(accountId);
57
+ if (!snapshot) return null;
58
+ if (now - snapshot.capturedAt > SNAPSHOT_TTL_MS) {
59
+ snapshots.delete(accountId);
60
+ return null;
61
+ }
62
+ return snapshot.windows;
63
+ }
64
+
65
+ /** Whether a fresh reading exists, so the collector can skip its probe. */
66
+ export function hasFreshGrokRateLimit(accountId: string, now = Date.now()): boolean {
67
+ return storedGrokRateLimit(accountId, now) !== null;
68
+ }
69
+
70
+ /** Test seam. */
71
+ export function clearGrokRateLimitSnapshots(): void {
72
+ snapshots.clear();
73
+ }
@@ -1,218 +1,218 @@
1
- // Grok's two quota numbers, and why they disagree.
2
- //
3
- // The CLI plane answers every response with a token bucket:
4
- //
5
- // x-ratelimit-limit-tokens: 1000000
6
- // x-ratelimit-remaining-tokens: 996169
7
- // x-ratelimit-remaining-requests: 20
8
- //
9
- // and that bucket is *eventually consistent*. Right after a request it can
10
- // still read a full `1000000` and only drop later. Worse, the figure it
11
- // advertises is not the figure that binds: the real free allowance for a model
12
- // like `grok-4.6` is 600k tokens per rolling 24 hours, and the only place
13
- // upstream states it is the body of a 429:
14
- //
15
- // {"code":"subscription:free-usage-exhausted",
16
- // "error":"... tokens (actual/limit): 603024/600000 ... Usage resets over a
17
- // rolling 24-hour window"}
18
- //
19
- // So there are two readings with different authority. The header is available
20
- // on every call and is the coarse indicator; the 429 body is only available
21
- // once the account is actually being refused, and is the authoritative one.
22
- //
23
- // They are therefore kept as separate windows rather than merged: a merged
24
- // number would look precise and be neither. The header window is listed first
25
- // because it is what a caller sees day to day, and the authoritative one is
26
- // appended beneath it.
27
-
28
- import type { ProviderQuotaWindow } from "../../quota/quota-contracts";
29
- import { isoDate, number, percentWindow, record, text } from "../../quota/quota-contracts";
30
-
31
- /** Header names the CLI plane uses for its token bucket. */
32
- const HEADER_LIMIT_TOKENS = "x-ratelimit-limit-tokens";
33
- const HEADER_REMAINING_TOKENS = "x-ratelimit-remaining-tokens";
34
- const HEADER_REMAINING_REQUESTS = "x-ratelimit-remaining-requests";
35
- const HEADER_RESET_TOKENS = "x-ratelimit-reset-tokens";
36
-
37
- /** The upstream code that carries the authoritative free-usage allowance. */
38
- export const GROK_FREE_USAGE_EXHAUSTED = "subscription:free-usage-exhausted";
39
-
40
- /** Kinds, so the view can tell an indicator from a verdict. */
41
- export const GROK_HEADER_WINDOW_KIND = "grok:cli-header";
42
- export const GROK_HEADER_REQUESTS_WINDOW_KIND = "grok:cli-header-requests";
43
- export const GROK_AUTHORITATIVE_WINDOW_KIND = "grok:free-usage-authoritative";
44
-
45
- /** The per-minute request cap the CLI plane applies. */
46
- const REQUEST_WINDOW_LIMIT = 21;
47
-
48
- /** One reading of the CLI token bucket. */
49
- export interface GrokRateLimitReading {
50
- readonly limitTokens: number | null;
51
- readonly remainingTokens: number | null;
52
- readonly remainingRequests: number | null;
53
- readonly resetsAt: string | null;
54
- }
55
-
56
- /** A `Headers`-like reader, narrowed at the boundary rather than asserted. */
57
- function headerReader(
58
- headers: { get(name: string): string | null } | Record<string, string>,
59
- ): (name: string) => string | null {
60
- if (typeof headers.get === "function") {
61
- const reader = headers as { get(name: string): string | null };
62
- return (name) => reader.get(name);
63
- }
64
- const literal = headers as Record<string, string>;
65
- return (name) => literal[name] ?? null;
66
- }
67
-
68
- /**
69
- * Reads the CLI token bucket off a response.
70
- *
71
- * `Headers` and a plain object both reach here — the quota probe gets the real
72
- * thing, a test gets a literal — so both shapes are accepted rather than
73
- * forcing one caller to adapt.
74
- */
75
- export function readGrokRateLimitHeaders(
76
- headers: { get(name: string): string | null } | Record<string, string>,
77
- ): GrokRateLimitReading {
78
- const get = headerReader(headers);
79
- const resetSeconds = number(get(HEADER_RESET_TOKENS));
80
- return {
81
- limitTokens: number(get(HEADER_LIMIT_TOKENS)),
82
- remainingTokens: number(get(HEADER_REMAINING_TOKENS)),
83
- remainingRequests: number(get(HEADER_REMAINING_REQUESTS)),
84
- resetsAt:
85
- isoDate(get(HEADER_RESET_TOKENS)) ??
86
- (resetSeconds !== null && resetSeconds > 0
87
- ? new Date(Date.now() + resetSeconds * 1000).toISOString()
88
- : null),
89
- };
90
- }
91
-
92
- /**
93
- * The header bucket as a quota window.
94
- *
95
- * Marked independent: this is the CLI request bucket, a different budget from
96
- * the free-usage allowance, so it must never be summed into a credit total.
97
- */
98
- export function grokHeaderWindow(reading: GrokRateLimitReading): ProviderQuotaWindow | null {
99
- if (reading.remainingTokens === null && reading.limitTokens === null) return null;
100
- const used =
101
- reading.remainingTokens !== null && reading.limitTokens !== null
102
- ? reading.limitTokens - reading.remainingTokens
103
- : null;
104
- // The third argument is the *percentage*, not the count. Passing null left
105
- // `usedPercent` null, and the bar renders a null percentage as grey — so a
106
- // window that knew its exact used and limit still drew as unknown.
107
- const usedPercent =
108
- reading.limitTokens !== null && reading.limitTokens > 0 && used !== null
109
- ? (used / reading.limitTokens) * 100
110
- : null;
111
- return percentWindow(
112
- GROK_HEADER_WINDOW_KIND,
113
- "Rate limit token (header)",
114
- usedPercent,
115
- reading.resetsAt,
116
- used,
117
- reading.limitTokens,
118
- "independent",
119
- );
120
- }
121
-
122
- /**
123
- * The request bucket as its own window.
124
- *
125
- * Separate from the token bucket on purpose: `x-ratelimit-remaining-requests`
126
- * is a per-minute cap (21) with no token equivalent, and folding it into the
127
- * token window produced one number that described neither budget.
128
- */
129
- export function grokHeaderRequestsWindow(
130
- reading: GrokRateLimitReading,
131
- ): ProviderQuotaWindow | null {
132
- if (reading.remainingRequests === null) return null;
133
- const used = REQUEST_WINDOW_LIMIT - reading.remainingRequests;
134
- const usedPercent = (used / REQUEST_WINDOW_LIMIT) * 100;
135
- return percentWindow(
136
- GROK_HEADER_REQUESTS_WINDOW_KIND,
137
- "Rate limit request (header)",
138
- usedPercent,
139
- reading.resetsAt,
140
- used,
141
- REQUEST_WINDOW_LIMIT,
142
- "independent",
143
- );
144
- }
145
-
146
- /** The authoritative allowance a 429 states, parsed out of its message. */
147
- export interface GrokFreeUsageVerdict {
148
- readonly actual: number | null;
149
- readonly limit: number | null;
150
- readonly resetsAt: string | null;
151
- readonly rolling: boolean;
152
- }
153
-
154
- /**
155
- * Parses `tokens (actual/limit): 603024/600000` out of a refusal.
156
- *
157
- * The numbers are in prose, not in a field, so this is a text match — but the
158
- * shape is the only place upstream publishes the real allowance, and a refusal
159
- * that does not carry it yields `null` rather than a guess.
160
- */
161
- export function parseGrokFreeUsageVerdict(body: unknown, message?: string): GrokFreeUsageVerdict | null {
162
- const root = record(body) ?? {};
163
- const error = text(root["error"] ?? root["message"]) ?? message ?? "";
164
- const haystack = `${error} ${text(root["code"]) ?? ""}`;
165
- const match = /tokens\s*\(actual\/limit\)\s*:?\s*([\d_,]+)\s*\/\s*([\d_,]+)/i.exec(haystack);
166
- const actual = match?.[1] ? number(match[1].replaceAll(/[_,]/g, "")) : null;
167
- const limit = match?.[2] ? number(match[2].replaceAll(/[_,]/g, "")) : null;
168
- if (actual === null && limit === null) {
169
- // A refusal without the figures still tells us the code; it just cannot
170
- // tell us the allowance.
171
- if (!haystack.toLowerCase().includes("free-usage") && !haystack.toLowerCase().includes("rolling 24")) return null;
172
- }
173
- const rolling = /rolling\s*24[\s-]*hour/i.test(haystack);
174
- const resetMatch = /resets?\s+(?:over|in|at)\s+([^.;]{0,60})/i.exec(haystack);
175
- return {
176
- actual,
177
- limit,
178
- resetsAt: resetMatch ? isoDate(resetMatch[1]) ?? null : null,
179
- rolling,
180
- };
181
- }
182
-
183
- /**
184
- * The authoritative allowance as a quota window.
185
- *
186
- * `remaining` is what decides whether the account can serve, so it is derived
187
- * rather than left null: an account that has spent 603024 of 600000 has none
188
- * left, and reporting "unknown" there would leave a refused account looking
189
- * merely unmeasured.
190
- */
191
- export function grokAuthoritativeWindow(verdict: GrokFreeUsageVerdict): ProviderQuotaWindow | null {
192
- if (verdict.limit === null && verdict.actual === null) return null;
193
- return percentWindow(
194
- GROK_AUTHORITATIVE_WINDOW_KIND,
195
- "Free usage allowance (authoritative)",
196
- null,
197
- verdict.resetsAt,
198
- verdict.actual,
199
- verdict.limit,
200
- // Independent: the free allowance is per model per rolling day, not part of
201
- // a pooled credit balance.
202
- "independent",
203
- );
204
- }
205
-
206
- /** One probe's two readings: the coarse header and the authoritative refusal. */
207
- export interface GrokRateLimitProbe {
208
- /** The CLI token bucket from the response headers. */
209
- readonly header: ProviderQuotaWindow | null;
210
- /** The per-minute request bucket, which has no token equivalent. */
211
- readonly requests: ProviderQuotaWindow | null;
212
- /** The free-usage allowance from a 429 body, or `null` when not refused. */
213
- readonly authoritative: ProviderQuotaWindow | null;
214
- /** Whether upstream refused the probe at all. */
215
- readonly refusal: boolean;
216
- /** The refusal code, for the console note. */
217
- readonly note: string | null;
218
- }
1
+ // Grok's two quota numbers, and why they disagree.
2
+ //
3
+ // The CLI plane answers every response with a token bucket:
4
+ //
5
+ // x-ratelimit-limit-tokens: 1000000
6
+ // x-ratelimit-remaining-tokens: 996169
7
+ // x-ratelimit-remaining-requests: 20
8
+ //
9
+ // and that bucket is *eventually consistent*. Right after a request it can
10
+ // still read a full `1000000` and only drop later. Worse, the figure it
11
+ // advertises is not the figure that binds: the real free allowance for a model
12
+ // like `grok-4.6` is 600k tokens per rolling 24 hours, and the only place
13
+ // upstream states it is the body of a 429:
14
+ //
15
+ // {"code":"subscription:free-usage-exhausted",
16
+ // "error":"... tokens (actual/limit): 603024/600000 ... Usage resets over a
17
+ // rolling 24-hour window"}
18
+ //
19
+ // So there are two readings with different authority. The header is available
20
+ // on every call and is the coarse indicator; the 429 body is only available
21
+ // once the account is actually being refused, and is the authoritative one.
22
+ //
23
+ // They are therefore kept as separate windows rather than merged: a merged
24
+ // number would look precise and be neither. The header window is listed first
25
+ // because it is what a caller sees day to day, and the authoritative one is
26
+ // appended beneath it.
27
+
28
+ import type { ProviderQuotaWindow } from "../../quota/quota-contracts";
29
+ import { isoDate, number, percentWindow, record, text } from "../../quota/quota-contracts";
30
+
31
+ /** Header names the CLI plane uses for its token bucket. */
32
+ const HEADER_LIMIT_TOKENS = "x-ratelimit-limit-tokens";
33
+ const HEADER_REMAINING_TOKENS = "x-ratelimit-remaining-tokens";
34
+ const HEADER_REMAINING_REQUESTS = "x-ratelimit-remaining-requests";
35
+ const HEADER_RESET_TOKENS = "x-ratelimit-reset-tokens";
36
+
37
+ /** The upstream code that carries the authoritative free-usage allowance. */
38
+ export const GROK_FREE_USAGE_EXHAUSTED = "subscription:free-usage-exhausted";
39
+
40
+ /** Kinds, so the view can tell an indicator from a verdict. */
41
+ export const GROK_HEADER_WINDOW_KIND = "grok:cli-header";
42
+ export const GROK_HEADER_REQUESTS_WINDOW_KIND = "grok:cli-header-requests";
43
+ export const GROK_AUTHORITATIVE_WINDOW_KIND = "grok:free-usage-authoritative";
44
+
45
+ /** The per-minute request cap the CLI plane applies. */
46
+ const REQUEST_WINDOW_LIMIT = 21;
47
+
48
+ /** One reading of the CLI token bucket. */
49
+ export interface GrokRateLimitReading {
50
+ readonly limitTokens: number | null;
51
+ readonly remainingTokens: number | null;
52
+ readonly remainingRequests: number | null;
53
+ readonly resetsAt: string | null;
54
+ }
55
+
56
+ /** A `Headers`-like reader, narrowed at the boundary rather than asserted. */
57
+ function headerReader(
58
+ headers: { get(name: string): string | null } | Record<string, string>,
59
+ ): (name: string) => string | null {
60
+ if (typeof headers.get === "function") {
61
+ const reader = headers as { get(name: string): string | null };
62
+ return (name) => reader.get(name);
63
+ }
64
+ const literal = headers as Record<string, string>;
65
+ return (name) => literal[name] ?? null;
66
+ }
67
+
68
+ /**
69
+ * Reads the CLI token bucket off a response.
70
+ *
71
+ * `Headers` and a plain object both reach here — the quota probe gets the real
72
+ * thing, a test gets a literal — so both shapes are accepted rather than
73
+ * forcing one caller to adapt.
74
+ */
75
+ export function readGrokRateLimitHeaders(
76
+ headers: { get(name: string): string | null } | Record<string, string>,
77
+ ): GrokRateLimitReading {
78
+ const get = headerReader(headers);
79
+ const resetSeconds = number(get(HEADER_RESET_TOKENS));
80
+ return {
81
+ limitTokens: number(get(HEADER_LIMIT_TOKENS)),
82
+ remainingTokens: number(get(HEADER_REMAINING_TOKENS)),
83
+ remainingRequests: number(get(HEADER_REMAINING_REQUESTS)),
84
+ resetsAt:
85
+ isoDate(get(HEADER_RESET_TOKENS)) ??
86
+ (resetSeconds !== null && resetSeconds > 0
87
+ ? new Date(Date.now() + resetSeconds * 1000).toISOString()
88
+ : null),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * The header bucket as a quota window.
94
+ *
95
+ * Marked independent: this is the CLI request bucket, a different budget from
96
+ * the free-usage allowance, so it must never be summed into a credit total.
97
+ */
98
+ export function grokHeaderWindow(reading: GrokRateLimitReading): ProviderQuotaWindow | null {
99
+ if (reading.remainingTokens === null && reading.limitTokens === null) return null;
100
+ const used =
101
+ reading.remainingTokens !== null && reading.limitTokens !== null
102
+ ? reading.limitTokens - reading.remainingTokens
103
+ : null;
104
+ // The third argument is the *percentage*, not the count. Passing null left
105
+ // `usedPercent` null, and the bar renders a null percentage as grey — so a
106
+ // window that knew its exact used and limit still drew as unknown.
107
+ const usedPercent =
108
+ reading.limitTokens !== null && reading.limitTokens > 0 && used !== null
109
+ ? (used / reading.limitTokens) * 100
110
+ : null;
111
+ return percentWindow(
112
+ GROK_HEADER_WINDOW_KIND,
113
+ "Rate limit token (header)",
114
+ usedPercent,
115
+ reading.resetsAt,
116
+ used,
117
+ reading.limitTokens,
118
+ "independent",
119
+ );
120
+ }
121
+
122
+ /**
123
+ * The request bucket as its own window.
124
+ *
125
+ * Separate from the token bucket on purpose: `x-ratelimit-remaining-requests`
126
+ * is a per-minute cap (21) with no token equivalent, and folding it into the
127
+ * token window produced one number that described neither budget.
128
+ */
129
+ export function grokHeaderRequestsWindow(
130
+ reading: GrokRateLimitReading,
131
+ ): ProviderQuotaWindow | null {
132
+ if (reading.remainingRequests === null) return null;
133
+ const used = REQUEST_WINDOW_LIMIT - reading.remainingRequests;
134
+ const usedPercent = (used / REQUEST_WINDOW_LIMIT) * 100;
135
+ return percentWindow(
136
+ GROK_HEADER_REQUESTS_WINDOW_KIND,
137
+ "Rate limit request (header)",
138
+ usedPercent,
139
+ reading.resetsAt,
140
+ used,
141
+ REQUEST_WINDOW_LIMIT,
142
+ "independent",
143
+ );
144
+ }
145
+
146
+ /** The authoritative allowance a 429 states, parsed out of its message. */
147
+ export interface GrokFreeUsageVerdict {
148
+ readonly actual: number | null;
149
+ readonly limit: number | null;
150
+ readonly resetsAt: string | null;
151
+ readonly rolling: boolean;
152
+ }
153
+
154
+ /**
155
+ * Parses `tokens (actual/limit): 603024/600000` out of a refusal.
156
+ *
157
+ * The numbers are in prose, not in a field, so this is a text match — but the
158
+ * shape is the only place upstream publishes the real allowance, and a refusal
159
+ * that does not carry it yields `null` rather than a guess.
160
+ */
161
+ export function parseGrokFreeUsageVerdict(body: unknown, message?: string): GrokFreeUsageVerdict | null {
162
+ const root = record(body) ?? {};
163
+ const error = text(root["error"] ?? root["message"]) ?? message ?? "";
164
+ const haystack = `${error} ${text(root["code"]) ?? ""}`;
165
+ const match = /tokens\s*\(actual\/limit\)\s*:?\s*([\d_,]+)\s*\/\s*([\d_,]+)/i.exec(haystack);
166
+ const actual = match?.[1] ? number(match[1].replaceAll(/[_,]/g, "")) : null;
167
+ const limit = match?.[2] ? number(match[2].replaceAll(/[_,]/g, "")) : null;
168
+ if (actual === null && limit === null) {
169
+ // A refusal without the figures still tells us the code; it just cannot
170
+ // tell us the allowance.
171
+ if (!haystack.toLowerCase().includes("free-usage") && !haystack.toLowerCase().includes("rolling 24")) return null;
172
+ }
173
+ const rolling = /rolling\s*24[\s-]*hour/i.test(haystack);
174
+ const resetMatch = /resets?\s+(?:over|in|at)\s+([^.;]{0,60})/i.exec(haystack);
175
+ return {
176
+ actual,
177
+ limit,
178
+ resetsAt: resetMatch ? isoDate(resetMatch[1]) ?? null : null,
179
+ rolling,
180
+ };
181
+ }
182
+
183
+ /**
184
+ * The authoritative allowance as a quota window.
185
+ *
186
+ * `remaining` is what decides whether the account can serve, so it is derived
187
+ * rather than left null: an account that has spent 603024 of 600000 has none
188
+ * left, and reporting "unknown" there would leave a refused account looking
189
+ * merely unmeasured.
190
+ */
191
+ export function grokAuthoritativeWindow(verdict: GrokFreeUsageVerdict): ProviderQuotaWindow | null {
192
+ if (verdict.limit === null && verdict.actual === null) return null;
193
+ return percentWindow(
194
+ GROK_AUTHORITATIVE_WINDOW_KIND,
195
+ "Free usage allowance (authoritative)",
196
+ null,
197
+ verdict.resetsAt,
198
+ verdict.actual,
199
+ verdict.limit,
200
+ // Independent: the free allowance is per model per rolling day, not part of
201
+ // a pooled credit balance.
202
+ "independent",
203
+ );
204
+ }
205
+
206
+ /** One probe's two readings: the coarse header and the authoritative refusal. */
207
+ export interface GrokRateLimitProbe {
208
+ /** The CLI token bucket from the response headers. */
209
+ readonly header: ProviderQuotaWindow | null;
210
+ /** The per-minute request bucket, which has no token equivalent. */
211
+ readonly requests: ProviderQuotaWindow | null;
212
+ /** The free-usage allowance from a 429 body, or `null` when not refused. */
213
+ readonly authoritative: ProviderQuotaWindow | null;
214
+ /** Whether upstream refused the probe at all. */
215
+ readonly refusal: boolean;
216
+ /** The refusal code, for the console note. */
217
+ readonly note: string | null;
218
+ }
@@ -92,7 +92,13 @@ export const HERMES_MODELS: readonly ModelDefinition[] = [
92
92
  defineModel({ id: "stepfun/step-3.7-flash:free", ctx: 262144, out: 256000, vision: true, reasoning: true, free: true, freeTier: true }),
93
93
  defineModel({ id: "upstage/solar-mini4:free", ctx: 524288, out: 131072, reasoning: true, free: true, freeTier: true }),
94
94
  defineModel({ id: "reflection/beam-1.0-501b-a23b:free", ctx: 262144, out: 131072, reasoning: true, free: true, freeTier: true }),
95
- defineModel({ id: "meituan/longcat-2.5-preview:free", ctx: 1048576, out: 131072, reasoning: true, free: true, freeTier: true }),
95
+ // Vision-corrected against the live catalog: `architecture.input_modalities`
96
+ // lists `image` for this row, so it was published without vision it has.
97
+ defineModel({ id: "meituan/longcat-2.5-preview:free", ctx: 1048576, out: 131072, vision: true, reasoning: true, free: true, freeTier: true }),
98
+ // Added after a live read: the catalog carries it, it is free, and a dispatch
99
+ // answers 200 — it was simply absent from this list, so a client asking for
100
+ // it resolved to nothing. 262144 out is the catalog's own cap.
101
+ defineModel({ id: "ornith/ornith-1.5-122b:free", ctx: 262144, out: 262144, vision: true, reasoning: true, free: true, freeTier: true }),
96
102
  ];
97
103
 
98
104