cartethyia 2.7.0 → 2.7.2

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 (330) hide show
  1. package/.env.example +107 -106
  2. package/README.md +25 -2
  3. package/dist/dashboard/assets/{BannedPage-P76QLGJA.js → BannedPage-XaUmiBrC.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-DdBggJS9.js → CliToolDetailPage-CqD7QiLM.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-aN1iXhhP.js → CliToolsPage-0XUo4UMn.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-DE-F1nxZ.js → CombosPage-CXGS_Ct-.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-3CUxMy2X.js → CustomizationPage-DAG5J9EY.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  17. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js +1 -0
  18. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-DKVslqG3.js → ModelPicker-BH8Tehdi.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +4 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js +1 -0
  26. package/dist/dashboard/assets/ProviderIcon-FB6yS4v1.js.gz +0 -0
  27. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +1 -0
  28. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  29. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +9 -0
  30. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  31. package/dist/dashboard/assets/{QuotaPage-BjldQwIU.js → QuotaPage-7P2n--Fv.js} +1 -1
  32. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  33. package/dist/dashboard/assets/{SettingsPage-DUIoLRxk.js → SettingsPage-CxZ1fTkN.js} +1 -1
  34. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  35. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +1 -0
  36. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  37. package/dist/dashboard/assets/{SortableList-fI7JkdIB.js → SortableList-Dqf4zpS-.js} +1 -1
  38. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-CHVZqskZ.js → StudioPage-Byj2lAEm.js} +3 -3
  40. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  43. package/dist/dashboard/assets/{api-keys-DFY_fZU4.js → api-keys-CUi6UXsR.js} +1 -1
  44. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  45. package/dist/dashboard/assets/app-KvsUJUMF.css +2 -0
  46. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CwdnZARt.js → assert-67xXH5rf.js} +1 -1
  48. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-Dr-uf0Au.js → badge-DC2L43Mj.js} +1 -1
  50. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-BddmMdoS.js → banner-BBVcb0J3.js} +1 -1
  52. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-CK0PXWlX.js → card-Cca1XC6G.js} +1 -1
  54. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CcB6mGh2.js → cli-tools-KvsWa1Jl.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-DGb2P9Yx.js → clipboard-button-DUzEbynq.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-LkfuL50u.js → dialog-DFvWhwTa.js} +1 -1
  60. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-ZbEZ_84c.js → drawer-CFM2sMX1.js} +1 -1
  62. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-BJfsVMvA.js → entry-Bqy8RFvM.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  65. package/dist/dashboard/assets/{entry-CWBY5A0f.js → entry-ClCMCWWQ.js} +3 -3
  66. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  67. package/dist/dashboard/assets/{entry-CvC1-Zqx.js → entry-_xHo5x6r.js} +1 -1
  68. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  69. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js +1 -0
  70. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  71. package/dist/dashboard/assets/{github-badge-BxpznFLt.js → github-badge-C9e8Ts7e.js} +1 -1
  72. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  73. package/dist/dashboard/assets/{index-CDzP3ovJ.js → index-DFVFrnWW.js} +2 -2
  74. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  75. package/dist/dashboard/assets/{inline-Ae13MQ5w.js → inline-BFUcvllj.js} +1 -1
  76. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  77. package/dist/dashboard/assets/{input-D23fo7cp.js → input-C_ILAvBb.js} +1 -1
  78. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  79. package/dist/dashboard/assets/{layout-CSsxjj5P.js → layout-CBIyT6Rt.js} +1 -1
  80. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  81. package/dist/dashboard/assets/{list-row-dmbwZMDY.js → list-row-ebJ_-Nsb.js} +1 -1
  82. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  83. package/dist/dashboard/assets/{live-BG1oUsZZ.js → live-D0mm--ME.js} +1 -1
  84. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-DSF-e4ho.js → mutation-sMD965oO.js} +1 -1
  86. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-Bm_aECY7.js → numbers-BO9dKaFr.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  89. package/dist/dashboard/assets/providers/inception.svg +5 -0
  90. package/dist/dashboard/assets/providers/inception.svg.gz +0 -0
  91. package/dist/dashboard/assets/{query-keys-CF56nYIV.js → query-keys-CfL_Zdoh.js} +1 -1
  92. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  93. package/dist/dashboard/assets/{routing-CeA71n7G.js → routing-BnIpi1IJ.js} +1 -1
  94. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  95. package/dist/dashboard/assets/{select-KIT0rC6G.js → select-DBNVLdUu.js} +1 -1
  96. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  97. package/dist/dashboard/assets/{settings-B_2gkePf.js → settings-BAFhDgX8.js} +1 -1
  98. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  99. package/dist/dashboard/assets/{stack-D7WOSzcX.js → stack-EMrOMt4I.js} +1 -1
  100. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  101. package/dist/dashboard/assets/{state-BBxtKZOQ.js → state-CWoo7ty5.js} +1 -1
  102. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  103. package/dist/dashboard/assets/{switch-DNS_D5Ea.js → switch-CCjT6IkF.js} +1 -1
  104. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  105. package/dist/dashboard/assets/{useMutation-BgoxdoMk.js → useMutation-DjkLHUcE.js} +1 -1
  106. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  107. package/dist/dashboard/index.html +1 -1
  108. package/dist/dashboard/index.html.gz +0 -0
  109. package/dist/dashboard/precompressed-assets.json +54 -53
  110. package/migrations/full-postgres/0024_add_pool_provider_cooldowns.sql +12 -0
  111. package/migrations/full-postgres/0025_add_pool_random_strategy.sql +3 -0
  112. package/migrations/lite-sqlite3/0024_add_pool_provider_cooldowns.sql +12 -0
  113. package/migrations/lite-sqlite3/0025_add_pool_random_strategy.sql +20 -0
  114. package/package.json +96 -96
  115. package/scripts/cli/console-menu.ts +5 -2
  116. package/scripts/cli/tray.ts +21 -6
  117. package/src/boot/app.ts +684 -714
  118. package/src/boot/dependencies.ts +65 -62
  119. package/src/config/cooldowns.ts +16 -0
  120. package/src/config/providers.ts +13 -1
  121. package/src/config/resources.ts +70 -70
  122. package/src/config/security.ts +37 -37
  123. package/src/config/spec.ts +371 -358
  124. package/src/console/cli-tools/contracts.ts +10 -4
  125. package/src/console/console-router.ts +233 -225
  126. package/src/console/domain-registration.ts +398 -381
  127. package/src/console/providers/catalog/catalog-projections.ts +2 -6
  128. package/src/console/providers/catalog/contracts.ts +19 -0
  129. package/src/console/providers/catalog/provider-operations.ts +38 -0
  130. package/src/console/providers/catalog/routes.ts +14 -0
  131. package/src/console/providers/catalog/store-helpers.ts +326 -0
  132. package/src/console/providers/catalog/store.ts +1292 -1563
  133. package/src/console/quota/account-quota-global.ts +2 -2
  134. package/src/console/quota/account-quota-shared.ts +1 -1
  135. package/src/console/quota/account-quota-tenant.ts +35 -12
  136. package/src/console/quota/quota-view.ts +2 -2
  137. package/src/console/routing/pools/contracts.ts +41 -1
  138. package/src/console/routing/pools/operations.ts +183 -4
  139. package/src/console/routing/pools/relay-deploy.ts +17 -4
  140. package/src/console/routing/pools/routes.ts +30 -0
  141. package/src/console/routing/pools/store.ts +98 -8
  142. package/src/main.ts +183 -167
  143. package/src/network/outbound-fetch.ts +124 -208
  144. package/src/network/pool/agent.ts +15 -0
  145. package/src/network/pool/pool-state.ts +7 -0
  146. package/src/network/pool/selector.ts +110 -2
  147. package/src/network/pool-cooldown-store.ts +252 -0
  148. package/src/network/pool-health-machine.ts +5 -3
  149. package/src/network/pool-health.ts +36 -9
  150. package/src/observability/event-query.ts +57 -0
  151. package/src/observability/metrics.ts +467 -467
  152. package/src/observability/runtime-metrics.ts +269 -269
  153. package/src/persistence/health-event-flush.ts +2 -0
  154. package/src/persistence/schema.ts +19 -0
  155. package/src/persistence/usage-flush.ts +8 -3
  156. package/src/protocol/canonical-model.ts +1 -1
  157. package/src/protocol/dsml.ts +377 -0
  158. package/src/protocol/gateway-error.ts +232 -232
  159. package/src/{providers/provider-reasoning.ts → protocol/reasoning.ts} +10 -9
  160. package/src/protocol/request/chat.ts +7 -7
  161. package/src/protocol/request/codex.ts +2 -2
  162. package/src/protocol/request/gemini.ts +1 -1
  163. package/src/protocol/request/messages.ts +6 -6
  164. package/src/protocol/request/responses.ts +7 -7
  165. package/src/protocol/response/dsml.ts +12 -133
  166. package/src/{transport → protocol}/translation/attachment-integrity.ts +2 -2
  167. package/src/{transport → protocol}/translation/cache-controls.ts +1 -1
  168. package/src/{transport → protocol}/translation/capabilities.ts +2 -2
  169. package/src/{transport → protocol}/translation/normalize.ts +1 -1
  170. package/src/{transport → protocol}/translation/quirks.ts +1 -1
  171. package/src/{transport → protocol}/translation/search.ts +1 -1
  172. package/src/{transport → protocol}/translation/thinking.ts +23 -1
  173. package/src/{transport → protocol}/translation/tool-id.ts +1 -1
  174. package/src/{transport → protocol}/translation/tool-repair.ts +2 -2
  175. package/src/protocol/upstream-parsing.ts +176 -0
  176. package/src/providers/adapters/compatible-adapter.ts +11 -2
  177. package/src/providers/authentication/oauth-refresh-service.ts +9 -5
  178. package/src/providers/default-registry.ts +22 -5
  179. package/src/providers/discovery/probe-phases.ts +16 -3
  180. package/src/providers/discovery/probing-service.ts +4 -1
  181. package/src/providers/integrations/anthropic.ts +1 -1
  182. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -4
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +141 -8
  184. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -1
  185. package/src/providers/integrations/buddy/buddy-growth-shared.ts +0 -82
  186. package/src/providers/integrations/buddy/buddy-identity.ts +25 -0
  187. package/src/providers/integrations/buddy/buddy-server-tools.ts +240 -0
  188. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +36 -0
  189. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +6 -31
  190. package/src/providers/integrations/buddy/codebuddy/provider.ts +2 -0
  191. package/src/providers/integrations/buddy/codebuddy/shared.ts +19 -4
  192. package/src/providers/integrations/buddy/turing-device-token.ts +262 -0
  193. package/src/providers/integrations/buddy/workbuddy/provider.ts +2 -0
  194. package/src/providers/integrations/buddy/workbuddy/shared.ts +56 -3
  195. package/src/providers/integrations/claude/claude.ts +1 -1
  196. package/src/providers/integrations/cloudflareai/cloudflareai.ts +1 -1
  197. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  198. package/src/providers/integrations/codex/codex.ts +1 -1
  199. package/src/providers/integrations/factory/factory.ts +1 -1
  200. package/src/providers/integrations/github/github.ts +1 -1
  201. package/src/providers/integrations/grok/grok.ts +1 -1
  202. package/src/providers/integrations/inception/provider.ts +91 -0
  203. package/src/providers/integrations/inferhub.ts +1 -1
  204. package/src/providers/integrations/kiro/kiro-request.ts +1 -1
  205. package/src/providers/integrations/mistral.ts +193 -19
  206. package/src/providers/integrations/ollamacloud-models.ts +50 -0
  207. package/src/providers/integrations/opencode/fingerprint.ts +19 -2
  208. package/src/providers/integrations/opencode/prompt.ts +111 -0
  209. package/src/providers/integrations/opencode/provider.ts +101 -23
  210. package/src/providers/integrations/opencode/tool-definitions.ts +4 -4
  211. package/src/providers/integrations/qoder/qoder-checkin.ts +303 -0
  212. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -1
  213. package/src/providers/integrations/qoder/qoder-realm.ts +20 -0
  214. package/src/providers/integrations/qoder/qoder-text-tools.ts +277 -0
  215. package/src/providers/integrations/qoder/qoder.ts +315 -400
  216. package/src/providers/integrations/shared/install-id.ts +2 -1
  217. package/src/providers/integrations/trae/trae-cache.ts +110 -0
  218. package/src/providers/integrations/trae/trae-oauth.ts +29 -4
  219. package/src/providers/integrations/trae/trae-protocol.ts +2 -1
  220. package/src/providers/integrations/trae/trae.ts +106 -23
  221. package/src/providers/operations/account-error-classification.ts +1684 -0
  222. package/src/providers/operations/account-health-service.ts +27 -1503
  223. package/src/providers/operations/account-reset-service.ts +2 -0
  224. package/src/providers/operations/provider-catalog-service.ts +11 -1
  225. package/src/providers/provider-contracts.ts +7 -4
  226. package/src/providers/provider-metadata.ts +25 -4
  227. package/src/providers/provider-probe.ts +2 -2
  228. package/src/{console → providers}/quota/cache.ts +1 -1
  229. package/src/{console → providers}/quota/refresh.ts +5 -5
  230. package/src/security/cli-client-fingerprint.ts +9 -2
  231. package/src/transport/dispatch/attempt-finalize.ts +114 -12
  232. package/src/transport/dispatch/attempt-loop.ts +3 -0
  233. package/src/transport/dispatch/empty-stream.ts +9 -2
  234. package/src/transport/dispatch/generation.ts +37 -55
  235. package/src/transport/dispatch/handler-deps.ts +32 -0
  236. package/src/transport/dispatch/leases.ts +9 -4
  237. package/src/transport/dispatch/proxy-request.ts +6 -30
  238. package/src/transport/dispatch/responses-compact.ts +22 -43
  239. package/src/transport/dispatch/streaming/finalize.ts +3 -0
  240. package/src/transport/dispatch/streaming/stream.ts +0 -8
  241. package/src/transport/dispatch/systemone.ts +22 -42
  242. package/src/transport/dispatch/token-count.ts +23 -39
  243. package/src/transport/dispatch/upstream.ts +1 -3
  244. package/src/transport/dispatch/websearch.ts +22 -41
  245. package/src/transport/failure-policy.ts +92 -178
  246. package/src/transport/guards/abuse-guard.ts +10 -0
  247. package/src/transport/guards/auth-guard.ts +109 -109
  248. package/src/transport/guards/error-lifecycle.ts +1 -1
  249. package/src/transport/guards/pipeline.ts +220 -220
  250. package/src/transport/guards/request-context.ts +245 -238
  251. package/src/transport/request/degradation.ts +20 -1
  252. package/src/transport/request/inflight.ts +4 -114
  253. package/src/transport/request/preparer.ts +6 -6
  254. package/src/transport/request/state.ts +66 -38
  255. package/src/transport/request/tenant-preferences.ts +1 -1
  256. package/src/transport/routing/route-catalog.ts +75 -43
  257. package/src/transport/routing/router.ts +30 -2
  258. package/src/transport/surface/messages/parse.ts +1 -1
  259. package/src/transport/surface/responses/encode.ts +2 -3
  260. package/src/transport/surface/responses/parse.ts +49 -7
  261. package/src/types/undici.d.ts +33 -0
  262. package/src/workers/buddy-growth-tasks.ts +346 -425
  263. package/src/workers/daily-checkin.ts +17 -6
  264. package/src/workers/quota-refresh-sweep.ts +2 -2
  265. package/src/workers/scheduler.ts +17 -1
  266. package/dist/dashboard/assets/BannedPage-P76QLGJA.js.gz +0 -0
  267. package/dist/dashboard/assets/CliToolDetailPage-DdBggJS9.js.gz +0 -0
  268. package/dist/dashboard/assets/CliToolsPage-aN1iXhhP.js.gz +0 -0
  269. package/dist/dashboard/assets/CombosPage-DE-F1nxZ.js.gz +0 -0
  270. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js +0 -1
  271. package/dist/dashboard/assets/ConsoleLogPage-5SpmfLyw.js.gz +0 -0
  272. package/dist/dashboard/assets/CustomizationPage-3CUxMy2X.js.gz +0 -0
  273. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js +0 -1
  274. package/dist/dashboard/assets/HealthEventsModal-BkmJQKC0.js.gz +0 -0
  275. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js +0 -1
  276. package/dist/dashboard/assets/LoginPage-B_0FUKWc.js.gz +0 -0
  277. package/dist/dashboard/assets/ModelPicker-DKVslqG3.js.gz +0 -0
  278. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js +0 -1
  279. package/dist/dashboard/assets/OverviewPage-D-H4MEiT.js.gz +0 -0
  280. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js +0 -3
  281. package/dist/dashboard/assets/ProviderDetailPage-CEJw6Guo.js.gz +0 -0
  282. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +0 -1
  283. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  284. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js +0 -1
  285. package/dist/dashboard/assets/ProvidersPage-CEplAR-i.js.gz +0 -0
  286. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js +0 -7
  287. package/dist/dashboard/assets/ProxyPage-CAN7hy0f.js.gz +0 -0
  288. package/dist/dashboard/assets/QuotaPage-BjldQwIU.js.gz +0 -0
  289. package/dist/dashboard/assets/SettingsPage-DUIoLRxk.js.gz +0 -0
  290. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js +0 -1
  291. package/dist/dashboard/assets/SetupPage-_bCaTM0v.js.gz +0 -0
  292. package/dist/dashboard/assets/SortableList-fI7JkdIB.js.gz +0 -0
  293. package/dist/dashboard/assets/StudioPage-CHVZqskZ.js.gz +0 -0
  294. package/dist/dashboard/assets/UsagePage-DiFBymHj.js +0 -56
  295. package/dist/dashboard/assets/UsagePage-DiFBymHj.js.gz +0 -0
  296. package/dist/dashboard/assets/api-keys-DFY_fZU4.js.gz +0 -0
  297. package/dist/dashboard/assets/app-BOJ1_xUB.css +0 -2
  298. package/dist/dashboard/assets/app-BOJ1_xUB.css.gz +0 -0
  299. package/dist/dashboard/assets/assert-CwdnZARt.js.gz +0 -0
  300. package/dist/dashboard/assets/badge-Dr-uf0Au.js.gz +0 -0
  301. package/dist/dashboard/assets/banner-BddmMdoS.js.gz +0 -0
  302. package/dist/dashboard/assets/card-CK0PXWlX.js.gz +0 -0
  303. package/dist/dashboard/assets/cli-tools-CcB6mGh2.js.gz +0 -0
  304. package/dist/dashboard/assets/clipboard-button-DGb2P9Yx.js.gz +0 -0
  305. package/dist/dashboard/assets/cog-CdWhsajs.js +0 -1
  306. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  307. package/dist/dashboard/assets/dialog-LkfuL50u.js.gz +0 -0
  308. package/dist/dashboard/assets/drawer-ZbEZ_84c.js.gz +0 -0
  309. package/dist/dashboard/assets/entry-BJfsVMvA.js.gz +0 -0
  310. package/dist/dashboard/assets/entry-CWBY5A0f.js.gz +0 -0
  311. package/dist/dashboard/assets/entry-CvC1-Zqx.js.gz +0 -0
  312. package/dist/dashboard/assets/github-badge-BxpznFLt.js.gz +0 -0
  313. package/dist/dashboard/assets/index-CDzP3ovJ.js.gz +0 -0
  314. package/dist/dashboard/assets/inline-Ae13MQ5w.js.gz +0 -0
  315. package/dist/dashboard/assets/input-D23fo7cp.js.gz +0 -0
  316. package/dist/dashboard/assets/layout-CSsxjj5P.js.gz +0 -0
  317. package/dist/dashboard/assets/list-row-dmbwZMDY.js.gz +0 -0
  318. package/dist/dashboard/assets/live-BG1oUsZZ.js.gz +0 -0
  319. package/dist/dashboard/assets/mutation-DSF-e4ho.js.gz +0 -0
  320. package/dist/dashboard/assets/numbers-Bm_aECY7.js.gz +0 -0
  321. package/dist/dashboard/assets/query-keys-CF56nYIV.js.gz +0 -0
  322. package/dist/dashboard/assets/routing-CeA71n7G.js.gz +0 -0
  323. package/dist/dashboard/assets/select-KIT0rC6G.js.gz +0 -0
  324. package/dist/dashboard/assets/settings-B_2gkePf.js.gz +0 -0
  325. package/dist/dashboard/assets/stack-D7WOSzcX.js.gz +0 -0
  326. package/dist/dashboard/assets/state-BBxtKZOQ.js.gz +0 -0
  327. package/dist/dashboard/assets/switch-DNS_D5Ea.js.gz +0 -0
  328. package/dist/dashboard/assets/useMutation-BgoxdoMk.js.gz +0 -0
  329. /package/dist/dashboard/assets/{app-CaXZ9SCn.js → app-BaWd0puM.js} +0 -0
  330. /package/dist/dashboard/assets/{app-CaXZ9SCn.js.gz → app-BaWd0puM.js.gz} +0 -0
@@ -1,184 +1,27 @@
1
1
  import { GatewayError, type GatewayErrorCode, type GatewayErrorOrigin } from "../protocol/gateway-error";
2
2
  import { isRecord } from "../protocol/codecs";
3
3
  import { resolveFallbackRetryBaseMs, resolveFallbackRetryCapMs } from "../config/timeouts";
4
-
5
- const MAX_UPSTREAM_ERROR_BYTES = 500;
6
- const MAX_COOLDOWN_MS = 24 * 3600 * 1000;
7
-
8
- /** Parses Retry-After seconds or an HTTP date, bounded to one day. */
9
- export function parseRetryAfter(value: string | null | undefined): number | null {
10
- if (!value) return null;
11
- const trimmed = value.trim();
12
- const numeric = Number(trimmed);
13
- if (Number.isFinite(numeric) && numeric > 0) return Math.min(MAX_COOLDOWN_MS, numeric * 1000);
14
- const parsed = Date.parse(trimmed);
15
- if (!Number.isFinite(parsed)) return null;
16
- const delay = parsed - Date.now();
17
- return delay > 0 ? Math.min(MAX_COOLDOWN_MS, delay) : null;
18
- }
4
+ import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
5
+ import { resolveAccountQuotaCooldownMs } from "../config/cooldowns";
19
6
 
20
7
  /**
21
- * Parses x-ratelimit-reset as epoch seconds or a relative delay.
8
+ * How much of the upstream's error body is kept and surfaced.
22
9
  *
23
- * Returns `null` — not `0` — when the instant has already passed. This matters
24
- * because `parseUpstreamBackoff` chains these parsers with `??`, which falls
25
- * through only on null: a `0` from an elapsed `x-ratelimit-reset` (a cached error
26
- * body, clock skew, a stale edge response) stopped the chain before the
27
- * lower-priority headers that carried the real wait. The `0` was then written to
28
- * `details.retryAfterMs` and read by `classifyAccountError` as `headerCooldown`,
29
- * which outranks the provider's own message text — so both were discarded and the
30
- * account fell back to the generic default cooldown while the provider had parked
31
- * it for the rest of the window.
32
- *
33
- * `parseRetryAfter` above already returns `null` for an elapsed date; matching it
34
- * is what removes the asymmetry.
35
- */
36
- export function parseRateLimitReset(value: string | null | undefined): number | null {
37
- if (!value) return null;
38
- const numeric = Number(value.trim());
39
- if (!Number.isFinite(numeric) || numeric <= 0) return null;
40
- const target = numeric >= 1_000_000_000 ? numeric * 1000 : Date.now() + numeric * 1000;
41
- const remaining = target - Date.now();
42
- return remaining > 0 ? Math.min(MAX_COOLDOWN_MS, remaining) : null;
43
- }
44
-
45
- /** Parses a millisecond-denominated reset header (`retry-after-ms`, `x-ratelimit-reset-ms`). */
46
- export function parseResetAfterMs(value: string | null | undefined): number | null {
47
- if (!value) return null;
48
- const numeric = Number(value.trim());
49
- if (!Number.isFinite(numeric) || numeric <= 0) return null;
50
- return Math.min(MAX_COOLDOWN_MS, Math.round(numeric));
51
- }
52
-
53
- /**
54
- * Reads upstream backoff evidence in reference priority order: millisecond
55
- * variants first (`retry-after-ms`, then `x-ratelimit-reset-ms`), then the
56
- * classic `retry-after` / `x-ratelimit-reset` pair.
10
+ * 500 bytes cut the CodeBuddy envelope mid-`extError`: the operator saw
11
+ * "request illegal" with none of the `displayMsg` prose that names the actual
12
+ * condition — "prompt is too long: 1048788 tokens > 1048576 maximum" — so a
13
+ * context overflow was indistinguishable from a policy refusal. The envelope
14
+ * runs a few KB; 4000 keeps the whole thing while bounding a hostile body.
57
15
  */
58
- export function parseUpstreamBackoff(headers: {
59
- get(name: string): string | null;
60
- }): number | null {
61
- return (
62
- parseResetAfterMs(headers.get("retry-after-ms")) ??
63
- parseRetryAfter(headers.get("retry-after")) ??
64
- parseResetAfterMs(headers.get("x-ratelimit-reset-ms")) ??
65
- parseRateLimitReset(headers.get("x-ratelimit-reset")) ??
66
- parseRateLimitReset(headers.get("x-ratelimit-reset-requests")) ??
67
- parseRateLimitReset(headers.get("retry-after"))
68
- );
69
- }
70
-
71
- /**
72
- * Extracts bounded reset durations from provider messages when headers omit
73
- * them.
74
- *
75
- * Two shapes are recognized, because providers state resets both ways:
76
- *
77
- * - **Relative** — "quota will reset in 3 hours", "try again in 30s".
78
- * - **Absolute** — "your usage will reset at 2026-09-24 02:12:51 UTC+8"
79
- * (WorkBuddy/CodeBuddy `6004`). An absolute stamp is *not* a duration, and
80
- * the relative-only pattern silently missed it, so the account fell back to
81
- * the generic 15-minute default while the provider had actually parked it
82
- * for the rest of the window — the account kept re-entering rotation and
83
- * failing every request inside the stated reset window.
84
- */
85
- /**
86
- * One `amount unit` pair of a duration phrase: `4h`, `13m`, `2 hours`, `30s`.
87
- *
88
- * The unit ends on `(?![a-z])`, not `\b`: a compact compound like `2h30m` puts
89
- * a digit straight after `h`, which is a word character, so a `\b` would refuse
90
- * to end the pair and the whole phrase would parse as null. `m(?!s|o)` is what
91
- * stops a bare `m` from swallowing `ms` (milliseconds) or `mo` (months),
92
- * neither of which is a reset duration.
93
- */
94
- const DURATION_PAIR_SOURCE = String.raw`(\d+(?:\.\d+)?)[\s-]*(hours?|hrs?|h|minutes?|mins?|m(?!s|o)|seconds?|secs?|s)(?![a-z])`;
95
-
96
- function durationUnitMs(unit: string): number {
97
- const first = unit.toLowerCase()[0];
98
- return first === "h" ? 3600_000 : first === "m" ? 60_000 : 1_000;
99
- }
100
-
101
- /**
102
- * Sums the `amount unit` pairs at the head of a duration phrase.
103
- *
104
- * Providers state a reset as one compound duration at least as often as a
105
- * single unit: a daily-limit 429 reads "Try again in 4h 13m". A single-pair
106
- * parser read only `4h` and dropped the 13 minutes, so the stored retry
107
- * deadline was earlier than the window the provider had actually stated and the
108
- * account re-entered rotation before it. Pairs are accepted only while they
109
- * continue the phrase — separated by whitespace, a comma, or `and` — so a
110
- * number that merely follows later in the sentence cannot be absorbed.
111
- */
112
- function parseCompoundDuration(text: string): number | null {
113
- const pairs = new RegExp(DURATION_PAIR_SOURCE, "gi");
114
- let totalMs = 0;
115
- let lastEnd = 0;
116
- let matched = false;
117
- for (let pair = pairs.exec(text); pair !== null; pair = pairs.exec(text)) {
118
- const gap = text.slice(lastEnd, pair.index);
119
- const continues = matched ? /^[\s,]*(?:and\s+)?$/i.test(gap) : /^\s*$/.test(gap);
120
- if (!continues) break;
121
- const amount = Number(pair[1]);
122
- if (!Number.isFinite(amount) || amount <= 0) break;
123
- totalMs += amount * durationUnitMs(pair[2] ?? "");
124
- matched = true;
125
- lastEnd = pair.index + pair[0].length;
126
- }
127
- return matched ? Math.round(totalMs) : null;
128
- }
129
-
130
- export function parseProviderResetDuration(message: string): number | null {
131
- const trigger =
132
- /(?:quota will reset|resets?|try again|retry(?:ing)?)\s+(?:in|after|over)\s+(?:a\s+)?(?:rolling\s+)?/i.exec(
133
- message,
134
- );
135
- if (trigger) {
136
- const parsed = parseCompoundDuration(message.slice(trigger.index + trigger[0].length));
137
- if (parsed !== null) return Math.min(MAX_COOLDOWN_MS, parsed);
138
- }
139
- return parseAbsoluteResetTimestamp(message);
140
- }
141
-
142
- /**
143
- * Reads an absolute reset instant out of a provider message, e.g.
144
- * `will reset at 2026-09-24 02:12:51 UTC+8`. Returns the delay from now,
145
- * bounded to one day, or null when no future timestamp is present.
146
- *
147
- * `Date.parse` cannot read the space-separated `YYYY-MM-DD HH:MM:SS` form with
148
- * a bare `UTC±H` offset, so the parts are normalized explicitly rather than
149
- * relying on engine-specific leniency.
150
- */
151
- export function parseAbsoluteResetTimestamp(message: string): number | null {
152
- const match = /(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\s*(?:UTC|GMT)?\s*([+-]\d{1,2})?(?::?(\d{2}))?/i.exec(
153
- message,
154
- );
155
- if (!match) return null;
156
- const [, year, month, day, hour, minute, second, offsetHours, offsetMinutes] = match;
157
- if (!year || !month || !day || !hour || !minute || !second) return null;
158
- // A bare `UTC+8` means a whole-hour offset; `+08:30`-style halves are rare
159
- // but cheap to honor. No offset at all is read as UTC (providers that omit
160
- // it always label the stamp "UTC" in practice).
161
- const sign = offsetHours?.startsWith("-") ? -1 : 1;
162
- const offsetTotalMinutes =
163
- offsetHours === undefined
164
- ? 0
165
- : sign * (Math.abs(Number(offsetHours)) * 60 + Number(offsetMinutes ?? 0));
166
- const utcMs = Date.UTC(
167
- Number(year),
168
- Number(month) - 1,
169
- Number(day),
170
- Number(hour),
171
- Number(minute),
172
- Number(second),
173
- );
174
- if (!Number.isFinite(utcMs)) return null;
175
- const targetMs = utcMs - offsetTotalMinutes * 60_000;
176
- const delay = targetMs - Date.now();
177
- // A past stamp is not a usable reset (clock skew, stale cached error); fall
178
- // through to the caller's default instead of returning a negative delay.
179
- if (!Number.isFinite(delay) || delay <= 0) return null;
180
- return Math.min(MAX_COOLDOWN_MS, Math.round(delay));
181
- }
16
+ const MAX_UPSTREAM_ERROR_BYTES = 4000;
17
+ export {
18
+ parseRetryAfter,
19
+ parseRateLimitReset,
20
+ parseResetAfterMs,
21
+ parseUpstreamBackoff,
22
+ parseProviderResetDuration,
23
+ parseAbsoluteResetTimestamp,
24
+ } from "../protocol/upstream-parsing";
182
25
  /** Extracts a bounded, newline-free provider message from any error envelope. */
183
26
  export function extractUpstreamMessage(body: unknown): string {
184
27
  let raw = "";
@@ -390,6 +233,31 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
390
233
  // DNS failure. Failing the client request instead of failing over is the wrong
391
234
  // outcome for a whole class the table already names as platform-wide.
392
235
  const statusCode = error.status;
236
+ // Qoder answers an exhausted quota by closing the stream with an upstream 499
237
+ // (nginx client-close style). That is a refusal from the upstream — the
238
+ // gateway's own aborts are `transport_closed`, produced locally, and never
239
+ // carry a qoder providerId — so a 499 scoped to qoder is quota, not a
240
+ // malformed request. Without this it fell through to `invalid_request`
241
+ // (non-retryable, no cooldown) and the account was re-dialled on every
242
+ // request inside the exhausted window until a late 429 finally classified it.
243
+ if (
244
+ error.origin === "upstream" &&
245
+ statusCode === 499 &&
246
+ (providerId === "qoder" || providerId === "qodercn")
247
+ ) {
248
+ const cooldownMs = resolveAccountQuotaCooldownMs();
249
+ return {
250
+ retryable: true,
251
+ mutatesAccount: true,
252
+ category: "quota_exceeded",
253
+ scope: "account",
254
+ origin: error.origin,
255
+ ...(providerCode ? { providerCode } : {}),
256
+ ...(providerId ? { providerId } : {}),
257
+ cooldownMs,
258
+ statusCode,
259
+ };
260
+ }
393
261
  const retryableByStatus =
394
262
  statusCode === 401 ||
395
263
  statusCode === 403 ||
@@ -544,7 +412,27 @@ export interface TerminalOutcome {
544
412
  readonly status: "cancelled" | "failed";
545
413
  readonly errorCategory: GatewayErrorCode | "unknown_error";
546
414
  readonly errorOrigin: GatewayErrorOrigin;
547
- }
415
+ /**
416
+ * The HTTP status the row should carry. `undefined` means the caller has a
417
+ * better one (the wire status of an actual response) and should keep it;
418
+ * a raw error has none, and the caller's own fallback was 500 — which
419
+ * read as a gateway fault for what was an upstream transport drop.
420
+ */
421
+ readonly httpStatus?: number;
422
+ }
423
+
424
+ /** The HTTP status each classifier-derived category carries when no wire response named one. */
425
+ const CATEGORY_HTTP_STATUS: Partial<Record<GatewayErrorCode | "unknown_error", number>> = {
426
+ // The upstream's transport died mid-response: a bad gateway, not a fault
427
+ // in this process.
428
+ transport_unavailable: 502,
429
+ platform_unavailable: 502,
430
+ deadline_exceeded: 504,
431
+ transport_closed: 499,
432
+ shutting_down: 503,
433
+ overloaded: 529,
434
+ unknown_error: 500,
435
+ };
548
436
 
549
437
  /**
550
438
  * Derives a failed attempt's status, error category, and error origin **from
@@ -559,11 +447,24 @@ export interface TerminalOutcome {
559
447
  * outcome carries the real code and origin.
560
448
  */
561
449
  export function classifyTerminalOutcome(error: unknown, signal: AbortSignal): TerminalOutcome {
450
+ const category = classifyTerminalCategory(error, signal);
451
+ const gateway = error instanceof GatewayError ? error : signal.reason instanceof GatewayError ? signal.reason : undefined;
562
452
  return {
563
453
  status: isClientCancellation(error, signal) ? "cancelled" : "failed",
564
- errorCategory: classifyTerminalCategory(error, signal),
565
- // A non-GatewayError outcome has no upstream origin to claim: it is ours.
566
- errorOrigin: error instanceof GatewayError ? error.origin : "cartethyia",
454
+ errorCategory: category,
455
+ // The classifier owns the blame. An upstream socket drop arrives as a
456
+ // raw runtime error — no `GatewayError` wraps it — and calling that ours
457
+ // made the row accuse the gateway for an upstream failure.
458
+ errorOrigin: gateway
459
+ ? gateway.origin
460
+ : category === "transport_unavailable"
461
+ ? "upstream"
462
+ : "cartethyia",
463
+ // A `GatewayError` carries its own status; a raw error takes the
464
+ // category's, so an upstream drop is 502 rather than the 500 fallback.
465
+ ...(gateway || CATEGORY_HTTP_STATUS[category] !== undefined
466
+ ? { httpStatus: gateway ? gateway.status : CATEGORY_HTTP_STATUS[category] }
467
+ : {}),
567
468
  };
568
469
  }
569
470
 
@@ -584,6 +485,13 @@ const CONTEXT_LENGTH_CODES: ReadonlySet<string> = new Set([
584
485
  "model_context_window_exceeded",
585
486
  // InferHub's spelling for the same condition.
586
487
  "context_too_long",
488
+ // Trae answers an overflow on its streaming surface with the bare numeric
489
+ // code `4026` and the prose "We're sorry, your context length has exceeded
490
+ // the maximum limit." — no HTTP status, no symbolic code (verified live
491
+ // 2026-10: `event:error data:{"code":4026,"message":"..."}`). Without it
492
+ // the frame fell to the 502 `platform_unavailable` fallback and the client
493
+ // never saw the 413 it compacts on.
494
+ "4026",
587
495
  ]);
588
496
 
589
497
  /**
@@ -735,6 +643,12 @@ const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
735
643
  "usage_not_included",
736
644
  "subscription:free-usage-exhausted",
737
645
  "resource_exhausted",
646
+ // Qoder answers an exhausted daily billing allowance with a bare numeric
647
+ // code: `{"code":"110","message":"Billing daily count exceeded"}`. The 403
648
+ // wrapper made the old status-only fallback read it as a credential fault;
649
+ // it is a spend/limit verdict, never an auth one, so it must classify as
650
+ // quota (a cooldown) instead of `auth_invalidated`.
651
+ "110",
738
652
  // InferHub publishes its own vocabulary in `error.code`. The two 403s below
739
653
  // are deliberate absences: `account_blocked` means the account was blocked
740
654
  // (an auth/entitlement verdict, not a spend limit) and
@@ -69,6 +69,16 @@ export function createIpAbuseProtectionMiddleware(deps: {
69
69
  throw peerAddressUnavailableError();
70
70
  const address = resolveClientIdentity(request, deps.trustedProxyBoundary, peer);
71
71
  const state = deps.stateStore.get(request);
72
+ // The identity middleware runs later in the plugin chain and resolves the
73
+ // same value from the same boundary; stashing it here means the request
74
+ // pays for one resolveClientIdentity instead of two, and the middleware can
75
+ // skip its own when it finds the stash.
76
+ if (state !== undefined) {
77
+ state.clientIdentity ??= {
78
+ address,
79
+ source: address === peer ? "tcp-peer" : "trusted-forwarded-header",
80
+ };
81
+ }
72
82
  try {
73
83
  await deps.ipAbuseProtection.checkBeforeAccess({
74
84
  identity: {
@@ -1,109 +1,109 @@
1
- import { log } from "../../observability/logger";
2
- // API-key authentication guard for `/v1/*`: token resolution, scope check,
3
- // client-router denylist, and the model-abuse ban gate.
4
- import { Elysia } from "elysia";
5
- import { GatewayError } from "../../protocol/gateway-error";
6
- import type { ProxyRequestStateStore } from "../request/state";
7
- import { fastPathname } from "../pathname";
8
- import type { CartethyiaDatabase } from "../../persistence/db-handle";
9
- import { requestToken, resolveApiKeyAuthorization, unknownApiKeyError } from "../../security/api-key-auth";
10
- import {
11
- deniedClientRouter,
12
- detectClientRouter,
13
- } from "../../security/client-router-fingerprint";
14
- import { createAccessDecision } from "../../security/access-control";
15
-
16
- /**
17
- * Stateless Elysia `beforeHandle` gateway check: API-key authentication,
18
- * wired into the middleware chain in `app.ts`.
19
- */
20
- export function createApiKeyAuthenticationMiddleware(deps: {
21
- readonly db: CartethyiaDatabase;
22
- readonly stateStore: ProxyRequestStateStore;
23
- /**
24
- * Model-abuse ban gate. A banned IP or key is refused here, before the
25
- * canonical parse and route preparation, and before `state.authorization` is
26
- * assigned — so a banned caller's attempts produce no telemetry row and no
27
- * console error, which is exactly the noise this layer exists to stop.
28
- */
29
- /**
30
- * Tenant invoke kill switch. Checked after the key resolves, because the
31
- * tenant is a property of the key: the same gateway serves several tenants
32
- * and one switching itself off must not stop the others.
33
- */
34
- readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
35
- }): Elysia {
36
- // Single policy for every `/v1/*` gateway route: authenticated, scoped to
37
- // `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
38
- // tenant this key may address). The check is inline rather than routed
39
- // through a per-route policy table: Elysia's router already owns which
40
- // paths exist, so a per-route table only added a second place to keep in
41
- // sync.
42
- return new Elysia()
43
- .beforeHandle(async ({ request }) => {
44
- const pathname = fastPathname(request.url);
45
- if (!pathname.startsWith("/v1/")) return;
46
- const token = requestToken(request.headers);
47
- // A database failure here is not "the key is bad". Left to propagate it
48
- // surfaced as a 500 whose log line carried the whole statement and its
49
- // bound parameters — including the key hash — once per request. Under an
50
- // overloaded database that is a credential leak written at request rate.
51
- //
52
- // It is also the wrong answer: the caller cannot fix it by sending a
53
- // different key, so it is a capacity signal, not an auth one.
54
- const authorization = await resolveApiKeyAuthorization(deps.db, token).catch((error: unknown) => {
55
- // Only the error's identity is logged, never its message: a drizzle
56
- // query error carries the full statement and its bound parameters, and
57
- // on this path one of those parameters is the key hash.
58
- // A sanitized stand-in, not the original: a drizzle query error's
59
- // message is the statement plus its bound parameters, and one of those
60
- // parameters is the key hash.
61
- const safe = new Error("[redacted: credential-store query failed]");
62
- safe.name = error instanceof Error ? error.name : "Error";
63
- log.error("[auth] key lookup failed; treating as unavailable", safe);
64
- throw new GatewayError(
65
- "accounts_unavailable",
66
- 503,
67
- "The gateway could not verify this API key because its credential store is unavailable. Retry shortly.",
68
- {},
69
- "cartethyia",
70
- );
71
- });
72
- if (!authorization) throw unknownApiKeyError();
73
- const decision = createAccessDecision({
74
- id: authorization.id,
75
- tenantId: authorization.tenantId,
76
- scopes: authorization.scopes,
77
- });
78
- if (!decision.scopes.includes("routing:invoke"))
79
- 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.");
80
- // Kill switch: refuse before any upstream is touched. Distinct from the
81
- // scope check above, which is a property of the key — this is the tenant
82
- // having stopped all traffic, and it applies to every key the tenant owns.
83
- // 503 rather than 403: nothing about the request is invalid, and the
84
- // caller should retry later instead of "fixing" a request that is fine.
85
- if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
86
- throw new GatewayError(
87
- "platform_unavailable",
88
- 503,
89
- "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.",
90
- );
91
- // A key may refuse a downstream router it has been resold to. This is a
92
- // policy check on the resolved key, so it belongs with the other
93
- // authorization decisions rather than in routing: the request is refused
94
- // before a route is planned or an upstream is touched.
95
- const denied = deniedClientRouter(
96
- authorization.snapshot.client_router_denylist,
97
- detectClientRouter({ headers: request.headers }),
98
- );
99
- if (denied !== null)
100
- throw new GatewayError(
101
- "client_router_denied",
102
- 403,
103
- "No API invocation access for this client.",
104
- { reason: "client_router_denied", clientRouter: denied },
105
- );
106
- deps.stateStore.require(request).authorization = authorization;
107
- })
108
- .as("plugin");
109
- }
1
+ import { log } from "../../observability/logger";
2
+ // API-key authentication guard for `/v1/*`: token resolution, scope check,
3
+ // client-router denylist, and the model-abuse ban gate.
4
+ import { Elysia } from "elysia";
5
+ import { GatewayError } from "../../protocol/gateway-error";
6
+ import type { ProxyRequestStateStore } from "../request/state";
7
+ import { fastPathname } from "../pathname";
8
+ import type { CartethyiaDatabase } from "../../persistence/db-handle";
9
+ import { requestToken, resolveApiKeyAuthorization, unknownApiKeyError } from "../../security/api-key-auth";
10
+ import {
11
+ deniedClientRouter,
12
+ detectClientRouter,
13
+ } from "../../security/client-router-fingerprint";
14
+ import { createAccessDecision } from "../../security/access-control";
15
+
16
+ /**
17
+ * Stateless Elysia `beforeHandle` gateway check: API-key authentication,
18
+ * wired into the middleware chain in `app.ts`.
19
+ */
20
+ export function createApiKeyAuthenticationMiddleware(deps: {
21
+ readonly db: CartethyiaDatabase;
22
+ readonly stateStore: ProxyRequestStateStore;
23
+ /**
24
+ * Model-abuse ban gate. A banned IP or key is refused here, before the
25
+ * canonical parse and route preparation, and before `state.authorization` is
26
+ * assigned — so a banned caller's attempts produce no telemetry row and no
27
+ * console error, which is exactly the noise this layer exists to stop.
28
+ */
29
+ /**
30
+ * Tenant invoke kill switch. Checked after the key resolves, because the
31
+ * tenant is a property of the key: the same gateway serves several tenants
32
+ * and one switching itself off must not stop the others.
33
+ */
34
+ readonly isInvokeDisabled?: (tenantId: string) => Promise<boolean | undefined>;
35
+ }): Elysia {
36
+ // Single policy for every `/v1/*` gateway route: authenticated, scoped to
37
+ // `routing:invoke`, and tenant-bound (the snapshot's tenant is the only
38
+ // tenant this key may address). The check is inline rather than routed
39
+ // through a per-route policy table: Elysia's router already owns which
40
+ // paths exist, so a per-route table only added a second place to keep in
41
+ // sync.
42
+ return new Elysia()
43
+ .beforeHandle(async ({ request }) => {
44
+ const pathname = fastPathname(request.url);
45
+ if (!pathname.startsWith("/v1/")) return;
46
+ const token = requestToken(request.headers);
47
+ // A database failure here is not "the key is bad". Left to propagate it
48
+ // surfaced as a 500 whose log line carried the whole statement and its
49
+ // bound parameters — including the key hash — once per request. Under an
50
+ // overloaded database that is a credential leak written at request rate.
51
+ //
52
+ // It is also the wrong answer: the caller cannot fix it by sending a
53
+ // different key, so it is a capacity signal, not an auth one.
54
+ const authorization = await resolveApiKeyAuthorization(deps.db, token).catch((error: unknown) => {
55
+ // Only the error's identity is logged, never its message: a drizzle
56
+ // query error carries the full statement and its bound parameters, and
57
+ // on this path one of those parameters is the key hash.
58
+ // A sanitized stand-in, not the original: a drizzle query error's
59
+ // message is the statement plus its bound parameters, and one of those
60
+ // parameters is the key hash.
61
+ const safe = new Error("[redacted: credential-store query failed]");
62
+ safe.name = error instanceof Error ? error.name : "Error";
63
+ log.error("[auth] key lookup failed; treating as unavailable", safe);
64
+ throw new GatewayError(
65
+ "accounts_unavailable",
66
+ 503,
67
+ "The gateway could not verify this API key because its credential store is unavailable. Retry shortly.",
68
+ {},
69
+ "cartethyia",
70
+ );
71
+ });
72
+ if (!authorization) throw unknownApiKeyError();
73
+ const decision = createAccessDecision({
74
+ id: authorization.id,
75
+ tenantId: authorization.tenantId,
76
+ scopes: authorization.scopes,
77
+ });
78
+ if (!decision.scopes.includes("routing:invoke"))
79
+ 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.");
80
+ // Kill switch: refuse before any upstream is touched. Distinct from the
81
+ // scope check above, which is a property of the key — this is the tenant
82
+ // having stopped all traffic, and it applies to every key the tenant owns.
83
+ // 503 rather than 403: nothing about the request is invalid, and the
84
+ // caller should retry later instead of "fixing" a request that is fine.
85
+ if ((await deps.isInvokeDisabled?.(authorization.tenantId)) === true)
86
+ throw new GatewayError(
87
+ "platform_unavailable",
88
+ 503,
89
+ "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.",
90
+ );
91
+ // A key may refuse a downstream router it has been resold to. This is a
92
+ // policy check on the resolved key, so it belongs with the other
93
+ // authorization decisions rather than in routing: the request is refused
94
+ // before a route is planned or an upstream is touched.
95
+ const denied = deniedClientRouter(
96
+ authorization.snapshot.client_router_denylist,
97
+ detectClientRouter({ headers: request.headers }),
98
+ );
99
+ if (denied !== null)
100
+ throw new GatewayError(
101
+ "client_router_denied",
102
+ 403,
103
+ "No API invocation access for this client.",
104
+ { reason: "client_router_denied", clientRouter: denied },
105
+ );
106
+ deps.stateStore.require(request).authorization = authorization;
107
+ })
108
+ .as("plugin");
109
+ }
@@ -7,7 +7,7 @@ import type { TelemetryBatchBuffer, TelemetryEventInput } from "../../observabil
7
7
  import type { TelemetryPayloadCapture } from "../../observability/payload-capture";
8
8
  import { captureEarlyRejectionPayload } from "./early-rejection-capture";
9
9
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
10
- import { reasoningEffortFromIntent } from "../../providers/provider-reasoning";
10
+ import { reasoningEffortFromIntent } from "../../protocol/reasoning";
11
11
  import { metrics } from "../../observability/metrics";
12
12
  import { computeTokensPerSec } from "../../observability/token-speed";
13
13
  import { pushStructuredConsoleLog } from "../../observability/log-ring";