@blockrun/clawrouter 0.12.94 → 0.12.96

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 (282) hide show
  1. package/dist/auth.d.ts +82 -0
  2. package/dist/auth.d.ts.map +1 -0
  3. package/dist/auth.js +388 -0
  4. package/dist/auth.js.map +1 -0
  5. package/dist/balance.d.ts +97 -0
  6. package/dist/balance.d.ts.map +1 -0
  7. package/dist/balance.js +157 -0
  8. package/dist/balance.js.map +1 -0
  9. package/dist/cli.d.ts +15 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +256 -54991
  12. package/dist/cli.js.map +1 -1
  13. package/dist/compression/codebook.d.ts +24 -0
  14. package/dist/compression/codebook.d.ts.map +1 -0
  15. package/dist/compression/codebook.js +119 -0
  16. package/dist/compression/codebook.js.map +1 -0
  17. package/dist/compression/index.d.ts +33 -0
  18. package/dist/compression/index.d.ts.map +1 -0
  19. package/dist/compression/index.js +246 -0
  20. package/dist/compression/index.js.map +1 -0
  21. package/dist/compression/layers/deduplication.d.ts +28 -0
  22. package/dist/compression/layers/deduplication.d.ts.map +1 -0
  23. package/dist/compression/layers/deduplication.js +101 -0
  24. package/dist/compression/layers/deduplication.js.map +1 -0
  25. package/dist/compression/layers/dictionary.d.ts +21 -0
  26. package/dist/compression/layers/dictionary.d.ts.map +1 -0
  27. package/dist/compression/layers/dictionary.js +73 -0
  28. package/dist/compression/layers/dictionary.js.map +1 -0
  29. package/dist/compression/layers/dynamic-codebook.d.ts +26 -0
  30. package/dist/compression/layers/dynamic-codebook.d.ts.map +1 -0
  31. package/dist/compression/layers/dynamic-codebook.js +149 -0
  32. package/dist/compression/layers/dynamic-codebook.js.map +1 -0
  33. package/dist/compression/layers/json-compact.d.ts +23 -0
  34. package/dist/compression/layers/json-compact.d.ts.map +1 -0
  35. package/dist/compression/layers/json-compact.js +79 -0
  36. package/dist/compression/layers/json-compact.js.map +1 -0
  37. package/dist/compression/layers/observation.d.ts +21 -0
  38. package/dist/compression/layers/observation.d.ts.map +1 -0
  39. package/dist/compression/layers/observation.js +130 -0
  40. package/dist/compression/layers/observation.js.map +1 -0
  41. package/dist/compression/layers/paths.d.ts +24 -0
  42. package/dist/compression/layers/paths.d.ts.map +1 -0
  43. package/dist/compression/layers/paths.js +110 -0
  44. package/dist/compression/layers/paths.js.map +1 -0
  45. package/dist/compression/layers/whitespace.d.ts +27 -0
  46. package/dist/compression/layers/whitespace.d.ts.map +1 -0
  47. package/dist/compression/layers/whitespace.js +60 -0
  48. package/dist/compression/layers/whitespace.js.map +1 -0
  49. package/dist/compression/types.d.ts +84 -0
  50. package/dist/compression/types.d.ts.map +1 -0
  51. package/dist/compression/types.js +27 -0
  52. package/dist/compression/types.js.map +1 -0
  53. package/dist/config.d.ts +12 -0
  54. package/dist/config.d.ts.map +1 -0
  55. package/dist/config.js +22 -0
  56. package/dist/config.js.map +1 -0
  57. package/dist/dedup.d.ts +34 -0
  58. package/dist/dedup.d.ts.map +1 -0
  59. package/dist/dedup.js +150 -0
  60. package/dist/dedup.js.map +1 -0
  61. package/dist/doctor.d.ts +8 -0
  62. package/dist/doctor.d.ts.map +1 -0
  63. package/dist/doctor.js +375 -0
  64. package/dist/doctor.js.map +1 -0
  65. package/dist/error-classification.test.d.ts +2 -0
  66. package/dist/error-classification.test.d.ts.map +1 -0
  67. package/dist/error-classification.test.js +59 -0
  68. package/dist/error-classification.test.js.map +1 -0
  69. package/dist/errors.d.ts +54 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +85 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/exclude-models.d.ts +28 -0
  74. package/dist/exclude-models.d.ts.map +1 -0
  75. package/dist/exclude-models.integration.test.d.ts +2 -0
  76. package/dist/exclude-models.integration.test.d.ts.map +1 -0
  77. package/dist/exclude-models.integration.test.js +46 -0
  78. package/dist/exclude-models.integration.test.js.map +1 -0
  79. package/dist/exclude-models.js +70 -0
  80. package/dist/exclude-models.js.map +1 -0
  81. package/dist/exclude-models.test.d.ts +5 -0
  82. package/dist/exclude-models.test.d.ts.map +1 -0
  83. package/dist/exclude-models.test.js +95 -0
  84. package/dist/exclude-models.test.js.map +1 -0
  85. package/dist/fs-read.d.ts +11 -0
  86. package/dist/fs-read.d.ts.map +1 -0
  87. package/dist/fs-read.js +47 -0
  88. package/dist/fs-read.js.map +1 -0
  89. package/dist/index.d.ts +34 -1368
  90. package/dist/index.d.ts.map +1 -0
  91. package/dist/index.js +1071 -56191
  92. package/dist/index.js.map +1 -1
  93. package/dist/journal.d.ts +67 -0
  94. package/dist/journal.d.ts.map +1 -0
  95. package/dist/journal.js +184 -0
  96. package/dist/journal.js.map +1 -0
  97. package/dist/journal.test.d.ts +2 -0
  98. package/dist/journal.test.d.ts.map +1 -0
  99. package/dist/journal.test.js +201 -0
  100. package/dist/journal.test.js.map +1 -0
  101. package/dist/logger.d.ts +33 -0
  102. package/dist/logger.d.ts.map +1 -0
  103. package/dist/logger.js +35 -0
  104. package/dist/logger.js.map +1 -0
  105. package/dist/models.d.ts +107 -0
  106. package/dist/models.d.ts.map +1 -0
  107. package/dist/models.js +941 -0
  108. package/dist/models.js.map +1 -0
  109. package/dist/models.test.d.ts +2 -0
  110. package/dist/models.test.d.ts.map +1 -0
  111. package/dist/models.test.js +38 -0
  112. package/dist/models.test.js.map +1 -0
  113. package/dist/partners/index.d.ts +8 -0
  114. package/dist/partners/index.d.ts.map +1 -0
  115. package/dist/partners/index.js +6 -0
  116. package/dist/partners/index.js.map +1 -0
  117. package/dist/partners/registry.d.ts +51 -0
  118. package/dist/partners/registry.d.ts.map +1 -0
  119. package/dist/partners/registry.js +256 -0
  120. package/dist/partners/registry.js.map +1 -0
  121. package/dist/partners/tools.d.ts +24 -0
  122. package/dist/partners/tools.d.ts.map +1 -0
  123. package/dist/partners/tools.js +97 -0
  124. package/dist/partners/tools.js.map +1 -0
  125. package/dist/payment-preauth.d.ts +17 -0
  126. package/dist/payment-preauth.d.ts.map +1 -0
  127. package/dist/payment-preauth.js +102 -0
  128. package/dist/payment-preauth.js.map +1 -0
  129. package/dist/provider.d.ts +19 -0
  130. package/dist/provider.d.ts.map +1 -0
  131. package/dist/provider.js +45 -0
  132. package/dist/provider.js.map +1 -0
  133. package/dist/proxy.d.ts +210 -0
  134. package/dist/proxy.d.ts.map +1 -0
  135. package/dist/proxy.debrand.test.d.ts +2 -0
  136. package/dist/proxy.debrand.test.d.ts.map +1 -0
  137. package/dist/proxy.debrand.test.js +101 -0
  138. package/dist/proxy.debrand.test.js.map +1 -0
  139. package/dist/proxy.degraded-response.test.d.ts +2 -0
  140. package/dist/proxy.degraded-response.test.d.ts.map +1 -0
  141. package/dist/proxy.degraded-response.test.js +105 -0
  142. package/dist/proxy.degraded-response.test.js.map +1 -0
  143. package/dist/proxy.image-download.test.d.ts +2 -0
  144. package/dist/proxy.image-download.test.d.ts.map +1 -0
  145. package/dist/proxy.image-download.test.js +92 -0
  146. package/dist/proxy.image-download.test.js.map +1 -0
  147. package/dist/proxy.js +3810 -0
  148. package/dist/proxy.js.map +1 -0
  149. package/dist/proxy.models-endpoint.test.d.ts +2 -0
  150. package/dist/proxy.models-endpoint.test.d.ts.map +1 -0
  151. package/dist/proxy.models-endpoint.test.js +19 -0
  152. package/dist/proxy.models-endpoint.test.js.map +1 -0
  153. package/dist/proxy.multiturn-thinking.test.d.ts +2 -0
  154. package/dist/proxy.multiturn-thinking.test.d.ts.map +1 -0
  155. package/dist/proxy.multiturn-thinking.test.js +82 -0
  156. package/dist/proxy.multiturn-thinking.test.js.map +1 -0
  157. package/dist/proxy.payment-chain-reuse.test.d.ts +2 -0
  158. package/dist/proxy.payment-chain-reuse.test.d.ts.map +1 -0
  159. package/dist/proxy.payment-chain-reuse.test.js +30 -0
  160. package/dist/proxy.payment-chain-reuse.test.js.map +1 -0
  161. package/dist/proxy.solana-resilience.test.d.ts +12 -0
  162. package/dist/proxy.solana-resilience.test.d.ts.map +1 -0
  163. package/dist/proxy.solana-resilience.test.js +39 -0
  164. package/dist/proxy.solana-resilience.test.js.map +1 -0
  165. package/dist/report.d.ts +6 -0
  166. package/dist/report.d.ts.map +1 -0
  167. package/dist/report.js +44 -0
  168. package/dist/report.js.map +1 -0
  169. package/dist/response-cache.advanced.test.d.ts +2 -0
  170. package/dist/response-cache.advanced.test.d.ts.map +1 -0
  171. package/dist/response-cache.advanced.test.js +566 -0
  172. package/dist/response-cache.advanced.test.js.map +1 -0
  173. package/dist/response-cache.d.ts +85 -0
  174. package/dist/response-cache.d.ts.map +1 -0
  175. package/dist/response-cache.edge-cases.test.d.ts +2 -0
  176. package/dist/response-cache.edge-cases.test.d.ts.map +1 -0
  177. package/dist/response-cache.edge-cases.test.js +488 -0
  178. package/dist/response-cache.edge-cases.test.js.map +1 -0
  179. package/dist/response-cache.extreme.test.d.ts +2 -0
  180. package/dist/response-cache.extreme.test.d.ts.map +1 -0
  181. package/dist/response-cache.extreme.test.js +561 -0
  182. package/dist/response-cache.extreme.test.js.map +1 -0
  183. package/dist/response-cache.js +240 -0
  184. package/dist/response-cache.js.map +1 -0
  185. package/dist/response-cache.test.d.ts +2 -0
  186. package/dist/response-cache.test.d.ts.map +1 -0
  187. package/dist/response-cache.test.js +242 -0
  188. package/dist/response-cache.test.js.map +1 -0
  189. package/dist/retry.d.ts +42 -0
  190. package/dist/retry.d.ts.map +1 -0
  191. package/dist/retry.js +99 -0
  192. package/dist/retry.js.map +1 -0
  193. package/dist/router/config.d.ts +11 -0
  194. package/dist/router/config.d.ts.map +1 -0
  195. package/dist/router/config.js +1215 -0
  196. package/dist/router/config.js.map +1 -0
  197. package/dist/router/index.d.ts +18 -0
  198. package/dist/router/index.d.ts.map +1 -0
  199. package/dist/router/index.js +19 -0
  200. package/dist/router/index.js.map +1 -0
  201. package/dist/router/llm-classifier.d.ts +29 -0
  202. package/dist/router/llm-classifier.d.ts.map +1 -0
  203. package/dist/router/llm-classifier.js +99 -0
  204. package/dist/router/llm-classifier.js.map +1 -0
  205. package/dist/router/rules.d.ts +12 -0
  206. package/dist/router/rules.d.ts.map +1 -0
  207. package/dist/router/rules.js +202 -0
  208. package/dist/router/rules.js.map +1 -0
  209. package/dist/router/selector.d.ts +58 -0
  210. package/dist/router/selector.d.ts.map +1 -0
  211. package/dist/router/selector.js +165 -0
  212. package/dist/router/selector.js.map +1 -0
  213. package/dist/router/selector.test.d.ts +2 -0
  214. package/dist/router/selector.test.d.ts.map +1 -0
  215. package/dist/router/selector.test.js +67 -0
  216. package/dist/router/selector.test.js.map +1 -0
  217. package/dist/router/strategy.d.ts +19 -0
  218. package/dist/router/strategy.d.ts.map +1 -0
  219. package/dist/router/strategy.js +138 -0
  220. package/dist/router/strategy.js.map +1 -0
  221. package/dist/router/strategy.test.d.ts +2 -0
  222. package/dist/router/strategy.test.d.ts.map +1 -0
  223. package/dist/router/strategy.test.js +131 -0
  224. package/dist/router/strategy.test.js.map +1 -0
  225. package/dist/router/types.d.ts +127 -0
  226. package/dist/router/types.d.ts.map +1 -0
  227. package/dist/router/types.js +11 -0
  228. package/dist/router/types.js.map +1 -0
  229. package/dist/session.d.ts +124 -0
  230. package/dist/session.d.ts.map +1 -0
  231. package/dist/session.js +256 -0
  232. package/dist/session.js.map +1 -0
  233. package/dist/session.test.d.ts +2 -0
  234. package/dist/session.test.d.ts.map +1 -0
  235. package/dist/session.test.js +163 -0
  236. package/dist/session.test.js.map +1 -0
  237. package/dist/solana-balance.d.ts +48 -0
  238. package/dist/solana-balance.d.ts.map +1 -0
  239. package/dist/solana-balance.js +142 -0
  240. package/dist/solana-balance.js.map +1 -0
  241. package/dist/spend-control.d.ts +112 -0
  242. package/dist/spend-control.d.ts.map +1 -0
  243. package/dist/spend-control.js +278 -0
  244. package/dist/spend-control.js.map +1 -0
  245. package/dist/spend-control.test.d.ts +5 -0
  246. package/dist/spend-control.test.d.ts.map +1 -0
  247. package/dist/spend-control.test.js +210 -0
  248. package/dist/spend-control.test.js.map +1 -0
  249. package/dist/stats.d.ts +64 -0
  250. package/dist/stats.d.ts.map +1 -0
  251. package/dist/stats.js +295 -0
  252. package/dist/stats.js.map +1 -0
  253. package/dist/types.d.ts +150 -0
  254. package/dist/types.d.ts.map +1 -0
  255. package/dist/types.js +9 -0
  256. package/dist/types.js.map +1 -0
  257. package/dist/update-hint.test.d.ts +9 -0
  258. package/dist/update-hint.test.d.ts.map +1 -0
  259. package/dist/update-hint.test.js +156 -0
  260. package/dist/update-hint.test.js.map +1 -0
  261. package/dist/updater.d.ts +10 -0
  262. package/dist/updater.d.ts.map +1 -0
  263. package/dist/updater.js +55 -0
  264. package/dist/updater.js.map +1 -0
  265. package/dist/version.d.ts +3 -0
  266. package/dist/version.d.ts.map +1 -0
  267. package/dist/version.js +16 -0
  268. package/dist/version.js.map +1 -0
  269. package/dist/wallet.d.ts +52 -0
  270. package/dist/wallet.d.ts.map +1 -0
  271. package/dist/wallet.js +93 -0
  272. package/dist/wallet.js.map +1 -0
  273. package/dist/wallet.test.d.ts +6 -0
  274. package/dist/wallet.test.d.ts.map +1 -0
  275. package/dist/wallet.test.js +117 -0
  276. package/dist/wallet.test.js.map +1 -0
  277. package/dist/x402-sdk.test.d.ts +6 -0
  278. package/dist/x402-sdk.test.d.ts.map +1 -0
  279. package/dist/x402-sdk.test.js +161 -0
  280. package/dist/x402-sdk.test.js.map +1 -0
  281. package/package.json +2 -2
  282. package/scripts/e2e-multiturn.mjs +4 -1
package/dist/proxy.js ADDED
@@ -0,0 +1,3810 @@
1
+ /**
2
+ * Local x402 Proxy Server
3
+ *
4
+ * Sits between OpenClaw's pi-ai (which makes standard OpenAI-format requests)
5
+ * and BlockRun's API (which requires x402 micropayments).
6
+ *
7
+ * Flow:
8
+ * pi-ai → http://localhost:{port}/v1/chat/completions
9
+ * → proxy forwards to https://blockrun.ai/api/v1/chat/completions
10
+ * → gets 402 → @x402/fetch signs payment → retries
11
+ * → streams response back to pi-ai
12
+ *
13
+ * Optimizations (v0.3.0):
14
+ * - SSE heartbeat: for streaming requests, sends headers + heartbeat immediately
15
+ * before the x402 flow, preventing OpenClaw's 10-15s timeout from firing.
16
+ * - Response dedup: hashes request bodies and caches responses for 30s,
17
+ * preventing double-charging when OpenClaw retries after timeout.
18
+ * - Smart routing: when model is "blockrun/auto", classify query and pick cheapest model.
19
+ * - Usage logging: log every request as JSON line to ~/.openclaw/blockrun/logs/
20
+ */
21
+ import { AsyncLocalStorage } from "node:async_hooks";
22
+ import { createServer } from "node:http";
23
+ // Per-request payment tracking via AsyncLocalStorage (safe for concurrent requests).
24
+ // The x402 onAfterPaymentCreation hook writes the actual payment amount into the
25
+ // request-scoped store, and the logging code reads it after payFetch completes.
26
+ const paymentStore = new AsyncLocalStorage();
27
+ import { finished } from "node:stream";
28
+ import { homedir } from "node:os";
29
+ import { join } from "node:path";
30
+ import { mkdir, writeFile, readFile, stat as fsStat } from "node:fs/promises";
31
+ import { readFileSync, existsSync } from "node:fs";
32
+ import { createPublicClient, http } from "viem";
33
+ import { base } from "viem/chains";
34
+ import { privateKeyToAccount } from "viem/accounts";
35
+ import { x402Client } from "@x402/fetch";
36
+ import { createPayFetchWithPreAuth } from "./payment-preauth.js";
37
+ import { registerExactEvmScheme } from "@x402/evm/exact/client";
38
+ import { toClientEvmSigner } from "@x402/evm";
39
+ import { route, getFallbackChain, getFallbackChainFiltered, filterByToolCalling, filterByVision, filterByExcludeList, calculateModelCost, DEFAULT_ROUTING_CONFIG, } from "./router/index.js";
40
+ import { classifyByRules } from "./router/rules.js";
41
+ import { BLOCKRUN_MODELS, OPENCLAW_MODELS, resolveModelAlias, getModelContextWindow, isReasoningModel, supportsToolCalling, supportsVision, getActivePromoPrice, } from "./models.js";
42
+ import { logUsage } from "./logger.js";
43
+ import { getStats, clearStats } from "./stats.js";
44
+ import { RequestDeduplicator } from "./dedup.js";
45
+ import { ResponseCache } from "./response-cache.js";
46
+ import { BalanceMonitor } from "./balance.js";
47
+ import { resolvePaymentChain } from "./auth.js";
48
+ import { compressContext, shouldCompress } from "./compression/index.js";
49
+ // Error classes available for programmatic use but not used in proxy
50
+ // (universal free fallback means we don't throw balance errors anymore)
51
+ // import { InsufficientFundsError, EmptyWalletError } from "./errors.js";
52
+ import { USER_AGENT, VERSION } from "./version.js";
53
+ import { SessionStore, getSessionId, deriveSessionId, hashRequestContent, } from "./session.js";
54
+ import { checkForUpdates } from "./updater.js";
55
+ import { loadExcludeList } from "./exclude-models.js";
56
+ import { PROXY_PORT } from "./config.js";
57
+ import { SessionJournal } from "./journal.js";
58
+ const BLOCKRUN_API = "https://blockrun.ai/api";
59
+ const BLOCKRUN_SOLANA_API = "https://sol.blockrun.ai/api";
60
+ const IMAGE_DIR = join(homedir(), ".openclaw", "blockrun", "images");
61
+ // Routing profile models - virtual models that trigger intelligent routing
62
+ const AUTO_MODEL = "blockrun/auto";
63
+ const ROUTING_PROFILES = new Set([
64
+ "blockrun/eco",
65
+ "eco",
66
+ "blockrun/auto",
67
+ "auto",
68
+ "blockrun/premium",
69
+ "premium",
70
+ ]);
71
+ const FREE_MODEL = "free/gpt-oss-120b"; // Last-resort single free model fallback
72
+ const FREE_MODELS = new Set([
73
+ "free/gpt-oss-120b",
74
+ "free/gpt-oss-20b",
75
+ "free/nemotron-ultra-253b",
76
+ "free/nemotron-3-super-120b",
77
+ "free/nemotron-super-49b",
78
+ "free/deepseek-v3.2",
79
+ "free/mistral-large-3-675b",
80
+ "free/qwen3-coder-480b",
81
+ "free/devstral-2-123b",
82
+ "free/glm-4.7",
83
+ "free/llama-4-maverick",
84
+ ]);
85
+ /**
86
+ * Map free/xxx model IDs to nvidia/xxx for upstream BlockRun API.
87
+ * The "free/" prefix is a ClawRouter convention for the /model picker;
88
+ * BlockRun server expects "nvidia/" prefix.
89
+ */
90
+ function toUpstreamModelId(modelId) {
91
+ if (modelId.startsWith("free/")) {
92
+ return "nvidia/" + modelId.slice("free/".length);
93
+ }
94
+ return modelId;
95
+ }
96
+ const MAX_MESSAGES = 200; // BlockRun API limit - truncate older messages if exceeded
97
+ const CONTEXT_LIMIT_KB = 5120; // Server-side limit: 5MB in KB
98
+ const HEARTBEAT_INTERVAL_MS = 2_000;
99
+ const DEFAULT_REQUEST_TIMEOUT_MS = 180_000; // 3 minutes (allows for on-chain tx + LLM response)
100
+ const PER_MODEL_TIMEOUT_MS = 60_000; // 60s per individual model attempt (fallback to next on exceed)
101
+ const MAX_FALLBACK_ATTEMPTS = 5; // Maximum models to try in fallback chain (increased from 3 to ensure cheap models are tried)
102
+ const HEALTH_CHECK_TIMEOUT_MS = 2_000; // Timeout for checking existing proxy
103
+ const RATE_LIMIT_COOLDOWN_MS = 60_000; // 60 seconds cooldown for rate-limited models
104
+ const OVERLOAD_COOLDOWN_MS = 15_000; // 15 seconds cooldown for overloaded providers
105
+ const PORT_RETRY_ATTEMPTS = 5; // Max attempts to bind port (handles TIME_WAIT)
106
+ const PORT_RETRY_DELAY_MS = 1_000; // Delay between retry attempts
107
+ const MODEL_BODY_READ_TIMEOUT_MS = 300_000; // 5 minutes for model responses (reasoning models are slow)
108
+ const ERROR_BODY_READ_TIMEOUT_MS = 30_000; // 30 seconds for error/partner body reads
109
+ async function readBodyWithTimeout(body, timeoutMs = MODEL_BODY_READ_TIMEOUT_MS) {
110
+ if (!body)
111
+ return [];
112
+ const reader = body.getReader();
113
+ const chunks = [];
114
+ let timer;
115
+ try {
116
+ while (true) {
117
+ const result = await Promise.race([
118
+ reader.read(),
119
+ new Promise((_, reject) => {
120
+ timer = setTimeout(() => reject(new Error("Body read timeout")), timeoutMs);
121
+ }),
122
+ ]);
123
+ clearTimeout(timer);
124
+ if (result.done)
125
+ break;
126
+ chunks.push(result.value);
127
+ }
128
+ }
129
+ finally {
130
+ clearTimeout(timer);
131
+ reader.releaseLock();
132
+ }
133
+ return chunks;
134
+ }
135
+ /**
136
+ * Transform upstream payment errors into user-friendly messages.
137
+ * Parses the raw x402 error and formats it nicely.
138
+ */
139
+ function transformPaymentError(errorBody) {
140
+ try {
141
+ // Try to parse the error JSON
142
+ const parsed = JSON.parse(errorBody);
143
+ // Check if this is a payment verification error
144
+ if (parsed.error === "Payment verification failed" && parsed.details) {
145
+ // Extract the nested JSON from details
146
+ // Format: "Verification failed: {json}\n"
147
+ const match = parsed.details.match(/Verification failed:\s*(\{.*\})/s);
148
+ if (match) {
149
+ const innerJson = JSON.parse(match[1]);
150
+ if (innerJson.invalidReason === "insufficient_funds" && innerJson.invalidMessage) {
151
+ // Parse "insufficient balance: 251 < 11463"
152
+ const balanceMatch = innerJson.invalidMessage.match(/insufficient balance:\s*(\d+)\s*<\s*(\d+)/i);
153
+ if (balanceMatch) {
154
+ const currentMicros = parseInt(balanceMatch[1], 10);
155
+ const requiredMicros = parseInt(balanceMatch[2], 10);
156
+ const currentUSD = (currentMicros / 1_000_000).toFixed(6);
157
+ const requiredUSD = (requiredMicros / 1_000_000).toFixed(6);
158
+ const wallet = innerJson.payer || "unknown";
159
+ const shortWallet = wallet.length > 12 ? `${wallet.slice(0, 6)}...${wallet.slice(-4)}` : wallet;
160
+ return JSON.stringify({
161
+ error: {
162
+ message: `Insufficient USDC balance. Current: $${currentUSD}, Required: ~$${requiredUSD}`,
163
+ type: "insufficient_funds",
164
+ wallet: wallet,
165
+ current_balance_usd: currentUSD,
166
+ required_usd: requiredUSD,
167
+ help: `Fund wallet ${shortWallet} with USDC on Base, or use free model: /model free`,
168
+ },
169
+ });
170
+ }
171
+ }
172
+ // Handle invalid_payload errors (signature issues, malformed payment)
173
+ if (innerJson.invalidReason === "invalid_payload") {
174
+ return JSON.stringify({
175
+ error: {
176
+ message: "Payment signature invalid. This may be a temporary issue.",
177
+ type: "invalid_payload",
178
+ help: "Try again. If this persists, reinstall ClawRouter: curl -fsSL https://blockrun.ai/ClawRouter-update | bash",
179
+ },
180
+ });
181
+ }
182
+ // Handle transaction simulation failures (Solana on-chain validation)
183
+ if (innerJson.invalidReason === "transaction_simulation_failed") {
184
+ console.error(`[ClawRouter] Solana transaction simulation failed: ${innerJson.invalidMessage || "unknown"}`);
185
+ return JSON.stringify({
186
+ error: {
187
+ message: "Solana payment simulation failed. Retrying with a different model.",
188
+ type: "transaction_simulation_failed",
189
+ help: "This is usually temporary. If it persists, check your Solana USDC balance or try: /model free",
190
+ },
191
+ });
192
+ }
193
+ }
194
+ }
195
+ // Handle blockrun-sol (Solana) format: code=PAYMENT_INVALID + debug=invalidReason string
196
+ if (parsed.error === "Payment verification failed" &&
197
+ parsed.code === "PAYMENT_INVALID" &&
198
+ parsed.debug) {
199
+ const debugLower = parsed.debug.toLowerCase();
200
+ const wallet = parsed.payer || "unknown";
201
+ const shortWallet = wallet.length > 12 ? `${wallet.slice(0, 6)}...${wallet.slice(-4)}` : wallet;
202
+ if (debugLower.includes("insufficient")) {
203
+ return JSON.stringify({
204
+ error: {
205
+ message: "Insufficient Solana USDC balance.",
206
+ type: "insufficient_funds",
207
+ wallet,
208
+ help: `Fund wallet ${shortWallet} with USDC on Solana, or switch to Base: /wallet base`,
209
+ },
210
+ });
211
+ }
212
+ if (debugLower.includes("transaction_simulation_failed") ||
213
+ debugLower.includes("simulation")) {
214
+ console.error(`[ClawRouter] Solana transaction simulation failed: ${parsed.debug}`);
215
+ return JSON.stringify({
216
+ error: {
217
+ message: "Solana payment simulation failed. Retrying with a different model.",
218
+ type: "transaction_simulation_failed",
219
+ help: "This is usually temporary. If it persists, try: /model free",
220
+ },
221
+ });
222
+ }
223
+ if (debugLower.includes("invalid signature") || debugLower.includes("invalid_signature")) {
224
+ return JSON.stringify({
225
+ error: {
226
+ message: "Solana payment signature invalid.",
227
+ type: "invalid_payload",
228
+ help: "Try again. If this persists, reinstall ClawRouter: curl -fsSL https://blockrun.ai/ClawRouter-update | bash",
229
+ },
230
+ });
231
+ }
232
+ if (debugLower.includes("expired")) {
233
+ return JSON.stringify({
234
+ error: {
235
+ message: "Solana payment expired. Retrying.",
236
+ type: "expired",
237
+ help: "This is usually temporary.",
238
+ },
239
+ });
240
+ }
241
+ // Unknown Solana verification error — surface the debug reason
242
+ console.error(`[ClawRouter] Solana payment verification failed: ${parsed.debug} payer=${wallet}`);
243
+ return JSON.stringify({
244
+ error: {
245
+ message: `Solana payment verification failed: ${parsed.debug}`,
246
+ type: "payment_invalid",
247
+ wallet,
248
+ help: "Try again or switch to Base: /wallet base",
249
+ },
250
+ });
251
+ }
252
+ // Handle settlement failures (gas estimation, on-chain errors)
253
+ if (parsed.error === "Settlement failed" ||
254
+ parsed.error === "Payment settlement failed" ||
255
+ parsed.details?.includes("Settlement failed") ||
256
+ parsed.details?.includes("transaction_simulation_failed")) {
257
+ const details = parsed.details || "";
258
+ const gasError = details.includes("unable to estimate gas");
259
+ return JSON.stringify({
260
+ error: {
261
+ message: gasError
262
+ ? "Payment failed: network congestion or gas issue. Try again."
263
+ : "Payment settlement failed. Try again in a moment.",
264
+ type: "settlement_failed",
265
+ help: "This is usually temporary. If it persists, try: /model free",
266
+ },
267
+ });
268
+ }
269
+ }
270
+ catch {
271
+ // If parsing fails, return original
272
+ }
273
+ return errorBody;
274
+ }
275
+ /**
276
+ * Classify an upstream error response into a semantic category.
277
+ * Returns null if the status+body is not a provider-side issue worth retrying.
278
+ */
279
+ export function categorizeError(status, body) {
280
+ if (status === 401)
281
+ return "auth_failure";
282
+ if (status === 402)
283
+ return "payment_error";
284
+ if (status === 403) {
285
+ if (/plan.*limit|quota.*exceeded|subscription|allowance/i.test(body))
286
+ return "quota_exceeded";
287
+ return "auth_failure"; // generic 403 = forbidden = likely auth issue
288
+ }
289
+ if (status === 429)
290
+ return "rate_limited";
291
+ if (status === 529)
292
+ return "overloaded";
293
+ if (status === 503 && /overload|capacity|too.*many.*request/i.test(body))
294
+ return "overloaded";
295
+ if (status >= 500)
296
+ return "server_error";
297
+ if (status === 400 || status === 413) {
298
+ // Only fallback on content-size or billing patterns; bare 400 = our bug, don't cycle
299
+ if (PROVIDER_ERROR_PATTERNS.some((p) => p.test(body)))
300
+ return "config_error";
301
+ return null;
302
+ }
303
+ return null;
304
+ }
305
+ /**
306
+ * Track rate-limited models to avoid hitting them again.
307
+ * Maps model ID to the timestamp when the rate limit was hit.
308
+ */
309
+ const rateLimitedModels = new Map();
310
+ /** Per-model overload tracking (529/503 capacity errors) — shorter cooldown than rate limits. */
311
+ const overloadedModels = new Map();
312
+ const perProviderErrors = new Map();
313
+ /** Record an error category hit for a model. */
314
+ function recordProviderError(modelId, category) {
315
+ if (!perProviderErrors.has(modelId)) {
316
+ perProviderErrors.set(modelId, {
317
+ auth_failure: 0,
318
+ quota_exceeded: 0,
319
+ rate_limited: 0,
320
+ overloaded: 0,
321
+ server_error: 0,
322
+ payment_error: 0,
323
+ config_error: 0,
324
+ });
325
+ }
326
+ perProviderErrors.get(modelId)[category]++;
327
+ }
328
+ /**
329
+ * Check if a model is currently rate-limited (in cooldown period).
330
+ */
331
+ function isRateLimited(modelId) {
332
+ const hitTime = rateLimitedModels.get(modelId);
333
+ if (!hitTime)
334
+ return false;
335
+ const elapsed = Date.now() - hitTime;
336
+ if (elapsed >= RATE_LIMIT_COOLDOWN_MS) {
337
+ rateLimitedModels.delete(modelId);
338
+ return false;
339
+ }
340
+ return true;
341
+ }
342
+ /**
343
+ * Mark a model as rate-limited.
344
+ */
345
+ function markRateLimited(modelId) {
346
+ rateLimitedModels.set(modelId, Date.now());
347
+ console.log(`[ClawRouter] Model ${modelId} rate-limited, will deprioritize for 60s`);
348
+ }
349
+ /**
350
+ * Mark a model as temporarily overloaded (529/503 capacity).
351
+ * Shorter cooldown than rate limits since capacity restores quickly.
352
+ */
353
+ function markOverloaded(modelId) {
354
+ overloadedModels.set(modelId, Date.now());
355
+ console.log(`[ClawRouter] Model ${modelId} overloaded, will deprioritize for 15s`);
356
+ }
357
+ /** Check if a model is in its overload cooldown period. */
358
+ function isOverloaded(modelId) {
359
+ const hitTime = overloadedModels.get(modelId);
360
+ if (!hitTime)
361
+ return false;
362
+ if (Date.now() - hitTime >= OVERLOAD_COOLDOWN_MS) {
363
+ overloadedModels.delete(modelId);
364
+ return false;
365
+ }
366
+ return true;
367
+ }
368
+ /**
369
+ * Reorder models to put rate-limited ones at the end.
370
+ */
371
+ function prioritizeNonRateLimited(models) {
372
+ const available = [];
373
+ const degraded = [];
374
+ for (const model of models) {
375
+ if (isRateLimited(model) || isOverloaded(model)) {
376
+ degraded.push(model);
377
+ }
378
+ else {
379
+ available.push(model);
380
+ }
381
+ }
382
+ return [...available, ...degraded];
383
+ }
384
+ /**
385
+ * Check if response socket is writable (prevents write-after-close errors).
386
+ * Returns true only if all conditions are safe for writing.
387
+ */
388
+ function canWrite(res) {
389
+ return (!res.writableEnded &&
390
+ !res.destroyed &&
391
+ res.socket !== null &&
392
+ !res.socket.destroyed &&
393
+ res.socket.writable);
394
+ }
395
+ /**
396
+ * Safe write with backpressure handling.
397
+ * Returns true if write succeeded, false if socket is closed or write failed.
398
+ */
399
+ function safeWrite(res, data) {
400
+ if (!canWrite(res)) {
401
+ const bytes = typeof data === "string" ? Buffer.byteLength(data) : data.length;
402
+ console.warn(`[ClawRouter] safeWrite: socket not writable, dropping ${bytes} bytes`);
403
+ return false;
404
+ }
405
+ return res.write(data);
406
+ }
407
+ // Extra buffer for balance check (on top of estimateAmount's 20% buffer)
408
+ // Total effective buffer: 1.2 * 1.5 = 1.8x (80% safety margin)
409
+ // This prevents x402 payment failures after streaming headers are sent,
410
+ // which would trigger OpenClaw's 5-24 hour billing cooldown.
411
+ const BALANCE_CHECK_BUFFER = 1.5;
412
+ /**
413
+ * Get the proxy port from pre-loaded configuration.
414
+ * Port is validated at module load time, this just returns the cached value.
415
+ */
416
+ export function getProxyPort() {
417
+ return PROXY_PORT;
418
+ }
419
+ /**
420
+ * Check if a proxy is already running on the given port.
421
+ * Returns the wallet address if running, undefined otherwise.
422
+ */
423
+ async function checkExistingProxy(port) {
424
+ const controller = new AbortController();
425
+ const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
426
+ try {
427
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
428
+ signal: controller.signal,
429
+ });
430
+ clearTimeout(timeoutId);
431
+ if (response.ok) {
432
+ const data = (await response.json());
433
+ if (data.status === "ok" && data.wallet) {
434
+ return { wallet: data.wallet, paymentChain: data.paymentChain };
435
+ }
436
+ }
437
+ return undefined;
438
+ }
439
+ catch {
440
+ clearTimeout(timeoutId);
441
+ return undefined;
442
+ }
443
+ }
444
+ /**
445
+ * Error patterns that indicate a provider-side issue (not user's fault).
446
+ * These errors should trigger fallback to the next model in the chain.
447
+ */
448
+ const PROVIDER_ERROR_PATTERNS = [
449
+ /billing/i,
450
+ /insufficient.*balance/i,
451
+ /credits/i,
452
+ /quota.*exceeded/i,
453
+ /rate.*limit/i,
454
+ /model.*unavailable/i,
455
+ /model.*not.*available/i,
456
+ /service.*unavailable/i,
457
+ /capacity/i,
458
+ /overloaded/i,
459
+ /temporarily.*unavailable/i,
460
+ /api.*key.*invalid/i,
461
+ /authentication.*failed/i,
462
+ /request too large/i,
463
+ /request.*size.*exceeds/i,
464
+ /payload too large/i,
465
+ /payment.*verification.*failed/i,
466
+ /model.*not.*allowed/i,
467
+ /unknown.*model/i,
468
+ /reasoning_content.*missing/i, // Thinking model multi-turn: missing reasoning_content → fallback
469
+ /thinking.*reasoning_content/i,
470
+ ];
471
+ /**
472
+ * "Successful" response bodies that are actually provider degradation placeholders.
473
+ * Some upstream providers occasionally return these with HTTP 200.
474
+ */
475
+ const DEGRADED_RESPONSE_PATTERNS = [
476
+ /the ai service is temporarily overloaded/i,
477
+ /service is temporarily overloaded/i,
478
+ /please try again in a moment/i,
479
+ ];
480
+ /**
481
+ * Known low-quality loop signatures seen during provider degradation windows.
482
+ */
483
+ const DEGRADED_LOOP_PATTERNS = [
484
+ /the boxed is the response\./i,
485
+ /the response is the text\./i,
486
+ /the final answer is the boxed\./i,
487
+ ];
488
+ function extractAssistantContent(payload) {
489
+ if (!payload || typeof payload !== "object")
490
+ return undefined;
491
+ const record = payload;
492
+ const choices = record.choices;
493
+ if (!Array.isArray(choices) || choices.length === 0)
494
+ return undefined;
495
+ const firstChoice = choices[0];
496
+ if (!firstChoice || typeof firstChoice !== "object")
497
+ return undefined;
498
+ const choice = firstChoice;
499
+ const message = choice.message;
500
+ if (!message || typeof message !== "object")
501
+ return undefined;
502
+ const content = message.content;
503
+ return typeof content === "string" ? content : undefined;
504
+ }
505
+ function hasKnownLoopSignature(text) {
506
+ const matchCount = DEGRADED_LOOP_PATTERNS.reduce((count, pattern) => (pattern.test(text) ? count + 1 : count), 0);
507
+ if (matchCount >= 2)
508
+ return true;
509
+ // Generic repetitive loop fallback for short repeated lines.
510
+ const lines = text
511
+ .split(/\r?\n/)
512
+ .map((line) => line.trim())
513
+ .filter(Boolean);
514
+ if (lines.length < 8)
515
+ return false;
516
+ const counts = new Map();
517
+ for (const line of lines) {
518
+ counts.set(line, (counts.get(line) ?? 0) + 1);
519
+ }
520
+ const maxRepeat = Math.max(...counts.values());
521
+ const uniqueRatio = counts.size / lines.length;
522
+ return maxRepeat >= 3 && uniqueRatio <= 0.45;
523
+ }
524
+ /**
525
+ * Detect degraded 200-response payloads that should trigger model fallback.
526
+ * Returns a short reason when fallback should happen, otherwise undefined.
527
+ */
528
+ export function detectDegradedSuccessResponse(body) {
529
+ const trimmed = body.trim();
530
+ if (!trimmed)
531
+ return undefined;
532
+ // Plain-text placeholder response.
533
+ if (DEGRADED_RESPONSE_PATTERNS.some((pattern) => pattern.test(trimmed))) {
534
+ return "degraded response: overloaded placeholder";
535
+ }
536
+ // Plain-text looping garbage response.
537
+ if (hasKnownLoopSignature(trimmed)) {
538
+ return "degraded response: repetitive loop output";
539
+ }
540
+ try {
541
+ const parsed = JSON.parse(trimmed);
542
+ // Some providers return JSON error payloads with HTTP 200.
543
+ const errorField = parsed.error;
544
+ let errorText = "";
545
+ if (typeof errorField === "string") {
546
+ errorText = errorField;
547
+ }
548
+ else if (errorField && typeof errorField === "object") {
549
+ const errObj = errorField;
550
+ errorText = [
551
+ typeof errObj.message === "string" ? errObj.message : "",
552
+ typeof errObj.type === "string" ? errObj.type : "",
553
+ typeof errObj.code === "string" ? errObj.code : "",
554
+ ]
555
+ .filter(Boolean)
556
+ .join(" ");
557
+ }
558
+ if (errorText && PROVIDER_ERROR_PATTERNS.some((pattern) => pattern.test(errorText))) {
559
+ return `degraded response: ${errorText.slice(0, 120)}`;
560
+ }
561
+ // Detect empty-turn responses: model returned 200 but no content and no tool calls.
562
+ // Happens when models like gemini-3.1-flash-lite receive complex agentic requests
563
+ // (e.g. Roo Code tool schemas) and produce zero output instead of refusing.
564
+ const choices = parsed.choices;
565
+ if (Array.isArray(choices) && choices.length > 0) {
566
+ const choice = choices[0];
567
+ const msg = (choice.message ?? choice.delta);
568
+ if (msg) {
569
+ const content = msg.content;
570
+ const toolCalls = msg.tool_calls;
571
+ const hasContent = typeof content === "string" && content.trim().length > 0;
572
+ const hasToolCalls = Array.isArray(toolCalls) && toolCalls.length > 0;
573
+ const finishReason = choice.finish_reason;
574
+ if (!hasContent && !hasToolCalls && finishReason === "stop") {
575
+ return "degraded response: empty turn (no content or tool calls)";
576
+ }
577
+ }
578
+ }
579
+ // Successful wrapper with bad assistant content.
580
+ const assistantContent = extractAssistantContent(parsed);
581
+ if (!assistantContent)
582
+ return undefined;
583
+ if (DEGRADED_RESPONSE_PATTERNS.some((pattern) => pattern.test(assistantContent))) {
584
+ return "degraded response: overloaded assistant content";
585
+ }
586
+ if (hasKnownLoopSignature(assistantContent)) {
587
+ return "degraded response: repetitive assistant loop";
588
+ }
589
+ }
590
+ catch {
591
+ // Not JSON - handled by plaintext checks above.
592
+ }
593
+ return undefined;
594
+ }
595
+ /**
596
+ * Valid message roles for OpenAI-compatible APIs.
597
+ * Some clients send non-standard roles (e.g., "developer" instead of "system").
598
+ */
599
+ const VALID_ROLES = new Set(["system", "user", "assistant", "tool", "function"]);
600
+ /**
601
+ * Role mappings for non-standard roles.
602
+ * Maps client-specific roles to standard OpenAI roles.
603
+ */
604
+ const ROLE_MAPPINGS = {
605
+ developer: "system", // OpenAI's newer API uses "developer" for system messages
606
+ model: "assistant", // Some APIs use "model" instead of "assistant"
607
+ };
608
+ /**
609
+ * Anthropic tool ID pattern: only alphanumeric, underscore, and hyphen allowed.
610
+ * Error: "messages.X.content.Y.tool_use.id: String should match pattern '^[a-zA-Z0-9_-]+$'"
611
+ */
612
+ const VALID_TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
613
+ /**
614
+ * Sanitize a tool ID to match Anthropic's required pattern.
615
+ * Replaces invalid characters with underscores.
616
+ */
617
+ function sanitizeToolId(id) {
618
+ if (!id || typeof id !== "string")
619
+ return id;
620
+ if (VALID_TOOL_ID_PATTERN.test(id))
621
+ return id;
622
+ // Replace invalid characters with underscores
623
+ return id.replace(/[^a-zA-Z0-9_-]/g, "_");
624
+ }
625
+ /**
626
+ * Sanitize all tool IDs in messages to match Anthropic's pattern.
627
+ * Handles both OpenAI format (tool_calls, tool_call_id) and content block formats.
628
+ */
629
+ function sanitizeToolIds(messages) {
630
+ if (!messages || messages.length === 0)
631
+ return messages;
632
+ let hasChanges = false;
633
+ const sanitized = messages.map((msg) => {
634
+ const typedMsg = msg;
635
+ let msgChanged = false;
636
+ let newMsg = { ...msg };
637
+ // Sanitize tool_calls[].id in assistant messages
638
+ if (typedMsg.tool_calls && Array.isArray(typedMsg.tool_calls)) {
639
+ const newToolCalls = typedMsg.tool_calls.map((tc) => {
640
+ if (tc.id && typeof tc.id === "string") {
641
+ const sanitized = sanitizeToolId(tc.id);
642
+ if (sanitized !== tc.id) {
643
+ msgChanged = true;
644
+ return { ...tc, id: sanitized };
645
+ }
646
+ }
647
+ return tc;
648
+ });
649
+ if (msgChanged) {
650
+ newMsg = { ...newMsg, tool_calls: newToolCalls };
651
+ }
652
+ }
653
+ // Sanitize tool_call_id in tool messages
654
+ if (typedMsg.tool_call_id && typeof typedMsg.tool_call_id === "string") {
655
+ const sanitized = sanitizeToolId(typedMsg.tool_call_id);
656
+ if (sanitized !== typedMsg.tool_call_id) {
657
+ msgChanged = true;
658
+ newMsg = { ...newMsg, tool_call_id: sanitized };
659
+ }
660
+ }
661
+ // Sanitize content blocks if content is an array (Anthropic-style content)
662
+ if (Array.isArray(typedMsg.content)) {
663
+ const newContent = typedMsg.content.map((block) => {
664
+ if (!block || typeof block !== "object")
665
+ return block;
666
+ let blockChanged = false;
667
+ let newBlock = { ...block };
668
+ // tool_use blocks have "id"
669
+ if (block.type === "tool_use" && block.id && typeof block.id === "string") {
670
+ const sanitized = sanitizeToolId(block.id);
671
+ if (sanitized !== block.id) {
672
+ blockChanged = true;
673
+ newBlock = { ...newBlock, id: sanitized };
674
+ }
675
+ }
676
+ // tool_result blocks have "tool_use_id"
677
+ if (block.type === "tool_result" &&
678
+ block.tool_use_id &&
679
+ typeof block.tool_use_id === "string") {
680
+ const sanitized = sanitizeToolId(block.tool_use_id);
681
+ if (sanitized !== block.tool_use_id) {
682
+ blockChanged = true;
683
+ newBlock = { ...newBlock, tool_use_id: sanitized };
684
+ }
685
+ }
686
+ if (blockChanged) {
687
+ msgChanged = true;
688
+ return newBlock;
689
+ }
690
+ return block;
691
+ });
692
+ if (msgChanged) {
693
+ newMsg = { ...newMsg, content: newContent };
694
+ }
695
+ }
696
+ if (msgChanged) {
697
+ hasChanges = true;
698
+ return newMsg;
699
+ }
700
+ return msg;
701
+ });
702
+ return hasChanges ? sanitized : messages;
703
+ }
704
+ /**
705
+ * Normalize message roles to standard OpenAI format.
706
+ * Converts non-standard roles (e.g., "developer") to valid ones.
707
+ */
708
+ function normalizeMessageRoles(messages) {
709
+ if (!messages || messages.length === 0)
710
+ return messages;
711
+ let hasChanges = false;
712
+ const normalized = messages.map((msg) => {
713
+ if (VALID_ROLES.has(msg.role))
714
+ return msg;
715
+ const mappedRole = ROLE_MAPPINGS[msg.role];
716
+ if (mappedRole) {
717
+ hasChanges = true;
718
+ return { ...msg, role: mappedRole };
719
+ }
720
+ // Unknown role - default to "user" to avoid API errors
721
+ hasChanges = true;
722
+ return { ...msg, role: "user" };
723
+ });
724
+ return hasChanges ? normalized : messages;
725
+ }
726
+ /**
727
+ * Normalize messages for Google models.
728
+ * Google's Gemini API requires the first non-system message to be from "user".
729
+ * If conversation starts with "assistant"/"model", prepend a placeholder user message.
730
+ */
731
+ function normalizeMessagesForGoogle(messages) {
732
+ if (!messages || messages.length === 0)
733
+ return messages;
734
+ // Find first non-system message
735
+ let firstNonSystemIdx = -1;
736
+ for (let i = 0; i < messages.length; i++) {
737
+ if (messages[i].role !== "system") {
738
+ firstNonSystemIdx = i;
739
+ break;
740
+ }
741
+ }
742
+ // If no non-system messages, return as-is
743
+ if (firstNonSystemIdx === -1)
744
+ return messages;
745
+ const firstRole = messages[firstNonSystemIdx].role;
746
+ // If first non-system message is already "user", no change needed
747
+ if (firstRole === "user")
748
+ return messages;
749
+ // If first non-system message is "assistant" or "model", prepend a user message
750
+ if (firstRole === "assistant" || firstRole === "model") {
751
+ const normalized = [...messages];
752
+ normalized.splice(firstNonSystemIdx, 0, {
753
+ role: "user",
754
+ content: "(continuing conversation)",
755
+ });
756
+ return normalized;
757
+ }
758
+ return messages;
759
+ }
760
+ /**
761
+ * Check if a model is a Google model that requires message normalization.
762
+ */
763
+ function isGoogleModel(modelId) {
764
+ return modelId.startsWith("google/") || modelId.startsWith("gemini");
765
+ }
766
+ /**
767
+ * Normalize messages for thinking-enabled requests.
768
+ * When thinking/extended_thinking is enabled, ALL assistant messages in history
769
+ * must have reasoning_content (can be empty string if not present).
770
+ *
771
+ * Reasoning models like Kimi K2.5, DeepSeek-R1, etc. strip their thinking tokens
772
+ * before we forward the response to the client. So when the client sends back the
773
+ * assistant message in a follow-up turn, it lacks reasoning_content. These models
774
+ * reject the multi-turn history with 400 if any assistant message is missing it.
775
+ *
776
+ * Previously only tool-call messages were patched — this missed plain text assistant
777
+ * messages, causing 100% failures on existing (multi-turn) chats.
778
+ *
779
+ * Error examples:
780
+ * "400 thinking is enabled but reasoning_content is missing in assistant tool call message"
781
+ * "400 thinking is enabled but reasoning_content is missing in assistant message"
782
+ */
783
+ export function normalizeMessagesForThinking(messages) {
784
+ if (!messages || messages.length === 0)
785
+ return messages;
786
+ let hasChanges = false;
787
+ const normalized = messages.map((msg) => {
788
+ // Skip if not assistant or already has reasoning_content
789
+ if (msg.role !== "assistant" || msg.reasoning_content !== undefined) {
790
+ return msg;
791
+ }
792
+ // Add reasoning_content: "" to ALL assistant messages.
793
+ // Reasoning models require it on every assistant turn in history, not just tool-call turns.
794
+ hasChanges = true;
795
+ return { ...msg, reasoning_content: "" };
796
+ });
797
+ return hasChanges ? normalized : messages;
798
+ }
799
+ /**
800
+ * Remove "blockrun" branding from system messages before sending upstream.
801
+ *
802
+ * OpenClaw embeds `model=blockrun/auto` and `default_model=blockrun/auto` in its
803
+ * system prompt Runtime section. LLMs pick up "blockrun" and adopt it as their
804
+ * identity (e.g. "I'm Blockrun"), overriding the user's SOUL.md persona.
805
+ *
806
+ * This function replaces `blockrun/<profile>` references with the actual resolved
807
+ * model name, and strips any remaining "blockrun/" prefix so the upstream LLM
808
+ * never sees "blockrun" as an identity to adopt.
809
+ */
810
+ export function debrandSystemMessages(messages, resolvedModel) {
811
+ // Routing profile names that get replaced with the actual model
812
+ const PROFILE_NAMES = ["auto", "free", "eco", "premium"];
813
+ const profilePattern = new RegExp(`\\bblockrun/(${PROFILE_NAMES.join("|")})\\b`, "gi");
814
+ // Also handle "blockrun/<provider>/<model>" → "<provider>/<model>"
815
+ const prefixPattern = /\bblockrun\/(?=[a-z])/gi;
816
+ let hasChanges = false;
817
+ const result = messages.map((msg) => {
818
+ if (msg.role !== "system" || typeof msg.content !== "string")
819
+ return msg;
820
+ let content = msg.content;
821
+ // Replace routing profiles (blockrun/auto etc.) with resolved model
822
+ const afterProfiles = content.replace(profilePattern, resolvedModel);
823
+ // Replace remaining blockrun/ prefix (e.g. blockrun/openai/gpt-4o → openai/gpt-4o)
824
+ const afterPrefix = afterProfiles.replace(prefixPattern, "");
825
+ if (afterPrefix !== content) {
826
+ hasChanges = true;
827
+ content = afterPrefix;
828
+ }
829
+ return content !== msg.content ? { ...msg, content } : msg;
830
+ });
831
+ return hasChanges ? result : messages;
832
+ }
833
+ /**
834
+ * Truncate messages to stay under BlockRun's MAX_MESSAGES limit.
835
+ * Keeps all system messages and the most recent conversation history.
836
+ * Returns the messages and whether truncation occurred.
837
+ */
838
+ function truncateMessages(messages) {
839
+ if (!messages || messages.length <= MAX_MESSAGES) {
840
+ return {
841
+ messages,
842
+ wasTruncated: false,
843
+ originalCount: messages?.length ?? 0,
844
+ truncatedCount: messages?.length ?? 0,
845
+ };
846
+ }
847
+ // Separate system messages from conversation
848
+ const systemMsgs = messages.filter((m) => m.role === "system");
849
+ const conversationMsgs = messages.filter((m) => m.role !== "system");
850
+ // Keep all system messages + most recent conversation messages
851
+ const maxConversation = MAX_MESSAGES - systemMsgs.length;
852
+ const truncatedConversation = conversationMsgs.slice(-maxConversation);
853
+ const result = [...systemMsgs, ...truncatedConversation];
854
+ console.log(`[ClawRouter] Truncated messages: ${messages.length} → ${result.length} (kept ${systemMsgs.length} system + ${truncatedConversation.length} recent)`);
855
+ return {
856
+ messages: result,
857
+ wasTruncated: true,
858
+ originalCount: messages.length,
859
+ truncatedCount: result.length,
860
+ };
861
+ }
862
+ // Kimi/Moonshot models use special Unicode tokens for thinking boundaries.
863
+ // Pattern: <|begin▁of▁thinking|>content<|end▁of▁thinking|>
864
+ // The | is fullwidth vertical bar (U+FF5C), ▁ is lower one-eighth block (U+2581).
865
+ // Match full Kimi thinking blocks: <|begin...|>content<|end...|>
866
+ const KIMI_BLOCK_RE = /<[||][^<>]*begin[^<>]*[||]>[\s\S]*?<[||][^<>]*end[^<>]*[||]>/gi;
867
+ // Match standalone Kimi tokens like <|end▁of▁thinking|>
868
+ const KIMI_TOKEN_RE = /<[||][^<>]*[||]>/g;
869
+ // Standard thinking tags that may leak through from various models
870
+ const THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
871
+ // Full thinking blocks: <think>content</think>
872
+ const THINKING_BLOCK_RE = /<\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
873
+ /**
874
+ * Strip thinking tokens and blocks from model response content.
875
+ * Handles both Kimi-style Unicode tokens and standard XML-style tags.
876
+ *
877
+ * NOTE: DSML tags (<|DSML|...>) are NOT stripped - those are tool calls
878
+ * that should be handled by the API, not hidden from users.
879
+ */
880
+ function stripThinkingTokens(content) {
881
+ if (!content)
882
+ return content;
883
+ // Strip full Kimi thinking blocks first (begin...end with content)
884
+ let cleaned = content.replace(KIMI_BLOCK_RE, "");
885
+ // Strip remaining standalone Kimi tokens
886
+ cleaned = cleaned.replace(KIMI_TOKEN_RE, "");
887
+ // Strip full thinking blocks (<think>...</think>)
888
+ cleaned = cleaned.replace(THINKING_BLOCK_RE, "");
889
+ // Strip remaining standalone thinking tags
890
+ cleaned = cleaned.replace(THINKING_TAG_RE, "");
891
+ return cleaned;
892
+ }
893
+ /**
894
+ * Build model pricing map from BLOCKRUN_MODELS.
895
+ */
896
+ function buildModelPricing() {
897
+ const map = new Map();
898
+ for (const m of BLOCKRUN_MODELS) {
899
+ if (m.id === AUTO_MODEL)
900
+ continue; // skip meta-model
901
+ const promoPrice = getActivePromoPrice(m);
902
+ map.set(m.id, {
903
+ inputPrice: m.inputPrice,
904
+ outputPrice: m.outputPrice,
905
+ ...(promoPrice !== undefined && { flatPrice: promoPrice }),
906
+ });
907
+ }
908
+ return map;
909
+ }
910
+ /**
911
+ * Build `/v1/models` response entries from the full OpenClaw model registry.
912
+ * This includes alias IDs (e.g., `flash`, `kimi`) so `/model <alias>` works reliably.
913
+ */
914
+ export function buildProxyModelList(createdAt = Math.floor(Date.now() / 1000)) {
915
+ const seen = new Set();
916
+ return OPENCLAW_MODELS.filter((model) => {
917
+ if (seen.has(model.id))
918
+ return false;
919
+ seen.add(model.id);
920
+ return true;
921
+ }).map((model) => ({
922
+ id: model.id,
923
+ object: "model",
924
+ created: createdAt,
925
+ owned_by: model.id.includes("/") ? (model.id.split("/")[0] ?? "blockrun") : "blockrun",
926
+ }));
927
+ }
928
+ /**
929
+ * Merge partial routing config overrides with defaults.
930
+ */
931
+ function mergeRoutingConfig(overrides) {
932
+ if (!overrides)
933
+ return DEFAULT_ROUTING_CONFIG;
934
+ return {
935
+ ...DEFAULT_ROUTING_CONFIG,
936
+ ...overrides,
937
+ classifier: { ...DEFAULT_ROUTING_CONFIG.classifier, ...overrides.classifier },
938
+ scoring: { ...DEFAULT_ROUTING_CONFIG.scoring, ...overrides.scoring },
939
+ tiers: { ...DEFAULT_ROUTING_CONFIG.tiers, ...overrides.tiers },
940
+ overrides: { ...DEFAULT_ROUTING_CONFIG.overrides, ...overrides.overrides },
941
+ };
942
+ }
943
+ /**
944
+ * Estimate USDC cost for a request based on model pricing.
945
+ * Returns amount string in USDC smallest unit (6 decimals) or undefined if unknown.
946
+ */
947
+ function estimateAmount(modelId, bodyLength, maxTokens) {
948
+ const model = BLOCKRUN_MODELS.find((m) => m.id === modelId);
949
+ if (!model)
950
+ return undefined;
951
+ let costUsd;
952
+ const promoPrice = getActivePromoPrice(model);
953
+ if (promoPrice !== undefined) {
954
+ // Active promo: fixed cost per request, no token estimation needed
955
+ costUsd = promoPrice;
956
+ }
957
+ else {
958
+ // Rough estimate: ~4 chars per token for input
959
+ const estimatedInputTokens = Math.ceil(bodyLength / 4);
960
+ const estimatedOutputTokens = maxTokens || model.maxOutput || 4096;
961
+ costUsd =
962
+ (estimatedInputTokens / 1_000_000) * model.inputPrice +
963
+ (estimatedOutputTokens / 1_000_000) * model.outputPrice;
964
+ }
965
+ // Convert to USDC 6-decimal integer, add 20% buffer for estimation error
966
+ // Minimum 1000 ($0.001) to match CDP Facilitator's enforced minimum payment
967
+ const amountMicros = Math.max(1000, Math.ceil(costUsd * 1.2 * 1_000_000));
968
+ return amountMicros.toString();
969
+ }
970
+ // Image pricing table (must match server's IMAGE_MODELS in blockrun/src/lib/models.ts)
971
+ // Server applies 5% margin on top of these prices.
972
+ const IMAGE_PRICING = {
973
+ "openai/dall-e-3": {
974
+ default: 0.04,
975
+ sizes: { "1024x1024": 0.04, "1792x1024": 0.08, "1024x1792": 0.08 },
976
+ },
977
+ "openai/gpt-image-1": {
978
+ default: 0.02,
979
+ sizes: { "1024x1024": 0.02, "1536x1024": 0.04, "1024x1536": 0.04 },
980
+ },
981
+ "black-forest/flux-1.1-pro": { default: 0.04 },
982
+ "google/nano-banana": { default: 0.05 },
983
+ "google/nano-banana-pro": {
984
+ default: 0.1,
985
+ sizes: { "1024x1024": 0.1, "2048x2048": 0.1, "4096x4096": 0.15 },
986
+ },
987
+ };
988
+ /**
989
+ * Estimate the cost of an image generation/editing request.
990
+ * Matches server-side calculateImagePrice() including 5% margin.
991
+ */
992
+ function estimateImageCost(model, size, n = 1) {
993
+ const pricing = IMAGE_PRICING[model];
994
+ if (!pricing)
995
+ return 0.04 * n * 1.05; // fallback: assume $0.04/image + margin
996
+ const sizePrice = size && pricing.sizes ? pricing.sizes[size] : undefined;
997
+ const pricePerImage = sizePrice ?? pricing.default;
998
+ return pricePerImage * n * 1.05; // 5% server margin
999
+ }
1000
+ /**
1001
+ * Proxy a partner API request through x402 payment flow.
1002
+ *
1003
+ * Simplified proxy for partner endpoints (/v1/x/*, /v1/partner/*).
1004
+ * No smart routing, SSE, compression, or sessions — just collect body,
1005
+ * forward via payFetch (which handles 402 automatically), and stream back.
1006
+ */
1007
+ async function proxyPartnerRequest(req, res, apiBase, payFetch, getActualPaymentUsd) {
1008
+ const startTime = Date.now();
1009
+ const upstreamUrl = `${apiBase}${req.url}`;
1010
+ // Collect request body
1011
+ const bodyChunks = [];
1012
+ for await (const chunk of req) {
1013
+ bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1014
+ }
1015
+ const body = Buffer.concat(bodyChunks);
1016
+ // Forward headers (strip hop-by-hop)
1017
+ const headers = {};
1018
+ for (const [key, value] of Object.entries(req.headers)) {
1019
+ if (key === "host" ||
1020
+ key === "connection" ||
1021
+ key === "transfer-encoding" ||
1022
+ key === "content-length")
1023
+ continue;
1024
+ if (typeof value === "string")
1025
+ headers[key] = value;
1026
+ }
1027
+ if (!headers["content-type"])
1028
+ headers["content-type"] = "application/json";
1029
+ headers["user-agent"] = USER_AGENT;
1030
+ console.log(`[ClawRouter] Partner request: ${req.method} ${req.url}`);
1031
+ const upstream = await payFetch(upstreamUrl, {
1032
+ method: req.method ?? "POST",
1033
+ headers,
1034
+ body: body.length > 0 ? new Uint8Array(body) : undefined,
1035
+ });
1036
+ // Forward response headers
1037
+ const responseHeaders = {};
1038
+ upstream.headers.forEach((value, key) => {
1039
+ if (key === "transfer-encoding" || key === "connection" || key === "content-encoding")
1040
+ return;
1041
+ responseHeaders[key] = value;
1042
+ });
1043
+ res.writeHead(upstream.status, responseHeaders);
1044
+ // Stream response body
1045
+ if (upstream.body) {
1046
+ const chunks = await readBodyWithTimeout(upstream.body, ERROR_BODY_READ_TIMEOUT_MS);
1047
+ for (const chunk of chunks) {
1048
+ safeWrite(res, Buffer.from(chunk));
1049
+ }
1050
+ }
1051
+ res.end();
1052
+ const latencyMs = Date.now() - startTime;
1053
+ console.log(`[ClawRouter] Partner response: ${upstream.status} (${latencyMs}ms)`);
1054
+ // Log partner usage with actual x402 payment amount (previously logged cost: 0)
1055
+ const partnerCost = getActualPaymentUsd();
1056
+ logUsage({
1057
+ timestamp: new Date().toISOString(),
1058
+ model: "partner",
1059
+ tier: "PARTNER",
1060
+ cost: partnerCost,
1061
+ baselineCost: partnerCost,
1062
+ savings: 0,
1063
+ latencyMs,
1064
+ partnerId: (req.url?.split("?")[0] ?? "").replace(/^\/v1\//, "").replace(/\//g, "_") || "unknown",
1065
+ service: "partner",
1066
+ }).catch(() => { });
1067
+ }
1068
+ /**
1069
+ * Read a local image file and return it as a base64 data URI.
1070
+ * Supports ~/ home directory expansion.
1071
+ */
1072
+ function readImageFileAsDataUri(filePath) {
1073
+ const resolved = filePath.startsWith("~/") ? join(homedir(), filePath.slice(2)) : filePath;
1074
+ if (!existsSync(resolved)) {
1075
+ throw new Error(`Image file not found: ${resolved}`);
1076
+ }
1077
+ const ext = resolved.split(".").pop()?.toLowerCase() ?? "png";
1078
+ const mimeMap = {
1079
+ png: "image/png",
1080
+ jpg: "image/jpeg",
1081
+ jpeg: "image/jpeg",
1082
+ webp: "image/webp",
1083
+ };
1084
+ const mime = mimeMap[ext] ?? "image/png";
1085
+ const data = readFileSync(resolved);
1086
+ return `data:${mime};base64,${data.toString("base64")}`;
1087
+ }
1088
+ /**
1089
+ * Upload a base64 data URI to catbox.moe and return a public URL.
1090
+ * Google image models (nano-banana) return data URIs instead of hosted URLs,
1091
+ * which breaks Telegram and other clients that can't render raw base64.
1092
+ */
1093
+ async function uploadDataUriToHost(dataUri) {
1094
+ const match = dataUri.match(/^data:(image\/\w+);base64,(.+)$/);
1095
+ if (!match)
1096
+ throw new Error("Invalid data URI format");
1097
+ const [, mimeType, b64Data] = match;
1098
+ const ext = mimeType === "image/jpeg" ? "jpg" : (mimeType.split("/")[1] ?? "png");
1099
+ const buffer = Buffer.from(b64Data, "base64");
1100
+ const blob = new Blob([buffer], { type: mimeType });
1101
+ const form = new FormData();
1102
+ form.append("reqtype", "fileupload");
1103
+ form.append("fileToUpload", blob, `image.${ext}`);
1104
+ const uploadController = new AbortController();
1105
+ const uploadTimeout = setTimeout(() => uploadController.abort(), 30_000);
1106
+ try {
1107
+ const resp = await fetch("https://catbox.moe/user/api.php", {
1108
+ method: "POST",
1109
+ body: form,
1110
+ signal: uploadController.signal,
1111
+ });
1112
+ if (!resp.ok)
1113
+ throw new Error(`catbox.moe upload failed: HTTP ${resp.status}`);
1114
+ const result = await resp.text();
1115
+ if (result.startsWith("https://")) {
1116
+ return result.trim();
1117
+ }
1118
+ throw new Error(`catbox.moe upload failed: ${result}`);
1119
+ }
1120
+ finally {
1121
+ clearTimeout(uploadTimeout);
1122
+ }
1123
+ }
1124
+ /**
1125
+ * Start the local x402 proxy server.
1126
+ *
1127
+ * If a proxy is already running on the target port, reuses it instead of failing.
1128
+ * Port can be configured via BLOCKRUN_PROXY_PORT environment variable.
1129
+ *
1130
+ * Returns a handle with the assigned port, base URL, and a close function.
1131
+ */
1132
+ export async function startProxy(options) {
1133
+ // Normalize wallet config: string = EVM-only, object = full resolution
1134
+ const walletKey = typeof options.wallet === "string" ? options.wallet : options.wallet.key;
1135
+ const solanaPrivateKeyBytes = typeof options.wallet === "string" ? undefined : options.wallet.solanaPrivateKeyBytes;
1136
+ // Payment chain: options > env var > persisted file > default "base".
1137
+ // No dynamic switching — user selects chain via /wallet solana or /wallet base.
1138
+ const paymentChain = options.paymentChain ?? (await resolvePaymentChain());
1139
+ const apiBase = options.apiBase ??
1140
+ (paymentChain === "solana" && solanaPrivateKeyBytes ? BLOCKRUN_SOLANA_API : BLOCKRUN_API);
1141
+ if (paymentChain === "solana" && !solanaPrivateKeyBytes) {
1142
+ console.warn(`[ClawRouter] ⚠ Payment chain is Solana but no mnemonic found — falling back to Base (EVM).`);
1143
+ console.warn(`[ClawRouter] To fix: run "npx @blockrun/clawrouter wallet recover" if your mnemonic exists,`);
1144
+ console.warn(`[ClawRouter] or run "npx @blockrun/clawrouter chain base" to switch to EVM.`);
1145
+ }
1146
+ else if (paymentChain === "solana") {
1147
+ console.log(`[ClawRouter] Payment chain: Solana (${BLOCKRUN_SOLANA_API})`);
1148
+ }
1149
+ // Determine port: options.port > env var > default
1150
+ const listenPort = options.port ?? getProxyPort();
1151
+ // Check if a proxy is already running on this port
1152
+ const existingProxy = await checkExistingProxy(listenPort);
1153
+ if (existingProxy) {
1154
+ // Proxy already running — reuse it instead of failing with EADDRINUSE
1155
+ const account = privateKeyToAccount(walletKey);
1156
+ const baseUrl = `http://127.0.0.1:${listenPort}`;
1157
+ // Verify the existing proxy is using the same wallet (or warn if different)
1158
+ if (existingProxy.wallet !== account.address) {
1159
+ console.warn(`[ClawRouter] Existing proxy on port ${listenPort} uses wallet ${existingProxy.wallet}, but current config uses ${account.address}. Reusing existing proxy.`);
1160
+ }
1161
+ // Verify the existing proxy is using the same payment chain
1162
+ if (existingProxy.paymentChain) {
1163
+ if (existingProxy.paymentChain !== paymentChain) {
1164
+ throw new Error(`Existing proxy on port ${listenPort} is using ${existingProxy.paymentChain} but ${paymentChain} was requested. ` +
1165
+ `Stop the existing proxy first or use a different port.`);
1166
+ }
1167
+ }
1168
+ else if (paymentChain !== "base") {
1169
+ // Old proxy doesn't report chain — assume Base. Reject if Solana was requested.
1170
+ console.warn(`[ClawRouter] Existing proxy on port ${listenPort} does not report paymentChain (pre-v0.11 instance). Assuming Base.`);
1171
+ throw new Error(`Existing proxy on port ${listenPort} is a pre-v0.11 instance (assumed Base) but ${paymentChain} was requested. ` +
1172
+ `Stop the existing proxy first or use a different port.`);
1173
+ }
1174
+ // Derive Solana address if keys are available (for wallet status display)
1175
+ let reuseSolanaAddress;
1176
+ if (solanaPrivateKeyBytes) {
1177
+ const { createKeyPairSignerFromPrivateKeyBytes } = await import("@solana/kit");
1178
+ const solanaSigner = await createKeyPairSignerFromPrivateKeyBytes(solanaPrivateKeyBytes);
1179
+ reuseSolanaAddress = solanaSigner.address;
1180
+ }
1181
+ // Use chain-appropriate balance monitor (lazy import to avoid loading @solana/kit on Base chain)
1182
+ let balanceMonitor;
1183
+ if (paymentChain === "solana" && reuseSolanaAddress) {
1184
+ const { SolanaBalanceMonitor } = await import("./solana-balance.js");
1185
+ balanceMonitor = new SolanaBalanceMonitor(reuseSolanaAddress);
1186
+ }
1187
+ else {
1188
+ balanceMonitor = new BalanceMonitor(account.address);
1189
+ }
1190
+ options.onReady?.(listenPort);
1191
+ return {
1192
+ port: listenPort,
1193
+ baseUrl,
1194
+ walletAddress: existingProxy.wallet,
1195
+ solanaAddress: reuseSolanaAddress,
1196
+ balanceMonitor,
1197
+ close: async () => {
1198
+ // No-op: we didn't start this proxy, so we shouldn't close it
1199
+ },
1200
+ };
1201
+ }
1202
+ // Create x402 payment client with EVM scheme (always available)
1203
+ const account = privateKeyToAccount(walletKey);
1204
+ const evmPublicClient = createPublicClient({ chain: base, transport: http() });
1205
+ const evmSigner = toClientEvmSigner(account, evmPublicClient);
1206
+ const x402 = new x402Client();
1207
+ registerExactEvmScheme(x402, { signer: evmSigner });
1208
+ // Register Solana scheme if key is available
1209
+ // Uses registerExactSvmScheme helper which registers:
1210
+ // - solana:* wildcard (catches any CAIP-2 Solana network)
1211
+ // - V1 compat names: "solana", "solana-devnet", "solana-testnet"
1212
+ let solanaAddress;
1213
+ if (solanaPrivateKeyBytes) {
1214
+ const { registerExactSvmScheme } = await import("@x402/svm/exact/client");
1215
+ const { createKeyPairSignerFromPrivateKeyBytes } = await import("@solana/kit");
1216
+ const solanaSigner = await createKeyPairSignerFromPrivateKeyBytes(solanaPrivateKeyBytes);
1217
+ solanaAddress = solanaSigner.address;
1218
+ registerExactSvmScheme(x402, { signer: solanaSigner });
1219
+ console.log(`[ClawRouter] Solana wallet: ${solanaAddress}`);
1220
+ }
1221
+ // Log which chain is used for each payment and capture actual payment amount
1222
+ x402.onAfterPaymentCreation(async (context) => {
1223
+ const network = context.selectedRequirements.network;
1224
+ const chain = network.startsWith("eip155")
1225
+ ? "Base (EVM)"
1226
+ : network.startsWith("solana")
1227
+ ? "Solana"
1228
+ : network;
1229
+ // Capture actual payment amount in USD (amount is in USDC micro units, 6 decimals)
1230
+ const amountMicros = parseInt(context.selectedRequirements.amount || "0", 10);
1231
+ const amountUsd = amountMicros / 1_000_000;
1232
+ // Write to request-scoped store (if available)
1233
+ const store = paymentStore.getStore();
1234
+ if (store)
1235
+ store.amountUsd = amountUsd;
1236
+ console.log(`[ClawRouter] Payment signed on ${chain} (${network}) — $${amountUsd.toFixed(6)}`);
1237
+ });
1238
+ const payFetch = createPayFetchWithPreAuth(fetch, x402, undefined, {
1239
+ skipPreAuth: paymentChain === "solana",
1240
+ });
1241
+ // Create balance monitor for pre-request checks (lazy import to avoid loading @solana/kit on Base chain)
1242
+ let balanceMonitor;
1243
+ if (options._balanceMonitorOverride) {
1244
+ balanceMonitor = options._balanceMonitorOverride;
1245
+ }
1246
+ else if (paymentChain === "solana" && solanaAddress) {
1247
+ const { SolanaBalanceMonitor } = await import("./solana-balance.js");
1248
+ balanceMonitor = new SolanaBalanceMonitor(solanaAddress);
1249
+ }
1250
+ else {
1251
+ balanceMonitor = new BalanceMonitor(account.address);
1252
+ }
1253
+ // Build router options (100% local — no external API calls for routing)
1254
+ const routingConfig = mergeRoutingConfig(options.routingConfig);
1255
+ const modelPricing = buildModelPricing();
1256
+ const routerOpts = {
1257
+ config: routingConfig,
1258
+ modelPricing,
1259
+ };
1260
+ // Request deduplicator (shared across all requests)
1261
+ const deduplicator = new RequestDeduplicator();
1262
+ // Response cache for identical requests (longer TTL than dedup)
1263
+ const responseCache = new ResponseCache(options.cacheConfig);
1264
+ // Session store for model persistence (prevents mid-task model switching)
1265
+ const sessionStore = new SessionStore(options.sessionConfig);
1266
+ // Session journal for memory (enables agents to recall earlier work)
1267
+ const sessionJournal = new SessionJournal();
1268
+ // Track active connections for graceful cleanup
1269
+ const connections = new Set();
1270
+ const server = createServer((req, res) => {
1271
+ // Wrap in paymentStore.run() so x402 hook can write actual payment amount per-request
1272
+ paymentStore.run({ amountUsd: 0 }, async () => {
1273
+ // Add stream error handlers to prevent server crashes
1274
+ req.on("error", (err) => {
1275
+ console.error(`[ClawRouter] Request stream error: ${err.message}`);
1276
+ // Don't throw - just log and let request handler deal with it
1277
+ });
1278
+ res.on("error", (err) => {
1279
+ console.error(`[ClawRouter] Response stream error: ${err.message}`);
1280
+ // Don't try to write to failed socket - just log
1281
+ });
1282
+ // Finished wrapper for guaranteed cleanup on response completion/error
1283
+ finished(res, (err) => {
1284
+ if (err && err.code !== "ERR_STREAM_DESTROYED") {
1285
+ console.error(`[ClawRouter] Response finished with error: ${err.message}`);
1286
+ }
1287
+ // Note: heartbeatInterval cleanup happens in res.on("close") handler
1288
+ // Note: completed and dedup cleanup happens in the res.on("close") handler below
1289
+ });
1290
+ // Request finished wrapper for complete stream lifecycle tracking
1291
+ finished(req, (err) => {
1292
+ if (err && err.code !== "ERR_STREAM_DESTROYED") {
1293
+ console.error(`[ClawRouter] Request finished with error: ${err.message}`);
1294
+ }
1295
+ });
1296
+ // Health check with optional balance info
1297
+ if (req.url === "/health" || req.url?.startsWith("/health?")) {
1298
+ const url = new URL(req.url, "http://localhost");
1299
+ const full = url.searchParams.get("full") === "true";
1300
+ const response = {
1301
+ status: "ok",
1302
+ wallet: account.address,
1303
+ paymentChain,
1304
+ };
1305
+ if (solanaAddress) {
1306
+ response.solana = solanaAddress;
1307
+ }
1308
+ if (full) {
1309
+ try {
1310
+ const balanceInfo = await balanceMonitor.checkBalance();
1311
+ response.balance = balanceInfo.balanceUSD;
1312
+ response.isLow = balanceInfo.isLow;
1313
+ response.isEmpty = balanceInfo.isEmpty;
1314
+ }
1315
+ catch {
1316
+ response.balanceError = "Could not fetch balance";
1317
+ }
1318
+ }
1319
+ res.writeHead(200, { "Content-Type": "application/json" });
1320
+ res.end(JSON.stringify(response));
1321
+ return;
1322
+ }
1323
+ // Cache stats endpoint
1324
+ if (req.url === "/cache" || req.url?.startsWith("/cache?")) {
1325
+ const stats = responseCache.getStats();
1326
+ res.writeHead(200, {
1327
+ "Content-Type": "application/json",
1328
+ "Cache-Control": "no-cache",
1329
+ });
1330
+ res.end(JSON.stringify(stats, null, 2));
1331
+ return;
1332
+ }
1333
+ // Stats clear endpoint - delete all log files
1334
+ if (req.url === "/stats" && req.method === "DELETE") {
1335
+ try {
1336
+ const result = await clearStats();
1337
+ res.writeHead(200, { "Content-Type": "application/json" });
1338
+ res.end(JSON.stringify({ cleared: true, deletedFiles: result.deletedFiles }));
1339
+ }
1340
+ catch (err) {
1341
+ res.writeHead(500, { "Content-Type": "application/json" });
1342
+ res.end(JSON.stringify({
1343
+ error: `Failed to clear stats: ${err instanceof Error ? err.message : String(err)}`,
1344
+ }));
1345
+ }
1346
+ return;
1347
+ }
1348
+ // Stats API endpoint - returns JSON for programmatic access
1349
+ if (req.url === "/stats" || req.url?.startsWith("/stats?")) {
1350
+ try {
1351
+ const url = new URL(req.url, "http://localhost");
1352
+ const days = parseInt(url.searchParams.get("days") || "7", 10);
1353
+ const stats = await getStats(Math.min(days, 30));
1354
+ res.writeHead(200, {
1355
+ "Content-Type": "application/json",
1356
+ "Cache-Control": "no-cache",
1357
+ });
1358
+ res.end(JSON.stringify({
1359
+ ...stats,
1360
+ providerErrors: Object.fromEntries(perProviderErrors),
1361
+ }, null, 2));
1362
+ }
1363
+ catch (err) {
1364
+ res.writeHead(500, { "Content-Type": "application/json" });
1365
+ res.end(JSON.stringify({
1366
+ error: `Failed to get stats: ${err instanceof Error ? err.message : String(err)}`,
1367
+ }));
1368
+ }
1369
+ return;
1370
+ }
1371
+ // --- Handle /v1/models locally (no upstream call needed) ---
1372
+ if (req.url === "/v1/models" && req.method === "GET") {
1373
+ const models = buildProxyModelList();
1374
+ res.writeHead(200, { "Content-Type": "application/json" });
1375
+ res.end(JSON.stringify({ object: "list", data: models }));
1376
+ return;
1377
+ }
1378
+ // --- Serve locally cached images (~/.openclaw/blockrun/images/) ---
1379
+ if (req.url?.startsWith("/images/") && req.method === "GET") {
1380
+ const filename = req.url
1381
+ .slice("/images/".length)
1382
+ .split("?")[0]
1383
+ .replace(/[^a-zA-Z0-9._-]/g, "");
1384
+ if (!filename) {
1385
+ res.writeHead(400);
1386
+ res.end("Bad request");
1387
+ return;
1388
+ }
1389
+ const filePath = join(IMAGE_DIR, filename);
1390
+ try {
1391
+ const s = await fsStat(filePath);
1392
+ if (!s.isFile())
1393
+ throw new Error("not a file");
1394
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "png";
1395
+ const mime = {
1396
+ png: "image/png",
1397
+ jpg: "image/jpeg",
1398
+ jpeg: "image/jpeg",
1399
+ webp: "image/webp",
1400
+ gif: "image/gif",
1401
+ };
1402
+ const data = await readFile(filePath);
1403
+ res.writeHead(200, {
1404
+ "Content-Type": mime[ext] ?? "application/octet-stream",
1405
+ "Content-Length": data.length,
1406
+ });
1407
+ res.end(data);
1408
+ }
1409
+ catch {
1410
+ res.writeHead(404, { "Content-Type": "application/json" });
1411
+ res.end(JSON.stringify({ error: "Image not found" }));
1412
+ }
1413
+ return;
1414
+ }
1415
+ // --- Handle /v1/images/generations: proxy with x402 payment + save data URIs locally ---
1416
+ // NOTE: image generation endpoints bypass maxCostPerRun budget tracking entirely.
1417
+ // Cost is charged via x402 micropayment directly — no session accumulation or cap enforcement.
1418
+ if (req.url === "/v1/images/generations" && req.method === "POST") {
1419
+ const imgStartTime = Date.now();
1420
+ const chunks = [];
1421
+ for await (const chunk of req) {
1422
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1423
+ }
1424
+ const reqBody = Buffer.concat(chunks);
1425
+ // Parse request for usage logging
1426
+ let imgModel = "unknown";
1427
+ let imgCost = 0;
1428
+ try {
1429
+ const parsed = JSON.parse(reqBody.toString());
1430
+ imgModel = parsed.model || "openai/dall-e-3";
1431
+ const n = parsed.n || 1;
1432
+ imgCost = estimateImageCost(imgModel, parsed.size, n);
1433
+ }
1434
+ catch {
1435
+ /* use defaults */
1436
+ }
1437
+ try {
1438
+ const upstream = await payFetch(`${apiBase}/v1/images/generations`, {
1439
+ method: "POST",
1440
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
1441
+ body: reqBody,
1442
+ });
1443
+ const text = await upstream.text();
1444
+ if (!upstream.ok) {
1445
+ res.writeHead(upstream.status, { "Content-Type": "application/json" });
1446
+ res.end(text);
1447
+ return;
1448
+ }
1449
+ let result;
1450
+ try {
1451
+ result = JSON.parse(text);
1452
+ }
1453
+ catch {
1454
+ res.writeHead(200, { "Content-Type": "application/json" });
1455
+ res.end(text);
1456
+ return;
1457
+ }
1458
+ // Save images to ~/.openclaw/blockrun/images/ and replace with localhost URLs
1459
+ // Handles both base64 data URIs (Google) and HTTP URLs (DALL-E 3)
1460
+ if (result.data?.length) {
1461
+ await mkdir(IMAGE_DIR, { recursive: true });
1462
+ const port = server.address()?.port ?? 8402;
1463
+ for (const img of result.data) {
1464
+ const dataUriMatch = img.url?.match(/^data:(image\/\w+);base64,(.+)$/);
1465
+ if (dataUriMatch) {
1466
+ const [, mimeType, b64] = dataUriMatch;
1467
+ const ext = mimeType === "image/jpeg" ? "jpg" : (mimeType.split("/")[1] ?? "png");
1468
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
1469
+ await writeFile(join(IMAGE_DIR, filename), Buffer.from(b64, "base64"));
1470
+ img.url = `http://localhost:${port}/images/${filename}`;
1471
+ console.log(`[ClawRouter] Image saved → ${img.url}`);
1472
+ }
1473
+ else if (img.url?.startsWith("https://") || img.url?.startsWith("http://")) {
1474
+ try {
1475
+ const imgResp = await fetch(img.url);
1476
+ if (imgResp.ok) {
1477
+ const contentType = imgResp.headers.get("content-type") ?? "image/png";
1478
+ const ext = contentType.includes("jpeg") || contentType.includes("jpg")
1479
+ ? "jpg"
1480
+ : contentType.includes("webp")
1481
+ ? "webp"
1482
+ : "png";
1483
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
1484
+ const buf = Buffer.from(await imgResp.arrayBuffer());
1485
+ await writeFile(join(IMAGE_DIR, filename), buf);
1486
+ img.url = `http://localhost:${port}/images/${filename}`;
1487
+ console.log(`[ClawRouter] Image downloaded & saved → ${img.url}`);
1488
+ }
1489
+ }
1490
+ catch (downloadErr) {
1491
+ console.warn(`[ClawRouter] Failed to download image, using original URL: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`);
1492
+ }
1493
+ }
1494
+ }
1495
+ }
1496
+ // Log image generation usage with actual x402 payment (previously missing entirely)
1497
+ const imgActualCost = paymentStore.getStore()?.amountUsd ?? imgCost;
1498
+ logUsage({
1499
+ timestamp: new Date().toISOString(),
1500
+ model: imgModel,
1501
+ tier: "IMAGE",
1502
+ cost: imgActualCost,
1503
+ baselineCost: imgActualCost,
1504
+ savings: 0,
1505
+ latencyMs: Date.now() - imgStartTime,
1506
+ }).catch(() => { });
1507
+ res.writeHead(200, { "Content-Type": "application/json" });
1508
+ res.end(JSON.stringify(result));
1509
+ }
1510
+ catch (err) {
1511
+ const msg = err instanceof Error ? err.message : String(err);
1512
+ console.error(`[ClawRouter] Image generation error: ${msg}`);
1513
+ if (!res.headersSent) {
1514
+ res.writeHead(502, { "Content-Type": "application/json" });
1515
+ res.end(JSON.stringify({ error: "Image generation failed", details: msg }));
1516
+ }
1517
+ }
1518
+ return;
1519
+ }
1520
+ // --- Handle /v1/images/image2image: proxy with x402 payment + save images locally ---
1521
+ // Accepts image as: data URI, local file path, ~/path, or HTTP(S) URL
1522
+ if (req.url === "/v1/images/image2image" && req.method === "POST") {
1523
+ const img2imgStartTime = Date.now();
1524
+ const chunks = [];
1525
+ for await (const chunk of req) {
1526
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1527
+ }
1528
+ const rawBody = Buffer.concat(chunks);
1529
+ // Resolve image/mask fields: file paths and URLs → data URIs
1530
+ let reqBody;
1531
+ // eslint-disable-next-line no-useless-assignment -- reassigned in try, used after catch
1532
+ let img2imgModel = "openai/gpt-image-1";
1533
+ // eslint-disable-next-line no-useless-assignment -- reassigned in try, used after catch
1534
+ let img2imgCost = 0;
1535
+ try {
1536
+ const parsed = JSON.parse(rawBody.toString());
1537
+ for (const field of ["image", "mask"]) {
1538
+ const val = parsed[field];
1539
+ if (typeof val !== "string" || !val)
1540
+ continue;
1541
+ if (val.startsWith("data:")) {
1542
+ // Already a data URI — pass through
1543
+ }
1544
+ else if (val.startsWith("https://") || val.startsWith("http://")) {
1545
+ // Download URL → data URI
1546
+ const imgResp = await fetch(val);
1547
+ if (!imgResp.ok)
1548
+ throw new Error(`Failed to download ${field} from ${val}: HTTP ${imgResp.status}`);
1549
+ const contentType = imgResp.headers.get("content-type") ?? "image/png";
1550
+ const buf = Buffer.from(await imgResp.arrayBuffer());
1551
+ parsed[field] = `data:${contentType};base64,${buf.toString("base64")}`;
1552
+ console.log(`[ClawRouter] img2img: downloaded ${field} URL → data URI (${buf.length} bytes)`);
1553
+ }
1554
+ else {
1555
+ // Local file path → data URI
1556
+ parsed[field] = readImageFileAsDataUri(val);
1557
+ console.log(`[ClawRouter] img2img: read ${field} file → data URI`);
1558
+ }
1559
+ }
1560
+ // Default model if not specified
1561
+ if (!parsed.model)
1562
+ parsed.model = "openai/gpt-image-1";
1563
+ img2imgModel = parsed.model;
1564
+ img2imgCost = estimateImageCost(img2imgModel, parsed.size, parsed.n || 1);
1565
+ reqBody = JSON.stringify(parsed);
1566
+ }
1567
+ catch (parseErr) {
1568
+ const msg = parseErr instanceof Error ? parseErr.message : String(parseErr);
1569
+ res.writeHead(400, { "Content-Type": "application/json" });
1570
+ res.end(JSON.stringify({ error: "Invalid request", details: msg }));
1571
+ return;
1572
+ }
1573
+ try {
1574
+ const upstream = await payFetch(`${apiBase}/v1/images/image2image`, {
1575
+ method: "POST",
1576
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
1577
+ body: reqBody,
1578
+ });
1579
+ const text = await upstream.text();
1580
+ if (!upstream.ok) {
1581
+ res.writeHead(upstream.status, { "Content-Type": "application/json" });
1582
+ res.end(text);
1583
+ return;
1584
+ }
1585
+ let result;
1586
+ try {
1587
+ result = JSON.parse(text);
1588
+ }
1589
+ catch {
1590
+ res.writeHead(200, { "Content-Type": "application/json" });
1591
+ res.end(text);
1592
+ return;
1593
+ }
1594
+ // Save images to ~/.openclaw/blockrun/images/ and replace with localhost URLs
1595
+ // Handles both base64 data URIs (Google) and HTTP URLs (DALL-E 3)
1596
+ if (result.data?.length) {
1597
+ await mkdir(IMAGE_DIR, { recursive: true });
1598
+ const port = server.address()?.port ?? 8402;
1599
+ for (const img of result.data) {
1600
+ const dataUriMatch = img.url?.match(/^data:(image\/\w+);base64,(.+)$/);
1601
+ if (dataUriMatch) {
1602
+ const [, mimeType, b64] = dataUriMatch;
1603
+ const ext = mimeType === "image/jpeg" ? "jpg" : (mimeType.split("/")[1] ?? "png");
1604
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
1605
+ await writeFile(join(IMAGE_DIR, filename), Buffer.from(b64, "base64"));
1606
+ img.url = `http://localhost:${port}/images/${filename}`;
1607
+ console.log(`[ClawRouter] Image saved → ${img.url}`);
1608
+ }
1609
+ else if (img.url?.startsWith("https://") || img.url?.startsWith("http://")) {
1610
+ try {
1611
+ const imgResp = await fetch(img.url);
1612
+ if (imgResp.ok) {
1613
+ const contentType = imgResp.headers.get("content-type") ?? "image/png";
1614
+ const ext = contentType.includes("jpeg") || contentType.includes("jpg")
1615
+ ? "jpg"
1616
+ : contentType.includes("webp")
1617
+ ? "webp"
1618
+ : "png";
1619
+ const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 10)}.${ext}`;
1620
+ const buf = Buffer.from(await imgResp.arrayBuffer());
1621
+ await writeFile(join(IMAGE_DIR, filename), buf);
1622
+ img.url = `http://localhost:${port}/images/${filename}`;
1623
+ console.log(`[ClawRouter] Image downloaded & saved → ${img.url}`);
1624
+ }
1625
+ }
1626
+ catch (downloadErr) {
1627
+ console.warn(`[ClawRouter] Failed to download image, using original URL: ${downloadErr instanceof Error ? downloadErr.message : String(downloadErr)}`);
1628
+ }
1629
+ }
1630
+ }
1631
+ }
1632
+ // Log image editing usage with actual x402 payment (previously missing entirely)
1633
+ const img2imgActualCost = paymentStore.getStore()?.amountUsd ?? img2imgCost;
1634
+ logUsage({
1635
+ timestamp: new Date().toISOString(),
1636
+ model: img2imgModel,
1637
+ tier: "IMAGE",
1638
+ cost: img2imgActualCost,
1639
+ baselineCost: img2imgActualCost,
1640
+ savings: 0,
1641
+ latencyMs: Date.now() - img2imgStartTime,
1642
+ }).catch(() => { });
1643
+ res.writeHead(200, { "Content-Type": "application/json" });
1644
+ res.end(JSON.stringify(result));
1645
+ }
1646
+ catch (err) {
1647
+ const msg = err instanceof Error ? err.message : String(err);
1648
+ console.error(`[ClawRouter] Image editing error: ${msg}`);
1649
+ if (!res.headersSent) {
1650
+ res.writeHead(502, { "Content-Type": "application/json" });
1651
+ res.end(JSON.stringify({ error: "Image editing failed", details: msg }));
1652
+ }
1653
+ }
1654
+ return;
1655
+ }
1656
+ // --- Handle partner API paths (/v1/x/*, /v1/partner/*, /v1/pm/*) ---
1657
+ if (req.url?.match(/^\/v1\/(?:x|partner|pm)\//)) {
1658
+ try {
1659
+ await proxyPartnerRequest(req, res, apiBase, payFetch, () => paymentStore.getStore()?.amountUsd ?? 0);
1660
+ }
1661
+ catch (err) {
1662
+ const error = err instanceof Error ? err : new Error(String(err));
1663
+ options.onError?.(error);
1664
+ if (!res.headersSent) {
1665
+ res.writeHead(502, { "Content-Type": "application/json" });
1666
+ res.end(JSON.stringify({
1667
+ error: { message: `Partner proxy error: ${error.message}`, type: "partner_error" },
1668
+ }));
1669
+ }
1670
+ }
1671
+ return;
1672
+ }
1673
+ // Only proxy paths starting with /v1
1674
+ if (!req.url?.startsWith("/v1")) {
1675
+ res.writeHead(404, { "Content-Type": "application/json" });
1676
+ res.end(JSON.stringify({ error: "Not found" }));
1677
+ return;
1678
+ }
1679
+ try {
1680
+ await proxyRequest(req, res, apiBase, payFetch, options, routerOpts, deduplicator, balanceMonitor, sessionStore, responseCache, sessionJournal);
1681
+ }
1682
+ catch (err) {
1683
+ const error = err instanceof Error ? err : new Error(String(err));
1684
+ options.onError?.(error);
1685
+ if (!res.headersSent) {
1686
+ res.writeHead(502, { "Content-Type": "application/json" });
1687
+ res.end(JSON.stringify({
1688
+ error: { message: `Proxy error: ${error.message}`, type: "proxy_error" },
1689
+ }));
1690
+ }
1691
+ else if (!res.writableEnded) {
1692
+ // Headers already sent (streaming) — send error as SSE event
1693
+ res.write(`data: ${JSON.stringify({ error: { message: error.message, type: "proxy_error" } })}\n\n`);
1694
+ res.write("data: [DONE]\n\n");
1695
+ res.end();
1696
+ }
1697
+ }
1698
+ }); // end paymentStore.run()
1699
+ });
1700
+ // Listen on configured port with retry logic for TIME_WAIT handling
1701
+ // When gateway restarts quickly, the port may still be in TIME_WAIT state.
1702
+ // We retry with delay instead of incorrectly assuming a proxy is running.
1703
+ const tryListen = (attempt) => {
1704
+ return new Promise((resolveAttempt, rejectAttempt) => {
1705
+ const onError = async (err) => {
1706
+ server.removeListener("error", onError);
1707
+ if (err.code === "EADDRINUSE") {
1708
+ // Port is in use - check if a proxy is actually running
1709
+ const existingProxy2 = await checkExistingProxy(listenPort);
1710
+ if (existingProxy2) {
1711
+ // Proxy is actually running - this is fine, reuse it
1712
+ console.log(`[ClawRouter] Existing proxy detected on port ${listenPort}, reusing`);
1713
+ rejectAttempt({
1714
+ code: "REUSE_EXISTING",
1715
+ wallet: existingProxy2.wallet,
1716
+ existingChain: existingProxy2.paymentChain,
1717
+ });
1718
+ return;
1719
+ }
1720
+ // Port is in TIME_WAIT (no proxy responding) - retry after delay
1721
+ if (attempt < PORT_RETRY_ATTEMPTS) {
1722
+ console.log(`[ClawRouter] Port ${listenPort} in TIME_WAIT, retrying in ${PORT_RETRY_DELAY_MS}ms (attempt ${attempt}/${PORT_RETRY_ATTEMPTS})`);
1723
+ rejectAttempt({ code: "RETRY", attempt });
1724
+ return;
1725
+ }
1726
+ // Max retries exceeded
1727
+ console.error(`[ClawRouter] Port ${listenPort} still in use after ${PORT_RETRY_ATTEMPTS} attempts`);
1728
+ rejectAttempt(err);
1729
+ return;
1730
+ }
1731
+ rejectAttempt(err);
1732
+ };
1733
+ server.once("error", onError);
1734
+ server.listen(listenPort, "127.0.0.1", () => {
1735
+ server.removeListener("error", onError);
1736
+ resolveAttempt();
1737
+ });
1738
+ });
1739
+ };
1740
+ // Retry loop for port binding
1741
+ let lastError;
1742
+ for (let attempt = 1; attempt <= PORT_RETRY_ATTEMPTS; attempt++) {
1743
+ try {
1744
+ await tryListen(attempt);
1745
+ break; // Success
1746
+ }
1747
+ catch (err) {
1748
+ const error = err;
1749
+ if (error.code === "REUSE_EXISTING" && error.wallet) {
1750
+ // Validate payment chain matches (same check as pre-listen reuse path)
1751
+ if (error.existingChain && error.existingChain !== paymentChain) {
1752
+ throw new Error(`Existing proxy on port ${listenPort} is using ${error.existingChain} but ${paymentChain} was requested. ` +
1753
+ `Stop the existing proxy first or use a different port.`, { cause: err });
1754
+ }
1755
+ // Proxy is running, reuse it
1756
+ const baseUrl = `http://127.0.0.1:${listenPort}`;
1757
+ options.onReady?.(listenPort);
1758
+ return {
1759
+ port: listenPort,
1760
+ baseUrl,
1761
+ walletAddress: error.wallet,
1762
+ balanceMonitor,
1763
+ close: async () => {
1764
+ // No-op: we didn't start this proxy, so we shouldn't close it
1765
+ },
1766
+ };
1767
+ }
1768
+ if (error.code === "RETRY") {
1769
+ // Wait before retry
1770
+ await new Promise((r) => setTimeout(r, PORT_RETRY_DELAY_MS));
1771
+ continue;
1772
+ }
1773
+ // Other error - throw
1774
+ lastError = err;
1775
+ break;
1776
+ }
1777
+ }
1778
+ if (lastError) {
1779
+ throw lastError;
1780
+ }
1781
+ // Server is now listening - set up remaining handlers
1782
+ const addr = server.address();
1783
+ const port = addr.port;
1784
+ const baseUrl = `http://127.0.0.1:${port}`;
1785
+ options.onReady?.(port);
1786
+ // Check for updates (non-blocking)
1787
+ checkForUpdates();
1788
+ // Add runtime error handler AFTER successful listen
1789
+ // This handles errors that occur during server operation (not just startup)
1790
+ server.on("error", (err) => {
1791
+ console.error(`[ClawRouter] Server runtime error: ${err.message}`);
1792
+ options.onError?.(err);
1793
+ // Don't crash - log and continue
1794
+ });
1795
+ // Handle client connection errors (bad requests, socket errors)
1796
+ server.on("clientError", (err, socket) => {
1797
+ console.error(`[ClawRouter] Client error: ${err.message}`);
1798
+ // Send 400 Bad Request if socket is still writable
1799
+ if (socket.writable && !socket.destroyed) {
1800
+ socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
1801
+ }
1802
+ });
1803
+ // Track connections for graceful cleanup
1804
+ server.on("connection", (socket) => {
1805
+ connections.add(socket);
1806
+ // Set 5-minute timeout for streaming requests
1807
+ socket.setTimeout(300_000);
1808
+ socket.on("timeout", () => {
1809
+ console.error(`[ClawRouter] Socket timeout, destroying connection`);
1810
+ socket.destroy();
1811
+ });
1812
+ socket.on("end", () => {
1813
+ // Half-closed by client (FIN received)
1814
+ });
1815
+ socket.on("error", (err) => {
1816
+ console.error(`[ClawRouter] Socket error: ${err.message}`);
1817
+ });
1818
+ socket.on("close", () => {
1819
+ connections.delete(socket);
1820
+ });
1821
+ });
1822
+ return {
1823
+ port,
1824
+ baseUrl,
1825
+ walletAddress: account.address,
1826
+ solanaAddress,
1827
+ balanceMonitor,
1828
+ close: () => new Promise((res, rej) => {
1829
+ const timeout = setTimeout(() => {
1830
+ rej(new Error("[ClawRouter] Close timeout after 4s"));
1831
+ }, 4000);
1832
+ sessionStore.close();
1833
+ // Destroy all active connections before closing server
1834
+ for (const socket of connections) {
1835
+ socket.destroy();
1836
+ }
1837
+ connections.clear();
1838
+ server.close((err) => {
1839
+ clearTimeout(timeout);
1840
+ if (err) {
1841
+ rej(err);
1842
+ }
1843
+ else {
1844
+ res();
1845
+ }
1846
+ });
1847
+ }),
1848
+ };
1849
+ }
1850
+ /**
1851
+ * Attempt a request with a specific model.
1852
+ * Returns the response or error details for fallback decision.
1853
+ */
1854
+ async function tryModelRequest(upstreamUrl, method, headers, body, modelId, maxTokens, payFetch, balanceMonitor, signal) {
1855
+ // Update model in body and normalize messages
1856
+ let requestBody = body;
1857
+ try {
1858
+ const parsed = JSON.parse(body.toString());
1859
+ parsed.model = toUpstreamModelId(modelId);
1860
+ // Normalize message roles (e.g., "developer" -> "system")
1861
+ if (Array.isArray(parsed.messages)) {
1862
+ parsed.messages = normalizeMessageRoles(parsed.messages);
1863
+ }
1864
+ // Remove "blockrun" branding from system messages so upstream LLMs don't
1865
+ // adopt "Blockrun" as their identity (overriding user's SOUL.md persona).
1866
+ if (Array.isArray(parsed.messages)) {
1867
+ parsed.messages = debrandSystemMessages(parsed.messages, modelId);
1868
+ }
1869
+ // Truncate messages to stay under BlockRun's limit (200 messages)
1870
+ if (Array.isArray(parsed.messages)) {
1871
+ const truncationResult = truncateMessages(parsed.messages);
1872
+ parsed.messages = truncationResult.messages;
1873
+ }
1874
+ // Sanitize tool IDs to match Anthropic's pattern (alphanumeric, underscore, hyphen only)
1875
+ if (Array.isArray(parsed.messages)) {
1876
+ parsed.messages = sanitizeToolIds(parsed.messages);
1877
+ }
1878
+ // Normalize messages for Google models (first non-system message must be "user")
1879
+ if (isGoogleModel(modelId) && Array.isArray(parsed.messages)) {
1880
+ parsed.messages = normalizeMessagesForGoogle(parsed.messages);
1881
+ }
1882
+ // Normalize messages for thinking-enabled requests (add reasoning_content to all assistant messages)
1883
+ // Check request flags AND target model - reasoning models have thinking enabled server-side
1884
+ const hasThinkingEnabled = !!(parsed.thinking ||
1885
+ parsed.extended_thinking ||
1886
+ isReasoningModel(modelId));
1887
+ if (hasThinkingEnabled && Array.isArray(parsed.messages)) {
1888
+ parsed.messages = normalizeMessagesForThinking(parsed.messages);
1889
+ }
1890
+ requestBody = Buffer.from(JSON.stringify(parsed));
1891
+ }
1892
+ catch {
1893
+ // If body isn't valid JSON, use as-is
1894
+ }
1895
+ try {
1896
+ const response = await payFetch(upstreamUrl, {
1897
+ method,
1898
+ headers,
1899
+ body: requestBody.length > 0 ? new Uint8Array(requestBody) : undefined,
1900
+ signal,
1901
+ });
1902
+ // Check for provider errors
1903
+ if (response.status !== 200) {
1904
+ // Clone response to read body without consuming it
1905
+ const errorBodyChunks = await readBodyWithTimeout(response.body, ERROR_BODY_READ_TIMEOUT_MS);
1906
+ const errorBody = Buffer.concat(errorBodyChunks).toString();
1907
+ const category = categorizeError(response.status, errorBody);
1908
+ return {
1909
+ success: false,
1910
+ errorBody,
1911
+ errorStatus: response.status,
1912
+ isProviderError: category !== null,
1913
+ errorCategory: category ?? undefined,
1914
+ };
1915
+ }
1916
+ // Detect provider degradation hidden inside HTTP 200 responses.
1917
+ const contentType = response.headers.get("content-type") || "";
1918
+ if (contentType.includes("json") || contentType.includes("text")) {
1919
+ try {
1920
+ const clonedChunks = await readBodyWithTimeout(response.clone().body, ERROR_BODY_READ_TIMEOUT_MS);
1921
+ const responseBody = Buffer.concat(clonedChunks).toString();
1922
+ const degradedReason = detectDegradedSuccessResponse(responseBody);
1923
+ if (degradedReason) {
1924
+ return {
1925
+ success: false,
1926
+ errorBody: degradedReason,
1927
+ errorStatus: 503,
1928
+ isProviderError: true,
1929
+ };
1930
+ }
1931
+ }
1932
+ catch {
1933
+ // Ignore body inspection failures and pass through response.
1934
+ }
1935
+ }
1936
+ return { success: true, response };
1937
+ }
1938
+ catch (err) {
1939
+ const errorMsg = err instanceof Error ? err.message : String(err);
1940
+ return {
1941
+ success: false,
1942
+ errorBody: errorMsg,
1943
+ errorStatus: 500,
1944
+ isProviderError: true, // Network errors are retryable
1945
+ };
1946
+ }
1947
+ }
1948
+ /**
1949
+ * Proxy a single request through x402 payment flow to BlockRun API.
1950
+ *
1951
+ * Optimizations applied in order:
1952
+ * 1. Dedup check — if same request body seen within 30s, replay cached response
1953
+ * 2. Streaming heartbeat — for stream:true, send 200 + heartbeats immediately
1954
+ * 3. Smart routing — when model is "blockrun/auto", pick cheapest capable model
1955
+ * 4. Fallback chain — on provider errors, try next model in tier's fallback list
1956
+ */
1957
+ async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, deduplicator, balanceMonitor, sessionStore, responseCache, sessionJournal) {
1958
+ const startTime = Date.now();
1959
+ // Build upstream URL: /v1/chat/completions → https://blockrun.ai/api/v1/chat/completions
1960
+ const upstreamUrl = `${apiBase}${req.url}`;
1961
+ // Collect request body
1962
+ const bodyChunks = [];
1963
+ for await (const chunk of req) {
1964
+ bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
1965
+ }
1966
+ let body = Buffer.concat(bodyChunks);
1967
+ // Track original context size for response headers
1968
+ const originalContextSizeKB = Math.ceil(body.length / 1024);
1969
+ // Routing debug info is on by default; disable with x-clawrouter-debug: false
1970
+ const debugMode = req.headers["x-clawrouter-debug"] !== "false";
1971
+ // --- Smart routing ---
1972
+ let routingDecision;
1973
+ let hasTools = false; // true when request includes a tools schema
1974
+ let hasVision = false; // true when request includes image_url content parts
1975
+ let isStreaming = false;
1976
+ let modelId = "";
1977
+ let maxTokens = 4096;
1978
+ let routingProfile = null;
1979
+ let balanceFallbackNotice;
1980
+ let budgetDowngradeNotice;
1981
+ let budgetDowngradeHeaderMode;
1982
+ let accumulatedContent = ""; // For session journal event extraction
1983
+ let responseInputTokens;
1984
+ let responseOutputTokens;
1985
+ let requestHadError = false; // Set to true when all models fail → used in logUsage
1986
+ const isChatCompletion = req.url?.includes("/chat/completions");
1987
+ // Extract session ID early for journal operations (header-only at this point)
1988
+ const sessionId = getSessionId(req.headers);
1989
+ // Full session ID (header + content-derived) — populated once messages are parsed
1990
+ let effectiveSessionId = sessionId;
1991
+ if (isChatCompletion && body.length > 0) {
1992
+ try {
1993
+ const parsed = JSON.parse(body.toString());
1994
+ isStreaming = parsed.stream === true;
1995
+ modelId = parsed.model || "";
1996
+ maxTokens = parsed.max_tokens || 4096;
1997
+ let bodyModified = false;
1998
+ // Extract last user message content (used by session journal + /debug command)
1999
+ const parsedMessages = Array.isArray(parsed.messages)
2000
+ ? parsed.messages
2001
+ : [];
2002
+ const lastUserMsg = [...parsedMessages].reverse().find((m) => m.role === "user");
2003
+ // Early tool detection for ALL request types (explicit model + routing profile).
2004
+ // The routing-profile branch may re-assign below (no-op since same value).
2005
+ hasTools = Array.isArray(parsed.tools) && parsed.tools.length > 0;
2006
+ const rawLastContent = lastUserMsg?.content;
2007
+ const lastContent = typeof rawLastContent === "string"
2008
+ ? rawLastContent
2009
+ : Array.isArray(rawLastContent)
2010
+ ? rawLastContent
2011
+ .filter((b) => b.type === "text")
2012
+ .map((b) => b.text ?? "")
2013
+ .join(" ")
2014
+ : "";
2015
+ // --- Session Journal: Inject context if needed ---
2016
+ // Check if the last user message asks about past work
2017
+ if (sessionId && parsedMessages.length > 0) {
2018
+ const messages = parsedMessages;
2019
+ if (sessionJournal.needsContext(lastContent)) {
2020
+ const journalText = sessionJournal.format(sessionId);
2021
+ if (journalText) {
2022
+ // Find system message and prepend journal, or add a new system message
2023
+ const sysIdx = messages.findIndex((m) => m.role === "system");
2024
+ if (sysIdx >= 0 && typeof messages[sysIdx].content === "string") {
2025
+ messages[sysIdx] = {
2026
+ ...messages[sysIdx],
2027
+ content: journalText + "\n\n" + messages[sysIdx].content,
2028
+ };
2029
+ }
2030
+ else {
2031
+ messages.unshift({ role: "system", content: journalText });
2032
+ }
2033
+ parsed.messages = messages;
2034
+ bodyModified = true;
2035
+ console.log(`[ClawRouter] Injected session journal (${journalText.length} chars) for session ${sessionId.slice(0, 8)}...`);
2036
+ }
2037
+ }
2038
+ }
2039
+ // --- /debug command: return routing diagnostics without calling upstream ---
2040
+ if (lastContent.startsWith("/debug")) {
2041
+ const debugPrompt = lastContent.slice("/debug".length).trim() || "hello";
2042
+ const messages = parsed.messages;
2043
+ const systemMsg = messages?.find((m) => m.role === "system");
2044
+ const systemPrompt = typeof systemMsg?.content === "string" ? systemMsg.content : undefined;
2045
+ const fullText = `${systemPrompt ?? ""} ${debugPrompt}`;
2046
+ const estimatedTokens = Math.ceil(fullText.length / 4);
2047
+ // Determine routing profile
2048
+ const normalizedModel = typeof parsed.model === "string" ? parsed.model.trim().toLowerCase() : "";
2049
+ const profileName = normalizedModel.replace("blockrun/", "");
2050
+ const debugProfile = (["eco", "auto", "premium"].includes(profileName) ? profileName : "auto");
2051
+ // Run scoring
2052
+ const scoring = classifyByRules(debugPrompt, systemPrompt, estimatedTokens, DEFAULT_ROUTING_CONFIG.scoring);
2053
+ // Run full routing decision
2054
+ const debugRouting = route(debugPrompt, systemPrompt, maxTokens, {
2055
+ ...routerOpts,
2056
+ routingProfile: debugProfile,
2057
+ });
2058
+ // Format dimension scores
2059
+ const dimLines = (scoring.dimensions ?? [])
2060
+ .map((d) => {
2061
+ const nameStr = (d.name + ":").padEnd(24);
2062
+ const scoreStr = d.score.toFixed(2).padStart(6);
2063
+ const sigStr = d.signal ? ` [${d.signal}]` : "";
2064
+ return ` ${nameStr}${scoreStr}${sigStr}`;
2065
+ })
2066
+ .join("\n");
2067
+ // Session info
2068
+ const sess = sessionId ? sessionStore.getSession(sessionId) : undefined;
2069
+ const sessLine = sess
2070
+ ? `Session: ${sessionId.slice(0, 8)}... → pinned: ${sess.model} (${sess.requestCount} requests)`
2071
+ : sessionId
2072
+ ? `Session: ${sessionId.slice(0, 8)}... → no pinned model`
2073
+ : "Session: none";
2074
+ const { simpleMedium, mediumComplex, complexReasoning } = DEFAULT_ROUTING_CONFIG.scoring.tierBoundaries;
2075
+ const debugText = [
2076
+ "ClawRouter Debug",
2077
+ "",
2078
+ `Profile: ${debugProfile} | Tier: ${debugRouting.tier} | Model: ${debugRouting.model}`,
2079
+ `Confidence: ${debugRouting.confidence.toFixed(2)} | Cost: $${debugRouting.costEstimate.toFixed(4)} | Savings: ${(debugRouting.savings * 100).toFixed(0)}%`,
2080
+ `Reasoning: ${debugRouting.reasoning}`,
2081
+ "",
2082
+ `Scoring (weighted: ${scoring.score.toFixed(3)})`,
2083
+ dimLines,
2084
+ "",
2085
+ `Tier Boundaries: SIMPLE <${simpleMedium.toFixed(2)} | MEDIUM <${mediumComplex.toFixed(2)} | COMPLEX <${complexReasoning.toFixed(2)} | REASONING >=${complexReasoning.toFixed(2)}`,
2086
+ "",
2087
+ sessLine,
2088
+ ].join("\n");
2089
+ // Build synthetic OpenAI chat completion response
2090
+ const completionId = `chatcmpl-debug-${Date.now()}`;
2091
+ const timestamp = Math.floor(Date.now() / 1000);
2092
+ const syntheticResponse = {
2093
+ id: completionId,
2094
+ object: "chat.completion",
2095
+ created: timestamp,
2096
+ model: "clawrouter/debug",
2097
+ choices: [
2098
+ {
2099
+ index: 0,
2100
+ message: { role: "assistant", content: debugText },
2101
+ finish_reason: "stop",
2102
+ },
2103
+ ],
2104
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
2105
+ };
2106
+ if (isStreaming) {
2107
+ // SSE streaming response
2108
+ res.writeHead(200, {
2109
+ "Content-Type": "text/event-stream",
2110
+ "Cache-Control": "no-cache",
2111
+ Connection: "keep-alive",
2112
+ });
2113
+ const sseChunk = {
2114
+ id: completionId,
2115
+ object: "chat.completion.chunk",
2116
+ created: timestamp,
2117
+ model: "clawrouter/debug",
2118
+ choices: [
2119
+ {
2120
+ index: 0,
2121
+ delta: { role: "assistant", content: debugText },
2122
+ finish_reason: null,
2123
+ },
2124
+ ],
2125
+ };
2126
+ const sseDone = {
2127
+ id: completionId,
2128
+ object: "chat.completion.chunk",
2129
+ created: timestamp,
2130
+ model: "clawrouter/debug",
2131
+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
2132
+ };
2133
+ res.write(`data: ${JSON.stringify(sseChunk)}\n\n`);
2134
+ res.write(`data: ${JSON.stringify(sseDone)}\n\n`);
2135
+ res.write("data: [DONE]\n\n");
2136
+ res.end();
2137
+ }
2138
+ else {
2139
+ res.writeHead(200, { "Content-Type": "application/json" });
2140
+ res.end(JSON.stringify(syntheticResponse));
2141
+ }
2142
+ console.log(`[ClawRouter] /debug command → ${debugRouting.tier} | ${debugRouting.model}`);
2143
+ return;
2144
+ }
2145
+ // --- /imagegen command: generate an image via BlockRun image API ---
2146
+ if (lastContent.startsWith("/imagegen")) {
2147
+ const imageArgs = lastContent.slice("/imagegen".length).trim();
2148
+ // Parse optional flags: /imagegen --model dall-e-3 --size 1792x1024 a cute cat
2149
+ let imageModel = "google/nano-banana";
2150
+ let imageSize = "1024x1024";
2151
+ let imagePrompt = imageArgs;
2152
+ // Extract --model flag
2153
+ const modelMatch = imageArgs.match(/--model\s+(\S+)/);
2154
+ if (modelMatch) {
2155
+ const raw = modelMatch[1];
2156
+ // Resolve shorthand aliases
2157
+ const IMAGE_MODEL_ALIASES = {
2158
+ "dall-e-3": "openai/dall-e-3",
2159
+ dalle3: "openai/dall-e-3",
2160
+ dalle: "openai/dall-e-3",
2161
+ "gpt-image": "openai/gpt-image-1",
2162
+ "gpt-image-1": "openai/gpt-image-1",
2163
+ flux: "black-forest/flux-1.1-pro",
2164
+ "flux-pro": "black-forest/flux-1.1-pro",
2165
+ banana: "google/nano-banana",
2166
+ "nano-banana": "google/nano-banana",
2167
+ "banana-pro": "google/nano-banana-pro",
2168
+ "nano-banana-pro": "google/nano-banana-pro",
2169
+ };
2170
+ imageModel = IMAGE_MODEL_ALIASES[raw] ?? raw;
2171
+ imagePrompt = imagePrompt.replace(/--model\s+\S+/, "").trim();
2172
+ }
2173
+ // Extract --size flag
2174
+ const sizeMatch = imageArgs.match(/--size\s+(\d+x\d+)/);
2175
+ if (sizeMatch) {
2176
+ imageSize = sizeMatch[1];
2177
+ imagePrompt = imagePrompt.replace(/--size\s+\d+x\d+/, "").trim();
2178
+ }
2179
+ if (!imagePrompt) {
2180
+ const errorText = [
2181
+ "Usage: /imagegen <prompt>",
2182
+ "",
2183
+ "Options:",
2184
+ " --model <model> Model to use (default: nano-banana)",
2185
+ " --size <WxH> Image size (default: 1024x1024)",
2186
+ "",
2187
+ "Models:",
2188
+ " nano-banana Google Gemini Flash — $0.05/image",
2189
+ " banana-pro Google Gemini Pro — $0.10/image (up to 4K)",
2190
+ " dall-e-3 OpenAI DALL-E 3 — $0.04/image",
2191
+ " gpt-image OpenAI GPT Image 1 — $0.02/image",
2192
+ " flux Black Forest Flux 1.1 Pro — $0.04/image",
2193
+ "",
2194
+ "Examples:",
2195
+ " /imagegen a cat wearing sunglasses",
2196
+ " /imagegen --model dall-e-3 a futuristic city at sunset",
2197
+ " /imagegen --model banana-pro --size 2048x2048 mountain landscape",
2198
+ ].join("\n");
2199
+ const completionId = `chatcmpl-image-${Date.now()}`;
2200
+ const timestamp = Math.floor(Date.now() / 1000);
2201
+ if (isStreaming) {
2202
+ res.writeHead(200, {
2203
+ "Content-Type": "text/event-stream",
2204
+ "Cache-Control": "no-cache",
2205
+ Connection: "keep-alive",
2206
+ });
2207
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: { role: "assistant", content: errorText }, finish_reason: null }] })}\n\n`);
2208
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`);
2209
+ res.write("data: [DONE]\n\n");
2210
+ res.end();
2211
+ }
2212
+ else {
2213
+ res.writeHead(200, { "Content-Type": "application/json" });
2214
+ res.end(JSON.stringify({
2215
+ id: completionId,
2216
+ object: "chat.completion",
2217
+ created: timestamp,
2218
+ model: "clawrouter/image",
2219
+ choices: [
2220
+ {
2221
+ index: 0,
2222
+ message: { role: "assistant", content: errorText },
2223
+ finish_reason: "stop",
2224
+ },
2225
+ ],
2226
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
2227
+ }));
2228
+ }
2229
+ console.log(`[ClawRouter] /imagegen command → showing usage help`);
2230
+ return;
2231
+ }
2232
+ // Call upstream image generation API
2233
+ console.log(`[ClawRouter] /imagegen command → ${imageModel} (${imageSize}): ${imagePrompt.slice(0, 80)}...`);
2234
+ try {
2235
+ const imageUpstreamUrl = `${apiBase}/v1/images/generations`;
2236
+ const imageBody = JSON.stringify({
2237
+ model: imageModel,
2238
+ prompt: imagePrompt,
2239
+ size: imageSize,
2240
+ n: 1,
2241
+ });
2242
+ const imageResponse = await payFetch(imageUpstreamUrl, {
2243
+ method: "POST",
2244
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
2245
+ body: imageBody,
2246
+ });
2247
+ const imageResult = (await imageResponse.json());
2248
+ let responseText;
2249
+ if (!imageResponse.ok || imageResult.error) {
2250
+ const errMsg = typeof imageResult.error === "string"
2251
+ ? imageResult.error
2252
+ : (imageResult.error?.message ??
2253
+ `HTTP ${imageResponse.status}`);
2254
+ responseText = `Image generation failed: ${errMsg}`;
2255
+ console.log(`[ClawRouter] /imagegen error: ${errMsg}`);
2256
+ }
2257
+ else {
2258
+ const images = imageResult.data ?? [];
2259
+ if (images.length === 0) {
2260
+ responseText = "Image generation returned no results.";
2261
+ }
2262
+ else {
2263
+ const lines = [];
2264
+ for (const img of images) {
2265
+ if (img.url) {
2266
+ if (img.url.startsWith("data:")) {
2267
+ try {
2268
+ const hostedUrl = await uploadDataUriToHost(img.url);
2269
+ lines.push(hostedUrl);
2270
+ }
2271
+ catch (uploadErr) {
2272
+ console.error(`[ClawRouter] /imagegen: failed to upload data URI: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`);
2273
+ lines.push("Image generated but upload failed. Try again or use --model dall-e-3.");
2274
+ }
2275
+ }
2276
+ else {
2277
+ lines.push(img.url);
2278
+ }
2279
+ }
2280
+ if (img.revised_prompt)
2281
+ lines.push(`Revised prompt: ${img.revised_prompt}`);
2282
+ }
2283
+ lines.push("", `Model: ${imageModel} | Size: ${imageSize}`);
2284
+ responseText = lines.join("\n");
2285
+ }
2286
+ console.log(`[ClawRouter] /imagegen success: ${images.length} image(s) generated`);
2287
+ // Log /imagegen usage with actual x402 payment
2288
+ const imagegenActualCost = paymentStore.getStore()?.amountUsd ?? estimateImageCost(imageModel, imageSize, 1);
2289
+ logUsage({
2290
+ timestamp: new Date().toISOString(),
2291
+ model: imageModel,
2292
+ tier: "IMAGE",
2293
+ cost: imagegenActualCost,
2294
+ baselineCost: imagegenActualCost,
2295
+ savings: 0,
2296
+ latencyMs: 0,
2297
+ }).catch(() => { });
2298
+ }
2299
+ // Return as synthetic chat completion
2300
+ const completionId = `chatcmpl-image-${Date.now()}`;
2301
+ const timestamp = Math.floor(Date.now() / 1000);
2302
+ if (isStreaming) {
2303
+ res.writeHead(200, {
2304
+ "Content-Type": "text/event-stream",
2305
+ "Cache-Control": "no-cache",
2306
+ Connection: "keep-alive",
2307
+ });
2308
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: { role: "assistant", content: responseText }, finish_reason: null }] })}\n\n`);
2309
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/image", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`);
2310
+ res.write("data: [DONE]\n\n");
2311
+ res.end();
2312
+ }
2313
+ else {
2314
+ res.writeHead(200, { "Content-Type": "application/json" });
2315
+ res.end(JSON.stringify({
2316
+ id: completionId,
2317
+ object: "chat.completion",
2318
+ created: timestamp,
2319
+ model: "clawrouter/image",
2320
+ choices: [
2321
+ {
2322
+ index: 0,
2323
+ message: { role: "assistant", content: responseText },
2324
+ finish_reason: "stop",
2325
+ },
2326
+ ],
2327
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
2328
+ }));
2329
+ }
2330
+ }
2331
+ catch (err) {
2332
+ const errMsg = err instanceof Error ? err.message : String(err);
2333
+ console.error(`[ClawRouter] /imagegen error: ${errMsg}`);
2334
+ if (!res.headersSent) {
2335
+ res.writeHead(500, { "Content-Type": "application/json" });
2336
+ res.end(JSON.stringify({
2337
+ error: { message: `Image generation failed: ${errMsg}`, type: "image_error" },
2338
+ }));
2339
+ }
2340
+ }
2341
+ return;
2342
+ }
2343
+ // --- /img2img command: edit an image via BlockRun image2image API ---
2344
+ if (lastContent.startsWith("/img2img")) {
2345
+ const imgArgs = lastContent.slice("/img2img".length).trim();
2346
+ let img2imgModel = "openai/gpt-image-1";
2347
+ let img2imgSize = "1024x1024";
2348
+ let imagePath = null;
2349
+ let maskPath = null;
2350
+ let img2imgPrompt = imgArgs;
2351
+ const imageMatch = imgArgs.match(/--image\s+(\S+)/);
2352
+ if (imageMatch) {
2353
+ imagePath = imageMatch[1];
2354
+ img2imgPrompt = img2imgPrompt.replace(/--image\s+\S+/, "").trim();
2355
+ }
2356
+ const maskMatch = imgArgs.match(/--mask\s+(\S+)/);
2357
+ if (maskMatch) {
2358
+ maskPath = maskMatch[1];
2359
+ img2imgPrompt = img2imgPrompt.replace(/--mask\s+\S+/, "").trim();
2360
+ }
2361
+ const img2imgSizeMatch = imgArgs.match(/--size\s+(\d+x\d+)/);
2362
+ if (img2imgSizeMatch) {
2363
+ img2imgSize = img2imgSizeMatch[1];
2364
+ img2imgPrompt = img2imgPrompt.replace(/--size\s+\d+x\d+/, "").trim();
2365
+ }
2366
+ const img2imgModelMatch = imgArgs.match(/--model\s+(\S+)/);
2367
+ if (img2imgModelMatch) {
2368
+ const raw = img2imgModelMatch[1];
2369
+ const IMG2IMG_ALIASES = {
2370
+ "gpt-image": "openai/gpt-image-1",
2371
+ "gpt-image-1": "openai/gpt-image-1",
2372
+ };
2373
+ img2imgModel = IMG2IMG_ALIASES[raw] ?? raw;
2374
+ img2imgPrompt = img2imgPrompt.replace(/--model\s+\S+/, "").trim();
2375
+ }
2376
+ const usageText = [
2377
+ "Usage: /img2img --image <path> <prompt>",
2378
+ "",
2379
+ "Options:",
2380
+ " --image <path> Source image path (required)",
2381
+ " --mask <path> Mask image path (optional, white = area to edit)",
2382
+ " --model <model> Model (default: gpt-image-1)",
2383
+ " --size <WxH> Output size (default: 1024x1024)",
2384
+ "",
2385
+ "Models:",
2386
+ " gpt-image-1 OpenAI GPT Image 1 — $0.02/image",
2387
+ "",
2388
+ "Examples:",
2389
+ " /img2img --image ~/photo.png change background to starry sky",
2390
+ " /img2img --image ./cat.jpg --mask ./mask.png remove the background",
2391
+ " /img2img --image /tmp/portrait.png --size 1536x1024 add a hat",
2392
+ ].join("\n");
2393
+ const sendImg2ImgText = (text) => {
2394
+ const completionId = `chatcmpl-img2img-${Date.now()}`;
2395
+ const timestamp = Math.floor(Date.now() / 1000);
2396
+ if (isStreaming) {
2397
+ res.writeHead(200, {
2398
+ "Content-Type": "text/event-stream",
2399
+ "Cache-Control": "no-cache",
2400
+ Connection: "keep-alive",
2401
+ });
2402
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/img2img", choices: [{ index: 0, delta: { role: "assistant", content: text }, finish_reason: null }] })}\n\n`);
2403
+ res.write(`data: ${JSON.stringify({ id: completionId, object: "chat.completion.chunk", created: timestamp, model: "clawrouter/img2img", choices: [{ index: 0, delta: {}, finish_reason: "stop" }] })}\n\n`);
2404
+ res.write("data: [DONE]\n\n");
2405
+ res.end();
2406
+ }
2407
+ else {
2408
+ res.writeHead(200, { "Content-Type": "application/json" });
2409
+ res.end(JSON.stringify({
2410
+ id: completionId,
2411
+ object: "chat.completion",
2412
+ created: timestamp,
2413
+ model: "clawrouter/img2img",
2414
+ choices: [
2415
+ {
2416
+ index: 0,
2417
+ message: { role: "assistant", content: text },
2418
+ finish_reason: "stop",
2419
+ },
2420
+ ],
2421
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
2422
+ }));
2423
+ }
2424
+ };
2425
+ if (!imagePath || !img2imgPrompt) {
2426
+ sendImg2ImgText(usageText);
2427
+ return;
2428
+ }
2429
+ let imageDataUri;
2430
+ let maskDataUri;
2431
+ try {
2432
+ imageDataUri = readImageFileAsDataUri(imagePath);
2433
+ if (maskPath)
2434
+ maskDataUri = readImageFileAsDataUri(maskPath);
2435
+ }
2436
+ catch (fileErr) {
2437
+ const fileErrMsg = fileErr instanceof Error ? fileErr.message : String(fileErr);
2438
+ sendImg2ImgText(`Failed to read image file: ${fileErrMsg}`);
2439
+ return;
2440
+ }
2441
+ console.log(`[ClawRouter] /img2img → ${img2imgModel} (${img2imgSize}): ${img2imgPrompt.slice(0, 80)}`);
2442
+ try {
2443
+ const img2imgBody = JSON.stringify({
2444
+ model: img2imgModel,
2445
+ prompt: img2imgPrompt,
2446
+ image: imageDataUri,
2447
+ ...(maskDataUri ? { mask: maskDataUri } : {}),
2448
+ size: img2imgSize,
2449
+ n: 1,
2450
+ });
2451
+ const img2imgResponse = await payFetch(`${apiBase}/v1/images/image2image`, {
2452
+ method: "POST",
2453
+ headers: { "content-type": "application/json", "user-agent": USER_AGENT },
2454
+ body: img2imgBody,
2455
+ });
2456
+ const img2imgResult = (await img2imgResponse.json());
2457
+ let responseText;
2458
+ if (!img2imgResponse.ok || img2imgResult.error) {
2459
+ const errMsg = typeof img2imgResult.error === "string"
2460
+ ? img2imgResult.error
2461
+ : (img2imgResult.error?.message ??
2462
+ `HTTP ${img2imgResponse.status}`);
2463
+ responseText = `Image editing failed: ${errMsg}`;
2464
+ console.log(`[ClawRouter] /img2img error: ${errMsg}`);
2465
+ }
2466
+ else {
2467
+ const images = img2imgResult.data ?? [];
2468
+ if (images.length === 0) {
2469
+ responseText = "Image editing returned no results.";
2470
+ }
2471
+ else {
2472
+ const lines = [];
2473
+ for (const img of images) {
2474
+ if (img.url) {
2475
+ if (img.url.startsWith("data:")) {
2476
+ try {
2477
+ const hostedUrl = await uploadDataUriToHost(img.url);
2478
+ lines.push(hostedUrl);
2479
+ }
2480
+ catch (uploadErr) {
2481
+ console.error(`[ClawRouter] /img2img: failed to upload data URI: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`);
2482
+ lines.push("Image edited but upload failed. Try again.");
2483
+ }
2484
+ }
2485
+ else {
2486
+ lines.push(img.url);
2487
+ }
2488
+ }
2489
+ if (img.revised_prompt)
2490
+ lines.push(`Revised prompt: ${img.revised_prompt}`);
2491
+ }
2492
+ lines.push("", `Model: ${img2imgModel} | Size: ${img2imgSize}`);
2493
+ responseText = lines.join("\n");
2494
+ }
2495
+ console.log(`[ClawRouter] /img2img success: ${images.length} image(s)`);
2496
+ // Log /img2img usage with actual x402 payment
2497
+ const img2imgActualCost2 = paymentStore.getStore()?.amountUsd ?? estimateImageCost(img2imgModel, img2imgSize, 1);
2498
+ logUsage({
2499
+ timestamp: new Date().toISOString(),
2500
+ model: img2imgModel,
2501
+ tier: "IMAGE",
2502
+ cost: img2imgActualCost2,
2503
+ baselineCost: img2imgActualCost2,
2504
+ savings: 0,
2505
+ latencyMs: 0,
2506
+ }).catch(() => { });
2507
+ }
2508
+ sendImg2ImgText(responseText);
2509
+ }
2510
+ catch (err) {
2511
+ const errMsg = err instanceof Error ? err.message : String(err);
2512
+ console.error(`[ClawRouter] /img2img error: ${errMsg}`);
2513
+ if (!res.headersSent) {
2514
+ res.writeHead(500, { "Content-Type": "application/json" });
2515
+ res.end(JSON.stringify({
2516
+ error: { message: `Image editing failed: ${errMsg}`, type: "img2img_error" },
2517
+ }));
2518
+ }
2519
+ }
2520
+ return;
2521
+ }
2522
+ // Force stream: false — BlockRun API doesn't support streaming yet
2523
+ // ClawRouter handles SSE heartbeat simulation for upstream compatibility
2524
+ if (parsed.stream === true) {
2525
+ parsed.stream = false;
2526
+ bodyModified = true;
2527
+ }
2528
+ // Normalize model name for comparison (trim whitespace, lowercase)
2529
+ const normalizedModel = typeof parsed.model === "string" ? parsed.model.trim().toLowerCase() : "";
2530
+ // Resolve model aliases (e.g., "claude" -> "anthropic/claude-sonnet-4-6")
2531
+ const resolvedModel = resolveModelAlias(normalizedModel);
2532
+ const wasAlias = resolvedModel !== normalizedModel;
2533
+ // Check both normalizedModel and resolvedModel — OpenClaw may send "openai/eco"
2534
+ // which resolveModelAlias strips to "eco" (a valid routing profile)
2535
+ const isRoutingProfile = ROUTING_PROFILES.has(normalizedModel) || ROUTING_PROFILES.has(resolvedModel);
2536
+ // Extract routing profile type (free/eco/auto/premium)
2537
+ if (isRoutingProfile) {
2538
+ const profileName = resolvedModel.replace("blockrun/", "");
2539
+ routingProfile = profileName;
2540
+ }
2541
+ // Debug: log received model name
2542
+ console.log(`[ClawRouter] Received model: "${parsed.model}" -> normalized: "${normalizedModel}"${wasAlias ? ` -> alias: "${resolvedModel}"` : ""}${routingProfile ? `, profile: ${routingProfile}` : ""}`);
2543
+ // For explicit model requests, always canonicalize the model ID before upstream calls.
2544
+ // This ensures case/whitespace variants (e.g. "DEEPSEEK/..." or " model ") route correctly.
2545
+ if (!isRoutingProfile) {
2546
+ if (parsed.model !== resolvedModel) {
2547
+ parsed.model = resolvedModel;
2548
+ bodyModified = true;
2549
+ }
2550
+ modelId = resolvedModel;
2551
+ }
2552
+ // Handle routing profiles (free/eco/auto/premium)
2553
+ if (isRoutingProfile) {
2554
+ {
2555
+ // eco/auto/premium - use tier routing
2556
+ // Check for session persistence - use pinned model if available
2557
+ // Fall back to deriving a session ID from message content when OpenClaw
2558
+ // doesn't send an explicit x-session-id header (the default behaviour).
2559
+ effectiveSessionId =
2560
+ getSessionId(req.headers) ??
2561
+ deriveSessionId(parsedMessages);
2562
+ const existingSession = effectiveSessionId
2563
+ ? sessionStore.getSession(effectiveSessionId)
2564
+ : undefined;
2565
+ // Extract prompt from last user message (handles both string and Anthropic array content)
2566
+ const rawPrompt = lastUserMsg?.content;
2567
+ const prompt = typeof rawPrompt === "string"
2568
+ ? rawPrompt
2569
+ : Array.isArray(rawPrompt)
2570
+ ? rawPrompt
2571
+ .filter((b) => b.type === "text")
2572
+ .map((b) => b.text ?? "")
2573
+ .join(" ")
2574
+ : "";
2575
+ const systemMsg = parsedMessages.find((m) => m.role === "system");
2576
+ const systemPrompt = typeof systemMsg?.content === "string" ? systemMsg.content : undefined;
2577
+ // Tool detection — when tools are present, force agentic tiers for reliable tool use
2578
+ const tools = parsed.tools;
2579
+ hasTools = Array.isArray(tools) && tools.length > 0;
2580
+ if (hasTools && tools) {
2581
+ console.log(`[ClawRouter] Tools detected (${tools.length}), forcing agentic tiers`);
2582
+ }
2583
+ // Vision detection: scan messages for image_url content parts
2584
+ hasVision = parsedMessages.some((m) => {
2585
+ if (Array.isArray(m.content)) {
2586
+ return m.content.some((p) => p.type === "image_url");
2587
+ }
2588
+ return false;
2589
+ });
2590
+ if (hasVision) {
2591
+ console.log(`[ClawRouter] Vision content detected, filtering to vision-capable models`);
2592
+ }
2593
+ // Always route based on current request content
2594
+ routingDecision = route(prompt, systemPrompt, maxTokens, {
2595
+ ...routerOpts,
2596
+ routingProfile: routingProfile ?? undefined,
2597
+ hasTools,
2598
+ });
2599
+ // Keep agentic routing when tools are present, even for SIMPLE queries.
2600
+ // Tool-using requests need models with reliable function-call support;
2601
+ // demoting to non-agentic tiers causes fallback to models that refuse
2602
+ // tool schemas (gemini-flash-lite, deepseek) or lack tool support entirely.
2603
+ if (hasTools && routingDecision.tier === "SIMPLE") {
2604
+ console.log(`[ClawRouter] SIMPLE+tools: keeping agentic model ${routingDecision.model} (tools need reliable function-call support)`);
2605
+ }
2606
+ if (existingSession) {
2607
+ // Never downgrade: only upgrade the session when the current request needs a higher
2608
+ // tier. This fixes the OpenClaw startup-message bias (the startup message always
2609
+ // scores low-complexity, which previously pinned all subsequent real queries to a
2610
+ // cheap model) while still preventing mid-task model switching on simple follow-ups.
2611
+ const tierRank = {
2612
+ SIMPLE: 0,
2613
+ MEDIUM: 1,
2614
+ COMPLEX: 2,
2615
+ REASONING: 3,
2616
+ };
2617
+ const existingRank = tierRank[existingSession.tier] ?? 0;
2618
+ const newRank = tierRank[routingDecision.tier] ?? 0;
2619
+ if (newRank > existingRank) {
2620
+ // Current request needs higher capability — upgrade the session
2621
+ console.log(`[ClawRouter] Session ${effectiveSessionId?.slice(0, 8)}... upgrading: ${existingSession.tier} → ${routingDecision.tier} (${routingDecision.model})`);
2622
+ parsed.model = routingDecision.model;
2623
+ modelId = routingDecision.model;
2624
+ bodyModified = true;
2625
+ if (effectiveSessionId) {
2626
+ sessionStore.setSession(effectiveSessionId, routingDecision.model, routingDecision.tier);
2627
+ }
2628
+ }
2629
+ else if (routingDecision.tier === "SIMPLE") {
2630
+ // SIMPLE follow-up in an active session: let it use cheap routing.
2631
+ // e.g. "你好" or "thanks" after a complex task should not inherit the
2632
+ // expensive session model or recount all context tokens on a paid model.
2633
+ console.log(`[ClawRouter] Session ${effectiveSessionId?.slice(0, 8)}... SIMPLE follow-up, using cheap model: ${routingDecision.model} (bypassing pinned ${existingSession.tier})`);
2634
+ parsed.model = routingDecision.model;
2635
+ modelId = routingDecision.model;
2636
+ bodyModified = true;
2637
+ sessionStore.touchSession(effectiveSessionId);
2638
+ // routingDecision already reflects cheap model — no override needed
2639
+ }
2640
+ else {
2641
+ // Keep existing higher-tier model (prevent downgrade mid-task)
2642
+ console.log(`[ClawRouter] Session ${effectiveSessionId?.slice(0, 8)}... keeping pinned model: ${existingSession.model} (${existingSession.tier} >= ${routingDecision.tier})`);
2643
+ parsed.model = existingSession.model;
2644
+ modelId = existingSession.model;
2645
+ bodyModified = true;
2646
+ sessionStore.touchSession(effectiveSessionId);
2647
+ // Reflect the actual model used in the routing decision for logging/fallback
2648
+ routingDecision = {
2649
+ ...routingDecision,
2650
+ model: existingSession.model,
2651
+ tier: existingSession.tier,
2652
+ };
2653
+ }
2654
+ // --- Three-strike escalation: detect repetitive request patterns ---
2655
+ const lastAssistantMsg = [...parsedMessages]
2656
+ .reverse()
2657
+ .find((m) => m.role === "assistant");
2658
+ const assistantToolCalls = lastAssistantMsg?.tool_calls;
2659
+ const toolCallNames = Array.isArray(assistantToolCalls)
2660
+ ? assistantToolCalls
2661
+ .map((tc) => tc.function?.name)
2662
+ .filter((n) => Boolean(n))
2663
+ : undefined;
2664
+ const contentHash = hashRequestContent(prompt, toolCallNames);
2665
+ const shouldEscalate = sessionStore.recordRequestHash(effectiveSessionId, contentHash);
2666
+ if (shouldEscalate) {
2667
+ const activeTierConfigs = routingDecision.tierConfigs ?? routerOpts.config.tiers;
2668
+ const escalation = sessionStore.escalateSession(effectiveSessionId, activeTierConfigs);
2669
+ if (escalation) {
2670
+ console.log(`[ClawRouter] ⚡ 3-strike escalation: ${existingSession.model} → ${escalation.model} (${existingSession.tier} → ${escalation.tier})`);
2671
+ parsed.model = escalation.model;
2672
+ modelId = escalation.model;
2673
+ routingDecision = {
2674
+ ...routingDecision,
2675
+ model: escalation.model,
2676
+ tier: escalation.tier,
2677
+ };
2678
+ }
2679
+ }
2680
+ }
2681
+ else {
2682
+ // No session — pin this routing decision for future requests
2683
+ parsed.model = routingDecision.model;
2684
+ modelId = routingDecision.model;
2685
+ bodyModified = true;
2686
+ if (effectiveSessionId) {
2687
+ sessionStore.setSession(effectiveSessionId, routingDecision.model, routingDecision.tier);
2688
+ console.log(`[ClawRouter] Session ${effectiveSessionId.slice(0, 8)}... pinned to model: ${routingDecision.model}`);
2689
+ }
2690
+ }
2691
+ options.onRouted?.(routingDecision);
2692
+ }
2693
+ }
2694
+ // Ensure effectiveSessionId is set for explicit model requests via content hash fallback.
2695
+ // Routing profile requests already derive session ID from message content (see line above).
2696
+ // Explicit model requests (openai/gpt-4o, anthropic/claude-*, etc.) without an
2697
+ // x-session-id header would otherwise have no session ID → maxCostPerRun not tracked.
2698
+ if (!effectiveSessionId && parsedMessages.length > 0) {
2699
+ effectiveSessionId = deriveSessionId(parsedMessages);
2700
+ }
2701
+ // Rebuild body if modified — map free/xxx → nvidia/xxx for upstream
2702
+ if (bodyModified) {
2703
+ if (parsed.model && typeof parsed.model === "string") {
2704
+ parsed.model = toUpstreamModelId(parsed.model);
2705
+ }
2706
+ body = Buffer.from(JSON.stringify(parsed));
2707
+ }
2708
+ }
2709
+ catch (err) {
2710
+ // Log routing errors so they're not silently swallowed
2711
+ const errorMsg = err instanceof Error ? err.message : String(err);
2712
+ console.error(`[ClawRouter] Routing error: ${errorMsg}`);
2713
+ console.error(`[ClawRouter] Need help? Run: npx @blockrun/clawrouter doctor`);
2714
+ options.onError?.(new Error(`Routing failed: ${errorMsg}`));
2715
+ }
2716
+ }
2717
+ // --- Auto-compression ---
2718
+ // Compress large requests to reduce network usage and improve performance
2719
+ const autoCompress = options.autoCompressRequests ?? true;
2720
+ const compressionThreshold = options.compressionThresholdKB ?? 180;
2721
+ const requestSizeKB = Math.ceil(body.length / 1024);
2722
+ if (autoCompress && requestSizeKB > compressionThreshold) {
2723
+ try {
2724
+ console.log(`[ClawRouter] Request size ${requestSizeKB}KB exceeds threshold ${compressionThreshold}KB, applying compression...`);
2725
+ // Parse messages for compression
2726
+ const parsed = JSON.parse(body.toString());
2727
+ if (parsed.messages && parsed.messages.length > 0 && shouldCompress(parsed.messages)) {
2728
+ // Apply compression with conservative settings
2729
+ const compressionResult = await compressContext(parsed.messages, {
2730
+ enabled: true,
2731
+ preserveRaw: false, // Don't need originals in proxy
2732
+ layers: {
2733
+ deduplication: true, // Safe: removes duplicate messages
2734
+ whitespace: true, // Safe: normalizes whitespace
2735
+ dictionary: false, // Disabled: requires model to understand codebook
2736
+ paths: false, // Disabled: requires model to understand path codes
2737
+ jsonCompact: true, // Safe: just removes JSON whitespace
2738
+ observation: false, // Disabled: may lose important context
2739
+ dynamicCodebook: false, // Disabled: requires model to understand codes
2740
+ },
2741
+ dictionary: {
2742
+ maxEntries: 50,
2743
+ minPhraseLength: 15,
2744
+ includeCodebookHeader: false,
2745
+ },
2746
+ });
2747
+ const compressedSizeKB = Math.ceil(compressionResult.compressedChars / 1024);
2748
+ const savings = (((requestSizeKB - compressedSizeKB) / requestSizeKB) * 100).toFixed(1);
2749
+ console.log(`[ClawRouter] Compressed ${requestSizeKB}KB → ${compressedSizeKB}KB (${savings}% reduction)`);
2750
+ // Update request body with compressed messages
2751
+ parsed.messages = compressionResult.messages;
2752
+ body = Buffer.from(JSON.stringify(parsed));
2753
+ }
2754
+ }
2755
+ catch (err) {
2756
+ // Compression failed - continue with original request
2757
+ console.warn(`[ClawRouter] Compression failed: ${err instanceof Error ? err.message : String(err)}`);
2758
+ }
2759
+ }
2760
+ // --- Response cache check (long-term, 10min TTL) ---
2761
+ const cacheKey = ResponseCache.generateKey(body);
2762
+ const reqHeaders = {};
2763
+ for (const [key, value] of Object.entries(req.headers)) {
2764
+ if (typeof value === "string")
2765
+ reqHeaders[key] = value;
2766
+ }
2767
+ if (responseCache.shouldCache(body, reqHeaders)) {
2768
+ const cachedResponse = responseCache.get(cacheKey);
2769
+ if (cachedResponse) {
2770
+ console.log(`[ClawRouter] Cache HIT for ${cachedResponse.model} (saved API call)`);
2771
+ res.writeHead(cachedResponse.status, cachedResponse.headers);
2772
+ res.end(cachedResponse.body);
2773
+ return;
2774
+ }
2775
+ }
2776
+ // --- Dedup check (short-term, 30s TTL for retries) ---
2777
+ const dedupKey = RequestDeduplicator.hash(body);
2778
+ // Check dedup cache (catches retries within 30s)
2779
+ const cached = deduplicator.getCached(dedupKey);
2780
+ if (cached) {
2781
+ res.writeHead(cached.status, cached.headers);
2782
+ res.end(cached.body);
2783
+ return;
2784
+ }
2785
+ // Check in-flight — wait for the original request to complete
2786
+ const inflight = deduplicator.getInflight(dedupKey);
2787
+ if (inflight) {
2788
+ const result = await inflight;
2789
+ res.writeHead(result.status, result.headers);
2790
+ res.end(result.body);
2791
+ return;
2792
+ }
2793
+ // Register this request as in-flight
2794
+ deduplicator.markInflight(dedupKey);
2795
+ // --- Pre-request balance check ---
2796
+ // Estimate cost and check if wallet has sufficient balance
2797
+ // Skip if skipBalanceCheck is set (for testing) or if using free model
2798
+ let estimatedCostMicros;
2799
+ // Use `let` so the balance-fallback path can update this when modelId is switched to a free model.
2800
+ let isFreeModel = FREE_MODELS.has(modelId ?? "");
2801
+ if (modelId && !options.skipBalanceCheck && !isFreeModel) {
2802
+ const estimated = estimateAmount(modelId, body.length, maxTokens);
2803
+ if (estimated) {
2804
+ estimatedCostMicros = BigInt(estimated);
2805
+ // Apply extra buffer for balance check to prevent x402 failures after streaming starts.
2806
+ // This is aggressive to avoid triggering OpenClaw's 5-24 hour billing cooldown.
2807
+ const bufferedCostMicros = (estimatedCostMicros * BigInt(Math.ceil(BALANCE_CHECK_BUFFER * 100))) / 100n;
2808
+ // Check balance before proceeding (using buffered amount)
2809
+ const sufficiency = await balanceMonitor.checkSufficient(bufferedCostMicros);
2810
+ if (sufficiency.info.isEmpty || !sufficiency.sufficient) {
2811
+ // Wallet is empty or insufficient — fallback to free model
2812
+ const originalModel = modelId;
2813
+ console.log(`[ClawRouter] Wallet ${sufficiency.info.isEmpty ? "empty" : "insufficient"} (${sufficiency.info.balanceUSD}), falling back to free model: ${FREE_MODEL} (requested: ${originalModel})`);
2814
+ modelId = FREE_MODEL;
2815
+ isFreeModel = true; // keep in sync — budget logic gates on !isFreeModel
2816
+ // Update the body with new model (map free/ → nvidia/ for upstream)
2817
+ const parsed = JSON.parse(body.toString());
2818
+ parsed.model = toUpstreamModelId(FREE_MODEL);
2819
+ body = Buffer.from(JSON.stringify(parsed));
2820
+ // Set notice to prepend to response so user knows about the fallback
2821
+ balanceFallbackNotice = sufficiency.info.isEmpty
2822
+ ? `> **⚠️ Wallet empty** — using free model. Fund your wallet to use ${originalModel}.\n\n`
2823
+ : `> **⚠️ Insufficient balance** (${sufficiency.info.balanceUSD}) — using free model instead of ${originalModel}.\n\n`;
2824
+ // Notify about the fallback
2825
+ options.onLowBalance?.({
2826
+ balanceUSD: sufficiency.info.balanceUSD,
2827
+ walletAddress: sufficiency.info.walletAddress,
2828
+ });
2829
+ }
2830
+ else if (sufficiency.info.isLow) {
2831
+ // Balance is low but sufficient — warn and proceed
2832
+ options.onLowBalance?.({
2833
+ balanceUSD: sufficiency.info.balanceUSD,
2834
+ walletAddress: sufficiency.info.walletAddress,
2835
+ });
2836
+ }
2837
+ }
2838
+ }
2839
+ // --- Cost cap check: strict mode hard-stop ---
2840
+ // In 'strict' mode, reject if the projected session spend (accumulated + this request's
2841
+ // estimate) would exceed the cap. Checking projected cost (not just historical) prevents
2842
+ // a single large request from overshooting the cap before it's recorded.
2843
+ // In 'graceful' mode (default), the cap is enforced via model downgrade below.
2844
+ // Must happen before streaming headers are sent.
2845
+ if (options.maxCostPerRunUsd &&
2846
+ effectiveSessionId &&
2847
+ !isFreeModel &&
2848
+ (options.maxCostPerRunMode ?? "graceful") === "strict") {
2849
+ const runCostUsd = sessionStore.getSessionCostUsd(effectiveSessionId);
2850
+ // Include this request's estimated cost so even the first request is blocked
2851
+ // if it would push the session over the cap.
2852
+ const thisReqEstStr = estimatedCostMicros !== undefined
2853
+ ? estimatedCostMicros.toString()
2854
+ : modelId
2855
+ ? estimateAmount(modelId, body.length, maxTokens)
2856
+ : undefined;
2857
+ const thisReqEstUsd = thisReqEstStr ? Number(thisReqEstStr) / 1_000_000 : 0;
2858
+ const projectedCostUsd = runCostUsd + thisReqEstUsd;
2859
+ if (projectedCostUsd > options.maxCostPerRunUsd) {
2860
+ console.log(`[ClawRouter] Cost cap exceeded for session ${effectiveSessionId.slice(0, 8)}...: projected $${projectedCostUsd.toFixed(4)} (spent $${runCostUsd.toFixed(4)} + est $${thisReqEstUsd.toFixed(4)}) > $${options.maxCostPerRunUsd} limit`);
2861
+ res.writeHead(429, {
2862
+ "Content-Type": "application/json",
2863
+ "X-ClawRouter-Cost-Cap-Exceeded": "1",
2864
+ });
2865
+ res.end(JSON.stringify({
2866
+ error: {
2867
+ message: `ClawRouter cost cap exceeded: projected spend $${projectedCostUsd.toFixed(4)} (spent $${runCostUsd.toFixed(4)} + est $${thisReqEstUsd.toFixed(4)}) would exceed limit $${options.maxCostPerRunUsd}`,
2868
+ type: "cost_cap_exceeded",
2869
+ code: "cost_cap_exceeded",
2870
+ },
2871
+ }));
2872
+ deduplicator.removeInflight(dedupKey);
2873
+ return;
2874
+ }
2875
+ }
2876
+ // --- Budget pre-check: block when remaining budget can't cover the request ---
2877
+ // Must happen BEFORE streaming headers (429 can't be sent after SSE headers are flushed).
2878
+ // Three cases that require a hard block rather than graceful downgrade:
2879
+ // (A) tool/COMPLEX/REASONING routing profile — free model can't substitute
2880
+ // (B) explicit model request (no routing profile) — user chose a specific model,
2881
+ // silently substituting with free model would be deceptive regardless of task type
2882
+ // Simple routing profile requests are handled later via graceful downgrade.
2883
+ if (options.maxCostPerRunUsd &&
2884
+ effectiveSessionId &&
2885
+ !isFreeModel &&
2886
+ (options.maxCostPerRunMode ?? "graceful") === "graceful") {
2887
+ const runCostUsd = sessionStore.getSessionCostUsd(effectiveSessionId);
2888
+ const remainingUsd = options.maxCostPerRunUsd - runCostUsd;
2889
+ const isComplexOrAgentic = hasTools || routingDecision?.tier === "COMPLEX" || routingDecision?.tier === "REASONING";
2890
+ if (isComplexOrAgentic) {
2891
+ // Case A: tool/complex/agentic routing profile — check global model table
2892
+ // Intentionally exclude free models: they cannot handle complex/agentic tasks.
2893
+ const canAffordAnyNonFreeModel = BLOCKRUN_MODELS.some((m) => {
2894
+ if (FREE_MODELS.has(m.id))
2895
+ return false;
2896
+ const est = estimateAmount(m.id, body.length, maxTokens);
2897
+ return est !== undefined && Number(est) / 1_000_000 <= remainingUsd;
2898
+ });
2899
+ if (!canAffordAnyNonFreeModel) {
2900
+ console.log(`[ClawRouter] Budget insufficient for agentic/complex session ${effectiveSessionId.slice(0, 8)}...: $${Math.max(0, remainingUsd).toFixed(4)} remaining — blocking (silent downgrade would corrupt tool/complex responses)`);
2901
+ res.writeHead(429, {
2902
+ "Content-Type": "application/json",
2903
+ "X-ClawRouter-Cost-Cap-Exceeded": "1",
2904
+ "X-ClawRouter-Budget-Mode": "blocked",
2905
+ });
2906
+ res.end(JSON.stringify({
2907
+ error: {
2908
+ message: `ClawRouter budget exhausted: $${Math.max(0, remainingUsd).toFixed(4)} remaining (limit: $${options.maxCostPerRunUsd}). Increase maxCostPerRun to continue.`,
2909
+ type: "cost_cap_exceeded",
2910
+ code: "budget_exhausted",
2911
+ },
2912
+ }));
2913
+ deduplicator.removeInflight(dedupKey);
2914
+ return;
2915
+ }
2916
+ }
2917
+ else if (!routingDecision && modelId && !FREE_MODELS.has(modelId)) {
2918
+ // Case B: explicit model request (user chose a specific model, not a routing profile).
2919
+ // Silently substituting their choice with free model is deceptive — block instead.
2920
+ const est = estimateAmount(modelId, body.length, maxTokens);
2921
+ const canAfford = !est || Number(est) / 1_000_000 <= remainingUsd;
2922
+ if (!canAfford) {
2923
+ console.log(`[ClawRouter] Budget insufficient for explicit model ${modelId} in session ${effectiveSessionId.slice(0, 8)}...: $${Math.max(0, remainingUsd).toFixed(4)} remaining — blocking (user explicitly chose ${modelId})`);
2924
+ res.writeHead(429, {
2925
+ "Content-Type": "application/json",
2926
+ "X-ClawRouter-Cost-Cap-Exceeded": "1",
2927
+ "X-ClawRouter-Budget-Mode": "blocked",
2928
+ });
2929
+ res.end(JSON.stringify({
2930
+ error: {
2931
+ message: `ClawRouter budget exhausted: $${Math.max(0, remainingUsd).toFixed(4)} remaining (limit: $${options.maxCostPerRunUsd}). Increase maxCostPerRun to continue using ${modelId}.`,
2932
+ type: "cost_cap_exceeded",
2933
+ code: "budget_exhausted",
2934
+ },
2935
+ }));
2936
+ deduplicator.removeInflight(dedupKey);
2937
+ return;
2938
+ }
2939
+ }
2940
+ }
2941
+ // --- Streaming: early header flush + heartbeat ---
2942
+ let heartbeatInterval;
2943
+ let headersSentEarly = false;
2944
+ if (isStreaming) {
2945
+ // Send 200 + SSE headers immediately, before x402 flow
2946
+ res.writeHead(200, {
2947
+ "content-type": "text/event-stream",
2948
+ "cache-control": "no-cache",
2949
+ connection: "keep-alive",
2950
+ "x-context-used-kb": String(originalContextSizeKB),
2951
+ "x-context-limit-kb": String(CONTEXT_LIMIT_KB),
2952
+ });
2953
+ headersSentEarly = true;
2954
+ // First heartbeat immediately
2955
+ safeWrite(res, ": heartbeat\n\n");
2956
+ // Continue heartbeats every 2s while waiting for upstream
2957
+ heartbeatInterval = setInterval(() => {
2958
+ if (canWrite(res)) {
2959
+ safeWrite(res, ": heartbeat\n\n");
2960
+ }
2961
+ else {
2962
+ // Socket closed, stop heartbeat
2963
+ clearInterval(heartbeatInterval);
2964
+ heartbeatInterval = undefined;
2965
+ }
2966
+ }, HEARTBEAT_INTERVAL_MS);
2967
+ }
2968
+ // Forward headers, stripping host, connection, and content-length
2969
+ const headers = {};
2970
+ for (const [key, value] of Object.entries(req.headers)) {
2971
+ if (key === "host" ||
2972
+ key === "connection" ||
2973
+ key === "transfer-encoding" ||
2974
+ key === "content-length")
2975
+ continue;
2976
+ if (typeof value === "string") {
2977
+ headers[key] = value;
2978
+ }
2979
+ }
2980
+ if (!headers["content-type"]) {
2981
+ headers["content-type"] = "application/json";
2982
+ }
2983
+ headers["user-agent"] = USER_AGENT;
2984
+ // --- Client disconnect cleanup ---
2985
+ let completed = false;
2986
+ res.on("close", () => {
2987
+ if (heartbeatInterval) {
2988
+ clearInterval(heartbeatInterval);
2989
+ heartbeatInterval = undefined;
2990
+ }
2991
+ // Remove from in-flight if client disconnected before completion
2992
+ if (!completed) {
2993
+ deduplicator.removeInflight(dedupKey);
2994
+ }
2995
+ });
2996
+ // --- Request timeout ---
2997
+ // Global controller: hard deadline for the entire request (all model attempts combined).
2998
+ // Each model attempt gets its own per-model controller (PER_MODEL_TIMEOUT_MS).
2999
+ // If a model times out individually, we fall back to the next model instead of failing.
3000
+ // Only the global timeout causes an immediate error.
3001
+ const timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
3002
+ const globalController = new AbortController();
3003
+ const timeoutId = setTimeout(() => globalController.abort(), timeoutMs);
3004
+ try {
3005
+ // --- Build fallback chain ---
3006
+ // If we have a routing decision, get the full fallback chain for the tier
3007
+ // Otherwise, just use the current model (no fallback for explicit model requests)
3008
+ let modelsToTry;
3009
+ const excludeList = options.excludeModels ?? loadExcludeList();
3010
+ if (routingDecision) {
3011
+ // Estimate total context: input tokens (~4 chars per token) + max output tokens
3012
+ const estimatedInputTokens = Math.ceil(body.length / 4);
3013
+ const estimatedTotalTokens = estimatedInputTokens + maxTokens;
3014
+ // Use tier configs from the routing decision (set by RouterStrategy)
3015
+ const tierConfigs = routingDecision.tierConfigs ?? routerOpts.config.tiers;
3016
+ // Get full chain first, then filter by context
3017
+ const fullChain = getFallbackChain(routingDecision.tier, tierConfigs);
3018
+ const contextFiltered = getFallbackChainFiltered(routingDecision.tier, tierConfigs, estimatedTotalTokens, getModelContextWindow);
3019
+ // Log if models were filtered out due to context limits
3020
+ const contextExcluded = fullChain.filter((m) => !contextFiltered.includes(m));
3021
+ if (contextExcluded.length > 0) {
3022
+ console.log(`[ClawRouter] Context filter (~${estimatedTotalTokens} tokens): excluded ${contextExcluded.join(", ")}`);
3023
+ }
3024
+ // Filter out user-excluded models
3025
+ const excludeFiltered = filterByExcludeList(contextFiltered, excludeList);
3026
+ const excludeExcluded = contextFiltered.filter((m) => !excludeFiltered.includes(m));
3027
+ if (excludeExcluded.length > 0) {
3028
+ console.log(`[ClawRouter] Exclude filter: excluded ${excludeExcluded.join(", ")} (user preference)`);
3029
+ }
3030
+ // Filter to models that support tool calling when request has tools.
3031
+ // Prevents models like grok-code-fast-1 from outputting tool invocations
3032
+ // as plain text JSON (the "talking to itself" bug).
3033
+ let toolFiltered = filterByToolCalling(excludeFiltered, hasTools, supportsToolCalling);
3034
+ const toolExcluded = excludeFiltered.filter((m) => !toolFiltered.includes(m));
3035
+ if (toolExcluded.length > 0) {
3036
+ console.log(`[ClawRouter] Tool-calling filter: excluded ${toolExcluded.join(", ")} (no structured function call support)`);
3037
+ }
3038
+ // Filter out models that declare toolCalling but fail tool compliance in practice.
3039
+ // gemini-2.5-flash-lite refuses certain tool schemas (e.g. brave search) while
3040
+ // cheaper models like nvidia/gpt-oss-120b handle them fine.
3041
+ const TOOL_NONCOMPLIANT_MODELS = [
3042
+ "google/gemini-2.5-flash-lite",
3043
+ "google/gemini-3-pro-preview",
3044
+ "google/gemini-3.1-pro",
3045
+ ];
3046
+ if (hasTools && toolFiltered.length > 1) {
3047
+ const compliant = toolFiltered.filter((m) => !TOOL_NONCOMPLIANT_MODELS.includes(m));
3048
+ if (compliant.length > 0 && compliant.length < toolFiltered.length) {
3049
+ const dropped = toolFiltered.filter((m) => TOOL_NONCOMPLIANT_MODELS.includes(m));
3050
+ console.log(`[ClawRouter] Tool-compliance filter: excluded ${dropped.join(", ")} (unreliable tool schema handling)`);
3051
+ toolFiltered = compliant;
3052
+ }
3053
+ }
3054
+ // Filter to models that support vision when request has image_url content
3055
+ const visionFiltered = filterByVision(toolFiltered, hasVision, supportsVision);
3056
+ const visionExcluded = toolFiltered.filter((m) => !visionFiltered.includes(m));
3057
+ if (visionExcluded.length > 0) {
3058
+ console.log(`[ClawRouter] Vision filter: excluded ${visionExcluded.join(", ")} (no vision support)`);
3059
+ }
3060
+ // Limit to MAX_FALLBACK_ATTEMPTS to prevent infinite loops
3061
+ modelsToTry = visionFiltered.slice(0, MAX_FALLBACK_ATTEMPTS);
3062
+ // Deprioritize rate-limited models (put them at the end)
3063
+ modelsToTry = prioritizeNonRateLimited(modelsToTry);
3064
+ }
3065
+ else {
3066
+ // For explicit model requests, use the requested model
3067
+ modelsToTry = modelId ? [modelId] : [];
3068
+ }
3069
+ // Ensure free model is the last-resort fallback for non-tool requests.
3070
+ // Skip free fallback when tools are present — nvidia/gpt-oss-120b lacks
3071
+ // tool calling support and would produce broken responses for agentic tasks.
3072
+ if (!hasTools && !modelsToTry.includes(FREE_MODEL) && !excludeList.has(FREE_MODEL)) {
3073
+ modelsToTry.push(FREE_MODEL); // last-resort free fallback
3074
+ }
3075
+ // --- Budget-aware routing (graceful mode) ---
3076
+ // Filter modelsToTry to only models that fit within the remaining session budget.
3077
+ // Simple tasks (no tools, non-complex tier): downgrade with visible warning.
3078
+ // Complex/agentic tasks with no affordable model: already blocked above (before streaming headers).
3079
+ if (options.maxCostPerRunUsd &&
3080
+ effectiveSessionId &&
3081
+ !isFreeModel &&
3082
+ (options.maxCostPerRunMode ?? "graceful") === "graceful") {
3083
+ const runCostUsd = sessionStore.getSessionCostUsd(effectiveSessionId);
3084
+ const remainingUsd = options.maxCostPerRunUsd - runCostUsd;
3085
+ const beforeFilter = [...modelsToTry];
3086
+ modelsToTry = modelsToTry.filter((m) => {
3087
+ if (FREE_MODELS.has(m))
3088
+ return true; // free models always fit (no cost)
3089
+ const est = estimateAmount(m, body.length, maxTokens);
3090
+ if (!est)
3091
+ return true; // no pricing data → keep (permissive)
3092
+ return Number(est) / 1_000_000 <= remainingUsd;
3093
+ });
3094
+ const excluded = beforeFilter.filter((m) => !modelsToTry.includes(m));
3095
+ // Second-pass block: the pre-check caught obvious cases early (before streaming headers).
3096
+ // Here we recheck against the actual filtered modelsToTry chain. If the ONLY remaining
3097
+ // models are free, we must block rather than silently degrade for:
3098
+ // (A) complex/agentic routing profile tasks (tools / COMPLEX / REASONING tier)
3099
+ // (B) explicit model requests (user chose a specific model; free substitution is deceptive)
3100
+ // The pre-check already handles case (B) for non-streaming; this is a safety net for
3101
+ // streaming requests where SSE headers may have already been sent.
3102
+ const isComplexOrAgenticFilter = hasTools ||
3103
+ routingDecision?.tier === "COMPLEX" ||
3104
+ routingDecision?.tier === "REASONING" ||
3105
+ routingDecision === undefined; // explicit model: no routing profile → user chose the model
3106
+ const filteredToFreeOnly = modelsToTry.length > 0 && modelsToTry.every((m) => FREE_MODELS.has(m));
3107
+ if (isComplexOrAgenticFilter && filteredToFreeOnly) {
3108
+ const budgetSummary = `$${Math.max(0, remainingUsd).toFixed(4)} remaining (limit: $${options.maxCostPerRunUsd})`;
3109
+ console.log(`[ClawRouter] Budget filter left only free model for complex/agentic session — blocking (${budgetSummary})`);
3110
+ const errPayload = JSON.stringify({
3111
+ error: {
3112
+ message: `ClawRouter budget exhausted: remaining budget (${budgetSummary}) cannot support a complex/tool request. Increase maxCostPerRun to continue.`,
3113
+ type: "cost_cap_exceeded",
3114
+ code: "budget_exhausted",
3115
+ },
3116
+ });
3117
+ if (heartbeatInterval)
3118
+ clearInterval(heartbeatInterval);
3119
+ if (headersSentEarly) {
3120
+ // Streaming: inject error SSE event + [DONE] and close
3121
+ safeWrite(res, `data: ${errPayload}\n\ndata: [DONE]\n\n`);
3122
+ res.end();
3123
+ }
3124
+ else {
3125
+ res.writeHead(429, {
3126
+ "Content-Type": "application/json",
3127
+ "X-ClawRouter-Cost-Cap-Exceeded": "1",
3128
+ "X-ClawRouter-Budget-Mode": "blocked",
3129
+ });
3130
+ res.end(errPayload);
3131
+ }
3132
+ deduplicator.removeInflight(dedupKey);
3133
+ return;
3134
+ }
3135
+ if (excluded.length > 0) {
3136
+ const budgetSummary = remainingUsd > 0
3137
+ ? `$${remainingUsd.toFixed(4)} remaining`
3138
+ : `budget exhausted ($${runCostUsd.toFixed(4)}/$${options.maxCostPerRunUsd})`;
3139
+ console.log(`[ClawRouter] Budget downgrade (${budgetSummary}): excluded ${excluded.join(", ")}`);
3140
+ // A: Set visible warning notice — prepended to response so user sees the downgrade
3141
+ const fromModel = excluded[0];
3142
+ const usingFree = modelsToTry.length === 1 && FREE_MODELS.has(modelsToTry[0]);
3143
+ if (usingFree) {
3144
+ budgetDowngradeNotice = `> **⚠️ Budget cap reached** ($${runCostUsd.toFixed(4)}/$${options.maxCostPerRunUsd}) — downgraded to free model. Quality may be reduced. Increase \`maxCostPerRun\` to continue with ${fromModel}.\n\n`;
3145
+ }
3146
+ else {
3147
+ const toModel = modelsToTry[0] ?? FREE_MODEL; // last resort
3148
+ budgetDowngradeNotice = `> **⚠️ Budget low** ($${remainingUsd > 0 ? remainingUsd.toFixed(4) : "0.0000"} remaining) — using ${toModel} instead of ${fromModel}.\n\n`;
3149
+ }
3150
+ // B: Header flag for orchestration layers (e.g. OpenClaw can pause/warn the user)
3151
+ budgetDowngradeHeaderMode = "downgraded";
3152
+ }
3153
+ }
3154
+ // --- Fallback loop: try each model until success ---
3155
+ let upstream;
3156
+ let lastError;
3157
+ let actualModelUsed = modelId;
3158
+ const failedAttempts = [];
3159
+ for (let i = 0; i < modelsToTry.length; i++) {
3160
+ const tryModel = modelsToTry[i];
3161
+ const isLastAttempt = i === modelsToTry.length - 1;
3162
+ // Abort immediately if global deadline has already fired
3163
+ if (globalController.signal.aborted) {
3164
+ throw new Error(`Request timed out after ${timeoutMs}ms`);
3165
+ }
3166
+ console.log(`[ClawRouter] Trying model ${i + 1}/${modelsToTry.length}: ${tryModel}`);
3167
+ // Per-model abort controller — each model attempt gets its own 60s window.
3168
+ // When it fires, the fallback loop moves to the next model rather than failing.
3169
+ const modelController = new AbortController();
3170
+ const modelTimeoutId = setTimeout(() => modelController.abort(), PER_MODEL_TIMEOUT_MS);
3171
+ const combinedSignal = AbortSignal.any([globalController.signal, modelController.signal]);
3172
+ const result = await tryModelRequest(upstreamUrl, req.method ?? "POST", headers, body, tryModel, maxTokens, payFetch, balanceMonitor, combinedSignal);
3173
+ clearTimeout(modelTimeoutId);
3174
+ // If the global deadline fired during this attempt, bail out entirely
3175
+ if (globalController.signal.aborted) {
3176
+ throw new Error(`Request timed out after ${timeoutMs}ms`);
3177
+ }
3178
+ // If the per-model timeout fired (but not global), treat as fallback-worthy error
3179
+ if (!result.success && modelController.signal.aborted && !isLastAttempt) {
3180
+ console.log(`[ClawRouter] Model ${tryModel} timed out after ${PER_MODEL_TIMEOUT_MS}ms, trying fallback`);
3181
+ recordProviderError(tryModel, "server_error");
3182
+ continue;
3183
+ }
3184
+ if (result.success && result.response) {
3185
+ upstream = result.response;
3186
+ actualModelUsed = tryModel;
3187
+ console.log(`[ClawRouter] Success with model: ${tryModel}`);
3188
+ // Accumulate estimated cost to session for maxCostPerRun tracking
3189
+ if (options.maxCostPerRunUsd && effectiveSessionId && !FREE_MODELS.has(tryModel)) {
3190
+ const costEst = estimateAmount(tryModel, body.length, maxTokens);
3191
+ if (costEst) {
3192
+ sessionStore.addSessionCost(effectiveSessionId, BigInt(costEst));
3193
+ }
3194
+ }
3195
+ break;
3196
+ }
3197
+ // Request failed
3198
+ lastError = {
3199
+ body: result.errorBody || "Unknown error",
3200
+ status: result.errorStatus || 500,
3201
+ };
3202
+ failedAttempts.push({
3203
+ model: tryModel,
3204
+ reason: result.errorCategory || `HTTP ${result.errorStatus || 500}`,
3205
+ status: result.errorStatus || 500,
3206
+ });
3207
+ // Payment error (insufficient funds, simulation failure) — skip remaining
3208
+ // paid models, jump straight to free model. No point trying other paid
3209
+ // models with the same wallet state.
3210
+ // Must be checked BEFORE isProviderError gate: payment settlement failures
3211
+ // may return non-standard HTTP codes that categorizeError doesn't recognize,
3212
+ // causing isProviderError=false and breaking out of the fallback loop.
3213
+ const isPaymentErr = /payment.*verification.*failed|payment.*settlement.*failed|insufficient.*funds|transaction_simulation_failed/i.test(result.errorBody || "");
3214
+ if (isPaymentErr && !FREE_MODELS.has(tryModel) && !isLastAttempt) {
3215
+ failedAttempts.push({
3216
+ ...failedAttempts[failedAttempts.length - 1],
3217
+ reason: "payment_error",
3218
+ });
3219
+ const freeIdx = modelsToTry.indexOf(FREE_MODEL);
3220
+ if (freeIdx > i + 1) {
3221
+ console.log(`[ClawRouter] Payment error — skipping to free model: ${FREE_MODEL}`);
3222
+ i = freeIdx - 1; // loop will increment to freeIdx
3223
+ continue;
3224
+ }
3225
+ // Free model not in chain — add it and try
3226
+ if (freeIdx === -1) {
3227
+ modelsToTry.push(FREE_MODEL);
3228
+ console.log(`[ClawRouter] Payment error — appending free model: ${FREE_MODEL}`);
3229
+ continue;
3230
+ }
3231
+ }
3232
+ // If it's a provider error and not the last attempt, try next model
3233
+ if (result.isProviderError && !isLastAttempt) {
3234
+ const isExplicitModelError = !routingDecision;
3235
+ const isUnknownExplicitModel = isExplicitModelError && /unknown.*model|invalid.*model/i.test(result.errorBody || "");
3236
+ if (isUnknownExplicitModel) {
3237
+ console.log(`[ClawRouter] Explicit model error from ${tryModel}, not falling back: ${result.errorBody?.slice(0, 100)}`);
3238
+ break;
3239
+ }
3240
+ // Record error and apply category-specific handling
3241
+ const errorCat = result.errorCategory;
3242
+ if (errorCat) {
3243
+ recordProviderError(tryModel, errorCat);
3244
+ }
3245
+ if (errorCat === "rate_limited") {
3246
+ // --- Stepped backoff retry (429) ---
3247
+ // Token-bucket rate limits often recover within milliseconds.
3248
+ // Retry once after 200ms before treating this as a model-level failure.
3249
+ if (!isLastAttempt && !globalController.signal.aborted) {
3250
+ console.log(`[ClawRouter] Rate-limited on ${tryModel}, retrying in 200ms before failover`);
3251
+ await new Promise((resolve) => setTimeout(resolve, 200));
3252
+ if (!globalController.signal.aborted) {
3253
+ const retryController = new AbortController();
3254
+ const retryTimeoutId = setTimeout(() => retryController.abort(), PER_MODEL_TIMEOUT_MS);
3255
+ const retrySignal = AbortSignal.any([
3256
+ globalController.signal,
3257
+ retryController.signal,
3258
+ ]);
3259
+ const retryResult = await tryModelRequest(upstreamUrl, req.method ?? "POST", headers, body, tryModel, maxTokens, payFetch, balanceMonitor, retrySignal);
3260
+ clearTimeout(retryTimeoutId);
3261
+ if (retryResult.success && retryResult.response) {
3262
+ upstream = retryResult.response;
3263
+ actualModelUsed = tryModel;
3264
+ console.log(`[ClawRouter] Rate-limit retry succeeded for: ${tryModel}`);
3265
+ if (options.maxCostPerRunUsd && effectiveSessionId && tryModel !== FREE_MODEL) {
3266
+ const costEst = estimateAmount(tryModel, body.length, maxTokens);
3267
+ if (costEst) {
3268
+ sessionStore.addSessionCost(effectiveSessionId, BigInt(costEst));
3269
+ }
3270
+ }
3271
+ break;
3272
+ }
3273
+ // Retry also failed — fall through to markRateLimited
3274
+ }
3275
+ }
3276
+ markRateLimited(tryModel);
3277
+ // Check for server-side update hint in 429 response
3278
+ try {
3279
+ const parsed = JSON.parse(result.errorBody || "{}");
3280
+ if (parsed.update_available) {
3281
+ console.log("");
3282
+ console.log(`\x1b[33m⬆️ ClawRouter ${parsed.update_available} available (you have ${VERSION})\x1b[0m`);
3283
+ console.log(` Run: \x1b[36mcurl -fsSL ${parsed.update_url || "https://blockrun.ai/ClawRouter-update"} | bash\x1b[0m`);
3284
+ console.log("");
3285
+ }
3286
+ }
3287
+ catch {
3288
+ /* ignore parse errors */
3289
+ }
3290
+ }
3291
+ else if (errorCat === "overloaded") {
3292
+ markOverloaded(tryModel);
3293
+ }
3294
+ else if (errorCat === "auth_failure" || errorCat === "quota_exceeded") {
3295
+ console.log(`[ClawRouter] 🔑 ${errorCat === "auth_failure" ? "Auth failure" : "Quota exceeded"} for ${tryModel} — check provider config`);
3296
+ }
3297
+ console.log(`[ClawRouter] Provider error from ${tryModel}, trying fallback: ${result.errorBody?.slice(0, 100)}`);
3298
+ continue;
3299
+ }
3300
+ // Not a provider error or last attempt — stop trying
3301
+ if (!result.isProviderError) {
3302
+ console.log(`[ClawRouter] Non-provider error from ${tryModel}, not retrying: ${result.errorBody?.slice(0, 100)}`);
3303
+ }
3304
+ break;
3305
+ }
3306
+ // Clear timeout — request attempts completed
3307
+ clearTimeout(timeoutId);
3308
+ // Clear heartbeat — real data is about to flow
3309
+ if (heartbeatInterval) {
3310
+ clearInterval(heartbeatInterval);
3311
+ heartbeatInterval = undefined;
3312
+ }
3313
+ // --- Emit routing debug info (opt-in via x-clawrouter-debug: true header) ---
3314
+ // For streaming: SSE comment (invisible to most clients, visible in raw stream)
3315
+ // For non-streaming: response headers added later
3316
+ if (debugMode && headersSentEarly && routingDecision) {
3317
+ const debugComment = `: x-clawrouter-debug profile=${routingProfile ?? "auto"} tier=${routingDecision.tier} model=${actualModelUsed} agentic=${routingDecision.agenticScore?.toFixed(2) ?? "n/a"} confidence=${routingDecision.confidence.toFixed(2)} reasoning=${routingDecision.reasoning}\n\n`;
3318
+ safeWrite(res, debugComment);
3319
+ }
3320
+ // Update routing decision with actual model used (for logging)
3321
+ // IMPORTANT: Recalculate cost for the actual model, not the original primary
3322
+ if (routingDecision && actualModelUsed !== routingDecision.model) {
3323
+ const estimatedInputTokens = Math.ceil(body.length / 4);
3324
+ const newCosts = calculateModelCost(actualModelUsed, routerOpts.modelPricing, estimatedInputTokens, maxTokens, routingProfile ?? undefined);
3325
+ routingDecision = {
3326
+ ...routingDecision,
3327
+ model: actualModelUsed,
3328
+ reasoning: `${routingDecision.reasoning} | fallback to ${actualModelUsed}`,
3329
+ costEstimate: newCosts.costEstimate,
3330
+ baselineCost: newCosts.baselineCost,
3331
+ savings: newCosts.savings,
3332
+ };
3333
+ options.onRouted?.(routingDecision);
3334
+ // Update session pin to the actual model used — ensures the next request in
3335
+ // this conversation starts from the fallback model rather than retrying the
3336
+ // primary and falling back again (prevents the "model keeps jumping" issue).
3337
+ if (effectiveSessionId) {
3338
+ sessionStore.setSession(effectiveSessionId, actualModelUsed, routingDecision.tier);
3339
+ console.log(`[ClawRouter] Session ${effectiveSessionId.slice(0, 8)}... updated pin to fallback: ${actualModelUsed}`);
3340
+ }
3341
+ }
3342
+ // --- Handle case where all models failed ---
3343
+ if (!upstream) {
3344
+ // Build structured error summary listing all attempted models
3345
+ const attemptSummary = failedAttempts.length > 0
3346
+ ? failedAttempts.map((a) => `${a.model} (${a.reason})`).join(", ")
3347
+ : "unknown";
3348
+ const structuredMessage = failedAttempts.length > 0
3349
+ ? `All ${failedAttempts.length} models failed. Tried: ${attemptSummary}`
3350
+ : "All models in fallback chain failed";
3351
+ console.log(`[ClawRouter] ${structuredMessage}`);
3352
+ const rawErrBody = lastError?.body || structuredMessage;
3353
+ const errStatus = lastError?.status || 502;
3354
+ // Transform payment errors into user-friendly messages
3355
+ const transformedErr = transformPaymentError(rawErrBody);
3356
+ // Mark as error for usage logging (logs this failed attempt with cost)
3357
+ requestHadError = true;
3358
+ if (headersSentEarly) {
3359
+ // Streaming: send error as SSE event in OpenAI error format.
3360
+ // The OpenAI SDK (used by continue.dev and others) expects {"error": {...}}.
3361
+ // If the upstream error is already in that shape, use it; otherwise wrap it.
3362
+ // Sending raw upstream JSON (e.g. {"code":400,"message":"..."}) causes the SDK
3363
+ // to throw a generic "Unexpected error" instead of a meaningful message.
3364
+ let errPayload;
3365
+ try {
3366
+ const parsed = JSON.parse(transformedErr);
3367
+ if (parsed && typeof parsed === "object" && "error" in parsed) {
3368
+ // Already has the correct {"error": {...}} wrapper — use as-is
3369
+ errPayload = JSON.stringify(parsed);
3370
+ }
3371
+ else {
3372
+ // Raw upstream JSON without error wrapper — wrap it
3373
+ errPayload = JSON.stringify({
3374
+ error: { message: rawErrBody, type: "provider_error", status: errStatus },
3375
+ });
3376
+ }
3377
+ }
3378
+ catch {
3379
+ errPayload = JSON.stringify({
3380
+ error: { message: rawErrBody, type: "provider_error", status: errStatus },
3381
+ });
3382
+ }
3383
+ const errEvent = `data: ${errPayload}\n\n`;
3384
+ safeWrite(res, errEvent);
3385
+ safeWrite(res, "data: [DONE]\n\n");
3386
+ res.end();
3387
+ const errBuf = Buffer.from(errEvent + "data: [DONE]\n\n");
3388
+ deduplicator.complete(dedupKey, {
3389
+ status: 200,
3390
+ headers: { "content-type": "text/event-stream" },
3391
+ body: errBuf,
3392
+ completedAt: Date.now(),
3393
+ });
3394
+ }
3395
+ else {
3396
+ // Non-streaming: send transformed error response with context headers
3397
+ res.writeHead(errStatus, {
3398
+ "Content-Type": "application/json",
3399
+ "x-context-used-kb": String(originalContextSizeKB),
3400
+ "x-context-limit-kb": String(CONTEXT_LIMIT_KB),
3401
+ });
3402
+ res.end(transformedErr);
3403
+ deduplicator.complete(dedupKey, {
3404
+ status: errStatus,
3405
+ headers: { "content-type": "application/json" },
3406
+ body: Buffer.from(transformedErr),
3407
+ completedAt: Date.now(),
3408
+ });
3409
+ }
3410
+ // Log failed request so users can see it in `clawrouter logs`
3411
+ // cost = actual x402 payment if any was made, otherwise 0
3412
+ const errModel = routingDecision?.model ?? modelId;
3413
+ if (errModel) {
3414
+ const errPayment = paymentStore.getStore()?.amountUsd ?? 0;
3415
+ logUsage({
3416
+ timestamp: new Date().toISOString(),
3417
+ model: errModel,
3418
+ tier: routingDecision?.tier ?? "DIRECT",
3419
+ cost: errPayment,
3420
+ baselineCost: errPayment,
3421
+ savings: 0,
3422
+ latencyMs: Date.now() - startTime,
3423
+ status: "error",
3424
+ }).catch(() => { });
3425
+ }
3426
+ return;
3427
+ }
3428
+ // --- Stream response and collect for dedup cache ---
3429
+ const responseChunks = [];
3430
+ if (headersSentEarly) {
3431
+ // Streaming: headers already sent. Response should be 200 at this point
3432
+ // (non-200 responses are handled in the fallback loop above)
3433
+ // Convert non-streaming JSON response to SSE streaming format for client
3434
+ // (BlockRun API returns JSON since we forced stream:false)
3435
+ // OpenClaw expects: object="chat.completion.chunk" with choices[].delta (not message)
3436
+ // We emit proper incremental deltas to match OpenAI's streaming format exactly
3437
+ if (upstream.body) {
3438
+ const chunks = await readBodyWithTimeout(upstream.body);
3439
+ // Combine chunks and transform to streaming format
3440
+ const jsonBody = Buffer.concat(chunks);
3441
+ const jsonStr = jsonBody.toString();
3442
+ try {
3443
+ const rsp = JSON.parse(jsonStr);
3444
+ // Extract input token count from upstream response
3445
+ if (rsp.usage && typeof rsp.usage === "object") {
3446
+ const u = rsp.usage;
3447
+ if (typeof u.prompt_tokens === "number")
3448
+ responseInputTokens = u.prompt_tokens;
3449
+ if (typeof u.completion_tokens === "number")
3450
+ responseOutputTokens = u.completion_tokens;
3451
+ }
3452
+ // Build base chunk structure (reused for all chunks)
3453
+ // Match OpenAI's exact format including system_fingerprint
3454
+ const baseChunk = {
3455
+ id: rsp.id ?? `chatcmpl-${Date.now()}`,
3456
+ object: "chat.completion.chunk",
3457
+ created: rsp.created ?? Math.floor(Date.now() / 1000),
3458
+ model: actualModelUsed || rsp.model || "unknown",
3459
+ system_fingerprint: null,
3460
+ };
3461
+ // Process each choice (usually just one)
3462
+ if (rsp.choices && Array.isArray(rsp.choices)) {
3463
+ for (const choice of rsp.choices) {
3464
+ // Strip thinking tokens (Kimi <|...|> and standard <think> tags)
3465
+ const rawContent = choice.message?.content ?? choice.delta?.content ?? "";
3466
+ const content = stripThinkingTokens(rawContent);
3467
+ const role = choice.message?.role ?? choice.delta?.role ?? "assistant";
3468
+ const index = choice.index ?? 0;
3469
+ // Accumulate content for session journal
3470
+ if (content) {
3471
+ accumulatedContent += content;
3472
+ }
3473
+ // Chunk 1: role only (mimics OpenAI's first chunk)
3474
+ const roleChunk = {
3475
+ ...baseChunk,
3476
+ choices: [{ index, delta: { role }, logprobs: null, finish_reason: null }],
3477
+ };
3478
+ const roleData = `data: ${JSON.stringify(roleChunk)}\n\n`;
3479
+ safeWrite(res, roleData);
3480
+ responseChunks.push(Buffer.from(roleData));
3481
+ // Chunk 1.5: balance fallback notice (tells user they got free model)
3482
+ if (balanceFallbackNotice) {
3483
+ const noticeChunk = {
3484
+ ...baseChunk,
3485
+ choices: [
3486
+ {
3487
+ index,
3488
+ delta: { content: balanceFallbackNotice },
3489
+ logprobs: null,
3490
+ finish_reason: null,
3491
+ },
3492
+ ],
3493
+ };
3494
+ const noticeData = `data: ${JSON.stringify(noticeChunk)}\n\n`;
3495
+ safeWrite(res, noticeData);
3496
+ responseChunks.push(Buffer.from(noticeData));
3497
+ balanceFallbackNotice = undefined; // Only inject once
3498
+ }
3499
+ // Chunk 1.6: budget downgrade notice (A: visible warning when model downgraded)
3500
+ if (budgetDowngradeNotice) {
3501
+ const noticeChunk = {
3502
+ ...baseChunk,
3503
+ choices: [
3504
+ {
3505
+ index,
3506
+ delta: { content: budgetDowngradeNotice },
3507
+ logprobs: null,
3508
+ finish_reason: null,
3509
+ },
3510
+ ],
3511
+ };
3512
+ const noticeData = `data: ${JSON.stringify(noticeChunk)}\n\n`;
3513
+ safeWrite(res, noticeData);
3514
+ responseChunks.push(Buffer.from(noticeData));
3515
+ budgetDowngradeNotice = undefined; // Only inject once
3516
+ }
3517
+ // Chunk 2: content (single chunk with full content)
3518
+ if (content) {
3519
+ const contentChunk = {
3520
+ ...baseChunk,
3521
+ choices: [{ index, delta: { content }, logprobs: null, finish_reason: null }],
3522
+ };
3523
+ const contentData = `data: ${JSON.stringify(contentChunk)}\n\n`;
3524
+ safeWrite(res, contentData);
3525
+ responseChunks.push(Buffer.from(contentData));
3526
+ }
3527
+ // Chunk 2b: tool_calls (forward tool calls from upstream)
3528
+ const toolCalls = choice.message?.tool_calls ?? choice.delta?.tool_calls;
3529
+ if (toolCalls && toolCalls.length > 0) {
3530
+ const toolCallChunk = {
3531
+ ...baseChunk,
3532
+ choices: [
3533
+ {
3534
+ index,
3535
+ delta: { tool_calls: toolCalls },
3536
+ logprobs: null,
3537
+ finish_reason: null,
3538
+ },
3539
+ ],
3540
+ };
3541
+ const toolCallData = `data: ${JSON.stringify(toolCallChunk)}\n\n`;
3542
+ safeWrite(res, toolCallData);
3543
+ responseChunks.push(Buffer.from(toolCallData));
3544
+ }
3545
+ // Chunk 3: finish_reason (signals completion)
3546
+ const finishChunk = {
3547
+ ...baseChunk,
3548
+ choices: [
3549
+ {
3550
+ index,
3551
+ delta: {},
3552
+ logprobs: null,
3553
+ finish_reason: toolCalls && toolCalls.length > 0
3554
+ ? "tool_calls"
3555
+ : (choice.finish_reason ?? "stop"),
3556
+ },
3557
+ ],
3558
+ };
3559
+ const finishData = `data: ${JSON.stringify(finishChunk)}\n\n`;
3560
+ safeWrite(res, finishData);
3561
+ responseChunks.push(Buffer.from(finishData));
3562
+ }
3563
+ }
3564
+ }
3565
+ catch {
3566
+ // If parsing fails, send raw response as single chunk
3567
+ const sseData = `data: ${jsonStr}\n\n`;
3568
+ safeWrite(res, sseData);
3569
+ responseChunks.push(Buffer.from(sseData));
3570
+ }
3571
+ }
3572
+ // Send cost summary as SSE comment before terminator
3573
+ if (routingDecision) {
3574
+ const costComment = `: cost=$${routingDecision.costEstimate.toFixed(4)} savings=${(routingDecision.savings * 100).toFixed(0)}% model=${actualModelUsed} tier=${routingDecision.tier}\n\n`;
3575
+ safeWrite(res, costComment);
3576
+ responseChunks.push(Buffer.from(costComment));
3577
+ }
3578
+ // Send SSE terminator
3579
+ safeWrite(res, "data: [DONE]\n\n");
3580
+ responseChunks.push(Buffer.from("data: [DONE]\n\n"));
3581
+ res.end();
3582
+ // Cache for dedup
3583
+ deduplicator.complete(dedupKey, {
3584
+ status: 200,
3585
+ headers: { "content-type": "text/event-stream" },
3586
+ body: Buffer.concat(responseChunks),
3587
+ completedAt: Date.now(),
3588
+ });
3589
+ }
3590
+ else {
3591
+ // Non-streaming: forward status and headers from upstream
3592
+ const responseHeaders = {};
3593
+ upstream.headers.forEach((value, key) => {
3594
+ // Skip hop-by-hop headers and content-encoding (fetch already decompresses)
3595
+ if (key === "transfer-encoding" || key === "connection" || key === "content-encoding")
3596
+ return;
3597
+ responseHeaders[key] = value;
3598
+ });
3599
+ // Add context usage headers
3600
+ responseHeaders["x-context-used-kb"] = String(originalContextSizeKB);
3601
+ responseHeaders["x-context-limit-kb"] = String(CONTEXT_LIMIT_KB);
3602
+ // Add routing debug headers (opt-in via x-clawrouter-debug: true header)
3603
+ if (debugMode && routingDecision) {
3604
+ responseHeaders["x-clawrouter-profile"] = routingProfile ?? "auto";
3605
+ responseHeaders["x-clawrouter-tier"] = routingDecision.tier;
3606
+ responseHeaders["x-clawrouter-model"] = actualModelUsed;
3607
+ responseHeaders["x-clawrouter-confidence"] = routingDecision.confidence.toFixed(2);
3608
+ responseHeaders["x-clawrouter-reasoning"] = routingDecision.reasoning;
3609
+ if (routingDecision.agenticScore !== undefined) {
3610
+ responseHeaders["x-clawrouter-agentic-score"] = routingDecision.agenticScore.toFixed(2);
3611
+ }
3612
+ }
3613
+ // Always include cost visibility headers when routing is active
3614
+ if (routingDecision) {
3615
+ responseHeaders["x-clawrouter-cost"] = routingDecision.costEstimate.toFixed(6);
3616
+ responseHeaders["x-clawrouter-savings"] = `${(routingDecision.savings * 100).toFixed(0)}%`;
3617
+ }
3618
+ // Collect full body for possible notice injection
3619
+ const bodyParts = [];
3620
+ if (upstream.body) {
3621
+ const chunks = await readBodyWithTimeout(upstream.body);
3622
+ for (const chunk of chunks) {
3623
+ bodyParts.push(Buffer.from(chunk));
3624
+ }
3625
+ }
3626
+ let responseBody = Buffer.concat(bodyParts);
3627
+ // Strip thinking tokens from non-streaming responses (same as streaming path)
3628
+ if (responseBody.length > 0) {
3629
+ try {
3630
+ const parsed = JSON.parse(responseBody.toString());
3631
+ if (parsed.choices?.[0]?.message?.content) {
3632
+ const stripped = stripThinkingTokens(parsed.choices[0].message.content);
3633
+ if (stripped !== parsed.choices[0].message.content) {
3634
+ parsed.choices[0].message.content = stripped;
3635
+ responseBody = Buffer.from(JSON.stringify(parsed));
3636
+ }
3637
+ }
3638
+ }
3639
+ catch {
3640
+ /* not JSON, skip */
3641
+ }
3642
+ }
3643
+ // Prepend balance fallback notice to response content
3644
+ if (balanceFallbackNotice && responseBody.length > 0) {
3645
+ try {
3646
+ const parsed = JSON.parse(responseBody.toString());
3647
+ if (parsed.choices?.[0]?.message?.content !== undefined) {
3648
+ parsed.choices[0].message.content =
3649
+ balanceFallbackNotice + parsed.choices[0].message.content;
3650
+ responseBody = Buffer.from(JSON.stringify(parsed));
3651
+ }
3652
+ }
3653
+ catch {
3654
+ /* not JSON, skip notice */
3655
+ }
3656
+ balanceFallbackNotice = undefined;
3657
+ }
3658
+ // A: Prepend budget downgrade notice to response content
3659
+ if (budgetDowngradeNotice && responseBody.length > 0) {
3660
+ try {
3661
+ const parsed = JSON.parse(responseBody.toString());
3662
+ if (parsed.choices?.[0]?.message?.content !== undefined) {
3663
+ parsed.choices[0].message.content =
3664
+ budgetDowngradeNotice + parsed.choices[0].message.content;
3665
+ responseBody = Buffer.from(JSON.stringify(parsed));
3666
+ }
3667
+ }
3668
+ catch {
3669
+ /* not JSON, skip notice */
3670
+ }
3671
+ budgetDowngradeNotice = undefined;
3672
+ }
3673
+ // Inject actualModelUsed into non-streaming response model field
3674
+ if (actualModelUsed && responseBody.length > 0) {
3675
+ try {
3676
+ const parsed = JSON.parse(responseBody.toString());
3677
+ if (parsed.model !== undefined) {
3678
+ parsed.model = actualModelUsed;
3679
+ responseBody = Buffer.from(JSON.stringify(parsed));
3680
+ }
3681
+ }
3682
+ catch {
3683
+ /* not JSON, skip model injection */
3684
+ }
3685
+ }
3686
+ // B: Add budget downgrade headers for orchestration layers
3687
+ if (budgetDowngradeHeaderMode) {
3688
+ responseHeaders["x-clawrouter-budget-downgrade"] = "1";
3689
+ responseHeaders["x-clawrouter-budget-mode"] = budgetDowngradeHeaderMode;
3690
+ budgetDowngradeHeaderMode = undefined;
3691
+ }
3692
+ // Update content-length header since body may have changed
3693
+ responseHeaders["content-length"] = String(responseBody.length);
3694
+ res.writeHead(upstream.status, responseHeaders);
3695
+ safeWrite(res, responseBody);
3696
+ responseChunks.push(responseBody);
3697
+ res.end();
3698
+ // Cache for dedup (short-term, 30s)
3699
+ deduplicator.complete(dedupKey, {
3700
+ status: upstream.status,
3701
+ headers: responseHeaders,
3702
+ body: responseBody,
3703
+ completedAt: Date.now(),
3704
+ });
3705
+ // Cache for response cache (long-term, 10min) - only successful non-streaming
3706
+ if (upstream.status === 200 && responseCache.shouldCache(body)) {
3707
+ responseCache.set(cacheKey, {
3708
+ body: responseBody,
3709
+ status: upstream.status,
3710
+ headers: responseHeaders,
3711
+ model: actualModelUsed,
3712
+ });
3713
+ console.log(`[ClawRouter] Cached response for ${actualModelUsed} (${responseBody.length} bytes)`);
3714
+ }
3715
+ // Extract content and token usage from non-streaming response
3716
+ try {
3717
+ const rspJson = JSON.parse(responseBody.toString());
3718
+ if (rspJson.choices?.[0]?.message?.content) {
3719
+ accumulatedContent = rspJson.choices[0].message.content;
3720
+ }
3721
+ if (rspJson.usage && typeof rspJson.usage === "object") {
3722
+ if (typeof rspJson.usage.prompt_tokens === "number")
3723
+ responseInputTokens = rspJson.usage.prompt_tokens;
3724
+ if (typeof rspJson.usage.completion_tokens === "number")
3725
+ responseOutputTokens = rspJson.usage.completion_tokens;
3726
+ }
3727
+ }
3728
+ catch {
3729
+ // Ignore parse errors - journal just won't have content for this response
3730
+ }
3731
+ }
3732
+ // --- Session Journal: Extract and record events from response ---
3733
+ if (sessionId && accumulatedContent) {
3734
+ const events = sessionJournal.extractEvents(accumulatedContent);
3735
+ if (events.length > 0) {
3736
+ sessionJournal.record(sessionId, events, actualModelUsed);
3737
+ console.log(`[ClawRouter] Recorded ${events.length} events to session journal for session ${sessionId.slice(0, 8)}...`);
3738
+ }
3739
+ }
3740
+ // --- Optimistic balance deduction after successful response ---
3741
+ if (estimatedCostMicros !== undefined) {
3742
+ balanceMonitor.deductEstimated(estimatedCostMicros);
3743
+ }
3744
+ // Mark request as completed (for client disconnect cleanup)
3745
+ completed = true;
3746
+ }
3747
+ catch (err) {
3748
+ // Clear timeout on error
3749
+ clearTimeout(timeoutId);
3750
+ // Clear heartbeat on error
3751
+ if (heartbeatInterval) {
3752
+ clearInterval(heartbeatInterval);
3753
+ heartbeatInterval = undefined;
3754
+ }
3755
+ // Remove in-flight entry so retries aren't blocked
3756
+ deduplicator.removeInflight(dedupKey);
3757
+ // Invalidate balance cache on payment failure (might be out of date)
3758
+ balanceMonitor.invalidate();
3759
+ // Convert abort error to more descriptive timeout error
3760
+ if (err instanceof Error && err.name === "AbortError") {
3761
+ throw new Error(`Request timed out after ${timeoutMs}ms`, { cause: err });
3762
+ }
3763
+ throw err;
3764
+ }
3765
+ // --- Usage logging (fire-and-forget) ---
3766
+ // Use actual x402 payment amount from the per-request AsyncLocalStorage store.
3767
+ // This is the real amount the user paid — no estimation needed.
3768
+ // Falls back to local estimate only for free models (no x402 payment).
3769
+ const logModel = routingDecision?.model ?? modelId;
3770
+ if (logModel) {
3771
+ const actualPayment = paymentStore.getStore()?.amountUsd ?? 0;
3772
+ // For free models (no x402 payment), use local cost calculation as fallback
3773
+ let logCost;
3774
+ let logBaseline;
3775
+ let logSavings;
3776
+ if (actualPayment > 0) {
3777
+ logCost = actualPayment;
3778
+ // Calculate baseline for savings comparison
3779
+ const chargedInputTokens = Math.ceil(body.length / 4);
3780
+ const modelDef = BLOCKRUN_MODELS.find((m) => m.id === logModel);
3781
+ const chargedOutputTokens = modelDef ? Math.min(maxTokens, modelDef.maxOutput) : maxTokens;
3782
+ const baseline = calculateModelCost(logModel, routerOpts.modelPricing, chargedInputTokens, chargedOutputTokens, routingProfile ?? undefined);
3783
+ logBaseline = baseline.baselineCost;
3784
+ logSavings = logBaseline > 0 ? Math.max(0, (logBaseline - logCost) / logBaseline) : 0;
3785
+ }
3786
+ else {
3787
+ const chargedInputTokens = Math.ceil(body.length / 4);
3788
+ const costs = calculateModelCost(logModel, routerOpts.modelPricing, chargedInputTokens, maxTokens, routingProfile ?? undefined);
3789
+ // Free models: actual cost is $0 (no x402 payment ever made).
3790
+ // MIN_PAYMENT_USD floor in calculateModelCost would falsely inflate stats.
3791
+ logCost = FREE_MODELS.has(logModel) ? 0 : costs.costEstimate;
3792
+ logBaseline = costs.baselineCost;
3793
+ logSavings = FREE_MODELS.has(logModel) ? 1 : costs.savings;
3794
+ }
3795
+ const entry = {
3796
+ timestamp: new Date().toISOString(),
3797
+ model: logModel,
3798
+ tier: routingDecision?.tier ?? "DIRECT",
3799
+ cost: logCost,
3800
+ baselineCost: logBaseline,
3801
+ savings: logSavings,
3802
+ latencyMs: Date.now() - startTime,
3803
+ status: requestHadError ? "error" : "success",
3804
+ ...(responseInputTokens !== undefined && { inputTokens: responseInputTokens }),
3805
+ ...(responseOutputTokens !== undefined && { outputTokens: responseOutputTokens }),
3806
+ };
3807
+ logUsage(entry).catch(() => { });
3808
+ }
3809
+ }
3810
+ //# sourceMappingURL=proxy.js.map