cartethyia 2.5.9 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (386) hide show
  1. package/.env.example +15 -42
  2. package/README.md +11 -10
  3. package/dist/dashboard/assets/{BannedPage-Brvuj_Eb.js → BannedPage-l6XMnfr9.js} +1 -1
  4. package/dist/dashboard/assets/BannedPage-l6XMnfr9.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolDetailPage-CqfXtI3y.js → CliToolDetailPage-DwOzAW45.js} +1 -1
  6. package/dist/dashboard/assets/CliToolDetailPage-DwOzAW45.js.gz +0 -0
  7. package/dist/dashboard/assets/{CliToolsPage-GQeS91bg.js → CliToolsPage-BT3Up3QA.js} +1 -1
  8. package/dist/dashboard/assets/CliToolsPage-BT3Up3QA.js.gz +0 -0
  9. package/dist/dashboard/assets/{CombosPage-BDRsTXBB.js → CombosPage-6kYIUpzR.js} +1 -1
  10. package/dist/dashboard/assets/CombosPage-6kYIUpzR.js.gz +0 -0
  11. package/dist/dashboard/assets/{ConsoleLogPage-zP8AFHX0.js → ConsoleLogPage-zRnj1mlR.js} +1 -1
  12. package/dist/dashboard/assets/ConsoleLogPage-zRnj1mlR.js.gz +0 -0
  13. package/dist/dashboard/assets/{CustomizationPage-BmfrNZ14.js → CustomizationPage-C7m-H1Rz.js} +1 -1
  14. package/dist/dashboard/assets/CustomizationPage-C7m-H1Rz.js.gz +0 -0
  15. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js +1 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C8MR_BdB.js.gz +0 -0
  17. package/dist/dashboard/assets/{LoginPage-DoreIgm8.js → LoginPage-CbphLvjg.js} +1 -1
  18. package/dist/dashboard/assets/LoginPage-CbphLvjg.js.gz +0 -0
  19. package/dist/dashboard/assets/{ModelPicker-C7pDb41R.js → ModelPicker-DVny4S5_.js} +1 -1
  20. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  21. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js +1 -0
  22. package/dist/dashboard/assets/OverviewPage-DABu2Wgt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js +3 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-C15x9q13.js.gz +0 -0
  25. package/dist/dashboard/assets/{ProvidersPage-BnoPgA7w.js → ProvidersPage-Dfb2896L.js} +1 -1
  26. package/dist/dashboard/assets/ProvidersPage-Dfb2896L.js.gz +0 -0
  27. package/dist/dashboard/assets/{ProxyPage-Bq_TGgdb.js → ProxyPage-BaCIdAUt.js} +1 -1
  28. package/dist/dashboard/assets/ProxyPage-BaCIdAUt.js.gz +0 -0
  29. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js +1 -0
  30. package/dist/dashboard/assets/QuotaPage-BsdoCIw1.js.gz +0 -0
  31. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js +1 -0
  32. package/dist/dashboard/assets/SettingsPage-KVJFh3Ip.js.gz +0 -0
  33. package/dist/dashboard/assets/{SetupPage-Day3mCOn.js → SetupPage-D4g6vOGc.js} +1 -1
  34. package/dist/dashboard/assets/SetupPage-D4g6vOGc.js.gz +0 -0
  35. package/dist/dashboard/assets/{SortableList-DQxllnB5.js → SortableList-C93i14BD.js} +1 -1
  36. package/dist/dashboard/assets/SortableList-C93i14BD.js.gz +0 -0
  37. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js → StudioPage-SgQpymjm.js} +1 -1
  38. package/dist/dashboard/assets/{StudioPage-uMExoWu1.js.gz → StudioPage-SgQpymjm.js.gz} +0 -0
  39. package/dist/dashboard/assets/{UsagePage-DXa1oR-4.js → UsagePage-BFME-eba.js} +2 -2
  40. package/dist/dashboard/assets/UsagePage-BFME-eba.js.gz +0 -0
  41. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  42. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  43. package/dist/dashboard/assets/{app-BIt446yE.js → app-2KhtHCv9.js} +1 -1
  44. package/dist/dashboard/assets/{app-BIt446yE.js.gz → app-2KhtHCv9.js.gz} +0 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  46. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  47. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  48. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  49. package/dist/dashboard/assets/{badge-CR72dasT.js → badge-CKsF123d.js} +1 -1
  50. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  51. package/dist/dashboard/assets/{banner-CI9_eSfO.js → banner-8j7uChA9.js} +1 -1
  52. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  53. package/dist/dashboard/assets/{card-DS7CQEsO.js → card-CdsSlWA3.js} +1 -1
  54. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  55. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  56. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  57. package/dist/dashboard/assets/{clipboard-button-611pSsaX.js → clipboard-button-uL5aYLfW.js} +1 -1
  58. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  59. package/dist/dashboard/assets/{dialog-k37exjNt.js → dialog-C07hr5_G.js} +1 -1
  60. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  61. package/dist/dashboard/assets/{drawer-CX7N8ucR.js → drawer-BPO1TcKH.js} +1 -1
  62. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  63. package/dist/dashboard/assets/{entry-CTP8AyiS.js → entry-Bz_CtDfC.js} +1 -1
  64. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CwPRKk0d.js +4 -0
  66. package/dist/dashboard/assets/entry-CwPRKk0d.js.gz +0 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  68. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  69. package/dist/dashboard/assets/{github-badge-sltk1qUi.js → github-badge-DmQreSDd.js} +1 -1
  70. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  71. package/dist/dashboard/assets/{index-z3zDOSMM.js → index-Bblu6erR.js} +2 -2
  72. package/dist/dashboard/assets/index-Bblu6erR.js.gz +0 -0
  73. package/dist/dashboard/assets/{inline-BhY2sSnO.js → inline-C9dCYaBZ.js} +1 -1
  74. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  75. package/dist/dashboard/assets/{input-V1umFiBk.js → input-CjCnXweJ.js} +1 -1
  76. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  77. package/dist/dashboard/assets/{layout-BBOYycyS.js → layout-V6y8DAkl.js} +1 -1
  78. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  79. package/dist/dashboard/assets/{list-row-1zzZYM0B.js → list-row-CM1AaG_y.js} +1 -1
  80. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  81. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  82. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  83. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  84. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  85. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  86. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  87. package/dist/dashboard/assets/{numbers-DkfMR5Qs.js → numbers-BOevJzdz.js} +1 -1
  88. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  89. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  90. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  91. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  92. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  93. package/dist/dashboard/assets/{select-C6MMiaio.js → select-C980c4cz.js} +1 -1
  94. package/dist/dashboard/assets/select-C980c4cz.js.gz +0 -0
  95. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  96. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  97. package/dist/dashboard/assets/{stack-BHTfXdz_.js → stack-_gfTdLVB.js} +1 -1
  98. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  99. package/dist/dashboard/assets/{state-mPDRlQkl.js → state-2c5V4zo7.js} +1 -1
  100. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  101. package/dist/dashboard/assets/{switch-DOmJxg7H.js → switch-Dkke7Naf.js} +1 -1
  102. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  105. package/dist/dashboard/index.html +1 -1
  106. package/dist/dashboard/index.html.gz +0 -0
  107. package/dist/dashboard/precompressed-assets.json +51 -51
  108. package/migrations/README.md +52 -52
  109. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  110. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  111. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  112. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  113. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  114. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  115. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  116. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  117. package/package.json +96 -97
  118. package/scripts/cli/admin.ts +126 -125
  119. package/scripts/cli/autostart.ts +237 -237
  120. package/scripts/cli/background.ts +120 -110
  121. package/scripts/cli/bin/carte.cjs +8 -8
  122. package/scripts/cli/bin/cartethyia.cjs +167 -167
  123. package/scripts/cli/console-menu.ts +308 -275
  124. package/scripts/cli/controller.ts +145 -0
  125. package/scripts/cli/doctor.ts +1 -1
  126. package/scripts/cli/env.ts +99 -99
  127. package/scripts/cli/gateway.ts +264 -261
  128. package/scripts/{health.ts → cli/health.ts} +4 -4
  129. package/scripts/cli/instance.ts +234 -224
  130. package/scripts/cli/main.ts +188 -164
  131. package/scripts/cli/platform.ts +64 -64
  132. package/scripts/cli/prompt.ts +275 -107
  133. package/scripts/cli/services.ts +88 -88
  134. package/scripts/cli/setup.ts +445 -445
  135. package/scripts/cli/start.ts +85 -82
  136. package/scripts/cli/startup-signal.ts +23 -23
  137. package/scripts/cli/telemetry.ts +70 -0
  138. package/scripts/cli/tray/index.ts +263 -263
  139. package/scripts/cli/tray/windows.ps1 +107 -107
  140. package/scripts/cli/tray.ts +192 -208
  141. package/scripts/cli/update.ts +39 -39
  142. package/scripts/cli/version.ts +67 -67
  143. package/scripts/cli/wordmark.ts +58 -58
  144. package/src/boot/app.ts +16 -1
  145. package/src/boot/dependencies.ts +128 -25
  146. package/src/config/package-paths.ts +24 -0
  147. package/src/config/resources.ts +137 -0
  148. package/src/config/runtime.ts +133 -76
  149. package/src/config/spec.ts +30 -29
  150. package/src/config/telemetry.ts +0 -25
  151. package/src/console/api-keys/bucket-spend.ts +14 -17
  152. package/src/console/api-keys/contracts.ts +410 -410
  153. package/src/console/api-keys/routes.ts +12 -0
  154. package/src/console/audit/store.ts +2 -3
  155. package/src/console/auth/setup-service.ts +8 -1
  156. package/src/console/backup/routes.ts +7 -0
  157. package/src/console/backup/service.ts +463 -463
  158. package/src/console/backup/store.ts +16 -11
  159. package/src/console/backup/validate.ts +1 -4
  160. package/src/console/cli-tools/fs-ops.ts +18 -2
  161. package/src/console/data-management/contracts.ts +1 -8
  162. package/src/console/data-management/operations.ts +61 -48
  163. package/src/console/data-management/routes.ts +4 -2
  164. package/src/console/domain-registration.ts +25 -3
  165. package/src/console/inferhub/client.ts +139 -0
  166. package/src/console/inferhub/key-store.ts +129 -0
  167. package/src/console/inferhub/normalize.ts +347 -0
  168. package/src/console/inferhub/routes.ts +411 -0
  169. package/src/console/inferhub/store.ts +109 -0
  170. package/src/console/inferhub/types.ts +183 -0
  171. package/src/console/observability/contracts.ts +2 -41
  172. package/src/console/observability/store.ts +176 -371
  173. package/src/console/providers/catalog/public-model-store.ts +0 -0
  174. package/src/console/providers/catalog/store.ts +23 -36
  175. package/src/console/quota/account-quota-shared.ts +52 -52
  176. package/src/console/quota/account-quota-tenant.ts +16 -24
  177. package/src/console/quota/refresh.ts +441 -441
  178. package/src/console/remote-support/service.ts +270 -114
  179. package/src/console/routing/pools/import-parse.ts +206 -206
  180. package/src/console/routing/pools/operations.ts +543 -543
  181. package/src/console/routing/pools/probe-verdict.ts +132 -132
  182. package/src/console/routing/pools/routes.ts +181 -181
  183. package/src/console/routing/pools/store.ts +1 -1
  184. package/src/console/settings/contracts.ts +42 -40
  185. package/src/console/settings/store.ts +25 -25
  186. package/src/console/share/share-stats.ts +22 -4
  187. package/src/console/share/share-usage.ts +30 -4
  188. package/src/console/shared/cursor.ts +15 -0
  189. package/src/console/updates/repository-updates.ts +40 -39
  190. package/src/console/updates/routes.ts +12 -0
  191. package/src/main.ts +13 -5
  192. package/src/network/pool/resolver.ts +1104 -1104
  193. package/src/observability/client-name.ts +22 -0
  194. package/src/observability/event-log.ts +5 -29
  195. package/src/observability/event-query.ts +690 -0
  196. package/src/observability/log-ring.ts +26 -2
  197. package/src/observability/log-sanitize.ts +118 -0
  198. package/src/observability/logger.ts +17 -13
  199. package/src/observability/metrics.ts +35 -0
  200. package/src/observability/payload-capture.ts +114 -41
  201. package/src/observability/payload-store.ts +249 -24
  202. package/src/observability/raw-event-writer.ts +118 -0
  203. package/src/observability/runtime-metrics.ts +71 -1
  204. package/src/observability/telemetry-buffer.ts +100 -17
  205. package/src/observability/telemetry-reconciliation.ts +417 -0
  206. package/src/persistence/boot.ts +20 -9
  207. package/src/persistence/db-pg.ts +13 -0
  208. package/src/persistence/db-sqlite.ts +569 -497
  209. package/src/persistence/dialect.ts +3 -2
  210. package/src/persistence/migrate.ts +181 -181
  211. package/src/persistence/schema.ts +1496 -1406
  212. package/src/persistence/shared-cache.ts +5 -1
  213. package/src/persistence/stores/api-key-store.ts +581 -581
  214. package/src/persistence/stores/telemetry-store.ts +124 -63
  215. package/src/protocol/reasoning-text.ts +30 -0
  216. package/src/protocol/registry.ts +2 -0
  217. package/src/protocol/response/chat.ts +33 -7
  218. package/src/protocol/response/codex.ts +14 -2
  219. package/src/protocol/response/messages.ts +5 -2
  220. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  221. package/src/providers/bundled-discovery-providers.ts +122 -122
  222. package/src/providers/default-registry.ts +14 -0
  223. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  224. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  225. package/src/providers/discovery/probe-phases.ts +43 -7
  226. package/src/providers/discovery/probing-service.ts +70 -32
  227. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  228. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  229. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  230. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  231. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  232. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  233. package/src/providers/integrations/buddy/capabilities.ts +4 -0
  234. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  235. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  236. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  237. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  238. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  239. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  240. package/src/providers/integrations/claude/claude.ts +3 -4
  241. package/src/providers/integrations/cline/cline.ts +94 -8
  242. package/src/providers/integrations/codex/codex.ts +61 -37
  243. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  244. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  245. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  246. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  247. package/src/providers/integrations/cursor/cursor.ts +29 -17
  248. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  249. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  250. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  251. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  252. package/src/providers/integrations/github/github.ts +3 -1
  253. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  254. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  255. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  256. package/src/providers/integrations/hermes.ts +7 -1
  257. package/src/providers/integrations/inferhub.ts +201 -73
  258. package/src/providers/integrations/kilo/kilo.ts +152 -152
  259. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  260. package/src/providers/integrations/opencode/provider.ts +2 -0
  261. package/src/providers/integrations/openrouter/provider.ts +129 -129
  262. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  263. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  264. package/src/providers/integrations/trae/trae-headers.ts +17 -48
  265. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  266. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  267. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  268. package/src/providers/integrations/trae/trae.ts +22 -24
  269. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  270. package/src/providers/integrations/zcode/oauth.ts +1 -1
  271. package/src/providers/operations/account-health-service.ts +605 -279
  272. package/src/providers/provider-metadata.ts +12 -1
  273. package/src/providers/provider-model-definition.ts +373 -373
  274. package/src/providers/provider-probe.ts +3 -0
  275. package/src/providers/quota/quota-contracts.ts +3 -7
  276. package/src/providers/search/search-provider.ts +2 -6
  277. package/src/security/admission/contracts.ts +10 -0
  278. package/src/security/admission/in-memory-store.ts +89 -14
  279. package/src/security/admission/lua.ts +191 -182
  280. package/src/security/admission/redis-store.ts +30 -0
  281. package/src/security/admission/service.ts +18 -0
  282. package/src/security/api-key-auth-cache.ts +96 -0
  283. package/src/security/api-key-auth.ts +11 -100
  284. package/src/security/client-router-fingerprint.ts +3 -7
  285. package/src/security/ip-boundary.ts +7 -3
  286. package/src/shared/coerce.ts +67 -0
  287. package/src/shared/hash.ts +6 -0
  288. package/src/shared/time.ts +7 -0
  289. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  290. package/src/transport/dispatch/attempt-loop.ts +103 -81
  291. package/src/transport/dispatch/capture-policy.ts +78 -0
  292. package/src/transport/dispatch/proxy-request.ts +4 -2
  293. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  294. package/src/transport/dispatch/streaming/stream.ts +19 -2
  295. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  296. package/src/transport/failure-policy.ts +27 -0
  297. package/src/transport/guards/abuse-guard.ts +2 -2
  298. package/src/transport/guards/auth-guard.ts +17 -0
  299. package/src/transport/guards/body-policy.ts +3 -2
  300. package/src/transport/guards/concurrency-guard.ts +89 -13
  301. package/src/transport/guards/early-rejection-capture.ts +43 -3
  302. package/src/transport/guards/error-lifecycle.ts +10 -2
  303. package/src/transport/guards/guard-errors.ts +23 -0
  304. package/src/transport/guards/headroom-guard.ts +41 -7
  305. package/src/transport/guards/pipeline.ts +24 -0
  306. package/src/transport/guards/request-context.ts +2 -1
  307. package/src/transport/routing/admission.ts +51 -9
  308. package/src/transport/routing/bounded-lru.ts +49 -0
  309. package/src/transport/routing/candidate-index.ts +88 -0
  310. package/src/transport/routing/reservations.ts +121 -7
  311. package/src/transport/routing/round-robin.ts +10 -22
  312. package/src/transport/routing/route-catalog.ts +26 -13
  313. package/src/transport/routing/route-model.ts +22 -2
  314. package/src/transport/routing/router.ts +53 -31
  315. package/src/transport/surface/chat/encode.ts +2 -3
  316. package/src/transport/surface/chat/parse.ts +2 -1
  317. package/src/transport/surface/dialects.ts +11 -3
  318. package/src/transport/surface/messages/encode.ts +2 -1
  319. package/src/transport/surface/messages/parse.ts +3 -1
  320. package/src/transport/surface/messages/stream.ts +2 -6
  321. package/src/workers/maintenance-lease.ts +200 -0
  322. package/src/workers/scheduler.ts +101 -1
  323. package/dist/dashboard/assets/BannedPage-Brvuj_Eb.js.gz +0 -0
  324. package/dist/dashboard/assets/CliToolDetailPage-CqfXtI3y.js.gz +0 -0
  325. package/dist/dashboard/assets/CliToolsPage-GQeS91bg.js.gz +0 -0
  326. package/dist/dashboard/assets/CombosPage-BDRsTXBB.js.gz +0 -0
  327. package/dist/dashboard/assets/ConsoleLogPage-zP8AFHX0.js.gz +0 -0
  328. package/dist/dashboard/assets/CustomizationPage-BmfrNZ14.js.gz +0 -0
  329. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js +0 -1
  330. package/dist/dashboard/assets/HealthEventsModal-Dn3M6WZc.js.gz +0 -0
  331. package/dist/dashboard/assets/LoginPage-DoreIgm8.js.gz +0 -0
  332. package/dist/dashboard/assets/ModelPicker-C7pDb41R.js.gz +0 -0
  333. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js +0 -1
  334. package/dist/dashboard/assets/OverviewPage-CvvD1Ugk.js.gz +0 -0
  335. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js +0 -3
  336. package/dist/dashboard/assets/ProviderDetailPage-Clbn1gsW.js.gz +0 -0
  337. package/dist/dashboard/assets/ProvidersPage-BnoPgA7w.js.gz +0 -0
  338. package/dist/dashboard/assets/ProxyPage-Bq_TGgdb.js.gz +0 -0
  339. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js +0 -1
  340. package/dist/dashboard/assets/QuotaPage-D9DHXvom.js.gz +0 -0
  341. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js +0 -1
  342. package/dist/dashboard/assets/SettingsPage-7xV8WkWL.js.gz +0 -0
  343. package/dist/dashboard/assets/SetupPage-Day3mCOn.js.gz +0 -0
  344. package/dist/dashboard/assets/SortableList-DQxllnB5.js.gz +0 -0
  345. package/dist/dashboard/assets/UsagePage-DXa1oR-4.js.gz +0 -0
  346. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  347. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  348. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  349. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  350. package/dist/dashboard/assets/badge-CR72dasT.js.gz +0 -0
  351. package/dist/dashboard/assets/banner-CI9_eSfO.js.gz +0 -0
  352. package/dist/dashboard/assets/card-DS7CQEsO.js.gz +0 -0
  353. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  354. package/dist/dashboard/assets/clipboard-button-611pSsaX.js.gz +0 -0
  355. package/dist/dashboard/assets/dialog-k37exjNt.js.gz +0 -0
  356. package/dist/dashboard/assets/drawer-CX7N8ucR.js.gz +0 -0
  357. package/dist/dashboard/assets/entry-B9Xvtnfj.js +0 -1
  358. package/dist/dashboard/assets/entry-B9Xvtnfj.js.gz +0 -0
  359. package/dist/dashboard/assets/entry-ByMGkR3C.js +0 -4
  360. package/dist/dashboard/assets/entry-ByMGkR3C.js.gz +0 -0
  361. package/dist/dashboard/assets/entry-CTP8AyiS.js.gz +0 -0
  362. package/dist/dashboard/assets/github-badge-sltk1qUi.js.gz +0 -0
  363. package/dist/dashboard/assets/index-z3zDOSMM.js.gz +0 -0
  364. package/dist/dashboard/assets/inline-BhY2sSnO.js.gz +0 -0
  365. package/dist/dashboard/assets/input-V1umFiBk.js.gz +0 -0
  366. package/dist/dashboard/assets/layout-BBOYycyS.js.gz +0 -0
  367. package/dist/dashboard/assets/list-row-1zzZYM0B.js.gz +0 -0
  368. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  369. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  370. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  371. package/dist/dashboard/assets/numbers-DkfMR5Qs.js.gz +0 -0
  372. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  373. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  374. package/dist/dashboard/assets/select-C6MMiaio.js.gz +0 -0
  375. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  376. package/dist/dashboard/assets/stack-BHTfXdz_.js.gz +0 -0
  377. package/dist/dashboard/assets/state-mPDRlQkl.js.gz +0 -0
  378. package/dist/dashboard/assets/switch-DOmJxg7H.js.gz +0 -0
  379. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  380. package/scripts/build/aot.ts +0 -143
  381. package/scripts/build/build.ts +0 -24
  382. package/scripts/build/compile.ts +0 -88
  383. package/scripts/build/restart.ts +0 -120
  384. package/scripts/build/start.ts +0 -25
  385. package/src/observability/event-compaction.ts +0 -317
  386. package/src/observability/event-day-index.ts +0 -440
@@ -30,6 +30,7 @@ import { DEFAULT_PROXY_SKIP_PROVIDER_IDS } from "../../providers/provider-metada
30
30
  import { buildClaudeCliUserAgent } from "../../providers/operations/custom-cli-headers";
31
31
  import { isBundledProviderId } from "../../providers/provider-contracts";
32
32
  import type { ServiceKind, WireFamily } from "../../protocol/canonical-model";
33
+ import { BoundedLruMap } from "./bounded-lru";
33
34
  import { providerHasAdapterUserAgent, providerUsesBespokeWire } from "../../providers/provider-metadata";
34
35
 
35
36
  const CLAUDE_MODEL_FAMILIES = new Set(["opus", "sonnet", "haiku", "fable", "mythos"]);
@@ -156,17 +157,28 @@ export function buildCapabilityProfile(row: {
156
157
  }
157
158
 
158
159
  /**
159
- * The capability profile for one model row, memoized across rebuilds.
160
- *
161
- * Bounded LRU keyed by `providerId/modelId`: the value is a pure function of
162
- * the row's metadata, so two rebuilds that see the same row get the same object
163
- * instead of two identical ones. The bound keeps a long-lived process from
164
- * pinning one profile per row it has ever seen — a model deleted from the
165
- * catalog must not leave its profile behind forever.
160
+ * Upper bound on the capability-profile memo. Same value as the hand-rolled
161
+ * bound it replaces: far above any real catalog, so eviction only ever targets
162
+ * rows deleted from the catalog.
166
163
  */
167
164
  const CAPABILITY_PROFILE_CACHE_MAX_ENTRIES = 4096;
168
- const capabilityProfileCache = new Map<string, Record<string, boolean>>();
165
+ const capabilityProfileCache = new BoundedLruMap<string, Record<string, boolean>>(
166
+ CAPABILITY_PROFILE_CACHE_MAX_ENTRIES,
167
+ );
169
168
 
169
+ /**
170
+ * The capability profile for one model row, memoized across rebuilds.
171
+ *
172
+ * The value is a pure function of the row's metadata, so two rebuilds that see
173
+ * the same row get the same object instead of two identical ones. The cache is
174
+ * a {@link BoundedLruMap} keyed by `providerId/modelId` **plus the row's
175
+ * modalities**: the key must change when the operator edits a row's modalities
176
+ * (text-only → vision), otherwise the rebuild would hand routing the stale
177
+ * pre-edit profile until the entry aged out of the LRU. A row that has never
178
+ * carried modality metadata keys with `null`, which only collides with another
179
+ * metadata-less row for the same provider/model — the same profile by
180
+ * construction.
181
+ */
170
182
  function capabilityProfileFor(row: {
171
183
  modalities: unknown;
172
184
  reasoning: boolean;
@@ -174,14 +186,10 @@ function capabilityProfileFor(row: {
174
186
  providerId: string;
175
187
  modelId: string;
176
188
  }): Record<string, boolean> {
177
- const key = `${row.providerId}/${row.modelId}`;
189
+ const key = `${row.providerId}/${row.modelId}/${JSON.stringify(row.modalities ?? null)}`;
178
190
  const cached = capabilityProfileCache.get(key);
179
191
  if (cached !== undefined) return cached;
180
192
  const profile = buildCapabilityProfile(row);
181
- if (capabilityProfileCache.size >= CAPABILITY_PROFILE_CACHE_MAX_ENTRIES) {
182
- const oldest = capabilityProfileCache.keys().next().value;
183
- if (oldest !== undefined) capabilityProfileCache.delete(oldest);
184
- }
185
193
  capabilityProfileCache.set(key, profile);
186
194
  return profile;
187
195
  }
@@ -421,10 +429,14 @@ class RouteCatalogRepository {
421
429
  * preference's own doc.
422
430
  */
423
431
  const cooldownOverrideTenants = new Set<string>();
432
+ const invokeDisabledTenants = new Set<string>();
424
433
  for (const row of settingsRows ?? []) {
425
434
  if (row.preferences?.forceDisableCooldown === true && row.tenantId) {
426
435
  cooldownOverrideTenants.add(row.tenantId);
427
436
  }
437
+ if (row.preferences?.invokeDisabled === true && row.tenantId) {
438
+ invokeDisabledTenants.add(row.tenantId);
439
+ }
428
440
  }
429
441
  /**
430
442
  * A shared account has no tenant of its own (`tenant_id IS NULL`), so a
@@ -843,6 +855,7 @@ class RouteCatalogRepository {
843
855
  combos,
844
856
  ...(routingMap ? { providerRouting: routingMap } : {}),
845
857
  ...(Object.keys(poolRouting).length > 0 ? { poolRouting } : {}),
858
+ ...(invokeDisabledTenants.size > 0 ? { invokeDisabledTenants } : {}),
846
859
  };
847
860
  }
848
861
  }
@@ -199,6 +199,15 @@ export interface RouteSnapshot {
199
199
  readonly providerRouting?: ProviderRoutingMap;
200
200
  /** Optional per-tenant pool selection strategy (absent = `least_loaded`). */
201
201
  readonly poolRouting?: Readonly<Record<string, PoolRoutingSetting>>;
202
+ /**
203
+ * Tenants whose invoke switch is off, so `/v1/*` must be refused outright.
204
+ *
205
+ * Read from the cached snapshot rather than per request: it is a console
206
+ * preference write, and the snapshot is already invalidated when one lands, so
207
+ * the switch takes effect on the next request without adding a query to the
208
+ * proxy path.
209
+ */
210
+ readonly invokeDisabledTenants?: ReadonlySet<string>;
202
211
  }
203
212
 
204
213
  export type EligibilityReason =
@@ -247,8 +256,19 @@ export interface AdmissionDecision {
247
256
  export interface AdmissionController {
248
257
  admit(candidate: RouteCandidate): Promise<AdmissionDecision>;
249
258
  release(reservation: Reservation): Promise<void>;
250
- /** Observable current counters by admission bucket (`provider:model[:account]`). */
251
- snapshotAccountInflight?(): Promise<ReadonlyMap<string, number>> | ReadonlyMap<string, number>;
259
+ /**
260
+ * Current inflight counts for exactly the buckets the given candidates draw
261
+ * from, keyed by the unprefixed admission bucket (`provider:model[:account]`).
262
+ *
263
+ * Required, not optional: the previous fleet-wide
264
+ * `snapshotAccountInflight()` was the only reason `plan()` could issue a
265
+ * Redis `KEYS` on the request path. Scoped reads make the ordering cost
266
+ * proportional to the candidates being ordered, and an empty input must be
267
+ * answerable with zero backend commands.
268
+ */
269
+ readAccountInflight(
270
+ candidates: readonly RouteCandidate[],
271
+ ): Promise<ReadonlyMap<string, number>> | ReadonlyMap<string, number>;
252
272
  }
253
273
 
254
274
  /**
@@ -25,24 +25,17 @@ import {
25
25
  import { planAllowsCandidate } from "./plan-gate";
26
26
  import { InMemoryAdmissionController, admissionKey } from "./admission";
27
27
  import { ReservationManager } from "./reservations";
28
- import { getOrCreateRoundRobin, RoundRobinState } from "./round-robin";
28
+ import { createRoundRobinMap, RoundRobinState } from "./round-robin";
29
+ import {
30
+ lookupRouteCandidates,
31
+ parseModelReference,
32
+ routeCandidateIndex,
33
+ } from "./candidate-index";
29
34
  import { totalRemainingCredit, totalRemainingPercent, type ProviderQuotaWindow } from "../../providers/quota/quota-contracts";
30
35
  import { getCachedQuotaEntry, quotaLens } from "../../console/quota/cache";
31
36
  import type { RedisClient } from "../../persistence/redis";
32
37
  import { sharedClaudeSlowLanes } from "../../providers/integrations/claude/claude-slow-lane";
33
38
 
34
- function parseModel(model: string): { providerId: string | null; bare: string } {
35
- const slash = model.indexOf("/");
36
- if (slash === -1) return { providerId: null, bare: model };
37
- return { providerId: model.slice(0, slash), bare: model.slice(slash + 1) };
38
- }
39
-
40
- function candidateMatches(candidate: RouteCandidate, model: string): boolean {
41
- const parsed = parseModel(model);
42
- if (parsed.providerId === null) return candidate.model_id === parsed.bare;
43
- return candidate.model_id === parsed.bare && candidate.provider_id === parsed.providerId;
44
- }
45
-
46
39
  /**
47
40
  * True when the account's remaining allowance is at or below its provider's
48
41
  * floor. Credit mode sums the absolute remaining credit; percent mode uses the
@@ -352,8 +345,8 @@ export class EligibilityEvaluator {
352
345
  }
353
346
 
354
347
  export class RoutingEngine {
355
- private readonly _roundRobinByCombo = new Map<string, RoundRobinState>();
356
- private readonly _roundRobinByProvider = new Map<string, RoundRobinState>();
348
+ private readonly _roundRobinByCombo = createRoundRobinMap();
349
+ private readonly _roundRobinByProvider = createRoundRobinMap();
357
350
  private readonly eligibility = new EligibilityEvaluator();
358
351
  private readonly reservations = new ReservationManager();
359
352
 
@@ -363,11 +356,19 @@ export class RoutingEngine {
363
356
  ) {}
364
357
 
365
358
  private getRoundRobin(key: string): RoundRobinState {
366
- return getOrCreateRoundRobin(this._roundRobinByCombo, key);
359
+ const existing = this._roundRobinByCombo.get(key);
360
+ if (existing) return existing;
361
+ const created = new RoundRobinState();
362
+ this._roundRobinByCombo.set(key, created);
363
+ return created;
367
364
  }
368
365
 
369
366
  private getProviderRoundRobin(key: string): RoundRobinState {
370
- return getOrCreateRoundRobin(this._roundRobinByProvider, key);
367
+ const existing = this._roundRobinByProvider.get(key);
368
+ if (existing) return existing;
369
+ const created = new RoundRobinState();
370
+ this._roundRobinByProvider.set(key, created);
371
+ return created;
371
372
  }
372
373
 
373
374
  /** Observable bounded-collection sizes for the runtime metrics sampler. */
@@ -375,11 +376,20 @@ export class RoutingEngine {
375
376
  return { combo: this._roundRobinByCombo.size, provider: this._roundRobinByProvider.size };
376
377
  }
377
378
 
378
- /** Live admission counters by `provider:model[:account]` bucket. */
379
- async accountInflightSnapshot(): Promise<ReadonlyMap<string, number>> {
380
- // Both controllers hand back a fresh map, so no defensive copy here —
381
- // this feeds an admin read, not a mutation site.
382
- return (await this.admission.snapshotAccountInflight?.()) ?? new Map();
379
+ /**
380
+ * Live admission counters for the buckets the given candidates draw from.
381
+ *
382
+ * Takes candidates rather than returning a fleet-wide snapshot: the scoped
383
+ * read is what keeps `plan()` off Redis `KEYS` (the fleet-wide read had no
384
+ * other implementation) and bounds the console's per-provider read to the
385
+ * accounts it will actually report on. Both controllers hand back a fresh
386
+ * map, so no defensive copy here — this feeds an admin read, not a mutation
387
+ * site.
388
+ */
389
+ async accountInflightSnapshot(
390
+ candidates: readonly RouteCandidate[],
391
+ ): Promise<ReadonlyMap<string, number>> {
392
+ return this.admission.readAccountInflight(candidates);
383
393
  }
384
394
 
385
395
  /**
@@ -402,7 +412,12 @@ export class RoutingEngine {
402
412
  (candidate) => typeof candidate.max_inflight === "number" && candidate.max_inflight > 0,
403
413
  );
404
414
  if (bounded.length === 0) return new Set();
405
- const snapshot = await this.accountInflightSnapshot().catch(() => new Map<string, number>());
415
+ // Only the bounded candidates can be saturated, so only their buckets are
416
+ // read. A failed ordering read must never fail routing — the admission
417
+ // attempt itself is the real gate; this read only optimizes attempt order.
418
+ const snapshot = await this.accountInflightSnapshot(bounded).catch(
419
+ () => new Map<string, number>(),
420
+ );
406
421
  if (snapshot.size === 0) return new Set();
407
422
  const saturated = new Set<RouteCandidate>();
408
423
  for (const candidate of bounded) {
@@ -792,16 +807,23 @@ export class RoutingEngine {
792
807
  ? combo.members.flatMap((name) => expandMember(name, new Set([resolved.model])))
793
808
  : [resolved.model];
794
809
  const modelIds = [...new Set(rawModelIds)];
795
- let matching = snapshot.candidates.filter(
796
- (candidate) =>
797
- modelIds.some((id) => candidateMatches(candidate, id)) &&
798
- (candidate.tenant_id == null || candidate.tenant_id === tid),
799
- );
810
+ // One index build per snapshot (cached on the immutable snapshot), one
811
+ // lookup per unique resolved model. The previous shape rescanned every
812
+ // candidate per model — O(C×K) string parses per plan — which was the
813
+ // quadratic cost the catalog rebuild had already paid once and the request
814
+ // path was paying again on every plan.
815
+ const index = routeCandidateIndex(snapshot);
816
+ const parsedModels = modelIds.map((id) => ({ id, parsed: parseModelReference(id) }));
817
+ let matching = parsedModels.flatMap((entry) => lookupRouteCandidates(index, entry.parsed, tid));
818
+ // Preserve the resolver's flatten-then-dedupe contract: one candidate can
819
+ // serve two member groups only via a combo listing the same model twice,
820
+ // and the plan must not dial it twice.
821
+ matching = [...new Set(matching)];
800
822
  let unmatchedMembers: readonly string[] = [];
801
823
  if (combo) {
802
- const perId = modelIds.map((id) => ({
803
- id,
804
- group: matching.filter((c) => candidateMatches(c, id)),
824
+ const perId = parsedModels.map((entry) => ({
825
+ id: entry.id,
826
+ group: lookupRouteCandidates(index, entry.parsed, tid),
805
827
  }));
806
828
  unmatchedMembers = perId.filter((entry) => entry.group.length === 0).map((entry) => entry.id);
807
829
  const groups = perId.map((entry) => entry.group).filter((g) => g.length > 0);
@@ -1,6 +1,7 @@
1
1
  import type { CanonicalEvent, CanonicalStopReason } from "../../../protocol/canonical-model";
2
2
  import { jsonrepair } from "jsonrepair";
3
3
  import { reasoningSummaryIdentity, reasoningSummarySeparator, type ReasoningSummaryIdentity } from "../reasoning-summary";
4
+ import { reasoningTextOf } from "../../../protocol/reasoning-text";
4
5
  import { eventText } from "../adapters";
5
6
  import { isRecord } from "../../../protocol/codecs";
6
7
  import {
@@ -113,9 +114,7 @@ export function eventAudio(event: CanonicalEvent): Record<string, unknown> | und
113
114
 
114
115
  export function eventReasoningText(event: CanonicalEvent): string | undefined {
115
116
  if (event.type !== "content_delta" || event.content.kind !== "reasoning") return undefined;
116
- if (event.content.opaque === true) return undefined;
117
- if (typeof event.content.payload === "string") return event.content.payload;
118
- return typeof event.content.summary === "string" ? event.content.summary : undefined;
117
+ return reasoningTextOf(event.content);
119
118
  }
120
119
 
121
120
  function mergedReasoningText(events: readonly CanonicalEvent[]): string {
@@ -1,6 +1,7 @@
1
1
  import type { CanonicalMessage, ContentPart, GenerationControls } from "../../../protocol/canonical-model";
2
2
  import type { SurfaceInput } from "../adapters";
3
3
  import { isRecord } from "../../../protocol/codecs";
4
+ import { asNumber } from "../../../shared/coerce";
4
5
  import { textPart, normalizeInlineFile } from "../content-parts";
5
6
  import { SERVICE_TIERS } from "../dialects";
6
7
  import { readNumber, readString } from "../../../protocol/codecs";
@@ -75,7 +76,7 @@ export function requestBody(input: unknown): unknown {
75
76
  }
76
77
 
77
78
  export function nonNegativeNumber(value: unknown): number | undefined {
78
- const number = typeof value === "number" && Number.isFinite(value) ? value : undefined;
79
+ const number = asNumber(value);
79
80
  return number !== undefined && number >= 0 ? number : undefined;
80
81
  }
81
82
 
@@ -71,11 +71,14 @@ function parseToolObject(item: Record<string, unknown>, dialect: ToolDialect): T
71
71
  } else {
72
72
  const functionValue = item["function"];
73
73
  schema = isRecord(functionValue)
74
- ? functionValue["parameters"] ?? functionValue["jsonSchema"] ?? {}
74
+ ? functionValue["parameters"] ??
75
+ functionValue["jsonSchema"] ??
76
+ functionValue["parametersJsonSchema"] ??
77
+ {}
75
78
  : // The Responses flat spelling (`{type:"function", parameters:…}`) is
76
79
  // common here too; without this a tool arrived with an empty schema
77
80
  // and the upstream rejected it.
78
- item["parameters"] ?? item["input_schema"] ?? {};
81
+ item["parameters"] ?? item["input_schema"] ?? item["parametersJsonSchema"] ?? {};
79
82
  }
80
83
  } else if (dialect === "responses") {
81
84
  // A Responses caller may still send the Chat-shaped envelope — the two
@@ -88,8 +91,13 @@ function parseToolObject(item: Record<string, unknown>, dialect: ToolDialect): T
88
91
  schema =
89
92
  item["input_schema"] ??
90
93
  item["parameters"] ??
94
+ item["parametersJsonSchema"] ??
91
95
  (wrapped !== undefined
92
- ? wrapped["parameters"] ?? wrapped["input_schema"] ?? wrapped["jsonSchema"] ?? {}
96
+ ? wrapped["parameters"] ??
97
+ wrapped["input_schema"] ??
98
+ wrapped["jsonSchema"] ??
99
+ wrapped["parametersJsonSchema"] ??
100
+ {}
93
101
  : item);
94
102
  } else {
95
103
  schema = item["input_schema"] ?? item["inputSchema"] ?? {};
@@ -1,6 +1,7 @@
1
1
  import type { CanonicalEvent, CanonicalStopReason, ContentPart, UsageRecord } from "../../../protocol/canonical-model";
2
2
  import { assertBoundedJsonDepth, isRecord } from "../../../protocol/codecs";
3
3
  import { messagesStopReason } from "../../../protocol/stop-reason";
4
+ import { reasoningTextOf } from "../../../protocol/reasoning-text";
4
5
  import {
5
6
  reasoningSummaryHasBoundary,
6
7
  reasoningSummaryIdentity,
@@ -111,7 +112,7 @@ export function blockFromPart(part: ContentPart): MessagesWireObject | undefined
111
112
  : { type: "redacted_thinking", data: part.payload };
112
113
  return {
113
114
  type: "thinking",
114
- thinking: part.summary ?? (typeof part.payload === "string" ? part.payload : ""),
115
+ thinking: reasoningTextOf(part) ?? "",
115
116
  ...(part.signature === undefined ? {} : { signature: part.signature }),
116
117
  };
117
118
  }
@@ -145,8 +145,10 @@ export function parseBlock(value: unknown): ContentPart {
145
145
  const thinking = stringValue(value.thinking) ? value.thinking : "";
146
146
  return {
147
147
  kind: "reasoning",
148
+ // Readable thinking lives in `payload` only. Duplicating it into
149
+ // `summary` too emitted the same text twice on surfaces that carry both
150
+ // channels (Responses `reasoning_text` + `reasoning_summary_text`).
148
151
  payload: thinking,
149
- summary: thinking,
150
152
  ...(stringValue(value.signature) ? { signature: value.signature } : {}),
151
153
  };
152
154
  }
@@ -5,6 +5,7 @@ import {
5
5
  reasoningSummarySeparator,
6
6
  type ReasoningSummaryIdentity,
7
7
  } from "../reasoning-summary";
8
+ import { reasoningTextOf } from "../../../protocol/reasoning-text";
8
9
  import {
9
10
  argumentsFrom,
10
11
  createToolCallTracker,
@@ -230,12 +231,7 @@ export class MessagesStreamEncoder extends SurfaceStreamEncoder<CanonicalEvent,
230
231
  }
231
232
  if (part.kind === "reasoning") {
232
233
  const isRedacted = part.opaque === true;
233
- const summary =
234
- typeof part.summary === "string"
235
- ? part.summary
236
- : typeof part.payload === "string"
237
- ? part.payload
238
- : "";
234
+ const summary = reasoningTextOf(part) ?? "";
239
235
  const signature = typeof part.signature === "string" ? part.signature : "";
240
236
  // Nothing to render: an empty thinking delta must not open a block
241
237
  // (same fragmentation as the empty-text case above).
@@ -0,0 +1,200 @@
1
+ /**
2
+ * A Redis lease so one maintenance task runs once across the fleet.
3
+ *
4
+ * Maintenance work is side-effecting and shared: telemetry retention deletes
5
+ * rows for every tenant, the payload sweeper deletes files, the session sweep
6
+ * deletes sessions. Two processes running "telemetry-retention" at once do not
7
+ * produce two correct halves — they produce two passes over the same batches,
8
+ * double the row churn, and a WAL spike on a box that is already short of
9
+ * memory. On a 1-core VPS the second pass is not parallel speedup; it is
10
+ * contention with the request path.
11
+ *
12
+ * The lease is a Redis key with a token. The token matters: release must be
13
+ * conditional (`compare-and-delete`), or a process that overran its lease and
14
+ * was replaced would delete its SUCCESSOR's lease and let a third process in.
15
+ * An unconditional `DEL` is the classic version of that bug.
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+
19
+ import type { RedisClient } from "../persistence/redis";
20
+
21
+ /** Key prefix; the task name is the discriminator. */
22
+ const LEASE_KEY_PREFIX = "cartethyia:maintenance:lease:v1:";
23
+
24
+ /**
25
+ * Bounds on the lease TTL.
26
+ *
27
+ * The floor prevents a fast task from holding a lease so briefly that a peer
28
+ * with a slightly skewed clock steals it mid-run. The ceiling keeps a crashed
29
+ * holder from blocking the task for longer than a maintenance interval is
30
+ * likely to be.
31
+ */
32
+ const MIN_LEASE_TTL_MS = 5_000;
33
+ const MAX_LEASE_TTL_MS = 60_000;
34
+
35
+ /**
36
+ * Acquires the lease: `SET key token PX ttl NX`.
37
+ *
38
+ * One atomic statement rather than GET-then-SET: two processes can both read
39
+ * "absent" before either writes, and both would then believe they held it.
40
+ */
41
+ const ACQUIRE_SCRIPT = `
42
+ local ok = redis.call('set', KEYS[1], ARGV[1], 'PX', tonumber(ARGV[2]), 'NX')
43
+ if ok then return 1 end
44
+ return 0
45
+ `;
46
+
47
+ /**
48
+ * Renews only if the token still matches: `GET` + compare + `PEXPIRE`.
49
+ *
50
+ * Extending a lease this process no longer holds — because it overran its TTL
51
+ * and a peer took over — would make two holders of one lease.
52
+ */
53
+ const RENEW_SCRIPT = `
54
+ if redis.call('get', KEYS[1]) == ARGV[1] then
55
+ redis.call('pexpire', KEYS[1], tonumber(ARGV[2]))
56
+ return 1
57
+ end
58
+ return 0
59
+ `;
60
+
61
+ /**
62
+ * Releases only if the token still matches.
63
+ *
64
+ * An unconditional DEL would delete a successor's lease, letting a third
65
+ * process acquire while the second is still running.
66
+ */
67
+ const RELEASE_SCRIPT = `
68
+ if redis.call('get', KEYS[1]) == ARGV[1] then
69
+ redis.call('del', KEYS[1])
70
+ return 1
71
+ end
72
+ return 0
73
+ `;
74
+
75
+ export interface MaintenanceLease {
76
+ readonly taskName: string;
77
+ /** True when this process holds the lease. */
78
+ readonly held: boolean;
79
+ /** Extends the lease. Returns false when it has already been lost. */
80
+ renew(): Promise<boolean>;
81
+ /** Releases the lease. Safe (and a no-op) when it was never held or already lost. */
82
+ release(): Promise<void>;
83
+ }
84
+
85
+ export interface MaintenanceLeaseStore {
86
+ /**
87
+ * One acquisition attempt.
88
+ *
89
+ * Deliberately not a retry loop: a tick that cannot acquire should SKIP, not
90
+ * wait. Waiting would hold the tick open, and the next tick would arrive and
91
+ * queue behind it — turning a missed interval into a catch-up storm.
92
+ */
93
+ tryAcquire(taskName: string, ttlMs: number): Promise<MaintenanceLease>;
94
+ }
95
+
96
+ /** The TTL for a task, clamped into the supported range. */
97
+ export function leaseTtlMs(intervalMs: number): number {
98
+ return Math.min(MAX_LEASE_TTL_MS, Math.max(MIN_LEASE_TTL_MS, Math.trunc(intervalMs)));
99
+ }
100
+
101
+ /** A store that hands out no lease — used when Redis is absent. */
102
+ export function createNoopMaintenanceLeaseStore(): MaintenanceLeaseStore {
103
+ return {
104
+ async tryAcquire(taskName) {
105
+ return {
106
+ taskName,
107
+ // Held = true: with no shared store this process cannot coordinate, so
108
+ // the local registry's own re-entrancy rule is the only guard. The
109
+ // alternative — reporting not-held — would silently disable every
110
+ // maintenance task on single-process deployments, which is most of
111
+ // them.
112
+ held: true,
113
+ async renew() {
114
+ return true;
115
+ },
116
+ async release() {
117
+ /* nothing to give back */
118
+ },
119
+ };
120
+ },
121
+ };
122
+ }
123
+
124
+ /**
125
+ * A Redis-backed lease store.
126
+ *
127
+ * Every failure is non-throwing at the caller's level: a Redis that is down
128
+ * must not stop a task that only that process can do, and must not crash a
129
+ * maintenance tick. Acquisition failure is reported as "not held" so the
130
+ * registry skips and counts.
131
+ */
132
+ export function createRedisMaintenanceLeaseStore(redis: RedisClient): MaintenanceLeaseStore {
133
+ const evalScript = async (
134
+ script: string,
135
+ key: string,
136
+ args: readonly (string | number)[],
137
+ ): Promise<number> => {
138
+ const raw = await (
139
+ redis as unknown as {
140
+ eval: (
141
+ script: string,
142
+ numKeys: number,
143
+ ...args: (string | number)[]
144
+ ) => Promise<unknown>;
145
+ }
146
+ ).eval(script, 1, key, ...args);
147
+ return Number(raw);
148
+ };
149
+
150
+ return {
151
+ async tryAcquire(taskName, ttlMs) {
152
+ const key = `${LEASE_KEY_PREFIX}${taskName}`;
153
+ const token = randomUUID();
154
+ const ttl = leaseTtlMs(ttlMs);
155
+ let held = false;
156
+ try {
157
+ held = (await evalScript(ACQUIRE_SCRIPT, key, [token, ttl])) === 1;
158
+ } catch {
159
+ // Redis unreachable: skip. Reported, not swallowed silently — the
160
+ // registry counts the skip so a persistent outage is visible.
161
+ held = false;
162
+ }
163
+ if (!held) {
164
+ return {
165
+ taskName,
166
+ held: false,
167
+ async renew() {
168
+ return false;
169
+ },
170
+ async release() {
171
+ /* nothing held */
172
+ },
173
+ };
174
+ }
175
+ let released = false;
176
+ return {
177
+ taskName,
178
+ held: true,
179
+ async renew() {
180
+ if (released) return false;
181
+ try {
182
+ return (await evalScript(RENEW_SCRIPT, key, [token, ttl])) === 1;
183
+ } catch {
184
+ return false;
185
+ }
186
+ },
187
+ async release() {
188
+ if (released) return;
189
+ released = true;
190
+ try {
191
+ await evalScript(RELEASE_SCRIPT, key, [token]);
192
+ } catch {
193
+ // Best-effort: the TTL releases it anyway, and a failed release
194
+ // must not turn a successful task run into an error.
195
+ }
196
+ },
197
+ };
198
+ },
199
+ };
200
+ }