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
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Thinking-loop guard for streamed reasoning.
3
+ *
4
+ * A model that falls into a degenerate reasoning loop re-emits the same
5
+ * paragraph — byte-identical or with cosmetic wording drift — until it burns
6
+ * the whole output budget without ever answering. The stream never goes silent,
7
+ * so the only protection this codebase had (`streamStallTimeoutMs`) cannot fire:
8
+ * it watches for *absence* of bytes, and a runaway emits them continuously.
9
+ *
10
+ * This is a narrow port of the donor `ThinkingLoopDetector`
11
+ * (`Public/oh-my-pi/packages/ai/src/utils/thinking-loop.ts`). Ported:
12
+ * 1. **Exact suffix cycles** — a byte-identical unit repeated back-to-back.
13
+ * Model-independent and calibration-free.
14
+ * 2. **Near-duplicate segments** — paragraphs that normalize to the same
15
+ * word-trigram fingerprint.
16
+ *
17
+ * Deliberately NOT ported: the donor's progress-lexicon novelty detector (its
18
+ * constants were calibrated against a 536k-block corpus we do not have) and its
19
+ * Gemini-specific summary-header runaway. Both would be uncalibrated guesses
20
+ * here and are better added later, with their corpora, if the shapes are
21
+ * actually observed.
22
+ *
23
+ * The detector is fed one reasoning delta at a time and returns a
24
+ * human-readable reason the first time a loop shape is recognized; every later
25
+ * call returns the same reason so a caller that does not stop stays harmless.
26
+ * Ported constants are copied verbatim — they are calibration values, not
27
+ * design choices.
28
+ */
29
+
30
+ /** Stable lead phrase of the guard's reason; exported for tests and for the
31
+ * terminal event's `provider_stop_reason`. */
32
+ export const THINKING_LOOP_MARKER = "Thinking loop detected";
33
+
34
+ /** Rolling tail retained for exact suffix-cycle detection. */
35
+ const EXACT_TAIL_WINDOW = 4096;
36
+ /** Longest exact cycle length considered. */
37
+ const EXACT_MAX_UNIT = 1024;
38
+ /** New characters between scans. Large deltas are scanned immediately. */
39
+ const EXACT_CHECK_STRIDE = 128;
40
+ /** Short cycles need four repeats covering at least this many characters. */
41
+ const EXACT_SHORT_MAX_UNIT = 60;
42
+ const EXACT_SHORT_MIN_REPEATED_CHARS = 180;
43
+ /** Long cycles need at least three repeats covering at least this many chars. */
44
+ const EXACT_LONG_MIN_REPEATED_CHARS = 1024;
45
+ /** Repeated characters before a punctuation-only run counts as a loop. Matches
46
+ * {@link EXACT_LONG_MIN_REPEATED_CHARS}: a kilobyte of one punctuation cycle
47
+ * back-to-back is degenerate, while the short separators real output produces
48
+ * (table rules, dividers) never reach it. */
49
+ const EXACT_PUNCTUATION_MIN_REPEATED_CHARS = EXACT_LONG_MIN_REPEATED_CHARS;
50
+
51
+ /** Char cap for an unterminated segment; forces a flush so a wall-of-text loop
52
+ * (no blank lines / headings) still segments. */
53
+ const SEGMENT_CHAR_CAP = 700;
54
+ /** Normalized-length floor below which a segment is ignored (too short to be a
55
+ * meaningful paragraph; bare headings must not trip detection). */
56
+ const SEGMENT_MIN_NORM_CHARS = 60;
57
+ /** How many recent substantial segments are kept for similarity comparison. */
58
+ const SEGMENT_WINDOW = 16;
59
+ /** Word-trigram Jaccard at/above which two segments count as near-duplicates. */
60
+ const SEGMENT_SIMILARITY = 0.8;
61
+ /** Substantial segments required before detection may fire (warm-up). */
62
+ const SEGMENT_MIN_COUNT = 8;
63
+ /** Near-duplicate cluster size (current + matches) that trips the loop. */
64
+ const SEGMENT_MIN_CLUSTER = 4;
65
+
66
+ /** A whole line shaped like code rather than prose: a fence marker, an indented
67
+ * line that is not a nested list item, or a line ending in a block, statement,
68
+ * or markup delimiter. Classified per line rather than by fence state because
69
+ * thought summaries routinely end inside an unclosed fence; tracking fences
70
+ * would hide every later prose paragraph from the heuristics. */
71
+ const CODE_LINE = /^(?:[ \t]*```.*|(?: {2,}|\t)(?![ \t]*(?:[-*+]|\d+[.)])[ \t]).*|.*[{}[\](;,>][ \t]*)$/gm;
72
+
73
+ export interface ThinkingLoopConfig {
74
+ readonly enabled?: boolean;
75
+ }
76
+
77
+ /**
78
+ * Stateful detector fed the streamed reasoning deltas of one stream. `push`
79
+ * returns a human-readable reason the first time a loop shape is recognized and
80
+ * the same reason on every later call, so a caller that keeps feeding after the
81
+ * first hit cannot double-report.
82
+ */
83
+ export class ThinkingLoopDetector {
84
+ /** Rolling char tail for exact suffix-cycle detection. */
85
+ #tail = "";
86
+ /** Total characters received when the exact detector last scanned. */
87
+ #exactScannedAt = 0;
88
+ /** Pending reasoning text not yet split into completed segments. */
89
+ #pending = "";
90
+ /** Fingerprints of the most recent substantial segments (≤ SEGMENT_WINDOW). */
91
+ #window: Set<string>[] = [];
92
+ /** Count of substantial segments seen so far (warm-up gate). */
93
+ #count = 0;
94
+ /** The reason produced by the first hit; latched for idempotent re-reporting. */
95
+ #reason: string | undefined;
96
+
97
+ constructor(private readonly config: ThinkingLoopConfig = {}) {}
98
+
99
+ /** Whether a loop has already been detected. */
100
+ get detected(): boolean {
101
+ return this.#reason !== undefined;
102
+ }
103
+
104
+ /** Feeds one reasoning delta. Returns the reason on and after the first hit. */
105
+ push(delta: string): string | undefined {
106
+ if (this.#reason !== undefined) return this.#reason;
107
+ if (this.config.enabled === false) return undefined;
108
+ if (delta.length === 0) return undefined;
109
+
110
+ // 1. Exact suffix cycles. Scan at a bounded cadence rather than doing
111
+ // quadratic work for every token-sized delta.
112
+ this.#tail += delta;
113
+ if (this.#tail.length > EXACT_TAIL_WINDOW) this.#tail = this.#tail.slice(-EXACT_TAIL_WINDOW);
114
+ this.#exactScannedAt += delta.length;
115
+ if (this.#exactScannedAt >= EXACT_CHECK_STRIDE || delta.length >= EXACT_CHECK_STRIDE) {
116
+ this.#exactScannedAt = 0;
117
+ const exact = detectExactSuffixCycle(this.#tail);
118
+ if (exact) {
119
+ const [unit, times] = exact;
120
+ this.#reason = `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
121
+ return this.#reason;
122
+ }
123
+ }
124
+
125
+ // 2. Near-duplicate paragraph loop. Append, then drain completed segments.
126
+ this.#pending += delta;
127
+ for (;;) {
128
+ const boundary = /\n\s*\n/.exec(this.#pending);
129
+ let raw: string;
130
+ if (boundary) {
131
+ raw = this.#pending.slice(0, boundary.index);
132
+ this.#pending = this.#pending.slice(boundary.index + boundary[0].length);
133
+ } else if (this.#pending.length > SEGMENT_CHAR_CAP) {
134
+ // No boundary yet but the segment is runaway-long: force a flush.
135
+ raw = this.#pending.slice(0, SEGMENT_CHAR_CAP);
136
+ this.#pending = this.#pending.slice(SEGMENT_CHAR_CAP);
137
+ } else {
138
+ return undefined;
139
+ }
140
+ // An over-long segment is chunked so each piece stays comparable.
141
+ for (let rest = raw; rest.length > 0; ) {
142
+ const chunk = rest.length > SEGMENT_CHAR_CAP ? rest.slice(0, SEGMENT_CHAR_CAP) : rest;
143
+ rest = rest.slice(chunk.length);
144
+ const hit = this.#consumeSegment(chunk);
145
+ if (hit) {
146
+ this.#reason = hit;
147
+ return this.#reason;
148
+ }
149
+ }
150
+ }
151
+ }
152
+
153
+ /** Process the buffered trailing paragraph (one with no blank-line / heading
154
+ * terminator). Called when the reasoning block ends so the final segment —
155
+ * which may be the one that completes a duplicate cluster — is not dropped. */
156
+ flush(): string | undefined {
157
+ if (this.#reason !== undefined) return this.#reason;
158
+ if (this.config.enabled === false) return undefined;
159
+ // A stream can end before the next cadence boundary: force one final exact
160
+ // check even when nothing is pending.
161
+ const exact = detectExactSuffixCycle(this.#tail);
162
+ if (exact) {
163
+ const [unit, times] = exact;
164
+ this.#reason = `repeated an exact ${unit.length}-character cycle ${times}× back-to-back`;
165
+ return this.#reason;
166
+ }
167
+ if (this.#pending.length === 0) return undefined;
168
+ let rest = this.#pending;
169
+ this.#pending = "";
170
+ while (rest.length > 0) {
171
+ const chunk = rest.length > SEGMENT_CHAR_CAP ? rest.slice(0, SEGMENT_CHAR_CAP) : rest;
172
+ rest = rest.slice(chunk.length);
173
+ const hit = this.#consumeSegment(chunk);
174
+ if (hit) {
175
+ this.#reason = hit;
176
+ return this.#reason;
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ #consumeSegment(raw: string): string | undefined {
183
+ // Reasoning-summarizer titles ("**Maintaining Momentum**", "## Heading")
184
+ // are per-thought formatting, not chain-of-thought; their ever-changing
185
+ // wording would otherwise mask a loop by inflating novelty. Strip them and
186
+ // code lines before analysis (a title- or code-only segment then falls below
187
+ // the length gate).
188
+ const segment = raw
189
+ .replace(CODE_LINE, "")
190
+ .replace(/^[ \t]*#{1,6}[ \t].*$/gm, "")
191
+ .replace(/^[ \t]*\*{2,3}.+?\*{2,3}[ \t]*$/gm, "");
192
+ const normalized = normalizeSegment(segment);
193
+ if (normalized.length < SEGMENT_MIN_NORM_CHARS) return undefined;
194
+
195
+ // Near-duplicate trigram cluster: the same paragraph reused with cosmetic
196
+ // wording drift (high word-trigram overlap).
197
+ const fingerprint = trigramShingles(normalized);
198
+ let cluster = 1;
199
+ for (const prev of this.#window) {
200
+ if (jaccard(fingerprint, prev) >= SEGMENT_SIMILARITY) cluster++;
201
+ }
202
+
203
+ this.#window.push(fingerprint);
204
+ if (this.#window.length > SEGMENT_WINDOW) this.#window.shift();
205
+ this.#count++;
206
+
207
+ if (this.#count >= SEGMENT_MIN_COUNT && cluster >= SEGMENT_MIN_CLUSTER) {
208
+ return `${cluster} near-identical segments within the last ${SEGMENT_WINDOW}`;
209
+ }
210
+ return undefined;
211
+ }
212
+ }
213
+
214
+ /** Reused Z-array scratch; scans are synchronous, so one buffer suffices. */
215
+ const exactZ = new Uint16Array(EXACT_MAX_UNIT + 1);
216
+
217
+ /**
218
+ * Detect an exact cycle at the text suffix. A Z-array over the reversed tail
219
+ * finds every possible suffix period in linear time without substring churn.
220
+ * The reversal is virtual (indexed from the end) and only `z[1..maxUnit]` is
221
+ * computed — the period check reads nothing past it, and each `z[i]` reads
222
+ * only `z[i - left]` with `i - left < i`, which is already filled. Short
223
+ * cycles retain the original 180-character/four-repeat sensitivity; long
224
+ * cycles require at least three repeats and 1024 repeated characters.
225
+ *
226
+ * A unit carrying a letter or an emoji matches at those thresholds. A unit with
227
+ * neither, such as the `?!?!` run a stuck model emits, matches only once it has
228
+ * repeated for {@link EXACT_PUNCTUATION_MIN_REPEATED_CHARS} characters, and never
229
+ * when it holds only digits and whitespace: padding, indentation and zero-filled
230
+ * hexdumps or literals are ordinary output at any length.
231
+ */
232
+ function detectExactSuffixCycle(text: string): [unit: string, count: number] | undefined {
233
+ const n = text.length;
234
+ if (n < EXACT_SHORT_MIN_REPEATED_CHARS) return undefined;
235
+ const last = n - 1;
236
+ const maxUnit = Math.min(EXACT_MAX_UNIT, Math.floor(n / 3));
237
+ const z = exactZ;
238
+ let left = 0;
239
+ let right = 0;
240
+ for (let i = 1; i <= maxUnit; i++) {
241
+ let zi = i <= right ? Math.min(right - i + 1, z[i - left]!) : 0;
242
+ // `z[i - left]` is always filled: `i - left < i` and `left >= 0`.
243
+ while (i + zi < n && text.charCodeAt(last - zi) === text.charCodeAt(last - i - zi)) zi++;
244
+ z[i] = zi;
245
+ if (i + zi - 1 > right) {
246
+ left = i;
247
+ right = i + zi - 1;
248
+ }
249
+ }
250
+
251
+ for (let len = 2; len <= maxUnit; len++) {
252
+ // Every `z[len]` in this range was written by the loop above.
253
+ const count = 1 + Math.floor(z[len]! / len);
254
+ const minCount = len <= EXACT_SHORT_MAX_UNIT ? 4 : 3;
255
+ if (count < minCount) continue;
256
+ const unit = text.slice(-len);
257
+ if (/\p{L}|\p{Extended_Pictographic}/u.test(unit)) {
258
+ const minChars = len <= EXACT_SHORT_MAX_UNIT ? EXACT_SHORT_MIN_REPEATED_CHARS : EXACT_LONG_MIN_REPEATED_CHARS;
259
+ if (len * count >= minChars) return [unit, count];
260
+ continue;
261
+ }
262
+ if (/^[\p{N}\s]*$/u.test(unit)) continue;
263
+ if (len * count >= EXACT_PUNCTUATION_MIN_REPEATED_CHARS) return [unit, count];
264
+ }
265
+ return undefined;
266
+ }
267
+
268
+ /** Lowercase and tokenize prose plus code/path payloads, dropping pure numbers. */
269
+ function normalizeSegment(segment: string): string {
270
+ return segment
271
+ .toLowerCase()
272
+ .replace(/`([^`]*)`/g, " $1 ")
273
+ .replace(/[^a-z0-9]+/g, " ")
274
+ .split(/\s+/)
275
+ .filter((token) => /[a-z]/.test(token))
276
+ .join(" ")
277
+ .trim();
278
+ }
279
+
280
+ /** Word-trigram shingle set of a normalized segment. */
281
+ function trigramShingles(normalized: string): Set<string> {
282
+ const words = normalized.split(" ").filter(Boolean);
283
+ if (words.length < 3) return new Set(words.length > 0 ? [words.join(" ")] : []);
284
+ const shingles = new Set<string>();
285
+ for (let i = 0; i + 3 <= words.length; i++) {
286
+ shingles.add(`${words[i]} ${words[i + 1]} ${words[i + 2]}`);
287
+ }
288
+ return shingles;
289
+ }
290
+
291
+ function jaccard(a: Set<string>, b: Set<string>): number {
292
+ if (a.size === 0 || b.size === 0) return 0;
293
+ const [small, large] = a.size < b.size ? [a, b] : [b, a];
294
+ let intersection = 0;
295
+ for (const x of small) {
296
+ if (large.has(x)) intersection++;
297
+ }
298
+ const union = a.size + b.size - intersection;
299
+ return union === 0 ? 0 : intersection / union;
300
+ }
@@ -582,6 +582,8 @@ const CONTEXT_LENGTH_CODES: ReadonlySet<string> = new Set([
582
582
  "context_length_exceeded",
583
583
  "context_too_large",
584
584
  "model_context_window_exceeded",
585
+ // InferHub's spelling for the same condition.
586
+ "context_too_long",
585
587
  ]);
586
588
 
587
589
  /**
@@ -733,6 +735,14 @@ const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
733
735
  "usage_not_included",
734
736
  "subscription:free-usage-exhausted",
735
737
  "resource_exhausted",
738
+ // InferHub publishes its own vocabulary in `error.code`. The two 403s below
739
+ // are deliberate absences: `account_blocked` means the account was blocked
740
+ // (an auth/entitlement verdict, not a spend limit) and
741
+ // `model_not_allowed_for_key` is a per-key allowlist refusal — both are
742
+ // classified separately rather than cooled as quota.
743
+ "rate_limited",
744
+ "providers_out_of_quota",
745
+ "providers_at_capacity",
736
746
  ]);
737
747
  const CAPACITY_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
738
748
  "overloaded_error",
@@ -740,6 +750,9 @@ const CAPACITY_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
740
750
  "model_at_capacity",
741
751
  "server_is_overloaded",
742
752
  "model_capacity",
753
+ // InferHub: no live provider could take the request right now.
754
+ "providers_busy",
755
+ "providers_overloaded",
743
756
  ]);
744
757
  const AUTH_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
745
758
  "authentication_error",
@@ -749,6 +762,12 @@ const AUTH_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
749
762
  "unauthenticated",
750
763
  "permission_denied",
751
764
  "permissiondenied",
765
+ // InferHub: the account was blocked, and a key whose scope or allowlist does
766
+ // not cover the request. `credentialEvidence` is what cools the account
767
+ // rather than the provider.
768
+ "account_blocked",
769
+ "insufficient_key_scope",
770
+ "model_not_allowed_for_key",
752
771
  ]);
753
772
  const PLATFORM_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
754
773
  "server_error",
@@ -895,6 +914,14 @@ export function classifyUpstreamError(
895
914
  };
896
915
  if (normalized === "model_not_found")
897
916
  return { code: "model_not_found", status: 404, origin: "upstream" };
917
+ // InferHub: no live publisher serves the requested model at all. That is a
918
+ // property of the model/upstream pair, not of the account or a spend limit,
919
+ // so it is not retried as quota and does not cool the account — the next
920
+ // attempt should pick a different route.
921
+ if (normalized === "no_provider")
922
+ return { code: "model_not_found", status: 404, origin: "upstream" };
923
+ if (normalized === "content_rejected")
924
+ return { code: "policy_rejected", status: 403, origin: "upstream" };
898
925
  if (
899
926
  normalized === "not_found_error" ||
900
927
  normalized === "resource_not_found" ||
@@ -1,12 +1,12 @@
1
1
  // IP-abuse protection guard: counts every `/v1/*` attempt against the per-IP
2
2
  // ceiling via the root `request` hook.
3
3
  import { Elysia } from "elysia";
4
- import { GatewayError } from "../../protocol/gateway-error";
5
4
  import type { TrustedProxyBoundary } from "../../config/network";
6
5
  import { resolveClientIdentity } from "../../security/ip-boundary";
7
6
  import type { ProxyRequestStateStore } from "../request/state";
8
7
  import { fastPathname } from "../pathname";
9
8
  import type { IpAbuseProtectionService } from "../../security/abuse";
9
+ import { peerAddressUnavailableError } from "./guard-errors";
10
10
 
11
11
  /**
12
12
  * Counts every `/v1/*` attempt against the per-IP ceiling, including the ones
@@ -66,7 +66,7 @@ export function createIpAbuseProtectionMiddleware(deps: {
66
66
  ? deps.resolvePeerAddress(request)
67
67
  : (server?.requestIP(request)?.address ?? null);
68
68
  if (!peer)
69
- throw new GatewayError("admission_unavailable", 503, "client peer address unavailable");
69
+ throw peerAddressUnavailableError();
70
70
  const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
71
71
  const state = deps.stateStore.get(request);
72
72
  try {
@@ -28,6 +28,12 @@ export function createApiKeyAuthenticationMiddleware(deps: {
28
28
  * console error, which is exactly the noise this layer exists to stop.
29
29
  */
30
30
  readonly modelStrikes?: Pick<ModelStrikeService, "check">;
31
+ /**
32
+ * Tenant invoke kill switch. Checked after the key resolves, because the
33
+ * tenant is a property of the key: the same gateway serves several tenants
34
+ * and one switching itself off must not stop the others.
35
+ */
36
+ readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
31
37
  }): Elysia {
32
38
  // Single policy for every `/v1/*` gateway route: authenticated, scoped to
33
39
  // `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
@@ -73,6 +79,17 @@ export function createApiKeyAuthenticationMiddleware(deps: {
73
79
  });
74
80
  if (!decision.scopes.includes("routing:invoke"))
75
81
  throw new GatewayError("invalid_request", 403, "This API key is not allowed to invoke routing. Grant it the routing:invoke scope, or use a key that has it.");
82
+ // Kill switch: refuse before any upstream is touched. Distinct from the
83
+ // scope check above, which is a property of the key — this is the tenant
84
+ // having stopped all traffic, and it applies to every key the tenant owns.
85
+ // 503 rather than 403: nothing about the request is invalid, and the
86
+ // caller should retry later instead of "fixing" a request that is fine.
87
+ if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
88
+ throw new GatewayError(
89
+ "platform_unavailable",
90
+ 503,
91
+ "This gateway has invoke switched off. Nothing upstream is being dialed while the switch is off; turn it back on from the dashboard overview to resume routing.",
92
+ );
76
93
  // A key may refuse a downstream router it has been resold to. This is a
77
94
  // policy check on the resolved key, so it belongs with the other
78
95
  // authorization decisions rather than in routing: the request is refused
@@ -5,6 +5,7 @@ import { assertBoundedJsonDepth } from "../../protocol/codecs";
5
5
  import type { ProxyRequestStateStore } from "../request/state";
6
6
  import { fastPathname } from "../pathname";
7
7
  import { NATIVE_SERVICE_PATHS, TOKEN_COUNT_PATH } from "../contracts";
8
+ import { bodyTooLargeError } from "./guard-errors";
8
9
 
9
10
  /** Whether this request is a gateway (`/v1/*`) request. */
10
11
  export function isProxyRequest(request: Request): boolean {
@@ -87,7 +88,7 @@ export async function readIngressBody(
87
88
  const declared = request.headers.get("content-length");
88
89
  const parsed = declared === null ? undefined : Number(declared);
89
90
  if (declared !== null && (!/^\d+$/.test(declared) || parsed === undefined || parsed > maxBytes))
90
- throw new GatewayError("invalid_request", 413, `The request body is larger than this endpoint accepts (${maxBytes} bytes). Shorten the prompt or split the request.`);
91
+ throw bodyTooLargeError(maxBytes);
91
92
 
92
93
  const reader = request.body?.getReader();
93
94
  if (!reader) throw new GatewayError("invalid_request", 400, "The request body could not be read. Resend it with a JSON body.");
@@ -104,7 +105,7 @@ export async function readIngressBody(
104
105
  totalBytes += chunk.byteLength;
105
106
  if (totalBytes > maxBytes) {
106
107
  await reader.cancel().catch(() => undefined);
107
- throw new GatewayError("invalid_request", 413, `The request body is larger than this endpoint accepts (${maxBytes} bytes). Shorten the prompt or split the request.`);
108
+ throw bodyTooLargeError(maxBytes);
108
109
  }
109
110
  chunks.push(chunk);
110
111
  }
@@ -6,23 +6,97 @@
6
6
  * their own cap still open a hundred upstream calls at once, and the event
7
7
  * loop, the database pool, and the upstream all feel that as one burst.
8
8
  *
9
- * This counts requests in flight in this process and refuses past the cap
10
- * with 503 and a three-second retry hint. It is deliberately not a queue:
11
- * queueing would hold the connection open and make the overload look like
12
- * latency. A client that retries after the hint lands on a process that has
13
- * drained.
9
+ * This admits requests into this process and refuses past the cap with 503 and
10
+ * a retry hint. It is deliberately not a queue: queueing would hold the
11
+ * connection open and make the overload look like latency. A client that
12
+ * retries after the hint lands on a process that has drained.
13
+ *
14
+ * The cap is now DERIVED — see `config/resources.ts` — rather than a constant,
15
+ * because a constant cap admits the same concurrency on a 512 MiB container as
16
+ * on a 4 GiB one, and only the latter can actually hold it.
14
17
  */
15
18
  import { Elysia } from "elysia";
16
19
  import { GatewayError } from "../../protocol/gateway-error";
20
+ import { resolveProcessConcurrency } from "../../config/resources";
21
+ import { resolveMemoryLimitBytes } from "../../observability/runtime-metrics";
17
22
  import { fastPathname } from "../pathname";
18
23
 
19
24
  /** In-flight `/v1` requests one process will carry before refusing. */
20
- export const PROCESS_CONCURRENCY_CAP = 64;
25
+ export const PROCESS_CONCURRENCY_CAP = resolveProcessConcurrency(
26
+ resolveMemoryLimitBytes() ?? 512 * 1024 * 1024,
27
+ );
21
28
  const PROCESS_CONCURRENCY_RETRY_MS = 3_000;
22
29
 
23
- let inFlight = 0;
30
+ /**
31
+ * A bounded set of in-flight tokens.
32
+ *
33
+ * Owned by the caller rather than held in a module global: the previous shape
34
+ * was one `let inFlight` for the whole module, so two middleware instances
35
+ * (tests, or a second app in one process) shared and corrupted a single count.
36
+ */
37
+ export interface ProcessConcurrencyController {
38
+ /**
39
+ * Takes a token, or returns `undefined` when the process is full.
40
+ *
41
+ * The returned release function is idempotent — released twice still frees
42
+ * exactly one token — because a request can reach more than one completion
43
+ * path (an abort racing a normal finish).
44
+ */
45
+ tryAcquire(): (() => void) | undefined;
46
+ /** Tokens currently held. */
47
+ activeCount(): number;
48
+ /** The effective cap. */
49
+ limit(): number;
50
+ }
51
+
52
+ export function createProcessConcurrencyController(
53
+ limit: number = PROCESS_CONCURRENCY_CAP,
54
+ ): ProcessConcurrencyController {
55
+ const effective = Math.min(64, Math.max(1, Math.trunc(limit)));
56
+ let active = 0;
57
+ return {
58
+ tryAcquire() {
59
+ if (active >= effective) return undefined;
60
+ active += 1;
61
+ let released = false;
62
+ return () => {
63
+ if (released) return;
64
+ released = true;
65
+ if (active > 0) active -= 1;
66
+ };
67
+ },
68
+ activeCount() {
69
+ return active;
70
+ },
71
+ limit() {
72
+ return effective;
73
+ },
74
+ };
75
+ }
76
+
77
+ /**
78
+ * The controller the middleware below uses when none is injected.
79
+ *
80
+ * Exported so the metrics sampler can publish its live count: in-flight
81
+ * concurrency is the number that explains both a latency rise and a shed, and
82
+ * a gauge nobody sets is worse than no gauge.
83
+ */
84
+ export const defaultProcessConcurrencyController = createProcessConcurrencyController();
24
85
 
25
- export function createProcessConcurrencyMiddleware(): Elysia {
86
+ /**
87
+ * The middleware.
88
+ *
89
+ * The token is keyed by the Request object in a `WeakMap` and released only if
90
+ * one was actually taken. The previous code decremented a module-global count
91
+ * in `afterResponse` for every `/v1` request — including the ones this guard
92
+ * had just REJECTED, which never incremented it. Under sustained overload that
93
+ * drove the count down spuriously and let more requests through than the cap
94
+ * allows, which is the opposite of what a cap is for.
95
+ */
96
+ export function createProcessConcurrencyMiddleware(
97
+ controller: ProcessConcurrencyController = defaultProcessConcurrencyController,
98
+ ): Elysia {
99
+ const tokens = new WeakMap<Request, () => void>();
26
100
  return new Elysia()
27
101
  .beforeHandle(({ request }) => {
28
102
  const path = fastPathname(request.url);
@@ -30,7 +104,8 @@ export function createProcessConcurrencyMiddleware(): Elysia {
30
104
  // still answer while the process is saturated, or an operator cannot
31
105
  // see why it is refusing.
32
106
  if (!path.startsWith("/v1/")) return;
33
- if (inFlight >= PROCESS_CONCURRENCY_CAP) {
107
+ const release = controller.tryAcquire();
108
+ if (release === undefined) {
34
109
  throw new GatewayError(
35
110
  "platform_unavailable",
36
111
  503,
@@ -38,12 +113,13 @@ export function createProcessConcurrencyMiddleware(): Elysia {
38
113
  { retryAfterMs: PROCESS_CONCURRENCY_RETRY_MS },
39
114
  );
40
115
  }
41
- inFlight += 1;
116
+ tokens.set(request, release);
42
117
  })
43
118
  .afterResponse(({ request }: { request: Request }) => {
44
- const path = fastPathname(request.url);
45
- if (!path.startsWith("/v1/")) return;
46
- if (inFlight > 0) inFlight -= 1;
119
+ const release = tokens.get(request);
120
+ if (release === undefined) return;
121
+ tokens.delete(request);
122
+ release();
47
123
  })
48
124
  .as("plugin");
49
125
  }
@@ -12,9 +12,15 @@
12
12
  * error envelope the client received.
13
13
  */
14
14
  import type { PayloadCaptureState } from "../../observability/payload-capture";
15
+ import { resolvePayloadCapture } from "../../observability/payload-capture";
16
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
15
17
  /** The capture surface this helper needs; matches the real capture's method. */
16
18
  export interface EarlyRejectionCaptureSink {
17
- capture(input: EarlyRejectionCapture): Promise<unknown>;
19
+ capture(input: EarlyRejectionCapture): Promise<{
20
+ id: string;
21
+ expiresAt: Date;
22
+ payloadCaptureState?: string;
23
+ }>;
18
24
  }
19
25
 
20
26
  /** One early-rejection payload frame. */
@@ -34,6 +40,15 @@ export interface EarlyRejectionState {
34
40
  readonly ingressBody?: unknown;
35
41
  readonly clientResponseText?: string;
36
42
  readonly payloadCapture?: EarlyRejectionCaptureSink;
43
+ /**
44
+ * The request's memoized capture preference, when the request already
45
+ * resolved one. Early rejections rarely have, so the helper resolves the
46
+ * tenant preference itself through the same `resolvePayloadCapture` path
47
+ * every other capture decision uses — the bypass this field exists to end
48
+ * was an early rejection capturing bodies with the deployment's full cap
49
+ * while the tenant had configured `none` or `metadata`.
50
+ */
51
+ payloadCapturePreference?: unknown;
37
52
  /**
38
53
  * Written by this helper so telemetry can record whether the rejection's body
39
54
  * was captured. Mutable because the reason is an outcome, not an input.
@@ -51,12 +66,36 @@ export interface EarlyRejectionState {
51
66
  export async function captureEarlyRejectionPayload(
52
67
  state: EarlyRejectionState,
53
68
  authorization: { readonly tenantId: string },
69
+ db?: CartethyiaDatabase,
54
70
  ): Promise<void> {
55
71
  const capture = state.payloadCapture;
56
72
  if (capture === undefined) return;
57
73
  if (state.ingressBody === undefined) return;
58
74
  try {
59
- await capture.capture({
75
+ // The same preference path a dispatched request resolves (memoized on the
76
+ // state when the request got far enough to have one). `none` captures
77
+ // nothing at all; `metadata` records stage presence only, without the
78
+ // ingress body; only `full` keeps bodies — and at the tenant's own
79
+ // retention, never the deployment default by accident.
80
+ const preference =
81
+ state.payloadCapturePreference !== undefined
82
+ ? (state.payloadCapturePreference as { ok: boolean })
83
+ : db !== undefined
84
+ ? await resolvePayloadCapture(db, authorization.tenantId)
85
+ : { ok: false };
86
+ const mode = preference.ok === true ? (preference as { mode: string }).mode : "none";
87
+ if (mode === "none") {
88
+ state.payloadCaptureState = "skipped_mode_none";
89
+ return;
90
+ }
91
+ if (mode === "metadata") {
92
+ // Stage presence only: the ingress body is exactly what `metadata`
93
+ // mode declines to retain. A frame with no body is pointless, so the
94
+ // state records the mode outcome and nothing is written.
95
+ state.payloadCaptureState = "captured_metadata";
96
+ return;
97
+ }
98
+ const result = await capture.capture({
60
99
  tenantId: authorization.tenantId,
61
100
  requestBody: state.ingressBody,
62
101
  // No provider exchange: the request never reached one.
@@ -66,7 +105,8 @@ export async function captureEarlyRejectionPayload(
66
105
  tenantOptIn: true,
67
106
  requestId: state.requestId,
68
107
  });
69
- state.payloadCaptureState = "captured";
108
+ state.payloadCaptureState =
109
+ (result.payloadCaptureState as PayloadCaptureState | undefined) ?? "captured";
70
110
  } catch {
71
111
  // A capture failure is not a request failure; the telemetry row stands.
72
112
  state.payloadCaptureState = "failed_write";