cartethyia 2.5.10 → 2.6.1

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 (369) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-wr370U7a.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-wr370U7a.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-D1sCezWa.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-D1sCezWa.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-DVWbJPEQ.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-DVWbJPEQ.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-CZc7fyMe.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-CZc7fyMe.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-OPN3qRCa.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-OPN3qRCa.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-DGTSqmwm.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-DGTSqmwm.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-C_Jd_KgP.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C_Jd_KgP.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-DbQnexQd.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-DbQnexQd.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-ByprzADo.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-ByprzADo.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-Bg9DcG-G.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-Bg9DcG-G.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C2PWPzJm.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C2PWPzJm.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-Dky1zZMt.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-Dky1zZMt.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-C5Vdpd_c.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-C5Vdpd_c.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BNFjUjQ0.js +4 -0
  63. package/dist/dashboard/assets/entry-BNFjUjQ0.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  65. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-DyllNVCY.js} +2 -2
  71. package/dist/dashboard/assets/index-DyllNVCY.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-DAhoTabv.js} +1 -1
  93. package/dist/dashboard/assets/select-DAhoTabv.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +123 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +177 -167
  122. package/scripts/cli/console-menu.ts +45 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +195 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/hidden-window.vbs +16 -2
  136. package/scripts/cli/tray/index.ts +263 -263
  137. package/scripts/cli/tray/windows.ps1 +107 -107
  138. package/scripts/cli/tray.ts +29 -4
  139. package/scripts/cli/update.ts +39 -39
  140. package/scripts/cli/version.ts +67 -67
  141. package/scripts/cli/wordmark.ts +58 -58
  142. package/src/boot/app.ts +16 -1
  143. package/src/boot/dependencies.ts +128 -25
  144. package/src/config/resources.ts +137 -0
  145. package/src/config/runtime.ts +133 -76
  146. package/src/config/spec.ts +30 -29
  147. package/src/config/telemetry.ts +0 -25
  148. package/src/console/api-keys/bucket-spend.ts +14 -17
  149. package/src/console/api-keys/contracts.ts +410 -410
  150. package/src/console/api-keys/routes.ts +12 -0
  151. package/src/console/audit/store.ts +2 -3
  152. package/src/console/auth/setup-service.ts +8 -1
  153. package/src/console/backup/routes.ts +7 -0
  154. package/src/console/backup/service.ts +463 -463
  155. package/src/console/backup/store.ts +16 -11
  156. package/src/console/backup/validate.ts +1 -4
  157. package/src/console/cli-tools/fs-ops.ts +18 -2
  158. package/src/console/data-management/contracts.ts +1 -8
  159. package/src/console/data-management/operations.ts +61 -48
  160. package/src/console/data-management/routes.ts +4 -2
  161. package/src/console/domain-registration.ts +25 -3
  162. package/src/console/inferhub/client.ts +139 -0
  163. package/src/console/inferhub/key-store.ts +129 -0
  164. package/src/console/inferhub/normalize.ts +347 -0
  165. package/src/console/inferhub/routes.ts +411 -0
  166. package/src/console/inferhub/store.ts +109 -0
  167. package/src/console/inferhub/types.ts +183 -0
  168. package/src/console/observability/contracts.ts +2 -41
  169. package/src/console/observability/store.ts +176 -371
  170. package/src/console/providers/catalog/public-model-store.ts +0 -0
  171. package/src/console/providers/catalog/store.ts +23 -36
  172. package/src/console/quota/account-quota-shared.ts +52 -52
  173. package/src/console/quota/account-quota-tenant.ts +16 -24
  174. package/src/console/quota/refresh.ts +441 -441
  175. package/src/console/routing/pools/import-parse.ts +206 -206
  176. package/src/console/routing/pools/operations.ts +543 -543
  177. package/src/console/routing/pools/probe-verdict.ts +132 -132
  178. package/src/console/routing/pools/routes.ts +181 -181
  179. package/src/console/settings/contracts.ts +42 -40
  180. package/src/console/settings/store.ts +25 -25
  181. package/src/console/share/share-stats.ts +22 -4
  182. package/src/console/share/share-usage.ts +30 -4
  183. package/src/console/shared/cursor.ts +15 -0
  184. package/src/console/updates/routes.ts +12 -0
  185. package/src/main.ts +13 -5
  186. package/src/network/pool/resolver.ts +1104 -1104
  187. package/src/observability/client-name.ts +22 -0
  188. package/src/observability/event-log.ts +5 -29
  189. package/src/observability/event-query.ts +690 -0
  190. package/src/observability/log-ring.ts +26 -2
  191. package/src/observability/log-sanitize.ts +118 -0
  192. package/src/observability/logger.ts +17 -13
  193. package/src/observability/metrics.ts +35 -0
  194. package/src/observability/payload-capture.ts +114 -41
  195. package/src/observability/payload-store.ts +249 -24
  196. package/src/observability/raw-event-writer.ts +118 -0
  197. package/src/observability/runtime-metrics.ts +71 -1
  198. package/src/observability/telemetry-buffer.ts +100 -17
  199. package/src/observability/telemetry-reconciliation.ts +417 -0
  200. package/src/persistence/boot.ts +20 -9
  201. package/src/persistence/db-pg.ts +13 -0
  202. package/src/persistence/db-sqlite.ts +569 -497
  203. package/src/persistence/dialect.ts +3 -2
  204. package/src/persistence/migrate.ts +181 -181
  205. package/src/persistence/schema.ts +1496 -1406
  206. package/src/persistence/shared-cache.ts +5 -1
  207. package/src/persistence/stores/api-key-store.ts +581 -581
  208. package/src/persistence/stores/telemetry-store.ts +124 -63
  209. package/src/protocol/reasoning-text.ts +30 -0
  210. package/src/protocol/response/chat.ts +33 -7
  211. package/src/protocol/response/codex.ts +14 -2
  212. package/src/protocol/response/messages.ts +5 -2
  213. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  214. package/src/providers/bundled-discovery-providers.ts +122 -122
  215. package/src/providers/default-registry.ts +10 -0
  216. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  217. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  218. package/src/providers/discovery/probe-phases.ts +43 -7
  219. package/src/providers/discovery/probing-service.ts +70 -32
  220. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  221. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  222. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  223. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  224. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  225. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  226. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  227. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  228. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  229. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  230. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  231. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  232. package/src/providers/integrations/claude/claude.ts +3 -4
  233. package/src/providers/integrations/cline/cline.ts +94 -8
  234. package/src/providers/integrations/codex/codex.ts +61 -37
  235. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  236. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  237. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  238. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  239. package/src/providers/integrations/cursor/cursor.ts +29 -17
  240. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  241. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  242. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  243. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  244. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  245. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  246. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  247. package/src/providers/integrations/hermes.ts +7 -1
  248. package/src/providers/integrations/inferhub.ts +201 -73
  249. package/src/providers/integrations/kilo/kilo.ts +152 -152
  250. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  251. package/src/providers/integrations/openrouter/provider.ts +129 -129
  252. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  253. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  254. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  255. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  256. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  257. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  258. package/src/providers/integrations/zcode/oauth.ts +1 -1
  259. package/src/providers/operations/account-health-service.ts +605 -279
  260. package/src/providers/provider-metadata.ts +12 -1
  261. package/src/providers/provider-model-definition.ts +373 -373
  262. package/src/providers/quota/quota-contracts.ts +3 -7
  263. package/src/providers/search/search-provider.ts +2 -6
  264. package/src/security/admission/contracts.ts +10 -0
  265. package/src/security/admission/in-memory-store.ts +89 -14
  266. package/src/security/admission/lua.ts +191 -182
  267. package/src/security/admission/redis-store.ts +30 -0
  268. package/src/security/admission/service.ts +18 -0
  269. package/src/security/api-key-auth-cache.ts +96 -0
  270. package/src/security/api-key-auth.ts +11 -100
  271. package/src/security/client-router-fingerprint.ts +3 -7
  272. package/src/security/ip-boundary.ts +7 -3
  273. package/src/shared/coerce.ts +67 -0
  274. package/src/shared/hash.ts +6 -0
  275. package/src/shared/time.ts +7 -0
  276. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  277. package/src/transport/dispatch/attempt-loop.ts +103 -81
  278. package/src/transport/dispatch/capture-policy.ts +78 -0
  279. package/src/transport/dispatch/proxy-request.ts +4 -2
  280. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  281. package/src/transport/dispatch/streaming/stream.ts +19 -2
  282. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  283. package/src/transport/failure-policy.ts +27 -0
  284. package/src/transport/guards/abuse-guard.ts +2 -2
  285. package/src/transport/guards/auth-guard.ts +17 -0
  286. package/src/transport/guards/body-policy.ts +3 -2
  287. package/src/transport/guards/concurrency-guard.ts +89 -13
  288. package/src/transport/guards/early-rejection-capture.ts +43 -3
  289. package/src/transport/guards/error-lifecycle.ts +10 -2
  290. package/src/transport/guards/guard-errors.ts +23 -0
  291. package/src/transport/guards/headroom-guard.ts +41 -7
  292. package/src/transport/guards/pipeline.ts +24 -0
  293. package/src/transport/guards/request-context.ts +2 -1
  294. package/src/transport/routing/admission.ts +51 -9
  295. package/src/transport/routing/bounded-lru.ts +49 -0
  296. package/src/transport/routing/candidate-index.ts +88 -0
  297. package/src/transport/routing/reservations.ts +121 -7
  298. package/src/transport/routing/round-robin.ts +10 -22
  299. package/src/transport/routing/route-catalog.ts +26 -13
  300. package/src/transport/routing/route-model.ts +22 -2
  301. package/src/transport/routing/router.ts +53 -31
  302. package/src/transport/surface/chat/encode.ts +2 -3
  303. package/src/transport/surface/chat/parse.ts +2 -1
  304. package/src/transport/surface/dialects.ts +11 -3
  305. package/src/transport/surface/messages/encode.ts +2 -1
  306. package/src/transport/surface/messages/parse.ts +3 -1
  307. package/src/transport/surface/messages/stream.ts +2 -6
  308. package/src/workers/maintenance-lease.ts +200 -0
  309. package/src/workers/scheduler.ts +101 -1
  310. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  312. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  313. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  314. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  315. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  317. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  318. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  319. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  321. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  323. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  324. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  329. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  330. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  331. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  332. package/dist/dashboard/assets/StudioPage-CM92fgJf.js.gz +0 -0
  333. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  334. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  335. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  336. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  337. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  338. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  339. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  340. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  341. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  342. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  343. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  344. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  346. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  347. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  348. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  349. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  350. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  351. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  352. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  353. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  354. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  355. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  356. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  357. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  358. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  359. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  360. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  361. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  362. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  363. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  364. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  365. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  366. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  367. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  368. package/src/observability/event-compaction.ts +0 -317
  369. package/src/observability/event-day-index.ts +0 -439
@@ -1,384 +1,389 @@
1
- // Trae quota.
2
- //
3
- // Two surfaces, on two different hosts, and neither is optional if the account
4
- // is to be read honestly:
5
- //
6
- // - `get_session_usage` on the **core** host is the running meter — what the
7
- // account has left of its advanced/premium request buckets and its dollar
8
- // balances.
9
- // - `ide_user_ent_usage` on the **pay** host is the entitlement: which package
10
- // the account holds and when it lapses.
11
- //
12
- // The donor reads both and lets either fail without the other, and that
13
- // tolerance is load-bearing here too: entitlement is a *pay* host, which is the
14
- // first thing a region-blocked or unpaid account loses. Letting a dead pay host
15
- // fail the whole read would report "no quota" for an account whose session
16
- // meter is perfectly readable — and "no quota" is what the router parks on.
17
- //
18
- // Headers come from `buildTraeHeaders` rather than being rebuilt here: the
19
- // device fingerprint is derived from the uid, and an account that reports one
20
- // device here and another at dispatch is exactly the correlation this gateway
21
- // exists to avoid.
22
-
23
- import type {
24
- FetchLike,
25
- ProviderQuotaResult,
26
- ProviderQuotaWindow,
27
- } from "../../quota/quota-contracts";
28
- import {
29
- cleanError,
30
- isoDate,
31
- number,
32
- percentWindow,
33
- record,
34
- text,
35
- } from "../../quota/quota-contracts";
36
- import type { QuotaCollectionContext } from "../../quota/quota-support";
37
- import { decodeJwtPayload } from "../../authentication/oauth-flow-store";
38
- import { buildTraeHeaders } from "./trae-headers";
39
- import {
40
- TRAE_ENT_USAGE_PATH,
41
- TRAE_REGION_CORE_HOSTS,
42
- TRAE_REALMS,
43
- TRAE_SESSION_USAGE_PATH,
44
- type TraeRealm,
45
- } from "./trae-realm";
46
-
47
- /**
48
- * The buckets `remain_usage` carries, in the order the donor reads them.
49
- *
50
- * `advanced_model` and `premium_model_*` are request counts; `dollar_basic` and
51
- * `dollar_bonus` are spend balances. They are separate budgets and must never
52
- * be summed — a dollar balance is not a request count.
53
- */
54
- const REQUEST_BUCKETS: readonly (readonly [string, string])[] = [
55
- ["advanced_model", "Advanced model requests"],
56
- ["premium_model_fast_request", "Premium fast requests"],
57
- ["premium_model_slow_request", "Premium slow requests"],
58
- ];
59
-
60
- const DOLLAR_BUCKETS: readonly (readonly [string, string])[] = [
61
- ["dollar_basic", "Basic spend"],
62
- ["dollar_bonus", "Bonus spend"],
63
- ];
64
-
65
- interface TraeQuotaAuth {
66
- readonly realm: TraeRealm;
67
- readonly uid: string;
68
- /** Provisioned region (`sg`, `us`), which selects the core host. */
69
- readonly region: string;
70
- }
71
-
72
- /**
73
- * Reads the account's realm and uid for the quota call.
74
- *
75
- * Both come from `auth_state`, the same place dispatch reads them: a Trae
76
- * token is realm-bound, so guessing `intl` for a CN account sends it to a host
77
- * that will not authenticate it.
78
- */
79
- function quotaAuth(context: QuotaCollectionContext | undefined): TraeQuotaAuth {
80
- const raw = context?.auth_state ?? {};
81
- // `realm` is not persisted by the OAuth client — it is a property of which
82
- // provider id the account was registered under, so the CN wrapper forces it
83
- // rather than trusting an absent field.
84
- const realm = raw["realm"] === "cn" || raw["region"] === "CN" ? "cn" : "intl";
85
- // The OAuth client stores the account id as `userId`; `uid` is the name the
86
- // dispatch header builder uses. Reading only one of them left the device
87
- // fingerprint empty for every account.
88
- const uidRaw = raw["uid"] ?? raw["userId"] ?? raw["user_id"];
89
- const uid = typeof uidRaw === "string" && uidRaw.trim() ? uidRaw.trim() : "";
90
- // `region` is provisioned, not chosen: an intl account is created into one
91
- // and its token only authenticates against that region's core host. `us` and
92
- // `cn` are served by the realm's default host, but `sg` is a different host
93
- // entirely — sending it to the US core answers 401 code 1001.
94
- const region = typeof raw["region"] === "string" ? raw["region"].trim().toLowerCase() : "";
95
- return { realm, uid, region };
96
- }
97
-
98
- /**
99
- * The account id out of the access token itself.
100
- *
101
- * The device fingerprint is derived from the uid, so an empty one presents no
102
- * device at all — and this surface correlates the credential against its
103
- * device, answering `1001` for a token that is perfectly valid. The JWT carries
104
- * the id, so it is read there when the auth state does not have it.
105
- */
106
- function uidFromToken(credential: string): string | undefined {
107
- const payload = decodeJwtPayload(credential);
108
- if (!payload) return undefined;
109
- // The id is nested: this token's payload is `{data:{id,type,tenant_id},exp,
110
- // iat}` — there is no `sub` and no top-level `userId`, so a flat read found
111
- // nothing and the request went out with no device identity at all.
112
- for (const source of [payload, record(payload["data"])]) {
113
- if (!source) continue;
114
- for (const key of ["userId", "user_id", "uid", "sub", "id"] as const) {
115
- const value = source[key];
116
- if (typeof value === "string" && value.trim()) return value.trim();
117
- if (typeof value === "number" && Number.isFinite(value)) return String(value);
118
- }
119
- }
120
- return undefined;
121
- }
122
-
123
- /** One bucket of `remain_usage`, paired with its cap from the sibling object. */
124
- function bucketWindow(
125
- label: string,
126
- remaining: number | null,
127
- limit: number | null,
128
- resetsAt: string | null,
129
- ): ProviderQuotaWindow | null {
130
- if (remaining === null && limit === null) return null;
131
- const used = remaining !== null && limit !== null ? limit - remaining : null;
132
- return percentWindow(
133
- `trae:${label}`,
134
- label,
135
- null,
136
- resetsAt,
137
- used,
138
- limit,
139
- // Independent: each bucket is its own budget. Summing a request count into
140
- // a dollar balance would produce a number that means nothing.
141
- "independent",
142
- );
143
- }
144
-
145
- /**
146
- * Pulls `remaining`/`limit` for one key out of either usage object.
147
- *
148
- * `remain_usage` carries a **bare number** per bucket (`advanced_model: 40`),
149
- * not a nested object — the caps live in the sibling `usage` object. Reading a
150
- * nested `remaining` here is what dropped every balance and left a limit with
151
- * no remaining, i.e. a window that cannot say how much is left.
152
- */
153
- function bucketValues(
154
- source: Record<string, unknown>,
155
- key: string,
156
- ): { readonly remaining: number | null; readonly limit: number | null } {
157
- const direct = record(source[key]);
158
- if (direct) {
159
- return {
160
- remaining: number(direct["remaining"] ?? direct["remain"] ?? direct["remain_usage"]),
161
- limit: number(direct["limit"] ?? direct["total"] ?? direct["cap"]),
162
- };
163
- }
164
- return {
165
- remaining: number(source[key] ?? source[`${key}_remaining`]),
166
- limit: number(source[`${key}_limit`] ?? source[`${key}_total`]),
167
- };
168
- }
169
-
170
- /**
171
- * The session-usage meter: `remain_usage` holds the remaining balance per
172
- * bucket, with the caps alongside it on the same object.
173
- */
174
- function parseSessionUsage(payload: unknown): {
175
- readonly windows: readonly ProviderQuotaWindow[];
176
- readonly plan: string | null;
177
- } {
178
- // Deliberately *not* `quotaRecord`: it unwraps a `usage` envelope, and on
179
- // this surface `usage` is the sibling object holding the caps — unwrapping it
180
- // discards `remain_usage` entirely and leaves a limit with no remaining.
181
- const root = record(payload) ?? {};
182
- const windows: ProviderQuotaWindow[] = [];
183
- // The meter is nested under `data` on some realms and at the root on others;
184
- // both shapes are live, so look in each rather than picking one.
185
- const candidates = [root, record(root["data"]), record(root["quota"])].filter(
186
- (value): value is Record<string, unknown> => value !== null,
187
- );
188
- const resetsAt = candidates
189
- .map((value) => isoDate(value["reset_time"] ?? value["resetTime"] ?? value["next_reset_time"]))
190
- .find((value) => value !== null) ?? null;
191
-
192
- for (const source of candidates) {
193
- const remain = record(source["remain_usage"]) ?? source;
194
- const usage = record(source["usage"]) ?? {};
195
- for (const [key, label] of [...REQUEST_BUCKETS, ...DOLLAR_BUCKETS]) {
196
- // Remaining is the bare number under `remain_usage`; the cap is the
197
- // sibling object under `usage`. Two objects, one per side of the same
198
- // budget, so both are read before a window is built.
199
- const remaining = bucketValues(remain, key).remaining ?? number(usage[key]);
200
- const limit = bucketValues(usage, key).limit ?? bucketValues(remain, key).limit;
201
- const window = bucketWindow(label, remaining, limit, resetsAt);
202
- if (window) windows.push(window);
203
- }
204
- if (windows.length > 0) break;
205
- }
206
- const plan = candidates
207
- .map((value) => text(value["plan_name"] ?? value["planName"] ?? value["user_type"]))
208
- .find((value) => value !== null) ?? null;
209
- return { windows, plan };
210
- }
211
-
212
- /** The entitlement package name and lapse, from the pay host. */
213
- function parseEntitlement(payload: unknown): {
214
- readonly windows: readonly ProviderQuotaWindow[];
215
- readonly plan: string | null;
216
- } {
217
- const root = record(payload) ?? {};
218
- const data = record(root["data"]) ?? root;
219
- const items = Array.isArray(data["entitlement_list"])
220
- ? data["entitlement_list"]
221
- : Array.isArray(data["list"])
222
- ? data["list"]
223
- : [];
224
- const windows: ProviderQuotaWindow[] = [];
225
- let plan: string | null = null;
226
- for (const entry of items) {
227
- const item = record(entry);
228
- if (!item) continue;
229
- const name = text(item["ent_title"] ?? item["name"] ?? item["package_name"]);
230
- if (!plan && name) plan = name;
231
- const total = number(item["total"] ?? item["quota"] ?? item["advanced_model_request_limit"]);
232
- const remaining = number(item["remain"] ?? item["remaining"] ?? item["remain_usage"]);
233
- const resetsAt = isoDate(item["end_time"] ?? item["endTime"] ?? item["expires_at"]);
234
- const window = bucketWindow(name ?? "Entitlement", remaining, total, resetsAt);
235
- if (window) windows.push(window);
236
- }
237
- return { windows, plan };
238
- }
239
-
240
- /**
241
- * Records a quiet surface where an operator can act on it.
242
- *
243
- * Deliberately not the returned `error`: that field is what flags the account
244
- * `quota_fetch_failed` and cools it. A 403 from the wrong realm, or a pay host
245
- * that is unreachable, says nothing about whether the account can serve.
246
- */
247
- function logTraeQuotaMiss(errors: readonly string[]): void {
248
- if (errors.length === 0) return;
249
- console.warn(`[quota] trae surfaces unavailable: ${errors.join("; ")}`);
250
- }
251
-
252
- /**
253
- * Reads one Trae account's quota.
254
- *
255
- * The session meter and the entitlement are fetched independently: whichever
256
- * answers contributes, and a failure is only fatal if *both* fail. An account
257
- * whose pay host is unreachable still has a readable meter, and reporting
258
- * "no quota" for it would park an account that can still serve.
259
- */
260
- /**
261
- * The core host for this account.
262
- *
263
- * A provisioned region overrides the realm default: an `sg` account sent to the
264
- * US core is answered 401 code 1001, which looks exactly like a dead token.
265
- */
266
- function coreHost(region: string, config: (typeof TRAE_REALMS)[TraeRealm]): string {
267
- return (region !== "" ? TRAE_REGION_CORE_HOSTS[region] : undefined) ?? config.core;
268
- }
269
-
270
- export async function fetchTraeQuota(
271
- credential: string,
272
- fetcher: FetchLike,
273
- context?: QuotaCollectionContext,
274
- ): Promise<ProviderQuotaResult> {
275
- const { realm, uid, region } = quotaAuth(context);
276
- const resolvedUid = uid || uidFromToken(credential) || "";
277
- const errors: string[] = [];
278
-
279
- const call = async (
280
- realm: TraeRealm,
281
- url: string,
282
- surface: string,
283
- ): Promise<unknown> => {
284
- const response = await fetcher(url, {
285
- // POST with an empty JSON body, not GET: this is the commercial surface
286
- // the desktop client calls, and it answers a GET with an error rather
287
- // than a meter — which read as "no quota" for every account.
288
- method: "POST",
289
- headers: {
290
- // The commercial surface authenticates with `Cloud-IDE-JWT` on both
291
- // realms.
292
- ...buildTraeHeaders({ realm, token: credential, uid: resolvedUid }),
293
- // Applied after the builder, which switches intl to `Bearer` for raw
294
- // chat: a Bearer here is answered 401 code 1001, so the account shows
295
- // no windows while its token is perfectly valid.
296
- authorization: `Cloud-IDE-JWT ${credential}`,
297
- "content-type": "application/json",
298
- // The builder pins `us` for every intl account. An `sg` account sent
299
- // with a US country code is refused with 401 code 1001 — the country
300
- // code is part of the identity this surface checks, not a hint.
301
- "store-country-code": region || "us",
302
- },
303
- body: "{}",
304
- });
305
- if (!response.ok) {
306
- // 403 here is a verdict about *how we asked*, not about the balance: the
307
- // commercial surface correlates the credential against its realm and its
308
- // device fingerprint, and a CN token sent to an intl host — or one sent
309
- // with no device id — is refused outright. Reporting that as a failed
310
- // read parks the account, so it is reported as unreadable-for-now and
311
- // left alone by the cooldown path.
312
- const body = await response.text().catch(() => "");
313
- throw new Error(
314
- response.status === 403
315
- ? `${surface}: HTTP 403 (realm or device mismatch, not a balance)`
316
- : `${surface}: HTTP ${response.status}${body ? ` ${body.slice(0, 80)}` : ""}`,
317
- );
318
- }
319
- return await response.json();
320
- };
321
-
322
- /**
323
- * Tries the realm we believe, then the other one.
324
- *
325
- * A Trae token is realm-bound and the auth state does not record which — the
326
- * provider id does, and a collector cannot see it. The wrong host answers 401
327
- * code 1001, which is indistinguishable from a dead token unless the other
328
- * realm is tried: one of them accepts, and that is the answer.
329
- */
330
- const tryRealms = async (
331
- path: (config: (typeof TRAE_REALMS)[TraeRealm]) => string,
332
- surface: string,
333
- parse: (payload: unknown) => { readonly windows: readonly ProviderQuotaWindow[]; readonly plan: string | null },
334
- ): Promise<{ readonly windows: readonly ProviderQuotaWindow[]; readonly plan: string | null } | null> => {
335
- for (const candidate of realm === "cn" ? (["cn", "intl"] as const) : (["intl", "cn"] as const)) {
336
- try {
337
- const payload = await call(candidate, path(TRAE_REALMS[candidate]), surface);
338
- return parse(payload);
339
- } catch (error) {
340
- errors.push(`${candidate} ${surface}: ${cleanError(error)}`);
341
- }
342
- }
343
- return null;
344
- };
345
-
346
- const session = await tryRealms((config) => coreHost(region, config) + TRAE_SESSION_USAGE_PATH, "session usage", parseSessionUsage);
347
- const entitlement = await tryRealms((config) => config.pay + TRAE_ENT_USAGE_PATH, "entitlement", parseEntitlement);
348
-
349
- const windows = [...(session?.windows ?? []), ...(entitlement?.windows ?? [])];
350
- if (!session && !entitlement) {
351
- // No window and *no error*: a quota read that could not reach upstream is
352
- // not evidence about the account, and `error` here is what flags the
353
- // account `quota_fetch_failed` and cools it down. An unreachable or
354
- // refusing surface must leave the account alone — otherwise an account is
355
- // parked by its own monitor, for a reason that has nothing to do with it.
356
- // The detail is kept for the log instead, where it cannot be read as a
357
- // balance.
358
- logTraeQuotaMiss(errors);
359
- return { source: "trae", plan: null, windows: [], error: null };
360
- }
361
- return {
362
- source: "trae",
363
- plan: session?.plan ?? entitlement?.plan ?? null,
364
- windows,
365
- // A partial read is still a read, and the quiet surface is a diagnostic —
366
- // never a reason to park the account.
367
- error: null,
368
- };
369
- }
370
-
371
- /**
372
- * The `traecn` provider's quota.
373
- *
374
- * Same collector, realm forced to `cn`: a CN token is refused by the intl
375
- * hosts, and the provider id is not something the collector can see — the
376
- * context carries no provider, only auth state.
377
- */
378
- export function fetchTraeCnQuota(
379
- credential: string,
380
- fetcher: FetchLike,
381
- context?: QuotaCollectionContext,
382
- ): Promise<ProviderQuotaResult> {
383
- return fetchTraeQuota(credential, fetcher, { ...context, auth_state: { ...(context?.auth_state ?? {}), realm: "cn" } });
384
- }
1
+ // Trae quota.
2
+ //
3
+ // Two surfaces, on two different hosts, and neither is optional if the account
4
+ // is to be read honestly:
5
+ //
6
+ // - `get_session_usage` on the **core** host is the running meter — what the
7
+ // account has left of its advanced/premium request buckets and its dollar
8
+ // balances.
9
+ // - `ide_user_ent_usage` on the **pay** host is the entitlement: which package
10
+ // the account holds and when it lapses.
11
+ //
12
+ // The donor reads both and lets either fail without the other, and that
13
+ // tolerance is load-bearing here too: entitlement is a *pay* host, which is the
14
+ // first thing a region-blocked or unpaid account loses. Letting a dead pay host
15
+ // fail the whole read would report "no quota" for an account whose session
16
+ // meter is perfectly readable — and "no quota" is what the router parks on.
17
+ //
18
+ // Headers come from `buildTraeHeaders` rather than being rebuilt here: the
19
+ // device fingerprint is derived from the uid, and an account that reports one
20
+ // device here and another at dispatch is exactly the correlation this gateway
21
+ // exists to avoid.
22
+
23
+ import type {
24
+ FetchLike,
25
+ ProviderQuotaResult,
26
+ ProviderQuotaWindow,
27
+ } from "../../quota/quota-contracts";
28
+ import {
29
+ cleanError,
30
+ isoDate,
31
+ number,
32
+ percentWindow,
33
+ record,
34
+ text,
35
+ } from "../../quota/quota-contracts";
36
+ import type { QuotaCollectionContext } from "../../quota/quota-support";
37
+ import { decodeJwtPayload } from "../../authentication/oauth-flow-store";
38
+ import { buildTraeHeaders } from "./trae-headers";
39
+ import { log } from "../../../observability/logger";
40
+ import {
41
+ TRAE_ENT_USAGE_PATH,
42
+ TRAE_REGION_CORE_HOSTS,
43
+ TRAE_REALMS,
44
+ TRAE_SESSION_USAGE_PATH,
45
+ type TraeRealm,
46
+ } from "./trae-realm";
47
+
48
+ /**
49
+ * The buckets `remain_usage` carries, in the order the donor reads them.
50
+ *
51
+ * `advanced_model` and `premium_model_*` are request counts; `dollar_basic` and
52
+ * `dollar_bonus` are spend balances. They are separate budgets and must never
53
+ * be summed — a dollar balance is not a request count.
54
+ */
55
+ const REQUEST_BUCKETS: readonly (readonly [string, string])[] = [
56
+ ["advanced_model", "Advanced model requests"],
57
+ ["premium_model_fast_request", "Premium fast requests"],
58
+ ["premium_model_slow_request", "Premium slow requests"],
59
+ ];
60
+
61
+ const DOLLAR_BUCKETS: readonly (readonly [string, string])[] = [
62
+ ["dollar_basic", "Basic spend"],
63
+ ["dollar_bonus", "Bonus spend"],
64
+ ];
65
+
66
+ interface TraeQuotaAuth {
67
+ readonly realm: TraeRealm;
68
+ readonly uid: string;
69
+ /** Provisioned region (`sg`, `us`), which selects the core host. */
70
+ readonly region: string;
71
+ }
72
+
73
+ /**
74
+ * Reads the account's realm and uid for the quota call.
75
+ *
76
+ * Both come from `auth_state`, the same place dispatch reads them: a Trae
77
+ * token is realm-bound, so guessing `intl` for a CN account sends it to a host
78
+ * that will not authenticate it.
79
+ */
80
+ function quotaAuth(context: QuotaCollectionContext | undefined): TraeQuotaAuth {
81
+ const raw = context?.auth_state ?? {};
82
+ // `realm` is not persisted by the OAuth client — it is a property of which
83
+ // provider id the account was registered under, so the CN wrapper forces it
84
+ // rather than trusting an absent field.
85
+ const realm = raw["realm"] === "cn" || raw["region"] === "CN" ? "cn" : "intl";
86
+ // The OAuth client stores the account id as `userId`; `uid` is the name the
87
+ // dispatch header builder uses. Reading only one of them left the device
88
+ // fingerprint empty for every account.
89
+ const uidRaw = raw["uid"] ?? raw["userId"] ?? raw["user_id"];
90
+ const uid = typeof uidRaw === "string" && uidRaw.trim() ? uidRaw.trim() : "";
91
+ // `region` is provisioned, not chosen: an intl account is created into one
92
+ // and its token only authenticates against that region's core host. `us` and
93
+ // `cn` are served by the realm's default host, but `sg` is a different host
94
+ // entirely — sending it to the US core answers 401 code 1001.
95
+ const region = typeof raw["region"] === "string" ? raw["region"].trim().toLowerCase() : "";
96
+ return { realm, uid, region };
97
+ }
98
+
99
+ /**
100
+ * The account id out of the access token itself.
101
+ *
102
+ * The device fingerprint is derived from the uid, so an empty one presents no
103
+ * device at all — and this surface correlates the credential against its
104
+ * device, answering `1001` for a token that is perfectly valid. The JWT carries
105
+ * the id, so it is read there when the auth state does not have it.
106
+ */
107
+ function uidFromToken(credential: string): string | undefined {
108
+ const payload = decodeJwtPayload(credential);
109
+ if (!payload) return undefined;
110
+ // The id is nested: this token's payload is `{data:{id,type,tenant_id},exp,
111
+ // iat}` — there is no `sub` and no top-level `userId`, so a flat read found
112
+ // nothing and the request went out with no device identity at all.
113
+ for (const source of [payload, record(payload["data"])]) {
114
+ if (!source) continue;
115
+ for (const key of ["userId", "user_id", "uid", "sub", "id"] as const) {
116
+ const value = source[key];
117
+ if (typeof value === "string" && value.trim()) return value.trim();
118
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
119
+ }
120
+ }
121
+ return undefined;
122
+ }
123
+
124
+ /** One bucket of `remain_usage`, paired with its cap from the sibling object. */
125
+ function bucketWindow(
126
+ label: string,
127
+ remaining: number | null,
128
+ limit: number | null,
129
+ resetsAt: string | null,
130
+ ): ProviderQuotaWindow | null {
131
+ if (remaining === null && limit === null) return null;
132
+ const used = remaining !== null && limit !== null ? limit - remaining : null;
133
+ return percentWindow(
134
+ `trae:${label}`,
135
+ label,
136
+ null,
137
+ resetsAt,
138
+ used,
139
+ limit,
140
+ // Independent: each bucket is its own budget. Summing a request count into
141
+ // a dollar balance would produce a number that means nothing.
142
+ "independent",
143
+ );
144
+ }
145
+
146
+ /**
147
+ * Pulls `remaining`/`limit` for one key out of either usage object.
148
+ *
149
+ * `remain_usage` carries a **bare number** per bucket (`advanced_model: 40`),
150
+ * not a nested object — the caps live in the sibling `usage` object. Reading a
151
+ * nested `remaining` here is what dropped every balance and left a limit with
152
+ * no remaining, i.e. a window that cannot say how much is left.
153
+ */
154
+ function bucketValues(
155
+ source: Record<string, unknown>,
156
+ key: string,
157
+ ): { readonly remaining: number | null; readonly limit: number | null } {
158
+ const direct = record(source[key]);
159
+ if (direct) {
160
+ return {
161
+ remaining: number(direct["remaining"] ?? direct["remain"] ?? direct["remain_usage"]),
162
+ limit: number(direct["limit"] ?? direct["total"] ?? direct["cap"]),
163
+ };
164
+ }
165
+ return {
166
+ remaining: number(source[key] ?? source[`${key}_remaining`]),
167
+ limit: number(source[`${key}_limit`] ?? source[`${key}_total`]),
168
+ };
169
+ }
170
+
171
+ /**
172
+ * The session-usage meter: `remain_usage` holds the remaining balance per
173
+ * bucket, with the caps alongside it on the same object.
174
+ */
175
+ function parseSessionUsage(payload: unknown): {
176
+ readonly windows: readonly ProviderQuotaWindow[];
177
+ readonly plan: string | null;
178
+ } {
179
+ // Deliberately *not* `quotaRecord`: it unwraps a `usage` envelope, and on
180
+ // this surface `usage` is the sibling object holding the caps — unwrapping it
181
+ // discards `remain_usage` entirely and leaves a limit with no remaining.
182
+ const root = record(payload) ?? {};
183
+ const windows: ProviderQuotaWindow[] = [];
184
+ // The meter is nested under `data` on some realms and at the root on others;
185
+ // both shapes are live, so look in each rather than picking one.
186
+ const candidates = [root, record(root["data"]), record(root["quota"])].filter(
187
+ (value): value is Record<string, unknown> => value !== null,
188
+ );
189
+ const resetsAt = candidates
190
+ .map((value) => isoDate(value["reset_time"] ?? value["resetTime"] ?? value["next_reset_time"]))
191
+ .find((value) => value !== null) ?? null;
192
+
193
+ for (const source of candidates) {
194
+ const remain = record(source["remain_usage"]) ?? source;
195
+ const usage = record(source["usage"]) ?? {};
196
+ for (const [key, label] of [...REQUEST_BUCKETS, ...DOLLAR_BUCKETS]) {
197
+ // Remaining is the bare number under `remain_usage`; the cap is the
198
+ // sibling object under `usage`. Two objects, one per side of the same
199
+ // budget, so both are read before a window is built.
200
+ const remaining = bucketValues(remain, key).remaining ?? number(usage[key]);
201
+ const limit = bucketValues(usage, key).limit ?? bucketValues(remain, key).limit;
202
+ const window = bucketWindow(label, remaining, limit, resetsAt);
203
+ if (window) windows.push(window);
204
+ }
205
+ if (windows.length > 0) break;
206
+ }
207
+ const plan = candidates
208
+ .map((value) => text(value["plan_name"] ?? value["planName"] ?? value["user_type"]))
209
+ .find((value) => value !== null) ?? null;
210
+ return { windows, plan };
211
+ }
212
+
213
+ /** The entitlement package name and lapse, from the pay host. */
214
+ function parseEntitlement(payload: unknown): {
215
+ readonly windows: readonly ProviderQuotaWindow[];
216
+ readonly plan: string | null;
217
+ } {
218
+ const root = record(payload) ?? {};
219
+ const data = record(root["data"]) ?? root;
220
+ const items = Array.isArray(data["entitlement_list"])
221
+ ? data["entitlement_list"]
222
+ : Array.isArray(data["list"])
223
+ ? data["list"]
224
+ : [];
225
+ const windows: ProviderQuotaWindow[] = [];
226
+ let plan: string | null = null;
227
+ for (const entry of items) {
228
+ const item = record(entry);
229
+ if (!item) continue;
230
+ const name = text(item["ent_title"] ?? item["name"] ?? item["package_name"]);
231
+ if (!plan && name) plan = name;
232
+ const total = number(item["total"] ?? item["quota"] ?? item["advanced_model_request_limit"]);
233
+ const remaining = number(item["remain"] ?? item["remaining"] ?? item["remain_usage"]);
234
+ const resetsAt = isoDate(item["end_time"] ?? item["endTime"] ?? item["expires_at"]);
235
+ const window = bucketWindow(name ?? "Entitlement", remaining, total, resetsAt);
236
+ if (window) windows.push(window);
237
+ }
238
+ return { windows, plan };
239
+ }
240
+
241
+ /**
242
+ * Records a quiet surface where an operator can act on it.
243
+ *
244
+ * Deliberately not the returned `error`: that field is what flags the account
245
+ * `quota_fetch_failed` and cools it. A 403 from the wrong realm, or a pay host
246
+ * that is unreachable, says nothing about whether the account can serve.
247
+ */
248
+ function logTraeQuotaMiss(errors: readonly string[]): void {
249
+ if (errors.length === 0) return;
250
+ log.warn(`[quota] trae surfaces unavailable: ${errors.join("; ")}`);
251
+ }
252
+
253
+ /**
254
+ * Reads one Trae account's quota.
255
+ *
256
+ * The session meter and the entitlement are fetched independently: whichever
257
+ * answers contributes, and a failure is only fatal if *both* fail. An account
258
+ * whose pay host is unreachable still has a readable meter, and reporting
259
+ * "no quota" for it would park an account that can still serve.
260
+ */
261
+ /**
262
+ * The core host for this account.
263
+ *
264
+ * A provisioned region overrides the realm default: an `sg` account sent to the
265
+ * US core is answered 401 code 1001, which looks exactly like a dead token.
266
+ */
267
+ function coreHost(region: string, config: (typeof TRAE_REALMS)[TraeRealm]): string {
268
+ return (region !== "" ? TRAE_REGION_CORE_HOSTS[region] : undefined) ?? config.core;
269
+ }
270
+
271
+ export async function fetchTraeQuota(
272
+ credential: string,
273
+ fetcher: FetchLike,
274
+ context?: QuotaCollectionContext,
275
+ ): Promise<ProviderQuotaResult> {
276
+ const { realm, uid, region } = quotaAuth(context);
277
+ const resolvedUid = uid || uidFromToken(credential) || "";
278
+ const errors: string[] = [];
279
+
280
+ const call = async (
281
+ realm: TraeRealm,
282
+ url: string,
283
+ surface: string,
284
+ ): Promise<unknown> => {
285
+ const response = await fetcher(url, {
286
+ // POST with an empty JSON body, not GET: this is the commercial surface
287
+ // the desktop client calls, and it answers a GET with an error rather
288
+ // than a meter — which read as "no quota" for every account.
289
+ method: "POST",
290
+ headers: {
291
+ // The commercial surface authenticates with `Cloud-IDE-JWT` on both
292
+ // realms.
293
+ ...buildTraeHeaders({ realm, token: credential, uid: resolvedUid }),
294
+ // Applied after the builder, which switches intl to `Bearer` for raw
295
+ // chat: a Bearer here is answered 401 code 1001, so the account shows
296
+ // no windows while its token is perfectly valid.
297
+ authorization: `Cloud-IDE-JWT ${credential}`,
298
+ "content-type": "application/json",
299
+ // The builder pins `us` for every intl account. An `sg` account sent
300
+ // with a US country code is refused with 401 code 1001 — the country
301
+ // code is part of the identity this surface checks, not a hint.
302
+ // `sg` rather than `us` for an account with no recorded region: the
303
+ // default core is SG now, and this header is part of the identity the
304
+ // commercial surface checks, so it has to agree with the host it is
305
+ // sent to. An account that *has* a region still sends its own.
306
+ "store-country-code": region || "sg",
307
+ },
308
+ body: "{}",
309
+ });
310
+ if (!response.ok) {
311
+ // 403 here is a verdict about *how we asked*, not about the balance: the
312
+ // commercial surface correlates the credential against its realm and its
313
+ // device fingerprint, and a CN token sent to an intl host — or one sent
314
+ // with no device id — is refused outright. Reporting that as a failed
315
+ // read parks the account, so it is reported as unreadable-for-now and
316
+ // left alone by the cooldown path.
317
+ const body = await response.text().catch(() => "");
318
+ throw new Error(
319
+ response.status === 403
320
+ ? `${surface}: HTTP 403 (realm or device mismatch, not a balance)`
321
+ : `${surface}: HTTP ${response.status}${body ? ` ${body.slice(0, 80)}` : ""}`,
322
+ );
323
+ }
324
+ return await response.json();
325
+ };
326
+
327
+ /**
328
+ * Tries the realm we believe, then the other one.
329
+ *
330
+ * A Trae token is realm-bound and the auth state does not record which — the
331
+ * provider id does, and a collector cannot see it. The wrong host answers 401
332
+ * code 1001, which is indistinguishable from a dead token unless the other
333
+ * realm is tried: one of them accepts, and that is the answer.
334
+ */
335
+ const tryRealms = async (
336
+ path: (config: (typeof TRAE_REALMS)[TraeRealm]) => string,
337
+ surface: string,
338
+ parse: (payload: unknown) => { readonly windows: readonly ProviderQuotaWindow[]; readonly plan: string | null },
339
+ ): Promise<{ readonly windows: readonly ProviderQuotaWindow[]; readonly plan: string | null } | null> => {
340
+ for (const candidate of realm === "cn" ? (["cn", "intl"] as const) : (["intl", "cn"] as const)) {
341
+ try {
342
+ const payload = await call(candidate, path(TRAE_REALMS[candidate]), surface);
343
+ return parse(payload);
344
+ } catch (error) {
345
+ errors.push(`${candidate} ${surface}: ${cleanError(error)}`);
346
+ }
347
+ }
348
+ return null;
349
+ };
350
+
351
+ const session = await tryRealms((config) => coreHost(region, config) + TRAE_SESSION_USAGE_PATH, "session usage", parseSessionUsage);
352
+ const entitlement = await tryRealms((config) => config.pay + TRAE_ENT_USAGE_PATH, "entitlement", parseEntitlement);
353
+
354
+ const windows = [...(session?.windows ?? []), ...(entitlement?.windows ?? [])];
355
+ if (!session && !entitlement) {
356
+ // No window and *no error*: a quota read that could not reach upstream is
357
+ // not evidence about the account, and `error` here is what flags the
358
+ // account `quota_fetch_failed` and cools it down. An unreachable or
359
+ // refusing surface must leave the account alone — otherwise an account is
360
+ // parked by its own monitor, for a reason that has nothing to do with it.
361
+ // The detail is kept for the log instead, where it cannot be read as a
362
+ // balance.
363
+ logTraeQuotaMiss(errors);
364
+ return { source: "trae", plan: null, windows: [], error: null };
365
+ }
366
+ return {
367
+ source: "trae",
368
+ plan: session?.plan ?? entitlement?.plan ?? null,
369
+ windows,
370
+ // A partial read is still a read, and the quiet surface is a diagnostic —
371
+ // never a reason to park the account.
372
+ error: null,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * The `traecn` provider's quota.
378
+ *
379
+ * Same collector, realm forced to `cn`: a CN token is refused by the intl
380
+ * hosts, and the provider id is not something the collector can see — the
381
+ * context carries no provider, only auth state.
382
+ */
383
+ export function fetchTraeCnQuota(
384
+ credential: string,
385
+ fetcher: FetchLike,
386
+ context?: QuotaCollectionContext,
387
+ ): Promise<ProviderQuotaResult> {
388
+ return fetchTraeQuota(credential, fetcher, { ...context, auth_state: { ...(context?.auth_state ?? {}), realm: "cn" } });
389
+ }