cartethyia 2.7.9 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (375) hide show
  1. package/.env.example +3 -2
  2. package/dist/dashboard/assets/{BannedPage-BxoG1MrL.js → BannedPage-Cc0vKpMG.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-ixwgKk53.js → CliToolDetailPage-Bqj2aa1T.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-BmttNV3U.js → CliToolsPage-CXsaOAO2.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-BE3qwwfC.js → CombosPage-BeLpJ4ma.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CRZdmMIX.js → ConsoleLogPage-DnV0uZ3m.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-Dv_FJ6Er.js → CustomizationPage-CBxAjo63.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
  16. package/dist/dashboard/assets/{InjectSystemPromptPanel-BOoO8rpz.js → InjectSystemPromptPanel-BWFLECmz.js} +2 -2
  17. package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-ChJuNNVN.js → LoginPage-CgoPc5B9.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
  20. package/dist/dashboard/assets/{ModelPicker-BZAJtPg1.js → ModelPicker-D9bFQern.js} +1 -1
  21. package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
  22. package/dist/dashboard/assets/{OverviewPage-BE40s3u5.js → OverviewPage-B_p4tL8k.js} +1 -1
  23. package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +8 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProviderIcon-CQ6bPUf_.js → ProviderIcon-D_0PiZtg.js} +1 -1
  27. package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
  30. package/dist/dashboard/assets/{ProxyPage-BJ87Pp0U.js → ProxyPage-BwNC_-Cx.js} +1 -1
  31. package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-C5HPWqEv.js → SettingsPage-CctTga2C.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
  36. package/dist/dashboard/assets/{SetupPage-CFNcATJJ.js → SetupPage-febLU4r1.js} +1 -1
  37. package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
  38. package/dist/dashboard/assets/{StudioPage-YoD4tY5l.js → StudioPage-M6fvR5Wz.js} +1 -1
  39. package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
  40. package/dist/dashboard/assets/{UsagePage-DxjaAhb_.js → UsagePage-nyeuRqz9.js} +1 -1
  41. package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
  42. package/dist/dashboard/assets/{api-keys-BTlkq_Uk.js → api-keys-BFOlvrJC.js} +1 -1
  43. package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
  44. package/dist/dashboard/assets/app-Dx2QgsNj.css +2 -0
  45. package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
  46. package/dist/dashboard/assets/assert-CqTLw5e7.js +1 -0
  47. package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DdbT_7Am.js → badge-C83EqLB0.js} +1 -1
  49. package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-CsGN-nbm.js → banner-D1xn7pIe.js} +1 -1
  51. package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-mgZ_TLLZ.js → card-DU3Lq5Lm.js} +1 -1
  53. package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-A1-v8-03.js → cli-tools-Dw1U_drK.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-CJ4Zty89.js → clipboard-button-CEL5Sg_J.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-B5L2jPQ_.js → dialog-BjFR5UHy.js} +1 -1
  59. package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-DkjUouAj.js → drawer-HvBW0AjI.js} +1 -1
  61. package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BSu3gFlt.js +4 -0
  63. package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-B-LVLV6o.js → entry-CECVELyy.js} +1 -1
  65. package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
  66. package/dist/dashboard/assets/{entry-CAhdYl3B.js → entry-DYYofQ7W.js} +1 -1
  67. package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
  68. package/dist/dashboard/assets/{formatters-CrsdLVsn.js → formatters-DLW4i8zK.js} +1 -1
  69. package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-BU64Lqvp.js → index-1-fCqe64.js} +2 -2
  71. package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-B2aDLyyg.js → inline-DGvU2oLK.js} +1 -1
  73. package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BCVkdR9d.js → input-iNnO1X2K.js} +1 -1
  75. package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-DNcAdMK-.js → layout-0q8UGypb.js} +1 -1
  77. package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-DgrIH0hw.js → list-row-C0w2bQjJ.js} +1 -1
  79. package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-Cae01iu8.js → live-M5aJ_xCa.js} +1 -1
  81. package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
  82. package/dist/dashboard/assets/{network-CRib7H_2.js → network-BKeQGT3K.js} +1 -1
  83. package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
  84. package/dist/dashboard/assets/{numbers-BKQE8Xpz.js → numbers-CivAk3da.js} +1 -1
  85. package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
  86. package/dist/dashboard/assets/{pagination-ecPvG_8g.js → pagination-CpzvI5ep.js} +1 -1
  87. package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
  88. package/dist/dashboard/assets/providers/zro.svg +3 -0
  89. package/dist/dashboard/assets/providers/zro.svg.gz +0 -0
  90. package/dist/dashboard/assets/quota-DiPD6UKH.js +1 -0
  91. package/dist/dashboard/assets/quota-DiPD6UKH.js.gz +0 -0
  92. package/dist/dashboard/assets/{routing-Ckm1UXuC.js → routing-BFQX2VBc.js} +1 -1
  93. package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
  94. package/dist/dashboard/assets/{select-ChoFWyKz.js → select-CXbck0jG.js} +2 -2
  95. package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
  96. package/dist/dashboard/assets/{settings-CKjFPlKW.js → settings-C_XvMHh9.js} +1 -1
  97. package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
  98. package/dist/dashboard/assets/{stack-DWCwU3fh.js → stack-DggRiCxE.js} +1 -1
  99. package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
  100. package/dist/dashboard/assets/{state-Cg3VL5J_.js → state-ClFzfbYp.js} +1 -1
  101. package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
  102. package/dist/dashboard/assets/{switch-Db70BMix.js → switch-CTIFxKqS.js} +1 -1
  103. package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
  104. package/dist/dashboard/assets/{useMutation-BqdE0LuM.js → useMutation-BClG3yWj.js} +1 -1
  105. package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/dashboard/index.html.gz +0 -0
  108. package/dist/dashboard/precompressed-assets.json +54 -53
  109. package/migrations/full-postgres/0046_add_accounts_quota_plan_fetched_at.sql +7 -0
  110. package/migrations/lite-sqlite3/0046_add_accounts_quota_plan_fetched_at.sql +4 -0
  111. package/package.json +1 -1
  112. package/src/boot/app.ts +10 -13
  113. package/src/boot/dependencies.ts +1 -1
  114. package/src/boot/flush.ts +20 -0
  115. package/src/boot/lifecycle.ts +7 -5
  116. package/src/config/routing.ts +26 -0
  117. package/src/config/spec-metadata.ts +72 -0
  118. package/src/config/spec.ts +20 -0
  119. package/src/console/api-keys/contracts.ts +12 -1
  120. package/src/console/api-keys/routes.ts +40 -28
  121. package/src/console/auth/account-reset.ts +7 -9
  122. package/src/console/backup/contracts.ts +4 -0
  123. package/src/console/backup/export-endpoint.ts +7 -5
  124. package/src/console/cli-tools/service.ts +6 -12
  125. package/src/console/console-router-guards.ts +23 -16
  126. package/src/console/domain-registration.ts +0 -2
  127. package/src/console/inferhub/normalize.ts +24 -28
  128. package/src/console/inferhub/types.ts +3 -0
  129. package/src/console/ip-bans/routes.ts +7 -13
  130. package/src/console/observability/store.ts +0 -53
  131. package/src/console/providers/catalog/contracts.ts +2 -2
  132. package/src/console/providers/catalog/discover-constants.ts +6 -0
  133. package/src/console/providers/catalog/routes.ts +1 -0
  134. package/src/console/providers/catalog/store.ts +21 -16
  135. package/src/console/quota/account-quota-global.ts +14 -24
  136. package/src/console/quota/account-quota-shared.ts +49 -0
  137. package/src/console/quota/account-quota-tenant.ts +6 -18
  138. package/src/console/quota/quota-view.ts +19 -3
  139. package/src/console/routing/pools/operations.ts +31 -46
  140. package/src/console/shared/errors.ts +33 -6
  141. package/src/main.ts +2 -3
  142. package/src/network/pool/byte-accounting.ts +3 -2
  143. package/src/network/pool/selector.ts +182 -39
  144. package/src/network/pool-cooldown-store.ts +89 -14
  145. package/src/network/pool-health.ts +49 -61
  146. package/src/network/rate-limit-verdict.ts +1 -1
  147. package/src/network/ssrf.ts +6 -6
  148. package/src/observability/event-log.ts +7 -361
  149. package/src/observability/performance-metrics.ts +9 -0
  150. package/src/observability/runtime-metrics.ts +14 -9
  151. package/src/observability/telemetry-buffer.ts +15 -25
  152. package/src/observability/telemetry-reconciliation.ts +9 -37
  153. package/src/observability/telemetry-row.ts +125 -0
  154. package/src/persistence/db-mode.ts +4 -1
  155. package/src/persistence/dialect.ts +18 -0
  156. package/src/persistence/migrate.ts +6 -3
  157. package/src/persistence/schema.ts +8 -0
  158. package/src/persistence/ttl-cache.ts +20 -2
  159. package/src/protocol/codecs.ts +1 -1
  160. package/src/protocol/dsml.ts +34 -86
  161. package/src/protocol/markup-stream-parser.ts +195 -0
  162. package/src/protocol/reasoning-text.ts +19 -0
  163. package/src/protocol/request/chat.ts +10 -10
  164. package/src/protocol/request/gemini.ts +8 -2
  165. package/src/protocol/request/messages.ts +3 -4
  166. package/src/protocol/response/chat.ts +12 -20
  167. package/src/protocol/response/omp-tool-call-markup.ts +43 -86
  168. package/src/protocol/response/responses.ts +18 -2
  169. package/src/protocol/stop-reason.ts +23 -1
  170. package/src/protocol/think-split.ts +112 -0
  171. package/src/protocol/translation/dialect/qwen.ts +26 -58
  172. package/src/providers/adapters/compatible-adapter.ts +71 -12
  173. package/src/providers/authentication/oauth-client.ts +1 -1
  174. package/src/providers/authentication/oauth-device-flow.ts +98 -8
  175. package/src/providers/authentication/oauth-refresh-service.ts +30 -49
  176. package/src/providers/configured-provider.ts +148 -0
  177. package/src/providers/default-registry.ts +61 -36
  178. package/src/providers/discovery/probe-phases.ts +14 -4
  179. package/src/providers/discovery/probing-service.ts +15 -2
  180. package/src/providers/integrations/alibabacp/alibabacp-models.ts +1 -2
  181. package/src/providers/integrations/{dashscope-models.ts → alibabacp/dashscope-models.ts} +2 -2
  182. package/src/providers/integrations/antigravity/antigravity-protocol.ts +5 -1
  183. package/src/providers/integrations/buddy/buddy-chat-shared.ts +173 -17
  184. package/src/providers/integrations/buddy/buddy-identity.ts +19 -6
  185. package/src/providers/integrations/buddy/capabilities.ts +2 -2
  186. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +3 -0
  187. package/src/providers/integrations/buddy/codebuddy/provider.ts +4 -0
  188. package/src/providers/integrations/buddy/codebuddy/shared.ts +37 -22
  189. package/src/providers/integrations/claude/claude-quota.ts +14 -17
  190. package/src/providers/integrations/cline/cline.ts +2 -2
  191. package/src/providers/integrations/commandcode/quota.ts +22 -28
  192. package/src/providers/integrations/cursor/cursor-auth.ts +0 -8
  193. package/src/providers/integrations/cursor/cursor-exec.ts +9 -2
  194. package/src/providers/integrations/cursor/cursor-request.ts +26 -12
  195. package/src/providers/integrations/cursor/cursor-shared.ts +7 -4
  196. package/src/providers/integrations/cursor/cursor.ts +89 -40
  197. package/src/providers/integrations/cursor/protocol/messages.ts +22 -29
  198. package/src/providers/integrations/cursor/protocol/protobuf-schema.ts +37 -0
  199. package/src/providers/integrations/devin/protocol/devin-proto.ts +1 -27
  200. package/src/providers/integrations/factory/factory-oauth.ts +17 -44
  201. package/src/providers/integrations/freebuff/assets/official-system-manager.txt +88 -0
  202. package/src/providers/integrations/freebuff/assets/official-system-worker.txt +57 -0
  203. package/src/providers/integrations/freebuff/assets/official-tools.json +1070 -0
  204. package/src/providers/integrations/freebuff/freebuff-catalog.ts +120 -0
  205. package/src/providers/integrations/freebuff/freebuff-constants.ts +1233 -0
  206. package/src/providers/integrations/freebuff/freebuff-discovery.ts +108 -0
  207. package/src/providers/integrations/freebuff/freebuff-oauth.ts +240 -0
  208. package/src/providers/integrations/freebuff/freebuff-quota.ts +260 -0
  209. package/src/providers/integrations/freebuff/freebuff-virtual.ts +89 -0
  210. package/src/providers/integrations/freebuff/freebuff.ts +682 -0
  211. package/src/providers/integrations/grok/grok-oauth.ts +48 -45
  212. package/src/providers/integrations/grok/grok-ratelimit-store.ts +10 -17
  213. package/src/providers/integrations/grok/grok-turn-index.ts +11 -16
  214. package/src/providers/integrations/{hermes.ts → hermes/hermes.ts} +9 -9
  215. package/src/providers/integrations/hermes/index.ts +2 -0
  216. package/src/providers/integrations/kiro/kiro-catalog.ts +17 -1
  217. package/src/providers/integrations/kiro/kiro-discovery.ts +10 -12
  218. package/src/providers/integrations/kiro/kiro-quota.ts +8 -4
  219. package/src/providers/integrations/kiro/kiro.ts +15 -19
  220. package/src/providers/integrations/ollamacloud/index.ts +2 -0
  221. package/src/providers/integrations/{ollamacloud-models.ts → ollamacloud/models.ts} +2 -2
  222. package/src/providers/integrations/{ollamacloud-quota.ts → ollamacloud/quota.ts} +3 -3
  223. package/src/providers/integrations/opencode/opencode-go-quota.ts +2 -8
  224. package/src/providers/integrations/perch/perch-credential.ts +25 -0
  225. package/src/providers/integrations/perch/perch-protocol.ts +0 -3
  226. package/src/providers/integrations/perch/perch-quota.ts +2 -16
  227. package/src/providers/integrations/perch/perch.ts +3 -13
  228. package/src/providers/integrations/qoder/claim-qoder-credits.ts +18 -59
  229. package/src/providers/integrations/qoder/qoder-adapter.ts +1 -12
  230. package/src/providers/integrations/qoder/qoder-auth.ts +101 -29
  231. package/src/providers/integrations/qoder/qoder-catalog.ts +20 -5
  232. package/src/providers/integrations/qoder/qoder-chat.ts +7 -21
  233. package/src/providers/integrations/qoder/qoder-checkin.ts +4 -4
  234. package/src/providers/integrations/qoder/qoder-grpc.ts +0 -1
  235. package/src/providers/integrations/qoder/qoder-ide.ts +0 -107
  236. package/src/providers/integrations/qoder/qoder.ts +0 -1
  237. package/src/providers/integrations/trae/trae-protocol.ts +9 -5
  238. package/src/providers/integrations/trae/trae-quota.ts +6 -28
  239. package/src/providers/integrations/trae/trae-realm.ts +38 -0
  240. package/src/providers/integrations/trae/trae.ts +7 -24
  241. package/src/providers/integrations/zcode-start-plan/provider.ts +226 -23
  242. package/src/providers/integrations/zcode-start-plan/quota.ts +19 -2
  243. package/src/providers/integrations/zro/zro.ts +204 -0
  244. package/src/providers/operations/account-error-classification.ts +199 -307
  245. package/src/providers/operations/account-health-service.ts +82 -1
  246. package/src/providers/operations/account-refresh-status.ts +22 -37
  247. package/src/providers/operations/account-tier.ts +15 -16
  248. package/src/providers/operations/client-version-resolver.ts +1 -1
  249. package/src/providers/operations/client-versions.ts +5 -51
  250. package/src/providers/operations/cooldown-rules.ts +11 -16
  251. package/src/providers/operations/{model-catalog-cache.ts → provider-caches.ts} +68 -16
  252. package/src/providers/operations/provider-catalog-service.ts +1 -1
  253. package/src/providers/operations/provider-credential-service.ts +102 -32
  254. package/src/providers/operations/provider-rules/github.ts +48 -0
  255. package/src/providers/operations/provider-rules/grok.ts +33 -0
  256. package/src/providers/operations/provider-rules/inception.ts +49 -0
  257. package/src/providers/operations/provider-rules/index.ts +32 -0
  258. package/src/providers/operations/provider-rules/perch.ts +37 -0
  259. package/src/providers/operations/provider-rules/qoder.ts +70 -0
  260. package/src/providers/operations/provider-rules/trae.ts +81 -0
  261. package/src/providers/provider-contracts.ts +4 -2
  262. package/src/providers/{provider-credential-resolver.ts → provider-credentials.ts} +40 -3
  263. package/src/providers/provider-metadata.ts +40 -10
  264. package/src/providers/provider-probe.ts +15 -1
  265. package/src/providers/provider-registry.ts +42 -33
  266. package/src/providers/quota/quota-limit-windows.ts +16 -5
  267. package/src/providers/quota/quota-support.ts +1 -1
  268. package/src/providers/quota/quota-window-parser.ts +90 -18
  269. package/src/providers/quota/refresh.ts +75 -18
  270. package/src/security/api-key-auth-cache.ts +1 -1
  271. package/src/security/api-key-auth.ts +6 -2
  272. package/src/transport/dispatch/attempt-finalize.ts +14 -13
  273. package/src/transport/dispatch/attempt-loop.ts +71 -17
  274. package/src/transport/dispatch/generation.ts +2 -8
  275. package/src/transport/dispatch/native-shared.ts +25 -0
  276. package/src/transport/dispatch/proxy-request.ts +8 -46
  277. package/src/transport/dispatch/responses-compact.ts +2 -8
  278. package/src/transport/dispatch/streaming/finalize.ts +9 -6
  279. package/src/transport/dispatch/streaming/stream.ts +8 -36
  280. package/src/transport/dispatch/systemone.ts +2 -8
  281. package/src/transport/dispatch/token-count.ts +2 -6
  282. package/src/transport/dispatch/upstream.ts +63 -0
  283. package/src/transport/drain-endpoint.ts +31 -4
  284. package/src/transport/failure-policy.ts +16 -30
  285. package/src/transport/guards/body-policy.ts +7 -1
  286. package/src/transport/guards/error-lifecycle.ts +13 -1
  287. package/src/transport/guards/request-context.ts +22 -14
  288. package/src/transport/request/preparer.ts +81 -71
  289. package/src/transport/routing/bounded-lru.ts +14 -0
  290. package/src/transport/routing/drain-reserve.ts +129 -0
  291. package/src/transport/routing/plan-gate.ts +7 -1
  292. package/src/transport/routing/round-robin.ts +60 -0
  293. package/src/transport/routing/route-catalog.ts +123 -111
  294. package/src/transport/routing/route-model.ts +51 -9
  295. package/src/transport/routing/router.ts +393 -32
  296. package/src/transport/surface/adapters.ts +11 -2
  297. package/src/transport/surface/chat/adapter.ts +2 -2
  298. package/src/transport/surface/chat/encode.ts +47 -4
  299. package/src/transport/surface/chat/parse.ts +2 -12
  300. package/src/transport/surface/completion.ts +44 -33
  301. package/src/transport/surface/content-parts.ts +21 -0
  302. package/src/transport/surface/messages/adapter.ts +7 -1
  303. package/src/transport/surface/messages/encode.ts +11 -3
  304. package/src/transport/surface/messages/parse.ts +2 -1
  305. package/src/transport/surface/messages/stream.ts +33 -17
  306. package/src/transport/surface/responses/encode.ts +22 -5
  307. package/src/transport/surface/responses/parse.ts +3 -11
  308. package/dist/dashboard/assets/BannedPage-BxoG1MrL.js.gz +0 -0
  309. package/dist/dashboard/assets/CliToolDetailPage-ixwgKk53.js.gz +0 -0
  310. package/dist/dashboard/assets/CliToolsPage-BmttNV3U.js.gz +0 -0
  311. package/dist/dashboard/assets/CombosPage-BE3qwwfC.js.gz +0 -0
  312. package/dist/dashboard/assets/ConsoleLogPage-CRZdmMIX.js.gz +0 -0
  313. package/dist/dashboard/assets/CustomizationPage-Dv_FJ6Er.js.gz +0 -0
  314. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js +0 -1
  315. package/dist/dashboard/assets/HealthEventsModal-CoVrw2sM.js.gz +0 -0
  316. package/dist/dashboard/assets/InjectSystemPromptPanel-BOoO8rpz.js.gz +0 -0
  317. package/dist/dashboard/assets/LoginPage-ChJuNNVN.js.gz +0 -0
  318. package/dist/dashboard/assets/ModelPicker-BZAJtPg1.js.gz +0 -0
  319. package/dist/dashboard/assets/OverviewPage-BE40s3u5.js.gz +0 -0
  320. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js +0 -8
  321. package/dist/dashboard/assets/ProviderDetailPage-sfLoQtPV.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderIcon-CQ6bPUf_.js.gz +0 -0
  323. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js +0 -1
  324. package/dist/dashboard/assets/ProvidersPage-DcepmPWo.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BJ87Pp0U.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-gkSyRAKa.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-C5HPWqEv.js.gz +0 -0
  329. package/dist/dashboard/assets/SetupPage-CFNcATJJ.js.gz +0 -0
  330. package/dist/dashboard/assets/StudioPage-YoD4tY5l.js.gz +0 -0
  331. package/dist/dashboard/assets/UsagePage-DxjaAhb_.js.gz +0 -0
  332. package/dist/dashboard/assets/api-keys-BTlkq_Uk.js.gz +0 -0
  333. package/dist/dashboard/assets/app-BX2nnKqs.css +0 -2
  334. package/dist/dashboard/assets/app-BX2nnKqs.css.gz +0 -0
  335. package/dist/dashboard/assets/assert-CndthXFQ.js +0 -1
  336. package/dist/dashboard/assets/assert-CndthXFQ.js.gz +0 -0
  337. package/dist/dashboard/assets/badge-DdbT_7Am.js.gz +0 -0
  338. package/dist/dashboard/assets/banner-CsGN-nbm.js.gz +0 -0
  339. package/dist/dashboard/assets/card-mgZ_TLLZ.js.gz +0 -0
  340. package/dist/dashboard/assets/cli-tools-A1-v8-03.js.gz +0 -0
  341. package/dist/dashboard/assets/clipboard-button-CJ4Zty89.js.gz +0 -0
  342. package/dist/dashboard/assets/dialog-B5L2jPQ_.js.gz +0 -0
  343. package/dist/dashboard/assets/drawer-DkjUouAj.js.gz +0 -0
  344. package/dist/dashboard/assets/entry-B-LVLV6o.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-CAhdYl3B.js.gz +0 -0
  346. package/dist/dashboard/assets/entry-D2xD0Tmd.js +0 -4
  347. package/dist/dashboard/assets/entry-D2xD0Tmd.js.gz +0 -0
  348. package/dist/dashboard/assets/formatters-CrsdLVsn.js.gz +0 -0
  349. package/dist/dashboard/assets/index-BU64Lqvp.js.gz +0 -0
  350. package/dist/dashboard/assets/inline-B2aDLyyg.js.gz +0 -0
  351. package/dist/dashboard/assets/input-BCVkdR9d.js.gz +0 -0
  352. package/dist/dashboard/assets/layout-DNcAdMK-.js.gz +0 -0
  353. package/dist/dashboard/assets/list-row-DgrIH0hw.js.gz +0 -0
  354. package/dist/dashboard/assets/live-Cae01iu8.js.gz +0 -0
  355. package/dist/dashboard/assets/network-CRib7H_2.js.gz +0 -0
  356. package/dist/dashboard/assets/numbers-BKQE8Xpz.js.gz +0 -0
  357. package/dist/dashboard/assets/pagination-ecPvG_8g.js.gz +0 -0
  358. package/dist/dashboard/assets/quota-DJ1QlAOq.js +0 -1
  359. package/dist/dashboard/assets/quota-DJ1QlAOq.js.gz +0 -0
  360. package/dist/dashboard/assets/routing-Ckm1UXuC.js.gz +0 -0
  361. package/dist/dashboard/assets/select-ChoFWyKz.js.gz +0 -0
  362. package/dist/dashboard/assets/settings-CKjFPlKW.js.gz +0 -0
  363. package/dist/dashboard/assets/stack-DWCwU3fh.js.gz +0 -0
  364. package/dist/dashboard/assets/state-Cg3VL5J_.js.gz +0 -0
  365. package/dist/dashboard/assets/switch-Db70BMix.js.gz +0 -0
  366. package/dist/dashboard/assets/useMutation-BqdE0LuM.js.gz +0 -0
  367. package/src/providers/bundled-discovery-providers.ts +0 -98
  368. package/src/providers/configured-openai-providers.ts +0 -120
  369. package/src/providers/operations/model-discovery-cache.ts +0 -34
  370. package/src/providers/operations/probe-fetch.ts +0 -15
  371. package/src/providers/operations/provider-version-cache.ts +0 -30
  372. package/src/providers/provider-capabilities.ts +0 -33
  373. package/src/providers/provider-credential-envelope.ts +0 -43
  374. /package/dist/dashboard/assets/{app-B7kIWFm5.js → app-DIXLjojg.js} +0 -0
  375. /package/dist/dashboard/assets/{app-B7kIWFm5.js.gz → app-DIXLjojg.js.gz} +0 -0
@@ -2,7 +2,6 @@ import { GatewayError, type GatewayErrorCode, type GatewayErrorOrigin } from "..
2
2
  import { isRecord } from "../protocol/codecs";
3
3
  import { resolveFallbackRetryBaseMs, resolveFallbackRetryCapMs } from "../config/timeouts";
4
4
  import { parseUpstreamBackoff } from "../protocol/upstream-parsing";
5
- import { resolveAccountQuotaCooldownMs } from "../config/cooldowns";
6
5
 
7
6
  /**
8
7
  * How much of the upstream's error body is kept and surfaced.
@@ -260,31 +259,6 @@ export function classifyUpstreamFailure(error: unknown): UpstreamFailurePolicy {
260
259
  // DNS failure. Failing the client request instead of failing over is the wrong
261
260
  // outcome for a whole class the table already names as platform-wide.
262
261
  const statusCode = error.status;
263
- // Qoder answers an exhausted quota by closing the stream with an upstream 499
264
- // (nginx client-close style). That is a refusal from the upstream — the
265
- // gateway's own aborts are `transport_closed`, produced locally, and never
266
- // carry a qoder providerId — so a 499 scoped to qoder is quota, not a
267
- // malformed request. Without this it fell through to `invalid_request`
268
- // (non-retryable, no cooldown) and the account was re-dialled on every
269
- // request inside the exhausted window until a late 429 finally classified it.
270
- if (
271
- error.origin === "upstream" &&
272
- statusCode === 499 &&
273
- (providerId === "qoder" || providerId === "qodercn")
274
- ) {
275
- const cooldownMs = resolveAccountQuotaCooldownMs();
276
- return {
277
- retryable: true,
278
- mutatesAccount: true,
279
- category: "quota_exceeded",
280
- scope: "account",
281
- origin: error.origin,
282
- ...(providerCode ? { providerCode } : {}),
283
- ...(providerId ? { providerId } : {}),
284
- cooldownMs,
285
- statusCode,
286
- };
287
- }
288
262
  const retryableByStatus =
289
263
  statusCode === 401 ||
290
264
  statusCode === 403 ||
@@ -684,10 +658,8 @@ const QUOTA_ERROR_IDENTIFIERS: ReadonlySet<string> = new Set([
684
658
  // quota (a cooldown) instead of `auth_invalidated`.
685
659
  "110",
686
660
  // Qoder also reports quota exhaustion as `{"code":116,"error":"quota
687
- // exceeded"}` (seen on the IDE model-server surface with a 499/stream
688
- // close). The 499 special-case below already treats qoder 499 as quota;
689
- // naming the code here makes any status that carries it classify as quota
690
- // instead of `invalid_request`/`authentication_failed`.
661
+ // exceeded"}`. Naming the code here makes any status that carries it classify
662
+ // as quota instead of `invalid_request`/`authentication_failed`.
691
663
  "116",
692
664
  // InferHub publishes its own vocabulary in `error.code`. The two 403s below
693
665
  // are deliberate absences: `account_blocked` means the account was blocked
@@ -846,6 +818,20 @@ export function classifyUpstreamError(
846
818
  return { code: "proxy_auth_required", status: 407, origin: "network" };
847
819
  if (normalized === "11140")
848
820
  return { code: "policy_rejected", status: 403, origin: "upstream" };
821
+ // ZCode plan-lane envelope codes (verified live 2026-10-30 by the donor and
822
+ // mirrored by the zcode-start provider). `3009` is model concurrency
823
+ // (transient, retried in place), `3008` user concurrency (the desktop client
824
+ // is busy on that account — switchable), `3012` is the identity/prompt-shape
825
+ // gate, `1113` the paid-lane "insufficient balance or no resource package"
826
+ // (a request that was not recognized as start-plan traffic).
827
+ if (normalized === "3009")
828
+ return { code: "rate_limited", status: 429, origin: "upstream" };
829
+ if (normalized === "3008")
830
+ return { code: "accounts_rate_limited", status: 429, origin: "upstream" };
831
+ if (normalized === "3012")
832
+ return { code: "policy_rejected", status: 405, origin: "upstream" };
833
+ if (normalized === "1113")
834
+ return { code: "quota_exceeded", status: 429, origin: "upstream", rateLimitScope: "provider" };
849
835
  if (normalized !== undefined && CONTEXT_LENGTH_CODES.has(normalized))
850
836
  return { code: "context_length_exceeded", status: 413, origin: "upstream" };
851
837
  if (normalized !== undefined && QUOTA_ERROR_IDENTIFIERS.has(normalized))
@@ -58,8 +58,14 @@ export function isProxyDispatchRoute(path: string | undefined): path is string {
58
58
  return path !== undefined && isJsonProxyRoute(path);
59
59
  }
60
60
 
61
+ /**
62
+ * The same canonical JSON-route membership as `isProxyDispatchRoute`, kept as
63
+ * a non-narrowing alias for callers that already hold a string path. One
64
+ * predicate, two spellings, so a route-table change cannot diverge between
65
+ * the body-read policy and the canonical-parse gate.
66
+ */
61
67
  export function isJsonProxyRoutePath(path: string): boolean {
62
- return isJsonProxyRoute(path);
68
+ return isProxyDispatchRoute(path);
63
69
  }
64
70
 
65
71
  interface IngressPolicyOptions {
@@ -77,8 +77,20 @@ export function createErrorNormalizationMiddleware(deps: {
77
77
  .error(({ request, error, set }) => {
78
78
  const state = deps.stateStore.get(request as Request);
79
79
  const gateway = error instanceof GatewayError ? error : undefined;
80
+ // Elysia's built-in errors (NotFound, ValidationError, ParseError,
81
+ // InternalServerError) always carry a numeric `status` **and** a code.
82
+ // A raw runtime error can carry a `.code` on its own — Bun's fetch
83
+ // surfaces `TypeError` with `code: "ECONNRESET"` when an upstream socket
84
+ // dies mid-response — so a `code` alone is not evidence of an Elysia
85
+ // built-in. Requiring the numeric status keeps that raw error from
86
+ // leaking into the public envelope as a fake code/400; the dispatch loop
87
+ // already normalizes socket drops to `transport_unavailable`/502 before
88
+ // they reach here.
80
89
  const builtin =
81
- !gateway && typeof error === "object" && error !== null
90
+ !gateway &&
91
+ typeof error === "object" &&
92
+ error !== null &&
93
+ typeof (error as ElysiaBuiltinError).status === "number"
82
94
  ? (error as ElysiaBuiltinError)
83
95
  : undefined;
84
96
  const builtinStatus = typeof builtin?.status === "number" ? builtin.status : undefined;
@@ -12,7 +12,7 @@ import { resolveClientIdentity } from "../../security/ip-boundary";
12
12
  import { GATEWAY_SECURITY_HEADERS } from "../../security/outbound-headers";
13
13
  import { pushStructuredConsoleLog } from "../../observability/log-ring";
14
14
  import { log } from "../../observability/logger";
15
- import { isJsonProxyRoutePath, isProxyDispatchRoute } from "./body-policy";
15
+ import { isProxyDispatchRoute } from "./body-policy";
16
16
  import { isOpaqueBodyRoute } from "../contracts";
17
17
  import { peerAddressUnavailableError } from "./guard-errors";
18
18
 
@@ -24,6 +24,24 @@ interface RequestApp {
24
24
  ): RequestApp;
25
25
  }
26
26
 
27
+ /**
28
+ * Whether this request falls outside the canonical JSON-proxy pipeline the
29
+ * canonical-parse and route-preparation stages serve.
30
+ *
31
+ * Shared gate, applied in the exact order both middlewares historically
32
+ * checked: non-`/v1/` paths, GET/HEAD (which carry no body to parse), then
33
+ * the JSON route table with opaque-body routes excluded — `/v1/completions`
34
+ * needs no separate arm because it is already in the JSON routes table, and
35
+ * native routes (compact, System One, token count) read a body but are never
36
+ * parsed into a canonical request, their bodies being opaque by contract.
37
+ */
38
+ function skipNonCanonicalProxyRequest(request: Request): boolean {
39
+ const path = fastPathname(request.url);
40
+ if (!path.startsWith("/v1/")) return true;
41
+ if (request.method === "GET" || request.method === "HEAD") return true;
42
+ return !isProxyDispatchRoute(path) || isOpaqueBodyRoute(path);
43
+ }
44
+
27
45
  interface RequestContextDeps {
28
46
  readonly stateStore: ProxyRequestStateStore;
29
47
  readonly clock?: () => number;
@@ -141,15 +159,9 @@ export function createCanonicalRequestMiddleware(deps: {
141
159
  }): Elysia {
142
160
  return new Elysia()
143
161
  .beforeHandle(async ({ request }) => {
144
- const path = fastPathname(request.url);
145
- if (!path.startsWith("/v1/")) return;
146
- if (request.method === "GET" || request.method === "HEAD") return;
147
- // `/v1/completions` needs no separate arm: it is already in
148
- // the JSON routes table, so the body-policy predicate covers it.
149
- // Native routes (compact, System One) read a body but are never parsed
150
- // into a canonical request — their bodies are opaque by contract.
151
- if (!isJsonProxyRoutePath(path) || isOpaqueBodyRoute(path)) return;
152
162
  // Model discovery and other GET/multimodal routes must not go through canonical parsing.
163
+ if (skipNonCanonicalProxyRequest(request)) return;
164
+ const path = fastPathname(request.url);
153
165
  const state = deps.stateStore.require(request);
154
166
  // Single-read invariant: the ingress policy middleware already decoded the
155
167
  // body once into `state.ingressBody`. Never re-read `request.body` here —
@@ -216,11 +228,7 @@ export function createProxyRoutePreparationMiddleware(deps: {
216
228
  }): Elysia {
217
229
  return new Elysia()
218
230
  .beforeHandle(async ({ request }) => {
219
- const path = fastPathname(request.url);
220
- if (!path.startsWith("/v1/")) return;
221
- if (request.method === "GET" || request.method === "HEAD") return;
222
- const isJsonRoute = isJsonProxyRoutePath(path);
223
- if (!isJsonRoute || isOpaqueBodyRoute(path)) return;
231
+ if (skipNonCanonicalProxyRequest(request)) return;
224
232
  const state = deps.stateStore.require(request);
225
233
  if (!state.authorization || !state.canonicalRequest)
226
234
  throw new GatewayError("route_unavailable", 503, "The request was not authenticated and parsed, so this route cannot be served (request {requestId}). Resend the request. The bouncer stepped out — resend shortly 🚪");
@@ -5,7 +5,7 @@ import type { ApiKeyAdmissionService } from "../../security/admission/service";
5
5
  import type { VisionHandoffCall, VisionHandoffResult } from "../vision-handoff";
6
6
  import { isImageOrPlaceholderPart, lastImageCarryingUserTurnIndex } from "../vision-handoff";
7
7
  import type { CallerIdentity } from "../../security/access-control";
8
- import type { RouteCandidate as RouteCandidate, InMemoryRouteSnapshotService, RoutePlan } from "../routing/route-model";
8
+ import type { RouteCandidate as RouteCandidate, InMemoryRouteSnapshotService, RoutePlan, RouteSnapshot } from "../routing/route-model";
9
9
  import type { RouteContinuationAffinity } from "../routing/route-model";
10
10
  import { resolveAliasTarget, type RoutingEngine } from "../routing/router";
11
11
  import { projectForRoute, routeCapabilitiesFor, deriveRequiredCapabilities } from "../../protocol/translation/capabilities";
@@ -80,7 +80,17 @@ function replaceImagePartsWithText(
80
80
  }
81
81
  const hasImage = message.content.some(isImageOrPlaceholderPart);
82
82
  if (!hasImage) return message;
83
- return { ...message, content: message.content.filter((part) => !isImageOrPlaceholderPart(part)) };
83
+ // Never delete images silently (proposal Option E): replace a dropped
84
+ // older image with a marker so the main model at least knows something
85
+ // was there, instead of the loss being invisible.
86
+ return {
87
+ ...message,
88
+ content: message.content.flatMap((part) =>
89
+ isImageOrPlaceholderPart(part)
90
+ ? [{ kind: "text" as const, text: "[image omitted]" }]
91
+ : [part],
92
+ ),
93
+ };
84
94
  });
85
95
  const unchangedMessages = nextMessages.every(
86
96
  (message, i) => message === request.messages[i],
@@ -350,6 +360,61 @@ export class ProxyRequestPreparer {
350
360
 
351
361
  constructor(private readonly deps: ProxyRequestPreparerDeps) {}
352
362
 
363
+ /**
364
+ * The key's model-admission gate, shared by the canonical and native
365
+ * preparation paths: CLI-mapping eligibility → alias resolution → the
366
+ * model allowlist → the model-prefix gate, in that exact order. Rejects
367
+ * with the same `model_not_allowed` 403s (same code, status, message, and
368
+ * detail fields) on both paths, so a policy change here cannot silently
369
+ * split admission between the chat and native surfaces. Returns the state
370
+ * the gate computed that the caller still needs for routing: the resolved
371
+ * alias target and whether CLI mappings were eligible.
372
+ */
373
+ #resolveAliasAndGateModel(
374
+ model: string,
375
+ authorization: ResolvedApiKey,
376
+ clientUserAgent: string | undefined,
377
+ snapshot: RouteSnapshot,
378
+ ): { readonly resolvedTarget: string; readonly allowCliMappings: boolean } {
379
+ // Scope opts the key into the mapping table; the User-Agent decides whether
380
+ // *this* request may consume it. Without the UA gate, a Claude→DeepSeek
381
+ // remap would also rewrite a non-Claude caller that named `opus`.
382
+ const allowCliMappings = allowsCliToolMappings(authorization.scopes, {
383
+ ...(clientUserAgent === undefined ? {} : { userAgent: clientUserAgent }),
384
+ });
385
+ // CLI source→target mappings are an explicit API-key capability. Ordinary
386
+ // tenant aliases remain available to every key; only selected keys may
387
+ // consume the CLI mapping table.
388
+ const resolvedTarget = resolveAliasTarget(
389
+ snapshot,
390
+ authorization.tenantId,
391
+ model,
392
+ allowCliMappings,
393
+ authorization.cliMappingOwnerId ?? authorization.id,
394
+ );
395
+ // Pass `model` as the requested name so an allowlisted alias / a CLI
396
+ // remapping can authorize the resolved target (see modelRejectionReason).
397
+ const allowedForKey = isModelAllowed(authorization.snapshot, resolvedTarget, undefined, model);
398
+ if (
399
+ authorization.modelPrefix &&
400
+ !model.startsWith(authorization.modelPrefix) &&
401
+ !resolvedTarget.startsWith(authorization.modelPrefix) &&
402
+ !allowedForKey
403
+ )
404
+ throw new GatewayError(
405
+ "model_not_allowed",
406
+ 403,
407
+ `This key may only use models starting with '${authorization.modelPrefix}' (request {requestId}). Ask for one inside that prefix, or have an operator widen the key. The door's locked for this one — check the allowlist 🔒`,
408
+ { model, required_prefix: authorization.modelPrefix },
409
+ );
410
+ if (!allowedForKey) {
411
+ throw new GatewayError("model_not_allowed", 403, "model is not allowed for this API key (request {requestId}). The door's locked for this one — check the allowlist 🔒", {
412
+ model,
413
+ });
414
+ }
415
+ return { resolvedTarget, allowCliMappings };
416
+ }
417
+
353
418
  /** Injects the vision-handoff runner (called once from boot wiring). */
354
419
  bindVisionHandoff(
355
420
  runner: (input: VisionHandoffCall) => Promise<readonly VisionHandoffResult[]>,
@@ -391,47 +456,12 @@ export class ProxyRequestPreparer {
391
456
  ? initialRequest
392
457
  : withThinkingSuffixIntent({ ...initialRequest, model: bareModel }, thinkingIntent);
393
458
  const snapshot = await this.deps.snapshotService.getSnapshot();
394
- // Scope opts the key into the mapping table; the User-Agent decides whether
395
- // *this* request may consume it. Without the UA gate, a Claude→DeepSeek
396
- // remap would also rewrite a non-Claude caller that named `opus`.
397
- const allowCliMappings = allowsCliToolMappings(authorization.scopes, {
398
- ...(input.clientUserAgent === undefined ? {} : { userAgent: input.clientUserAgent }),
399
- });
400
- // CLI source→target mappings are an explicit API-key capability. Ordinary
401
- // tenant aliases remain available to every key; only selected keys may
402
- // consume the CLI mapping table.
403
- const resolvedTarget = resolveAliasTarget(
404
- snapshot,
405
- authorization.tenantId,
406
- request.model,
407
- allowCliMappings,
408
- authorization.cliMappingOwnerId ?? authorization.id,
409
- );
410
- // Pass `request.model` as the requested name so an allowlisted alias / a
411
- // CLI remapping can authorize the resolved target (see modelRejectionReason).
412
- const allowedForKey = isModelAllowed(
413
- authorization.snapshot,
414
- resolvedTarget,
415
- undefined,
459
+ const { allowCliMappings } = this.#resolveAliasAndGateModel(
416
460
  request.model,
461
+ authorization,
462
+ input.clientUserAgent,
463
+ snapshot,
417
464
  );
418
- if (
419
- authorization.modelPrefix &&
420
- !request.model.startsWith(authorization.modelPrefix) &&
421
- !resolvedTarget.startsWith(authorization.modelPrefix) &&
422
- !allowedForKey
423
- )
424
- throw new GatewayError(
425
- "model_not_allowed",
426
- 403,
427
- `This key may only use models starting with '${authorization.modelPrefix}' (request {requestId}). Ask for one inside that prefix, or have an operator widen the key. The door's locked for this one — check the allowlist 🔒`,
428
- { model: request.model, required_prefix: authorization.modelPrefix },
429
- );
430
- if (!allowedForKey) {
431
- throw new GatewayError("model_not_allowed", 403, "model is not allowed for this API key (request {requestId}). The door's locked for this one — check the allowlist 🔒", {
432
- model: request.model,
433
- });
434
- }
435
465
  // An inline image that cannot survive the upstream is dropped here rather
436
466
  // than dispatched: a provider rejects the whole request for one corrupt
437
467
  // attachment, and the buddy family reports it with no field named, so the
@@ -474,6 +504,7 @@ export class ProxyRequestPreparer {
474
504
  allowCliMappings,
475
505
  authorization.cliMappingOwnerId ?? authorization.id,
476
506
  continuationAffinity,
507
+ request.conversation_id ?? request.conversation?.conversation_id,
477
508
  );
478
509
  } catch (error) {
479
510
  // `plan()` collapses "no candidate supports this capability" into
@@ -541,6 +572,7 @@ export class ProxyRequestPreparer {
541
572
  allowCliMappings,
542
573
  authorization.cliMappingOwnerId ?? authorization.id,
543
574
  continuationAffinity,
575
+ request.conversation_id ?? request.conversation?.conversation_id,
544
576
  );
545
577
  plan = handoffPlan;
546
578
  variantRequest = replaced;
@@ -725,42 +757,18 @@ export class ProxyRequestPreparer {
725
757
  readonly clientUserAgent?: string;
726
758
  readonly eligible: (candidate: RouteCandidate) => boolean;
727
759
  readonly emptyMessage: string;
760
+ /** Session identity for sticky routing (conversation id when present). */
761
+ readonly conversationId?: string;
728
762
  }): Promise<PreparedNativeRequest> {
729
763
  if (input.signal?.aborted)
730
764
  throw new GatewayError("transport_closed", 499, "The request was cancelled (request {requestId}). Send it again whenever ready 🙃");
731
765
  const snapshot = await this.deps.snapshotService.getSnapshot();
732
- const allowCliMappings = allowsCliToolMappings(input.authorization.scopes, {
733
- ...(input.clientUserAgent === undefined ? {} : { userAgent: input.clientUserAgent }),
734
- });
735
- const resolvedTarget = resolveAliasTarget(
736
- snapshot,
737
- input.authorization.tenantId,
738
- input.model,
739
- allowCliMappings,
740
- input.authorization.cliMappingOwnerId ?? input.authorization.id,
741
- );
742
- const allowedForKey = isModelAllowed(
743
- input.authorization.snapshot,
744
- resolvedTarget,
745
- undefined,
766
+ const { allowCliMappings } = this.#resolveAliasAndGateModel(
746
767
  input.model,
768
+ input.authorization,
769
+ input.clientUserAgent,
770
+ snapshot,
747
771
  );
748
- if (
749
- input.authorization.modelPrefix &&
750
- !input.model.startsWith(input.authorization.modelPrefix) &&
751
- !resolvedTarget.startsWith(input.authorization.modelPrefix) &&
752
- !allowedForKey
753
- )
754
- throw new GatewayError(
755
- "model_not_allowed",
756
- 403,
757
- `This key may only use models starting with '${input.authorization.modelPrefix}' (request {requestId}). Ask for one inside that prefix, or have an operator widen the key. The door's locked for this one — check the allowlist 🔒`,
758
- { model: input.model, required_prefix: input.authorization.modelPrefix },
759
- );
760
- if (!allowedForKey)
761
- throw new GatewayError("model_not_allowed", 403, "model is not allowed for this API key (request {requestId}). The door's locked for this one — check the allowlist 🔒", {
762
- model: input.model,
763
- });
764
772
  const plan = await this.deps.routingEngine.plan(
765
773
  input.model,
766
774
  snapshot,
@@ -768,6 +776,8 @@ export class ProxyRequestPreparer {
768
776
  undefined,
769
777
  allowCliMappings,
770
778
  input.authorization.cliMappingOwnerId ?? input.authorization.id,
779
+ undefined,
780
+ input.conversationId,
771
781
  );
772
782
  if (input.signal?.aborted)
773
783
  throw new GatewayError("transport_closed", 499, "The request was cancelled (request {requestId}). Send it again whenever ready 🙃");
@@ -1,5 +1,19 @@
1
1
  // Bounded LRU map: the one shared implementation for routing-layer caches.
2
2
 
3
+ /**
4
+ * Evicts the oldest-inserted entry while `map.size > cap`. The shared
5
+ * insertion-order bound for caches whose read path must not refresh recency
6
+ * (unlike `BoundedLruMap`, whose `get` does). Eviction is one `keys().next()`
7
+ * and no bookkeeping arrays.
8
+ */
9
+ export function evictOldestEntry<K>(map: Map<K, unknown>, cap: number): void {
10
+ while (map.size > cap) {
11
+ const oldest = map.keys().next().value;
12
+ if (oldest === undefined) break;
13
+ map.delete(oldest);
14
+ }
15
+ }
16
+
3
17
  /**
4
18
  * A capacity-bounded LRU `Map`.
5
19
  *
@@ -0,0 +1,129 @@
1
+ // Drain urgency and reserve scoring for candidate ordering.
2
+ //
3
+ // Ported from oh-my-pi's `windowRequiredDrain` and reserve semantics
4
+ // (`packages/ai/src/auth/usage-report.ts`, `policy.ts`): within the same rank
5
+ // group, an account whose remaining quota is about to expire unused should be
6
+ // burned first ("use it or lose it"), and an account parked in reserve (at or
7
+ // below the reserve percent) should yield to non-reserve siblings.
8
+ import {
9
+ totalRemainingPercent,
10
+ type ProviderQuotaWindow,
11
+ } from "../../providers/quota/quota-contracts";
12
+
13
+ /** Minimum time before reset that still counts as "some headroom" (1 minute). */
14
+ const MIN_REMAINING_MS = 60_000;
15
+
16
+ /** Usable fraction of a window: 0..1, with 0.5 neutral when unmeasured. */
17
+ function usedFraction(window: ProviderQuotaWindow): number {
18
+ const used = window.usedPercent;
19
+ if (typeof used === "number" && Number.isFinite(used)) {
20
+ return Math.min(1, Math.max(0, used / 100));
21
+ }
22
+ const remaining = window.remainingPercent;
23
+ if (typeof remaining === "number" && Number.isFinite(remaining)) {
24
+ return Math.min(1, Math.max(0, 1 - remaining / 100));
25
+ }
26
+ if (typeof window.used === "number" && Number.isFinite(window.used)) {
27
+ if (typeof window.limit === "number" && Number.isFinite(window.limit) && window.limit > 0) {
28
+ return Math.min(1, Math.max(0, window.used / window.limit));
29
+ }
30
+ }
31
+ if (typeof window.remaining === "number" && Number.isFinite(window.remaining)) {
32
+ if (typeof window.limit === "number" && Number.isFinite(window.limit) && window.limit > 0) {
33
+ return Math.min(1, Math.max(0, 1 - window.remaining / window.limit));
34
+ }
35
+ }
36
+ return 0.5;
37
+ }
38
+
39
+ /** Reset instant (epoch ms) for a window, or undefined when it has no clock. */
40
+ function resetAtMs(window: ProviderQuotaWindow): number | undefined {
41
+ if (window.resetsAt === null || window.resetsAt === undefined) return undefined;
42
+ const at = Date.parse(window.resetsAt);
43
+ return Number.isFinite(at) ? at : undefined;
44
+ }
45
+
46
+ /**
47
+ * Drain score for the most-urgent relevant window: headroom fraction divided by
48
+ * remaining hours until reset. Higher means quota that is about to expire
49
+ * unused and should be burned first. Returns 0 when no window carries usable
50
+ * evidence (the candidate is neutral, never penalized).
51
+ */
52
+ export function drainScore(
53
+ windows: readonly ProviderQuotaWindow[] | undefined,
54
+ nowMs = Date.now(),
55
+ ): number {
56
+ if (windows === undefined || windows.length === 0) return 0;
57
+ let best = 0;
58
+ for (const window of windows) {
59
+ const headroom = 1 - usedFraction(window);
60
+ if (headroom <= 0) continue;
61
+ const resetAt = resetAtMs(window);
62
+ const durationMs = windowAccountingDurationMs(window);
63
+ let remainingMs = resetAt === undefined ? (durationMs ?? MIN_REMAINING_MS) : resetAt - nowMs;
64
+ if (durationMs !== undefined && durationMs > 0) {
65
+ remainingMs = Math.min(remainingMs, durationMs);
66
+ }
67
+ const remainingHours = Math.max(remainingMs, MIN_REMAINING_MS) / (60 * 60 * 1000);
68
+ best = Math.max(best, headroom / remainingHours);
69
+ }
70
+ return best;
71
+ }
72
+
73
+ function windowAccountingDurationMs(window: ProviderQuotaWindow): number | undefined {
74
+ // Providers state window durations in the kind label (e.g. "5h", "7d") when
75
+ // they do not carry an explicit duration field. Parse the common spellings;
76
+ // an unparseable label contributes no clock.
77
+ const text = `${window.kind} ${window.label}`.toLowerCase();
78
+ const m = /(\d+(?:\.\d+)?)\s*(h|hour|hr|d|day|m|min|minute|s|sec|second)\b/.exec(text);
79
+ if (!m) return undefined;
80
+ const value = Number(m[1]);
81
+ if (!Number.isFinite(value)) return undefined;
82
+ const unit = m[2];
83
+ if (unit === "h" || unit === "hour" || unit === "hr") return value * 60 * 60 * 1000;
84
+ if (unit === "d" || unit === "day") return value * 24 * 60 * 60 * 1000;
85
+ if (unit === "m" || unit === "min" || unit === "minute") return value * 60 * 1000;
86
+ if (unit === "s" || unit === "sec" || unit === "second") return value * 1000;
87
+ return undefined;
88
+ }
89
+
90
+ /**
91
+ * The account's remaining fraction (0..1) across its pooled credit and
92
+ * independent windows, or undefined when nothing is measured. Mirrors
93
+ * `totalRemainingPercent` (the router's existing aggregate) but normalized to a
94
+ * 0..1 fraction.
95
+ */
96
+ export function remainingFraction(
97
+ windows: readonly ProviderQuotaWindow[] | undefined,
98
+ ): number | undefined {
99
+ if (windows === undefined || windows.length === 0) return undefined;
100
+ const percent = totalRemainingPercent(windows);
101
+ if (percent === null) return undefined;
102
+ return Math.min(1, Math.max(0, percent / 100));
103
+ }
104
+
105
+ /** True when the account's remaining fraction is at or below the reserve percent. */
106
+ export function isInReserve(
107
+ windows: readonly ProviderQuotaWindow[] | undefined,
108
+ reservePct: number,
109
+ ): boolean {
110
+ if (reservePct <= 0) return false;
111
+ const fraction = remainingFraction(windows);
112
+ if (fraction === undefined) return false;
113
+ return fraction * 100 <= reservePct;
114
+ }
115
+
116
+ /**
117
+ * True when a reserved account has healed above the reclaim threshold and may be
118
+ * reclaimed to full priority.
119
+ */
120
+ export function isReclaimReady(
121
+ windows: readonly ProviderQuotaWindow[] | undefined,
122
+ reservePct: number,
123
+ reclaimAbovePct: number,
124
+ ): boolean {
125
+ if (reservePct <= 0 || reclaimAbovePct <= reservePct) return false;
126
+ const fraction = remainingFraction(windows);
127
+ if (fraction === undefined) return false;
128
+ return fraction * 100 >= reclaimAbovePct;
129
+ }
@@ -17,7 +17,13 @@
17
17
  * costs one refused request that the cooldown machinery already handles; a
18
18
  * wrong paid match silently blackholes a model.
19
19
  */
20
- const FREE_PLAN_MARKERS: readonly string[] = ["free"];
20
+ /**
21
+ * The marker set that proves a plan string is free. Exported so every
22
+ * free-plan consumer — the routing plan gate here and the cooldown-tier
23
+ * derivation in `account-tier.ts` — reads the same list, so the two cannot
24
+ * drift apart about an account.
25
+ */
26
+ export const FREE_PLAN_MARKERS: readonly string[] = ["free"];
21
27
 
22
28
  export function isFreePlanAccount(plan: string | null | undefined): boolean {
23
29
  if (plan === null || plan === undefined) return false;
@@ -36,3 +36,63 @@ const MAX_ROUND_ROBIN_ENTRIES = 1000;
36
36
  export function createRoundRobinMap(): BoundedLruMap<string, RoundRobinState> {
37
37
  return new BoundedLruMap<string, RoundRobinState>(MAX_ROUND_ROBIN_ENTRIES);
38
38
  }
39
+
40
+ /** One session's sticky account: which provider account last served it, and when. */
41
+ export interface StickySession {
42
+ readonly accountId: string;
43
+ readonly lastUsedAtMs: number;
44
+ /** Set only by an explicit operator pin; automatic warm stickiness leaves it absent. */
45
+ readonly explicit?: true;
46
+ }
47
+
48
+ /**
49
+ * Session → account stickiness per provider, bounded by an LRU. Used by the
50
+ * router to prefer the account that last served a session within the warm
51
+ * window — prompt-cache warmth is worth more than perfect load balance. A
52
+ * session id that is absent, evicted, or past the warm window simply carries
53
+ * no preference.
54
+ */
55
+ export class StickySessionStore {
56
+ private readonly byProvider = new Map<string, BoundedLruMap<string, StickySession>>();
57
+
58
+ constructor(private readonly maxSessionsPerProvider: number) {}
59
+
60
+ private mapFor(providerId: string): BoundedLruMap<string, StickySession> {
61
+ let map = this.byProvider.get(providerId);
62
+ if (map === undefined) {
63
+ map = new BoundedLruMap<string, StickySession>(this.maxSessionsPerProvider);
64
+ this.byProvider.set(providerId, map);
65
+ }
66
+ return map;
67
+ }
68
+
69
+ /** The account last pinned to this session, or undefined. */
70
+ get(providerId: string, sessionId: string | undefined): StickySession | undefined {
71
+ if (sessionId === undefined) return undefined;
72
+ return this.mapFor(providerId).get(sessionId);
73
+ }
74
+
75
+ /** Record that `accountId` served `sessionId` now (or pin it explicitly). */
76
+ set(providerId: string, sessionId: string | undefined, accountId: string, explicit = false): void {
77
+ if (sessionId === undefined) return;
78
+ const now = Date.now();
79
+ this.mapFor(providerId).set(sessionId, {
80
+ accountId,
81
+ lastUsedAtMs: now,
82
+ ...(explicit ? { explicit: true as const } : {}),
83
+ });
84
+ }
85
+
86
+ /** Drop one session's stickiness (e.g. after the account is invalidated). */
87
+ clear(providerId: string, sessionId: string | undefined): void {
88
+ if (sessionId === undefined) return;
89
+ this.mapFor(providerId).delete(sessionId);
90
+ }
91
+
92
+ /** Total retained sticky entries across providers (for metrics/tests). */
93
+ size(): number {
94
+ let total = 0;
95
+ for (const map of this.byProvider.values()) total += map.size;
96
+ return total;
97
+ }
98
+ }