cartethyia 2.4.5 → 2.5.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 (415) hide show
  1. package/.env.example +4 -1
  2. package/README.md +63 -13
  3. package/dist/dashboard/assets/AccountCooldown-ChpdzxRH.js.gz +0 -0
  4. package/dist/dashboard/assets/BannedPage-9igfctfZ.js +1 -0
  5. package/dist/dashboard/assets/BannedPage-9igfctfZ.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolDetailPage-BzGevuql.js → CliToolDetailPage-ZRr2K2sF.js} +1 -1
  7. package/dist/dashboard/assets/CliToolDetailPage-ZRr2K2sF.js.gz +0 -0
  8. package/dist/dashboard/assets/{CliToolsPage-BaRM_7We.js → CliToolsPage-D3xqZbN-.js} +1 -1
  9. package/dist/dashboard/assets/CliToolsPage-D3xqZbN-.js.gz +0 -0
  10. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js +1 -0
  11. package/dist/dashboard/assets/CombosPage-CVnQPBBz.js.gz +0 -0
  12. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js +1 -0
  13. package/dist/dashboard/assets/ConsoleLogPage-u6W1UXSc.js.gz +0 -0
  14. package/dist/dashboard/assets/{CustomizationPage-DqUGcoH0.js → CustomizationPage-CTTpCb25.js} +1 -1
  15. package/dist/dashboard/assets/CustomizationPage-CTTpCb25.js.gz +0 -0
  16. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js +1 -0
  17. package/dist/dashboard/assets/HealthEventsModal-C5XpWlvy.js.gz +0 -0
  18. package/dist/dashboard/assets/{LoginPage-DbhTGfhb.js → LoginPage-DHoB3Agn.js} +1 -1
  19. package/dist/dashboard/assets/LoginPage-DHoB3Agn.js.gz +0 -0
  20. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js +1 -0
  21. package/dist/dashboard/assets/ModelPicker-oQ-SDXp8.js.gz +0 -0
  22. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js +1 -0
  23. package/dist/dashboard/assets/OverviewPage-BrY4SgIt.js.gz +0 -0
  24. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js +3 -0
  25. package/dist/dashboard/assets/ProviderDetailPage-D9VSt7T_.js.gz +0 -0
  26. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js +1 -0
  27. package/dist/dashboard/assets/ProviderIcon-CmfzxpVM.js.gz +0 -0
  28. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js +1 -0
  29. package/dist/dashboard/assets/ProvidersPage-Df77V5I5.js.gz +0 -0
  30. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js +7 -0
  31. package/dist/dashboard/assets/ProxyPage-CDfxP7AC.js.gz +0 -0
  32. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js +1 -0
  33. package/dist/dashboard/assets/QuotaPage-CY4o2t4p.js.gz +0 -0
  34. package/dist/dashboard/assets/{SettingsPage-YxRml8AT.js → SettingsPage-CvjZxHE4.js} +1 -1
  35. package/dist/dashboard/assets/SettingsPage-CvjZxHE4.js.gz +0 -0
  36. package/dist/dashboard/assets/SetupPage-D3nza7LH.js +1 -0
  37. package/dist/dashboard/assets/SetupPage-D3nza7LH.js.gz +0 -0
  38. package/dist/dashboard/assets/{SortableList-Bol1ASmI.js → SortableList-Dtt12WT1.js} +1 -1
  39. package/dist/dashboard/assets/SortableList-Dtt12WT1.js.gz +0 -0
  40. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js +31 -0
  41. package/dist/dashboard/assets/StudioPage-CbK3hXvU.js.gz +0 -0
  42. package/dist/dashboard/assets/ToolIcon-K1ckcDKg.js.gz +0 -0
  43. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js +56 -0
  44. package/dist/dashboard/assets/UsagePage-BQlWbWg2.js.gz +0 -0
  45. package/dist/dashboard/assets/api-keys-BmDbsior.js +1 -0
  46. package/dist/dashboard/assets/api-keys-BmDbsior.js.gz +0 -0
  47. package/dist/dashboard/assets/app-BbpSarVV.css +2 -0
  48. package/dist/dashboard/assets/app-BbpSarVV.css.gz +0 -0
  49. package/dist/dashboard/assets/{app-BDJWMThu.js → app-C-Y4oZT0.js} +3 -3
  50. package/dist/dashboard/assets/app-C-Y4oZT0.js.gz +0 -0
  51. package/dist/dashboard/assets/arrow-left-CpBxkoxI.js.gz +0 -0
  52. package/dist/dashboard/assets/arrow-right-Dl5yKnp8.js.gz +0 -0
  53. package/dist/dashboard/assets/assert-BHfaei-X.js +1 -0
  54. package/dist/dashboard/assets/assert-BHfaei-X.js.gz +0 -0
  55. package/dist/dashboard/assets/backup-file-gdKIlg8U.js.gz +0 -0
  56. package/dist/dashboard/assets/{badge-BwmwEOgA.js → badge-DpXT4Ixt.js} +1 -1
  57. package/dist/dashboard/assets/badge-DpXT4Ixt.js.gz +0 -0
  58. package/dist/dashboard/assets/{banner-BKBfKTqn.js → banner-CSAAWLJv.js} +1 -1
  59. package/dist/dashboard/assets/banner-CSAAWLJv.js.gz +0 -0
  60. package/dist/dashboard/assets/brain-Cwed_72z.js.gz +0 -0
  61. package/dist/dashboard/assets/{card-BTdbRFFg.js → card-DEhRXXw4.js} +1 -1
  62. package/dist/dashboard/assets/card-DEhRXXw4.js.gz +0 -0
  63. package/dist/dashboard/assets/check-D69FzdvC.js.gz +0 -0
  64. package/dist/dashboard/assets/chevron-right-BXIy5l3h.js.gz +0 -0
  65. package/dist/dashboard/assets/circle-alert-CntYHV5v.js.gz +0 -0
  66. package/dist/dashboard/assets/circle-check-BU3v_mFW.js.gz +0 -0
  67. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js +1 -0
  68. package/dist/dashboard/assets/cli-tools-B0wBq-RL.js.gz +0 -0
  69. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js +1 -0
  70. package/dist/dashboard/assets/clipboard-button-DOo6EU8O.js.gz +0 -0
  71. package/dist/dashboard/assets/cog-CdWhsajs.js.gz +0 -0
  72. package/dist/dashboard/assets/copy-ngmQ9zcZ.js.gz +0 -0
  73. package/dist/dashboard/assets/{dialog-iocKA3jr.js → dialog-BvQeBMfP.js} +1 -1
  74. package/dist/dashboard/assets/dialog-BvQeBMfP.js.gz +0 -0
  75. package/dist/dashboard/assets/dialog-actions-B1axpEg7.js.gz +0 -0
  76. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  77. package/dist/dashboard/assets/download-dRFo5_Wu.js.gz +0 -0
  78. package/dist/dashboard/assets/{drawer-CDKOTZdY.js → drawer-CMwPXwQB.js} +1 -1
  79. package/dist/dashboard/assets/drawer-CMwPXwQB.js.gz +0 -0
  80. package/dist/dashboard/assets/entry-D4O2229X.js +1 -0
  81. package/dist/dashboard/assets/entry-D4O2229X.js.gz +0 -0
  82. package/dist/dashboard/assets/entry-DKvr5Q9K.js +1 -0
  83. package/dist/dashboard/assets/entry-DKvr5Q9K.js.gz +0 -0
  84. package/dist/dashboard/assets/entry-FQCejoDY.js +4 -0
  85. package/dist/dashboard/assets/entry-FQCejoDY.js.gz +0 -0
  86. package/dist/dashboard/assets/eye-C_hawfH4.js.gz +0 -0
  87. package/dist/dashboard/assets/eye-off-Bk5g2luF.js.gz +0 -0
  88. package/dist/dashboard/assets/fingerprint-pattern-BWuHtbW4.js.gz +0 -0
  89. package/dist/dashboard/assets/gauge-BbIMy7MB.js.gz +0 -0
  90. package/dist/dashboard/assets/github-badge-zliLvBwr.js +1 -0
  91. package/dist/dashboard/assets/github-badge-zliLvBwr.js.gz +0 -0
  92. package/dist/dashboard/assets/image-BPaouViG.js.gz +0 -0
  93. package/dist/dashboard/assets/{index-C4hXfAMs.js → index-CXm3FIyt.js} +2 -2
  94. package/dist/dashboard/assets/index-CXm3FIyt.js.gz +0 -0
  95. package/dist/dashboard/assets/{inline-DCejSLlA.js → inline-D1IlZZ8H.js} +1 -1
  96. package/dist/dashboard/assets/inline-D1IlZZ8H.js.gz +0 -0
  97. package/dist/dashboard/assets/{input-Bn4_kNav.js → input-BpVZdrbc.js} +1 -1
  98. package/dist/dashboard/assets/input-BpVZdrbc.js.gz +0 -0
  99. package/dist/dashboard/assets/jsx-runtime-CXUMngI_.js.gz +0 -0
  100. package/dist/dashboard/assets/key-round-UkavsGxp.js.gz +0 -0
  101. package/dist/dashboard/assets/layers-2-glBbjl.js.gz +0 -0
  102. package/dist/dashboard/assets/{layout-Bv-NxqZ5.js → layout-BioLW7gN.js} +1 -1
  103. package/dist/dashboard/assets/layout-BioLW7gN.js.gz +0 -0
  104. package/dist/dashboard/assets/{list-row-BLA0sldD.js → list-row-CwVcGAIc.js} +1 -1
  105. package/dist/dashboard/assets/list-row-CwVcGAIc.js.gz +0 -0
  106. package/dist/dashboard/assets/{live-YlifPYox.js → live-yfEJFF9T.js} +1 -1
  107. package/dist/dashboard/assets/live-yfEJFF9T.js.gz +0 -0
  108. package/dist/dashboard/assets/log-in-BmRjy60c.js.gz +0 -0
  109. package/dist/dashboard/assets/message-circle-CCxGwRvv.js.gz +0 -0
  110. package/dist/dashboard/assets/model-bans-CT-TxSNX.js +1 -0
  111. package/dist/dashboard/assets/model-bans-CT-TxSNX.js.gz +0 -0
  112. package/dist/dashboard/assets/mutation-BkoLM5Ba.js +1 -0
  113. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  114. package/dist/dashboard/assets/numbers-CFL4_Dqc.js.gz +0 -0
  115. package/dist/dashboard/assets/pencil-768ysRAr.js.gz +0 -0
  116. package/dist/dashboard/assets/plus-DIdX_g-x.js.gz +0 -0
  117. package/dist/dashboard/assets/power-off-Yjw9GMJh.js +1 -0
  118. package/dist/dashboard/assets/power-off-Yjw9GMJh.js.gz +0 -0
  119. package/dist/dashboard/assets/privacy-mode-GM8bej85.js +1 -0
  120. package/dist/dashboard/assets/privacy-mode-GM8bej85.js.gz +0 -0
  121. package/dist/dashboard/assets/providers/agentrouter.svg.gz +0 -0
  122. package/dist/dashboard/assets/providers/aihubmix.svg.gz +0 -0
  123. package/dist/dashboard/assets/providers/alibaba-coding-plan.svg.gz +0 -0
  124. package/dist/dashboard/assets/providers/alibaba.svg.gz +0 -0
  125. package/dist/dashboard/assets/providers/antigravity.svg.gz +0 -0
  126. package/dist/dashboard/assets/providers/bai.svg.gz +0 -0
  127. package/dist/dashboard/assets/providers/blackbox.svg.gz +0 -0
  128. package/dist/dashboard/assets/providers/claude-code.svg.gz +0 -0
  129. package/dist/dashboard/assets/providers/cursor.webp +0 -0
  130. package/dist/dashboard/assets/providers/exa.svg.gz +0 -0
  131. package/dist/dashboard/assets/providers/gmi-cloud.svg.gz +0 -0
  132. package/dist/dashboard/assets/providers/inferhub.svg.gz +0 -0
  133. package/dist/dashboard/assets/providers/muse.svg.gz +0 -0
  134. package/dist/dashboard/assets/providers/nous-research.svg.gz +0 -0
  135. package/dist/dashboard/assets/providers/openai-light.svg.gz +0 -0
  136. package/dist/dashboard/assets/providers/parallel.svg.gz +0 -0
  137. package/dist/dashboard/assets/providers/tokenharbor.svg.gz +0 -0
  138. package/dist/dashboard/assets/providers/trae.webp +0 -0
  139. package/dist/dashboard/assets/providers/zai-coding-plan.svg.gz +0 -0
  140. package/dist/dashboard/assets/{query-keys-ROf-g1Wi.js → query-keys-D7wX2vM5.js} +1 -1
  141. package/dist/dashboard/assets/query-keys-D7wX2vM5.js.gz +0 -0
  142. package/dist/dashboard/assets/quota-DZGyp0N5.js.gz +0 -0
  143. package/dist/dashboard/assets/refresh-cw-BqJFk4sU.js.gz +0 -0
  144. package/dist/dashboard/assets/rotate-ccw-CufcqITi.js.gz +0 -0
  145. package/dist/dashboard/assets/route-CNSNj9Zm.js.gz +0 -0
  146. package/dist/dashboard/assets/routing-Cs6jl49L.js +1 -0
  147. package/dist/dashboard/assets/routing-Cs6jl49L.js.gz +0 -0
  148. package/dist/dashboard/assets/scaling-nWE4Cx8f.js.gz +0 -0
  149. package/dist/dashboard/assets/search-JOKus9rb.js.gz +0 -0
  150. package/dist/dashboard/assets/select-C0Rer3gF.js +41 -0
  151. package/dist/dashboard/assets/select-C0Rer3gF.js.gz +0 -0
  152. package/dist/dashboard/assets/settings-CfcTcOyU.js +1 -0
  153. package/dist/dashboard/assets/settings-CfcTcOyU.js.gz +0 -0
  154. package/dist/dashboard/assets/shield-check-DVS0Foqh.js.gz +0 -0
  155. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  156. package/dist/dashboard/assets/{stack-C1kTcOmP.js → stack-C9_uUjaA.js} +1 -1
  157. package/dist/dashboard/assets/stack-C9_uUjaA.js.gz +0 -0
  158. package/dist/dashboard/assets/{state-DHKdg-pm.js → state-DA0B95UQ.js} +1 -1
  159. package/dist/dashboard/assets/state-DA0B95UQ.js.gz +0 -0
  160. package/dist/dashboard/assets/stream-retry-5QnVuSSo.js.gz +0 -0
  161. package/dist/dashboard/assets/{switch-DIDeAiz9.js → switch-Cittxy-x.js} +1 -1
  162. package/dist/dashboard/assets/switch-Cittxy-x.js.gz +0 -0
  163. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +1 -0
  164. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  165. package/dist/dashboard/assets/toast-CNt_jzO4.js +2 -0
  166. package/dist/dashboard/assets/toast-CNt_jzO4.js.gz +0 -0
  167. package/dist/dashboard/assets/tokens-DDxMbSRL.js.gz +0 -0
  168. package/dist/dashboard/assets/trash-Cppn5c3R.js.gz +0 -0
  169. package/dist/dashboard/assets/use-clipboard-B0Sy03fE.js.gz +0 -0
  170. package/dist/dashboard/assets/use-presence-BSZEBx7U.js.gz +0 -0
  171. package/dist/dashboard/assets/use-reduced-motion-C4B0R6Sz.js.gz +0 -0
  172. package/dist/dashboard/assets/use-timeout-BwcBBRrT.js.gz +0 -0
  173. package/dist/dashboard/assets/{useMutation-DVmwOKU-.js → useMutation-BQKMcJ1H.js} +1 -1
  174. package/dist/dashboard/assets/useMutation-BQKMcJ1H.js.gz +0 -0
  175. package/dist/dashboard/assets/wrench-B6E1oEuY.js.gz +0 -0
  176. package/dist/dashboard/index.html +1 -1
  177. package/dist/dashboard/index.html.gz +0 -0
  178. package/dist/dashboard/precompressed-assets.json +122 -0
  179. package/migrations/full-postgres/0013_add_providers_tenant_id_index.sql +22 -0
  180. package/migrations/full-postgres/0014_add_provider_accounts_tenant_id_index.sql +21 -0
  181. package/migrations/full-postgres/0015_add_payload_stages.sql +18 -0
  182. package/migrations/full-postgres/0016_add_share_link_slug.sql +20 -0
  183. package/migrations/full-postgres/0017_add_models_pro_only.sql +18 -0
  184. package/migrations/full-postgres/0018_add_accounts_quota_plan.sql +18 -0
  185. package/migrations/full-postgres/0019_add_models_free_tier.sql +15 -0
  186. package/migrations/lite-sqlite3/0000_baseline.sql +4 -2
  187. package/migrations/lite-sqlite3/0013_add_providers_tenant_id_index.sql +4 -0
  188. package/migrations/lite-sqlite3/0014_add_provider_accounts_tenant_id_index.sql +4 -0
  189. package/migrations/lite-sqlite3/0015_add_payload_stages.sql +18 -0
  190. package/migrations/lite-sqlite3/0016_add_share_link_slug.sql +20 -0
  191. package/migrations/lite-sqlite3/0017_add_models_pro_only.sql +18 -0
  192. package/migrations/lite-sqlite3/0018_add_accounts_quota_plan.sql +18 -0
  193. package/migrations/lite-sqlite3/0019_add_models_free_tier.sql +15 -0
  194. package/package.json +26 -30
  195. package/scripts/cli/{carte-launcher.cjs → bin/carte.cjs} +8 -8
  196. package/scripts/cli/{cli-launcher.cjs → bin/cartethyia.cjs} +167 -134
  197. package/scripts/cli/command/admin.ts +65 -0
  198. package/scripts/cli/command/console-menu.ts +246 -0
  199. package/scripts/cli/{doctor.ts → command/doctor.ts} +257 -258
  200. package/scripts/cli/{setup.ts → command/setup.ts} +8 -12
  201. package/scripts/cli/command/start.ts +77 -0
  202. package/scripts/cli/command/tray.ts +165 -0
  203. package/scripts/cli/command/update.ts +23 -0
  204. package/scripts/cli/{runtime-state.ts → gateway/drain-token.ts} +78 -78
  205. package/scripts/cli/gateway/instance-lock.ts +105 -0
  206. package/scripts/cli/gateway/spawn.ts +173 -0
  207. package/scripts/cli/gateway/stop.ts +51 -0
  208. package/scripts/cli/main.ts +94 -0
  209. package/scripts/cli/support/bun.ts +27 -0
  210. package/scripts/cli/support/env.ts +99 -0
  211. package/scripts/cli/support/platform.ts +64 -0
  212. package/scripts/cli/support/services.ts +88 -0
  213. package/scripts/cli/support/startup-signal.ts +23 -0
  214. package/scripts/cli/{version.ts → support/version.ts} +1 -1
  215. package/scripts/cli/tray/hidden-window.vbs +28 -0
  216. package/scripts/cli/{tray.ts → tray/index.ts} +3 -3
  217. package/scripts/cli/{tray.ps1 → tray/windows.ps1} +107 -98
  218. package/scripts/cli/ui/background.ts +110 -0
  219. package/src/boot/app.ts +23 -7
  220. package/src/boot/dependencies.ts +55 -13
  221. package/src/boot/elysia-typebox-bridge.ts +37 -0
  222. package/src/boot/lifecycle.ts +2 -0
  223. package/src/config/cooldowns.ts +138 -138
  224. package/src/config/providers.ts +31 -5
  225. package/src/config/runtime.ts +19 -3
  226. package/src/config/spec.ts +20 -0
  227. package/src/console/api-keys/contracts.ts +414 -391
  228. package/src/console/api-keys/routes.ts +880 -870
  229. package/src/console/auth/account-reset-store.ts +14 -0
  230. package/src/console/auth/account-reset.ts +3 -2
  231. package/src/console/auth/setup-service.ts +6 -3
  232. package/src/console/backup/routes.ts +8 -0
  233. package/src/console/cli-tools/contracts.ts +6 -6
  234. package/src/console/console-router.ts +5 -3
  235. package/src/console/dashboard-assets.ts +81 -11
  236. package/src/console/domain-registration.ts +9 -2
  237. package/src/console/observability/contracts.ts +475 -423
  238. package/src/console/observability/routes.ts +31 -6
  239. package/src/console/observability/store.ts +1015 -912
  240. package/src/console/providers/catalog/contracts.ts +31 -0
  241. package/src/console/providers/catalog/provider-operations.ts +8 -0
  242. package/src/console/providers/catalog/public-model-store.ts +91 -60
  243. package/src/console/providers/catalog/store.ts +1532 -1521
  244. package/src/console/providers/oauth/callback-listener.ts +18 -3
  245. package/src/console/providers/oauth/routes.ts +38 -15
  246. package/src/console/quota/account-quota-tenant.ts +9 -6
  247. package/src/console/quota/refresh.ts +19 -0
  248. package/src/console/response-compression.ts +69 -2
  249. package/src/console/routing/pools/probe-verdict.ts +72 -0
  250. package/src/console/share/share-router.ts +7 -4
  251. package/src/console/share/share-stats.ts +12 -5
  252. package/src/console/shared/read-through-cache.ts +133 -7
  253. package/src/console/updates/repository-updates.ts +39 -34
  254. package/src/console/updates/routes.ts +162 -4
  255. package/src/main.ts +3 -0
  256. package/src/network/http2-bidi.ts +85 -0
  257. package/src/network/outbound-fetch.ts +87 -24
  258. package/src/network/pool/agent.ts +478 -460
  259. package/src/network/pool/resolver.ts +1104 -544
  260. package/src/network/pool-health-machine.ts +12 -5
  261. package/src/observability/event-log.ts +29 -8
  262. package/src/observability/metrics.ts +40 -13
  263. package/src/observability/payload-capture.ts +344 -324
  264. package/src/observability/runtime-metrics.ts +7 -0
  265. package/src/persistence/db-pg.ts +9 -3
  266. package/src/persistence/db-sqlite.ts +497 -495
  267. package/src/persistence/health-event-flush.ts +137 -0
  268. package/src/persistence/migrate.ts +181 -129
  269. package/src/persistence/schema.ts +1360 -1257
  270. package/src/persistence/settings-revision.ts +25 -0
  271. package/src/persistence/shared-cache.ts +217 -0
  272. package/src/persistence/stores/share-store.ts +556 -528
  273. package/src/persistence/usage-flush.ts +61 -1
  274. package/src/protocol/canonical-model.ts +23 -1
  275. package/src/protocol/response/chat.ts +143 -10
  276. package/src/protocol/response/codex.ts +9 -75
  277. package/src/protocol/response/omp-tool-call-markup.ts +221 -0
  278. package/src/protocol/tool-identity.ts +43 -9
  279. package/src/providers/authentication/jwt-validator.ts +130 -187
  280. package/src/providers/authentication/oauth-flow-store.ts +16 -9
  281. package/src/providers/default-registry.ts +177 -1
  282. package/src/providers/discovery/catalog/catalog-format.ts +20 -0
  283. package/src/providers/discovery/models-dev-catalog.ts +39 -22
  284. package/src/providers/discovery/openai-model-discovery.ts +39 -3
  285. package/src/providers/discovery/openrouter-free-roster.ts +98 -0
  286. package/src/providers/discovery/probe-wire.ts +29 -5
  287. package/src/providers/discovery/probing-service.ts +1022 -1001
  288. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +42 -2
  289. package/src/providers/integrations/buddy/buddy-chat-shared.ts +913 -914
  290. package/src/providers/integrations/buddy/buddy-cli-headers.ts +88 -0
  291. package/src/providers/integrations/buddy/buddy-conversation-headers.ts +22 -5
  292. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +33 -1
  293. package/src/providers/integrations/buddy/buddy-identity.ts +46 -0
  294. package/src/providers/integrations/buddy/buddy-oauth-shared.ts +37 -1
  295. package/src/providers/integrations/buddy/buddy-usage-store.ts +40 -5
  296. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +4 -0
  297. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -175
  298. package/src/providers/integrations/buddy/codebuddy/shared.ts +115 -73
  299. package/src/providers/integrations/buddy/workbuddy/cn-provider.ts +5 -2
  300. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -204
  301. package/src/providers/integrations/buddy/workbuddy/quota.ts +172 -159
  302. package/src/providers/integrations/buddy/workbuddy/shared.ts +200 -171
  303. package/src/providers/integrations/claude/claude.ts +606 -582
  304. package/src/providers/integrations/cline/cline-quota.ts +18 -16
  305. package/src/providers/integrations/cline/cline.ts +539 -468
  306. package/src/providers/integrations/codex/codex.ts +5 -8
  307. package/src/providers/integrations/commandcode/commandcode.ts +675 -541
  308. package/src/providers/integrations/commandcode/discovery.ts +116 -0
  309. package/src/providers/integrations/commandcode/quota.ts +160 -0
  310. package/src/providers/integrations/cursor/cursor-auth.ts +418 -0
  311. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -0
  312. package/src/providers/integrations/cursor/cursor-http2.ts +300 -0
  313. package/src/providers/integrations/cursor/cursor-identity.ts +92 -0
  314. package/src/providers/integrations/cursor/cursor-request.ts +471 -0
  315. package/src/providers/integrations/cursor/cursor-session-manager.ts +281 -0
  316. package/src/providers/integrations/cursor/cursor-shared.ts +108 -0
  317. package/src/providers/integrations/cursor/cursor.ts +960 -0
  318. package/src/providers/integrations/cursor/protocol/framing.ts +129 -0
  319. package/src/providers/integrations/cursor/protocol/messages.ts +2355 -0
  320. package/src/providers/integrations/cursor/protocol/struct.ts +305 -0
  321. package/src/providers/integrations/factory/factory-oauth.ts +7 -11
  322. package/src/providers/integrations/github/github.ts +4 -4
  323. package/src/providers/integrations/hermes.ts +79 -8
  324. package/src/providers/integrations/kilo/kilo-discovery.ts +10 -1
  325. package/src/providers/integrations/kilo/kilo.ts +153 -146
  326. package/src/providers/integrations/opencode/opencode-go-quota.ts +130 -0
  327. package/src/providers/integrations/opencode/provider.ts +532 -499
  328. package/src/providers/integrations/openrouter/provider.ts +139 -281
  329. package/src/providers/integrations/qoder/qoder-catalog.ts +450 -0
  330. package/src/providers/integrations/qoder/qoder-oauth.ts +310 -263
  331. package/src/providers/integrations/qoder/qoder-realm.ts +106 -0
  332. package/src/providers/integrations/qoder/qoder-virtual.ts +77 -0
  333. package/src/providers/integrations/qoder/qoder.ts +865 -730
  334. package/src/providers/integrations/shared/install-id.ts +29 -2
  335. package/src/providers/integrations/trae/trae-catalog.ts +190 -0
  336. package/src/providers/integrations/trae/trae-headers.ts +149 -0
  337. package/src/providers/integrations/trae/trae-oauth.ts +773 -0
  338. package/src/providers/integrations/trae/trae-protocol.ts +400 -0
  339. package/src/providers/integrations/trae/trae-realm.ts +220 -0
  340. package/src/providers/integrations/trae/trae.ts +458 -0
  341. package/src/providers/operations/account-health-service.ts +107 -10
  342. package/src/providers/operations/client-versions.ts +143 -6
  343. package/src/providers/operations/model-catalog-cache.ts +18 -3
  344. package/src/providers/operations/provider-catalog-seeder.ts +95 -86
  345. package/src/providers/operations/provider-catalog-service.ts +76 -0
  346. package/src/providers/operations/provider-credential-service.ts +46 -7
  347. package/src/providers/provider-contracts.ts +76 -0
  348. package/src/providers/provider-metadata.ts +78 -6
  349. package/src/providers/provider-model-definition.ts +373 -349
  350. package/src/security/api-key-auth.ts +126 -8
  351. package/src/transport/contracts.ts +3 -0
  352. package/src/transport/dispatch/attempt-finalize.ts +39 -2
  353. package/src/transport/dispatch/attempt-loop.ts +530 -513
  354. package/src/transport/dispatch/empty-stream.ts +17 -26
  355. package/src/transport/dispatch/generation.ts +88 -21
  356. package/src/transport/dispatch/leases.ts +99 -2
  357. package/src/transport/dispatch/proxy-request.ts +60 -3
  358. package/src/transport/dispatch/streaming/finalize.ts +16 -1
  359. package/src/transport/dispatch/streaming/stream.ts +22 -2
  360. package/src/transport/dispatch/upstream.ts +19 -1
  361. package/src/transport/failure-policy.ts +20 -0
  362. package/src/transport/guards/auth-guard.ts +26 -1
  363. package/src/transport/guards/body-policy.ts +5 -4
  364. package/src/transport/request/preparer.ts +103 -0
  365. package/src/transport/request/state.ts +14 -0
  366. package/src/transport/routing/plan-gate.ts +45 -0
  367. package/src/transport/routing/route-catalog.ts +799 -738
  368. package/src/transport/routing/route-model.ts +52 -1
  369. package/src/transport/routing/router.ts +51 -0
  370. package/src/transport/surface/chat/encode.ts +18 -31
  371. package/src/transport/surface/messages/stream.ts +3 -2
  372. package/src/transport/translation/tool-repair.ts +37 -16
  373. package/src/workers/account-identity-sweep.ts +14 -12
  374. package/src/workers/daily-checkin.ts +62 -0
  375. package/src/workers/quota-refresh-sweep.ts +27 -2
  376. package/dist/dashboard/assets/BannedPage-DUrXpp-X.js +0 -1
  377. package/dist/dashboard/assets/CombosPage-B7ETkfVH.js +0 -1
  378. package/dist/dashboard/assets/ConsoleLogPage-AisQzCI1.js +0 -1
  379. package/dist/dashboard/assets/HealthEventsModal-BNflDm_f.js +0 -1
  380. package/dist/dashboard/assets/ModelPicker-CAiPR5Vd.js +0 -1
  381. package/dist/dashboard/assets/OverviewPage-C3NIzsDx.js +0 -1
  382. package/dist/dashboard/assets/ProviderDetailPage-BK8i5B5X.js +0 -3
  383. package/dist/dashboard/assets/ProviderIcon-paOCq8I0.js +0 -1
  384. package/dist/dashboard/assets/ProvidersPage-CvCVgm1e.js +0 -1
  385. package/dist/dashboard/assets/ProxyPage-DjabkuzP.js +0 -7
  386. package/dist/dashboard/assets/QuotaPage-Dxe0B0iu.js +0 -1
  387. package/dist/dashboard/assets/SetupPage-BaD4P_jW.js +0 -1
  388. package/dist/dashboard/assets/StudioPage-CLP4le3U.js +0 -31
  389. package/dist/dashboard/assets/UsagePage-Djes-Epy.js +0 -56
  390. package/dist/dashboard/assets/api-keys-CjIGQhe7.js +0 -1
  391. package/dist/dashboard/assets/app-Cjgo4-Lf.css +0 -2
  392. package/dist/dashboard/assets/assert-Bwh6DJTK.js +0 -1
  393. package/dist/dashboard/assets/cli-tools-DccaOfhX.js +0 -1
  394. package/dist/dashboard/assets/clipboard-button-DQmMikNQ.js +0 -1
  395. package/dist/dashboard/assets/entry-Cpi0rph1.js +0 -4
  396. package/dist/dashboard/assets/entry-Dm3PpDCO.js +0 -1
  397. package/dist/dashboard/assets/entry-keNDZ_Wz.js +0 -1
  398. package/dist/dashboard/assets/github-badge-BuAJr8oU.js +0 -1
  399. package/dist/dashboard/assets/model-bans-CVvHZ-qz.js +0 -1
  400. package/dist/dashboard/assets/mutation-BIizFZav.js +0 -1
  401. package/dist/dashboard/assets/privacy-mode-CQjbZ3xk.js +0 -1
  402. package/dist/dashboard/assets/routing-jntw9-Ui.js +0 -1
  403. package/dist/dashboard/assets/select-CGUuTQmj.js +0 -41
  404. package/dist/dashboard/assets/settings-BAS6QbmJ.js +0 -1
  405. package/dist/dashboard/assets/sliders-horizontal-ClG7dBeJ.js +0 -1
  406. package/dist/dashboard/assets/terminal-C9mZ4Ugm.js +0 -1
  407. package/dist/dashboard/assets/toast-DKAdP2mo.js +0 -2
  408. package/scripts/cli/cli.ts +0 -771
  409. package/scripts/operations/probe-egress.ts +0 -335
  410. package/scripts/operations/restart.ts +0 -120
  411. package/scripts/shared/env.ts +0 -269
  412. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.ico +0 -0
  413. /package/{tray-assets → scripts/cli/tray/assets}/cartethyia.png +0 -0
  414. /package/scripts/cli/{menu.ts → ui/prompt.ts} +0 -0
  415. /package/scripts/cli/{wordmark.ts → ui/wordmark.ts} +0 -0
@@ -1,324 +1,344 @@
1
- import type { CartethyiaDatabase } from "../persistence/db-handle";
2
- import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
3
- import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
4
- import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
5
- import { preferencesReaderFor } from "../persistence/tenant-preferences";
6
- import type { TelemetryPayloadDepth, TelemetryPayloadMode } from "../console/settings/contracts";
7
- export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
8
-
9
- /**
10
- * Settings-gated payload capture. Request-event metadata is always retained;
11
- * tenants opt into drawer capture through Settings → Privacy: `metadata`
12
- * keeps only the Proxy→Provider request line, `full` keeps redacted bodies
13
- * at the tenant's chosen depth. Fail-closed on error.
14
- */
15
- export async function resolvePayloadCapture(
16
- db: CartethyiaDatabase,
17
- tenantId: string | null,
18
- ): Promise<
19
- | { readonly ok: true; readonly mode: TelemetryPayloadMode; readonly depth: TelemetryPayloadDepth }
20
- | { readonly ok: false }
21
- > {
22
- if (!tenantId) return { ok: false };
23
- try {
24
- const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
25
- const mode = prefs?.telemetryPayloads;
26
- const depth = prefs?.telemetryPayloadDepth;
27
- return {
28
- ok: true,
29
- mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
30
- depth:
31
- depth === "minimum" || depth === "moderate" || depth === "maximum"
32
- ? depth
33
- : depth === "medium"
34
- ? "minimum"
35
- : depth === "high"
36
- ? "moderate"
37
- : depth === "full"
38
- ? "maximum"
39
- : "minimum",
40
- };
41
- } catch {
42
- // A preference read that fails must not invent capture. Off keeps nothing.
43
- // Reported as its own state rather than silently as `none`: an operator
44
- // whose capture is off by choice and one whose preference read is failing
45
- // need different fixes, and both used to look like "not configured".
46
- return { ok: false };
47
- }
48
- }
49
-
50
- /**
51
- * Whether the tenant's capture mode keeps bodies (`full`). Both the
52
- * provider-response tee and the ingress-body release key off this: `none` and
53
- * `metadata` store no client request body, so the parsed ingress body can be
54
- * dropped once the canonical request exists instead of being held until
55
- * finalize — which, for a streaming response, is the whole stream.
56
- */
57
- export async function payloadCaptureKeepsBodies(
58
- db: CartethyiaDatabase,
59
- tenantId: string | null,
60
- ): Promise<boolean> {
61
- const resolved = await resolvePayloadCapture(db, tenantId);
62
- return resolved.ok && resolved.mode === "full";
63
- }
64
-
65
- /**
66
- * Why one request's drawer payloads are empty.
67
- *
68
- * Every reason the drawer can read empty is named here, because from the
69
- * client the four panels look identical in all of them: an operator who turned
70
- * capture off and an operator whose capture is broken both see four blank
71
- * panels. Recording the reason is what makes remote diagnosis possible without
72
- * access to the server's logs.
73
- */
74
- export type PayloadCaptureState =
75
- /** Bodies were captured for this request. */
76
- | "captured"
77
- /** Only the Proxy→Provider request line was kept (mode `metadata`). */
78
- | "captured_metadata"
79
- /** The tenant has capture switched off; request metadata is still retained. */
80
- | "skipped_mode_none"
81
- /** The request had no tenant, so no capture preference could be read. */
82
- | "skipped_no_tenant"
83
- /** The attempt was not the terminal one; the terminal attempt captures. */
84
- | "skipped_not_terminal"
85
- /** The preference read failed, and capture fails closed. */
86
- | "skipped_preference_read_failed"
87
- /** The storage write failed after the decision to capture. */
88
- | "failed_write";
89
-
90
- export interface PayloadCaptureInput {
91
- tenantId: string;
92
- requestBody: unknown;
93
- responseBody: unknown;
94
- clientResponseBody?: unknown;
95
- providerRequestBody?: unknown;
96
- providerResponseBody?: unknown;
97
- /**
98
- * Combined body cap override for this capture (Settings → capture depth).
99
- * Defaults to the configured capture limit.
100
- */
101
- maxBytes?: number;
102
- scope: CaptureScope;
103
- /** Links the row to `telemetry_events.request_id`. */
104
- requestId?: string;
105
- // optional opt-in flags — exactly one must be true for `scope`, see `isCaptureAllowed`
106
- tenantOptIn?: boolean;
107
- debugSessionOptIn?: boolean;
108
- operatorFlagOptIn?: boolean;
109
- }
110
-
111
- export interface PayloadSignals {
112
- readonly toolCalls: number;
113
- readonly images: number;
114
- readonly attachments: number;
115
- }
116
-
117
- export interface StoredPayload {
118
- id: string;
119
- /**
120
- * JSON text of the five body surfaces, in drawer order, built while the
121
- * record was measured. Present only on a record that has not been persisted
122
- * yet; the frame writer consumes it so the bodies are not serialized twice.
123
- */
124
- _serialized?: readonly string[];
125
- request_id: string | null;
126
- captured_at: Date;
127
- expires_at: Date;
128
- request_body: unknown;
129
- response_body: unknown;
130
- client_response_body: unknown;
131
- provider_request_body: unknown;
132
- provider_response_body: unknown;
133
- signals: PayloadSignals;
134
- }
135
-
136
- function payloadRetentionMs(): number {
137
- const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
138
- if (!Number.isFinite(raw) || raw < 1_000 || raw > 7 * 24 * 60 * 60_000) return 15 * 60_000;
139
- return Math.floor(raw);
140
- }
141
- function isCaptureAllowed(input: PayloadCaptureInput): boolean {
142
- // Exactly one opt-in must be set, and it must be the one `scope` names:
143
- // two flags at once would silently widen capture beyond the declared scope.
144
- const optIns = [input.tenantOptIn, input.debugSessionOptIn, input.operatorFlagOptIn];
145
- if (optIns.filter(Boolean).length !== 1) return false;
146
- if (input.scope === "tenant") return input.tenantOptIn === true;
147
- if (input.scope === "debug_session") return input.debugSessionOptIn === true;
148
- if (input.scope === "operator_flag") return input.operatorFlagOptIn === true;
149
- return false;
150
- }
151
-
152
- function payloadSignals(values: readonly unknown[]): PayloadSignals {
153
- let toolCalls = 0;
154
- let images = 0;
155
- let attachments = 0;
156
- const seen = new Set<object>();
157
- const visit = (value: unknown, depth: number): void => {
158
- if (depth > 8 || value === null || typeof value !== "object") return;
159
- if (seen.has(value)) return;
160
- seen.add(value);
161
- if (Array.isArray(value)) {
162
- for (const entry of value) visit(entry, depth + 1);
163
- return;
164
- }
165
- const record = value as Record<string, unknown>;
166
- const type = typeof record["type"] === "string" ? record["type"].toLowerCase() : "";
167
- if (type === "tool_call" || type === "tool_use" || type === "function_call") toolCalls += 1;
168
- if (type === "image" || type === "input_image" || type === "output_image") images += 1;
169
- if (type === "file" || type === "document" || type === "attachment") attachments += 1;
170
- for (const [key, child] of Object.entries(record)) {
171
- const normalizedKey = key.toLowerCase();
172
- if (normalizedKey === "tool_calls" || normalizedKey === "toolcalls") {
173
- if (Array.isArray(child)) toolCalls += child.length;
174
- } else if (normalizedKey === "attachments" || normalizedKey === "files") {
175
- if (Array.isArray(child)) attachments += child.length;
176
- } else if (
177
- (normalizedKey === "image_url" ||
178
- normalizedKey === "input_image" ||
179
- normalizedKey === "output_image") &&
180
- !type.includes("image")
181
- ) {
182
- images += 1;
183
- }
184
- visit(child, depth + 1);
185
- }
186
- };
187
- for (const value of values) visit(value, 0);
188
- return { toolCalls, images, attachments };
189
- }
190
-
191
- /**
192
- * Shapes a captured payload without persisting it — a pure helper reused by
193
- * `TelemetryPayloadCapture.capture` and exercised directly in tests. Bodies
194
- * larger than `maxBytes` (combined, post-redaction, defaulting to the
195
- * configured capture limit) are dropped and replaced with a truncation
196
- * marker — no object store exists, so an oversized body is never referenced,
197
- * only accounted. Payloads remain redacted and bounded during the 15-minute
198
- * retention window.
199
- */
200
- export function buildPayloadRecord(
201
- input: PayloadCaptureInput,
202
- now = new Date(),
203
- maxBytes = resolveTelemetryPayloadMaxBytes(),
204
- ): Omit<StoredPayload, "id"> {
205
- const expiresAt = new Date(now.getTime() + payloadRetentionMs());
206
- // Each body is serialized once, in drawer order, and only while the running
207
- // total still fits the cap. The old path serialized every body up front to
208
- // measure them and then serialized the whole record again to store it, so a
209
- // capture held two copies of every body at once — measured at 23 MB of heap
210
- // for 8 MB of bodies. A body that would cross the cap is never touched: its
211
- // text is not built and its object is not walked for signals.
212
- const surfaces: ReadonlyArray<readonly [keyof StoredPayload, unknown]> = [
213
- ["request_body", input.requestBody],
214
- ["response_body", input.responseBody],
215
- ["client_response_body", input.clientResponseBody],
216
- ["provider_request_body", input.providerRequestBody],
217
- ["provider_response_body", input.providerResponseBody],
218
- ];
219
- const stored: Partial<Record<keyof StoredPayload, unknown>> = {};
220
- const serialized: string[] = [];
221
- const kept: unknown[] = [];
222
- let accounted = 0;
223
- let truncated = false;
224
- for (const [key, body] of surfaces) {
225
- if (truncated || body === undefined || body === null) {
226
- stored[key] = null;
227
- serialized.push("null");
228
- continue;
229
- }
230
- const text = JSON.stringify(body);
231
- if (accounted + text.length > maxBytes) {
232
- truncated = true;
233
- stored[key] = null;
234
- serialized.push("null");
235
- continue;
236
- }
237
- accounted += text.length;
238
- stored[key] = body;
239
- serialized.push(text);
240
- kept.push(body);
241
- }
242
- const marker = truncated
243
- ? {
244
- _truncated: true,
245
- _original_bytes: accounted,
246
- _hint: "Truncated by the capture cap — raise Settings → Telemetry payloads → Capture depth to keep more.",
247
- }
248
- : undefined;
249
- if (marker) {
250
- for (const [key] of surfaces) {
251
- if (stored[key] === null) stored[key] = marker;
252
- }
253
- }
254
- return {
255
- request_id: input.requestId ?? null,
256
- captured_at: now,
257
- expires_at: expiresAt,
258
- request_body: stored.request_body ?? null,
259
- response_body: stored.response_body ?? null,
260
- client_response_body: stored.client_response_body ?? null,
261
- provider_request_body: stored.provider_request_body ?? null,
262
- provider_response_body: stored.provider_response_body ?? null,
263
- signals: payloadSignals(kept),
264
- _serialized: serialized,
265
- };
266
- }
267
-
268
- /**
269
- * Postgres-backed redacted request/response payload capture. Payloads have a
270
- * 15-minute retention window and are automatically pruned.
271
-
272
- */
273
- export class TelemetryPayloadCapture {
274
- private readonly store: DrizzleTelemetryStore;
275
-
276
- constructor(db: CartethyiaDatabase) {
277
- this.store = new DrizzleTelemetryStore(db);
278
- }
279
-
280
- async capture(input: PayloadCaptureInput): Promise<{ id: string; expiresAt: Date }> {
281
- if (!isCaptureAllowed(input)) {
282
- throw Object.assign(new Error("payload capture not opted in"), {
283
- code: "capture_not_opted_in",
284
- });
285
- }
286
- const record = buildPayloadRecord(input, new Date(), input.maxBytes ?? resolveTelemetryPayloadMaxBytes());
287
- const reference = await writePayloadFrame(record, record.expires_at, record._serialized
288
- ? {
289
- requestId: record.request_id,
290
- capturedAt: record.captured_at.toISOString(),
291
- signals: record.signals,
292
- bodies: record._serialized,
293
- }
294
- : undefined);
295
- // Typed columns only — bodies live in the frame file. The schema has no
296
- // jsonb body column, so a body cannot be written to Postgres here.
297
- return this.store.insertPayload({
298
- tenantId: input.tenantId,
299
- requestId: input.requestId ?? null,
300
- capturedAt: record.captured_at,
301
- expiresAt: record.expires_at,
302
- storage: reference.storage,
303
- file: reference.file,
304
- offset: reference.offset,
305
- length: reference.length,
306
- checksum: reference.checksum,
307
- version: reference.version,
308
- });
309
- }
310
-
311
- async cleanupExpired(batchSize = 5000, now = new Date()): Promise<{ deleted: number }> {
312
- let deleted = 0;
313
- for (let run = 0; run < 40; run += 1) {
314
- const batch = await this.store.deleteExpiredPayloads(batchSize, now);
315
- deleted += batch;
316
- if (batch < batchSize) break;
317
- }
318
- // Every frame expires `payloadRetentionMs()` after it is written, so a file
319
- // last written before that window closed cannot still hold a live frame.
320
- // That bound is what makes a damaged, unparsable file reclaimable at all.
321
- deleted += await prunePayloadFrames(now, new Date(now.getTime() - payloadRetentionMs()));
322
- return { deleted };
323
- }
324
- }
1
+ import type { CartethyiaDatabase } from "../persistence/db-handle";
2
+ import { DrizzleTelemetryStore } from "../persistence/stores/telemetry-store";
3
+ import { prunePayloadFrames, writePayloadFrame } from "./payload-store";
4
+ import { resolveTelemetryPayloadMaxBytes } from "../config/telemetry";
5
+ import { preferencesReaderFor } from "../persistence/tenant-preferences";
6
+ import type { TelemetryPayloadDepth, TelemetryPayloadMode } from "../console/settings/contracts";
7
+ export type CaptureScope = "tenant" | "debug_session" | "operator_flag";
8
+
9
+ /**
10
+ * Settings-gated payload capture. Request-event metadata is always retained;
11
+ * tenants opt into drawer capture through Settings → Privacy: `metadata`
12
+ * keeps only the Proxy→Provider request line, `full` keeps redacted bodies
13
+ * at the tenant's chosen depth. Fail-closed on error.
14
+ */
15
+ export async function resolvePayloadCapture(
16
+ db: CartethyiaDatabase,
17
+ tenantId: string | null,
18
+ ): Promise<
19
+ | { readonly ok: true; readonly mode: TelemetryPayloadMode; readonly depth: TelemetryPayloadDepth }
20
+ | { readonly ok: false }
21
+ > {
22
+ if (!tenantId) return { ok: false };
23
+ try {
24
+ const prefs = await preferencesReaderFor(db).readPreferences(tenantId);
25
+ const mode = prefs?.telemetryPayloads;
26
+ const depth = prefs?.telemetryPayloadDepth;
27
+ return {
28
+ ok: true,
29
+ mode: mode === "full" || mode === "metadata" || mode === "none" ? mode : "none",
30
+ depth:
31
+ depth === "minimum" || depth === "moderate" || depth === "maximum"
32
+ ? depth
33
+ : depth === "medium"
34
+ ? "minimum"
35
+ : depth === "high"
36
+ ? "moderate"
37
+ : depth === "full"
38
+ ? "maximum"
39
+ : "minimum",
40
+ };
41
+ } catch {
42
+ // A preference read that fails must not invent capture. Off keeps nothing.
43
+ // Reported as its own state rather than silently as `none`: an operator
44
+ // whose capture is off by choice and one whose preference read is failing
45
+ // need different fixes, and both used to look like "not configured".
46
+ return { ok: false };
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Whether the tenant's capture mode keeps bodies (`full`). Both the
52
+ * provider-response tee and the ingress-body release key off this: `none` and
53
+ * `metadata` store no client request body, so the parsed ingress body can be
54
+ * dropped once the canonical request exists instead of being held until
55
+ * finalize — which, for a streaming response, is the whole stream.
56
+ */
57
+ export async function payloadCaptureKeepsBodies(
58
+ db: CartethyiaDatabase,
59
+ tenantId: string | null,
60
+ ): Promise<boolean> {
61
+ const resolved = await resolvePayloadCapture(db, tenantId);
62
+ return resolved.ok && resolved.mode === "full";
63
+ }
64
+
65
+ /**
66
+ * Why one request's drawer payloads are empty.
67
+ *
68
+ * Every reason the drawer can read empty is named here, because from the
69
+ * client the four panels look identical in all of them: an operator who turned
70
+ * capture off and an operator whose capture is broken both see four blank
71
+ * panels. Recording the reason is what makes remote diagnosis possible without
72
+ * access to the server's logs.
73
+ */
74
+ export type PayloadCaptureState =
75
+ /** Bodies were captured for this request. */
76
+ | "captured"
77
+ /** Only the Proxy→Provider request line was kept (mode `metadata`). */
78
+ | "captured_metadata"
79
+ /** The tenant has capture switched off; request metadata is still retained. */
80
+ | "skipped_mode_none"
81
+ /** The request had no tenant, so no capture preference could be read. */
82
+ | "skipped_no_tenant"
83
+ /** The attempt was not the terminal one; the terminal attempt captures. */
84
+ | "skipped_not_terminal"
85
+ /** The preference read failed, and capture fails closed. */
86
+ | "skipped_preference_read_failed"
87
+ /** The storage write failed after the decision to capture. */
88
+ | "failed_write";
89
+
90
+ export interface PayloadCaptureInput {
91
+ tenantId: string;
92
+ requestBody: unknown;
93
+ responseBody: unknown;
94
+ clientResponseBody?: unknown;
95
+ providerRequestBody?: unknown;
96
+ providerResponseBody?: unknown;
97
+ /**
98
+ * Combined body cap override for this capture (Settings → capture depth).
99
+ * Defaults to the configured capture limit.
100
+ */
101
+ maxBytes?: number;
102
+ scope: CaptureScope;
103
+ /** Links the row to `telemetry_events.request_id`. */
104
+ requestId?: string;
105
+ // optional opt-in flags — exactly one must be true for `scope`, see `isCaptureAllowed`
106
+ tenantOptIn?: boolean;
107
+ debugSessionOptIn?: boolean;
108
+ operatorFlagOptIn?: boolean;
109
+ }
110
+
111
+ export interface PayloadSignals {
112
+ readonly toolCalls: number;
113
+ readonly images: number;
114
+ readonly attachments: number;
115
+ }
116
+
117
+ export interface StoredPayload {
118
+ id: string;
119
+ /**
120
+ * JSON text of the five body surfaces, in drawer order, built while the
121
+ * record was measured. Present only on a record that has not been persisted
122
+ * yet; the frame writer consumes it so the bodies are not serialized twice.
123
+ */
124
+ _serialized?: readonly string[];
125
+ request_id: string | null;
126
+ captured_at: Date;
127
+ expires_at: Date;
128
+ request_body: unknown;
129
+ response_body: unknown;
130
+ client_response_body: unknown;
131
+ provider_request_body: unknown;
132
+ provider_response_body: unknown;
133
+ /**
134
+ * Bitmask of the stages whose body was kept: 1 request, 2 provider request,
135
+ * 4 provider response, 8 client response. Written to its own column so the
136
+ * Usage list can show which panels have content without inflating a frame
137
+ * per row.
138
+ */
139
+ stages: number;
140
+ signals: PayloadSignals;
141
+ }
142
+
143
+ function payloadRetentionMs(): number {
144
+ const raw = Number(process.env.CARTETHYIA_TELEMETRY_PAYLOAD_RETENTION_MS ?? 15 * 60_000);
145
+ if (!Number.isFinite(raw) || raw < 1_000 || raw > 7 * 24 * 60 * 60_000) return 15 * 60_000;
146
+ return Math.floor(raw);
147
+ }
148
+ function isCaptureAllowed(input: PayloadCaptureInput): boolean {
149
+ // Exactly one opt-in must be set, and it must be the one `scope` names:
150
+ // two flags at once would silently widen capture beyond the declared scope.
151
+ const optIns = [input.tenantOptIn, input.debugSessionOptIn, input.operatorFlagOptIn];
152
+ if (optIns.filter(Boolean).length !== 1) return false;
153
+ if (input.scope === "tenant") return input.tenantOptIn === true;
154
+ if (input.scope === "debug_session") return input.debugSessionOptIn === true;
155
+ if (input.scope === "operator_flag") return input.operatorFlagOptIn === true;
156
+ return false;
157
+ }
158
+
159
+ function payloadSignals(values: readonly unknown[]): PayloadSignals {
160
+ let toolCalls = 0;
161
+ let images = 0;
162
+ let attachments = 0;
163
+ const seen = new Set<object>();
164
+ const visit = (value: unknown, depth: number): void => {
165
+ if (depth > 8 || value === null || typeof value !== "object") return;
166
+ if (seen.has(value)) return;
167
+ seen.add(value);
168
+ if (Array.isArray(value)) {
169
+ for (const entry of value) visit(entry, depth + 1);
170
+ return;
171
+ }
172
+ const record = value as Record<string, unknown>;
173
+ const type = typeof record["type"] === "string" ? record["type"].toLowerCase() : "";
174
+ if (type === "tool_call" || type === "tool_use" || type === "function_call") toolCalls += 1;
175
+ if (type === "image" || type === "input_image" || type === "output_image") images += 1;
176
+ if (type === "file" || type === "document" || type === "attachment") attachments += 1;
177
+ for (const [key, child] of Object.entries(record)) {
178
+ const normalizedKey = key.toLowerCase();
179
+ if (normalizedKey === "tool_calls" || normalizedKey === "toolcalls") {
180
+ if (Array.isArray(child)) toolCalls += child.length;
181
+ } else if (normalizedKey === "attachments" || normalizedKey === "files") {
182
+ if (Array.isArray(child)) attachments += child.length;
183
+ } else if (
184
+ (normalizedKey === "image_url" ||
185
+ normalizedKey === "input_image" ||
186
+ normalizedKey === "output_image") &&
187
+ !type.includes("image")
188
+ ) {
189
+ images += 1;
190
+ }
191
+ visit(child, depth + 1);
192
+ }
193
+ };
194
+ for (const value of values) visit(value, 0);
195
+ return { toolCalls, images, attachments };
196
+ }
197
+
198
+ /**
199
+ * Shapes a captured payload without persisting it — a pure helper reused by
200
+ * `TelemetryPayloadCapture.capture` and exercised directly in tests. Bodies
201
+ * larger than `maxBytes` (combined, post-redaction, defaulting to the
202
+ * configured capture limit) are dropped and replaced with a truncation
203
+ * marker — no object store exists, so an oversized body is never referenced,
204
+ * only accounted. Payloads remain redacted and bounded during the 15-minute
205
+ * retention window.
206
+ */
207
+ export function buildPayloadRecord(
208
+ input: PayloadCaptureInput,
209
+ now = new Date(),
210
+ maxBytes = resolveTelemetryPayloadMaxBytes(),
211
+ ): Omit<StoredPayload, "id"> {
212
+ const expiresAt = new Date(now.getTime() + payloadRetentionMs());
213
+ // Each body is serialized once, in drawer order, and only while the running
214
+ // total still fits the cap. The old path serialized every body up front to
215
+ // measure them and then serialized the whole record again to store it, so a
216
+ // capture held two copies of every body at once — measured at 23 MB of heap
217
+ // for 8 MB of bodies. A body that would cross the cap is never touched: its
218
+ // text is not built and its object is not walked for signals.
219
+ const surfaces: ReadonlyArray<readonly [keyof StoredPayload, unknown]> = [
220
+ ["request_body", input.requestBody],
221
+ ["response_body", input.responseBody],
222
+ ["client_response_body", input.clientResponseBody],
223
+ ["provider_request_body", input.providerRequestBody],
224
+ ["provider_response_body", input.providerResponseBody],
225
+ ];
226
+ const stored: Partial<Record<keyof StoredPayload, unknown>> = {};
227
+ const serialized: string[] = [];
228
+ const kept: unknown[] = [];
229
+ let accounted = 0;
230
+ let truncated = false;
231
+ for (const [key, body] of surfaces) {
232
+ if (truncated || body === undefined || body === null) {
233
+ stored[key] = null;
234
+ serialized.push("null");
235
+ continue;
236
+ }
237
+ const text = JSON.stringify(body);
238
+ if (accounted + text.length > maxBytes) {
239
+ truncated = true;
240
+ stored[key] = null;
241
+ serialized.push("null");
242
+ continue;
243
+ }
244
+ accounted += text.length;
245
+ stored[key] = body;
246
+ serialized.push(text);
247
+ kept.push(body);
248
+ }
249
+ const marker = truncated
250
+ ? {
251
+ _truncated: true,
252
+ _original_bytes: accounted,
253
+ _hint: "Truncated by the capture cap — raise Settings → Telemetry payloads → Capture depth to keep more.",
254
+ }
255
+ : undefined;
256
+ if (marker) {
257
+ for (const [key] of surfaces) {
258
+ if (stored[key] === null) stored[key] = marker;
259
+ }
260
+ }
261
+ return {
262
+ request_id: input.requestId ?? null,
263
+ captured_at: now,
264
+ expires_at: expiresAt,
265
+ request_body: stored.request_body ?? null,
266
+ response_body: stored.response_body ?? null,
267
+ client_response_body: stored.client_response_body ?? null,
268
+ provider_request_body: stored.provider_request_body ?? null,
269
+ provider_response_body: stored.provider_response_body ?? null,
270
+ // Presence, not size: a stage counts as captured when a body was kept for
271
+ // it. `response_body` is the legacy alias of the client response, so it
272
+ // folds into that stage — it never names a panel of its own.
273
+ stages:
274
+ (stored.request_body != null ? 1 : 0) |
275
+ (stored.provider_request_body != null ? 2 : 0) |
276
+ (stored.provider_response_body != null ? 4 : 0) |
277
+ (stored.client_response_body != null || stored.response_body != null ? 8 : 0),
278
+ signals: payloadSignals(kept),
279
+ _serialized: serialized,
280
+ };
281
+ }
282
+
283
+ /**
284
+ * Postgres-backed redacted request/response payload capture. Payloads have a
285
+ * 15-minute retention window and are automatically pruned.
286
+
287
+ */
288
+ export class TelemetryPayloadCapture {
289
+ private readonly store: DrizzleTelemetryStore;
290
+
291
+ constructor(db: CartethyiaDatabase) {
292
+ this.store = new DrizzleTelemetryStore(db);
293
+ }
294
+
295
+ async capture(input: PayloadCaptureInput): Promise<{ id: string; expiresAt: Date }> {
296
+ if (!isCaptureAllowed(input)) {
297
+ throw Object.assign(new Error("payload capture not opted in"), {
298
+ code: "capture_not_opted_in",
299
+ });
300
+ }
301
+ const record = buildPayloadRecord(input, new Date(), input.maxBytes ?? resolveTelemetryPayloadMaxBytes());
302
+ const reference = await writePayloadFrame(record, record.expires_at, record._serialized
303
+ ? {
304
+ requestId: record.request_id,
305
+ capturedAt: record.captured_at.toISOString(),
306
+ signals: record.signals,
307
+ bodies: record._serialized,
308
+ }
309
+ : undefined);
310
+ // Typed columns only — bodies live in the frame file. The schema has no
311
+ // jsonb body column, so a body cannot be written to Postgres here.
312
+ return this.store.insertPayload({
313
+ tenantId: input.tenantId,
314
+ requestId: input.requestId ?? null,
315
+ capturedAt: record.captured_at,
316
+ expiresAt: record.expires_at,
317
+ storage: reference.storage,
318
+ file: reference.file,
319
+ offset: reference.offset,
320
+ length: reference.length,
321
+ checksum: reference.checksum,
322
+ version: reference.version,
323
+ // Presence travels with the row so the Usage list can read it without
324
+ // inflating the frame. Built by `buildPayloadRecord`, which always sets
325
+ // it; the guard keeps the insert honest if a caller supplies a record
326
+ // from before the field existed.
327
+ ...(Number.isInteger(record.stages) ? { stages: record.stages } : {}),
328
+ });
329
+ }
330
+
331
+ async cleanupExpired(batchSize = 5000, now = new Date()): Promise<{ deleted: number }> {
332
+ let deleted = 0;
333
+ for (let run = 0; run < 40; run += 1) {
334
+ const batch = await this.store.deleteExpiredPayloads(batchSize, now);
335
+ deleted += batch;
336
+ if (batch < batchSize) break;
337
+ }
338
+ // Every frame expires `payloadRetentionMs()` after it is written, so a file
339
+ // last written before that window closed cannot still hold a live frame.
340
+ // That bound is what makes a damaged, unparsable file reclaimable at all.
341
+ deleted += await prunePayloadFrames(now, new Date(now.getTime() - payloadRetentionMs()));
342
+ return { deleted };
343
+ }
344
+ }