cartethyia 2.7.2 → 2.7.4

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 (405) hide show
  1. package/.env.example +2 -0
  2. package/dist/dashboard/assets/{BannedPage-XaUmiBrC.js → BannedPage-ChG5svFb.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-ChG5svFb.js.gz +0 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js +1 -0
  5. package/dist/dashboard/assets/CliToolDetailPage-BIcWQun7.js.gz +0 -0
  6. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js +1 -0
  7. package/dist/dashboard/assets/CliToolsPage-Bf2VelDe.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-CXGS_Ct-.js → CombosPage-DCgm5g6g.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-DCgm5g6g.js.gz +0 -0
  10. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js +1 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-a8mD0QF1.js.gz +0 -0
  12. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js +1 -0
  13. package/dist/dashboard/assets/CustomizationPage-ggeMSjte.js.gz +0 -0
  14. package/dist/dashboard/assets/{HealthEventsModal-BKtAPYVF.js → HealthEventsModal-DzSWzE_j.js} +1 -1
  15. package/dist/dashboard/assets/HealthEventsModal-DzSWzE_j.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-BdmRLnq2.js → LoginPage-U6AKaJcF.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-U6AKaJcF.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-BH8Tehdi.js → ModelPicker-BMS2t8BA.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-BMS2t8BA.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-D2D4IiHl.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js +5 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-CrNf7j_X.js.gz +0 -0
  24. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js +1 -0
  25. package/dist/dashboard/assets/ProvidersPage-BSDbquc1.js.gz +0 -0
  26. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js +9 -0
  27. package/dist/dashboard/assets/ProxyPage-lsrx7NYg.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-L0skzML8.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-L0skzML8.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-CmAbjRHW.js.gz +0 -0
  32. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js +1 -0
  33. package/dist/dashboard/assets/SetupPage-f0DE1h0-.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Dqf4zpS-.js → SortableList-ClzpwyET.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-ClzpwyET.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-Byj2lAEm.js → StudioPage-Cq5tW7hW.js} +3 -3
  37. package/dist/dashboard/assets/StudioPage-Cq5tW7hW.js.gz +0 -0
  38. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js +56 -0
  39. package/dist/dashboard/assets/UsagePage-Dfc9rWQn.js.gz +0 -0
  40. package/dist/dashboard/assets/api-keys-C38iXoO4.js +1 -0
  41. package/dist/dashboard/assets/api-keys-C38iXoO4.js.gz +0 -0
  42. package/dist/dashboard/assets/app-a159k0ub.css +2 -0
  43. package/dist/dashboard/assets/app-a159k0ub.css.gz +0 -0
  44. package/dist/dashboard/assets/arrow-up-59ms1iiT.js +1 -0
  45. package/dist/dashboard/assets/arrow-up-59ms1iiT.js.gz +0 -0
  46. package/dist/dashboard/assets/assert-DbRA8EG2.js +15 -0
  47. package/dist/dashboard/assets/assert-DbRA8EG2.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-DC2L43Mj.js → badge-D9CS6dZk.js} +1 -1
  49. package/dist/dashboard/assets/badge-D9CS6dZk.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-BBVcb0J3.js → banner-DympBNq4.js} +1 -1
  51. package/dist/dashboard/assets/banner-DympBNq4.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-Cca1XC6G.js → card-ibRWU_uT.js} +1 -1
  53. package/dist/dashboard/assets/card-ibRWU_uT.js.gz +0 -0
  54. package/dist/dashboard/assets/cli-tools-63qBGaRw.js +1 -0
  55. package/dist/dashboard/assets/cli-tools-63qBGaRw.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-DUzEbynq.js → clipboard-button-DJm-9pz7.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-DJm-9pz7.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-DFvWhwTa.js → dialog-8fiE6tec.js} +1 -1
  59. package/dist/dashboard/assets/dialog-8fiE6tec.js.gz +0 -0
  60. package/dist/dashboard/assets/drawer-CqGYeOrd.js +1 -0
  61. package/dist/dashboard/assets/drawer-CqGYeOrd.js.gz +0 -0
  62. package/dist/dashboard/assets/{entry-Bqy8RFvM.js → entry-BSAsYsmV.js} +1 -1
  63. package/dist/dashboard/assets/entry-BSAsYsmV.js.gz +0 -0
  64. package/dist/dashboard/assets/entry-CiIQULZD.js +4 -0
  65. package/dist/dashboard/assets/entry-CiIQULZD.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-Daz0lJ-W.js +1 -0
  67. package/dist/dashboard/assets/entry-Daz0lJ-W.js.gz +0 -0
  68. package/dist/dashboard/assets/{concurrency-BDF0ok7y.js → fingerprint-pattern-BWuHtbW4.js} +1 -1
  69. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  70. package/dist/dashboard/assets/{formatters-CWWZ-uCZ.js → formatters-D-MTJxMr.js} +1 -1
  71. package/dist/dashboard/assets/formatters-D-MTJxMr.js.gz +0 -0
  72. package/dist/dashboard/assets/{github-badge-C9e8Ts7e.js → github-badge-CPK47xQx.js} +1 -1
  73. package/dist/dashboard/assets/github-badge-CPK47xQx.js.gz +0 -0
  74. package/dist/dashboard/assets/{index-DFVFrnWW.js → index-DBNPOfhV.js} +2 -2
  75. package/dist/dashboard/assets/index-DBNPOfhV.js.gz +0 -0
  76. package/dist/dashboard/assets/{inline-BFUcvllj.js → inline-BT0zntEr.js} +1 -1
  77. package/dist/dashboard/assets/inline-BT0zntEr.js.gz +0 -0
  78. package/dist/dashboard/assets/{input-C_ILAvBb.js → input-Da2ZViWg.js} +1 -1
  79. package/dist/dashboard/assets/input-Da2ZViWg.js.gz +0 -0
  80. package/dist/dashboard/assets/{layout-CBIyT6Rt.js → layout-BXNeLw6M.js} +1 -1
  81. package/dist/dashboard/assets/layout-BXNeLw6M.js.gz +0 -0
  82. package/dist/dashboard/assets/{list-row-ebJ_-Nsb.js → list-row-BycOrrIV.js} +1 -1
  83. package/dist/dashboard/assets/list-row-BycOrrIV.js.gz +0 -0
  84. package/dist/dashboard/assets/live-DCQNZpF7.js +1 -0
  85. package/dist/dashboard/assets/live-DCQNZpF7.js.gz +0 -0
  86. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +1 -0
  87. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
  88. package/dist/dashboard/assets/{mutation-sMD965oO.js → mutation-Rr0TIvcj.js} +1 -1
  89. package/dist/dashboard/assets/mutation-Rr0TIvcj.js.gz +0 -0
  90. package/dist/dashboard/assets/network-9Yj2ZyLo.js +1 -0
  91. package/dist/dashboard/assets/network-9Yj2ZyLo.js.gz +0 -0
  92. package/dist/dashboard/assets/{numbers-BO9dKaFr.js → numbers-CEagj6ZZ.js} +2 -2
  93. package/dist/dashboard/assets/numbers-CEagj6ZZ.js.gz +0 -0
  94. package/dist/dashboard/assets/{query-keys-CfL_Zdoh.js → query-keys-BloKtaXJ.js} +1 -1
  95. package/dist/dashboard/assets/query-keys-BloKtaXJ.js.gz +0 -0
  96. package/dist/dashboard/assets/quota-Caw7wqci.js +1 -0
  97. package/dist/dashboard/assets/quota-Caw7wqci.js.gz +0 -0
  98. package/dist/dashboard/assets/routing-CO9zCUus.js +1 -0
  99. package/dist/dashboard/assets/routing-CO9zCUus.js.gz +0 -0
  100. package/dist/dashboard/assets/{select-DBNVLdUu.js → select-BIYIBkMU.js} +2 -2
  101. package/dist/dashboard/assets/select-BIYIBkMU.js.gz +0 -0
  102. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js +1 -0
  103. package/dist/dashboard/assets/settings-2-Ds9DQp0t.js.gz +0 -0
  104. package/dist/dashboard/assets/settings-jS3-PNSA.js +1 -0
  105. package/dist/dashboard/assets/settings-jS3-PNSA.js.gz +0 -0
  106. package/dist/dashboard/assets/{stack-EMrOMt4I.js → stack-DfgnFDWY.js} +1 -1
  107. package/dist/dashboard/assets/stack-DfgnFDWY.js.gz +0 -0
  108. package/dist/dashboard/assets/{state-CWoo7ty5.js → state-CkfdYWel.js} +1 -1
  109. package/dist/dashboard/assets/state-CkfdYWel.js.gz +0 -0
  110. package/dist/dashboard/assets/{switch-CCjT6IkF.js → switch-SYm237HG.js} +1 -1
  111. package/dist/dashboard/assets/switch-SYm237HG.js.gz +0 -0
  112. package/dist/dashboard/assets/{useMutation-DjkLHUcE.js → useMutation-BQqCRNIi.js} +1 -1
  113. package/dist/dashboard/assets/useMutation-BQqCRNIi.js.gz +0 -0
  114. package/dist/dashboard/index.html +3 -2
  115. package/dist/dashboard/index.html.gz +0 -0
  116. package/dist/dashboard/precompressed-assets.json +118 -115
  117. package/migrations/full-postgres/0026_add_qoder_native_identity.sql +20 -0
  118. package/migrations/full-postgres/0027_qoder_identity_genuine_seed.sql +21 -0
  119. package/migrations/full-postgres/0028_add_family_template_key_telemetry.sql +40 -0
  120. package/migrations/full-postgres/0029_null_share_child_policy_columns.sql +38 -0
  121. package/migrations/full-postgres/0030_add_api_key_max_children.sql +10 -0
  122. package/migrations/full-postgres/0031_tenant_system_prompts.sql +16 -0
  123. package/migrations/full-postgres/0032_add_provider_pool_accounts.sql +53 -0
  124. package/migrations/full-postgres/0033_add_api_key_ban.sql +9 -0
  125. package/migrations/full-postgres/0034_add_api_key_ban_until.sql +7 -0
  126. package/migrations/full-postgres/0035_align_pool_account_status_enum.sql +21 -0
  127. package/migrations/lite-sqlite3/0026_add_qoder_native_identity.sql +15 -0
  128. package/migrations/lite-sqlite3/0027_qoder_identity_genuine_seed.sql +21 -0
  129. package/migrations/lite-sqlite3/0028_add_family_template_key_telemetry.sql +40 -0
  130. package/migrations/lite-sqlite3/0029_null_share_child_policy_columns.sql +38 -0
  131. package/migrations/lite-sqlite3/0030_add_api_key_max_children.sql +10 -0
  132. package/migrations/lite-sqlite3/0031_tenant_system_prompts.sql +10 -0
  133. package/migrations/lite-sqlite3/0032_add_provider_pool_accounts.sql +44 -0
  134. package/migrations/lite-sqlite3/0033_add_api_key_ban.sql +8 -0
  135. package/migrations/lite-sqlite3/0034_add_api_key_ban_until.sql +5 -0
  136. package/migrations/lite-sqlite3/0035_align_pool_account_status_enum.sql +6 -0
  137. package/package.json +2 -1
  138. package/src/boot/app.ts +9 -8
  139. package/src/config/network.ts +7 -3
  140. package/src/console/api-keys/bucket-spend.ts +7 -7
  141. package/src/console/api-keys/contracts.ts +475 -410
  142. package/src/console/api-keys/routes.ts +211 -23
  143. package/src/console/audit/routes.ts +3 -3
  144. package/src/console/auth/account-reset.ts +4 -4
  145. package/src/console/auth/session.ts +3 -3
  146. package/src/console/backup/contracts.ts +2 -0
  147. package/src/console/backup/export-endpoint.ts +14 -17
  148. package/src/console/backup/routes.ts +43 -11
  149. package/src/console/backup/service.ts +11 -6
  150. package/src/console/backup/store.ts +384 -88
  151. package/src/console/cli-tools/routes.ts +3 -3
  152. package/src/console/console-router-guards.ts +2 -2
  153. package/src/console/console-router.ts +4 -4
  154. package/src/console/dashboard-assets.ts +16 -1
  155. package/src/console/data-management/routes.ts +3 -3
  156. package/src/console/domain-registration.ts +4 -2
  157. package/src/console/observability/contracts.ts +4 -0
  158. package/src/console/observability/live.ts +3 -3
  159. package/src/console/observability/logs.ts +3 -3
  160. package/src/console/observability/performance.ts +3 -3
  161. package/src/console/observability/routes.ts +3 -3
  162. package/src/console/observability/store.ts +16 -16
  163. package/src/console/providers/catalog/contracts.ts +43 -22
  164. package/src/console/providers/catalog/provider-operations.ts +17 -2
  165. package/src/console/providers/catalog/routes.ts +3 -3
  166. package/src/console/providers/catalog/store-helpers.ts +349 -326
  167. package/src/console/providers/catalog/store.ts +1523 -1292
  168. package/src/console/providers/detail/routes.ts +3 -3
  169. package/src/console/providers/oauth/routes.ts +3 -3
  170. package/src/console/quota/account-quota-tenant.ts +34 -2
  171. package/src/console/quota/account-quota.ts +4 -1
  172. package/src/console/quota/account-test-egress.ts +4 -4
  173. package/src/console/quota/native-identity.ts +189 -0
  174. package/src/console/remote-support/contracts.ts +12 -6
  175. package/src/console/remote-support/service.ts +2 -2
  176. package/src/console/routing/model/routes.ts +3 -3
  177. package/src/console/routing/pools/contracts.ts +11 -8
  178. package/src/console/routing/pools/operations.ts +14 -7
  179. package/src/console/routing/pools/probe-verdict.ts +8 -3
  180. package/src/console/routing/pools/relay-deploy.ts +9 -9
  181. package/src/console/routing/pools/routes.ts +3 -3
  182. package/src/console/routing/pools/store.ts +21 -1
  183. package/src/console/settings/routes.ts +3 -3
  184. package/src/console/share/share-router.ts +51 -18
  185. package/src/console/share/share-stats.ts +158 -49
  186. package/src/console/share/share-usage.ts +24 -7
  187. package/src/console/shared/read-through-cache.ts +38 -2
  188. package/src/console/system-prompt/contracts.ts +102 -0
  189. package/src/console/system-prompt/routes.ts +165 -0
  190. package/src/console/updates/routes.ts +7 -4
  191. package/src/main.ts +11 -0
  192. package/src/network/outbound-fetch.ts +75 -4
  193. package/src/network/pool/resolver.ts +39 -39
  194. package/src/network/pool-cooldown-store.ts +71 -16
  195. package/src/network/pool-health-machine.ts +21 -1
  196. package/src/network/rate-limit-verdict.ts +68 -0
  197. package/src/network/ssrf.ts +4 -4
  198. package/src/observability/event-query.ts +437 -22
  199. package/src/observability/telemetry-status.ts +1 -1
  200. package/src/persistence/db-pg.ts +42 -0
  201. package/src/persistence/db-sqlite.ts +7 -2
  202. package/src/persistence/readiness.ts +18 -6
  203. package/src/persistence/schema.ts +162 -1
  204. package/src/persistence/stores/api-key-store.ts +849 -581
  205. package/src/persistence/stores/share-store.ts +65 -13
  206. package/src/persistence/stores/telemetry-store.ts +58 -2
  207. package/src/persistence/system-prompt-store.ts +104 -0
  208. package/src/protocol/codecs.ts +6 -6
  209. package/src/protocol/gateway-error.ts +6 -3
  210. package/src/protocol/image-generation.ts +2 -2
  211. package/src/protocol/reasoning.ts +65 -1
  212. package/src/protocol/request/chat.ts +1 -0
  213. package/src/protocol/response/chat.ts +2 -2
  214. package/src/protocol/response/codex.ts +1 -1
  215. package/src/protocol/response/gemini.ts +1 -1
  216. package/src/protocol/response/messages.ts +5 -5
  217. package/src/protocol/response/responses.ts +2 -2
  218. package/src/protocol/sse-decoder.ts +14 -3
  219. package/src/protocol/translation/capabilities.ts +2 -2
  220. package/src/protocol/upstream/send-claude-messages.ts +3 -3
  221. package/src/providers/adapters/base-adapter.ts +1 -1
  222. package/src/providers/adapters/compatible-adapter.ts +2 -2
  223. package/src/providers/authentication/oauth-refresh-service.ts +2 -2
  224. package/src/providers/configured-provider.ts +1 -1
  225. package/src/providers/discovery/probe-phases.ts +2 -2
  226. package/src/providers/discovery/probing-service.ts +12 -12
  227. package/src/providers/integrations/agentrouter/agentrouter.ts +2 -3
  228. package/src/providers/integrations/alibabacp/alibabacp.ts +2 -2
  229. package/src/providers/integrations/anthropic.ts +4 -11
  230. package/src/providers/integrations/antigravity/antigravity.ts +33 -47
  231. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +1 -1
  232. package/src/providers/integrations/buddy/buddy-chat-shared.ts +30 -2
  233. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +37 -6
  234. package/src/providers/integrations/buddy/buddy-identity.ts +22 -1
  235. package/src/providers/integrations/buddy/buddy-media.ts +21 -17
  236. package/src/providers/integrations/buddy/codebuddy/provider.ts +6 -2
  237. package/src/providers/integrations/buddy/workbuddy/provider.ts +6 -2
  238. package/src/providers/integrations/claude/claude-betas.ts +1 -1
  239. package/src/providers/integrations/claude/claude-credentials.ts +1 -1
  240. package/src/providers/integrations/claude/claude-slow-lane-retry.ts +1 -1
  241. package/src/providers/integrations/claude/claude.ts +3 -10
  242. package/src/providers/integrations/cline/cline.ts +8 -1
  243. package/src/providers/integrations/cloudflareai/cloudflareai.ts +4 -7
  244. package/src/providers/integrations/codex/codex-errors.ts +1 -1
  245. package/src/providers/integrations/codex/codex.ts +14 -15
  246. package/src/providers/integrations/commandcode/commandcode.ts +4 -4
  247. package/src/providers/integrations/commandcode/quota.ts +5 -1
  248. package/src/providers/integrations/cursor/cursor-http2.ts +11 -11
  249. package/src/providers/integrations/cursor/cursor.ts +15 -15
  250. package/src/providers/integrations/factory/factory.ts +4 -4
  251. package/src/providers/integrations/gemini.ts +11 -11
  252. package/src/providers/integrations/github/github.ts +26 -7
  253. package/src/providers/integrations/grok/grok.ts +2 -6
  254. package/src/providers/integrations/hermes.ts +5 -1
  255. package/src/providers/integrations/inferhub.ts +4 -11
  256. package/src/providers/integrations/kilo/kilo.ts +4 -0
  257. package/src/providers/integrations/kimi/kimi.ts +4 -11
  258. package/src/providers/integrations/kiro/kiro.ts +10 -10
  259. package/src/providers/integrations/muse/muse.ts +6 -9
  260. package/src/providers/integrations/openrouter/provider.ts +4 -0
  261. package/src/providers/integrations/perplexity.ts +2 -2
  262. package/src/providers/integrations/qoder/qoder-catalog.ts +47 -9
  263. package/src/providers/integrations/qoder/qoder-checkin.ts +242 -51
  264. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +157 -0
  265. package/src/providers/integrations/qoder/qoder-oauth.ts +12 -3
  266. package/src/providers/integrations/qoder/qoder-realm.ts +15 -0
  267. package/src/providers/integrations/qoder/qoder-umid.ts +287 -0
  268. package/src/providers/integrations/qoder/qoder-virtual.ts +2 -2
  269. package/src/providers/integrations/qoder/qoder.ts +192 -24
  270. package/src/providers/integrations/tokenharbor.ts +2 -2
  271. package/src/providers/integrations/trae/trae.ts +6 -9
  272. package/src/providers/integrations/zcode/oauth.ts +1 -1
  273. package/src/providers/operations/account-error-classification.ts +6 -8
  274. package/src/providers/operations/account-health-service.ts +14 -16
  275. package/src/providers/operations/client-versions.ts +37 -22
  276. package/src/providers/operations/provider-catalog-service.ts +4 -4
  277. package/src/providers/operations/provider-credential-service.ts +16 -4
  278. package/src/providers/operations/upstream-deadline.ts +3 -3
  279. package/src/providers/provider-contracts.ts +55 -3
  280. package/src/providers/provider-credential-resolver.ts +1 -1
  281. package/src/providers/provider-registry.ts +4 -4
  282. package/src/providers/quota/quota-contracts.ts +24 -10
  283. package/src/providers/quota/refresh.ts +2 -2
  284. package/src/providers/search/search-provider.ts +5 -5
  285. package/src/security/abuse.ts +4 -4
  286. package/src/security/access-control.ts +19 -0
  287. package/src/security/admission/reasons.ts +17 -17
  288. package/src/security/api-key-auth.ts +18 -5
  289. package/src/shared/coerce.ts +19 -0
  290. package/src/transport/dispatch/attempt-finalize.ts +4 -3
  291. package/src/transport/dispatch/attempt-loop.ts +10 -7
  292. package/src/transport/dispatch/empty-stream.ts +6 -6
  293. package/src/transport/dispatch/generation.ts +9 -9
  294. package/src/transport/dispatch/leases.ts +8 -8
  295. package/src/transport/dispatch/proxy-request.ts +38 -9
  296. package/src/transport/dispatch/responses-compact.ts +11 -11
  297. package/src/transport/dispatch/retry-policy.ts +4 -67
  298. package/src/transport/dispatch/stream-bridge.ts +1 -1
  299. package/src/transport/dispatch/streaming/finalize.ts +9 -8
  300. package/src/transport/dispatch/streaming/stream.ts +16 -1
  301. package/src/transport/dispatch/systemone.ts +8 -8
  302. package/src/transport/dispatch/token-count.ts +9 -9
  303. package/src/transport/dispatch/upstream.ts +10 -0
  304. package/src/transport/dispatch/websearch.ts +6 -6
  305. package/src/transport/drain-endpoint.ts +2 -2
  306. package/src/transport/failure-policy.ts +13 -6
  307. package/src/transport/guards/auth-guard.ts +6 -5
  308. package/src/transport/guards/body-policy.ts +4 -4
  309. package/src/transport/guards/error-lifecycle.ts +18 -3
  310. package/src/transport/guards/guard-errors.ts +7 -3
  311. package/src/transport/guards/headroom-guard.ts +1 -1
  312. package/src/transport/guards/pipeline.ts +6 -6
  313. package/src/transport/guards/readiness-guard.ts +16 -2
  314. package/src/transport/guards/request-context.ts +3 -3
  315. package/src/transport/request/preparer.ts +37 -21
  316. package/src/transport/request/state.ts +4 -7
  317. package/src/transport/request/system-prompt-injection.ts +89 -0
  318. package/src/transport/request/tenant-preferences.ts +43 -0
  319. package/src/transport/routing/admission.ts +13 -3
  320. package/src/transport/routing/route-catalog.ts +104 -2
  321. package/src/transport/routing/route-model.ts +60 -7
  322. package/src/transport/routing/router.ts +306 -256
  323. package/src/transport/shutdown-notice.ts +19 -7
  324. package/src/transport/surface/completion.ts +1 -1
  325. package/src/transport/surface/messages/encode.ts +1 -1
  326. package/src/workers/daily-checkin.ts +2 -0
  327. package/dist/dashboard/assets/BannedPage-XaUmiBrC.js.gz +0 -0
  328. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js +0 -1
  329. package/dist/dashboard/assets/CliToolDetailPage-CqD7QiLM.js.gz +0 -0
  330. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js +0 -1
  331. package/dist/dashboard/assets/CliToolsPage-0XUo4UMn.js.gz +0 -0
  332. package/dist/dashboard/assets/CombosPage-CXGS_Ct-.js.gz +0 -0
  333. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js +0 -1
  334. package/dist/dashboard/assets/ConsoleLogPage-DGnGNYnM.js.gz +0 -0
  335. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js +0 -1
  336. package/dist/dashboard/assets/CustomizationPage-DAG5J9EY.js.gz +0 -0
  337. package/dist/dashboard/assets/HealthEventsModal-BKtAPYVF.js.gz +0 -0
  338. package/dist/dashboard/assets/LoginPage-BdmRLnq2.js.gz +0 -0
  339. package/dist/dashboard/assets/ModelPicker-BH8Tehdi.js.gz +0 -0
  340. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js +0 -1
  341. package/dist/dashboard/assets/OverviewPage-CqMxibJB.js.gz +0 -0
  342. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js +0 -4
  343. package/dist/dashboard/assets/ProviderDetailPage-CqMh2NRX.js.gz +0 -0
  344. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js +0 -1
  345. package/dist/dashboard/assets/ProvidersPage-B9uWu4Nz.js.gz +0 -0
  346. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js +0 -9
  347. package/dist/dashboard/assets/ProxyPage-BoWOhpDO.js.gz +0 -0
  348. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js +0 -1
  349. package/dist/dashboard/assets/QuotaPage-7P2n--Fv.js.gz +0 -0
  350. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js +0 -1
  351. package/dist/dashboard/assets/SettingsPage-CxZ1fTkN.js.gz +0 -0
  352. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js +0 -1
  353. package/dist/dashboard/assets/SetupPage-Co12Zzaw.js.gz +0 -0
  354. package/dist/dashboard/assets/SortableList-Dqf4zpS-.js.gz +0 -0
  355. package/dist/dashboard/assets/StudioPage-Byj2lAEm.js.gz +0 -0
  356. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js +0 -56
  357. package/dist/dashboard/assets/UsagePage-CTkLAuRc.js.gz +0 -0
  358. package/dist/dashboard/assets/api-keys-CUi6UXsR.js +0 -1
  359. package/dist/dashboard/assets/api-keys-CUi6UXsR.js.gz +0 -0
  360. package/dist/dashboard/assets/app-KvsUJUMF.css +0 -2
  361. package/dist/dashboard/assets/app-KvsUJUMF.css.gz +0 -0
  362. package/dist/dashboard/assets/assert-67xXH5rf.js +0 -1
  363. package/dist/dashboard/assets/assert-67xXH5rf.js.gz +0 -0
  364. package/dist/dashboard/assets/badge-DC2L43Mj.js.gz +0 -0
  365. package/dist/dashboard/assets/banner-BBVcb0J3.js.gz +0 -0
  366. package/dist/dashboard/assets/card-Cca1XC6G.js.gz +0 -0
  367. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js +0 -1
  368. package/dist/dashboard/assets/cli-tools-KvsWa1Jl.js.gz +0 -0
  369. package/dist/dashboard/assets/clipboard-button-DUzEbynq.js.gz +0 -0
  370. package/dist/dashboard/assets/concurrency-BDF0ok7y.js.gz +0 -0
  371. package/dist/dashboard/assets/dialog-DFvWhwTa.js.gz +0 -0
  372. package/dist/dashboard/assets/drawer-CFM2sMX1.js +0 -1
  373. package/dist/dashboard/assets/drawer-CFM2sMX1.js.gz +0 -0
  374. package/dist/dashboard/assets/entry-Bqy8RFvM.js.gz +0 -0
  375. package/dist/dashboard/assets/entry-ClCMCWWQ.js +0 -4
  376. package/dist/dashboard/assets/entry-ClCMCWWQ.js.gz +0 -0
  377. package/dist/dashboard/assets/entry-_xHo5x6r.js +0 -1
  378. package/dist/dashboard/assets/entry-_xHo5x6r.js.gz +0 -0
  379. package/dist/dashboard/assets/formatters-CWWZ-uCZ.js.gz +0 -0
  380. package/dist/dashboard/assets/github-badge-C9e8Ts7e.js.gz +0 -0
  381. package/dist/dashboard/assets/index-DFVFrnWW.js.gz +0 -0
  382. package/dist/dashboard/assets/inline-BFUcvllj.js.gz +0 -0
  383. package/dist/dashboard/assets/input-C_ILAvBb.js.gz +0 -0
  384. package/dist/dashboard/assets/layout-CBIyT6Rt.js.gz +0 -0
  385. package/dist/dashboard/assets/list-row-ebJ_-Nsb.js.gz +0 -0
  386. package/dist/dashboard/assets/live-D0mm--ME.js +0 -1
  387. package/dist/dashboard/assets/live-D0mm--ME.js.gz +0 -0
  388. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js +0 -1
  389. package/dist/dashboard/assets/minimize-2-D-MN8W8a.js.gz +0 -0
  390. package/dist/dashboard/assets/mutation-sMD965oO.js.gz +0 -0
  391. package/dist/dashboard/assets/numbers-BO9dKaFr.js.gz +0 -0
  392. package/dist/dashboard/assets/query-keys-CfL_Zdoh.js.gz +0 -0
  393. package/dist/dashboard/assets/quota-DZGyp0N5.js +0 -1
  394. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  395. package/dist/dashboard/assets/routing-BnIpi1IJ.js +0 -1
  396. package/dist/dashboard/assets/routing-BnIpi1IJ.js.gz +0 -0
  397. package/dist/dashboard/assets/select-DBNVLdUu.js.gz +0 -0
  398. package/dist/dashboard/assets/settings-BAFhDgX8.js +0 -1
  399. package/dist/dashboard/assets/settings-BAFhDgX8.js.gz +0 -0
  400. package/dist/dashboard/assets/stack-EMrOMt4I.js.gz +0 -0
  401. package/dist/dashboard/assets/state-CWoo7ty5.js.gz +0 -0
  402. package/dist/dashboard/assets/switch-CCjT6IkF.js.gz +0 -0
  403. package/dist/dashboard/assets/useMutation-DjkLHUcE.js.gz +0 -0
  404. /package/dist/dashboard/assets/{app-BaWd0puM.js → app-BayTTy01.js} +0 -0
  405. /package/dist/dashboard/assets/{app-BaWd0puM.js.gz → app-BayTTy01.js.gz} +0 -0
@@ -6,7 +6,6 @@ import {
6
6
  accountsUnavailableError,
7
7
  ambiguousModelError,
8
8
  capacityExhaustedError,
9
- capabilityUnsupportedError,
10
9
  modelNotFoundError,
11
10
  type AdmissionController,
12
11
  type ComboDefinition,
@@ -17,6 +16,8 @@ import {
17
16
  type EligibilityDecision,
18
17
  type RouteContinuationAffinity,
19
18
  type RouteSnapshot,
19
+ type RoutingDecision,
20
+ ROUTING_RANK,
20
21
  } from "./route-model";
21
22
  import {
22
23
  candidateSupportsRequest,
@@ -45,9 +46,9 @@ import { sharedClaudeSlowLanes } from "../../providers/integrations/claude/claud
45
46
  function belowUsageFloor(state: {
46
47
  readonly usage_floor?: number;
47
48
  readonly usage_floor_unit?: "credit" | "percent";
48
- readonly quota_windows?: readonly ProviderQuotaWindow[] | undefined;
49
+ readonly quota_windows?: readonly unknown[] | undefined;
49
50
  }): boolean {
50
- const windows = state.quota_windows;
51
+ const windows = state.quota_windows as readonly ProviderQuotaWindow[] | undefined;
51
52
  if (windows === undefined || windows.length === 0) return false;
52
53
  const credit = totalRemainingCredit(windows);
53
54
  const percent = totalRemainingPercent(windows);
@@ -254,7 +255,9 @@ function earliestWindowResetMs(
254
255
  * account comes back, so quoting the slowest would hold it off longer than it
255
256
  * has to wait.
256
257
  */
257
- function earliestResetMs(decisions: readonly EligibilityDecision[]): number | undefined {
258
+ function earliestResetMs(
259
+ decisions: readonly { readonly cooldown_until?: number }[],
260
+ ): number | undefined {
258
261
  let earliest: number | undefined;
259
262
  for (const decision of decisions) {
260
263
  const until = decision.cooldown_until;
@@ -264,30 +267,6 @@ function earliestResetMs(decisions: readonly EligibilityDecision[]): number | un
264
267
  return earliest;
265
268
  }
266
269
 
267
- /**
268
- * Stamps the slow lane onto candidates whose account is currently lane-active.
269
- *
270
- * Read at plan time from the live lane registry, exactly as `applyUsageFloor`
271
- * reads live quota: lane state changes per response, so baking it into the
272
- * snapshot would route on a stale observation. An account with no lane state
273
- * is untouched, which keeps every non-Claude provider byte-identical here.
274
- */
275
- function applySlowLane(
276
- candidates: readonly RouteCandidate[],
277
- ): readonly RouteCandidate[] {
278
- let stamped = false;
279
- const out = candidates.map((candidate) => {
280
- const accountId = (
281
- candidate as RouteCandidate & { readonly provider_account_id?: string }
282
- ).provider_account_id;
283
- if (accountId === undefined || !sharedClaudeSlowLanes.isActive(accountId))
284
- return candidate;
285
- stamped = true;
286
- return { ...candidate, lane: "slow" as const };
287
- });
288
- return stamped ? out : candidates;
289
- }
290
-
291
270
  export class EligibilityEvaluator {
292
271
  evaluate(candidate: RouteCandidate): EligibilityDecision {
293
272
  const state = candidate as RouteCandidate & {
@@ -467,29 +446,151 @@ export class RoutingEngine {
467
446
  return [...run.slice(idx), ...run.slice(0, idx)];
468
447
  }
469
448
 
470
- private applyProviderRouting(
449
+ /**
450
+ * Stamps one candidate with its provider's usage floor + quota windows (the
451
+ * per-candidate half of the old `applyUsageFloor`).
452
+ */
453
+ private async applyUsageFloorToCandidate(
454
+ candidate: RouteCandidate,
455
+ snapshot: RouteSnapshot,
456
+ tenantId: string | null,
457
+ ): Promise<RouteCandidate> {
458
+ const accountId = candidate.provider_account_id;
459
+ if (accountId === undefined) return candidate;
460
+ const setting = this.resolveProviderRouting(
461
+ snapshot,
462
+ candidate.provider_id,
463
+ candidate.tenant_id ?? tenantId,
464
+ );
465
+ const cached = await getCachedQuotaEntry(
466
+ quotaLens(candidate.tenant_id ?? tenantId),
467
+ accountId,
468
+ this.redis,
469
+ ).catch(() => undefined);
470
+ const quotaWindows = cached?.quota.windows ?? [];
471
+ return {
472
+ ...candidate,
473
+ ...(setting?.usageFloor == null
474
+ ? {}
475
+ : { usage_floor: setting.usageFloor, usage_floor_unit: setting.usageFloorUnit }),
476
+ quota_windows: quotaWindows,
477
+ };
478
+ }
479
+
480
+ /**
481
+ * One merged evaluation of a candidate: health/cooldown eligibility, slow
482
+ * lane, usage floor, plan gate, and capability support. Returns the candidate
483
+ * and the single `RoutingDecision` every downstream layer reads.
484
+ */
485
+ private evaluateCandidate(
486
+ candidate: RouteCandidate,
487
+ _snapshot: RouteSnapshot,
488
+ _tenantId: string | null,
489
+ requiredCapabilities: readonly RequiredCapability[] | undefined,
490
+ ): { candidate: RouteCandidate; decision: RoutingDecision } {
491
+ const eligibility = this.eligibility.evaluate(candidate);
492
+ if (!eligibility.eligible) {
493
+ const reason = eligibility.reason;
494
+ const rank =
495
+ reason === "model_cooldown" || reason === "cooldown_hard"
496
+ ? ROUTING_RANK.COOLING
497
+ : ROUTING_RANK.SATURATED;
498
+ return {
499
+ candidate,
500
+ decision: {
501
+ eligible: false,
502
+ rank,
503
+ reason,
504
+ ...(eligibility.cooldown_until !== undefined
505
+ ? { cooldown_until: eligibility.cooldown_until }
506
+ : {}),
507
+ floor_blocked: reason === "quota_exhausted",
508
+ },
509
+ };
510
+ }
511
+ // Slow lane.
512
+ const accountId = (candidate as RouteCandidate & { readonly provider_account_id?: string })
513
+ .provider_account_id;
514
+ if (accountId !== undefined && sharedClaudeSlowLanes.isActive(accountId)) {
515
+ return {
516
+ candidate,
517
+ decision: {
518
+ eligible: true,
519
+ rank: ROUTING_RANK.SLOW,
520
+ reason: "slow",
521
+ },
522
+ };
523
+ }
524
+ // Plan gate (free plan).
525
+ if (!planAllowsCandidate(candidate)) {
526
+ return {
527
+ candidate,
528
+ decision: {
529
+ eligible: false,
530
+ rank: ROUTING_RANK.SATURATED,
531
+ reason: "plan_gate",
532
+ plan_gate_blocked: true,
533
+ },
534
+ };
535
+ }
536
+ // Capability.
537
+ if (requiredCapabilities !== undefined && requiredCapabilities.length > 0) {
538
+ if (!candidateSupportsRequest(candidate, requiredCapabilities)) {
539
+ return {
540
+ candidate,
541
+ decision: {
542
+ eligible: false,
543
+ rank: ROUTING_RANK.SATURATED,
544
+ reason: "capability_unsupported",
545
+ capability_blocked: true,
546
+ },
547
+ };
548
+ }
549
+ }
550
+ return {
551
+ candidate,
552
+ decision: {
553
+ eligible: true,
554
+ rank: ROUTING_RANK.FRESH,
555
+ reason: "healthy",
556
+ },
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Rotates accounts within the same rank group only. The rank is the primary
562
+ * sort key, so rotation can never float a cooling/saturated account ahead of
563
+ * a fresh one — the old double-sort is gone by construction.
564
+ */
565
+ private applyProviderRoutingSorted(
471
566
  eligible: readonly RouteCandidate[],
567
+ decisions: ReadonlyMap<string, RoutingDecision>,
472
568
  snapshot: RouteSnapshot,
473
569
  tid: string | null,
474
570
  ): RouteCandidate[] {
475
571
  if (eligible.length <= 1) return [...eligible];
476
- if (!snapshot.providerRouting) return [...eligible];
477
572
  const result: RouteCandidate[] = [];
478
573
  let i = 0;
479
574
  while (i < eligible.length) {
480
575
  const cur = eligible[i]!;
481
- const runKey = `${cur.provider_id}::${cur.model_id}`;
576
+ const rank = decisions.get(admissionKey(cur))?.rank ?? ROUTING_RANK.FRESH;
482
577
  let j = i + 1;
483
- while (
484
- j < eligible.length &&
485
- `${eligible[j]!.provider_id}::${eligible[j]!.model_id}` === runKey
486
- )
487
- j++;
578
+ while (j < eligible.length) {
579
+ const next = eligible[j]!;
580
+ const nextRank = decisions.get(admissionKey(next))?.rank ?? ROUTING_RANK.FRESH;
581
+ if (
582
+ next.provider_id !== cur.provider_id ||
583
+ next.model_id !== cur.model_id ||
584
+ nextRank !== rank
585
+ ) {
586
+ break;
587
+ }
588
+ j += 1;
589
+ }
488
590
  const run = eligible.slice(i, j);
489
591
  if (run.length > 1) {
490
592
  const settings = this.resolveProviderRouting(snapshot, cur.provider_id, tid);
491
- const reordered = this.reorderRun([...run], settings, tid);
492
- result.push(...reordered);
593
+ result.push(...this.reorderRun([...run], settings, tid));
493
594
  } else {
494
595
  result.push(...run);
495
596
  }
@@ -497,44 +598,89 @@ export class RoutingEngine {
497
598
  }
498
599
  return result;
499
600
  }
601
+
500
602
  /**
501
- * Stamps each account with its provider's usage floor and the quota reading
502
- * the console last fetched, so eligibility can reject an account that has
503
- * spent down to the floor. The reading is the live cache, not the route
504
- * snapshot: a snapshot rebuilt every few minutes routes on a stale balance
505
- * and lets a spent account through.
603
+ * Applies continuation affinity as a hard pin (or a rebase pass-through).
604
+ *
605
+ * Returns a plan when the pinned account is present and eligible, `null`
606
+ * when the continuation is rebaseable and the pin is empty (so the caller
607
+ * continues with ordinary routing), and throws the strict 400 when the pin
608
+ * is a hard constraint with no matching candidate.
506
609
  */
507
- private async applyUsageFloor(
508
- candidates: readonly RouteCandidate[],
610
+ private resolveContinuationPlan(
611
+ affinity: RouteContinuationAffinity,
612
+ matching: readonly RouteCandidate[],
613
+ requestedModel: string,
614
+ resolvedModel: string,
509
615
  snapshot: RouteSnapshot,
510
- tenantId: string | null,
511
- ): Promise<readonly RouteCandidate[]> {
512
- const readings = new Map<string, Promise<readonly ProviderQuotaWindow[]>>();
513
- return Promise.all(candidates.map(async (candidate) => {
514
- const accountId = candidate.provider_account_id;
515
- if (accountId === undefined) return candidate;
516
- const setting = this.resolveProviderRouting(snapshot, candidate.provider_id, candidate.tenant_id ?? tenantId);
517
- let windows = readings.get(accountId);
518
- if (windows === undefined) {
519
- windows = getCachedQuotaEntry(
520
- quotaLens(candidate.tenant_id ?? tenantId),
521
- accountId,
522
- this.redis,
523
- ).then((cached) => cached?.quota.windows ?? []);
524
- readings.set(accountId, windows);
616
+ ): RoutePlan | null {
617
+ const pinned = matching.filter(
618
+ (candidate) =>
619
+ candidate.provider_id === affinity.providerId &&
620
+ candidate.provider_account_id === affinity.providerAccountId,
621
+ );
622
+ if (pinned.length === 0) {
623
+ if (affinity.rebaseable) return null;
624
+ throw new GatewayError(
625
+ "invalid_request",
626
+ 400,
627
+ "This tool result belongs to a Cursor account that is no longer routable (request {requestId}). Reconnect the account, or resend the complete conversation. The session went cold — reconnect or resend 🔌",
628
+ {
629
+ providerId: affinity.providerId,
630
+ accountId: affinity.providerAccountId,
631
+ },
632
+ );
633
+ }
634
+ // The pinned account owns the live session, so it is the only correct
635
+ // target for this tool result — but health still applies. Forcing a
636
+ // session whose account is hard-cooling (quota exhausted) reproduces the
637
+ // refusal that cooled it, which is how "still hit a cooled-down account"
638
+ // leaked through continuation affinity. Evaluate the pin before
639
+ // returning: time-bounded cooling answers 429 with the real reset; an
640
+ // account that is disabled/locked answers a clear 400.
641
+ const pinnedDecisions = pinned.map((candidate) => this.eligibility.evaluate(candidate));
642
+ const blocked = pinnedDecisions.find((decision) => !decision.eligible);
643
+ if (blocked !== undefined) {
644
+ const reason = blocked.reason;
645
+ if (
646
+ reason === "cooldown_hard" ||
647
+ reason === "model_cooldown" ||
648
+ (reason === "quota_exhausted" && typeof blocked.cooldown_until === "number")
649
+ ) {
650
+ throw accountsRateLimitedError(requestedModel, resolvedModel, earliestResetMs(pinnedDecisions));
525
651
  }
526
- const quotaWindows = await windows;
527
- return {
528
- ...candidate,
529
- ...(setting?.usageFloor == null
530
- ? {}
531
- : { usage_floor: setting.usageFloor, usage_floor_unit: setting.usageFloorUnit }),
532
- quota_windows: quotaWindows,
533
- };
534
- }));
652
+ throw new GatewayError(
653
+ "invalid_request",
654
+ 400,
655
+ "This tool result belongs to an account that is no longer routable (request {requestId}). Reconnect the account, or resend the complete conversation. The session went cold — reconnect or resend 🔌",
656
+ {
657
+ providerId: affinity.providerId,
658
+ accountId: affinity.providerAccountId,
659
+ },
660
+ );
661
+ }
662
+ const decisions = new Map<string, RoutingDecision>();
663
+ for (const candidate of pinned) {
664
+ const decision = pinnedDecisions.find((d) => d.candidate === candidate);
665
+ decisions.set(admissionKey(candidate), {
666
+ eligible: true,
667
+ rank: ROUTING_RANK.FRESH,
668
+ reason: "healthy",
669
+ ...(decision?.cooldown_until !== undefined
670
+ ? { cooldown_until: decision.cooldown_until }
671
+ : {}),
672
+ });
673
+ }
674
+ return {
675
+ revision: snapshot.revision,
676
+ candidates: pinned,
677
+ requested_model: requestedModel,
678
+ resolved_model: resolvedModel,
679
+ provider_id: pinned[0]!.provider_id,
680
+ decisions,
681
+ };
535
682
  }
536
683
 
537
-
538
684
  async plan(
539
685
  requestedModel: string,
540
686
  snapshot: RouteSnapshot,
@@ -562,97 +708,86 @@ export class RoutingEngine {
562
708
  // or into a fresh turn that never asked for the call. So the candidate set
563
709
  // is restricted before any health, cooling, or rotation logic runs, and an
564
710
  // empty result is a refusal rather than a fallback to normal routing.
711
+ // Continuation affinity is applied first and absolutely.
712
+ //
713
+ // A request carrying a tool result for a provider session this process is
714
+ // holding MUST reach the exact live account that owns that session. Routing
715
+ // it anywhere else is not a degraded choice — it is a correctness failure:
716
+ // the result would be written onto a different account's upstream turn
717
+ // (corrupting both conversations and leaking the first account's context),
718
+ // or into a fresh turn that never asked for the call. So the candidate set
719
+ // is restricted before any health, cooling, or rotation logic runs, and an
720
+ // empty result is a refusal rather than a fallback to normal routing.
721
+ //
722
+ // The one exception is a *rebaseable* continuation: when the conversation
723
+ // is complete (every tool result is answered in the history) and the pinned
724
+ // account is no longer routable for the requested model, the request may
725
+ // fall through to normal routing so the adapter starts a fresh Run carrying
726
+ // the recorded history. This is what makes model switches across accounts
727
+ // work — see `resolveContinuationPlan` and `preparer.ts`.
565
728
  if (continuationAffinity !== undefined) {
566
- const pinned = matching.filter(
567
- (candidate) =>
568
- candidate.provider_id === continuationAffinity.providerId &&
569
- candidate.provider_account_id === continuationAffinity.providerAccountId,
729
+ const continuationPlan = this.resolveContinuationPlan(
730
+ continuationAffinity,
731
+ matching,
732
+ requestedModel,
733
+ resolved.model,
734
+ snapshot,
570
735
  );
571
- if (pinned.length === 0) {
572
- throw new GatewayError(
573
- "invalid_request",
574
- 400,
575
- "This tool result belongs to a Cursor account that is no longer routable. Reconnect the account, or resend the complete conversation.",
576
- {
577
- providerId: continuationAffinity.providerId,
578
- accountId: continuationAffinity.providerAccountId,
579
- },
580
- );
581
- }
582
- // The pinned account owns the live session, so it is the only correct
583
- // target for this tool result — but health still applies. Forcing a
584
- // session whose account is hard-cooling (quota exhausted) reproduces the
585
- // refusal that cooled it, which is how "still hit a cooled-down account"
586
- // leaked through continuation affinity. Evaluate the pin before
587
- // returning: time-bounded cooling answers 429 with the real reset; an
588
- // account that is disabled/locked answers a clear 400.
589
- const pinnedDecisions = pinned.map((candidate) => this.eligibility.evaluate(candidate));
590
- const blocked = pinnedDecisions.find((decision) => !decision.eligible);
591
- if (blocked !== undefined) {
592
- const reason = blocked.reason;
593
- if (
594
- reason === "cooldown_hard" ||
595
- reason === "model_cooldown" ||
596
- (reason === "quota_exhausted" && typeof blocked.cooldown_until === "number")
597
- ) {
598
- throw accountsRateLimitedError(requestedModel, resolved.model, earliestResetMs(pinnedDecisions));
599
- }
600
- throw new GatewayError(
601
- "invalid_request",
602
- 400,
603
- "This tool result belongs to an account that is no longer routable. Reconnect the account, or resend the complete conversation.",
604
- {
605
- providerId: continuationAffinity.providerId,
606
- accountId: continuationAffinity.providerAccountId,
607
- },
608
- );
736
+ if (continuationPlan !== null) return continuationPlan;
737
+ }
738
+ // ── One evaluation pass ──────────────────────────────────────────────
739
+ // Every filter that used to run as a separate pass (usage floor, slow lane,
740
+ // health/cooldown eligibility, plan gate, capability) is merged into a
741
+ // single per-candidate decision. `plan()` then sorts once by rank and
742
+ // hands the full `decisions` map downstream, so no later layer re-derives
743
+ // a competing ordering.
744
+ const evaluations = await Promise.all(
745
+ matching.map(async (candidate) => {
746
+ const stamped = await this.applyUsageFloorToCandidate(candidate, snapshot, tid);
747
+ return this.evaluateCandidate(stamped, snapshot, tid, requiredCapabilities);
748
+ }),
749
+ );
750
+ const decisions = new Map<string, RoutingDecision>();
751
+ for (const evaluation of evaluations) {
752
+ decisions.set(admissionKey(evaluation.candidate), evaluation.decision);
753
+ }
754
+ // Saturation is read once (inflight counters) and merged into the same
755
+ // decision map rather than a second sort pass.
756
+ const saturated = await this.saturatedCandidates(matching);
757
+ for (const candidate of saturated) {
758
+ const key = admissionKey(candidate);
759
+ const existing = decisions.get(key);
760
+ if (existing !== undefined) {
761
+ decisions.set(key, {
762
+ ...existing,
763
+ eligible: existing.eligible,
764
+ rank: ROUTING_RANK.SATURATED,
765
+ saturated: true,
766
+ });
609
767
  }
610
- return {
611
- revision: snapshot.revision,
612
- candidates: pinned,
613
- requested_model: requestedModel,
614
- resolved_model: resolved.model,
615
- provider_id: pinned[0]!.provider_id,
616
- };
617
768
  }
618
- // `matching` non-empty but nothing eligible means every account serving
619
- // this model is currently unusable — NOT a missing model. Distinct from
620
- // the `matching.length === 0` throw in the resolver, which is a genuine 404.
621
- const gated = await this.applyUsageFloor(matching, snapshot, tid);
622
- const laned = applySlowLane(gated);
623
- const decisions = laned.map((candidate) => this.eligibility.evaluate(candidate));
624
- let eligible = decisions.filter((d) => d.eligible).map((d) => d.candidate);
769
+ let eligible = evaluations
770
+ .map((evaluation) => evaluation.candidate)
771
+ .filter((candidate) => decisions.get(admissionKey(candidate))?.eligible === true);
772
+
625
773
  if (eligible.length === 0) {
626
- // A time-bounded exclusion answers 429, not 503. Both reasons here carry
627
- // a deadline the upstream itself stated, so the request is not
628
- // "unavailable" — it is "not until the reset", and the client is owed an
629
- // answer it can retry against. `model_cooldown` belongs in the same
630
- // bucket as `cooldown_hard` for exactly the reason the evaluator's own
631
- // comment gives: the upstream said this (account, model) pair is
632
- // exhausted until a named reset, so dialing it can only reproduce the
633
- // refusal. Answering 503 there told the client the model was down when
634
- // every account serving it was merely resting, which is the "503 DOWN
635
- // while the model is allowed" report — and 503 invites a client to give
636
- // up or fail a health check, where 429 says retry later.
637
- const rateLimited = decisions.some(
774
+ // Time-bounded exclusions answer 429, not 503. See the long comment that
775
+ // used to live here: cooldown/floor exclusions with a stated reset are
776
+ // "not until then", not "down". Only a floor with no stated reset stays
777
+ // 503.
778
+ const rateLimited = [...decisions.values()].some(
638
779
  (d) =>
639
780
  d.reason === "cooldown_hard" ||
640
781
  d.reason === "model_cooldown" ||
641
- // A usage-floor exclusion whose window states a reset is the same
642
- // shape as the two above: the account is resting until a named
643
- // instant, not broken. Falling through to 503 tells the client the
644
- // model is down while every account serving it is merely waiting for
645
- // its window to refill — the "503 DOWN while the model is allowed"
646
- // report again. Only a floor with *no* stated
647
- // reset stays unavailable: there is nothing to retry against yet.
648
782
  (d.reason === "quota_exhausted" && typeof d.cooldown_until === "number"),
649
783
  );
650
784
  if (rateLimited) {
651
- throw accountsRateLimitedError(requestedModel, resolved.model, earliestResetMs(decisions));
785
+ throw accountsRateLimitedError(
786
+ requestedModel,
787
+ resolved.model,
788
+ earliestResetMs([...decisions.values()]),
789
+ );
652
790
  }
653
- // Combo members without any routable candidate never reach the client
654
- // envelope — but the operator needs them to fix the combo, so they go
655
- // to the server logs with the full resolution picture.
656
791
  if (unmatchedMembers.length > 0) {
657
792
  log.warn("[routing] combo members without routable candidates", {
658
793
  requested: requestedModel,
@@ -660,119 +795,33 @@ export class RoutingEngine {
660
795
  unmatched: [...new Set(unmatchedMembers)].sort(),
661
796
  });
662
797
  }
663
- throw accountsUnavailableError(
664
- requestedModel,
665
- decisions.map((d) => d.reason),
666
- );
798
+ throw accountsUnavailableError(requestedModel, [...decisions.values()].map((d) => d.reason));
667
799
  }
668
- // Every eligible candidate is account-wide cooling: no healthy account is
669
- // left to serve this request. Cooling accounts stay eligible so the ORDER
670
- // below can deprioritize them behind healthy siblings, but once nothing but
671
- // cooling remains there is no sibling to fail over to — dialing a cooling
672
- // account can only reproduce the refusal that cooled it, and doing so made
673
- // the gateway "still hit a cooled-down account" instead of answering the
674
- // client honestly. Answer 429 (rate limited) so the client retries after
675
- // the reset rather than 503 capacity that is merely resting.
676
- // A slow-lane account still serves on spare capacity, so it counts as
677
- // servable: an all-slow pool must plan and dial rather than answer 429
678
- // while capacity sits idle.
679
- const healthyCount = decisions.filter(
800
+
801
+ // Every eligible candidate cooling and no healthy/slow sibling → 429.
802
+ const healthyCount = [...decisions.values()].filter(
680
803
  (d) => d.eligible && (d.reason === "healthy" || d.reason === "slow"),
681
804
  ).length;
682
805
  if (healthyCount === 0) {
683
- throw accountsRateLimitedError(requestedModel, resolved.model, earliestResetMs(decisions));
684
- }
685
- // Cooling accounts are eligible but tried last: a healthy candidate that
686
- // can serve the request must win, while a deployment whose only account is
687
- // cooling still routes instead of failing. Sorted on the evaluator's reason
688
- // so the ordering and the eligibility rule cannot disagree about which
689
- // candidates are cooling.
690
- // Slow-lane accounts are eligible but tried behind fresh siblings, and
691
- // ahead of cooling ones: fresh → slow → cooling → saturated. A slow account
692
- // serves on spare capacity, so it must be reached when no fresh candidate
693
- // remains, but it should not absorb traffic a fresh account could take
694
- // without the lane's latency.
695
- const slow = new Set(
696
- decisions
697
- .filter((decision) => decision.eligible && decision.reason === "slow")
698
- .map((decision) => decision.candidate),
699
- );
700
- if (slow.size > 0 && slow.size < eligible.length) {
701
- eligible = [
702
- ...eligible.filter((candidate) => !slow.has(candidate)),
703
- ...eligible.filter((candidate) => slow.has(candidate)),
704
- ];
705
- }
706
- const cooling = new Set(
707
- decisions
708
- .filter((decision) => decision.eligible && decision.reason === "cooldown")
709
- .map((decision) => decision.candidate),
710
- );
711
- if (cooling.size > 0 && cooling.size < eligible.length) {
712
- eligible = [
713
- ...eligible.filter((candidate) => !cooling.has(candidate)),
714
- ...eligible.filter((candidate) => cooling.has(candidate)),
715
- ];
716
- }
717
- // Accounts already at their concurrency ceiling go last, for the same
718
- // reason cooling ones do. Without this the plan order put a saturated
719
- // account first and the first attempt was spent learning it was full —
720
- // an admission 429, a retry, and only then the sibling that had room. The
721
- // caller paid a round-trip to be told something the gateway already knew
722
- // from its own inflight counters.
723
- //
724
- // Deprioritized, not dropped: when every account is genuinely saturated
725
- // the request must still be dialed somewhere and answered 429, rather than
726
- // reading as "no account available" (503) for a deployment that is merely
727
- // busy.
728
- const saturated = await this.saturatedCandidates(eligible);
729
- if (saturated.size > 0 && saturated.size < eligible.length) {
730
- eligible = [
731
- ...eligible.filter((candidate) => !saturated.has(candidate)),
732
- ...eligible.filter((candidate) => saturated.has(candidate)),
733
- ];
734
- }
735
- // Capability-aware routing: filter against snapshot capability profiles
736
- // (shared predicate with the planner). Empty here means no candidate
737
- // supports the variant — the planner catches this code and re-plans a
738
- // degraded variant; model/tenant/ambiguity errors above still throw.
739
- if (requiredCapabilities !== undefined && requiredCapabilities.length > 0) {
740
- eligible = eligible.filter((candidate) =>
741
- candidateSupportsRequest(candidate, requiredCapabilities),
806
+ throw accountsRateLimitedError(
807
+ requestedModel,
808
+ resolved.model,
809
+ earliestResetMs([...decisions.values()]),
742
810
  );
743
- // No silent model switch. The requested model cannot serve
744
- // this requirement set, so the planner degrades it in place (media becomes
745
- // a placeholder, controls are dropped) and re-plans. When nothing
746
- // degradable remains, the request fails as `capability_unsupported`
747
- // rather than being served by a different model the caller never asked for.
748
- if (eligible.length === 0)
749
- throw capabilityUnsupportedError(requiredCapabilities.join(", "));
750
- }
751
- // Plan gate: a model the upstream refuses on a free plan is not offered to
752
- // an account the upstream reports as free. That pair is a certain refusal,
753
- // and sending it anyway advances the account's failure streak toward a
754
- // six-hour park for something that is not the account's fault.
755
- //
756
- // Applied only when another candidate survives. Every account being free is
757
- // not a routing failure — it is the operator having only free accounts, and
758
- // the honest outcome is the upstream's own `11158`, which names the model
759
- // and the plan. Dropping the last candidate would replace that clear refusal
760
- // with a generic "no candidates" that names neither.
761
- const planAllowed = eligible.filter((candidate) => planAllowsCandidate(candidate));
762
- if (planAllowed.length > 0) eligible = planAllowed;
763
- eligible = this.applyProviderRouting(eligible, snapshot, tid);
764
- // Re-assert the cooling order: `applyProviderRouting` rotates accounts
765
- // within one `provider::model` run, and that rotation is by design blind to
766
- // health — so it can float a cooling account back to the front and undo the
767
- // deprioritization above. Sorting again here is what makes the cooling rule
768
- // hold regardless of the operator's rotation strategy. A stable partition
769
- // keeps the rotation's own ordering inside each group.
770
- if (cooling.size > 0 && cooling.size < eligible.length) {
771
- eligible = [
772
- ...eligible.filter((candidate) => !cooling.has(candidate)),
773
- ...eligible.filter((candidate) => cooling.has(candidate)),
774
- ];
775
811
  }
812
+
813
+ // Single sort by rank (fresh → slow → cooling → saturated), stable.
814
+ eligible = [...eligible].sort((left, right) => {
815
+ const leftRank = decisions.get(admissionKey(left))?.rank ?? ROUTING_RANK.FRESH;
816
+ const rightRank = decisions.get(admissionKey(right))?.rank ?? ROUTING_RANK.FRESH;
817
+ return leftRank - rightRank;
818
+ });
819
+
820
+ // Provider routing rotates accounts within the same rank group only; it can
821
+ // never float a cooling/saturated account ahead of a fresh one because the
822
+ // rank is the primary key of the sort.
823
+ eligible = this.applyProviderRoutingSorted(eligible, decisions, snapshot, tid);
824
+
776
825
  const chosen = eligible[0]!;
777
826
  return {
778
827
  revision: snapshot.revision,
@@ -780,6 +829,7 @@ export class RoutingEngine {
780
829
  requested_model: requestedModel,
781
830
  resolved_model: resolved.model,
782
831
  provider_id: chosen.provider_id,
832
+ decisions,
783
833
  };
784
834
  }
785
835