@kognai/clawrouter-x402 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,811 @@
1
+ "use strict";
2
+ /**
3
+ * clawrouter-v2.ts — ClawRouter Universal LLM Gateway v2.0
4
+ *
5
+ * THE SINGLE DOOR every LLM call passes through (ClawRouter Spec v2.0).
6
+ * Execution Protocol Section 17: ClawRouter Mandatory Gateway Rule.
7
+ *
8
+ * Routing Matrix:
9
+ * T0 NANO → qwen3:0.6b (local) — classification, templating
10
+ * T1 LOCAL → qwen3:4b (local) — QA, supervision, aggregation
11
+ * T2 POWER → qwen3:14b (local) — primary execution engine
12
+ * T2.5 EXEC → Gemini Flash (API) — cloud-quality, no constitutional
13
+ * T3 APEX → Claude Sonnet (API) — constitutional judge only
14
+ *
15
+ * Rules:
16
+ * - constitutional_flag=true → always T3 APEX
17
+ * - Local tiers (T0/T1/T2) → Ollama direct ($0)
18
+ * - Cloud tiers (T2.5/T3) → OpenClaw gateway at :18789
19
+ * - QCG pre-compression for cloud tiers when context > 5000 tokens
20
+ * - NO agent holds API keys — keys only in ClawRouter/.env
21
+ *
22
+ * @see ~/Documents/Kognai/Master Documents/clawrouter_spec_v2.docx
23
+ * @see ~/Documents/Kognai/Master Documents/execution_protocol_sections_17_18.docx
24
+ */
25
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
26
+ if (k2 === undefined) k2 = k;
27
+ var desc = Object.getOwnPropertyDescriptor(m, k);
28
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
29
+ desc = { enumerable: true, get: function() { return m[k]; } };
30
+ }
31
+ Object.defineProperty(o, k2, desc);
32
+ }) : (function(o, m, k, k2) {
33
+ if (k2 === undefined) k2 = k;
34
+ o[k2] = m[k];
35
+ }));
36
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
37
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
38
+ }) : function(o, v) {
39
+ o["default"] = v;
40
+ });
41
+ var __importStar = (this && this.__importStar) || (function () {
42
+ var ownKeys = function(o) {
43
+ ownKeys = Object.getOwnPropertyNames || function (o) {
44
+ var ar = [];
45
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
46
+ return ar;
47
+ };
48
+ return ownKeys(o);
49
+ };
50
+ return function (mod) {
51
+ if (mod && mod.__esModule) return mod;
52
+ var result = {};
53
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
54
+ __setModuleDefault(result, mod);
55
+ return result;
56
+ };
57
+ })();
58
+ Object.defineProperty(exports, "__esModule", { value: true });
59
+ exports.getCostLog = getCostLog;
60
+ exports.getDailyCostDigest = getDailyCostDigest;
61
+ exports.getMeterStats = getMeterStats;
62
+ exports.routeCall = routeCall;
63
+ exports.callLLM = callLLM;
64
+ exports.clawRouterHealthCheck = clawRouterHealthCheck;
65
+ const http = __importStar(require("http"));
66
+ const https = __importStar(require("https"));
67
+ const fs = __importStar(require("fs"));
68
+ const path = __importStar(require("path"));
69
+ const crypto = __importStar(require("crypto"));
70
+ const wallet_state_1 = require("../../orchestrator-core/src/lib/wallet-state");
71
+ const acp_1 = require("../../orchestrator-core/src/lib/acp");
72
+ // ── Config ────────────────────────────────────────────────────────────────────
73
+ const _rawOllamaHost = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434'; // SEC1: loopback-only default
74
+ const OLLAMA_BASE = _rawOllamaHost.startsWith('http') ? _rawOllamaHost : `http://${_rawOllamaHost}`;
75
+ const CLAWROUTER_GATEWAY = process.env.CLAWROUTER_GATEWAY_URL || 'http://127.0.0.1:18789/v1'; // SEC3: loopback-only default
76
+ const OLLAMA_TIMEOUT_MS = 480_000; // 8 min for local models — qwen3:14b needs time for large file generation
77
+ const CLOUD_TIMEOUT_MS = 300_000; // 5 min for cloud API calls
78
+ const QCG_TOKEN_THRESHOLD = 5000; // QCG pre-compression trigger
79
+ // ── x402 Payment Config (USDC on Base mainnet) ──────────────────────────────
80
+ const X402_WALLET_KEY = process.env.X402_WALLET_KEY || '';
81
+ const USDC_ADDRESS = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
82
+ const BASE_CHAIN_ID = 8453;
83
+ const USDC_DOMAIN = {
84
+ name: 'USD Coin',
85
+ version: '2',
86
+ chainId: BASE_CHAIN_ID,
87
+ verifyingContract: USDC_ADDRESS,
88
+ };
89
+ // ── Tier Models ───────────────────────────────────────────────────────────────
90
+ const TIER_MODELS = {
91
+ // Text tiers
92
+ T0_NANO: 'qwen3:0.6b',
93
+ T1_LOCAL: 'qwen3:4b',
94
+ T2_POWER: 'qwen3:14b',
95
+ T2_5_THINK: 'qwen3:14b', // T2.5-LOCAL: thinking mode (TICKET-007-CLAWROUTER-THINK)
96
+ T2_5_EXEC: 'google/gemini-2.5-flash', // via OpenClaw gateway
97
+ T2_5_MIMO: 'mimo-v2-pro', // A/B test candidate (CR-AMD-001)
98
+ T3_APEX: 'anthropic/claude-sonnet-4-20250514', // via OpenClaw gateway
99
+ };
100
+ // T2.5-LOCAL: thinking mode config (TICKET-007-CLAWROUTER-THINK)
101
+ const THINKING_MODE_ENABLED = process.env.THINKING_MODE_LOCAL_ENABLED === 'true';
102
+ const THINKING_MODE_THRESHOLD = parseFloat(process.env.THINKING_MODE_THRESHOLD || '0.85');
103
+ // Markers that indicate the model is uncertain — trigger escalation to T3 APEX
104
+ const UNCERTAINTY_MARKERS = [
105
+ /\b(I am not sure|I cannot determine|I don't have enough|requires human judgment|uncertain|ambiguous)\b/i,
106
+ /\b(cannot confidently|low confidence|unclear|insufficient context)\b/i,
107
+ ];
108
+ const costLog = [];
109
+ const MAX_COST_LOG = 5000;
110
+ function getCostLog() { return costLog; }
111
+ function getDailyCostDigest() {
112
+ const today = new Date().toISOString().slice(0, 10);
113
+ const todayEntries = costLog.filter(e => e.timestamp.startsWith(today));
114
+ const by_tier = {};
115
+ const by_agent = {};
116
+ let total_usd = 0;
117
+ let tokens_saved = 0;
118
+ for (const e of todayEntries) {
119
+ total_usd += e.cost_usd;
120
+ tokens_saved += e.tokens_saved;
121
+ by_tier[e.tier] = (by_tier[e.tier] || 0) + e.cost_usd;
122
+ by_agent[e.agent_id] = (by_agent[e.agent_id] || 0) + e.cost_usd;
123
+ }
124
+ return { total_usd, by_tier, by_agent, tokens_saved_by_qcg: tokens_saved, call_count: todayEntries.length };
125
+ }
126
+ // ── Routing Matrix ────────────────────────────────────────────────────────────
127
+ function resolveTextTier(req) {
128
+ // Constitutional flag: T2.5-LOCAL (thinking mode) when enabled and not apex; T3 APEX otherwise
129
+ if (req.constitutional_flag) {
130
+ if (THINKING_MODE_ENABLED && req.complexity !== 'apex') {
131
+ return { tier: 'T2.5-LOCAL', model: TIER_MODELS.T2_5_THINK, local: true };
132
+ }
133
+ return { tier: 'T3', model: TIER_MODELS.T3_APEX, local: false };
134
+ }
135
+ switch (req.complexity) {
136
+ case 'nano':
137
+ return { tier: 'T0', model: TIER_MODELS.T0_NANO, local: true };
138
+ case 'local':
139
+ return { tier: 'T1', model: TIER_MODELS.T1_LOCAL, local: true };
140
+ case 'exec': {
141
+ // CR-AMD-001 A/B test: 50/50 MiMo-V2-Pro vs Gemini Flash when active
142
+ const abActive = process.env.MIMO_AB_TEST_ACTIVE === 'true';
143
+ const useMimo = abActive && Math.random() < 0.5;
144
+ const model = useMimo ? TIER_MODELS.T2_5_MIMO : TIER_MODELS.T2_5_EXEC;
145
+ if (abActive)
146
+ logABTest(req.agent_id || 'unknown', model);
147
+ return { tier: 'T2.5', model, local: false };
148
+ }
149
+ case 'apex':
150
+ return { tier: 'T3', model: TIER_MODELS.T3_APEX, local: false };
151
+ case 'power':
152
+ default:
153
+ // Default unspecified complexity → T2 POWER (local)
154
+ return { tier: 'T2', model: TIER_MODELS.T2_POWER, local: true };
155
+ }
156
+ }
157
+ // CR-AMD-001: A/B test logging for MiMo-V2-Pro vs Gemini Flash
158
+ const AB_LOG_PATH = require('path').join(__dirname, '../../logs/clawrouter/ab-test-mimo.jsonl');
159
+ function logABTest(agentId, model) {
160
+ try {
161
+ const entry = JSON.stringify({ timestamp: new Date().toISOString(), agent_id: agentId, model, tier: 'T2.5' });
162
+ fs.appendFileSync(AB_LOG_PATH, entry + '\n');
163
+ }
164
+ catch { /* non-blocking */ }
165
+ }
166
+ function resolveCreativeTier(req) {
167
+ // Creative routing — Phase 2A. Stubs for now, returns model_unavailable for uninstalled.
168
+ switch (req.modality) {
169
+ case 'image':
170
+ return req.quality === 'high'
171
+ ? { tier: 'C2', model: 'flux-dev', local: false }
172
+ : { tier: 'C1', model: 'flux-schnell', local: true };
173
+ case 'video':
174
+ return { tier: 'C3', model: 'wan2.1', local: true };
175
+ case 'speech':
176
+ if (req.quality === 'emotional')
177
+ return { tier: 'C4', model: 'mimo-v2-tts', local: false };
178
+ return req.quality === 'high'
179
+ ? { tier: 'C4', model: 'elevenlabs', local: false }
180
+ : { tier: 'C4', model: 'kokoro', local: true };
181
+ case 'music':
182
+ return { tier: 'C5', model: 'musicgen', local: true };
183
+ case 'transcription':
184
+ return { tier: 'C6', model: 'whisper', local: true };
185
+ case 'visual_understanding':
186
+ return { tier: 'C7', model: 'qwen2.5-vl', local: true };
187
+ default:
188
+ return { tier: 'C1', model: 'flux-schnell', local: true };
189
+ }
190
+ }
191
+ // ── HTTP Helpers ───────────────────────────────────────────────────────────────
192
+ function httpRequest(url, body, method = 'POST', extraHeaders = {}, timeoutMs = CLOUD_TIMEOUT_MS) {
193
+ return new Promise((resolve, reject) => {
194
+ const parsed = new URL(url);
195
+ const isHttps = parsed.protocol === 'https:';
196
+ const lib = isHttps ? https : http;
197
+ const req = lib.request({
198
+ hostname: parsed.hostname,
199
+ port: parsed.port || (isHttps ? 443 : 80),
200
+ path: parsed.pathname + parsed.search,
201
+ method,
202
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), ...extraHeaders },
203
+ }, (res) => {
204
+ let data = '';
205
+ res.on('data', (c) => (data += c));
206
+ res.on('end', () => resolve({ status: res.statusCode || 0, data, headers: res.headers }));
207
+ });
208
+ req.on('error', reject);
209
+ req.setTimeout(timeoutMs, () => { req.destroy(); reject(new Error(`ClawRouter timeout (${timeoutMs / 1000}s)`)); });
210
+ req.write(body);
211
+ req.end();
212
+ });
213
+ }
214
+ // ── Ollama Direct Call (Local Tiers) ──────────────────────────────────────────
215
+ async function callOllamaLocal(model, prompt, systemPrompt, maxTokens = 4096) {
216
+ const body = JSON.stringify({
217
+ model,
218
+ prompt,
219
+ system: systemPrompt,
220
+ stream: false,
221
+ think: false, // Disable qwen3 thinking mode — outputs to separate key, breaks JSON parsing
222
+ options: { num_predict: maxTokens, temperature: 0.1 },
223
+ });
224
+ const res = await httpRequest(`${OLLAMA_BASE}/api/generate`, body, 'POST', {}, OLLAMA_TIMEOUT_MS);
225
+ if (res.status !== 200)
226
+ throw new Error(`Ollama ${model} returned ${res.status}: ${res.data.slice(0, 300)}`);
227
+ const json = JSON.parse(res.data);
228
+ // Strip any residual <think>...</think> tags from qwen3 response
229
+ let content = json.response || '';
230
+ content = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
231
+ return {
232
+ content,
233
+ input_tokens: json.prompt_eval_count || 0,
234
+ output_tokens: json.eval_count || 0,
235
+ };
236
+ }
237
+ // ── Ollama Thinking Mode Call (T2.5-LOCAL, TICKET-007-CLAWROUTER-THINK) ─────────
238
+ async function callOllamaThink(model, prompt, systemPrompt, maxTokens = 4096) {
239
+ const body = JSON.stringify({
240
+ model,
241
+ prompt,
242
+ system: systemPrompt,
243
+ stream: false,
244
+ think: true, // Enable qwen3 thinking mode
245
+ options: { num_predict: maxTokens, temperature: 0.1 },
246
+ });
247
+ const res = await httpRequest(`${OLLAMA_BASE}/api/generate`, body, 'POST', {}, OLLAMA_TIMEOUT_MS);
248
+ if (res.status !== 200)
249
+ throw new Error(`Ollama think ${model} returned ${res.status}: ${res.data.slice(0, 300)}`);
250
+ const json = JSON.parse(res.data);
251
+ // Ollama returns thinking content in json.thinking (separate from json.response)
252
+ const thinking = json.thinking || '';
253
+ let content = json.response || '';
254
+ // Also strip any inline <think> tags in case model mixes formats
255
+ content = content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
256
+ return {
257
+ content, thinking,
258
+ input_tokens: json.prompt_eval_count || 0,
259
+ output_tokens: json.eval_count || 0,
260
+ };
261
+ }
262
+ // ── Anthropic Direct API Fallback (when OpenClaw gateway is unavailable) ────────
263
+ /**
264
+ * Call Anthropic API directly — fallback when OpenClaw gateway returns non-200/non-402.
265
+ * Uses ANTHROPIC_API_KEY from .env. Only called for anthropic/* models.
266
+ * Anthropic Messages API: https://docs.anthropic.com/en/api/messages
267
+ */
268
+ async function callAnthropicDirect(model, messages, maxTokens = 4096) {
269
+ const apiKey = process.env.ANTHROPIC_API_KEY || '';
270
+ if (!apiKey)
271
+ throw new Error('ANTHROPIC_API_KEY not set — cannot call Anthropic API directly');
272
+ // Anthropic Messages API: system message is top-level, not in messages array
273
+ const systemMsg = messages.find(m => m.role === 'system')?.content;
274
+ const userMsgs = messages.filter(m => m.role !== 'system');
275
+ const bodyObj = {
276
+ model,
277
+ max_tokens: maxTokens,
278
+ messages: userMsgs,
279
+ };
280
+ if (systemMsg)
281
+ bodyObj.system = systemMsg;
282
+ const body = JSON.stringify(bodyObj);
283
+ const res = await httpRequest('https://api.anthropic.com/v1/messages', body, 'POST', {
284
+ 'x-api-key': apiKey,
285
+ 'anthropic-version': '2023-06-01',
286
+ }, CLOUD_TIMEOUT_MS);
287
+ if (res.status !== 200) {
288
+ throw new Error(`Anthropic direct API returned ${res.status}: ${res.data.slice(0, 300)}`);
289
+ }
290
+ const json = JSON.parse(res.data);
291
+ return {
292
+ content: json.content?.[0]?.text || '',
293
+ input_tokens: json.usage?.input_tokens || 0,
294
+ output_tokens: json.usage?.output_tokens || 0,
295
+ cost_usd: 0, // billing handled outside (no gateway metering on direct path)
296
+ };
297
+ }
298
+ // ── x402 Payment Signing (EIP-3009 TransferWithAuthorization) ─────────────────
299
+ /**
300
+ * Sign an x402 payment for a cloud LLM call.
301
+ * Uses viem to sign EIP-3009 TransferWithAuthorization on USDC (Base mainnet).
302
+ * Returns base64-encoded X-PAYMENT header value.
303
+ */
304
+ async function signX402Payment(payTo, amountAtomic) {
305
+ if (!X402_WALLET_KEY) {
306
+ throw new Error('x402: gateway requires payment but X402_WALLET_KEY not set in .env');
307
+ }
308
+ // Dynamic imports — viem is already a project dependency
309
+ const { privateKeyToAccount } = require('viem/accounts');
310
+ const { createWalletClient, http: viemHttp } = require('viem');
311
+ const { base } = require('viem/chains');
312
+ const account = privateKeyToAccount(X402_WALLET_KEY);
313
+ const walletClient = createWalletClient({
314
+ account,
315
+ chain: base,
316
+ transport: viemHttp('https://mainnet.base.org'),
317
+ });
318
+ const now = BigInt(Math.floor(Date.now() / 1000));
319
+ const validBefore = now + BigInt(3600); // 1 hour validity
320
+ const nonce = `0x${crypto.randomBytes(32).toString('hex')}`;
321
+ const signature = await walletClient.signTypedData({
322
+ domain: USDC_DOMAIN,
323
+ types: {
324
+ TransferWithAuthorization: [
325
+ { name: 'from', type: 'address' },
326
+ { name: 'to', type: 'address' },
327
+ { name: 'value', type: 'uint256' },
328
+ { name: 'validAfter', type: 'uint256' },
329
+ { name: 'validBefore', type: 'uint256' },
330
+ { name: 'nonce', type: 'bytes32' },
331
+ ],
332
+ },
333
+ primaryType: 'TransferWithAuthorization',
334
+ message: {
335
+ from: account.address,
336
+ to: payTo,
337
+ value: BigInt(amountAtomic),
338
+ validAfter: now,
339
+ validBefore,
340
+ nonce,
341
+ },
342
+ });
343
+ const paymentPayload = {
344
+ x402Version: 1,
345
+ scheme: 'exact',
346
+ network: 'base',
347
+ payload: {
348
+ signature,
349
+ authorization: {
350
+ from: account.address,
351
+ to: payTo,
352
+ value: String(amountAtomic),
353
+ validAfter: String(now),
354
+ validBefore: String(validBefore),
355
+ nonce,
356
+ },
357
+ },
358
+ };
359
+ const xPayment = Buffer.from(JSON.stringify(paymentPayload)).toString('base64');
360
+ const costUsd = amountAtomic / 1_000_000; // atomic USDC (6 decimals) → USD
361
+ return { xPayment, costUsd };
362
+ }
363
+ // ── OpenClaw Gateway Call (Cloud Tiers — x402 payment flow) ──────────────────
364
+ /**
365
+ * Call cloud LLM via OpenClaw gateway with x402 payment signing.
366
+ *
367
+ * Flow:
368
+ * 1. POST /chat/completions → may return 200 (free) or 402 (payment required)
369
+ * 2. If 402: parse payment requirements (amount, payTo)
370
+ * 3. Sign EIP-3009 TransferWithAuthorization with CEO wallet
371
+ * 4. Retry POST with X-PAYMENT header (base64-encoded signed proof)
372
+ * 5. Return response + actual cost in USD
373
+ */
374
+ // ── x402 on-chain metering (Option B) ────────────────────────────────────────
375
+ //
376
+ // When X402_METER_ENABLED=true, every cloud-tier call broadcasts a small
377
+ // EIP-3009 USDC transfer from the x402 signer (X402_WALLET_KEY) to a
378
+ // metering wallet. Makes ClawRouter's spend visible on Basescan in real
379
+ // time — one tx per cloud call. Serialized to avoid nonce conflicts.
380
+ // Fire-and-forget from the caller's perspective.
381
+ const METER_RECIPIENT = (process.env.X402_METER_RECIPIENT || '0x260E18591371A6E7A3da0AC5f9d47Ff06508B61F');
382
+ const METER_AMOUNT_ATOMIC = parseInt(process.env.X402_METER_AMOUNT_ATOMIC || '1000', 10); // default 0.001 USDC
383
+ const METER_MIN_INTERVAL_MS = parseInt(process.env.X402_METER_MIN_INTERVAL_MS || '2000', 10);
384
+ const USDC_CONTRACT = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
385
+ let _meterQueue = Promise.resolve();
386
+ let _meterLastAt = 0;
387
+ let _meterStats = { settled: 0, failed: 0, totalAtomicSettled: 0 };
388
+ async function meterX402Settle(costUsd, tier, model, agentId) {
389
+ // Chain on the previous settle to serialize broadcasts (avoid nonce races).
390
+ _meterQueue = _meterQueue.then(async () => {
391
+ try {
392
+ if (!X402_WALLET_KEY)
393
+ return;
394
+ // Throttle so we don't flood the chain
395
+ const sinceLast = Date.now() - _meterLastAt;
396
+ if (sinceLast < METER_MIN_INTERVAL_MS) {
397
+ await new Promise(r => setTimeout(r, METER_MIN_INTERVAL_MS - sinceLast));
398
+ }
399
+ // Pick amount: floor METER_AMOUNT_ATOMIC, scale up if real cost was bigger,
400
+ // hard cap so demo doesn't accidentally burn through the wallet.
401
+ const scaled = Math.max(METER_AMOUNT_ATOMIC, Math.floor(costUsd * 1_000_000));
402
+ const amount = BigInt(Math.min(scaled, 5000)); // hard cap 0.005 USDC per call
403
+ const { privateKeyToAccount } = require('viem/accounts');
404
+ const { createWalletClient, createPublicClient, http: viemHttp, parseAbi } = require('viem');
405
+ const { base } = require('viem/chains');
406
+ const account = privateKeyToAccount(X402_WALLET_KEY);
407
+ const wallet = createWalletClient({ account, chain: base, transport: viemHttp('https://mainnet.base.org') });
408
+ const pub = createPublicClient({ chain: base, transport: viemHttp('https://mainnet.base.org') });
409
+ // Build EIP-3009 TransferWithAuthorization, then submit via
410
+ // USDC.transferWithAuthorization (this IS the x402 settle path —
411
+ // identical to what a facilitator would do, just self-relayed).
412
+ const now = BigInt(Math.floor(Date.now() / 1000));
413
+ const message = {
414
+ from: account.address,
415
+ to: METER_RECIPIENT,
416
+ value: amount,
417
+ validAfter: now - 60n,
418
+ validBefore: now + 3600n,
419
+ nonce: (`0x${crypto.randomBytes(32).toString('hex')}`),
420
+ };
421
+ const signature = await account.signTypedData({
422
+ domain: USDC_DOMAIN,
423
+ types: {
424
+ TransferWithAuthorization: [
425
+ { name: 'from', type: 'address' },
426
+ { name: 'to', type: 'address' },
427
+ { name: 'value', type: 'uint256' },
428
+ { name: 'validAfter', type: 'uint256' },
429
+ { name: 'validBefore', type: 'uint256' },
430
+ { name: 'nonce', type: 'bytes32' },
431
+ ],
432
+ },
433
+ primaryType: 'TransferWithAuthorization',
434
+ message,
435
+ });
436
+ const sig = signature.slice(2);
437
+ const r = `0x${sig.slice(0, 64)}`;
438
+ const s = `0x${sig.slice(64, 128)}`;
439
+ const v = parseInt(sig.slice(128, 130), 16);
440
+ const abi = parseAbi([
441
+ 'function transferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce,uint8 v,bytes32 r,bytes32 s)',
442
+ ]);
443
+ const hash = await wallet.writeContract({
444
+ address: USDC_CONTRACT,
445
+ abi,
446
+ functionName: 'transferWithAuthorization',
447
+ args: [message.from, message.to, message.value, message.validAfter, message.validBefore, message.nonce, v, r, s],
448
+ });
449
+ _meterLastAt = Date.now();
450
+ _meterStats.settled += 1;
451
+ _meterStats.totalAtomicSettled += Number(amount);
452
+ // Append to a meter log alongside ceo-wallet ledger
453
+ try {
454
+ const dir = path.join(process.cwd(), 'logs', 'clawrouter');
455
+ if (!fs.existsSync(dir))
456
+ fs.mkdirSync(dir, { recursive: true });
457
+ const file = path.join(dir, `x402-meter-${new Date().toISOString().slice(0, 10)}.jsonl`);
458
+ fs.appendFileSync(file, JSON.stringify({
459
+ ts: new Date().toISOString(),
460
+ tx_hash: hash,
461
+ basescan: `https://basescan.org/tx/${hash}`,
462
+ from: message.from, to: message.to,
463
+ amount_atomic: Number(amount), amount_usdc: Number(amount) / 1e6,
464
+ tier, model, agent_id: agentId, cost_usd: costUsd,
465
+ }) + '\n');
466
+ }
467
+ catch { /* non-fatal */ }
468
+ console.log(`[x402-meter] ${hash} · ${Number(amount) / 1e6} USDC → ${METER_RECIPIENT} · tier=${tier}`);
469
+ }
470
+ catch (e) {
471
+ _meterStats.failed += 1;
472
+ console.warn(`[x402-meter] settle failed: ${e?.message || e}`);
473
+ }
474
+ });
475
+ }
476
+ function getMeterStats() { return { ..._meterStats, recipient: METER_RECIPIENT, enabled: process.env.X402_METER_ENABLED === 'true' }; }
477
+ // Founder directive 2026-05-26 (spec-pure): three-layer fallback so the
478
+ // swarm never halts on external billing failures. Spec order:
479
+ // OpenClaw upstream → local fallback → direct Anthropic
480
+ // Rationale: local is free & sovereign, so prefer it on OpenClaw failure.
481
+ // Direct Anthropic is the LAST resort because it burns the founder's
482
+ // personal balance (which is the only thing keeping the lights on when
483
+ // OpenClaw's upstream credit is drained). Returns null only if every
484
+ // layer fails, letting the caller throw the original error.
485
+ async function tryLocalThenDirect(originalModel, messages, maxTokens, reason) {
486
+ // Layer 1: local sovereign via Ollama. DeepSeek-R1 first (best for code),
487
+ // Qwen3 14B as secondary, Qwen3 32B as tertiary. Stitch the messages
488
+ // into a single prompt the local helper expects.
489
+ const sysMsg = messages.find(m => m.role === 'system')?.content;
490
+ const userPrompt = messages.filter(m => m.role !== 'system').map(m => m.content).join('\n');
491
+ const LOCAL_ORDER = ['deepseek-r1:14b', 'qwen3:14b', 'qwen3:32b'];
492
+ for (const localModel of LOCAL_ORDER) {
493
+ try {
494
+ console.warn(`[ClawRouter] ${reason} — falling back to local ${localModel}`);
495
+ const r = await callOllamaLocal(localModel, userPrompt, sysMsg, maxTokens);
496
+ return { content: r.content, input_tokens: r.input_tokens, output_tokens: r.output_tokens, cost_usd: 0 };
497
+ }
498
+ catch (e) {
499
+ console.warn(`[ClawRouter] local ${localModel} failed: ${(e?.message || '').slice(0, 150)}`);
500
+ }
501
+ }
502
+ // Layer 2: direct Anthropic (last resort — only for Claude-family models,
503
+ // costs founder's personal balance).
504
+ const anthropicKey = process.env.ANTHROPIC_API_KEY || '';
505
+ if (anthropicKey) {
506
+ const directModel = originalModel.startsWith('anthropic/')
507
+ ? originalModel.replace('anthropic/', '')
508
+ : (originalModel === 'openclaw/main' || originalModel === 'openclaw')
509
+ ? 'claude-sonnet-4-6'
510
+ : originalModel;
511
+ if (/^claude[-/]/i.test(directModel) || directModel.startsWith('claude-')) {
512
+ try {
513
+ console.warn(`[ClawRouter] ${reason} — last-resort direct Anthropic API (${directModel})`);
514
+ return await callAnthropicDirect(directModel, messages, maxTokens);
515
+ }
516
+ catch (e) {
517
+ console.warn(`[ClawRouter] direct Anthropic also failed: ${(e?.message || '').slice(0, 200)}`);
518
+ }
519
+ }
520
+ }
521
+ return null;
522
+ }
523
+ async function callCloudGateway(model, messages, maxTokens = 4096) {
524
+ const chatUrl = `${CLAWROUTER_GATEWAY}/chat/completions`;
525
+ // Sprint-1556 fix: OpenClaw v2026.5.12 only accepts the `openclaw/<agentId>`
526
+ // model format. Legacy provider/model literals (anthropic/claude-X,
527
+ // deepseek/deepseek-chat, google/gemini-X, etc.) return 400 with "Invalid
528
+ // model" which the orchestrator wraps as a stub and commits — exactly the
529
+ // failure mode that produced the 2026-05-17→18 stub cascade. Translate
530
+ // anything that isn't already `openclaw/*` to `openclaw/main` so it routes
531
+ // through the main agent's configured fallback chain. The ORIGINAL model is
532
+ // preserved as `originalModel` so the Anthropic-direct fallback below still
533
+ // triggers correctly when the gateway misbehaves.
534
+ const originalModel = model;
535
+ const gatewayModel = (model.startsWith('openclaw/') || model === 'openclaw') ? model : 'openclaw/main';
536
+ const body = JSON.stringify({ model: gatewayModel, messages, max_tokens: maxTokens });
537
+ // Step 1: Initial request (may return 402 requiring x402 payment)
538
+ let res = await httpRequest(chatUrl, body);
539
+ let paidAmountUsd = 0;
540
+ // Step 2: If 402, sign x402 payment and retry
541
+ if (res.status === 402) {
542
+ if (!X402_WALLET_KEY) {
543
+ throw new Error(`ClawRouter gateway returned 402 (payment required) but X402_WALLET_KEY not set. ` +
544
+ `Fund the CEO wallet and add the private key to .env.`);
545
+ }
546
+ try {
547
+ const paymentReq = JSON.parse(res.data);
548
+ // The gateway uses HTTP 402 for two distinct conditions that must NOT
549
+ // be conflated:
550
+ // (1) x402 protocol payment required — body has accepts.payTo+amount
551
+ // (2) upstream provider credit exhausted (Anthropic/OpenAI) bubbled
552
+ // through with their original error in body.error
553
+ // Detect (2) first so the orchestrator's credit-exhaustion fallback can
554
+ // fire on the actual upstream message (per scripts/orchestrate-agents-v2.ts
555
+ // isCreditExhaustion matcher: 'credit balance is too low', 'insufficient_quota', etc).
556
+ const upstreamErr = paymentReq.error;
557
+ if (upstreamErr) {
558
+ const upstreamMsg = String(upstreamErr.message || '');
559
+ const upstreamType = String(upstreamErr.type || '');
560
+ const looksLikeCreditExhaustion = (upstreamType === 'insufficient_quota' ||
561
+ upstreamType === 'insufficient_credit' ||
562
+ /credit balance is too low|insufficient (?:quota|credit|balance)|billing[_ ]hard[_ ]limit|quota exceeded/i.test(upstreamMsg));
563
+ if (looksLikeCreditExhaustion) {
564
+ // Founder directive 2026-05-26 (spec-pure): three-layer fallback.
565
+ // Order: OpenClaw upstream → local (free, sovereign) → direct
566
+ // Anthropic (last-resort, burns founder balance). See
567
+ // tryLocalThenDirect() for rationale.
568
+ const direct = await tryLocalThenDirect(originalModel, messages, maxTokens, upstreamMsg || upstreamType);
569
+ if (direct)
570
+ return direct;
571
+ throw new Error(`upstream provider credit exhausted: ${upstreamMsg || upstreamType}`);
572
+ }
573
+ }
574
+ // OpenClaw 402 format: { accepts: { amount, payTo, ... } } or flat { amount, payTo }
575
+ const accepts = paymentReq.accepts || paymentReq;
576
+ const amountAtomic = parseInt(accepts.maxAmountRequired || accepts.amount || '10000', 10);
577
+ const payTo = accepts.payTo || '';
578
+ if (!payTo)
579
+ throw new Error('x402: gateway returned 402 but no payTo address in response');
580
+ const { xPayment, costUsd } = await signX402Payment(payTo, amountAtomic);
581
+ paidAmountUsd = costUsd;
582
+ // Step 3: Retry with signed payment
583
+ res = await httpRequest(chatUrl, body, 'POST', { 'X-PAYMENT': xPayment });
584
+ }
585
+ catch (e) {
586
+ if (e.message?.includes('x402'))
587
+ throw e;
588
+ throw new Error(`x402 payment signing failed: ${e.message}`);
589
+ }
590
+ }
591
+ if (res.status !== 200) {
592
+ // Log response body on 4xx/5xx so the gateway's actual error is recoverable
593
+ // for future debugging — losing the message body was hiding root causes.
594
+ const bodyExcerpt = (res.data || '').slice(0, 500).replace(/\s+/g, ' ');
595
+ console.warn(`[ClawRouter] gateway ${res.status} for ${originalModel} (sent as ${gatewayModel}) — body: ${bodyExcerpt}`);
596
+ // Spec-pure three-layer fallback: local DeepSeek/Qwen first (free,
597
+ // sovereign), direct Anthropic last-resort (Claude family only, burns
598
+ // founder balance). Note: tryLocalThenDirect checks originalModel, not
599
+ // model — model has been rewritten to gatewayModel (always
600
+ // openclaw/main after the sprint-1556 translation).
601
+ const direct = await tryLocalThenDirect(originalModel, messages, maxTokens, `gateway ${res.status}`);
602
+ if (direct)
603
+ return direct;
604
+ throw new Error(`ClawRouter gateway returned ${res.status}: ${bodyExcerpt}`);
605
+ }
606
+ const json = JSON.parse(res.data);
607
+ // Cost: prefer actual payment amount, fall back to response header
608
+ const costHeader = res.headers['x-payment-amount'];
609
+ const cost_usd = paidAmountUsd > 0
610
+ ? paidAmountUsd
611
+ : (costHeader ? Number(costHeader) / 1_000_000 : 0);
612
+ return {
613
+ content: json.choices?.[0]?.message?.content || '',
614
+ input_tokens: json.usage?.prompt_tokens || 0,
615
+ output_tokens: json.usage?.completion_tokens || 0,
616
+ cost_usd,
617
+ };
618
+ }
619
+ // ── QCG Stub (AMD-12 — to be wired with full QCG pre-flight) ─────────────────
620
+ function applyQCGCompression(payload, contextTokens) {
621
+ // TODO: Wire full QCG pre-flight compression here
622
+ // For now: pass-through, no compression applied
623
+ // When implemented: read data sources → compress → return compressed brief
624
+ return { compressed: false, tokensSaved: 0, payload };
625
+ }
626
+ // ── MAIN ENTRY POINT ──────────────────────────────────────────────────────────
627
+ /**
628
+ * Route an LLM call through ClawRouter v2.0.
629
+ * This is the ONLY function any agent should call for LLM inference.
630
+ *
631
+ * @example
632
+ * const result = await routeCall({
633
+ * task_type: 'code_review',
634
+ * tier_class: 'text',
635
+ * complexity: 'power',
636
+ * context_tokens: 3000,
637
+ * constitutional_flag: false,
638
+ * agent_id: 'cto-agent',
639
+ * payload: { system: '...', prompt: '...' }
640
+ * });
641
+ */
642
+ async function routeCall(req) {
643
+ const timestamp = new Date().toISOString();
644
+ // 1. Resolve tier & model
645
+ let route = req.tier_class === 'creative'
646
+ ? resolveCreativeTier(req)
647
+ : resolveTextTier(req);
648
+ // 1a. Per-agent spend gate (TICKET-215 Wave C-b) — only agents declared with the
649
+ // `llm_call_cloud` ACP capability may incur paid cloud routing. This is how spend
650
+ // is scoped to authorized Kognai agents/swarm; everyone else falls back to local.
651
+ // FAIL-OPEN for UNREGISTERED agents (getAgent falsy) so unknown callers — incl. the
652
+ // orchestrator itself — never regress. Scoped to text tiers (clean local fallback).
653
+ // Kill switch / rollout via X402_SPEND_GATE: off | shadow | enforce (default shadow
654
+ // = log only; flip to `enforce` to actually downgrade after reviewing shadow logs).
655
+ if (!route.local && req.tier_class === 'text') {
656
+ const gateMode = (process.env.X402_SPEND_GATE || 'shadow').toLowerCase();
657
+ if (gateMode !== 'off' && (0, acp_1.getAgent)(req.agent_id) && !(0, acp_1.checkCapability)(req.agent_id, 'llm_call_cloud').allowed) {
658
+ console.warn(`[ClawRouter] spend-gate(${gateMode}): agent "${req.agent_id}" lacks llm_call_cloud — ` +
659
+ `${route.tier} ${gateMode === 'enforce' ? 'downgraded → T2 local' : 'WOULD be downgraded'}`);
660
+ if (gateMode === 'enforce') {
661
+ route = { tier: 'T2', model: TIER_MODELS.T2_POWER, local: true };
662
+ }
663
+ }
664
+ }
665
+ // 1b. CEO Wallet freeze guard — block cloud calls when budget exhausted (§17.5)
666
+ if (!route.local) {
667
+ const wallet = (0, wallet_state_1.getWalletState)();
668
+ if (wallet.isFrozen) {
669
+ throw new Error(`ClawRouter FROZEN: CEO wallet at ${wallet.burnPct.toFixed(1)}% ` +
670
+ `($${wallet.spentThisMonth.toFixed(2)}/$${wallet.monthlyBudget}). ` +
671
+ `Cloud tier ${route.tier} blocked. Fund wallet or use local tiers.`);
672
+ }
673
+ }
674
+ // 2. QCG pre-compression for cloud tiers
675
+ let qcgResult = { compressed: false, tokensSaved: 0, payload: req.payload };
676
+ if (!route.local && req.context_tokens > QCG_TOKEN_THRESHOLD) {
677
+ qcgResult = applyQCGCompression(req.payload, req.context_tokens);
678
+ }
679
+ // 3. Build messages array
680
+ const payload = qcgResult.payload;
681
+ let messages = [];
682
+ if (payload.messages) {
683
+ messages = payload.messages;
684
+ }
685
+ else {
686
+ if (payload.system)
687
+ messages.push({ role: 'system', content: payload.system });
688
+ if (payload.prompt)
689
+ messages.push({ role: 'user', content: payload.prompt });
690
+ }
691
+ const prompt = messages.filter(m => m.role === 'user').map(m => m.content).join('\n');
692
+ const systemPrompt = messages.find(m => m.role === 'system')?.content;
693
+ // 4. Execute call — local or cloud
694
+ let content = '';
695
+ let input_tokens = 0;
696
+ let output_tokens = 0;
697
+ let cost_usd = 0;
698
+ if (route.tier === 'T2.5-LOCAL') {
699
+ // T2.5-LOCAL: thinking mode call with confidence-based escalation
700
+ const thinkResult = await callOllamaThink(route.model, prompt, systemPrompt, payload.max_tokens);
701
+ content = thinkResult.content;
702
+ input_tokens = thinkResult.input_tokens;
703
+ output_tokens = thinkResult.output_tokens;
704
+ cost_usd = 0;
705
+ // Confidence-based escalation: if response contains uncertainty markers, escalate to T3 APEX
706
+ const isUncertain = UNCERTAINTY_MARKERS.some(rx => rx.test(thinkResult.content));
707
+ if (isUncertain) {
708
+ console.warn(`[ClawRouter] T2.5-LOCAL confidence below threshold — escalating to T3 APEX`);
709
+ const apexResult = await callCloudGateway(TIER_MODELS.T3_APEX, messages, payload.max_tokens || 4096);
710
+ content = apexResult.content;
711
+ input_tokens += apexResult.input_tokens;
712
+ output_tokens += apexResult.output_tokens;
713
+ cost_usd = apexResult.cost_usd;
714
+ if (cost_usd > 0)
715
+ (0, wallet_state_1.recordSpend)(cost_usd);
716
+ route = { tier: 'T3', model: TIER_MODELS.T3_APEX, local: false };
717
+ }
718
+ }
719
+ else if (route.local) {
720
+ const result = await callOllamaLocal(route.model, prompt, systemPrompt, payload.max_tokens);
721
+ content = result.content;
722
+ input_tokens = result.input_tokens;
723
+ output_tokens = result.output_tokens;
724
+ cost_usd = 0; // local = free
725
+ }
726
+ else {
727
+ const result = await callCloudGateway(route.model, messages, payload.max_tokens || 4096);
728
+ content = result.content;
729
+ input_tokens = result.input_tokens;
730
+ output_tokens = result.output_tokens;
731
+ cost_usd = result.cost_usd;
732
+ }
733
+ // 4b. CEO Wallet billing — record every cloud spend (§17.5)
734
+ if (cost_usd > 0) {
735
+ (0, wallet_state_1.recordSpend)(cost_usd);
736
+ }
737
+ // 4c. x402 on-chain metering (Option B): when enabled, every cloud-tier
738
+ // call broadcasts a small EIP-3009 USDC transfer from the x402 signer
739
+ // wallet to the configured meter recipient. Makes ClawRouter's accounting
740
+ // visible on Basescan in real time. Fire-and-forget — won't block the
741
+ // LLM response. Local-tier calls are NOT metered (they're $0 anyway).
742
+ if (!route.local && process.env.X402_METER_ENABLED === 'true') {
743
+ void meterX402Settle(cost_usd, route.tier, route.model, req.agent_id);
744
+ }
745
+ // 5. Log
746
+ const logEntry = {
747
+ timestamp, agent_id: req.agent_id, task_type: req.task_type,
748
+ tier: route.tier, model: route.model, local: route.local,
749
+ cost_usd, input_tokens, output_tokens,
750
+ qcg_compressed: qcgResult.compressed, tokens_saved: qcgResult.tokensSaved,
751
+ };
752
+ costLog.push(logEntry);
753
+ if (costLog.length > MAX_COST_LOG)
754
+ costLog.shift();
755
+ // 6. Append to daily cost log file
756
+ try {
757
+ const logDir = path.join(process.cwd(), 'logs', 'clawrouter');
758
+ if (!fs.existsSync(logDir))
759
+ fs.mkdirSync(logDir, { recursive: true });
760
+ const logFile = path.join(logDir, `${timestamp.slice(0, 10)}.jsonl`);
761
+ fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n');
762
+ }
763
+ catch { /* non-critical */ }
764
+ return {
765
+ content, model: route.model, tier: route.tier, local: route.local,
766
+ cost_usd, input_tokens, output_tokens,
767
+ qcg_compressed: qcgResult.compressed, tokens_saved_by_qcg: qcgResult.tokensSaved,
768
+ agent_id: req.agent_id, task_type: req.task_type, timestamp,
769
+ };
770
+ }
771
+ // ── BACKWARD-COMPATIBLE WRAPPER ───────────────────────────────────────────────
772
+ // Drop-in replacement for the old callClawRouter / callLLM pattern
773
+ async function callLLM(prompt, opts = {}) {
774
+ const result = await routeCall({
775
+ task_type: opts.taskType || 'generic',
776
+ tier_class: 'text',
777
+ complexity: opts.complexity || 'power',
778
+ context_tokens: (prompt.length + (opts.systemPrompt?.length || 0)) / 4,
779
+ constitutional_flag: opts.constitutional || false,
780
+ agent_id: opts.agentId || 'unknown',
781
+ payload: {
782
+ system: opts.systemPrompt,
783
+ prompt,
784
+ max_tokens: opts.maxTokens || 4096,
785
+ },
786
+ });
787
+ return { content: result.content, model: result.model, tier: result.tier, cost_usd: result.cost_usd };
788
+ }
789
+ // ── HEALTH CHECK ──────────────────────────────────────────────────────────────
790
+ async function clawRouterHealthCheck() {
791
+ let ollama = false;
792
+ let gateway = false;
793
+ let models = [];
794
+ // Check Ollama
795
+ try {
796
+ const res = await httpRequest(`${OLLAMA_BASE}/api/tags`, '{}', 'GET', {}, 3000);
797
+ if (res.status === 200) {
798
+ ollama = true;
799
+ const json = JSON.parse(res.data);
800
+ models = (json.models || []).map((m) => m.name || m.model);
801
+ }
802
+ }
803
+ catch { /* not available */ }
804
+ // Check OpenClaw gateway
805
+ try {
806
+ const res = await httpRequest(`${CLAWROUTER_GATEWAY}/models`, '{}', 'GET', {}, 3000);
807
+ gateway = res.status === 200;
808
+ }
809
+ catch { /* not available */ }
810
+ return { ollama, gateway, models };
811
+ }