cartethyia 2.7.8 → 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 (519) hide show
  1. package/.env.example +3 -3
  2. package/README.md +29 -59
  3. package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js +1 -0
  4. package/dist/dashboard/assets/BannedPage-Cc0vKpMG.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-BRWKPQPf.js → CliToolDetailPage-Bqj2aa1T.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-Bqj2aa1T.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-BP-Y5iHN.js → CliToolsPage-CXsaOAO2.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-CXsaOAO2.js.gz +0 -0
  9. package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js +1 -0
  10. package/dist/dashboard/assets/CombosPage-BeLpJ4ma.js.gz +0 -0
  11. package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js +1 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-DnV0uZ3m.js.gz +0 -0
  13. package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js +1 -0
  14. package/dist/dashboard/assets/CustomizationPage-CBxAjo63.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-B5yEE0k6.js.gz +0 -0
  17. package/dist/dashboard/assets/{InjectSystemPromptPanel-Cv-2Ol_K.js → InjectSystemPromptPanel-BWFLECmz.js} +2 -2
  18. package/dist/dashboard/assets/InjectSystemPromptPanel-BWFLECmz.js.gz +0 -0
  19. package/dist/dashboard/assets/{LoginPage-bKfz_b1K.js → LoginPage-CgoPc5B9.js} +1 -1
  20. package/dist/dashboard/assets/LoginPage-CgoPc5B9.js.gz +0 -0
  21. package/dist/dashboard/assets/{ModelPicker-B-WQxx_H.js → ModelPicker-D9bFQern.js} +1 -1
  22. package/dist/dashboard/assets/ModelPicker-D9bFQern.js.gz +0 -0
  23. package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js +1 -0
  24. package/dist/dashboard/assets/OverviewPage-B_p4tL8k.js.gz +0 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js +8 -0
  26. package/dist/dashboard/assets/ProviderDetailPage-rs5E2rxv.js.gz +0 -0
  27. package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js +1 -0
  28. package/dist/dashboard/assets/ProviderIcon-D_0PiZtg.js.gz +0 -0
  29. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js +1 -0
  30. package/dist/dashboard/assets/ProvidersPage-CLI2bJTK.js.gz +0 -0
  31. package/dist/dashboard/assets/{ProxyPage-emlTuxz6.js → ProxyPage-BwNC_-Cx.js} +1 -1
  32. package/dist/dashboard/assets/ProxyPage-BwNC_-Cx.js.gz +0 -0
  33. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js +1 -0
  34. package/dist/dashboard/assets/QuotaPage-DA-a1iSN.js.gz +0 -0
  35. package/dist/dashboard/assets/SettingsPage-CctTga2C.js +1 -0
  36. package/dist/dashboard/assets/SettingsPage-CctTga2C.js.gz +0 -0
  37. package/dist/dashboard/assets/{SetupPage-CUZrKs9r.js → SetupPage-febLU4r1.js} +1 -1
  38. package/dist/dashboard/assets/SetupPage-febLU4r1.js.gz +0 -0
  39. package/dist/dashboard/assets/{StudioPage-jWmUdDpy.js → StudioPage-M6fvR5Wz.js} +1 -1
  40. package/dist/dashboard/assets/StudioPage-M6fvR5Wz.js.gz +0 -0
  41. package/dist/dashboard/assets/UsagePage-nyeuRqz9.js +56 -0
  42. package/dist/dashboard/assets/UsagePage-nyeuRqz9.js.gz +0 -0
  43. package/dist/dashboard/assets/api-keys-BFOlvrJC.js +1 -0
  44. package/dist/dashboard/assets/api-keys-BFOlvrJC.js.gz +0 -0
  45. package/dist/dashboard/assets/app-Dx2QgsNj.css +2 -0
  46. package/dist/dashboard/assets/app-Dx2QgsNj.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-CidQVoNo.js → assert-CqTLw5e7.js} +1 -1
  48. package/dist/dashboard/assets/assert-CqTLw5e7.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-DyI_mJfi.js → badge-C83EqLB0.js} +1 -1
  50. package/dist/dashboard/assets/badge-C83EqLB0.js.gz +0 -0
  51. package/dist/dashboard/assets/ban-durations-B58FyRxC.js +1 -0
  52. package/dist/dashboard/assets/ban-durations-B58FyRxC.js.gz +0 -0
  53. package/dist/dashboard/assets/{banner-B-yXtCqb.js → banner-D1xn7pIe.js} +1 -1
  54. package/dist/dashboard/assets/banner-D1xn7pIe.js.gz +0 -0
  55. package/dist/dashboard/assets/brand/bansos_page.webp +0 -0
  56. package/dist/dashboard/assets/brand/denia_bansos.webp +0 -0
  57. package/dist/dashboard/assets/brand/favicon.webp +0 -0
  58. package/dist/dashboard/assets/brand/login_register.webp +0 -0
  59. package/dist/dashboard/assets/brand/login_register_initial.webp +0 -0
  60. package/dist/dashboard/assets/brand/og_bansos.webp +0 -0
  61. package/dist/dashboard/assets/brand/og_image.webp +0 -0
  62. package/dist/dashboard/assets/brand/sidebar_ico.webp +0 -0
  63. package/dist/dashboard/assets/{card-_HwAEqFF.js → card-DU3Lq5Lm.js} +1 -1
  64. package/dist/dashboard/assets/card-DU3Lq5Lm.js.gz +0 -0
  65. package/dist/dashboard/assets/cli-tools-Dw1U_drK.js +1 -0
  66. package/dist/dashboard/assets/cli-tools-Dw1U_drK.js.gz +0 -0
  67. package/dist/dashboard/assets/{clipboard-button-ByjCk0o9.js → clipboard-button-CEL5Sg_J.js} +1 -1
  68. package/dist/dashboard/assets/clipboard-button-CEL5Sg_J.js.gz +0 -0
  69. package/dist/dashboard/assets/{dialog-BZhOlfz-.js → dialog-BjFR5UHy.js} +1 -1
  70. package/dist/dashboard/assets/dialog-BjFR5UHy.js.gz +0 -0
  71. package/dist/dashboard/assets/{drawer-BADqIRYP.js → drawer-HvBW0AjI.js} +1 -1
  72. package/dist/dashboard/assets/drawer-HvBW0AjI.js.gz +0 -0
  73. package/dist/dashboard/assets/entry-BSu3gFlt.js +4 -0
  74. package/dist/dashboard/assets/entry-BSu3gFlt.js.gz +0 -0
  75. package/dist/dashboard/assets/{entry-COaC-irN.js → entry-CECVELyy.js} +1 -1
  76. package/dist/dashboard/assets/entry-CECVELyy.js.gz +0 -0
  77. package/dist/dashboard/assets/entry-DYYofQ7W.js +1 -0
  78. package/dist/dashboard/assets/entry-DYYofQ7W.js.gz +0 -0
  79. package/dist/dashboard/assets/{formatters-DcHJyRiu.js → formatters-DLW4i8zK.js} +1 -1
  80. package/dist/dashboard/assets/formatters-DLW4i8zK.js.gz +0 -0
  81. package/dist/dashboard/assets/{index-CgKmFngv.js → index-1-fCqe64.js} +2 -2
  82. package/dist/dashboard/assets/index-1-fCqe64.js.gz +0 -0
  83. package/dist/dashboard/assets/{inline-B2BirKry.js → inline-DGvU2oLK.js} +1 -1
  84. package/dist/dashboard/assets/inline-DGvU2oLK.js.gz +0 -0
  85. package/dist/dashboard/assets/{input-D-qV9dKY.js → input-iNnO1X2K.js} +1 -1
  86. package/dist/dashboard/assets/input-iNnO1X2K.js.gz +0 -0
  87. package/dist/dashboard/assets/{layout-B7tVWhtV.js → layout-0q8UGypb.js} +1 -1
  88. package/dist/dashboard/assets/layout-0q8UGypb.js.gz +0 -0
  89. package/dist/dashboard/assets/{list-row-CtkGE3bF.js → list-row-C0w2bQjJ.js} +1 -1
  90. package/dist/dashboard/assets/list-row-C0w2bQjJ.js.gz +0 -0
  91. package/dist/dashboard/assets/{live-DGizF06-.js → live-M5aJ_xCa.js} +1 -1
  92. package/dist/dashboard/assets/live-M5aJ_xCa.js.gz +0 -0
  93. package/dist/dashboard/assets/{mutation-GU7XdEjz.js → mutation-BQ0ccH_A.js} +1 -1
  94. package/dist/dashboard/assets/mutation-BQ0ccH_A.js.gz +0 -0
  95. package/dist/dashboard/assets/{network-C9UE-bOr.js → network-BKeQGT3K.js} +1 -1
  96. package/dist/dashboard/assets/network-BKeQGT3K.js.gz +0 -0
  97. package/dist/dashboard/assets/{numbers-BnAd22a_.js → numbers-CivAk3da.js} +1 -1
  98. package/dist/dashboard/assets/numbers-CivAk3da.js.gz +0 -0
  99. package/dist/dashboard/assets/{pagination-BAywyMzS.js → pagination-CpzvI5ep.js} +1 -1
  100. package/dist/dashboard/assets/pagination-CpzvI5ep.js.gz +0 -0
  101. package/dist/dashboard/assets/providers/devin.svg +9 -0
  102. package/dist/dashboard/assets/providers/devin.svg.gz +0 -0
  103. package/dist/dashboard/assets/providers/windsurf.svg +10 -0
  104. package/dist/dashboard/assets/providers/windsurf.svg.gz +0 -0
  105. package/dist/dashboard/assets/providers/zro.svg +3 -0
  106. package/dist/dashboard/assets/providers/zro.svg.gz +0 -0
  107. package/dist/dashboard/assets/{query-keys-YSoQOUh6.js → query-keys-IKLh0YFd.js} +1 -1
  108. package/dist/dashboard/assets/query-keys-IKLh0YFd.js.gz +0 -0
  109. package/dist/dashboard/assets/quota-DiPD6UKH.js +1 -0
  110. package/dist/dashboard/assets/quota-DiPD6UKH.js.gz +0 -0
  111. package/dist/dashboard/assets/routing-BFQX2VBc.js +1 -0
  112. package/dist/dashboard/assets/routing-BFQX2VBc.js.gz +0 -0
  113. package/dist/dashboard/assets/{select-BCOcv8WK.js → select-CXbck0jG.js} +2 -2
  114. package/dist/dashboard/assets/select-CXbck0jG.js.gz +0 -0
  115. package/dist/dashboard/assets/settings-C_XvMHh9.js +1 -0
  116. package/dist/dashboard/assets/settings-C_XvMHh9.js.gz +0 -0
  117. package/dist/dashboard/assets/{stack-ByKt3Q1g.js → stack-DggRiCxE.js} +1 -1
  118. package/dist/dashboard/assets/stack-DggRiCxE.js.gz +0 -0
  119. package/dist/dashboard/assets/{state-DXbGTx7N.js → state-ClFzfbYp.js} +1 -1
  120. package/dist/dashboard/assets/state-ClFzfbYp.js.gz +0 -0
  121. package/dist/dashboard/assets/story/25817331.webp +0 -0
  122. package/dist/dashboard/assets/story/Shorekeeper.webp +0 -0
  123. package/dist/dashboard/assets/story/cartethyia-god.webp +0 -0
  124. package/dist/dashboard/assets/story/fleurdelys_plus.webp +0 -0
  125. package/dist/dashboard/assets/story/jinhsi-blossom.webp +0 -0
  126. package/dist/dashboard/assets/story/phrolova-a.webp +0 -0
  127. package/dist/dashboard/assets/story/requestdeniawokkjpg.webp +0 -0
  128. package/dist/dashboard/assets/story/wuthering-waves-hiyuki-aemeath-hiyuki-rover.webp +0 -0
  129. package/dist/dashboard/assets/{switch-ZeTsW623.js → switch-CTIFxKqS.js} +1 -1
  130. package/dist/dashboard/assets/switch-CTIFxKqS.js.gz +0 -0
  131. package/dist/dashboard/assets/{useMutation-CbCP4w1o.js → useMutation-BClG3yWj.js} +1 -1
  132. package/dist/dashboard/assets/useMutation-BClG3yWj.js.gz +0 -0
  133. package/dist/dashboard/console-backgrounds.webp +0 -0
  134. package/dist/dashboard/console-backgrounds_initial.webp +0 -0
  135. package/dist/dashboard/donation/qris-risun-marke.webp +0 -0
  136. package/dist/dashboard/index.html +3 -3
  137. package/dist/dashboard/index.html.gz +0 -0
  138. package/dist/dashboard/precompressed-assets.json +59 -56
  139. package/migrations/full-postgres/0044_add_models_warning_metadata.sql +10 -0
  140. package/migrations/full-postgres/0045_migrate_workbuddy_to_codebuddy.sql +156 -0
  141. package/migrations/full-postgres/0046_add_accounts_quota_plan_fetched_at.sql +7 -0
  142. package/migrations/lite-sqlite3/0044_add_models_warning_metadata.sql +11 -0
  143. package/migrations/lite-sqlite3/0045_migrate_workbuddy_to_codebuddy.sql +156 -0
  144. package/migrations/lite-sqlite3/0046_add_accounts_quota_plan_fetched_at.sql +4 -0
  145. package/package.json +1 -1
  146. package/src/boot/app.ts +10 -13
  147. package/src/boot/dependencies.ts +12 -4
  148. package/src/boot/flush.ts +20 -0
  149. package/src/boot/lifecycle.ts +7 -5
  150. package/src/config/cooldowns.ts +1 -1
  151. package/src/config/routing.ts +26 -0
  152. package/src/config/spec-metadata.ts +72 -0
  153. package/src/config/spec.ts +20 -6
  154. package/src/console/api-keys/contracts.ts +12 -1
  155. package/src/console/api-keys/routes.ts +50 -31
  156. package/src/console/auth/account-reset.ts +7 -9
  157. package/src/console/backup/contracts.ts +4 -0
  158. package/src/console/backup/export-endpoint.ts +7 -5
  159. package/src/console/cli-tools/service.ts +6 -12
  160. package/src/console/console-router-guards.ts +23 -16
  161. package/src/console/console-router.ts +7 -0
  162. package/src/console/dashboard-assets.ts +5 -5
  163. package/src/console/domain-registration.ts +27 -2
  164. package/src/console/inferhub/normalize.ts +24 -28
  165. package/src/console/inferhub/types.ts +3 -0
  166. package/src/console/ip-bans/routes.ts +93 -0
  167. package/src/console/observability/store.ts +0 -53
  168. package/src/console/providers/catalog/catalog-projections.ts +5 -2
  169. package/src/console/providers/catalog/contracts.ts +123 -0
  170. package/src/console/providers/catalog/discover-constants.ts +6 -0
  171. package/src/console/providers/catalog/model-operations.ts +122 -10
  172. package/src/console/providers/catalog/provider-operations.ts +116 -8
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/routes.ts +65 -4
  175. package/src/console/providers/catalog/store-helpers.ts +30 -1
  176. package/src/console/providers/catalog/store.ts +249 -4
  177. package/src/console/quota/account-quota-global.ts +97 -19
  178. package/src/console/quota/account-quota-shared.ts +49 -0
  179. package/src/console/quota/account-quota-tenant.ts +6 -18
  180. package/src/console/quota/native-identity.ts +2 -0
  181. package/src/console/quota/quota-view.ts +19 -3
  182. package/src/console/routing/pools/operations.ts +31 -46
  183. package/src/console/settings/contracts.ts +18 -0
  184. package/src/console/settings/store.ts +32 -0
  185. package/src/console/shared/errors.ts +33 -6
  186. package/src/console/system-prompt/contracts.ts +0 -1
  187. package/src/main.ts +3 -3
  188. package/src/network/pool/byte-accounting.ts +3 -2
  189. package/src/network/pool/selector.ts +182 -39
  190. package/src/network/pool-cooldown-store.ts +89 -14
  191. package/src/network/pool-health.ts +49 -61
  192. package/src/network/rate-limit-verdict.ts +1 -1
  193. package/src/network/ssrf.ts +6 -6
  194. package/src/observability/event-log.ts +7 -361
  195. package/src/observability/event-query.ts +18 -4
  196. package/src/observability/performance-metrics.ts +9 -0
  197. package/src/observability/runtime-metrics.ts +14 -9
  198. package/src/observability/telemetry-buffer.ts +15 -25
  199. package/src/observability/telemetry-reconciliation.ts +9 -37
  200. package/src/observability/telemetry-row.ts +125 -0
  201. package/src/persistence/db-mode.ts +4 -1
  202. package/src/persistence/dialect.ts +30 -0
  203. package/src/persistence/migrate.ts +6 -3
  204. package/src/persistence/schema.ts +28 -2
  205. package/src/persistence/ttl-cache.ts +20 -2
  206. package/src/protocol/codecs.ts +1 -1
  207. package/src/protocol/dsml.ts +35 -87
  208. package/src/protocol/gateway-error.ts +1 -0
  209. package/src/protocol/markup-stream-parser.ts +195 -0
  210. package/src/protocol/reasoning-text.ts +19 -0
  211. package/src/protocol/reasoning.ts +1 -1
  212. package/src/protocol/request/chat.ts +14 -14
  213. package/src/protocol/request/gemini.ts +8 -2
  214. package/src/protocol/request/messages.ts +4 -5
  215. package/src/protocol/response/chat.ts +12 -20
  216. package/src/protocol/response/gemini.ts +6 -1
  217. package/src/protocol/response/omp-tool-call-markup.ts +43 -86
  218. package/src/protocol/response/responses.ts +18 -2
  219. package/src/protocol/sse-decoder.ts +25 -8
  220. package/src/protocol/stop-reason.ts +23 -1
  221. package/src/protocol/stream-error-frames.ts +13 -3
  222. package/src/protocol/think-split.ts +112 -0
  223. package/src/protocol/translation/dialect/qwen.ts +26 -58
  224. package/src/protocol/translation/quirks.ts +1 -1
  225. package/src/protocol/translation/tool-repair.ts +2 -2
  226. package/src/protocol/upstream/send-claude-messages.ts +15 -4
  227. package/src/protocol/upstream-parsing.ts +1 -1
  228. package/src/providers/adapters/compatible-adapter.ts +78 -17
  229. package/src/providers/authentication/oauth-client.ts +1 -1
  230. package/src/providers/authentication/oauth-device-flow.ts +98 -8
  231. package/src/providers/authentication/oauth-refresh-service.ts +153 -59
  232. package/src/providers/configured-provider.ts +148 -0
  233. package/src/providers/default-registry.ts +117 -27
  234. package/src/providers/discovery/discovery-types.ts +12 -0
  235. package/src/providers/discovery/probe-phases.ts +74 -33
  236. package/src/providers/discovery/probing-service.ts +293 -165
  237. package/src/providers/integrations/alibabacp/alibabacp-models.ts +1 -2
  238. package/src/providers/integrations/{dashscope-models.ts → alibabacp/dashscope-models.ts} +2 -2
  239. package/src/providers/integrations/antigravity/antigravity-protocol.ts +121 -2
  240. package/src/providers/integrations/antigravity/antigravity.ts +13 -2
  241. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +72 -34
  242. package/src/providers/integrations/buddy/buddy-chat-shared.ts +191 -35
  243. package/src/providers/integrations/buddy/buddy-checkin.ts +17 -39
  244. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +2 -2
  245. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +5 -5
  246. package/src/providers/integrations/buddy/buddy-identity.ts +89 -6
  247. package/src/providers/integrations/buddy/buddy-media.ts +4 -5
  248. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +2 -2
  249. package/src/providers/integrations/buddy/buddy-quota-shared.ts +1 -1
  250. package/src/providers/integrations/buddy/buddy-usage-shared.ts +1 -3
  251. package/src/providers/integrations/buddy/capabilities.ts +6 -43
  252. package/src/providers/integrations/buddy/codebuddy/cn-catalog.ts +2 -1
  253. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +23 -1
  254. package/src/providers/integrations/buddy/codebuddy/provider.ts +6 -2
  255. package/src/providers/integrations/buddy/codebuddy/shared.ts +38 -27
  256. package/src/providers/integrations/claude/claude-quota.ts +14 -17
  257. package/src/providers/integrations/cline/cline.ts +2 -2
  258. package/src/providers/integrations/commandcode/quota.ts +22 -28
  259. package/src/providers/integrations/cursor/cursor-auth.ts +0 -8
  260. package/src/providers/integrations/cursor/cursor-catalog.ts +2 -0
  261. package/src/providers/integrations/cursor/cursor-exec.ts +745 -0
  262. package/src/providers/integrations/cursor/cursor-http2.ts +2 -0
  263. package/src/providers/integrations/cursor/cursor-request.ts +35 -20
  264. package/src/providers/integrations/cursor/cursor-shared.ts +26 -5
  265. package/src/providers/integrations/cursor/cursor.ts +139 -70
  266. package/src/providers/integrations/cursor/protocol/messages.ts +22 -29
  267. package/src/providers/integrations/cursor/protocol/protobuf-schema.ts +37 -0
  268. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +26 -100
  269. package/src/providers/integrations/devin/devin-auth.ts +353 -0
  270. package/src/providers/integrations/devin/devin-catalog.ts +207 -0
  271. package/src/providers/integrations/devin/devin-http.ts +91 -0
  272. package/src/providers/integrations/devin/devin-quota.ts +214 -0
  273. package/src/providers/integrations/devin/devin-shared.ts +111 -0
  274. package/src/providers/integrations/devin/devin.ts +539 -0
  275. package/src/providers/integrations/devin/protocol/devin-proto.ts +558 -0
  276. package/src/providers/integrations/devin/protocol/framing.ts +124 -0
  277. package/src/providers/integrations/factory/factory-oauth.ts +17 -44
  278. package/src/providers/integrations/factory/factory.ts +2 -2
  279. package/src/providers/integrations/freebuff/assets/official-system-manager.txt +88 -0
  280. package/src/providers/integrations/freebuff/assets/official-system-worker.txt +57 -0
  281. package/src/providers/integrations/freebuff/assets/official-tools.json +1070 -0
  282. package/src/providers/integrations/freebuff/freebuff-catalog.ts +120 -0
  283. package/src/providers/integrations/freebuff/freebuff-constants.ts +1233 -0
  284. package/src/providers/integrations/freebuff/freebuff-discovery.ts +108 -0
  285. package/src/providers/integrations/freebuff/freebuff-oauth.ts +240 -0
  286. package/src/providers/integrations/freebuff/freebuff-quota.ts +260 -0
  287. package/src/providers/integrations/freebuff/freebuff-virtual.ts +89 -0
  288. package/src/providers/integrations/freebuff/freebuff.ts +682 -0
  289. package/src/providers/integrations/freepi/freepi-catalog.ts +60 -0
  290. package/src/providers/integrations/freepi/freepi-discovery.ts +82 -0
  291. package/src/providers/integrations/freepi/freepi-headers.ts +21 -0
  292. package/src/providers/integrations/freepi/freepi-oauth.ts +142 -0
  293. package/src/providers/integrations/freepi/freepi-protocol.ts +242 -0
  294. package/src/providers/integrations/freepi/freepi-quota.ts +126 -0
  295. package/src/providers/integrations/freepi/freepi-realm.ts +35 -0
  296. package/src/providers/integrations/freepi/freepi.ts +292 -0
  297. package/src/providers/integrations/gemini.ts +2 -2
  298. package/src/providers/integrations/grok/grok-oauth.ts +48 -45
  299. package/src/providers/integrations/grok/grok-ratelimit-store.ts +10 -17
  300. package/src/providers/integrations/grok/grok-turn-index.ts +11 -16
  301. package/src/providers/integrations/hermes/hermes-oauth.ts +94 -0
  302. package/src/providers/integrations/{hermes.ts → hermes/hermes.ts} +13 -21
  303. package/src/providers/integrations/hermes/index.ts +2 -0
  304. package/src/providers/integrations/kiro/kiro-catalog.ts +17 -1
  305. package/src/providers/integrations/kiro/kiro-discovery.ts +10 -12
  306. package/src/providers/integrations/kiro/kiro-quota.ts +8 -4
  307. package/src/providers/integrations/kiro/kiro.ts +15 -19
  308. package/src/providers/integrations/ollamacloud/index.ts +2 -0
  309. package/src/providers/integrations/{ollamacloud-models.ts → ollamacloud/models.ts} +2 -2
  310. package/src/providers/integrations/{ollamacloud-quota.ts → ollamacloud/quota.ts} +3 -3
  311. package/src/providers/integrations/opencode/fingerprint.ts +1 -1
  312. package/src/providers/integrations/opencode/opencode-go-quota.ts +2 -8
  313. package/src/providers/integrations/perch/perch-catalog.ts +105 -0
  314. package/src/providers/integrations/perch/perch-credential.ts +25 -0
  315. package/src/providers/integrations/perch/perch-oauth.ts +170 -0
  316. package/src/providers/integrations/perch/perch-protocol.ts +208 -0
  317. package/src/providers/integrations/perch/perch-quota.ts +123 -0
  318. package/src/providers/integrations/perch/perch-realm.ts +82 -0
  319. package/src/providers/integrations/perch/perch-system-prompt.ts +13 -0
  320. package/src/providers/integrations/perch/perch.ts +305 -0
  321. package/src/providers/integrations/perch/saffron-system-prompt.txt +8 -0
  322. package/src/providers/integrations/qoder/chat-proto.ts +328 -328
  323. package/src/providers/integrations/qoder/claim-qoder-credits.ts +19 -60
  324. package/src/providers/integrations/qoder/qoder-adapter.ts +90 -40
  325. package/src/providers/integrations/qoder/qoder-auth.ts +101 -29
  326. package/src/providers/integrations/qoder/qoder-cache.ts +32 -0
  327. package/src/providers/integrations/qoder/qoder-catalog.ts +26 -6
  328. package/src/providers/integrations/qoder/qoder-chat.ts +7 -21
  329. package/src/providers/integrations/qoder/qoder-checkin.ts +6 -6
  330. package/src/providers/integrations/qoder/qoder-grpc.ts +210 -7
  331. package/src/providers/integrations/qoder/qoder-ide.ts +5 -108
  332. package/src/providers/integrations/qoder/qoder-identity-cache.ts +184 -0
  333. package/src/providers/integrations/qoder/qoder-native-identity-store.ts +83 -18
  334. package/src/providers/integrations/qoder/qoder-realm.ts +9 -6
  335. package/src/providers/integrations/qoder/qoder-umid.ts +109 -342
  336. package/src/providers/integrations/qoder/qoder-virtual.ts +33 -1
  337. package/src/providers/integrations/qoder/qoder.ts +3 -7
  338. package/src/providers/integrations/trae/trae-catalog.ts +9 -14
  339. package/src/providers/integrations/trae/trae-headers.ts +36 -11
  340. package/src/providers/integrations/trae/trae-oauth.ts +14 -17
  341. package/src/providers/integrations/trae/trae-protocol.ts +12 -11
  342. package/src/providers/integrations/trae/trae-quota.ts +96 -36
  343. package/src/providers/integrations/trae/trae-realm.ts +74 -15
  344. package/src/providers/integrations/trae/trae.ts +99 -26
  345. package/src/providers/integrations/zcode-start-plan/provider.ts +226 -23
  346. package/src/providers/integrations/zcode-start-plan/quota.ts +19 -2
  347. package/src/providers/integrations/zro/zro.ts +204 -0
  348. package/src/providers/operations/account-error-classification.ts +235 -278
  349. package/src/providers/operations/account-health-service.ts +273 -26
  350. package/src/providers/operations/account-refresh-status.ts +269 -0
  351. package/src/providers/operations/account-tier.ts +66 -0
  352. package/src/providers/operations/client-version-resolver.ts +1 -1
  353. package/src/providers/operations/client-versions.ts +42 -150
  354. package/src/providers/operations/cooldown-rules.ts +56 -0
  355. package/src/providers/operations/{model-catalog-cache.ts → provider-caches.ts} +68 -16
  356. package/src/providers/operations/provider-catalog-seeder.ts +4 -0
  357. package/src/providers/operations/provider-catalog-service.ts +1 -1
  358. package/src/providers/operations/provider-credential-service.ts +102 -32
  359. package/src/providers/operations/provider-rules/github.ts +48 -0
  360. package/src/providers/operations/provider-rules/grok.ts +33 -0
  361. package/src/providers/operations/provider-rules/inception.ts +49 -0
  362. package/src/providers/operations/provider-rules/index.ts +32 -0
  363. package/src/providers/operations/provider-rules/perch.ts +37 -0
  364. package/src/providers/operations/provider-rules/qoder.ts +70 -0
  365. package/src/providers/operations/provider-rules/trae.ts +81 -0
  366. package/src/providers/provider-contracts.ts +33 -2
  367. package/src/providers/{provider-credential-resolver.ts → provider-credentials.ts} +40 -3
  368. package/src/providers/provider-metadata.ts +99 -14
  369. package/src/providers/provider-model-definition.ts +36 -0
  370. package/src/providers/provider-probe.ts +15 -1
  371. package/src/providers/provider-registry.ts +42 -33
  372. package/src/providers/quota/quota-limit-windows.ts +16 -5
  373. package/src/providers/quota/quota-support.ts +1 -1
  374. package/src/providers/quota/quota-window-parser.ts +90 -18
  375. package/src/providers/quota/refresh.ts +96 -18
  376. package/src/security/abuse.ts +71 -0
  377. package/src/security/api-key-auth-cache.ts +1 -1
  378. package/src/security/api-key-auth.ts +6 -2
  379. package/src/transport/dispatch/attempt-finalize.ts +14 -13
  380. package/src/transport/dispatch/attempt-loop.ts +71 -17
  381. package/src/transport/dispatch/generation.ts +2 -8
  382. package/src/transport/dispatch/native-shared.ts +25 -0
  383. package/src/transport/dispatch/proxy-request.ts +9 -46
  384. package/src/transport/dispatch/responses-compact.ts +2 -8
  385. package/src/transport/dispatch/streaming/finalize.ts +9 -6
  386. package/src/transport/dispatch/streaming/stream.ts +9 -36
  387. package/src/transport/dispatch/systemone.ts +2 -8
  388. package/src/transport/dispatch/token-count.ts +2 -6
  389. package/src/transport/dispatch/upstream.ts +67 -1
  390. package/src/transport/drain-endpoint.ts +31 -4
  391. package/src/transport/failure-policy.ts +129 -37
  392. package/src/transport/guards/body-policy.ts +7 -1
  393. package/src/transport/guards/error-lifecycle.ts +13 -1
  394. package/src/transport/guards/request-context.ts +22 -14
  395. package/src/transport/request/preparer.ts +81 -71
  396. package/src/transport/routing/bounded-lru.ts +14 -0
  397. package/src/transport/routing/drain-reserve.ts +129 -0
  398. package/src/transport/routing/plan-gate.ts +7 -1
  399. package/src/transport/routing/round-robin.ts +60 -0
  400. package/src/transport/routing/route-catalog.ts +123 -111
  401. package/src/transport/routing/route-model.ts +51 -9
  402. package/src/transport/routing/router.ts +418 -33
  403. package/src/transport/surface/adapters.ts +11 -2
  404. package/src/transport/surface/chat/adapter.ts +2 -2
  405. package/src/transport/surface/chat/encode.ts +47 -4
  406. package/src/transport/surface/chat/parse.ts +2 -12
  407. package/src/transport/surface/completion.ts +44 -33
  408. package/src/transport/surface/content-parts.ts +21 -0
  409. package/src/transport/surface/messages/adapter.ts +9 -2
  410. package/src/transport/surface/messages/encode.ts +11 -3
  411. package/src/transport/surface/messages/parse.ts +2 -1
  412. package/src/transport/surface/messages/stream.ts +33 -17
  413. package/src/transport/surface/responses/encode.ts +34 -6
  414. package/src/transport/surface/responses/parse.ts +3 -11
  415. package/src/workers/daily-checkin.ts +1 -1
  416. package/src/workers/quota-refresh-sweep.ts +1 -1
  417. package/src/workers/scheduler.ts +22 -4
  418. package/dist/dashboard/assets/BannedPage-DeIbIiBU.js +0 -1
  419. package/dist/dashboard/assets/BannedPage-DeIbIiBU.js.gz +0 -0
  420. package/dist/dashboard/assets/CliToolDetailPage-BRWKPQPf.js.gz +0 -0
  421. package/dist/dashboard/assets/CliToolsPage-BP-Y5iHN.js.gz +0 -0
  422. package/dist/dashboard/assets/CombosPage-B7JHozVa.js +0 -1
  423. package/dist/dashboard/assets/CombosPage-B7JHozVa.js.gz +0 -0
  424. package/dist/dashboard/assets/ConsoleLogPage-GX7iGGcu.js +0 -1
  425. package/dist/dashboard/assets/ConsoleLogPage-GX7iGGcu.js.gz +0 -0
  426. package/dist/dashboard/assets/CustomizationPage-HVonbGEa.js +0 -1
  427. package/dist/dashboard/assets/CustomizationPage-HVonbGEa.js.gz +0 -0
  428. package/dist/dashboard/assets/HealthEventsModal-xaut4OJ9.js +0 -1
  429. package/dist/dashboard/assets/HealthEventsModal-xaut4OJ9.js.gz +0 -0
  430. package/dist/dashboard/assets/InjectSystemPromptPanel-Cv-2Ol_K.js.gz +0 -0
  431. package/dist/dashboard/assets/LoginPage-bKfz_b1K.js.gz +0 -0
  432. package/dist/dashboard/assets/ModelPicker-B-WQxx_H.js.gz +0 -0
  433. package/dist/dashboard/assets/OverviewPage-Z9pJZFo8.js +0 -1
  434. package/dist/dashboard/assets/OverviewPage-Z9pJZFo8.js.gz +0 -0
  435. package/dist/dashboard/assets/ProviderDetailPage-CMeDBxR1.js +0 -7
  436. package/dist/dashboard/assets/ProviderDetailPage-CMeDBxR1.js.gz +0 -0
  437. package/dist/dashboard/assets/ProviderIcon-jjqXJDA3.js +0 -1
  438. package/dist/dashboard/assets/ProviderIcon-jjqXJDA3.js.gz +0 -0
  439. package/dist/dashboard/assets/ProvidersPage-BXPdOpG4.js +0 -1
  440. package/dist/dashboard/assets/ProvidersPage-BXPdOpG4.js.gz +0 -0
  441. package/dist/dashboard/assets/ProxyPage-emlTuxz6.js.gz +0 -0
  442. package/dist/dashboard/assets/QuotaPage-CMJKNYWb.js +0 -1
  443. package/dist/dashboard/assets/QuotaPage-CMJKNYWb.js.gz +0 -0
  444. package/dist/dashboard/assets/SettingsPage-BSy7EwGo.js +0 -1
  445. package/dist/dashboard/assets/SettingsPage-BSy7EwGo.js.gz +0 -0
  446. package/dist/dashboard/assets/SetupPage-CUZrKs9r.js.gz +0 -0
  447. package/dist/dashboard/assets/StudioPage-jWmUdDpy.js.gz +0 -0
  448. package/dist/dashboard/assets/UsagePage-5F9tGVGG.js +0 -56
  449. package/dist/dashboard/assets/UsagePage-5F9tGVGG.js.gz +0 -0
  450. package/dist/dashboard/assets/api-keys-KvX83mRe.js +0 -1
  451. package/dist/dashboard/assets/api-keys-KvX83mRe.js.gz +0 -0
  452. package/dist/dashboard/assets/app-BEsU8hKs.css +0 -2
  453. package/dist/dashboard/assets/app-BEsU8hKs.css.gz +0 -0
  454. package/dist/dashboard/assets/assert-CidQVoNo.js.gz +0 -0
  455. package/dist/dashboard/assets/badge-DyI_mJfi.js.gz +0 -0
  456. package/dist/dashboard/assets/banner-B-yXtCqb.js.gz +0 -0
  457. package/dist/dashboard/assets/brand/bansos_page.png +0 -0
  458. package/dist/dashboard/assets/brand/denia_bansos.jpg +0 -0
  459. package/dist/dashboard/assets/brand/favicon.jpg +0 -0
  460. package/dist/dashboard/assets/brand/login_register.jpg +0 -0
  461. package/dist/dashboard/assets/brand/login_register_initial.jpg +0 -0
  462. package/dist/dashboard/assets/brand/og_bansos.jpg +0 -0
  463. package/dist/dashboard/assets/brand/og_image.jpg +0 -0
  464. package/dist/dashboard/assets/brand/sidebar_ico.jpg +0 -0
  465. package/dist/dashboard/assets/card-_HwAEqFF.js.gz +0 -0
  466. package/dist/dashboard/assets/cli-tools-Bwv5-zGZ.js +0 -1
  467. package/dist/dashboard/assets/cli-tools-Bwv5-zGZ.js.gz +0 -0
  468. package/dist/dashboard/assets/clipboard-button-ByjCk0o9.js.gz +0 -0
  469. package/dist/dashboard/assets/dialog-BZhOlfz-.js.gz +0 -0
  470. package/dist/dashboard/assets/drawer-BADqIRYP.js.gz +0 -0
  471. package/dist/dashboard/assets/entry-COaC-irN.js.gz +0 -0
  472. package/dist/dashboard/assets/entry-Ca7LSCet.js +0 -4
  473. package/dist/dashboard/assets/entry-Ca7LSCet.js.gz +0 -0
  474. package/dist/dashboard/assets/entry-PDd27uCd.js +0 -1
  475. package/dist/dashboard/assets/entry-PDd27uCd.js.gz +0 -0
  476. package/dist/dashboard/assets/formatters-DcHJyRiu.js.gz +0 -0
  477. package/dist/dashboard/assets/index-CgKmFngv.js.gz +0 -0
  478. package/dist/dashboard/assets/inline-B2BirKry.js.gz +0 -0
  479. package/dist/dashboard/assets/input-D-qV9dKY.js.gz +0 -0
  480. package/dist/dashboard/assets/layout-B7tVWhtV.js.gz +0 -0
  481. package/dist/dashboard/assets/list-row-CtkGE3bF.js.gz +0 -0
  482. package/dist/dashboard/assets/live-DGizF06-.js.gz +0 -0
  483. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js +0 -1
  484. package/dist/dashboard/assets/minimize-2-BTmMTK4o.js.gz +0 -0
  485. package/dist/dashboard/assets/mutation-GU7XdEjz.js.gz +0 -0
  486. package/dist/dashboard/assets/network-C9UE-bOr.js.gz +0 -0
  487. package/dist/dashboard/assets/numbers-BnAd22a_.js.gz +0 -0
  488. package/dist/dashboard/assets/pagination-BAywyMzS.js.gz +0 -0
  489. package/dist/dashboard/assets/query-keys-YSoQOUh6.js.gz +0 -0
  490. package/dist/dashboard/assets/quota-DJ1QlAOq.js +0 -1
  491. package/dist/dashboard/assets/quota-DJ1QlAOq.js.gz +0 -0
  492. package/dist/dashboard/assets/routing-fnCsxgNO.js +0 -1
  493. package/dist/dashboard/assets/routing-fnCsxgNO.js.gz +0 -0
  494. package/dist/dashboard/assets/select-BCOcv8WK.js.gz +0 -0
  495. package/dist/dashboard/assets/settings-BxslsErn.js +0 -1
  496. package/dist/dashboard/assets/settings-BxslsErn.js.gz +0 -0
  497. package/dist/dashboard/assets/stack-ByKt3Q1g.js.gz +0 -0
  498. package/dist/dashboard/assets/state-DXbGTx7N.js.gz +0 -0
  499. package/dist/dashboard/assets/switch-ZeTsW623.js.gz +0 -0
  500. package/dist/dashboard/assets/useMutation-CbCP4w1o.js.gz +0 -0
  501. package/dist/dashboard/console-backgrounds.jpg +0 -0
  502. package/dist/dashboard/console-backgrounds_initial.jpg +0 -0
  503. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  504. package/src/providers/bundled-discovery-providers.ts +0 -98
  505. package/src/providers/configured-openai-providers.ts +0 -120
  506. package/src/providers/integrations/buddy/turing-device-token.ts +0 -262
  507. package/src/providers/integrations/buddy/workbuddy/cn-oauth.ts +0 -30
  508. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +0 -58
  509. package/src/providers/integrations/buddy/workbuddy/oauth.ts +0 -31
  510. package/src/providers/integrations/buddy/workbuddy/provider.ts +0 -209
  511. package/src/providers/integrations/buddy/workbuddy/quota.ts +0 -161
  512. package/src/providers/integrations/buddy/workbuddy/shared.ts +0 -285
  513. package/src/providers/operations/model-discovery-cache.ts +0 -34
  514. package/src/providers/operations/probe-fetch.ts +0 -15
  515. package/src/providers/operations/provider-version-cache.ts +0 -30
  516. package/src/providers/provider-capabilities.ts +0 -33
  517. package/src/providers/provider-credential-envelope.ts +0 -43
  518. /package/dist/dashboard/assets/{app-CTxoqyYB.js → app-DIXLjojg.js} +0 -0
  519. /package/dist/dashboard/assets/{app-CTxoqyYB.js.gz → app-DIXLjojg.js.gz} +0 -0
@@ -25,17 +25,26 @@ import {
25
25
  type RequiredCapability,
26
26
  } from "../../protocol/translation/capabilities";
27
27
  import { planAllowsCandidate } from "./plan-gate";
28
+ import { accountReservePct, routingStickyWarmMs, routingStickyMaxSessions } from "../../config/routing";
29
+ import type { NormalizedQuotaFact } from "./route-model";
28
30
  import { InMemoryAdmissionController, admissionKey } from "./admission";
29
31
  import { ReservationManager } from "./reservations";
30
- import { createRoundRobinMap, RoundRobinState } from "./round-robin";
32
+ import { createRoundRobinMap, RoundRobinState, StickySessionStore } from "./round-robin";
33
+ import { drainScore, isInReserve } from "./drain-reserve";
31
34
  import {
32
35
  lookupRouteCandidates,
33
36
  parseModelReference,
34
37
  routeCandidateIndex,
35
38
  } from "./candidate-index";
36
- import { totalRemainingCredit, type ProviderQuotaWindow } from "../../providers/quota/quota-contracts";
37
- import { getCachedQuotaEntry, quotaLens } from "../../providers/quota/cache";
38
- import { readQuotaSnapshot } from "../../providers/quota/snapshot";
39
+ import {
40
+ totalRemainingCredit,
41
+ totalRemainingPercent,
42
+ type ProviderQuotaResult,
43
+ type ProviderQuotaWindow,
44
+ } from "../../providers/quota/quota-contracts";
45
+ import { getCachedQuotaEntries, quotaLens, type CachedQuotaEntry } from "../../providers/quota/cache";
46
+ import { readQuotaSnapshots } from "../../providers/quota/snapshot";
47
+ import type { ProviderQuotaResultSnapshot } from "../../persistence/schema";
39
48
  import type { CartethyiaDatabase } from "../../persistence/db-handle";
40
49
  import type { RedisClient } from "../../persistence/redis";
41
50
  import { sharedClaudeSlowLanes } from "../../providers/integrations/claude/claude-slow-lane";
@@ -88,6 +97,179 @@ export function filterCandidateQuotaWindows(
88
97
  return windows;
89
98
  }
90
99
 
100
+ /**
101
+ * Global freshness horizon for persisted quota/plan evidence. A plan or
102
+ * snapshot older than this is downgraded to `unknown` by routing instead of
103
+ * being trusted indefinitely. First cut uses one global value (not
104
+ * provider-specific); see proposal locked decision #4.
105
+ */
106
+ export const QUOTA_FRESHNESS_HORIZON_MS = 6 * 60 * 60 * 1000;
107
+
108
+ /** The account's total remaining absolute credit, or `null` when unknown. */
109
+ function accountRemainingCredit(candidate: RoutedCandidate): number | null {
110
+ const windows = candidate.quota_windows;
111
+ if (windows === undefined || windows.length === 0) return null;
112
+ return totalRemainingCredit(windows);
113
+ }
114
+
115
+ /**
116
+ * Evaluates whether the candidate sits below its configured usage floor.
117
+ * Mirrors the legacy ordering heuristic (credit vs lowest independent percent),
118
+ * but returns a tri-state used for proactive exclusion under the unified
119
+ * routing engine.
120
+ */
121
+ function usageFloorStateFor(candidate: RoutedCandidate): "above" | "below" | "unknown" {
122
+ const floor = candidate.usage_floor;
123
+ if (floor === null || floor === undefined || floor <= 0) return "unknown";
124
+ if (candidate.free_tier === true) return "above";
125
+ const windows = candidate.quota_windows;
126
+ if (windows === undefined || windows.length === 0) return "unknown";
127
+ if (candidate.usage_floor_unit === "credit") {
128
+ const credit = accountRemainingCredit(candidate);
129
+ if (credit === null) return "unknown";
130
+ return credit >= floor ? "above" : "below";
131
+ }
132
+ const percent = totalRemainingPercent(windows);
133
+ if (percent === null) return "unknown";
134
+ return percent >= floor ? "above" : "below";
135
+ }
136
+
137
+ /** Earliest stated reset across exhausted windows, when any carries one. */
138
+ function exhaustedResetMs(windows: readonly ProviderQuotaWindow[] | undefined): number | undefined {
139
+ if (windows === undefined) return undefined;
140
+ let earliest: number | undefined;
141
+ for (const window of windows) {
142
+ if (window.resetsAt === null || window.resetsAt === undefined) continue;
143
+ const at = Date.parse(window.resetsAt);
144
+ if (!Number.isFinite(at)) continue;
145
+ if (earliest === undefined || at < earliest) earliest = at;
146
+ }
147
+ return earliest;
148
+ }
149
+
150
+ /** True when every relevant quota window for the candidate is known-spent. */
151
+ function allWindowsExhausted(windows: readonly ProviderQuotaWindow[]): boolean {
152
+ if (windows.length === 0) return false;
153
+ for (const window of windows) {
154
+ if (window.accounting === "pooled") {
155
+ const remaining = window.remaining;
156
+ if (remaining !== null && remaining !== undefined && remaining > 0) return false;
157
+ const limit = window.limit;
158
+ if (limit !== null && limit !== undefined) {
159
+ const used = window.used;
160
+ if (used !== null && used !== undefined && used < limit) return false;
161
+ if (used === null && window.remainingPercent !== null && window.remainingPercent > 0) {
162
+ return false;
163
+ }
164
+ } else if (window.remainingPercent !== null && window.remainingPercent > 0) {
165
+ return false;
166
+ } else if (remaining === null && window.used === null) {
167
+ // No evidence of either remaining or used — not provably spent.
168
+ return false;
169
+ } else if (remaining !== null && limit === null) {
170
+ // Remaining=0 but no limit: cannot prove the pool is exhausted without
171
+ // knowing the total. This is the "credit window without a known ceiling"
172
+ // shape — treat as unknown, not exhausted.
173
+ return false;
174
+ }
175
+ } else {
176
+ const remainingPercent = window.remainingPercent;
177
+ if (remainingPercent !== null && remainingPercent > 0) return false;
178
+ if (remainingPercent === null) {
179
+ // Unknown independent window: cannot prove exhaustion.
180
+ return false;
181
+ }
182
+ }
183
+ }
184
+ return true;
185
+ }
186
+
187
+ /**
188
+ * Derives the normalized quota fact the routing decision engine evaluates.
189
+ */
190
+ function normalizeQuotaFact(
191
+ candidate: RoutedCandidate,
192
+ cacheEntry: { readonly fetchedAt: string | null; readonly quota: ProviderQuotaResult } | null | undefined,
193
+ snapshotEntry: { readonly fetchedAt: string; readonly quota: ProviderQuotaResult } | null | undefined,
194
+ now = Date.now(),
195
+ ): NormalizedQuotaFact {
196
+ const windows = candidate.quota_windows;
197
+ const source =
198
+ cacheEntry?.quota !== undefined
199
+ ? cacheEntry.quota.error === null
200
+ ? ("live_cache" as const)
201
+ : ("unsupported" as const)
202
+ : snapshotEntry?.quota !== undefined
203
+ ? snapshotEntry.quota.error === null
204
+ ? ("snapshot" as const)
205
+ : ("unsupported" as const)
206
+ : ("none" as const);
207
+
208
+ // Freshness: only live-cache and snapshot evidence carries an observed-at.
209
+ const observedSrc =
210
+ cacheEntry?.quota !== undefined && cacheEntry.quota.error === null
211
+ ? cacheEntry
212
+ : snapshotEntry?.quota !== undefined && snapshotEntry.quota.error === null
213
+ ? snapshotEntry
214
+ : null;
215
+ const rawObservedAt = observedSrc?.fetchedAt;
216
+ const observedAt =
217
+ rawObservedAt === null || rawObservedAt === undefined ? Number.NaN : Date.parse(rawObservedAt);
218
+ const hasObservedAt = Number.isFinite(observedAt) && observedAt > 0;
219
+ // Stale is only meaningful when we have an observation timestamp to judge.
220
+ // Without one (candidate-stamped windows, no cache/snapshot metadata) there
221
+ // is nothing to mark stale, so the windows remain usable evidence.
222
+ const isStale = hasObservedAt && now - observedAt > QUOTA_FRESHNESS_HORIZON_MS;
223
+
224
+ const usageFloorState = usageFloorStateFor(candidate);
225
+ // Stale evidence is downgraded to unknown — the freshness horizon (locked
226
+ // decision #4) means routing must not trust a weeks-old balance for either
227
+ // exhaustion or floor decisions.
228
+ if (isStale) {
229
+ return {
230
+ quotaState: "unknown",
231
+ quotaSource: source,
232
+ quotaObservedAt: hasObservedAt ? observedAt : null,
233
+ isStale: true,
234
+ usageFloorState: "unknown",
235
+ };
236
+ }
237
+ if (windows === undefined || windows.length === 0) {
238
+ return {
239
+ quotaState: "unknown",
240
+ quotaSource: source,
241
+ quotaObservedAt: hasObservedAt ? observedAt : null,
242
+ isStale: false,
243
+ usageFloorState,
244
+ };
245
+ }
246
+ if (usageFloorState === "below") {
247
+ return {
248
+ quotaState: "exhausted",
249
+ quotaSource: source,
250
+ quotaObservedAt: hasObservedAt ? observedAt : null,
251
+ isStale,
252
+ usageFloorState,
253
+ };
254
+ }
255
+ if (allWindowsExhausted(windows)) {
256
+ return {
257
+ quotaState: "exhausted",
258
+ quotaSource: source,
259
+ quotaObservedAt: hasObservedAt ? observedAt : null,
260
+ isStale,
261
+ usageFloorState,
262
+ };
263
+ }
264
+ return {
265
+ quotaState: "available",
266
+ quotaSource: source,
267
+ quotaObservedAt: hasObservedAt ? observedAt : null,
268
+ isStale,
269
+ usageFloorState,
270
+ };
271
+ }
272
+
91
273
  /**
92
274
  * Routing priority for an account's remaining allowance when two candidates sit
93
275
  * in the same rank group: positive remaining credit first, then unknown, then
@@ -103,6 +285,23 @@ function creditPriority(candidate: RoutedCandidate): number {
103
285
  return credit > 0 ? 1 : -1;
104
286
  }
105
287
 
288
+ /**
289
+ * Hot-window guard: true when the account's most-urgent window is ≥85% used.
290
+ * A nearly exhausted short window means an imminent mid-session block, so these
291
+ * candidates rank behind cooler siblings no matter their drain score — overflow
292
+ * lands on the next-most-urgent cool account instead.
293
+ */
294
+ function hotWindow(windows: readonly ProviderQuotaWindow[] | undefined): boolean {
295
+ if (windows === undefined || windows.length === 0) return false;
296
+ for (const window of windows) {
297
+ const used = window.usedPercent;
298
+ if (typeof used === "number" && Number.isFinite(used) && used >= 85) return true;
299
+ const remaining = window.remainingPercent;
300
+ if (typeof remaining === "number" && Number.isFinite(remaining) && remaining <= 15) return true;
301
+ }
302
+ return false;
303
+ }
304
+
106
305
  /**
107
306
  * Normalizes a requested model name before alias lookup. Coding-agent CLIs
108
307
  * version their model ids with variant suffixes (`claude-opus-5[1m]`,
@@ -299,13 +498,6 @@ export class EligibilityEvaluator {
299
498
  return { eligible: true, reason: "cooldown", candidate, ...spreadDeadline(candidate.cooldown_until) };
300
499
  return { eligible: true, reason: "healthy", candidate };
301
500
  }
302
-
303
- filter(candidates: readonly RouteCandidate[]): readonly RouteCandidate[] {
304
- return candidates
305
- .map((c) => this.evaluate(c))
306
- .filter((d) => d.eligible)
307
- .map((d) => d.candidate);
308
- }
309
501
  }
310
502
 
311
503
  export class RoutingEngine {
@@ -313,6 +505,7 @@ export class RoutingEngine {
313
505
  private readonly _roundRobinByProvider = createRoundRobinMap();
314
506
  private readonly eligibility = new EligibilityEvaluator();
315
507
  private readonly reservations = new ReservationManager();
508
+ private readonly sticky = new StickySessionStore(routingStickyMaxSessions());
316
509
 
317
510
  constructor(
318
511
  private readonly admission: AdmissionController = new InMemoryAdmissionController(),
@@ -440,6 +633,8 @@ export class RoutingEngine {
440
633
  candidate: RouteCandidate,
441
634
  snapshot: RouteSnapshot,
442
635
  tenantId: string | null,
636
+ quotaCache: ReadonlyMap<string, CachedQuotaEntry>,
637
+ snapshotMap: ReadonlyMap<string, ProviderQuotaResultSnapshot>,
443
638
  ): Promise<RouteCandidate> {
444
639
  const accountId = candidate.provider_account_id;
445
640
  if (accountId === undefined) return candidate;
@@ -448,11 +643,7 @@ export class RoutingEngine {
448
643
  candidate.provider_id,
449
644
  candidate.tenant_id ?? tenantId,
450
645
  );
451
- const cached = await getCachedQuotaEntry(
452
- quotaLens(candidate.tenant_id ?? tenantId),
453
- accountId,
454
- this.redis,
455
- ).catch(() => undefined);
646
+ const cached = quotaCache.get(accountId);
456
647
  // The live cache is authoritative when warm; when it is cold (e.g. right
457
648
  // after a restart, before the sweep re-reads accounts), fall back to the
458
649
  // persisted quota snapshot — the durable source of truth — so routing
@@ -460,9 +651,13 @@ export class RoutingEngine {
460
651
  // is never fresher than the cache, but a stale balance beats an unknown
461
652
  // one for ordering, and the sweep refreshes it on schedule.
462
653
  let quota = cached?.quota;
463
- if (quota === undefined && this.db !== undefined) {
464
- const stored = await readQuotaSnapshot(this.db, accountId).catch(() => null);
465
- quota = stored?.quota;
654
+ let stored: { readonly fetchedAt: string; readonly quota: ProviderQuotaResult } | null = null;
655
+ if (quota === undefined) {
656
+ const snap = snapshotMap.get(accountId);
657
+ if (snap !== undefined) {
658
+ stored = { fetchedAt: snap.fetchedAt, quota: snap.quota };
659
+ quota = snap.quota;
660
+ }
466
661
  }
467
662
  const rawWindows = quota?.windows;
468
663
  // The cache is authoritative when it has a reading; when it does not,
@@ -472,14 +667,32 @@ export class RoutingEngine {
472
667
  rawWindows === undefined
473
668
  ? undefined
474
669
  : candidate.provider_id === "zcode-start" ? rawWindows : filterCandidateQuotaWindows(rawWindows, candidate);
475
- return {
670
+ const stamped: RoutedCandidate = {
476
671
  ...candidate,
477
672
  ...(setting?.usageFloor == null
478
673
  ? {}
479
674
  : { usage_floor: setting.usageFloor, usage_floor_unit: setting.usageFloorUnit }),
480
- ...(quotaWindows === undefined ? {} : { quota_windows: quotaWindows }),
675
+ // When no cache/snapshot evidence exists, keep the candidate's own
676
+ // stamped windows (unit tests and in-memory fixture route this way).
677
+ ...(quotaWindows === undefined
678
+ ? candidate.quota_windows === undefined
679
+ ? {}
680
+ : { quota_windows: candidate.quota_windows }
681
+ : { quota_windows: quotaWindows }),
481
682
  ...(quota?.source === "zcode-start" && quota.error === null ? { quota_entitlements_known: true } : {}),
683
+ // Drain urgency and reserve are derived from the same windows the credit
684
+ // ordering already reads. They are ordering preferences, never
685
+ // exclusions: an account in reserve or with a zero drain score stays
686
+ // eligible and routed.
687
+ ...(quotaWindows === undefined && candidate.quota_windows === undefined
688
+ ? {}
689
+ : { drain_score: drainScore(quotaWindows ?? candidate.quota_windows) }),
690
+ ...(quotaWindows === undefined && candidate.quota_windows === undefined
691
+ ? {}
692
+ : { in_reserve: isInReserve(quotaWindows ?? candidate.quota_windows, accountReservePct()) }),
482
693
  };
694
+ const fact = normalizeQuotaFact(stamped, cached, stored);
695
+ return { ...stamped, quota_fact: fact };
483
696
  }
484
697
 
485
698
  /**
@@ -526,6 +739,24 @@ export class RoutingEngine {
526
739
  },
527
740
  };
528
741
  }
742
+ // Unified decision engine: normalized quota/plan fact.
743
+ const fact = candidate.quota_fact;
744
+ if (fact !== undefined) {
745
+ if (fact.quotaState === "exhausted" || fact.usageFloorState === "below") {
746
+ const reset = exhaustedResetMs(candidate.quota_windows);
747
+ return {
748
+ candidate,
749
+ decision: {
750
+ eligible: false,
751
+ rank: ROUTING_RANK.SATURATED,
752
+ reason: "quota_exhausted",
753
+ floor_blocked: fact.usageFloorState === "below",
754
+ saturated: false,
755
+ ...(reset === undefined ? {} : { cooldown_until: reset }),
756
+ },
757
+ };
758
+ }
759
+ }
529
760
  // Plan gate (free plan).
530
761
  if (!planAllowsCandidate(candidate)) {
531
762
  return {
@@ -552,11 +783,54 @@ export class RoutingEngine {
552
783
  };
553
784
  }
554
785
  }
786
+ // A soft cooldown is a deprioritization, not an exclusion: the evaluator
787
+ // returned it eligible (so a single-account deployment still routes), but
788
+ // it must still behind every fresh/slow sibling. Without this branch the
789
+ // candidate falls through to the quota classification below, which labels
790
+ // it FRESH_KNOWN/FRESH_UNKNOWN and lets it sort ahead of healthy accounts —
791
+ // the "still hit a cooled-down account, never failed over" report. The raw
792
+ // `reason` stays `"cooldown"` so `ELIGIBILITY_REASON_LABELS` and the
793
+ // envelope `reasons` stream are untouched, and the reset deadline rides
794
+ // along for the all-cooling 429 answer.
795
+ if (eligibility.reason === "cooldown") {
796
+ return {
797
+ candidate,
798
+ decision: {
799
+ eligible: true,
800
+ rank: ROUTING_RANK.COOLING,
801
+ reason: "cooldown",
802
+ ...(eligibility.cooldown_until !== undefined
803
+ ? { cooldown_until: eligibility.cooldown_until }
804
+ : {}),
805
+ },
806
+ };
807
+ }
808
+ // Unified engine: unknown quota is eligible but lower-rank than known-available.
809
+ if (fact !== undefined) {
810
+ if (fact.quotaState === "unknown") {
811
+ return {
812
+ candidate,
813
+ decision: {
814
+ eligible: true,
815
+ rank: ROUTING_RANK.FRESH_UNKNOWN,
816
+ reason: "quota_unknown",
817
+ },
818
+ };
819
+ }
820
+ return {
821
+ candidate,
822
+ decision: {
823
+ eligible: true,
824
+ rank: ROUTING_RANK.FRESH_KNOWN,
825
+ reason: "healthy",
826
+ },
827
+ };
828
+ }
555
829
  return {
556
830
  candidate,
557
831
  decision: {
558
832
  eligible: true,
559
- rank: ROUTING_RANK.FRESH,
833
+ rank: ROUTING_RANK.FRESH_KNOWN,
560
834
  reason: "healthy",
561
835
  },
562
836
  };
@@ -578,11 +852,11 @@ export class RoutingEngine {
578
852
  let i = 0;
579
853
  while (i < eligible.length) {
580
854
  const cur = eligible[i]!;
581
- const rank = decisions.get(admissionKey(cur))?.rank ?? ROUTING_RANK.FRESH;
855
+ const rank = decisions.get(admissionKey(cur))?.rank ?? ROUTING_RANK.FRESH_KNOWN;
582
856
  let j = i + 1;
583
857
  while (j < eligible.length) {
584
858
  const next = eligible[j]!;
585
- const nextRank = decisions.get(admissionKey(next))?.rank ?? ROUTING_RANK.FRESH;
859
+ const nextRank = decisions.get(admissionKey(next))?.rank ?? ROUTING_RANK.FRESH_KNOWN;
586
860
  if (
587
861
  next.provider_id !== cur.provider_id ||
588
862
  next.model_id !== cur.model_id ||
@@ -674,7 +948,7 @@ export class RoutingEngine {
674
948
  const decision = pinnedDecisions.find((d) => d.candidate === candidate);
675
949
  decisions.set(admissionKey(candidate), {
676
950
  eligible: true,
677
- rank: ROUTING_RANK.FRESH,
951
+ rank: ROUTING_RANK.FRESH_KNOWN,
678
952
  reason: "healthy",
679
953
  ...(decision?.cooldown_until !== undefined
680
954
  ? { cooldown_until: decision.cooldown_until }
@@ -699,6 +973,7 @@ export class RoutingEngine {
699
973
  allowCliMappings = false,
700
974
  keyId?: string,
701
975
  continuationAffinity?: RouteContinuationAffinity,
976
+ sessionId?: string,
702
977
  ): Promise<RoutePlan> {
703
978
  const tid = tenantId ?? null;
704
979
  const { resolved, matching, unmatchedMembers } = this.resolveMatchingCandidates(
@@ -751,9 +1026,49 @@ export class RoutingEngine {
751
1026
  // single per-candidate decision. `plan()` then sorts once by rank and
752
1027
  // hands the full `decisions` map downstream, so no later layer re-derives
753
1028
  // a competing ordering.
1029
+ //
1030
+ // Quota evidence is read in batch before the per-candidate pass: one
1031
+ // in-memory/Redis `MGET` per lens and one DB query for the accounts the
1032
+ // cache did not answer, instead of a serial round trip per candidate. The
1033
+ // per-candidate stamping below then consumes the shared maps, keeping the
1034
+ // live-cache-authoritative / snapshot-fallback semantics identical.
1035
+ const quotaCacheByAccount = new Map<string, CachedQuotaEntry>();
1036
+ const lensToAccounts = new Map<string, string[]>();
1037
+ for (const candidate of matching) {
1038
+ const accountId = candidate.provider_account_id;
1039
+ if (accountId === undefined) continue;
1040
+ const lens = quotaLens(candidate.tenant_id ?? tid);
1041
+ const accounts = lensToAccounts.get(lens);
1042
+ if (accounts === undefined) lensToAccounts.set(lens, [accountId]);
1043
+ else accounts.push(accountId);
1044
+ }
1045
+ for (const [lens, accountIds] of lensToAccounts) {
1046
+ const entries = await getCachedQuotaEntries(lens, accountIds, this.redis).catch(
1047
+ () => new Map<string, CachedQuotaEntry>(),
1048
+ );
1049
+ for (const [accountId, entry] of entries) quotaCacheByAccount.set(accountId, entry);
1050
+ }
1051
+ const snapshotByAccount = new Map<string, ProviderQuotaResultSnapshot>();
1052
+ if (this.db !== undefined) {
1053
+ const needingSnapshot = matching
1054
+ .map((candidate) => candidate.provider_account_id)
1055
+ .filter((accountId): accountId is string => accountId !== undefined && !quotaCacheByAccount.has(accountId));
1056
+ if (needingSnapshot.length > 0) {
1057
+ const snaps = await readQuotaSnapshots(this.db, needingSnapshot).catch(
1058
+ () => new Map<string, ProviderQuotaResultSnapshot>(),
1059
+ );
1060
+ for (const [accountId, snap] of snaps) snapshotByAccount.set(accountId, snap);
1061
+ }
1062
+ }
754
1063
  const evaluations = await Promise.all(
755
1064
  matching.map(async (candidate) => {
756
- const stamped = await this.applyUsageFloorToCandidate(candidate, snapshot, tid);
1065
+ const stamped = await this.applyUsageFloorToCandidate(
1066
+ candidate,
1067
+ snapshot,
1068
+ tid,
1069
+ quotaCacheByAccount,
1070
+ snapshotByAccount,
1071
+ );
757
1072
  return this.evaluateCandidate(stamped, snapshot, tid, requiredCapabilities);
758
1073
  }),
759
1074
  );
@@ -810,7 +1125,9 @@ export class RoutingEngine {
810
1125
 
811
1126
  // Every eligible candidate cooling and no healthy/slow sibling → 429.
812
1127
  const healthyCount = [...decisions.values()].filter(
813
- (d) => d.eligible && (d.reason === "healthy" || d.reason === "slow"),
1128
+ (d) =>
1129
+ d.eligible &&
1130
+ (d.reason === "healthy" || d.reason === "slow" || d.reason === "quota_unknown"),
814
1131
  ).length;
815
1132
  if (healthyCount === 0) {
816
1133
  throw accountsRateLimitedError(
@@ -821,13 +1138,50 @@ export class RoutingEngine {
821
1138
  }
822
1139
 
823
1140
  // Single sort by rank (fresh → slow → cooling → saturated), stable. Within
824
- // the same rank, accounts with remaining credit come first unless the model
825
- // is free tier, which routes to any account.
1141
+ // the same rank, ordered by credit → reserve → hot-window guard → measured
1142
+ // usage → drain → round-robin. The drain score chases quota about to expire
1143
+ // unused; reserve yields to non-reserve siblings; a hot primary window (≥85%
1144
+ // used) defers to cooler accounts so overflow lands on a cool sibling.
1145
+ const reservePct = accountReservePct();
1146
+ const stickyWarmMs = routingStickyWarmMs();
1147
+ const stickyEntry = stickyWarmMs > 0 ? this.sticky.get(eligible[0]?.provider_id ?? "", sessionId) : undefined;
1148
+ const stickyAccountId = stickyEntry?.accountId;
1149
+ const stickyWarm = stickyEntry !== undefined && Date.now() - stickyEntry.lastUsedAtMs < stickyWarmMs;
826
1150
  eligible = [...eligible].sort((left, right) => {
827
- const leftRank = decisions.get(admissionKey(left))?.rank ?? ROUTING_RANK.FRESH;
828
- const rightRank = decisions.get(admissionKey(right))?.rank ?? ROUTING_RANK.FRESH;
1151
+ const leftRank = decisions.get(admissionKey(left))?.rank ?? ROUTING_RANK.FRESH_KNOWN;
1152
+ const rightRank = decisions.get(admissionKey(right))?.rank ?? ROUTING_RANK.FRESH_KNOWN;
829
1153
  if (leftRank !== rightRank) return leftRank - rightRank;
830
- return creditPriority(right) - creditPriority(left);
1154
+ // A free-tier model routes to any account: skip every quota-derived
1155
+ // preference (credit, reserve, hot-window, drain) so the original order
1156
+ // is preserved. Sticky warm pins still apply — they are about prompt
1157
+ // cache warmth, not quota.
1158
+ const bothFree = left.free_tier === true && right.free_tier === true;
1159
+ // Sticky warm pin: within the warm window the account that last served
1160
+ // this session wins outright (explicit pins always win).
1161
+ const leftSticky = left.provider_account_id === stickyAccountId;
1162
+ const rightSticky = right.provider_account_id === stickyAccountId;
1163
+ if (leftSticky !== rightSticky) {
1164
+ if (stickyEntry?.explicit === true) return leftSticky ? -1 : 1;
1165
+ if (stickyWarm) return leftSticky ? -1 : 1;
1166
+ }
1167
+ if (bothFree) return 0;
1168
+ const leftReserve = (left as RoutedCandidate).in_reserve ??
1169
+ isInReserve(left.quota_windows, reservePct);
1170
+ const rightReserve = (right as RoutedCandidate).in_reserve ??
1171
+ isInReserve(right.quota_windows, reservePct);
1172
+ if (leftReserve !== rightReserve) return leftReserve ? 1 : -1;
1173
+ const leftPriority = (left as RoutedCandidate).account_priority ?? 0;
1174
+ const rightPriority = (right as RoutedCandidate).account_priority ?? 0;
1175
+ if (leftPriority !== rightPriority) return rightPriority - leftPriority;
1176
+ const leftHot = hotWindow(left.quota_windows);
1177
+ const rightHot = hotWindow(right.quota_windows);
1178
+ if (leftHot !== rightHot) return leftHot ? 1 : -1;
1179
+ const credit = creditPriority(right) - creditPriority(left);
1180
+ if (credit !== 0) return credit;
1181
+ const leftDrain = (left as RoutedCandidate).drain_score ?? 0;
1182
+ const rightDrain = (right as RoutedCandidate).drain_score ?? 0;
1183
+ if (leftDrain !== rightDrain) return rightDrain - leftDrain;
1184
+ return 0;
831
1185
  });
832
1186
 
833
1187
  // Provider routing rotates accounts within the same rank group only; it can
@@ -836,6 +1190,13 @@ export class RoutingEngine {
836
1190
  eligible = this.applyProviderRoutingSorted(eligible, decisions, snapshot, tid);
837
1191
 
838
1192
  const chosen = eligible[0]!;
1193
+ // Record which account served this session so a follow-up request within the
1194
+ // warm window can prefer it (prompt-cache warmth). Explicit operator pins are
1195
+ // recorded as explicit so they win even past the warm window.
1196
+ if (sessionId !== undefined && chosen.provider_account_id !== undefined) {
1197
+ const explicitPin = continuationAffinity?.providerAccountId === chosen.provider_account_id;
1198
+ this.sticky.set(chosen.provider_id, sessionId, chosen.provider_account_id, explicitPin);
1199
+ }
839
1200
  return {
840
1201
  revision: snapshot.revision,
841
1202
  candidates: eligible,
@@ -928,7 +1289,31 @@ export class RoutingEngine {
928
1289
  matching = groups.flat();
929
1290
  }
930
1291
  }
931
- if (matching.length === 0) throw modelNotFoundError(requestedModel, modelIds);
1292
+ if (matching.length === 0) {
1293
+ // A qualified `provider/model` reference whose model is not in the
1294
+ // catalog still routes: clone the provider's first live candidate (any
1295
+ // model) and retarget it at the requested model. This is the passthrough
1296
+ // contract — a provider that accepts arbitrary model ids (BYOK, custom
1297
+ // gateways, or a model the catalog has not fetched yet) must be able to
1298
+ // serve them. The clone inherits the provider's endpoint, wire family,
1299
+ // dialect, capabilities, tenant scoping and account/network-pool wiring;
1300
+ // only the model id changes. A provider with zero candidates cannot be
1301
+ // synthesized, so bare-model misses and empty providers still 404.
1302
+ if (parsedModels.length === 1 && parsedModels[0]!.parsed.providerId !== null) {
1303
+ const providerId = parsedModels[0]!.parsed.providerId;
1304
+ const sibling = [...index.qualified.entries()]
1305
+ .filter(([key]) => key.startsWith(`${providerId}/`))
1306
+ .flatMap(([, group]) => group)
1307
+ .find((candidate) => candidate.tenant_id == null || candidate.tenant_id === tid);
1308
+ if (sibling !== undefined) {
1309
+ matching = [{
1310
+ ...sibling,
1311
+ model_id: parsedModels[0]!.parsed.modelId,
1312
+ }];
1313
+ }
1314
+ }
1315
+ if (matching.length === 0) throw modelNotFoundError(requestedModel, modelIds);
1316
+ }
932
1317
  const owners = [...new Set(matching.map((candidate) => candidate.provider_id))];
933
1318
  if (owners.length > 1 && !resolved.model.includes("/") && !combo)
934
1319
  throw ambiguousModelError(requestedModel, owners);
@@ -113,13 +113,22 @@ function endpointPathSurface(path: string): SourceSurface | undefined {
113
113
  return undefined;
114
114
  }
115
115
 
116
- export class SurfaceAdapterRegistry {
117
- constructor(private readonly adapters: SurfaceAdapter[]) {
116
+ export class SurfaceAdapterRegistry<T extends SurfaceAdapter = SurfaceAdapter> {
117
+ constructor(private readonly adapters: T[]) {
118
118
  if (!adapters || adapters.length === 0) {
119
119
  throw new Error("SurfaceAdapterRegistry requires at least one adapter");
120
120
  }
121
121
  }
122
122
 
123
+ /**
124
+ * Map of surface → adapter. The keys are the adapters' `surface` values, so
125
+ * this is the single place pipeline composition derives the adapter lookup
126
+ * from — never re-spell the adapter list beside the registry.
127
+ */
128
+ bySurface(): ReadonlyMap<SourceSurface, T> {
129
+ return new Map(this.adapters.map((adapter) => [adapter.surface, adapter]));
130
+ }
131
+
123
132
  /**
124
133
  * Evaluates every signal once, then picks a winner by the fixed precedence
125
134
  * above. Evaluating all three even when an earlier signal already wins is
@@ -2,9 +2,9 @@ import type { CanonicalEvent, CanonicalMessage, CanonicalRequest, ContentPart }
2
2
  import type { SurfaceAdapter, SurfaceInput, SurfaceOutput } from "../adapters";
3
3
  import { isRecord } from "../../../protocol/codecs";
4
4
  import { readString } from "../../../protocol/codecs";
5
+ import { decodeWireBody } from "../content-parts";
5
6
  import type { ChatEncodeOptions } from "./parse";
6
7
  import {
7
- decodeBody,
8
8
  parseGenerationControls,
9
9
  parseMessage,
10
10
  requestBody,
@@ -38,7 +38,7 @@ export class ChatAdapter implements SurfaceAdapter {
38
38
  * false "ambiguous body shape" disagreement while routing correctly.
39
39
  */
40
40
  matchesBodyShape(body: unknown): boolean {
41
- const value = decodeBody(body);
41
+ const value = decodeWireBody(body);
42
42
  if (!isRecord(value) || "input" in value || !Array.isArray(value.messages)) return false;
43
43
  if (value.messages.length === 0) return false;
44
44
  return value.messages.some((message) => isRecord(message) && typeof message.role === "string");