carthagent 0.0.0-stage → 1.0.7

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 (379) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +219 -2
  3. package/bin/ctg.mjs +736 -0
  4. package/docs/budgets.md +72 -0
  5. package/docs/compaction.md +34 -0
  6. package/docs/context-efficiency-plan.md +24 -0
  7. package/docs/context-efficiency-review.md +27 -0
  8. package/docs/context-efficiency.md +49 -0
  9. package/docs/deepseek-jev-benchmark-plan.md +70 -0
  10. package/docs/deepseek-jev-benchmark-repair-plan.md +20 -0
  11. package/docs/deepseek-jev-benchmark-restart.md +9 -0
  12. package/docs/deepseek-jev-benchmark-results.md +77 -0
  13. package/docs/deepseek-jev-benchmark-running.md +60 -0
  14. package/docs/deepseek-jev-benchmark-v3-results.md +41 -0
  15. package/docs/deepseek-jev-benchmark-v4-protocol.md +15 -0
  16. package/docs/deepseek-jev-benchmark-v4-results.md +119 -0
  17. package/docs/deepseek-jev-http400-diagnosis.md +45 -0
  18. package/docs/delivery-review-evidence.md +53 -0
  19. package/docs/delivery-timeout-repair-plan.md +51 -0
  20. package/docs/evaluation-readiness.md +31 -0
  21. package/docs/evaluation.md +70 -0
  22. package/docs/extending.md +354 -0
  23. package/docs/glm-pilot-execution.md +9 -0
  24. package/docs/glm-pilot-plan.md +15 -0
  25. package/docs/glm-pilot-results.md +71 -0
  26. package/docs/graph-context-optimization.md +15 -0
  27. package/docs/jev-repository-pilot-plan.md +25 -0
  28. package/docs/jev-repository-pilot-protocol.md +39 -0
  29. package/docs/jev-repository-pilot-restart.md +7 -0
  30. package/docs/jev-repository-pilot-results.md +79 -0
  31. package/docs/jev.md +32 -0
  32. package/docs/model-registry.md +25 -0
  33. package/docs/native-compatibility.md +117 -0
  34. package/docs/opencode-archive.md +9 -0
  35. package/docs/opencode-migration.md +115 -0
  36. package/docs/opencode-runtime.md +94 -0
  37. package/docs/opencode-workspace.md +31 -0
  38. package/docs/owned-runtime-workflow-registration.md +17 -0
  39. package/docs/plan-advisor-evaluation.md +38 -0
  40. package/docs/planning-startup-fix-plan.md +23 -0
  41. package/docs/planning-startup-fix-review.md +24 -0
  42. package/docs/planning.md +118 -0
  43. package/docs/production-merge-review.md +23 -0
  44. package/docs/quality.md +35 -0
  45. package/docs/repository-benchmark-design.md +111 -0
  46. package/docs/review-assurance.md +9 -0
  47. package/docs/review-experiment-results.md +59 -0
  48. package/docs/tested-planning-roadmap.md +58 -0
  49. package/docs/tested-planning.md +58 -0
  50. package/docs/tui-pickers.md +9 -0
  51. package/docs/webdriver.md +83 -0
  52. package/docs/workflow-modes.md +26 -0
  53. package/docs/workflow.d2 +17 -0
  54. package/docs/workflow.svg +149 -0
  55. package/docs/workspace-coordination.md +9 -0
  56. package/evaluate.mjs +249 -0
  57. package/extensions/codegraph.ts +36 -0
  58. package/extensions/delivery.ts +819 -0
  59. package/extensions/experiential.ts +21 -0
  60. package/extensions/subscriptions.ts +21 -0
  61. package/guidance/profiles/authenticated-web.json +24 -0
  62. package/guidance/profiles/data-visualization.json +25 -0
  63. package/guidance/profiles/database-change.json +24 -0
  64. package/guidance/profiles/external-api.json +24 -0
  65. package/guidance/profiles/transactional-data.json +25 -0
  66. package/guidance/profiles/web-application.json +26 -0
  67. package/lib/acceptance-review.mjs +4 -0
  68. package/lib/browser-bin.mjs +160 -0
  69. package/lib/browser-errors.mjs +54 -0
  70. package/lib/browser.mjs +219 -0
  71. package/lib/budget.mjs +37 -0
  72. package/lib/cdp.mjs +221 -0
  73. package/lib/cloud/client.mjs +275 -0
  74. package/lib/cloud/control-plane.mjs +350 -0
  75. package/lib/cloud/gateway.mjs +380 -0
  76. package/lib/cloud/http.mjs +135 -0
  77. package/lib/cloud/ledger.mjs +215 -0
  78. package/lib/codegraph-query.mjs +60 -0
  79. package/lib/codegraph.mjs +378 -0
  80. package/lib/context-archive.mjs +18 -0
  81. package/lib/delivery-context.mjs +132 -0
  82. package/lib/delivery-guidance.mjs +9 -0
  83. package/lib/delivery-review.mjs +94 -0
  84. package/lib/delivery.mjs +610 -0
  85. package/lib/engine.mjs +137 -0
  86. package/lib/guidance.mjs +71 -0
  87. package/lib/jev-advisor.mjs +47 -0
  88. package/lib/opencode/archive.mjs +122 -0
  89. package/lib/opencode/compatibility.mjs +19 -0
  90. package/lib/opencode/credentials.mjs +205 -0
  91. package/lib/opencode/evaluation-budget-plugin/index.ts +147 -0
  92. package/lib/opencode/evaluation.mjs +242 -0
  93. package/lib/opencode/file-lock.mjs +86 -0
  94. package/lib/opencode/headless.mjs +200 -0
  95. package/lib/opencode/review.mjs +213 -0
  96. package/lib/opencode/runtime.mjs +246 -0
  97. package/lib/opencode/service.mjs +249 -0
  98. package/lib/opencode/session-options.mjs +60 -0
  99. package/lib/opencode/workspace.mjs +133 -0
  100. package/lib/openrouter-jev.mjs +37 -0
  101. package/lib/outcome-contract.mjs +21 -0
  102. package/lib/plan-advisor.mjs +38 -0
  103. package/lib/planning-access.mjs +7 -0
  104. package/lib/planning-advice.mjs +28 -0
  105. package/lib/planning-discovery.mjs +14 -0
  106. package/lib/planning-guide.mjs +34 -0
  107. package/lib/planning-view.mjs +57 -0
  108. package/lib/planning.mjs +155 -0
  109. package/lib/probe-diagnostics.mjs +13 -0
  110. package/lib/providers/experiential.mjs +292 -0
  111. package/lib/providers/model-catalog.json +700 -0
  112. package/lib/providers/model-catalog.mjs +7 -0
  113. package/lib/providers/names.mjs +13 -0
  114. package/lib/providers/subscription-models.mjs +483 -0
  115. package/lib/reports.mjs +80 -0
  116. package/lib/review-state.mjs +49 -0
  117. package/lib/review.mjs +114 -0
  118. package/lib/scenarios.mjs +99 -0
  119. package/lib/ship-auth.mjs +44 -0
  120. package/lib/ship-jev.mjs +59 -0
  121. package/lib/ship-telemetry.mjs +183 -0
  122. package/lib/tui/ansi.mjs +255 -0
  123. package/lib/tui/app.mjs +2445 -0
  124. package/lib/tui/auth.mjs +249 -0
  125. package/lib/tui/carthage.mjs +109 -0
  126. package/lib/tui/clipboard.mjs +122 -0
  127. package/lib/tui/d2.mjs +431 -0
  128. package/lib/tui/feed.mjs +467 -0
  129. package/lib/tui/framing.mjs +80 -0
  130. package/lib/tui/glyphs.mjs +172 -0
  131. package/lib/tui/hash-worker.mjs +11 -0
  132. package/lib/tui/mock-engine.mjs +222 -0
  133. package/lib/tui/models.mjs +73 -0
  134. package/lib/tui/picker.mjs +18 -0
  135. package/lib/tui/theme.mjs +663 -0
  136. package/lib/tui/title.mjs +7 -0
  137. package/lib/update-channel.mjs +62 -0
  138. package/lib/update.mjs +230 -0
  139. package/lib/webdriver.mjs +82 -0
  140. package/lib/workflow.mjs +88 -0
  141. package/lib/workspace.mjs +39 -0
  142. package/metadata.json +8 -0
  143. package/package.json +94 -4
  144. package/plugins/carthagent/index.ts +3 -0
  145. package/plugins/carthagent/package.json +28 -0
  146. package/plugins/carthagent/src/archive-rpc.ts +46 -0
  147. package/plugins/carthagent/src/bootstrap.ts +38 -0
  148. package/plugins/carthagent/src/index.ts +62 -0
  149. package/plugins/carthagent/src/pull-request-rpc.ts +54 -0
  150. package/plugins/carthagent/src/rpc.ts +76 -0
  151. package/plugins/carthagent/src/server/archive-rpc.ts +327 -0
  152. package/plugins/carthagent/src/server/budgets.ts +21 -0
  153. package/plugins/carthagent/src/server/codegraph.ts +46 -0
  154. package/plugins/carthagent/src/server/delivery.ts +488 -0
  155. package/plugins/carthagent/src/server/device-auth.ts +91 -0
  156. package/plugins/carthagent/src/server/native-delivery.ts +26 -0
  157. package/plugins/carthagent/src/server/orchestration.ts +56 -0
  158. package/plugins/carthagent/src/server/policy.ts +24 -0
  159. package/plugins/carthagent/src/server/providers.ts +424 -0
  160. package/plugins/carthagent/src/server/pull-request-rpc.ts +387 -0
  161. package/plugins/carthagent/src/server/session-controls.ts +82 -0
  162. package/plugins/carthagent/src/server/telemetry.ts +92 -0
  163. package/plugins/carthagent/src/server/workspace-rpc.ts +349 -0
  164. package/plugins/carthagent/src/session-rpc.ts +31 -0
  165. package/plugins/carthagent/src/testing.ts +85 -0
  166. package/plugins/carthagent/src/workspace-rpc.ts +133 -0
  167. package/plugins/carthagent/test-plugin/index.ts +4 -0
  168. package/plugins/carthagent/test-plugin/package.json +1 -0
  169. package/plugins/carthagent/tsconfig.json +19 -0
  170. package/plugins/carthagent/tui.ts +1 -0
  171. package/prompts/delivery.md +7 -0
  172. package/public/index.html +983 -0
  173. package/scenarios/cli/grade_cli.py +69 -0
  174. package/scenarios/cli/scenario.json +21 -0
  175. package/scenarios/game/baseline/minecraft.py +193 -0
  176. package/scenarios/game/context.md +1 -0
  177. package/scenarios/game/grade_minecraft_clone.py +603 -0
  178. package/scenarios/game/requirements.txt +2 -0
  179. package/scenarios/game/scenario.json +33 -0
  180. package/server.mjs +341 -0
  181. package/skills/game-development/SKILL.md +36 -0
  182. package/skills/game-development/assets/ursina_starter.py +98 -0
  183. package/skills/game-development/scripts/verify_ursina.py +80 -0
  184. package/skills/software-delivery/SKILL.md +50 -0
  185. package/skills/typescript-delivery/SKILL.md +69 -0
  186. package/tests/acceptance-review.test.mjs +13 -0
  187. package/tests/auth.test.mjs +234 -0
  188. package/tests/bounded-summary.test.mjs +68 -0
  189. package/tests/browser-bin.test.mjs +91 -0
  190. package/tests/browser.test.mjs +96 -0
  191. package/tests/budget-engine.test.mjs +110 -0
  192. package/tests/budget.test.mjs +23 -0
  193. package/tests/cli.test.mjs +245 -0
  194. package/tests/clipboard.test.mjs +79 -0
  195. package/tests/cloud-client.test.mjs +129 -0
  196. package/tests/cloud-control-plane.test.mjs +144 -0
  197. package/tests/cloud-gateway.test.mjs +147 -0
  198. package/tests/cloud-http.test.mjs +102 -0
  199. package/tests/cloud-ledger.test.mjs +75 -0
  200. package/tests/codegraph-query.test.mjs +66 -0
  201. package/tests/codegraph.test.mjs +142 -0
  202. package/tests/delivery-context.test.mjs +66 -0
  203. package/tests/delivery-review.test.mjs +139 -0
  204. package/tests/delivery.test.mjs +343 -0
  205. package/tests/engine-api.mjs +24 -0
  206. package/tests/experiential-extension.test.mjs +37 -0
  207. package/tests/experiential.test.mjs +300 -0
  208. package/tests/extension.test.mjs +921 -0
  209. package/tests/fixtures/archive-plugin/index.ts +10 -0
  210. package/tests/guidance.test.mjs +78 -0
  211. package/tests/model-registry.test.mjs +68 -0
  212. package/tests/models.test.mjs +70 -0
  213. package/tests/openai-idempotency.test.mjs +99 -0
  214. package/tests/opencode-archive-rpc.test.mjs +82 -0
  215. package/tests/opencode-delivery.test.mjs +231 -0
  216. package/tests/opencode-providers.test.mjs +414 -0
  217. package/tests/opencode-runtime.test.mjs +455 -0
  218. package/tests/opencode-session-controls.test.mjs +115 -0
  219. package/tests/opencode-session-options.test.mjs +26 -0
  220. package/tests/opencode-workspace.test.mjs +339 -0
  221. package/tests/outcome-contract.test.mjs +17 -0
  222. package/tests/picker.test.mjs +34 -0
  223. package/tests/plan-advisor.test.mjs +88 -0
  224. package/tests/plan-review-loop.test.mjs +53 -0
  225. package/tests/planning-advice.test.mjs +27 -0
  226. package/tests/planning-contract.test.mjs +65 -0
  227. package/tests/planning-startup.test.mjs +38 -0
  228. package/tests/planning-view.test.mjs +33 -0
  229. package/tests/planning.test.mjs +223 -0
  230. package/tests/probe-diagnostics.test.mjs +21 -0
  231. package/tests/pull-request-rpc.test.mjs +366 -0
  232. package/tests/review-state.test.mjs +32 -0
  233. package/tests/review.test.mjs +127 -0
  234. package/tests/server.test.mjs +252 -0
  235. package/tests/ship-jev.test.mjs +77 -0
  236. package/tests/ship-telemetry.test.mjs +333 -0
  237. package/tests/standard-workflow.test.mjs +28 -0
  238. package/tests/subscription-models.test.mjs +416 -0
  239. package/tests/subscriptions-extension.test.mjs +27 -0
  240. package/tests/terminal-title.test.mjs +13 -0
  241. package/tests/test_grade_minecraft_clone.py +199 -0
  242. package/tests/test_starter.py +78 -0
  243. package/tests/theme.test.mjs +175 -0
  244. package/tests/tui-copy-paste.test.mjs +54 -0
  245. package/tests/tui.test.mjs +808 -0
  246. package/tests/update-channels.test.mjs +129 -0
  247. package/tests/update.test.mjs +175 -0
  248. package/tests/webdriver.test.mjs +31 -0
  249. package/tests/workflow.test.mjs +47 -0
  250. package/tests/workspace-rpc.test.mjs +236 -0
  251. package/tests/workspace.test.mjs +24 -0
  252. package/tools/benchmark/advisor.test.mjs +7 -0
  253. package/tools/benchmark/advisor.ts +27 -0
  254. package/tools/benchmark/campaign.mjs +45 -0
  255. package/tools/benchmark/diagnose-request.mjs +40 -0
  256. package/tools/benchmark/eval-common.mjs +32 -0
  257. package/tools/benchmark/eval-ledger.mjs +22 -0
  258. package/tools/benchmark/eval-shop.mjs +26 -0
  259. package/tools/benchmark/eval-voxel.mjs +20 -0
  260. package/tools/benchmark/evaluate.mjs +24 -0
  261. package/tools/benchmark/evaluators.test.mjs +13 -0
  262. package/tools/benchmark/fixtures/shop/server.mjs +18 -0
  263. package/tools/benchmark/fixtures/shop/ui.html +8 -0
  264. package/tools/benchmark/freeze.mjs +30 -0
  265. package/tools/benchmark/gateway.mjs +91 -0
  266. package/tools/benchmark/gateway.test.mjs +38 -0
  267. package/tools/benchmark/generator.mjs +11 -0
  268. package/tools/benchmark/generator.test.mjs +26 -0
  269. package/tools/benchmark/guard.test.mjs +133 -0
  270. package/tools/benchmark/guard.ts +136 -0
  271. package/tools/benchmark/native-plugin/index.ts +24 -0
  272. package/tools/benchmark/planning-startup.test.mjs +47 -0
  273. package/tools/benchmark/preflight-live.mjs +31 -0
  274. package/tools/benchmark/preflight.mjs +22 -0
  275. package/tools/benchmark/replay-diagnostic.mjs +23 -0
  276. package/tools/benchmark/runner.mjs +141 -0
  277. package/tools/benchmark/runner.test.mjs +93 -0
  278. package/tools/benchmark/runtime.mjs +23 -0
  279. package/tools/benchmark/selftest.mjs +47 -0
  280. package/tools/benchmark/smoke-jev.mjs +5 -0
  281. package/tools/benchmark/tasks.mjs +20 -0
  282. package/tools/benchmark/transport.mjs +65 -0
  283. package/tools/benchmark/transport.test.mjs +44 -0
  284. package/tools/benchmark/upstream-error.mjs +18 -0
  285. package/tools/benchmark/upstream-error.test.mjs +10 -0
  286. package/tools/browser-check.mjs +109 -0
  287. package/tools/check-syntax.mjs +27 -0
  288. package/tools/evaluate-plan-advisor.mjs +20 -0
  289. package/tools/native-fixture.mjs +194 -0
  290. package/tools/native-pty-smoke.py +108 -0
  291. package/tools/native-report-plugin/index.ts +50 -0
  292. package/tools/native-report-plugin/package.json +7 -0
  293. package/tools/patch-compaction.mjs +24 -0
  294. package/tools/patch-model-catalog.mjs +12 -0
  295. package/tools/patch-print-shutdown.mjs +39 -0
  296. package/tools/repo-pilot/evaluator.cjs +32 -0
  297. package/tools/repo-pilot/reference.mjs +43 -0
  298. package/tools/repo-pilot/runner.mjs +85 -0
  299. package/tools/repo-pilot/sandbox-entry.mjs +12 -0
  300. package/tools/repo-pilot/tasks.mjs +20 -0
  301. package/tools/repo-pilot/type-evaluator.cjs +6 -0
  302. package/tools/test-native-controls.mjs +84 -0
  303. package/tools/test-native-delivery-host.mjs +307 -0
  304. package/tools/test-native-evaluation.mjs +46 -0
  305. package/tools/test-native-host.mjs +385 -0
  306. package/tools/test-native-outcomes.mjs +73 -0
  307. package/tools/test-native-package.mjs +70 -0
  308. package/tools/test-native-pty.mjs +20 -0
  309. package/tools/test-native-report.mjs +88 -0
  310. package/tools/test-native-swe-host.mjs +132 -0
  311. package/tools/test-native-swe.mjs +122 -0
  312. package/tools/test-picker-pty.py +45 -0
  313. package/tools/test-python.mjs +29 -0
  314. package/tools/test-webdriver.mjs +54 -0
  315. package/tools/verify-release-version.mjs +37 -0
  316. package/tools/webdriver-check.mjs +52 -0
  317. package/vendor/agent/bounded-summary.mjs +88 -0
  318. package/vendor/agent/chunks/anthropic-messages-VWZZOSJQ.js +6 -0
  319. package/vendor/agent/chunks/anthropic.js +75 -0
  320. package/vendor/agent/chunks/azure-openai-responses-BJLAM6CD.js +2 -0
  321. package/vendor/agent/chunks/bedrock-converse-stream.js +74 -0
  322. package/vendor/agent/chunks/chord-context.js +96 -0
  323. package/vendor/agent/chunks/chunk-4L3WN2XY.js +2 -0
  324. package/vendor/agent/chunks/chunk-6CYCI3EJ.js +12 -0
  325. package/vendor/agent/chunks/chunk-6R7UI4IY.js +2 -0
  326. package/vendor/agent/chunks/chunk-7ZC4UXSL.js +2 -0
  327. package/vendor/agent/chunks/chunk-AXIIZGTV.js +2 -0
  328. package/vendor/agent/chunks/chunk-CLNPYIDP.js +42 -0
  329. package/vendor/agent/chunks/chunk-DRQDKNXA.js +2 -0
  330. package/vendor/agent/chunks/chunk-GMWCTUPB.js +2 -0
  331. package/vendor/agent/chunks/chunk-IDDQWTHI.js +2 -0
  332. package/vendor/agent/chunks/chunk-JVUZSMYM.js +1548 -0
  333. package/vendor/agent/chunks/chunk-L2ZPNLYD.js +2 -0
  334. package/vendor/agent/chunks/chunk-NUHFSC37.js +25 -0
  335. package/vendor/agent/chunks/chunk-OUXLLA64.js +11 -0
  336. package/vendor/agent/chunks/chunk-PGXDEOVV.js +2 -0
  337. package/vendor/agent/chunks/chunk-PJACZCIR.js +2 -0
  338. package/vendor/agent/chunks/chunk-PRRNXB7S.js +2 -0
  339. package/vendor/agent/chunks/chunk-TEPMHNKQ.js +2 -0
  340. package/vendor/agent/chunks/chunk-TRDNDS6A.js +4 -0
  341. package/vendor/agent/chunks/chunk-U6ZMQKGD.js +50 -0
  342. package/vendor/agent/chunks/chunk-UAQELI3K.js +2 -0
  343. package/vendor/agent/chunks/chunk-USJZMAIR.js +2 -0
  344. package/vendor/agent/chunks/chunk-XDUWOHPL.js +6 -0
  345. package/vendor/agent/chunks/chunk-XNGRGP62.js +2 -0
  346. package/vendor/agent/chunks/github-copilot.js +2 -0
  347. package/vendor/agent/chunks/google-generative-ai-Q2K7FBDI.js +2 -0
  348. package/vendor/agent/chunks/google-vertex-7UJBF4S7.js +2 -0
  349. package/vendor/agent/chunks/https-proxy-agent-2VXB7436.js +13 -0
  350. package/vendor/agent/chunks/image-resize-worker.js +2 -0
  351. package/vendor/agent/chunks/kimi-coding.js +2 -0
  352. package/vendor/agent/chunks/lib-HGQMNYVT.js +9 -0
  353. package/vendor/agent/chunks/mistral-conversations-4M6BZBX6.js +5 -0
  354. package/vendor/agent/chunks/multipart-parser-SQ6GDJL7.js +3 -0
  355. package/vendor/agent/chunks/node-KG362ECQ.js +16 -0
  356. package/vendor/agent/chunks/node-TWVVAGPO.js +2 -0
  357. package/vendor/agent/chunks/openai-codex-responses-R6VYZTVY.js +10 -0
  358. package/vendor/agent/chunks/openai-codex.js +75 -0
  359. package/vendor/agent/chunks/openai-completions-EKZT2IH2.js +7 -0
  360. package/vendor/agent/chunks/openai-responses-TFDINO6W.js +2 -0
  361. package/vendor/agent/chunks/openrouter-images-N4XVVFSD.js +2 -0
  362. package/vendor/agent/chunks/openrouter.js +75 -0
  363. package/vendor/agent/chunks/pi-messages-PML4SPVJ.js +8 -0
  364. package/vendor/agent/chunks/radius.js +75 -0
  365. package/vendor/agent/chunks/src-UK77SYZE.js +4 -0
  366. package/vendor/agent/chunks/xai.js +2 -0
  367. package/vendor/agent/cli.js +3 -0
  368. package/vendor/agent/dist/modes/interactive/theme/dark.json +91 -0
  369. package/vendor/agent/dist/modes/interactive/theme/light.json +90 -0
  370. package/vendor/agent/dist/modes/interactive/theme/theme-schema.json +356 -0
  371. package/vendor/agent/index.js +2 -0
  372. package/vendor/agent/modes/interactive/theme/dark.json +91 -0
  373. package/vendor/agent/modes/interactive/theme/light.json +90 -0
  374. package/vendor/agent/modes/interactive/theme/theme-schema.json +356 -0
  375. package/vendor/agent/package.json +14 -0
  376. package/vendor/agent/rpc-entry.js +3 -0
  377. package/vendor/agent/theme/dark.json +91 -0
  378. package/vendor/agent/theme/light.json +90 -0
  379. package/vendor/agent/theme/theme-schema.json +356 -0
@@ -0,0 +1,380 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { nanoUsd } from './ledger.mjs';
3
+
4
+ const MILLION = 1_000_000n;
5
+ const TERMINAL_STATES = new Set(['completed', 'failed', 'cancelled', 'incomplete']);
6
+ const SAFE_RESPONSE_HEADERS = new Set([
7
+ 'content-type', 'retry-after', 'x-request-id', 'x-client-request-id',
8
+ 'x-gateway-alias', 'x-gateway-alias-revision', 'x-gateway-canonical-model',
9
+ 'x-gateway-provider', 'x-gateway-deployment', 'x-gateway-route-depth',
10
+ 'x-gateway-route-reason', 'x-gateway-replay-repair', 'x-gateway-zdr-constrained',
11
+ ]);
12
+
13
+ const integer = (value, name, { minimum = 0 } = {}) => {
14
+ if (!Number.isSafeInteger(value) || value < minimum) throw new TypeError(`${name} must be a safe integer >= ${minimum}`);
15
+ return value;
16
+ };
17
+
18
+ const text = (value, name, maximum = 200) => {
19
+ if (typeof value !== 'string' || !value.trim() || value.length > maximum) throw new TypeError(`${name} must be a non-empty string up to ${maximum} characters`);
20
+ return value.trim();
21
+ };
22
+
23
+ function exactReplay(existing, candidate, fields, label) {
24
+ for (const field of fields) if (existing[field] !== candidate[field]) throw new Error(`${label}_immutable_conflict`);
25
+ return existing;
26
+ }
27
+
28
+ export function tokenChargeNanoUsd(tokens, rateNanoUsdPerMillionTokens) {
29
+ integer(tokens, 'tokens');
30
+ nanoUsd(rateNanoUsdPerMillionTokens, 'rateNanoUsdPerMillionTokens');
31
+ if (rateNanoUsdPerMillionTokens < 0) throw new Error('token price cannot be negative');
32
+ const charge = (BigInt(tokens) * BigInt(rateNanoUsdPerMillionTokens) + MILLION - 1n) / MILLION;
33
+ if (charge > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('calculated charge exceeds safe integer range');
34
+ return Number(charge);
35
+ }
36
+
37
+ export function customerChargeNanoUsd(pricing, attempts) {
38
+ const fixed = nanoUsd(Number(pricing.fixed_request_nano_usd), 'fixedRequestNanoUsd');
39
+ let charge = fixed;
40
+ for (const attempt of attempts) {
41
+ charge += tokenChargeNanoUsd(attempt.inputTokens, Number(pricing.input_nano_usd_per_million_tokens));
42
+ charge += tokenChargeNanoUsd(attempt.outputTokens, Number(pricing.output_nano_usd_per_million_tokens));
43
+ if (!Number.isSafeInteger(charge)) throw new Error('calculated charge exceeds safe integer range');
44
+ }
45
+ return charge;
46
+ }
47
+
48
+ export function estimateInputTokens(body) {
49
+ // A token cannot contain more bytes than the complete encoded request. Using
50
+ // UTF-8 bytes therefore deliberately over-reserves without interpreting or
51
+ // retaining customer content.
52
+ return Buffer.byteLength(JSON.stringify(body), 'utf8');
53
+ }
54
+
55
+ export function boundedGatewayRequest(endpoint, body, revision) {
56
+ if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error('invalid_request_body');
57
+ if (body.model !== revision.alias) throw new Error('model_not_found');
58
+ const request = structuredClone(body);
59
+ const inputTokens = estimateInputTokens(request);
60
+ if (inputTokens > Number(revision.max_input_tokens)) throw new Error('request_too_large');
61
+
62
+ const outputField = endpoint === 'responses' ? 'max_output_tokens'
63
+ : Object.hasOwn(request, 'max_completion_tokens') ? 'max_completion_tokens' : 'max_tokens';
64
+ const supplied = request[outputField];
65
+ const outputTokens = supplied === undefined
66
+ ? Number(revision.default_max_output_tokens)
67
+ : integer(supplied, outputField, { minimum: 1 });
68
+ if (outputTokens > Number(revision.max_output_tokens)) throw new Error('max_output_tokens_exceeded');
69
+ request[outputField] = outputTokens;
70
+ request.model = revision.upstream_alias;
71
+ return { request, inputTokens, outputTokens };
72
+ }
73
+
74
+ export class GatewayDispatchError extends Error {
75
+ constructor(message, { outcome = 'unknown', cause } = {}) {
76
+ super(message, { cause });
77
+ this.name = 'GatewayDispatchError';
78
+ this.outcome = outcome;
79
+ }
80
+ }
81
+
82
+ export class ExperientialGatewayClient {
83
+ constructor({
84
+ baseUrl = process.env.EXP_GATEWAY_URL || 'https://api.experientiallabs.ai/v1',
85
+ apiKey = process.env.EXPLABS_API_KEY || '',
86
+ fetchImpl = globalThis.fetch,
87
+ } = {}) {
88
+ this.baseUrl = String(baseUrl).replace(/\/+$/, '');
89
+ this.apiKey = apiKey;
90
+ this.fetch = fetchImpl;
91
+ }
92
+
93
+ async dispatch({ endpoint, body, operationKey, clientRequestId, signal }) {
94
+ if (!this.apiKey) throw new GatewayDispatchError('Upstream gateway credential is not configured', { outcome: 'not_dispatched' });
95
+ if (typeof this.fetch !== 'function') throw new GatewayDispatchError('Gateway dispatch requires fetch support', { outcome: 'not_dispatched' });
96
+ try {
97
+ return await this.fetch(`${this.baseUrl}/${endpoint === 'responses' ? 'responses' : 'chat/completions'}`, {
98
+ method: 'POST',
99
+ headers: {
100
+ authorization: `Bearer ${this.apiKey}`,
101
+ 'content-type': 'application/json',
102
+ 'idempotency-key': operationKey,
103
+ 'x-client-request-id': clientRequestId,
104
+ },
105
+ body: JSON.stringify(body),
106
+ signal,
107
+ });
108
+ } catch (cause) {
109
+ // Once fetch begins a POST, a transport failure cannot prove that the
110
+ // gateway or provider did not execute it. Keep the reservation held.
111
+ throw new GatewayDispatchError('Upstream gateway outcome is unknown', { outcome: 'unknown', cause });
112
+ }
113
+ }
114
+ }
115
+
116
+ export function pendingSettlementSource() {
117
+ return {
118
+ // The upstream OpenAI-compatible response contract currently
119
+ // exposes the gateway request ID, but not every physical attempt identity.
120
+ // Never infer attempts or charges from an HTTP success or client-visible
121
+ // usage alone; deployers must inject an authoritative accounting resolver.
122
+ async resolve() { return { status: 'pending' }; },
123
+ };
124
+ }
125
+
126
+ export class CloudGatewayAuthority {
127
+ constructor({
128
+ control,
129
+ ledger = control?.ledger,
130
+ upstream = new ExperientialGatewayClient(),
131
+ settlementSource = pendingSettlementSource(),
132
+ id = randomUUID,
133
+ clock = () => new Date(),
134
+ } = {}) {
135
+ if (!control || !ledger) throw new TypeError('control and ledger are required');
136
+ this.control = control;
137
+ this.ledger = ledger;
138
+ this.db = ledger.db;
139
+ this.upstream = upstream;
140
+ this.settlementSource = settlementSource;
141
+ this.id = id;
142
+ this.clock = clock;
143
+ this.db.exec(`
144
+ CREATE TABLE IF NOT EXISTS cloud_pricing_versions (
145
+ id TEXT PRIMARY KEY,
146
+ input_nano_usd_per_million_tokens INTEGER NOT NULL CHECK(input_nano_usd_per_million_tokens >= 0),
147
+ output_nano_usd_per_million_tokens INTEGER NOT NULL CHECK(output_nano_usd_per_million_tokens >= 0),
148
+ fixed_request_nano_usd INTEGER NOT NULL CHECK(fixed_request_nano_usd >= 0),
149
+ created_at TEXT NOT NULL
150
+ );
151
+ CREATE TABLE IF NOT EXISTS cloud_model_aliases (
152
+ alias TEXT PRIMARY KEY, display_name TEXT NOT NULL, current_revision_id TEXT, active INTEGER NOT NULL DEFAULT 1,
153
+ created_at TEXT NOT NULL
154
+ );
155
+ CREATE TABLE IF NOT EXISTS cloud_model_alias_revisions (
156
+ id TEXT PRIMARY KEY, alias TEXT NOT NULL REFERENCES cloud_model_aliases(alias), upstream_alias TEXT NOT NULL,
157
+ pricing_version_id TEXT NOT NULL REFERENCES cloud_pricing_versions(id), max_input_tokens INTEGER NOT NULL,
158
+ default_max_output_tokens INTEGER NOT NULL, max_output_tokens INTEGER NOT NULL, maximum_attempts INTEGER NOT NULL,
159
+ created_at TEXT NOT NULL, UNIQUE(alias, id)
160
+ );
161
+ CREATE TABLE IF NOT EXISTS cloud_gateway_requests (
162
+ id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), operation_key TEXT NOT NULL,
163
+ endpoint TEXT NOT NULL, alias TEXT NOT NULL, alias_revision_id TEXT NOT NULL, pricing_version_id TEXT NOT NULL,
164
+ reservation_id TEXT REFERENCES reservations(id), state TEXT NOT NULL,
165
+ gateway_request_id TEXT UNIQUE, customer_charge_nano_usd INTEGER, input_tokens INTEGER, output_tokens INTEGER,
166
+ terminal_state TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(account_id, operation_key)
167
+ );
168
+ CREATE TABLE IF NOT EXISTS cloud_gateway_attempts (
169
+ gateway_request_row_id TEXT NOT NULL REFERENCES cloud_gateway_requests(id), attempt_id TEXT NOT NULL UNIQUE,
170
+ state TEXT NOT NULL, input_tokens INTEGER NOT NULL, output_tokens INTEGER NOT NULL,
171
+ PRIMARY KEY(gateway_request_row_id, attempt_id)
172
+ );
173
+ `);
174
+ }
175
+
176
+ now() { return this.clock().toISOString(); }
177
+
178
+ publishPricing({ id = this.id(), inputNanoUsdPerMillionTokens, outputNanoUsdPerMillionTokens, fixedRequestNanoUsd = 0 }) {
179
+ const candidate = {
180
+ id: text(id, 'pricing id'),
181
+ input_nano_usd_per_million_tokens: integer(inputNanoUsdPerMillionTokens, 'inputNanoUsdPerMillionTokens'),
182
+ output_nano_usd_per_million_tokens: integer(outputNanoUsdPerMillionTokens, 'outputNanoUsdPerMillionTokens'),
183
+ fixed_request_nano_usd: integer(fixedRequestNanoUsd, 'fixedRequestNanoUsd'),
184
+ };
185
+ const existing = this.db.prepare('SELECT * FROM cloud_pricing_versions WHERE id = ?').get(candidate.id);
186
+ if (existing) return exactReplay(existing, candidate, ['input_nano_usd_per_million_tokens', 'output_nano_usd_per_million_tokens', 'fixed_request_nano_usd'], 'pricing_version');
187
+ this.db.prepare(`INSERT INTO cloud_pricing_versions
188
+ (id, input_nano_usd_per_million_tokens, output_nano_usd_per_million_tokens, fixed_request_nano_usd, created_at)
189
+ VALUES (?, ?, ?, ?, ?)`).run(candidate.id, candidate.input_nano_usd_per_million_tokens, candidate.output_nano_usd_per_million_tokens, candidate.fixed_request_nano_usd, this.now());
190
+ return this.db.prepare('SELECT * FROM cloud_pricing_versions WHERE id = ?').get(candidate.id);
191
+ }
192
+
193
+ publishAlias({
194
+ alias, displayName, upstreamAlias, pricingVersionId, revisionId = this.id(),
195
+ maxInputTokens, defaultMaxOutputTokens, maxOutputTokens, maximumAttempts = 1, active = true,
196
+ }) {
197
+ alias = text(alias, 'alias');
198
+ const pricing = this.db.prepare('SELECT * FROM cloud_pricing_versions WHERE id = ?').get(pricingVersionId);
199
+ if (!pricing) throw new Error('pricing_version_not_found');
200
+ const revision = {
201
+ id: text(revisionId, 'revision id'), alias, upstream_alias: text(upstreamAlias, 'upstreamAlias'),
202
+ pricing_version_id: pricingVersionId,
203
+ max_input_tokens: integer(maxInputTokens, 'maxInputTokens', { minimum: 1 }),
204
+ default_max_output_tokens: integer(defaultMaxOutputTokens, 'defaultMaxOutputTokens', { minimum: 1 }),
205
+ max_output_tokens: integer(maxOutputTokens, 'maxOutputTokens', { minimum: 1 }),
206
+ maximum_attempts: integer(maximumAttempts, 'maximumAttempts', { minimum: 1 }),
207
+ };
208
+ if (revision.default_max_output_tokens > revision.max_output_tokens) throw new Error('default output limit exceeds maximum');
209
+ this.db.exec('BEGIN IMMEDIATE');
210
+ try {
211
+ this.db.prepare(`INSERT OR IGNORE INTO cloud_model_aliases (alias, display_name, active, created_at) VALUES (?, ?, ?, ?)`)
212
+ .run(alias, text(displayName, 'displayName'), active ? 1 : 0, this.now());
213
+ const existing = this.db.prepare('SELECT * FROM cloud_model_alias_revisions WHERE id = ?').get(revision.id);
214
+ if (existing) exactReplay(existing, revision, ['alias', 'upstream_alias', 'pricing_version_id', 'max_input_tokens', 'default_max_output_tokens', 'max_output_tokens', 'maximum_attempts'], 'alias_revision');
215
+ else this.db.prepare(`INSERT INTO cloud_model_alias_revisions
216
+ (id, alias, upstream_alias, pricing_version_id, max_input_tokens, default_max_output_tokens, max_output_tokens, maximum_attempts, created_at)
217
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(revision.id, alias, revision.upstream_alias, pricingVersionId, revision.max_input_tokens, revision.default_max_output_tokens, revision.max_output_tokens, revision.maximum_attempts, this.now());
218
+ this.db.prepare('UPDATE cloud_model_aliases SET display_name = ?, current_revision_id = ?, active = ? WHERE alias = ?')
219
+ .run(displayName.trim(), revision.id, active ? 1 : 0, alias);
220
+ this.db.exec('COMMIT');
221
+ } catch (error) { this.db.exec('ROLLBACK'); throw error; }
222
+ return this.resolveAlias(alias);
223
+ }
224
+
225
+ resolveAlias(alias) {
226
+ return this.db.prepare(`SELECT a.alias, a.display_name, a.active, r.*, p.input_nano_usd_per_million_tokens,
227
+ p.output_nano_usd_per_million_tokens, p.fixed_request_nano_usd
228
+ FROM cloud_model_aliases a JOIN cloud_model_alias_revisions r ON r.id = a.current_revision_id
229
+ JOIN cloud_pricing_versions p ON p.id = r.pricing_version_id WHERE a.alias = ? AND a.active = 1`).get(alias);
230
+ }
231
+
232
+ models() {
233
+ return this.db.prepare(`SELECT alias AS id, display_name AS name FROM cloud_model_aliases
234
+ WHERE active = 1 AND current_revision_id IS NOT NULL ORDER BY alias`).all()
235
+ .map(row => ({ id: row.id, name: row.name }));
236
+ }
237
+
238
+ modelEnvelope() {
239
+ const created = Math.floor(this.clock().getTime() / 1000);
240
+ return { object: 'list', data: this.models().map(model => ({ id: model.id, object: 'model', created, owned_by: 'carthagent' })) };
241
+ }
242
+
243
+ request(requestId) {
244
+ const row = this.db.prepare('SELECT * FROM cloud_gateway_requests WHERE id = ?').get(requestId);
245
+ if (!row) return null;
246
+ return { ...row, attempts: this.db.prepare('SELECT * FROM cloud_gateway_attempts WHERE gateway_request_row_id = ? ORDER BY rowid').all(requestId) };
247
+ }
248
+
249
+ frozenRevision(revisionId) {
250
+ return this.db.prepare(`SELECT r.*, p.input_nano_usd_per_million_tokens,
251
+ p.output_nano_usd_per_million_tokens, p.fixed_request_nano_usd
252
+ FROM cloud_model_alias_revisions r JOIN cloud_pricing_versions p ON p.id = r.pricing_version_id
253
+ WHERE r.id = ?`).get(revisionId);
254
+ }
255
+
256
+ mark(requestId, state) {
257
+ this.db.prepare('UPDATE cloud_gateway_requests SET state = ?, updated_at = ? WHERE id = ?').run(state, this.now(), requestId);
258
+ }
259
+
260
+ async invoke({ accountId, endpoint, body, operationKey = this.id(), clientRequestId, signal }) {
261
+ if (!['chat.completions', 'responses'].includes(endpoint)) throw new Error('unsupported_gateway_endpoint');
262
+ operationKey = text(operationKey, 'operation key', 512);
263
+ const duplicate = this.db.prepare('SELECT * FROM cloud_gateway_requests WHERE account_id = ? AND operation_key = ?').get(accountId, operationKey);
264
+ if (duplicate) throw new Error(duplicate.state === 'reconciliation_required' ? 'request_reconciliation_required' : 'idempotency_key_reused');
265
+ const revision = this.resolveAlias(body?.model);
266
+ if (!revision) throw new Error('model_not_found');
267
+ const bounded = boundedGatewayRequest(endpoint, body, revision);
268
+ const maximumAttempts = Number(revision.maximum_attempts);
269
+ const maximumNanoUsd = customerChargeNanoUsd(revision, Array.from({ length: maximumAttempts }, () => ({
270
+ inputTokens: bounded.inputTokens, outputTokens: bounded.outputTokens,
271
+ })));
272
+ if (maximumNanoUsd <= 0) throw new Error('invalid_zero_cost_reservation');
273
+
274
+ const id = this.id();
275
+ const now = this.now();
276
+ this.db.prepare(`INSERT INTO cloud_gateway_requests
277
+ (id, account_id, operation_key, endpoint, alias, alias_revision_id, pricing_version_id, state, created_at, updated_at)
278
+ VALUES (?, ?, ?, ?, ?, ?, ?, 'admitting', ?, ?)`).run(id, accountId, operationKey, endpoint, revision.alias, revision.id, revision.pricing_version_id, now, now);
279
+ let reservation;
280
+ try {
281
+ reservation = this.ledger.reserve({ accountId, maximumNanoUsd, idempotencyKey: `gateway:${accountId}:${operationKey}` });
282
+ this.db.prepare(`UPDATE cloud_gateway_requests SET reservation_id = ?, state = 'reserved', updated_at = ? WHERE id = ?`).run(reservation.id, this.now(), id);
283
+ } catch (error) {
284
+ this.mark(id, error.message === 'insufficient_credit' ? 'rejected_insufficient_credit' : 'admission_failed');
285
+ throw error;
286
+ }
287
+
288
+ let response;
289
+ try {
290
+ response = await this.upstream.dispatch({
291
+ endpoint, body: bounded.request, operationKey,
292
+ clientRequestId: clientRequestId || id, signal,
293
+ });
294
+ } catch (error) {
295
+ if (error?.outcome === 'not_dispatched') {
296
+ this.ledger.release({ reservationId: reservation.id, idempotencyKey: `gateway-release:${id}` });
297
+ this.mark(id, 'released_not_dispatched');
298
+ } else this.mark(id, 'reconciliation_required');
299
+ throw error;
300
+ }
301
+ const gatewayRequestId = response.headers.get('x-request-id');
302
+ this.db.prepare(`UPDATE cloud_gateway_requests SET gateway_request_id = ?, state = 'dispatched', updated_at = ? WHERE id = ?`)
303
+ .run(gatewayRequestId || null, this.now(), id);
304
+ return { id, response, reservation, revision };
305
+ }
306
+
307
+ async finalize(handle, { responseBody = null } = {}) {
308
+ const current = this.request(handle.id);
309
+ if (!current) throw new Error('gateway_request_not_found');
310
+ if (['settled', 'released_not_dispatched'].includes(current.state)) return current;
311
+ let settlement;
312
+ try {
313
+ settlement = await this.settlementSource.resolve({
314
+ gatewayRequest: current, response: handle.response, responseBody,
315
+ });
316
+ } catch {
317
+ this.mark(handle.id, 'reconciliation_required');
318
+ return this.request(handle.id);
319
+ }
320
+ return this.reconcile(handle.id, settlement);
321
+ }
322
+
323
+ async reconcile(requestRowId, settlement = null) {
324
+ const current = this.request(requestRowId);
325
+ if (!current) throw new Error('gateway_request_not_found');
326
+ if (['settled', 'released_not_dispatched'].includes(current.state)) return current;
327
+ if (!settlement) {
328
+ try { settlement = await this.settlementSource.resolve({ gatewayRequest: current }); }
329
+ catch { settlement = null; }
330
+ }
331
+ if (settlement?.status === 'not_dispatched') {
332
+ this.ledger.release({ reservationId: current.reservation_id, idempotencyKey: `gateway-release:${requestRowId}` });
333
+ this.mark(requestRowId, 'released_not_dispatched');
334
+ return this.request(requestRowId);
335
+ }
336
+ if (settlement?.status !== 'terminal') {
337
+ this.mark(requestRowId, 'reconciliation_required');
338
+ return this.request(requestRowId);
339
+ }
340
+ try {
341
+ const revision = this.frozenRevision(current.alias_revision_id);
342
+ if (!revision) throw new Error('alias_revision_not_found');
343
+ const requestId = text(settlement.requestId, 'gateway request id', 512);
344
+ if (current.gateway_request_id && current.gateway_request_id !== requestId) throw new Error('gateway_request_id_mismatch');
345
+ if (!TERMINAL_STATES.has(settlement.terminalState)) throw new Error('invalid_terminal_state');
346
+ if (!Array.isArray(settlement.attempts) || !settlement.attempts.length || settlement.attempts.length > Number(revision.maximum_attempts)) throw new Error('invalid_gateway_attempts');
347
+ const attempts = settlement.attempts.map(attempt => ({
348
+ attemptId: text(attempt.attemptId, 'gateway attempt id', 512),
349
+ state: text(attempt.state, 'attempt state', 40),
350
+ inputTokens: integer(attempt.inputTokens, 'inputTokens'),
351
+ outputTokens: integer(attempt.outputTokens, 'outputTokens'),
352
+ }));
353
+ if (new Set(attempts.map(attempt => attempt.attemptId)).size !== attempts.length) throw new Error('duplicate_gateway_attempt');
354
+ const charge = customerChargeNanoUsd(revision, attempts);
355
+ const inputTokens = attempts.reduce((sum, attempt) => sum + attempt.inputTokens, 0);
356
+ const outputTokens = attempts.reduce((sum, attempt) => sum + attempt.outputTokens, 0);
357
+ this.ledger.settle({
358
+ reservationId: current.reservation_id, actualNanoUsd: charge, requestId,
359
+ attemptId: attempts.at(-1).attemptId, idempotencyKey: `gateway-settle:${requestRowId}`,
360
+ metadata: {
361
+ alias: current.alias, pricingVersionId: current.pricing_version_id,
362
+ inputTokens, outputTokens, status: settlement.terminalState,
363
+ },
364
+ });
365
+ for (const attempt of attempts) this.db.prepare(`INSERT OR IGNORE INTO cloud_gateway_attempts
366
+ (gateway_request_row_id, attempt_id, state, input_tokens, output_tokens) VALUES (?, ?, ?, ?, ?)`)
367
+ .run(requestRowId, attempt.attemptId, attempt.state, attempt.inputTokens, attempt.outputTokens);
368
+ this.db.prepare(`UPDATE cloud_gateway_requests SET state = 'settled', gateway_request_id = ?, customer_charge_nano_usd = ?,
369
+ input_tokens = ?, output_tokens = ?, terminal_state = ?, updated_at = ? WHERE id = ?`)
370
+ .run(requestId, charge, inputTokens, outputTokens, settlement.terminalState, this.now(), requestRowId);
371
+ } catch {
372
+ this.mark(requestRowId, 'reconciliation_required');
373
+ }
374
+ return this.request(requestRowId);
375
+ }
376
+ }
377
+
378
+ export function copyGatewayResponseHeaders(response, res) {
379
+ for (const [name, value] of response.headers) if (SAFE_RESPONSE_HEADERS.has(name.toLowerCase())) res.setHeader(name, value);
380
+ }
@@ -0,0 +1,135 @@
1
+ import express from 'express';
2
+ import { copyGatewayResponseHeaders } from './gateway.mjs';
3
+
4
+ const asyncRoute = handler => (req, res, next) => Promise.resolve(handler(req, res)).catch(next);
5
+ const bearer = req => String(req.get('authorization') || '').replace(/^Bearer\s+/i, '');
6
+
7
+ function oauthError(error) {
8
+ const code = error?.message || 'server_error';
9
+ const status = ['invalid_grant', 'invalid_token', 'revoked_token', 'access_denied'].includes(code) ? 401
10
+ : code === 'authorization_pending' ? 428
11
+ : code === 'expired_token' ? 410
12
+ : code === 'insufficient_scope' ? 403 : 400;
13
+ return { status, body: { error: code } };
14
+ }
15
+
16
+ export function cloudRouter(control, { authenticateBrowser, gateway = null } = {}) {
17
+ const router = express.Router();
18
+ router.use(express.json({ limit: '1mb' }));
19
+
20
+ const browserAccount = async req => {
21
+ if (!authenticateBrowser) throw new Error('browser_auth_not_configured');
22
+ const account = await authenticateBrowser(req);
23
+ if (!account?.accountId) throw new Error('unauthorized');
24
+ return account;
25
+ };
26
+
27
+ router.post('/v1/device/authorizations', (req, res) => {
28
+ res.status(201).json(control.createDeviceAuthorization({ clientName: req.body?.clientName }));
29
+ });
30
+ router.post('/v1/device/authorizations/:userCode/approve', asyncRoute(async (req, res) => {
31
+ const account = await browserAccount(req);
32
+ res.json(control.approveDeviceAuthorization({ userCode: req.params.userCode, accountId: account.accountId, approve: req.body?.approve !== false }));
33
+ }));
34
+ router.post('/v1/device/token', (req, res) => {
35
+ try { res.json(control.pollDeviceToken({ deviceCode: req.body?.deviceCode })); }
36
+ catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
37
+ });
38
+ router.post('/v1/token/refresh', (req, res) => {
39
+ try { res.json(control.refreshSession({ refreshToken: req.body?.refreshToken })); }
40
+ catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
41
+ });
42
+ router.post('/v1/token/revoke', (req, res) => {
43
+ try { const claims = control.verifyAccessToken(bearer(req)); res.json(control.revokeSession({ sessionId: claims.sid })); }
44
+ catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
45
+ });
46
+ router.get('/v1/models', (req, res) => {
47
+ if (!gateway) return res.status(503).json({ error: { message: 'Cloud gateway is not configured.', type: 'api_error', code: 'gateway_not_configured' } });
48
+ try { control.verifyAccessToken(bearer(req), 'models:read'); res.json(gateway.modelEnvelope()); }
49
+ catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
50
+ });
51
+ const invokeGateway = endpoint => asyncRoute(async (req, res) => {
52
+ if (!gateway) return res.status(503).json({ error: { message: 'Cloud gateway is not configured.', type: 'api_error', code: 'gateway_not_configured' } });
53
+ const claims = control.verifyAccessToken(bearer(req), 'gateway:invoke');
54
+ let handle;
55
+ try {
56
+ handle = await gateway.invoke({
57
+ accountId: claims.sub, endpoint, body: req.body,
58
+ operationKey: req.get('idempotency-key') || undefined,
59
+ clientRequestId: req.get('x-client-request-id') || undefined,
60
+ signal: req.signal,
61
+ });
62
+ } catch (error) {
63
+ if (error.message === 'insufficient_credit') return res.status(429).json({ error: { message: 'Carthagent Ship credit is exhausted. Manage billing or connect your own provider with /login.', type: 'insufficient_quota', code: 'insufficient_quota' } });
64
+ if (['model_not_found', 'request_too_large', 'max_output_tokens_exceeded', 'idempotency_key_reused', 'request_reconciliation_required'].includes(error.message)) return res.status(400).json({ error: { message: error.message, type: 'invalid_request_error', code: error.message } });
65
+ throw error;
66
+ }
67
+ copyGatewayResponseHeaders(handle.response, res);
68
+ res.status(handle.response.status);
69
+ if (handle.response.body) {
70
+ const chunks = [];
71
+ try {
72
+ for await (const chunk of handle.response.body) { const buffer = Buffer.from(chunk); chunks.push(buffer); res.write(buffer); }
73
+ res.end();
74
+ } finally { await gateway.finalize(handle, { responseBody: Buffer.concat(chunks) }); }
75
+ } else {
76
+ res.status(handle.response.status).end();
77
+ await gateway.finalize(handle);
78
+ }
79
+ });
80
+ router.post('/v1/chat/completions', invokeGateway('chat.completions'));
81
+ router.post('/v1/responses', invokeGateway('responses'));
82
+ router.get('/v1/account', (req, res) => {
83
+ try {
84
+ const claims = control.verifyAccessToken(bearer(req), 'account:read');
85
+ res.json(control.account(claims.sub));
86
+ } catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
87
+ });
88
+ router.get('/v1/account/balance', (req, res) => {
89
+ try {
90
+ const claims = control.verifyAccessToken(bearer(req), 'account:read');
91
+ const account = control.account(claims.sub);
92
+ res.json({ accountId: claims.sub, balance: account.balance, grants: account.grants });
93
+ } catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
94
+ });
95
+ router.get('/v1/account/usage', (req, res) => {
96
+ try {
97
+ const claims = control.verifyAccessToken(bearer(req), 'account:read');
98
+ const requested = Number(req.query?.limit);
99
+ res.json({ accountId: claims.sub, data: control.usage(claims.sub, { limit: Number.isSafeInteger(requested) ? requested : 50 }) });
100
+ } catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
101
+ });
102
+ router.get('/v1/account/sessions', (req, res) => {
103
+ try {
104
+ const claims = control.verifyAccessToken(bearer(req), 'account:read');
105
+ res.json({ accountId: claims.sub, currentSessionId: claims.sid, data: control.listSessions(claims.sub) });
106
+ } catch (error) { const out = oauthError(error); res.status(out.status).json(out.body); }
107
+ });
108
+ router.delete('/v1/account/sessions/:sessionId', (req, res) => {
109
+ try {
110
+ const claims = control.verifyAccessToken(bearer(req), 'account:read');
111
+ res.json(control.revokeAccountSession({ accountId: claims.sub, sessionId: req.params.sessionId }));
112
+ } catch (error) {
113
+ if (error.message === 'session_not_found') return res.status(404).json({ error: error.message });
114
+ const out = oauthError(error); res.status(out.status).json(out.body);
115
+ }
116
+ });
117
+ router.post('/v1/billing/checkout', asyncRoute(async (req, res) => {
118
+ const claims = control.verifyAccessToken(bearer(req), 'account:read');
119
+ res.json(await control.createCheckout({ accountId: claims.sub, successUrl: req.body?.successUrl, cancelUrl: req.body?.cancelUrl, priceId: req.body?.priceId || req.body?.planId }));
120
+ }));
121
+ router.post('/v1/billing/portal', asyncRoute(async (req, res) => {
122
+ const claims = control.verifyAccessToken(bearer(req), 'account:read');
123
+ res.json(await control.createPortal({ accountId: claims.sub, returnUrl: req.body?.returnUrl }));
124
+ }));
125
+ router.post('/v1/webhooks/stripe', express.raw({ type: 'application/json', limit: '256kb' }), (req, res) => {
126
+ try { res.json(control.handleStripeWebhook({ payload: req.body.toString('utf8'), signature: req.get('stripe-signature') })); }
127
+ catch (error) { res.status(400).json({ error: error.message }); }
128
+ });
129
+ router.use((error, req, res, next) => {
130
+ if (res.headersSent) return next(error);
131
+ const status = ['unauthorized', 'browser_auth_not_configured'].includes(error.message) ? 401 : 400;
132
+ res.status(status).json({ error: error.message });
133
+ });
134
+ return router;
135
+ }