@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.
- package/LICENSE +12 -0
- package/dist/clawrouter-x402/src/clawrouter-v2.d.ts +87 -0
- package/dist/clawrouter-x402/src/clawrouter-v2.js +811 -0
- package/dist/clawrouter-x402/src/index.d.ts +21 -0
- package/dist/clawrouter-x402/src/index.js +45 -0
- package/dist/orchestrator-core/src/lib/acp.d.ts +61 -0
- package/dist/orchestrator-core/src/lib/acp.js +425 -0
- package/dist/orchestrator-core/src/lib/engine-paths.d.ts +13 -0
- package/dist/orchestrator-core/src/lib/engine-paths.js +32 -0
- package/dist/orchestrator-core/src/lib/model-router-contract.d.ts +91 -0
- package/dist/orchestrator-core/src/lib/model-router-contract.js +19 -0
- package/dist/orchestrator-core/src/lib/wallet-state.d.ts +26 -0
- package/dist/orchestrator-core/src/lib/wallet-state.js +85 -0
- package/package.json +33 -0
|
@@ -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
|
+
}
|