cartethyia 2.5.3 → 2.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (262) hide show
  1. package/dist/dashboard/assets/{BannedPage-hfCzi-Ux.js → BannedPage-C2orBsI2.js} +1 -1
  2. package/dist/dashboard/assets/BannedPage-C2orBsI2.js.gz +0 -0
  3. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js +1 -0
  4. package/dist/dashboard/assets/CliToolDetailPage-DUcZ6pMB.js.gz +0 -0
  5. package/dist/dashboard/assets/{CliToolsPage-DEDyKBlC.js → CliToolsPage-BV6pxVQo.js} +1 -1
  6. package/dist/dashboard/assets/CliToolsPage-BV6pxVQo.js.gz +0 -0
  7. package/dist/dashboard/assets/{CombosPage-CfuYiQ8Z.js → CombosPage-Bsan4679.js} +1 -1
  8. package/dist/dashboard/assets/CombosPage-Bsan4679.js.gz +0 -0
  9. package/dist/dashboard/assets/{ConsoleLogPage-bsLuZmYH.js → ConsoleLogPage-CW-wPmJ6.js} +1 -1
  10. package/dist/dashboard/assets/ConsoleLogPage-CW-wPmJ6.js.gz +0 -0
  11. package/dist/dashboard/assets/{CustomizationPage-_UWhwziJ.js → CustomizationPage-0p8uX7vB.js} +1 -1
  12. package/dist/dashboard/assets/CustomizationPage-0p8uX7vB.js.gz +0 -0
  13. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js +1 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BjqSMfko.js.gz +0 -0
  15. package/dist/dashboard/assets/{LoginPage-DDVIfZGW.js → LoginPage-BvzCx8Kr.js} +1 -1
  16. package/dist/dashboard/assets/LoginPage-BvzCx8Kr.js.gz +0 -0
  17. package/dist/dashboard/assets/{ModelPicker-CWkTUtpX.js → ModelPicker-BY1aKEkP.js} +1 -1
  18. package/dist/dashboard/assets/ModelPicker-BY1aKEkP.js.gz +0 -0
  19. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js +1 -0
  20. package/dist/dashboard/assets/OverviewPage-DOQpy1y0.js.gz +0 -0
  21. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js +3 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-ecxyUMzt.js.gz +0 -0
  23. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js +1 -0
  24. package/dist/dashboard/assets/ProvidersPage-Js9jUPSb.js.gz +0 -0
  25. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js +7 -0
  26. package/dist/dashboard/assets/ProxyPage-J3TBi3Kx.js.gz +0 -0
  27. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js +1 -0
  28. package/dist/dashboard/assets/QuotaPage-C1gZS6xr.js.gz +0 -0
  29. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js +1 -0
  30. package/dist/dashboard/assets/SettingsPage-CS86ho5X.js.gz +0 -0
  31. package/dist/dashboard/assets/{SetupPage-D6pmQ86L.js → SetupPage-DDbMQsoo.js} +1 -1
  32. package/dist/dashboard/assets/SetupPage-DDbMQsoo.js.gz +0 -0
  33. package/dist/dashboard/assets/{SortableList-DWvKiuSl.js → SortableList-CB3Z7VTf.js} +1 -1
  34. package/dist/dashboard/assets/SortableList-CB3Z7VTf.js.gz +0 -0
  35. package/dist/dashboard/assets/{StudioPage-CmkUrgdj.js → StudioPage-D95UCPHI.js} +3 -3
  36. package/dist/dashboard/assets/StudioPage-D95UCPHI.js.gz +0 -0
  37. package/dist/dashboard/assets/{UsagePage-B47uOydr.js → UsagePage-Cnae9SWA.js} +13 -13
  38. package/dist/dashboard/assets/UsagePage-Cnae9SWA.js.gz +0 -0
  39. package/dist/dashboard/assets/{api-keys-uNywYlfv.js → api-keys-VY68Mhd1.js} +1 -1
  40. package/dist/dashboard/assets/api-keys-VY68Mhd1.js.gz +0 -0
  41. package/dist/dashboard/assets/app-COlK95oi.css +2 -0
  42. package/dist/dashboard/assets/app-COlK95oi.css.gz +0 -0
  43. package/dist/dashboard/assets/{assert-NHwYq95t.js → assert-Chi7pCnt.js} +1 -1
  44. package/dist/dashboard/assets/assert-Chi7pCnt.js.gz +0 -0
  45. package/dist/dashboard/assets/{badge-6uzDDvqs.js → badge-D4pQapKj.js} +1 -1
  46. package/dist/dashboard/assets/badge-D4pQapKj.js.gz +0 -0
  47. package/dist/dashboard/assets/{banner-4_WNg2WZ.js → banner-DH6cg-_B.js} +1 -1
  48. package/dist/dashboard/assets/banner-DH6cg-_B.js.gz +0 -0
  49. package/dist/dashboard/assets/{card--AtFedAq.js → card-BKqlUOe4.js} +1 -1
  50. package/dist/dashboard/assets/card-BKqlUOe4.js.gz +0 -0
  51. package/dist/dashboard/assets/{cli-tools-B3e7dx3N.js → cli-tools-fTgljTGA.js} +1 -1
  52. package/dist/dashboard/assets/cli-tools-fTgljTGA.js.gz +0 -0
  53. package/dist/dashboard/assets/{clipboard-button-NreUx6c8.js → clipboard-button-D5Ys_sM0.js} +1 -1
  54. package/dist/dashboard/assets/clipboard-button-D5Ys_sM0.js.gz +0 -0
  55. package/dist/dashboard/assets/database-Dz9N3zGd.js +1 -0
  56. package/dist/dashboard/assets/database-Dz9N3zGd.js.gz +0 -0
  57. package/dist/dashboard/assets/{dialog-BtpsJU98.js → dialog-BYj-8WuK.js} +1 -1
  58. package/dist/dashboard/assets/dialog-BYj-8WuK.js.gz +0 -0
  59. package/dist/dashboard/assets/{drawer-WwwtzihF.js → drawer-CmXIZ6H1.js} +1 -1
  60. package/dist/dashboard/assets/drawer-CmXIZ6H1.js.gz +0 -0
  61. package/dist/dashboard/assets/entry-CBg5zeu8.js +1 -0
  62. package/dist/dashboard/assets/entry-CBg5zeu8.js.gz +0 -0
  63. package/dist/dashboard/assets/entry-CQBr6U9c.js +1 -0
  64. package/dist/dashboard/assets/entry-CQBr6U9c.js.gz +0 -0
  65. package/dist/dashboard/assets/entry-CgdSAzEV.js +4 -0
  66. package/dist/dashboard/assets/entry-CgdSAzEV.js.gz +0 -0
  67. package/dist/dashboard/assets/{github-badge-D_eM3IgG.js → github-badge-BlptL8Uf.js} +1 -1
  68. package/dist/dashboard/assets/github-badge-BlptL8Uf.js.gz +0 -0
  69. package/dist/dashboard/assets/{index-BydyKaDz.js → index-js6TJvdr.js} +2 -2
  70. package/dist/dashboard/assets/index-js6TJvdr.js.gz +0 -0
  71. package/dist/dashboard/assets/{inline-BcNGF8Gr.js → inline-DUWDSmDV.js} +1 -1
  72. package/dist/dashboard/assets/inline-DUWDSmDV.js.gz +0 -0
  73. package/dist/dashboard/assets/{input-BG41et9G.js → input-LVjgZq4f.js} +1 -1
  74. package/dist/dashboard/assets/input-LVjgZq4f.js.gz +0 -0
  75. package/dist/dashboard/assets/{layout-C8mer_Ak.js → layout-CPfxpsfy.js} +1 -1
  76. package/dist/dashboard/assets/layout-CPfxpsfy.js.gz +0 -0
  77. package/dist/dashboard/assets/{list-row-Gh_W4OR_.js → list-row-DupvLfDx.js} +1 -1
  78. package/dist/dashboard/assets/list-row-DupvLfDx.js.gz +0 -0
  79. package/dist/dashboard/assets/{live-BJGYRBnR.js → live-C4pEJUqI.js} +1 -1
  80. package/dist/dashboard/assets/live-C4pEJUqI.js.gz +0 -0
  81. package/dist/dashboard/assets/model-bans-BRiuufkB.js +1 -0
  82. package/dist/dashboard/assets/model-bans-BRiuufkB.js.gz +0 -0
  83. package/dist/dashboard/assets/{mutation-BkoLM5Ba.js → mutation-B4ROwk8L.js} +1 -1
  84. package/dist/dashboard/assets/mutation-B4ROwk8L.js.gz +0 -0
  85. package/dist/dashboard/assets/numbers-YNcHvRY7.js +2 -0
  86. package/dist/dashboard/assets/numbers-YNcHvRY7.js.gz +0 -0
  87. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js → query-keys-gmjzC3RS.js} +1 -1
  88. package/dist/dashboard/assets/{query-keys-D7wX2vM5.js.gz → query-keys-gmjzC3RS.js.gz} +0 -0
  89. package/dist/dashboard/assets/{routing-CcusqXA7.js → routing-C7tBT5o-.js} +1 -1
  90. package/dist/dashboard/assets/routing-C7tBT5o-.js.gz +0 -0
  91. package/dist/dashboard/assets/{select-BO6h8yNv.js → select-WUCXorXw.js} +2 -2
  92. package/dist/dashboard/assets/select-WUCXorXw.js.gz +0 -0
  93. package/dist/dashboard/assets/{settings-mpmXTgUa.js → settings-C_zKl1GS.js} +1 -1
  94. package/dist/dashboard/assets/settings-C_zKl1GS.js.gz +0 -0
  95. package/dist/dashboard/assets/{stack-DwLmjUFK.js → stack-_3O0F8Xn.js} +1 -1
  96. package/dist/dashboard/assets/stack-_3O0F8Xn.js.gz +0 -0
  97. package/dist/dashboard/assets/{state-DYdQmdAl.js → state-BgTxHcJS.js} +1 -1
  98. package/dist/dashboard/assets/state-BgTxHcJS.js.gz +0 -0
  99. package/dist/dashboard/assets/switch-DEjTtOWf.js +1 -0
  100. package/dist/dashboard/assets/switch-DEjTtOWf.js.gz +0 -0
  101. package/dist/dashboard/assets/terminal-0_IjPWTk.js +1 -0
  102. package/dist/dashboard/assets/terminal-0_IjPWTk.js.gz +0 -0
  103. package/dist/dashboard/assets/{useMutation-w4u4CVue.js → useMutation-BWSC0UAS.js} +1 -1
  104. package/dist/dashboard/assets/useMutation-BWSC0UAS.js.gz +0 -0
  105. package/dist/dashboard/assets/zap-C4EeVW4q.js +1 -0
  106. package/dist/dashboard/assets/zap-C4EeVW4q.js.gz +0 -0
  107. package/dist/dashboard/donation/qris-risun-marke.png +0 -0
  108. package/dist/dashboard/index.html +1 -1
  109. package/dist/dashboard/index.html.gz +0 -0
  110. package/dist/dashboard/precompressed-assets.json +54 -54
  111. package/package.json +1 -1
  112. package/scripts/cli/autostart.ts +237 -0
  113. package/scripts/cli/command/console-menu.ts +4 -8
  114. package/scripts/cli/command/update.ts +27 -10
  115. package/scripts/cli/main.ts +28 -1
  116. package/src/boot/app.ts +693 -693
  117. package/src/boot/dependencies.ts +646 -616
  118. package/src/config/spec.ts +55 -0
  119. package/src/config/telemetry.ts +46 -0
  120. package/src/console/api-keys/bucket-spend.ts +15 -4
  121. package/src/console/backup/contracts.ts +15 -2
  122. package/src/console/console-router.ts +8 -0
  123. package/src/console/data-management/contracts.ts +93 -0
  124. package/src/console/data-management/operations.ts +229 -0
  125. package/src/console/data-management/routes.ts +104 -0
  126. package/src/console/domain-registration.ts +39 -0
  127. package/src/console/observability/contracts.ts +524 -485
  128. package/src/console/observability/store.ts +1115 -1018
  129. package/src/console/providers/catalog/contracts.ts +6 -0
  130. package/src/console/providers/catalog/provider-operations.ts +20 -0
  131. package/src/console/providers/catalog/routes.ts +7 -0
  132. package/src/console/providers/catalog/store.ts +22 -1
  133. package/src/console/quota/account-quota-shared.ts +52 -39
  134. package/src/console/quota/refresh.ts +441 -429
  135. package/src/console/routing/pools/import-parse.ts +206 -0
  136. package/src/console/routing/pools/operations.ts +543 -501
  137. package/src/console/routing/pools/routes.ts +181 -164
  138. package/src/console/settings/contracts.ts +37 -0
  139. package/src/console/settings/store.ts +51 -0
  140. package/src/console/updates/routes.ts +16 -0
  141. package/src/main.ts +10 -0
  142. package/src/observability/event-compaction.ts +315 -0
  143. package/src/observability/event-day-index.ts +442 -0
  144. package/src/observability/event-log.ts +152 -3
  145. package/src/observability/payload-capture.ts +8 -1
  146. package/src/observability/payload-store.ts +440 -376
  147. package/src/persistence/health-event-flush.ts +36 -0
  148. package/src/persistence/schema.ts +1402 -1360
  149. package/src/persistence/stores/telemetry-store.ts +237 -214
  150. package/src/protocol/codecs.ts +1 -2
  151. package/src/protocol/image-clamp.ts +19 -1
  152. package/src/protocol/registry.ts +8 -0
  153. package/src/protocol/request/gemini.ts +9 -2
  154. package/src/protocol/request/messages.ts +14 -2
  155. package/src/providers/adapters/compatible-adapter.ts +3 -0
  156. package/src/providers/bundled-discovery-providers.ts +122 -119
  157. package/src/providers/configured-openai-providers.ts +136 -134
  158. package/src/providers/default-registry.ts +5 -4
  159. package/src/providers/discovery/openai-model-discovery.ts +5 -0
  160. package/src/providers/discovery/openrouter-free-roster.ts +15 -17
  161. package/src/providers/integrations/cline/cline-quota.ts +132 -125
  162. package/src/providers/integrations/codex/codex-headers.ts +25 -39
  163. package/src/providers/integrations/codex/codex.ts +2 -29
  164. package/src/providers/integrations/cursor/cursor-quota.ts +310 -0
  165. package/src/providers/integrations/cursor/cursor-request.ts +616 -471
  166. package/src/providers/integrations/cursor/cursor-session-manager.ts +319 -281
  167. package/src/providers/integrations/cursor/cursor.ts +1485 -960
  168. package/src/providers/integrations/cursor/protocol/messages.ts +25 -0
  169. package/src/providers/integrations/factory/factory.ts +8 -1
  170. package/src/providers/integrations/gemini.ts +6 -1
  171. package/src/providers/integrations/grok/grok-quota.ts +279 -177
  172. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -0
  173. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -0
  174. package/src/providers/integrations/opencode/provider.ts +12 -13
  175. package/src/providers/integrations/qoder/qoder-quota.ts +192 -0
  176. package/src/providers/integrations/trae/trae-quota.ts +384 -0
  177. package/src/providers/operations/account-health-service.ts +1523 -1480
  178. package/src/providers/provider-contracts.ts +7 -0
  179. package/src/providers/provider-metadata.ts +6 -0
  180. package/src/providers/quota/quota-support.ts +8 -0
  181. package/src/security/api-key-auth.ts +504 -494
  182. package/src/transport/dispatch/generation.ts +6 -0
  183. package/src/transport/dispatch/proxy-request.ts +24 -1
  184. package/src/transport/dispatch/ratelimit-capture.ts +37 -0
  185. package/src/transport/dispatch/streaming/stream.ts +14 -0
  186. package/src/transport/dispatch/upstream.ts +9 -0
  187. package/src/transport/request/preparer.ts +7 -6
  188. package/src/transport/request/state.ts +9 -0
  189. package/src/transport/request/tenant-preferences.ts +60 -0
  190. package/src/transport/routing/route-catalog.ts +58 -10
  191. package/dist/dashboard/assets/BannedPage-hfCzi-Ux.js.gz +0 -0
  192. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js +0 -1
  193. package/dist/dashboard/assets/CliToolDetailPage-DLGqYEwI.js.gz +0 -0
  194. package/dist/dashboard/assets/CliToolsPage-DEDyKBlC.js.gz +0 -0
  195. package/dist/dashboard/assets/CombosPage-CfuYiQ8Z.js.gz +0 -0
  196. package/dist/dashboard/assets/ConsoleLogPage-bsLuZmYH.js.gz +0 -0
  197. package/dist/dashboard/assets/CustomizationPage-_UWhwziJ.js.gz +0 -0
  198. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js +0 -1
  199. package/dist/dashboard/assets/HealthEventsModal-Cjfc5WFS.js.gz +0 -0
  200. package/dist/dashboard/assets/LoginPage-DDVIfZGW.js.gz +0 -0
  201. package/dist/dashboard/assets/ModelPicker-CWkTUtpX.js.gz +0 -0
  202. package/dist/dashboard/assets/OverviewPage-B53YYMng.js +0 -1
  203. package/dist/dashboard/assets/OverviewPage-B53YYMng.js.gz +0 -0
  204. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js +0 -3
  205. package/dist/dashboard/assets/ProviderDetailPage-B0KaAQf0.js.gz +0 -0
  206. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js +0 -1
  207. package/dist/dashboard/assets/ProvidersPage-Dx31DgIO.js.gz +0 -0
  208. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js +0 -7
  209. package/dist/dashboard/assets/ProxyPage-BpnR4kXT.js.gz +0 -0
  210. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js +0 -1
  211. package/dist/dashboard/assets/QuotaPage-BpOQCnun.js.gz +0 -0
  212. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js +0 -1
  213. package/dist/dashboard/assets/SettingsPage-DV1eo2J_.js.gz +0 -0
  214. package/dist/dashboard/assets/SetupPage-D6pmQ86L.js.gz +0 -0
  215. package/dist/dashboard/assets/SortableList-DWvKiuSl.js.gz +0 -0
  216. package/dist/dashboard/assets/StudioPage-CmkUrgdj.js.gz +0 -0
  217. package/dist/dashboard/assets/UsagePage-B47uOydr.js.gz +0 -0
  218. package/dist/dashboard/assets/api-keys-uNywYlfv.js.gz +0 -0
  219. package/dist/dashboard/assets/app-DxivpfYG.css +0 -2
  220. package/dist/dashboard/assets/app-DxivpfYG.css.gz +0 -0
  221. package/dist/dashboard/assets/assert-NHwYq95t.js.gz +0 -0
  222. package/dist/dashboard/assets/badge-6uzDDvqs.js.gz +0 -0
  223. package/dist/dashboard/assets/banner-4_WNg2WZ.js.gz +0 -0
  224. package/dist/dashboard/assets/card--AtFedAq.js.gz +0 -0
  225. package/dist/dashboard/assets/cli-tools-B3e7dx3N.js.gz +0 -0
  226. package/dist/dashboard/assets/clipboard-button-NreUx6c8.js.gz +0 -0
  227. package/dist/dashboard/assets/dialog-BtpsJU98.js.gz +0 -0
  228. package/dist/dashboard/assets/download-A0SW9OCW.js +0 -1
  229. package/dist/dashboard/assets/download-A0SW9OCW.js.gz +0 -0
  230. package/dist/dashboard/assets/drawer-WwwtzihF.js.gz +0 -0
  231. package/dist/dashboard/assets/entry-CkryrIea.js +0 -4
  232. package/dist/dashboard/assets/entry-CkryrIea.js.gz +0 -0
  233. package/dist/dashboard/assets/entry-TJIye_jT.js +0 -1
  234. package/dist/dashboard/assets/entry-TJIye_jT.js.gz +0 -0
  235. package/dist/dashboard/assets/entry-jy9_4-Ke.js +0 -1
  236. package/dist/dashboard/assets/entry-jy9_4-Ke.js.gz +0 -0
  237. package/dist/dashboard/assets/github-badge-D_eM3IgG.js.gz +0 -0
  238. package/dist/dashboard/assets/index-BydyKaDz.js.gz +0 -0
  239. package/dist/dashboard/assets/inline-BcNGF8Gr.js.gz +0 -0
  240. package/dist/dashboard/assets/input-BG41et9G.js.gz +0 -0
  241. package/dist/dashboard/assets/layout-C8mer_Ak.js.gz +0 -0
  242. package/dist/dashboard/assets/list-row-Gh_W4OR_.js.gz +0 -0
  243. package/dist/dashboard/assets/live-BJGYRBnR.js.gz +0 -0
  244. package/dist/dashboard/assets/model-bans-DeVLDA7I.js +0 -1
  245. package/dist/dashboard/assets/model-bans-DeVLDA7I.js.gz +0 -0
  246. package/dist/dashboard/assets/mutation-BkoLM5Ba.js.gz +0 -0
  247. package/dist/dashboard/assets/numbers-Bs4LOOuf.js +0 -2
  248. package/dist/dashboard/assets/numbers-Bs4LOOuf.js.gz +0 -0
  249. package/dist/dashboard/assets/routing-CcusqXA7.js.gz +0 -0
  250. package/dist/dashboard/assets/select-BO6h8yNv.js.gz +0 -0
  251. package/dist/dashboard/assets/settings-mpmXTgUa.js.gz +0 -0
  252. package/dist/dashboard/assets/sparkles-BRaRUxAU.js +0 -1
  253. package/dist/dashboard/assets/sparkles-BRaRUxAU.js.gz +0 -0
  254. package/dist/dashboard/assets/stack-DwLmjUFK.js.gz +0 -0
  255. package/dist/dashboard/assets/state-DYdQmdAl.js.gz +0 -0
  256. package/dist/dashboard/assets/switch-CidSfbG7.js +0 -1
  257. package/dist/dashboard/assets/switch-CidSfbG7.js.gz +0 -0
  258. package/dist/dashboard/assets/terminal-Bd4ee8TA.js +0 -1
  259. package/dist/dashboard/assets/terminal-Bd4ee8TA.js.gz +0 -0
  260. package/dist/dashboard/assets/useMutation-w4u4CVue.js.gz +0 -0
  261. /package/dist/dashboard/assets/{app-CafWtVXA.js → app-DCQZRmHY.js} +0 -0
  262. /package/dist/dashboard/assets/{app-CafWtVXA.js.gz → app-DCQZRmHY.js.gz} +0 -0
@@ -1,214 +1,237 @@
1
- import { appendFile, mkdir, readdir, rm } from "node:fs/promises";
2
- import { join } from "node:path";
3
- import { sql } from "drizzle-orm";
4
- import { affectedRows, currentTimestamp, laterOf } from "../dialect";
5
- import type { CartethyiaDatabase } from "../db-handle";
6
- import {
7
- telemetryEvents,
8
- telemetryPayloads,
9
- telemetryUsageTotals,
10
- type TelemetryUsageIdentityType,
11
- } from "../schema";
12
- import { isGatewayError } from "../../observability/telemetry-status";
13
-
14
- interface UsageTotalDelta {
15
- readonly tenantId: string;
16
- readonly identityType: TelemetryUsageIdentityType;
17
- readonly entityId: string;
18
- requests: number;
19
- errors: number;
20
- inputTokens: number;
21
- outputTokens: number;
22
- }
23
-
24
- function addUsageDelta(
25
- deltas: Map<string, UsageTotalDelta>,
26
- row: typeof telemetryEvents.$inferInsert,
27
- identityType: TelemetryUsageIdentityType,
28
- entityId: string | null | undefined,
29
- ): void {
30
- if (entityId == null) return;
31
- const key = `${row.tenantId}\0${identityType}\0${entityId}`;
32
- let delta = deltas.get(key);
33
- if (!delta) {
34
- delta = {
35
- tenantId: row.tenantId,
36
- identityType,
37
- entityId,
38
- requests: 0,
39
- errors: 0,
40
- inputTokens: 0,
41
- outputTokens: 0,
42
- };
43
- deltas.set(key, delta);
44
- }
45
- delta.requests += 1;
46
- // Same definition as every read-side error count, including the HTTP-status
47
- // exclusions (404/499/503/504): this rollup is durable, so a request counted here
48
- // stays counted after its raw row is pruned and cannot be corrected later.
49
- if (isGatewayError(row.status, row.httpStatus)) delta.errors += 1;
50
- delta.inputTokens += row.inputTokens ?? 0;
51
- delta.outputTokens += row.outputTokens ?? 0;
52
- }
53
-
54
- function aggregateUsage(rows: readonly (typeof telemetryEvents.$inferInsert)[]) {
55
- const deltas = new Map<string, UsageTotalDelta>();
56
- for (const row of rows) {
57
- addUsageDelta(deltas, row, "account", row.accountId);
58
- addUsageDelta(deltas, row, "api_key", row.apiKeyId);
59
- }
60
- return [...deltas.values()];
61
- }
62
-
63
- /**
64
- * Database boundary for durable telemetry writes and bounded retention cleanup.
65
- * The observability buffer owns queueing and retry policy; this store owns table
66
- * operations, lifetime aggregates, and retention.
67
- */
68
-
69
- const EVENT_DIR = "./data/telemetry-events";
70
- const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
71
-
72
- /**
73
- * Deletes hourly event files whose whole hour is older than the retention
74
- * cutoff. The current hour's file is never touched: it is still being appended.
75
- */
76
- async function pruneEventFiles(before: Date): Promise<void> {
77
- const files = await readdir(EVENT_DIR).catch(() => [] as string[]);
78
- const cutoff = before.toISOString().slice(0, 13).replaceAll(":", "-");
79
- for (const file of files) {
80
- if (!file.endsWith(".jsonl")) continue;
81
- const hour = file.slice(0, 13);
82
- if (hour < cutoff) await rm(join(EVENT_DIR, file), { force: true });
83
- }
84
- }
85
-
86
- /** Appends one JSON line per event. One file per hour, so retention is a file delete. */
87
- async function appendEvents(rows: readonly (typeof telemetryEvents.$inferInsert)[]): Promise<void> {
88
- await mkdir(EVENT_DIR, { recursive: true });
89
- const file = join(EVENT_DIR, new Date().toISOString().slice(0, 13).replaceAll(":", "-") + ".jsonl");
90
- const newline = String.fromCharCode(10);
91
- const body = rows.map((row) => JSON.stringify(row)).join(newline) + newline;
92
- await appendFile(file, body, "utf8");
93
- }
94
-
95
- export class DrizzleTelemetryStore {
96
- constructor(private readonly db: CartethyiaDatabase) {}
97
-
98
- /** Inserts events and their durable identity aggregates atomically. */
99
- async insertEvents(rows: Array<typeof telemetryEvents.$inferInsert>): Promise<void> {
100
- if (rows.length === 0) return;
101
- // The file accepts anything, so a malformed row would be written and then
102
- // retried forever without ever failing. Rejecting it here keeps the buffer's
103
- // drop-and-count contract: one bad row fails the batch, once.
104
- for (const row of rows) {
105
- if (!UUID_RE.test(row.tenantId)) {
106
- throw new Error("telemetry event rejected: tenantId is not a uuid");
107
- }
108
- }
109
- const deltas = aggregateUsage(rows);
110
- // The per-request row goes to an append-only file, not to Postgres. The
111
- // database keeps only the durable aggregate below, so a request never
112
- // adds a row to the largest table. Readers of recent requests read the
113
- // file; readers of long-term totals read the aggregate.
114
- await this.db.transaction(async (tx) => {
115
- if (deltas.length === 0) return;
116
- const now = new Date();
117
- await tx
118
- .insert(telemetryUsageTotals)
119
- .values(deltas.map((delta) => ({ ...delta, lastUsedAt: now })))
120
- .onConflictDoUpdate({
121
- target: [
122
- telemetryUsageTotals.tenantId,
123
- telemetryUsageTotals.identityType,
124
- telemetryUsageTotals.entityId,
125
- ],
126
- set: {
127
- requests: sql`${telemetryUsageTotals.requests} + excluded.requests`,
128
- errors: sql`${telemetryUsageTotals.errors} + excluded.errors`,
129
- inputTokens: sql`${telemetryUsageTotals.inputTokens} + excluded.input_tokens`,
130
- outputTokens: sql`${telemetryUsageTotals.outputTokens} + excluded.output_tokens`,
131
- lastUsedAt: laterOf(sql`${telemetryUsageTotals.lastUsedAt}`, sql`excluded.last_used_at`),
132
- updatedAt: currentTimestamp(),
133
- },
134
- });
135
- });
136
- // After the aggregate commits, so a rejected batch is retried once, not
137
- // appended once per attempt. The file is the record of requests whose
138
- // totals were actually counted.
139
- await appendEvents(rows);
140
- }
141
-
142
- /** Inserts one redacted payload and returns its generated identity. */
143
- async insertPayload(
144
- row: typeof telemetryPayloads.$inferInsert,
145
- ): Promise<{ id: string; expiresAt: Date }> {
146
- const [inserted] = await this.db
147
- .insert(telemetryPayloads)
148
- .values(row)
149
- .returning({ id: telemetryPayloads.id, expiresAt: telemetryPayloads.expiresAt });
150
- if (!inserted) throw new Error("payload capture insert returned no row");
151
- return inserted;
152
- }
153
-
154
- /**
155
- * Deletes at most one bounded batch of expired payloads in a single
156
- * statement, so concurrent sweeps atomically claim disjoint rows and the
157
- * returned count matches the rows actually deleted.
158
- */
159
- async deleteExpiredPayloads(batchSize = 5000, before = new Date()): Promise<number> {
160
- const result = await this.db.execute(sql`
161
- DELETE FROM ${telemetryPayloads}
162
- WHERE ${telemetryPayloads.id} IN (
163
- SELECT ${telemetryPayloads.id} FROM ${telemetryPayloads}
164
- WHERE ${telemetryPayloads.expiresAt} < ${before}
165
- ORDER BY ${telemetryPayloads.expiresAt}
166
- LIMIT ${batchSize}
167
- )
168
- `);
169
- return affectedRows(result);
170
- }
171
-
172
- /**
173
- * Deletes at most one bounded batch of aged events in a single statement, so
174
- * concurrent sweeps atomically claim disjoint rows and the returned count
175
- * matches the rows actually deleted. Ordered by `created_at`, which
176
- * `idx_telemetry_created_at` backs.
177
- */
178
- async deleteExpiredEvents(batchSize = 5000, before: Date): Promise<number> {
179
- const result = await this.db.execute(sql`
180
- DELETE FROM ${telemetryEvents}
181
- WHERE ${telemetryEvents.id} IN (
182
- SELECT ${telemetryEvents.id} FROM ${telemetryEvents}
183
- WHERE ${telemetryEvents.createdAt} < ${before}
184
- ORDER BY ${telemetryEvents.createdAt}
185
- LIMIT ${batchSize}
186
- )
187
- `);
188
- return affectedRows(result);
189
- }
190
-
191
- /**
192
- * Deletes telemetry older than the supplied cutoff. Payloads follow their
193
- * `expires_at` contract (15 minutes per row) rather than capture time.
194
- *
195
- * Events are pruned in bounded batches rather than one `DELETE`, because
196
- * `telemetry_events` is the highest-volume table and a single statement
197
- * covering every aged row grows without limit: it holds locks and WAL for the
198
- * whole sweep, and on a deployment that has run for months it can exceed the
199
- * pool's `statement_timeout` and be cancelled, so the sweep never converges
200
- * and retention silently stops working. Each batch commits independently, so
201
- * progress survives a cancel or a restart. The loop is bounded so one pass
202
- * cannot run unbounded — a backlog drains over successive sweeps — mirroring
203
- * the payload sweeper's `cleanupExpired`.
204
- */
205
- async pruneTelemetry(before: Date): Promise<void> {
206
- await pruneEventFiles(before);
207
- const MAX_BATCHES_PER_SWEEP = 40;
208
- for (let batch = 0; batch < MAX_BATCHES_PER_SWEEP; batch += 1) {
209
- const deleted = await this.deleteExpiredEvents(5000, before);
210
- if (deleted < 5000) break;
211
- }
212
- await this.deleteExpiredPayloads(5000, before);
213
- }
214
- }
1
+ import { appendFile, mkdir, readdir, rm } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { sql } from "drizzle-orm";
4
+ import { affectedRows, currentTimestamp, laterOf } from "../dialect";
5
+ import type { CartethyiaDatabase } from "../db-handle";
6
+ import {
7
+ telemetryEvents,
8
+ telemetryPayloads,
9
+ telemetryUsageTotals,
10
+ type TelemetryUsageIdentityType,
11
+ } from "../schema";
12
+ import { isGatewayError } from "../../observability/telemetry-status";
13
+ import { TELEMETRY_EVENT_DIR } from "../../observability/event-log";
14
+ import { dayEventFileName, utcDay } from "../../observability/event-day-index";
15
+
16
+ interface UsageTotalDelta {
17
+ readonly tenantId: string;
18
+ readonly identityType: TelemetryUsageIdentityType;
19
+ readonly entityId: string;
20
+ requests: number;
21
+ errors: number;
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ }
25
+
26
+ function addUsageDelta(
27
+ deltas: Map<string, UsageTotalDelta>,
28
+ row: typeof telemetryEvents.$inferInsert,
29
+ identityType: TelemetryUsageIdentityType,
30
+ entityId: string | null | undefined,
31
+ ): void {
32
+ if (entityId == null) return;
33
+ const key = `${row.tenantId}\0${identityType}\0${entityId}`;
34
+ let delta = deltas.get(key);
35
+ if (!delta) {
36
+ delta = {
37
+ tenantId: row.tenantId,
38
+ identityType,
39
+ entityId,
40
+ requests: 0,
41
+ errors: 0,
42
+ inputTokens: 0,
43
+ outputTokens: 0,
44
+ };
45
+ deltas.set(key, delta);
46
+ }
47
+ delta.requests += 1;
48
+ // Same definition as every read-side error count, including the HTTP-status
49
+ // exclusions (404/499/503/504): this rollup is durable, so a request counted here
50
+ // stays counted after its raw row is pruned and cannot be corrected later.
51
+ if (isGatewayError(row.status, row.httpStatus)) delta.errors += 1;
52
+ delta.inputTokens += row.inputTokens ?? 0;
53
+ delta.outputTokens += row.outputTokens ?? 0;
54
+ }
55
+
56
+ function aggregateUsage(rows: readonly (typeof telemetryEvents.$inferInsert)[]) {
57
+ const deltas = new Map<string, UsageTotalDelta>();
58
+ for (const row of rows) {
59
+ addUsageDelta(deltas, row, "account", row.accountId);
60
+ addUsageDelta(deltas, row, "api_key", row.apiKeyId);
61
+ }
62
+ return [...deltas.values()];
63
+ }
64
+
65
+ /**
66
+ * Database boundary for durable telemetry writes and bounded retention cleanup.
67
+ * The observability buffer owns queueing and retry policy; this store owns table
68
+ * operations, lifetime aggregates, and retention.
69
+ */
70
+
71
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
72
+
73
+ /**
74
+ * Deletes whole days past the retention cutoff: the event file and its index
75
+ * together.
76
+ *
77
+ * The day is the retention unit now, so both go at once. Removing one without
78
+ * the other is the failure this avoids — an index with no events reports totals
79
+ * for requests that no longer exist, and events with no index are invisible to
80
+ * every usage read.
81
+ *
82
+ * The cutoff is a whole day, so today's file — still being appended to — is
83
+ * never a candidate. It used to compare filenames hour by hour, which for a day
84
+ * file compared the `.events` suffix against an hour stamp and could drop the
85
+ * open file.
86
+ */
87
+ async function pruneEventFiles(before: Date): Promise<void> {
88
+ const files = await readdir(TELEMETRY_EVENT_DIR).catch(() => [] as string[]);
89
+ const cutoffDay = before.toISOString().slice(0, 10);
90
+ for (const file of files) {
91
+ const day = file.slice(0, 10);
92
+ if (day.length !== 10 || day >= cutoffDay) continue;
93
+ if (!file.endsWith(".events.jsonl") && !file.endsWith(".index.json")) continue;
94
+ await rm(join(TELEMETRY_EVENT_DIR, file), { force: true });
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Appends one JSON line per event to the day's event file.
100
+ *
101
+ * One file per UTC day, appended to and never rewritten while the day is open.
102
+ * It used to be one file per `windowMinutes` window — 96 a day at the default —
103
+ * which the reader then had to find, open and parse one by one on every usage
104
+ * query. Which of those files was still being written had to be inferred from
105
+ * the name, and that inference is what let the open one be deleted mid-write.
106
+ *
107
+ * A single growing file has no such ambiguity, and the day index tracks how much
108
+ * of it has been counted by byte offset.
109
+ */
110
+ async function appendEvents(rows: readonly (typeof telemetryEvents.$inferInsert)[]): Promise<void> {
111
+ await mkdir(TELEMETRY_EVENT_DIR, { recursive: true });
112
+ const file = join(TELEMETRY_EVENT_DIR, dayEventFileName(utcDay(new Date())));
113
+ const newline = String.fromCharCode(10);
114
+ const body = rows.map((row) => JSON.stringify(row)).join(newline) + newline;
115
+ await appendFile(file, body, "utf8");
116
+ }
117
+
118
+ export class DrizzleTelemetryStore {
119
+ constructor(private readonly db: CartethyiaDatabase) {}
120
+
121
+ /** Inserts events and their durable identity aggregates atomically. */
122
+ async insertEvents(rows: Array<typeof telemetryEvents.$inferInsert>): Promise<void> {
123
+ if (rows.length === 0) return;
124
+ // The file accepts anything, so a malformed row would be written and then
125
+ // retried forever without ever failing. Rejecting it here keeps the buffer's
126
+ // drop-and-count contract: one bad row fails the batch, once.
127
+ for (const row of rows) {
128
+ if (!UUID_RE.test(row.tenantId)) {
129
+ throw new Error("telemetry event rejected: tenantId is not a uuid");
130
+ }
131
+ }
132
+ const deltas = aggregateUsage(rows);
133
+ // The per-request row goes to an append-only file, not to Postgres. The
134
+ // database keeps only the durable aggregate below, so a request never
135
+ // adds a row to the largest table. Readers of recent requests read the
136
+ // file; readers of long-term totals read the aggregate.
137
+ await this.db.transaction(async (tx) => {
138
+ if (deltas.length === 0) return;
139
+ const now = new Date();
140
+ await tx
141
+ .insert(telemetryUsageTotals)
142
+ .values(deltas.map((delta) => ({ ...delta, lastUsedAt: now })))
143
+ .onConflictDoUpdate({
144
+ target: [
145
+ telemetryUsageTotals.tenantId,
146
+ telemetryUsageTotals.identityType,
147
+ telemetryUsageTotals.entityId,
148
+ ],
149
+ set: {
150
+ requests: sql`${telemetryUsageTotals.requests} + excluded.requests`,
151
+ errors: sql`${telemetryUsageTotals.errors} + excluded.errors`,
152
+ inputTokens: sql`${telemetryUsageTotals.inputTokens} + excluded.input_tokens`,
153
+ outputTokens: sql`${telemetryUsageTotals.outputTokens} + excluded.output_tokens`,
154
+ lastUsedAt: laterOf(sql`${telemetryUsageTotals.lastUsedAt}`, sql`excluded.last_used_at`),
155
+ updatedAt: currentTimestamp(),
156
+ },
157
+ });
158
+ });
159
+ // After the aggregate commits, so a rejected batch is retried once, not
160
+ // appended once per attempt. The file is the record of requests whose
161
+ // totals were actually counted.
162
+ await appendEvents(rows);
163
+ }
164
+
165
+ /** Inserts one redacted payload and returns its generated identity. */
166
+ async insertPayload(
167
+ row: typeof telemetryPayloads.$inferInsert,
168
+ ): Promise<{ id: string; expiresAt: Date }> {
169
+ const [inserted] = await this.db
170
+ .insert(telemetryPayloads)
171
+ .values(row)
172
+ .returning({ id: telemetryPayloads.id, expiresAt: telemetryPayloads.expiresAt });
173
+ if (!inserted) throw new Error("payload capture insert returned no row");
174
+ return inserted;
175
+ }
176
+
177
+ /**
178
+ * Deletes at most one bounded batch of expired payloads in a single
179
+ * statement, so concurrent sweeps atomically claim disjoint rows and the
180
+ * returned count matches the rows actually deleted.
181
+ */
182
+ async deleteExpiredPayloads(batchSize = 5000, before = new Date()): Promise<number> {
183
+ const result = await this.db.execute(sql`
184
+ DELETE FROM ${telemetryPayloads}
185
+ WHERE ${telemetryPayloads.id} IN (
186
+ SELECT ${telemetryPayloads.id} FROM ${telemetryPayloads}
187
+ WHERE ${telemetryPayloads.expiresAt} < ${before}
188
+ ORDER BY ${telemetryPayloads.expiresAt}
189
+ LIMIT ${batchSize}
190
+ )
191
+ `);
192
+ return affectedRows(result);
193
+ }
194
+
195
+ /**
196
+ * Deletes at most one bounded batch of aged events in a single statement, so
197
+ * concurrent sweeps atomically claim disjoint rows and the returned count
198
+ * matches the rows actually deleted. Ordered by `created_at`, which
199
+ * `idx_telemetry_created_at` backs.
200
+ */
201
+ async deleteExpiredEvents(batchSize = 5000, before: Date): Promise<number> {
202
+ const result = await this.db.execute(sql`
203
+ DELETE FROM ${telemetryEvents}
204
+ WHERE ${telemetryEvents.id} IN (
205
+ SELECT ${telemetryEvents.id} FROM ${telemetryEvents}
206
+ WHERE ${telemetryEvents.createdAt} < ${before}
207
+ ORDER BY ${telemetryEvents.createdAt}
208
+ LIMIT ${batchSize}
209
+ )
210
+ `);
211
+ return affectedRows(result);
212
+ }
213
+
214
+ /**
215
+ * Deletes telemetry older than the supplied cutoff. Payloads follow their
216
+ * `expires_at` contract (15 minutes per row) rather than capture time.
217
+ *
218
+ * Events are pruned in bounded batches rather than one `DELETE`, because
219
+ * `telemetry_events` is the highest-volume table and a single statement
220
+ * covering every aged row grows without limit: it holds locks and WAL for the
221
+ * whole sweep, and on a deployment that has run for months it can exceed the
222
+ * pool's `statement_timeout` and be cancelled, so the sweep never converges
223
+ * and retention silently stops working. Each batch commits independently, so
224
+ * progress survives a cancel or a restart. The loop is bounded so one pass
225
+ * cannot run unbounded — a backlog drains over successive sweeps — mirroring
226
+ * the payload sweeper's `cleanupExpired`.
227
+ */
228
+ async pruneTelemetry(before: Date): Promise<void> {
229
+ await pruneEventFiles(before);
230
+ const MAX_BATCHES_PER_SWEEP = 40;
231
+ for (let batch = 0; batch < MAX_BATCHES_PER_SWEEP; batch += 1) {
232
+ const deleted = await this.deleteExpiredEvents(5000, before);
233
+ if (deleted < 5000) break;
234
+ }
235
+ await this.deleteExpiredPayloads(5000, before);
236
+ }
237
+ }
@@ -485,13 +485,12 @@ export function endpointUrl(
485
485
  return url;
486
486
  }
487
487
 
488
- /** Session-scoped state (turn-state / models-etag / session identity). */
488
+ /** Session-scoped state (turn-state / session identity). */
489
489
  export interface CodexSessionState {
490
490
  sessionId: string;
491
491
  threadId: string;
492
492
  windowId: string;
493
493
  turnState?: string;
494
- modelsEtag?: string;
495
494
  turnStartedAtUnixMs?: number;
496
495
  }
497
496
 
@@ -56,6 +56,17 @@ export interface ImageClampInput {
56
56
  readonly height?: number;
57
57
  /** Images in the whole request, including this one. Defaults to this one alone. */
58
58
  readonly imageCount?: number;
59
+ /**
60
+ * Per-tenant byte ceiling override, in bytes.
61
+ *
62
+ * Absent means the deployment default (`IMAGE_CLAMP_MAX_BYTES`). A tenant
63
+ * raising this asserts that its upstreams accept larger attachments: the
64
+ * clamp exists so an oversized block cannot poison every retry and every
65
+ * replayed turn with a request the upstream rejects whole. An override
66
+ * promising more than the provider accepts turns that protection back off,
67
+ * which is why it is a deliberate per-tenant choice and not a global knob.
68
+ */
69
+ readonly maxBytesOverride?: number;
59
70
  }
60
71
 
61
72
  export interface ImageClampDecision {
@@ -108,7 +119,14 @@ export function imageClampMaxDimensionPx(imageCount: number): number {
108
119
  export function clampImage(input: ImageClampInput): ImageClampDecision {
109
120
  const imageCount = input.imageCount ?? 1;
110
121
  const maxDimensionPx = imageClampMaxDimensionPx(imageCount);
111
- const maxBytes = IMAGE_CLAMP_MAX_BYTES;
122
+ // A non-finite or non-positive override is not a ceiling, it is a mistake:
123
+ // falling back to the default keeps a malformed preference from silently
124
+ // admitting (or rejecting) every image.
125
+ const override = input.maxBytesOverride;
126
+ const maxBytes =
127
+ typeof override === "number" && Number.isFinite(override) && override > 0
128
+ ? Math.floor(override)
129
+ : IMAGE_CLAMP_MAX_BYTES;
112
130
  const longestEdge = Math.max(input.width ?? 0, input.height ?? 0);
113
131
  if (longestEdge > maxDimensionPx) {
114
132
  return {
@@ -8,6 +8,11 @@ export interface CodecContext {
8
8
  readonly maxOutputTokens?: number;
9
9
  readonly promptCacheKey?: string;
10
10
  readonly signal?: AbortSignal;
11
+ /**
12
+ * Per-tenant ceiling for one inline attachment, in bytes. Absent means the
13
+ * deployment default; only wire encoders that clamp images read it.
14
+ */
15
+ readonly maxBytesOverride?: number;
11
16
  }
12
17
 
13
18
  /**
@@ -138,6 +143,9 @@ export async function encodeWireRequest(
138
143
  if (codec.family === "messages") {
139
144
  return codec.encodeWireRequest(request, {
140
145
  ...(context?.isOAuth === undefined ? {} : { isOAuth: context.isOAuth }),
146
+ ...(context?.maxBytesOverride === undefined
147
+ ? {}
148
+ : { maxBytesOverride: context.maxBytesOverride }),
141
149
  });
142
150
  }
143
151
  if (codec.family === "chat") {
@@ -147,6 +147,7 @@ function geminiMediaPart(part: {
147
147
  function toGeminiPart(
148
148
  part: CanonicalRequest["messages"][number]["content"][number],
149
149
  toolNames: ReadonlyMap<string, string>,
150
+ maxBytesOverride?: number,
150
151
  ): Record<string, unknown>[] {
151
152
  if (part.kind === "text") return [{ text: part.text }];
152
153
  if (part.kind === "reasoning") {
@@ -173,6 +174,7 @@ function toGeminiPart(
173
174
  // whose attachment silently vanished.
174
175
  const decision = clampImage({
175
176
  bytes: base64DecodedByteLength(split.data),
177
+ ...(maxBytesOverride === undefined ? {} : { maxBytesOverride }),
176
178
  });
177
179
  if (decision.action === "reject") {
178
180
  // Only the byte ceiling refuses a block. A `downscale` decision is
@@ -255,7 +257,10 @@ function parseJsonObject(value: string): Record<string, unknown> {
255
257
  }
256
258
  }
257
259
 
258
- export function buildGeminiPayload(request: CanonicalRequest): Record<string, unknown> {
260
+ export function buildGeminiPayload(
261
+ request: CanonicalRequest,
262
+ options: { maxBytesOverride?: number } = {},
263
+ ): Record<string, unknown> {
259
264
  const systemParts: Array<{ text: string }> = [];
260
265
  const pushSystemText = (text: string): void => {
261
266
  if (!isClaudeBillingHeaderText(text)) systemParts.push({ text });
@@ -275,7 +280,9 @@ export function buildGeminiPayload(request: CanonicalRequest): Record<string, un
275
280
  .map((message) => {
276
281
  // Gemini roles: user or model (assistant -> model)
277
282
  const role = message.role === "assistant" ? "model" : message.role === "tool" ? "user" : "user";
278
- const parts = message.content.flatMap((block) => toGeminiPart(block, toolNames));
283
+ const parts = message.content.flatMap((block) =>
284
+ toGeminiPart(block, toolNames, options.maxBytesOverride),
285
+ );
279
286
  return { role, parts: parts.length ? parts : [{ text: "" }] };
280
287
  });
281
288
  const payload: Record<string, unknown> = { contents };
@@ -121,6 +121,7 @@ function applyImageClamp(
121
121
  block: ClaudeWireObject,
122
122
  imageCount: number,
123
123
  payload: unknown,
124
+ maxBytesOverride?: number,
124
125
  ): ClaudeWireObject {
125
126
  const source = block["source"];
126
127
  const data = isRecord(source) ? source["data"] : undefined;
@@ -131,6 +132,7 @@ function applyImageClamp(
131
132
  ...(width === undefined ? {} : { width }),
132
133
  ...(height === undefined ? {} : { height }),
133
134
  imageCount,
135
+ ...(maxBytesOverride === undefined ? {} : { maxBytesOverride }),
134
136
  });
135
137
  if (decision.action === "pass") return block;
136
138
  log.warn(
@@ -281,6 +283,7 @@ function partToClaudeBlock(
281
283
  idMap: Map<string, string>;
282
284
  isOAuth: boolean;
283
285
  imageCount: number;
286
+ maxBytesOverride?: number;
284
287
  },
285
288
  ): ClaudeWireObject | undefined {
286
289
  switch (part.kind) {
@@ -291,6 +294,7 @@ function partToClaudeBlock(
291
294
  normalizeImageBlock(part.payload),
292
295
  ctx?.imageCount ?? 1,
293
296
  part.payload,
297
+ ctx?.maxBytesOverride,
294
298
  );
295
299
  case "file":
296
300
  return {
@@ -553,14 +557,22 @@ export function claudeFallbacksChain(
553
557
 
554
558
  export function canonicalToClaudeMessagesPayload(
555
559
  request: CanonicalRequest,
556
- options: { isOAuth?: boolean } = {},
560
+ options: { isOAuth?: boolean; maxBytesOverride?: number } = {},
557
561
  ): ClaudeWireObject {
558
562
  const seen = new Map<string, number>();
559
563
  const idMap = new Map<string, string>();
560
564
  // The clamp's per-image ceiling depends on how many images the whole request
561
565
  // carries, so it is counted once over every part rather than per message.
562
566
  const imageCount = allParts(request).filter((part) => part.kind === "image").length;
563
- const ctx = { seen, idMap, isOAuth: options.isOAuth ?? false, imageCount };
567
+ const ctx = {
568
+ seen,
569
+ idMap,
570
+ isOAuth: options.isOAuth ?? false,
571
+ imageCount,
572
+ ...(options.maxBytesOverride === undefined
573
+ ? {}
574
+ : { maxBytesOverride: options.maxBytesOverride }),
575
+ };
564
576
  const messages: ClaudeWireObject[] = [];
565
577
  // Anthropic accepts system content only at the top level. Chat `developer`
566
578
  // turns and Responses `instructions` have no `developer` role upstream, and
@@ -207,6 +207,9 @@ export class OpenAICompatibleAdapter extends BaseProviderAdapter {
207
207
  ...(this.config.chat_token_limit_field === undefined
208
208
  ? {}
209
209
  : { chatTokenLimitField: this.config.chat_token_limit_field }),
210
+ ...(context.image_clamp_max_bytes === undefined
211
+ ? {}
212
+ : { maxBytesOverride: context.image_clamp_max_bytes }),
210
213
  });
211
214
  if (
212
215
  wireFamily !== "messages" &&