@blockrun/clawrouter 0.6.9 → 0.8.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/dist/cli.d.ts +1 -0
- package/dist/cli.js +3416 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +233 -131
- package/dist/index.js.map +1 -1
- package/package.json +4 -1
package/dist/cli.js
ADDED
|
@@ -0,0 +1,3416 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/proxy.ts
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { finished } from "stream";
|
|
6
|
+
import { privateKeyToAccount as privateKeyToAccount2 } from "viem/accounts";
|
|
7
|
+
|
|
8
|
+
// src/x402.ts
|
|
9
|
+
import { signTypedData, privateKeyToAccount } from "viem/accounts";
|
|
10
|
+
|
|
11
|
+
// src/payment-cache.ts
|
|
12
|
+
var DEFAULT_TTL_MS = 36e5;
|
|
13
|
+
var PaymentCache = class {
|
|
14
|
+
cache = /* @__PURE__ */ new Map();
|
|
15
|
+
ttlMs;
|
|
16
|
+
constructor(ttlMs = DEFAULT_TTL_MS) {
|
|
17
|
+
this.ttlMs = ttlMs;
|
|
18
|
+
}
|
|
19
|
+
/** Get cached payment params for an endpoint path. */
|
|
20
|
+
get(endpointPath) {
|
|
21
|
+
const entry = this.cache.get(endpointPath);
|
|
22
|
+
if (!entry) return void 0;
|
|
23
|
+
if (Date.now() - entry.cachedAt > this.ttlMs) {
|
|
24
|
+
this.cache.delete(endpointPath);
|
|
25
|
+
return void 0;
|
|
26
|
+
}
|
|
27
|
+
return entry;
|
|
28
|
+
}
|
|
29
|
+
/** Cache payment params from a 402 response. */
|
|
30
|
+
set(endpointPath, params) {
|
|
31
|
+
this.cache.set(endpointPath, { ...params, cachedAt: Date.now() });
|
|
32
|
+
}
|
|
33
|
+
/** Invalidate cache for an endpoint (e.g., if payTo changed). */
|
|
34
|
+
invalidate(endpointPath) {
|
|
35
|
+
this.cache.delete(endpointPath);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/x402.ts
|
|
40
|
+
var BASE_CHAIN_ID = 8453;
|
|
41
|
+
var USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
42
|
+
var USDC_DOMAIN = {
|
|
43
|
+
name: "USD Coin",
|
|
44
|
+
version: "2",
|
|
45
|
+
chainId: BASE_CHAIN_ID,
|
|
46
|
+
verifyingContract: USDC_BASE
|
|
47
|
+
};
|
|
48
|
+
var TRANSFER_TYPES = {
|
|
49
|
+
TransferWithAuthorization: [
|
|
50
|
+
{ name: "from", type: "address" },
|
|
51
|
+
{ name: "to", type: "address" },
|
|
52
|
+
{ name: "value", type: "uint256" },
|
|
53
|
+
{ name: "validAfter", type: "uint256" },
|
|
54
|
+
{ name: "validBefore", type: "uint256" },
|
|
55
|
+
{ name: "nonce", type: "bytes32" }
|
|
56
|
+
]
|
|
57
|
+
};
|
|
58
|
+
function createNonce() {
|
|
59
|
+
const bytes = new Uint8Array(32);
|
|
60
|
+
crypto.getRandomValues(bytes);
|
|
61
|
+
return `0x${Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
62
|
+
}
|
|
63
|
+
function parsePaymentRequired(headerValue) {
|
|
64
|
+
const decoded = atob(headerValue);
|
|
65
|
+
return JSON.parse(decoded);
|
|
66
|
+
}
|
|
67
|
+
async function createPaymentPayload(privateKey, fromAddress, recipient, amount, resourceUrl) {
|
|
68
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
69
|
+
const validAfter = now - 600;
|
|
70
|
+
const validBefore = now + 300;
|
|
71
|
+
const nonce = createNonce();
|
|
72
|
+
const signature = await signTypedData({
|
|
73
|
+
privateKey,
|
|
74
|
+
domain: USDC_DOMAIN,
|
|
75
|
+
types: TRANSFER_TYPES,
|
|
76
|
+
primaryType: "TransferWithAuthorization",
|
|
77
|
+
message: {
|
|
78
|
+
from: fromAddress,
|
|
79
|
+
to: recipient,
|
|
80
|
+
value: BigInt(amount),
|
|
81
|
+
validAfter: BigInt(validAfter),
|
|
82
|
+
validBefore: BigInt(validBefore),
|
|
83
|
+
nonce
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const paymentData = {
|
|
87
|
+
x402Version: 2,
|
|
88
|
+
resource: {
|
|
89
|
+
url: resourceUrl,
|
|
90
|
+
description: "BlockRun AI API call",
|
|
91
|
+
mimeType: "application/json"
|
|
92
|
+
},
|
|
93
|
+
accepted: {
|
|
94
|
+
scheme: "exact",
|
|
95
|
+
network: "eip155:8453",
|
|
96
|
+
amount,
|
|
97
|
+
asset: USDC_BASE,
|
|
98
|
+
payTo: recipient,
|
|
99
|
+
maxTimeoutSeconds: 300,
|
|
100
|
+
extra: { name: "USD Coin", version: "2" }
|
|
101
|
+
},
|
|
102
|
+
payload: {
|
|
103
|
+
signature,
|
|
104
|
+
authorization: {
|
|
105
|
+
from: fromAddress,
|
|
106
|
+
to: recipient,
|
|
107
|
+
value: amount,
|
|
108
|
+
validAfter: validAfter.toString(),
|
|
109
|
+
validBefore: validBefore.toString(),
|
|
110
|
+
nonce
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
extensions: {}
|
|
114
|
+
};
|
|
115
|
+
return btoa(JSON.stringify(paymentData));
|
|
116
|
+
}
|
|
117
|
+
function createPaymentFetch(privateKey) {
|
|
118
|
+
const account = privateKeyToAccount(privateKey);
|
|
119
|
+
const walletAddress = account.address;
|
|
120
|
+
const paymentCache = new PaymentCache();
|
|
121
|
+
const payFetch = async (input, init, preAuth) => {
|
|
122
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
123
|
+
const endpointPath = new URL(url).pathname;
|
|
124
|
+
const cached = paymentCache.get(endpointPath);
|
|
125
|
+
if (cached && preAuth?.estimatedAmount) {
|
|
126
|
+
const paymentPayload = await createPaymentPayload(
|
|
127
|
+
privateKey,
|
|
128
|
+
walletAddress,
|
|
129
|
+
cached.payTo,
|
|
130
|
+
preAuth.estimatedAmount,
|
|
131
|
+
url
|
|
132
|
+
);
|
|
133
|
+
const preAuthHeaders = new Headers(init?.headers);
|
|
134
|
+
preAuthHeaders.set("payment-signature", paymentPayload);
|
|
135
|
+
const response2 = await fetch(input, { ...init, headers: preAuthHeaders });
|
|
136
|
+
if (response2.status !== 402) {
|
|
137
|
+
return response2;
|
|
138
|
+
}
|
|
139
|
+
const paymentHeader2 = response2.headers.get("x-payment-required");
|
|
140
|
+
if (paymentHeader2) {
|
|
141
|
+
return handle402(input, init, url, endpointPath, paymentHeader2);
|
|
142
|
+
}
|
|
143
|
+
paymentCache.invalidate(endpointPath);
|
|
144
|
+
const cleanResponse = await fetch(input, init);
|
|
145
|
+
if (cleanResponse.status !== 402) {
|
|
146
|
+
return cleanResponse;
|
|
147
|
+
}
|
|
148
|
+
const cleanHeader = cleanResponse.headers.get("x-payment-required");
|
|
149
|
+
if (!cleanHeader) {
|
|
150
|
+
throw new Error("402 response missing x-payment-required header");
|
|
151
|
+
}
|
|
152
|
+
return handle402(input, init, url, endpointPath, cleanHeader);
|
|
153
|
+
}
|
|
154
|
+
const response = await fetch(input, init);
|
|
155
|
+
if (response.status !== 402) {
|
|
156
|
+
return response;
|
|
157
|
+
}
|
|
158
|
+
const paymentHeader = response.headers.get("x-payment-required");
|
|
159
|
+
if (!paymentHeader) {
|
|
160
|
+
throw new Error("402 response missing x-payment-required header");
|
|
161
|
+
}
|
|
162
|
+
return handle402(input, init, url, endpointPath, paymentHeader);
|
|
163
|
+
};
|
|
164
|
+
async function handle402(input, init, url, endpointPath, paymentHeader) {
|
|
165
|
+
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
166
|
+
const option = paymentRequired.accepts?.[0];
|
|
167
|
+
if (!option) {
|
|
168
|
+
throw new Error("No payment options in 402 response");
|
|
169
|
+
}
|
|
170
|
+
const amount = option.amount || option.maxAmountRequired;
|
|
171
|
+
if (!amount) {
|
|
172
|
+
throw new Error("No amount in payment requirements");
|
|
173
|
+
}
|
|
174
|
+
paymentCache.set(endpointPath, {
|
|
175
|
+
payTo: option.payTo,
|
|
176
|
+
asset: option.asset,
|
|
177
|
+
scheme: option.scheme,
|
|
178
|
+
network: option.network,
|
|
179
|
+
extra: option.extra,
|
|
180
|
+
maxTimeoutSeconds: option.maxTimeoutSeconds
|
|
181
|
+
});
|
|
182
|
+
const paymentPayload = await createPaymentPayload(
|
|
183
|
+
privateKey,
|
|
184
|
+
walletAddress,
|
|
185
|
+
option.payTo,
|
|
186
|
+
amount,
|
|
187
|
+
url
|
|
188
|
+
);
|
|
189
|
+
const retryHeaders = new Headers(init?.headers);
|
|
190
|
+
retryHeaders.set("payment-signature", paymentPayload);
|
|
191
|
+
return fetch(input, {
|
|
192
|
+
...init,
|
|
193
|
+
headers: retryHeaders
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
return { fetch: payFetch, cache: paymentCache };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/router/rules.ts
|
|
200
|
+
function scoreTokenCount(estimatedTokens, thresholds) {
|
|
201
|
+
if (estimatedTokens < thresholds.simple) {
|
|
202
|
+
return { name: "tokenCount", score: -1, signal: `short (${estimatedTokens} tokens)` };
|
|
203
|
+
}
|
|
204
|
+
if (estimatedTokens > thresholds.complex) {
|
|
205
|
+
return { name: "tokenCount", score: 1, signal: `long (${estimatedTokens} tokens)` };
|
|
206
|
+
}
|
|
207
|
+
return { name: "tokenCount", score: 0, signal: null };
|
|
208
|
+
}
|
|
209
|
+
function scoreKeywordMatch(text, keywords, name, signalLabel, thresholds, scores) {
|
|
210
|
+
const matches = keywords.filter((kw) => text.includes(kw.toLowerCase()));
|
|
211
|
+
if (matches.length >= thresholds.high) {
|
|
212
|
+
return {
|
|
213
|
+
name,
|
|
214
|
+
score: scores.high,
|
|
215
|
+
signal: `${signalLabel} (${matches.slice(0, 3).join(", ")})`
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
if (matches.length >= thresholds.low) {
|
|
219
|
+
return {
|
|
220
|
+
name,
|
|
221
|
+
score: scores.low,
|
|
222
|
+
signal: `${signalLabel} (${matches.slice(0, 3).join(", ")})`
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return { name, score: scores.none, signal: null };
|
|
226
|
+
}
|
|
227
|
+
function scoreMultiStep(text) {
|
|
228
|
+
const patterns = [/first.*then/i, /step \d/i, /\d\.\s/];
|
|
229
|
+
const hits = patterns.filter((p) => p.test(text));
|
|
230
|
+
if (hits.length > 0) {
|
|
231
|
+
return { name: "multiStepPatterns", score: 0.5, signal: "multi-step" };
|
|
232
|
+
}
|
|
233
|
+
return { name: "multiStepPatterns", score: 0, signal: null };
|
|
234
|
+
}
|
|
235
|
+
function scoreQuestionComplexity(prompt) {
|
|
236
|
+
const count = (prompt.match(/\?/g) || []).length;
|
|
237
|
+
if (count > 3) {
|
|
238
|
+
return { name: "questionComplexity", score: 0.5, signal: `${count} questions` };
|
|
239
|
+
}
|
|
240
|
+
return { name: "questionComplexity", score: 0, signal: null };
|
|
241
|
+
}
|
|
242
|
+
function scoreAgenticTask(text, keywords) {
|
|
243
|
+
let matchCount = 0;
|
|
244
|
+
const signals = [];
|
|
245
|
+
for (const keyword of keywords) {
|
|
246
|
+
if (text.includes(keyword.toLowerCase())) {
|
|
247
|
+
matchCount++;
|
|
248
|
+
if (signals.length < 3) {
|
|
249
|
+
signals.push(keyword);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (matchCount >= 4) {
|
|
254
|
+
return {
|
|
255
|
+
dimensionScore: {
|
|
256
|
+
name: "agenticTask",
|
|
257
|
+
score: 1,
|
|
258
|
+
signal: `agentic (${signals.join(", ")})`
|
|
259
|
+
},
|
|
260
|
+
agenticScore: 1
|
|
261
|
+
};
|
|
262
|
+
} else if (matchCount >= 3) {
|
|
263
|
+
return {
|
|
264
|
+
dimensionScore: {
|
|
265
|
+
name: "agenticTask",
|
|
266
|
+
score: 0.6,
|
|
267
|
+
signal: `agentic (${signals.join(", ")})`
|
|
268
|
+
},
|
|
269
|
+
agenticScore: 0.6
|
|
270
|
+
};
|
|
271
|
+
} else if (matchCount >= 1) {
|
|
272
|
+
return {
|
|
273
|
+
dimensionScore: {
|
|
274
|
+
name: "agenticTask",
|
|
275
|
+
score: 0.2,
|
|
276
|
+
signal: `agentic-light (${signals.join(", ")})`
|
|
277
|
+
},
|
|
278
|
+
agenticScore: 0.2
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
return {
|
|
282
|
+
dimensionScore: { name: "agenticTask", score: 0, signal: null },
|
|
283
|
+
agenticScore: 0
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function classifyByRules(prompt, systemPrompt, estimatedTokens, config) {
|
|
287
|
+
const text = `${systemPrompt ?? ""} ${prompt}`.toLowerCase();
|
|
288
|
+
const userText = prompt.toLowerCase();
|
|
289
|
+
const dimensions = [
|
|
290
|
+
// Original 8 dimensions
|
|
291
|
+
scoreTokenCount(estimatedTokens, config.tokenCountThresholds),
|
|
292
|
+
scoreKeywordMatch(
|
|
293
|
+
text,
|
|
294
|
+
config.codeKeywords,
|
|
295
|
+
"codePresence",
|
|
296
|
+
"code",
|
|
297
|
+
{ low: 1, high: 2 },
|
|
298
|
+
{ none: 0, low: 0.5, high: 1 }
|
|
299
|
+
),
|
|
300
|
+
// Reasoning markers use USER prompt only — system prompt "step by step" shouldn't trigger reasoning
|
|
301
|
+
scoreKeywordMatch(
|
|
302
|
+
userText,
|
|
303
|
+
config.reasoningKeywords,
|
|
304
|
+
"reasoningMarkers",
|
|
305
|
+
"reasoning",
|
|
306
|
+
{ low: 1, high: 2 },
|
|
307
|
+
{ none: 0, low: 0.7, high: 1 }
|
|
308
|
+
),
|
|
309
|
+
scoreKeywordMatch(
|
|
310
|
+
text,
|
|
311
|
+
config.technicalKeywords,
|
|
312
|
+
"technicalTerms",
|
|
313
|
+
"technical",
|
|
314
|
+
{ low: 2, high: 4 },
|
|
315
|
+
{ none: 0, low: 0.5, high: 1 }
|
|
316
|
+
),
|
|
317
|
+
scoreKeywordMatch(
|
|
318
|
+
text,
|
|
319
|
+
config.creativeKeywords,
|
|
320
|
+
"creativeMarkers",
|
|
321
|
+
"creative",
|
|
322
|
+
{ low: 1, high: 2 },
|
|
323
|
+
{ none: 0, low: 0.5, high: 0.7 }
|
|
324
|
+
),
|
|
325
|
+
scoreKeywordMatch(
|
|
326
|
+
text,
|
|
327
|
+
config.simpleKeywords,
|
|
328
|
+
"simpleIndicators",
|
|
329
|
+
"simple",
|
|
330
|
+
{ low: 1, high: 2 },
|
|
331
|
+
{ none: 0, low: -1, high: -1 }
|
|
332
|
+
),
|
|
333
|
+
scoreMultiStep(text),
|
|
334
|
+
scoreQuestionComplexity(prompt),
|
|
335
|
+
// 6 new dimensions
|
|
336
|
+
scoreKeywordMatch(
|
|
337
|
+
text,
|
|
338
|
+
config.imperativeVerbs,
|
|
339
|
+
"imperativeVerbs",
|
|
340
|
+
"imperative",
|
|
341
|
+
{ low: 1, high: 2 },
|
|
342
|
+
{ none: 0, low: 0.3, high: 0.5 }
|
|
343
|
+
),
|
|
344
|
+
scoreKeywordMatch(
|
|
345
|
+
text,
|
|
346
|
+
config.constraintIndicators,
|
|
347
|
+
"constraintCount",
|
|
348
|
+
"constraints",
|
|
349
|
+
{ low: 1, high: 3 },
|
|
350
|
+
{ none: 0, low: 0.3, high: 0.7 }
|
|
351
|
+
),
|
|
352
|
+
scoreKeywordMatch(
|
|
353
|
+
text,
|
|
354
|
+
config.outputFormatKeywords,
|
|
355
|
+
"outputFormat",
|
|
356
|
+
"format",
|
|
357
|
+
{ low: 1, high: 2 },
|
|
358
|
+
{ none: 0, low: 0.4, high: 0.7 }
|
|
359
|
+
),
|
|
360
|
+
scoreKeywordMatch(
|
|
361
|
+
text,
|
|
362
|
+
config.referenceKeywords,
|
|
363
|
+
"referenceComplexity",
|
|
364
|
+
"references",
|
|
365
|
+
{ low: 1, high: 2 },
|
|
366
|
+
{ none: 0, low: 0.3, high: 0.5 }
|
|
367
|
+
),
|
|
368
|
+
scoreKeywordMatch(
|
|
369
|
+
text,
|
|
370
|
+
config.negationKeywords,
|
|
371
|
+
"negationComplexity",
|
|
372
|
+
"negation",
|
|
373
|
+
{ low: 2, high: 3 },
|
|
374
|
+
{ none: 0, low: 0.3, high: 0.5 }
|
|
375
|
+
),
|
|
376
|
+
scoreKeywordMatch(
|
|
377
|
+
text,
|
|
378
|
+
config.domainSpecificKeywords,
|
|
379
|
+
"domainSpecificity",
|
|
380
|
+
"domain-specific",
|
|
381
|
+
{ low: 1, high: 2 },
|
|
382
|
+
{ none: 0, low: 0.5, high: 0.8 }
|
|
383
|
+
)
|
|
384
|
+
];
|
|
385
|
+
const agenticResult = scoreAgenticTask(text, config.agenticTaskKeywords);
|
|
386
|
+
dimensions.push(agenticResult.dimensionScore);
|
|
387
|
+
const agenticScore = agenticResult.agenticScore;
|
|
388
|
+
const signals = dimensions.filter((d) => d.signal !== null).map((d) => d.signal);
|
|
389
|
+
const weights = config.dimensionWeights;
|
|
390
|
+
let weightedScore = 0;
|
|
391
|
+
for (const d of dimensions) {
|
|
392
|
+
const w = weights[d.name] ?? 0;
|
|
393
|
+
weightedScore += d.score * w;
|
|
394
|
+
}
|
|
395
|
+
const reasoningMatches = config.reasoningKeywords.filter(
|
|
396
|
+
(kw) => userText.includes(kw.toLowerCase())
|
|
397
|
+
);
|
|
398
|
+
if (reasoningMatches.length >= 2) {
|
|
399
|
+
const confidence2 = calibrateConfidence(
|
|
400
|
+
Math.max(weightedScore, 0.3),
|
|
401
|
+
// ensure positive for confidence calc
|
|
402
|
+
config.confidenceSteepness
|
|
403
|
+
);
|
|
404
|
+
return {
|
|
405
|
+
score: weightedScore,
|
|
406
|
+
tier: "REASONING",
|
|
407
|
+
confidence: Math.max(confidence2, 0.85),
|
|
408
|
+
signals,
|
|
409
|
+
agenticScore
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
const { simpleMedium, mediumComplex, complexReasoning } = config.tierBoundaries;
|
|
413
|
+
let tier;
|
|
414
|
+
let distanceFromBoundary;
|
|
415
|
+
if (weightedScore < simpleMedium) {
|
|
416
|
+
tier = "SIMPLE";
|
|
417
|
+
distanceFromBoundary = simpleMedium - weightedScore;
|
|
418
|
+
} else if (weightedScore < mediumComplex) {
|
|
419
|
+
tier = "MEDIUM";
|
|
420
|
+
distanceFromBoundary = Math.min(weightedScore - simpleMedium, mediumComplex - weightedScore);
|
|
421
|
+
} else if (weightedScore < complexReasoning) {
|
|
422
|
+
tier = "COMPLEX";
|
|
423
|
+
distanceFromBoundary = Math.min(
|
|
424
|
+
weightedScore - mediumComplex,
|
|
425
|
+
complexReasoning - weightedScore
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
tier = "REASONING";
|
|
429
|
+
distanceFromBoundary = weightedScore - complexReasoning;
|
|
430
|
+
}
|
|
431
|
+
const confidence = calibrateConfidence(distanceFromBoundary, config.confidenceSteepness);
|
|
432
|
+
if (confidence < config.confidenceThreshold) {
|
|
433
|
+
return { score: weightedScore, tier: null, confidence, signals, agenticScore };
|
|
434
|
+
}
|
|
435
|
+
return { score: weightedScore, tier, confidence, signals, agenticScore };
|
|
436
|
+
}
|
|
437
|
+
function calibrateConfidence(distance, steepness) {
|
|
438
|
+
return 1 / (1 + Math.exp(-steepness * distance));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// src/router/selector.ts
|
|
442
|
+
function selectModel(tier, confidence, method, reasoning, tierConfigs, modelPricing, estimatedInputTokens, maxOutputTokens) {
|
|
443
|
+
const tierConfig = tierConfigs[tier];
|
|
444
|
+
const model = tierConfig.primary;
|
|
445
|
+
const pricing = modelPricing.get(model);
|
|
446
|
+
const inputCost = pricing ? estimatedInputTokens / 1e6 * pricing.inputPrice : 0;
|
|
447
|
+
const outputCost = pricing ? maxOutputTokens / 1e6 * pricing.outputPrice : 0;
|
|
448
|
+
const costEstimate = inputCost + outputCost;
|
|
449
|
+
const opusPricing = modelPricing.get("anthropic/claude-opus-4");
|
|
450
|
+
const baselineInput = opusPricing ? estimatedInputTokens / 1e6 * opusPricing.inputPrice : 0;
|
|
451
|
+
const baselineOutput = opusPricing ? maxOutputTokens / 1e6 * opusPricing.outputPrice : 0;
|
|
452
|
+
const baselineCost = baselineInput + baselineOutput;
|
|
453
|
+
const savings = baselineCost > 0 ? Math.max(0, (baselineCost - costEstimate) / baselineCost) : 0;
|
|
454
|
+
return {
|
|
455
|
+
model,
|
|
456
|
+
tier,
|
|
457
|
+
confidence,
|
|
458
|
+
method,
|
|
459
|
+
reasoning,
|
|
460
|
+
costEstimate,
|
|
461
|
+
baselineCost,
|
|
462
|
+
savings
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
function getFallbackChain(tier, tierConfigs) {
|
|
466
|
+
const config = tierConfigs[tier];
|
|
467
|
+
return [config.primary, ...config.fallback];
|
|
468
|
+
}
|
|
469
|
+
function getFallbackChainFiltered(tier, tierConfigs, estimatedTotalTokens, getContextWindow) {
|
|
470
|
+
const fullChain = getFallbackChain(tier, tierConfigs);
|
|
471
|
+
const filtered = fullChain.filter((modelId) => {
|
|
472
|
+
const contextWindow = getContextWindow(modelId);
|
|
473
|
+
if (contextWindow === void 0) {
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
return contextWindow >= estimatedTotalTokens * 1.1;
|
|
477
|
+
});
|
|
478
|
+
if (filtered.length === 0) {
|
|
479
|
+
return fullChain;
|
|
480
|
+
}
|
|
481
|
+
return filtered;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// src/router/config.ts
|
|
485
|
+
var DEFAULT_ROUTING_CONFIG = {
|
|
486
|
+
version: "2.0",
|
|
487
|
+
classifier: {
|
|
488
|
+
llmModel: "google/gemini-2.5-flash",
|
|
489
|
+
llmMaxTokens: 10,
|
|
490
|
+
llmTemperature: 0,
|
|
491
|
+
promptTruncationChars: 500,
|
|
492
|
+
cacheTtlMs: 36e5
|
|
493
|
+
// 1 hour
|
|
494
|
+
},
|
|
495
|
+
scoring: {
|
|
496
|
+
tokenCountThresholds: { simple: 50, complex: 500 },
|
|
497
|
+
// Multilingual keywords: English + Chinese (中文) + Japanese (日本語) + Russian (Русский) + German (Deutsch)
|
|
498
|
+
codeKeywords: [
|
|
499
|
+
// English
|
|
500
|
+
"function",
|
|
501
|
+
"class",
|
|
502
|
+
"import",
|
|
503
|
+
"def",
|
|
504
|
+
"SELECT",
|
|
505
|
+
"async",
|
|
506
|
+
"await",
|
|
507
|
+
"const",
|
|
508
|
+
"let",
|
|
509
|
+
"var",
|
|
510
|
+
"return",
|
|
511
|
+
"```",
|
|
512
|
+
// Chinese
|
|
513
|
+
"\u51FD\u6570",
|
|
514
|
+
"\u7C7B",
|
|
515
|
+
"\u5BFC\u5165",
|
|
516
|
+
"\u5B9A\u4E49",
|
|
517
|
+
"\u67E5\u8BE2",
|
|
518
|
+
"\u5F02\u6B65",
|
|
519
|
+
"\u7B49\u5F85",
|
|
520
|
+
"\u5E38\u91CF",
|
|
521
|
+
"\u53D8\u91CF",
|
|
522
|
+
"\u8FD4\u56DE",
|
|
523
|
+
// Japanese
|
|
524
|
+
"\u95A2\u6570",
|
|
525
|
+
"\u30AF\u30E9\u30B9",
|
|
526
|
+
"\u30A4\u30F3\u30DD\u30FC\u30C8",
|
|
527
|
+
"\u975E\u540C\u671F",
|
|
528
|
+
"\u5B9A\u6570",
|
|
529
|
+
"\u5909\u6570",
|
|
530
|
+
// Russian
|
|
531
|
+
"\u0444\u0443\u043D\u043A\u0446\u0438\u044F",
|
|
532
|
+
"\u043A\u043B\u0430\u0441\u0441",
|
|
533
|
+
"\u0438\u043C\u043F\u043E\u0440\u0442",
|
|
534
|
+
"\u043E\u043F\u0440\u0435\u0434\u0435\u043B",
|
|
535
|
+
"\u0437\u0430\u043F\u0440\u043E\u0441",
|
|
536
|
+
"\u0430\u0441\u0438\u043D\u0445\u0440\u043E\u043D\u043D\u044B\u0439",
|
|
537
|
+
"\u043E\u0436\u0438\u0434\u0430\u0442\u044C",
|
|
538
|
+
"\u043A\u043E\u043D\u0441\u0442\u0430\u043D\u0442\u0430",
|
|
539
|
+
"\u043F\u0435\u0440\u0435\u043C\u0435\u043D\u043D\u0430\u044F",
|
|
540
|
+
"\u0432\u0435\u0440\u043D\u0443\u0442\u044C",
|
|
541
|
+
// German
|
|
542
|
+
"funktion",
|
|
543
|
+
"klasse",
|
|
544
|
+
"importieren",
|
|
545
|
+
"definieren",
|
|
546
|
+
"abfrage",
|
|
547
|
+
"asynchron",
|
|
548
|
+
"erwarten",
|
|
549
|
+
"konstante",
|
|
550
|
+
"variable",
|
|
551
|
+
"zur\xFCckgeben"
|
|
552
|
+
],
|
|
553
|
+
reasoningKeywords: [
|
|
554
|
+
// English
|
|
555
|
+
"prove",
|
|
556
|
+
"theorem",
|
|
557
|
+
"derive",
|
|
558
|
+
"step by step",
|
|
559
|
+
"chain of thought",
|
|
560
|
+
"formally",
|
|
561
|
+
"mathematical",
|
|
562
|
+
"proof",
|
|
563
|
+
"logically",
|
|
564
|
+
// Chinese
|
|
565
|
+
"\u8BC1\u660E",
|
|
566
|
+
"\u5B9A\u7406",
|
|
567
|
+
"\u63A8\u5BFC",
|
|
568
|
+
"\u9010\u6B65",
|
|
569
|
+
"\u601D\u7EF4\u94FE",
|
|
570
|
+
"\u5F62\u5F0F\u5316",
|
|
571
|
+
"\u6570\u5B66",
|
|
572
|
+
"\u903B\u8F91",
|
|
573
|
+
// Japanese
|
|
574
|
+
"\u8A3C\u660E",
|
|
575
|
+
"\u5B9A\u7406",
|
|
576
|
+
"\u5C0E\u51FA",
|
|
577
|
+
"\u30B9\u30C6\u30C3\u30D7\u30D0\u30A4\u30B9\u30C6\u30C3\u30D7",
|
|
578
|
+
"\u8AD6\u7406\u7684",
|
|
579
|
+
// Russian
|
|
580
|
+
"\u0434\u043E\u043A\u0430\u0437\u0430\u0442\u044C",
|
|
581
|
+
"\u0434\u043E\u043A\u0430\u0436\u0438",
|
|
582
|
+
"\u0434\u043E\u043A\u0430\u0437\u0430\u0442\u0435\u043B\u044C\u0441\u0442\u0432",
|
|
583
|
+
"\u0442\u0435\u043E\u0440\u0435\u043C\u0430",
|
|
584
|
+
"\u0432\u044B\u0432\u0435\u0441\u0442\u0438",
|
|
585
|
+
"\u0448\u0430\u0433 \u0437\u0430 \u0448\u0430\u0433\u043E\u043C",
|
|
586
|
+
"\u043F\u043E\u0448\u0430\u0433\u043E\u0432\u043E",
|
|
587
|
+
"\u043F\u043E\u044D\u0442\u0430\u043F\u043D\u043E",
|
|
588
|
+
"\u0446\u0435\u043F\u043E\u0447\u043A\u0430 \u0440\u0430\u0441\u0441\u0443\u0436\u0434\u0435\u043D\u0438\u0439",
|
|
589
|
+
"\u0440\u0430\u0441\u0441\u0443\u0436\u0434\u0435\u043D\u0438",
|
|
590
|
+
"\u0444\u043E\u0440\u043C\u0430\u043B\u044C\u043D\u043E",
|
|
591
|
+
"\u043C\u0430\u0442\u0435\u043C\u0430\u0442\u0438\u0447\u0435\u0441\u043A\u0438",
|
|
592
|
+
"\u043B\u043E\u0433\u0438\u0447\u0435\u0441\u043A\u0438",
|
|
593
|
+
// German
|
|
594
|
+
"beweisen",
|
|
595
|
+
"beweis",
|
|
596
|
+
"theorem",
|
|
597
|
+
"ableiten",
|
|
598
|
+
"schritt f\xFCr schritt",
|
|
599
|
+
"gedankenkette",
|
|
600
|
+
"formal",
|
|
601
|
+
"mathematisch",
|
|
602
|
+
"logisch"
|
|
603
|
+
],
|
|
604
|
+
simpleKeywords: [
|
|
605
|
+
// English
|
|
606
|
+
"what is",
|
|
607
|
+
"define",
|
|
608
|
+
"translate",
|
|
609
|
+
"hello",
|
|
610
|
+
"yes or no",
|
|
611
|
+
"capital of",
|
|
612
|
+
"how old",
|
|
613
|
+
"who is",
|
|
614
|
+
"when was",
|
|
615
|
+
// Chinese
|
|
616
|
+
"\u4EC0\u4E48\u662F",
|
|
617
|
+
"\u5B9A\u4E49",
|
|
618
|
+
"\u7FFB\u8BD1",
|
|
619
|
+
"\u4F60\u597D",
|
|
620
|
+
"\u662F\u5426",
|
|
621
|
+
"\u9996\u90FD",
|
|
622
|
+
"\u591A\u5927",
|
|
623
|
+
"\u8C01\u662F",
|
|
624
|
+
"\u4F55\u65F6",
|
|
625
|
+
// Japanese
|
|
626
|
+
"\u3068\u306F",
|
|
627
|
+
"\u5B9A\u7FA9",
|
|
628
|
+
"\u7FFB\u8A33",
|
|
629
|
+
"\u3053\u3093\u306B\u3061\u306F",
|
|
630
|
+
"\u306F\u3044\u304B\u3044\u3044\u3048",
|
|
631
|
+
"\u9996\u90FD",
|
|
632
|
+
"\u8AB0",
|
|
633
|
+
// Russian
|
|
634
|
+
"\u0447\u0442\u043E \u0442\u0430\u043A\u043E\u0435",
|
|
635
|
+
"\u043E\u043F\u0440\u0435\u0434\u0435\u043B\u0435\u043D\u0438\u0435",
|
|
636
|
+
"\u043F\u0435\u0440\u0435\u0432\u0435\u0441\u0442\u0438",
|
|
637
|
+
"\u043F\u0435\u0440\u0435\u0432\u0435\u0434\u0438",
|
|
638
|
+
"\u043F\u0440\u0438\u0432\u0435\u0442",
|
|
639
|
+
"\u0434\u0430 \u0438\u043B\u0438 \u043D\u0435\u0442",
|
|
640
|
+
"\u0441\u0442\u043E\u043B\u0438\u0446\u0430",
|
|
641
|
+
"\u0441\u043A\u043E\u043B\u044C\u043A\u043E \u043B\u0435\u0442",
|
|
642
|
+
"\u043A\u0442\u043E \u0442\u0430\u043A\u043E\u0439",
|
|
643
|
+
"\u043A\u043E\u0433\u0434\u0430",
|
|
644
|
+
"\u043E\u0431\u044A\u044F\u0441\u043D\u0438",
|
|
645
|
+
// German
|
|
646
|
+
"was ist",
|
|
647
|
+
"definiere",
|
|
648
|
+
"\xFCbersetze",
|
|
649
|
+
"hallo",
|
|
650
|
+
"ja oder nein",
|
|
651
|
+
"hauptstadt",
|
|
652
|
+
"wie alt",
|
|
653
|
+
"wer ist",
|
|
654
|
+
"wann",
|
|
655
|
+
"erkl\xE4re"
|
|
656
|
+
],
|
|
657
|
+
technicalKeywords: [
|
|
658
|
+
// English
|
|
659
|
+
"algorithm",
|
|
660
|
+
"optimize",
|
|
661
|
+
"architecture",
|
|
662
|
+
"distributed",
|
|
663
|
+
"kubernetes",
|
|
664
|
+
"microservice",
|
|
665
|
+
"database",
|
|
666
|
+
"infrastructure",
|
|
667
|
+
// Chinese
|
|
668
|
+
"\u7B97\u6CD5",
|
|
669
|
+
"\u4F18\u5316",
|
|
670
|
+
"\u67B6\u6784",
|
|
671
|
+
"\u5206\u5E03\u5F0F",
|
|
672
|
+
"\u5FAE\u670D\u52A1",
|
|
673
|
+
"\u6570\u636E\u5E93",
|
|
674
|
+
"\u57FA\u7840\u8BBE\u65BD",
|
|
675
|
+
// Japanese
|
|
676
|
+
"\u30A2\u30EB\u30B4\u30EA\u30BA\u30E0",
|
|
677
|
+
"\u6700\u9069\u5316",
|
|
678
|
+
"\u30A2\u30FC\u30AD\u30C6\u30AF\u30C1\u30E3",
|
|
679
|
+
"\u5206\u6563",
|
|
680
|
+
"\u30DE\u30A4\u30AF\u30ED\u30B5\u30FC\u30D3\u30B9",
|
|
681
|
+
"\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9",
|
|
682
|
+
// Russian
|
|
683
|
+
"\u0430\u043B\u0433\u043E\u0440\u0438\u0442\u043C",
|
|
684
|
+
"\u043E\u043F\u0442\u0438\u043C\u0438\u0437\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
|
|
685
|
+
"\u043E\u043F\u0442\u0438\u043C\u0438\u0437\u0430\u0446\u0438",
|
|
686
|
+
"\u043E\u043F\u0442\u0438\u043C\u0438\u0437\u0438\u0440\u0443\u0439",
|
|
687
|
+
"\u0430\u0440\u0445\u0438\u0442\u0435\u043A\u0442\u0443\u0440\u0430",
|
|
688
|
+
"\u0440\u0430\u0441\u043F\u0440\u0435\u0434\u0435\u043B\u0451\u043D\u043D\u044B\u0439",
|
|
689
|
+
"\u043C\u0438\u043A\u0440\u043E\u0441\u0435\u0440\u0432\u0438\u0441",
|
|
690
|
+
"\u0431\u0430\u0437\u0430 \u0434\u0430\u043D\u043D\u044B\u0445",
|
|
691
|
+
"\u0438\u043D\u0444\u0440\u0430\u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0430",
|
|
692
|
+
// German
|
|
693
|
+
"algorithmus",
|
|
694
|
+
"optimieren",
|
|
695
|
+
"architektur",
|
|
696
|
+
"verteilt",
|
|
697
|
+
"kubernetes",
|
|
698
|
+
"mikroservice",
|
|
699
|
+
"datenbank",
|
|
700
|
+
"infrastruktur"
|
|
701
|
+
],
|
|
702
|
+
creativeKeywords: [
|
|
703
|
+
// English
|
|
704
|
+
"story",
|
|
705
|
+
"poem",
|
|
706
|
+
"compose",
|
|
707
|
+
"brainstorm",
|
|
708
|
+
"creative",
|
|
709
|
+
"imagine",
|
|
710
|
+
"write a",
|
|
711
|
+
// Chinese
|
|
712
|
+
"\u6545\u4E8B",
|
|
713
|
+
"\u8BD7",
|
|
714
|
+
"\u521B\u4F5C",
|
|
715
|
+
"\u5934\u8111\u98CE\u66B4",
|
|
716
|
+
"\u521B\u610F",
|
|
717
|
+
"\u60F3\u8C61",
|
|
718
|
+
"\u5199\u4E00\u4E2A",
|
|
719
|
+
// Japanese
|
|
720
|
+
"\u7269\u8A9E",
|
|
721
|
+
"\u8A69",
|
|
722
|
+
"\u4F5C\u66F2",
|
|
723
|
+
"\u30D6\u30EC\u30A4\u30F3\u30B9\u30C8\u30FC\u30E0",
|
|
724
|
+
"\u5275\u9020\u7684",
|
|
725
|
+
"\u60F3\u50CF",
|
|
726
|
+
// Russian
|
|
727
|
+
"\u0438\u0441\u0442\u043E\u0440\u0438\u044F",
|
|
728
|
+
"\u0440\u0430\u0441\u0441\u043A\u0430\u0437",
|
|
729
|
+
"\u0441\u0442\u0438\u0445\u043E\u0442\u0432\u043E\u0440\u0435\u043D\u0438\u0435",
|
|
730
|
+
"\u0441\u043E\u0447\u0438\u043D\u0438\u0442\u044C",
|
|
731
|
+
"\u0441\u043E\u0447\u0438\u043D\u0438",
|
|
732
|
+
"\u043C\u043E\u0437\u0433\u043E\u0432\u043E\u0439 \u0448\u0442\u0443\u0440\u043C",
|
|
733
|
+
"\u0442\u0432\u043E\u0440\u0447\u0435\u0441\u043A\u0438\u0439",
|
|
734
|
+
"\u043F\u0440\u0435\u0434\u0441\u0442\u0430\u0432\u0438\u0442\u044C",
|
|
735
|
+
"\u043F\u0440\u0438\u0434\u0443\u043C\u0430\u0439",
|
|
736
|
+
"\u043D\u0430\u043F\u0438\u0448\u0438",
|
|
737
|
+
// German
|
|
738
|
+
"geschichte",
|
|
739
|
+
"gedicht",
|
|
740
|
+
"komponieren",
|
|
741
|
+
"brainstorming",
|
|
742
|
+
"kreativ",
|
|
743
|
+
"vorstellen",
|
|
744
|
+
"schreibe",
|
|
745
|
+
"erz\xE4hlung"
|
|
746
|
+
],
|
|
747
|
+
// New dimension keyword lists (multilingual)
|
|
748
|
+
imperativeVerbs: [
|
|
749
|
+
// English
|
|
750
|
+
"build",
|
|
751
|
+
"create",
|
|
752
|
+
"implement",
|
|
753
|
+
"design",
|
|
754
|
+
"develop",
|
|
755
|
+
"construct",
|
|
756
|
+
"generate",
|
|
757
|
+
"deploy",
|
|
758
|
+
"configure",
|
|
759
|
+
"set up",
|
|
760
|
+
// Chinese
|
|
761
|
+
"\u6784\u5EFA",
|
|
762
|
+
"\u521B\u5EFA",
|
|
763
|
+
"\u5B9E\u73B0",
|
|
764
|
+
"\u8BBE\u8BA1",
|
|
765
|
+
"\u5F00\u53D1",
|
|
766
|
+
"\u751F\u6210",
|
|
767
|
+
"\u90E8\u7F72",
|
|
768
|
+
"\u914D\u7F6E",
|
|
769
|
+
"\u8BBE\u7F6E",
|
|
770
|
+
// Japanese
|
|
771
|
+
"\u69CB\u7BC9",
|
|
772
|
+
"\u4F5C\u6210",
|
|
773
|
+
"\u5B9F\u88C5",
|
|
774
|
+
"\u8A2D\u8A08",
|
|
775
|
+
"\u958B\u767A",
|
|
776
|
+
"\u751F\u6210",
|
|
777
|
+
"\u30C7\u30D7\u30ED\u30A4",
|
|
778
|
+
"\u8A2D\u5B9A",
|
|
779
|
+
// Russian
|
|
780
|
+
"\u043F\u043E\u0441\u0442\u0440\u043E\u0438\u0442\u044C",
|
|
781
|
+
"\u043F\u043E\u0441\u0442\u0440\u043E\u0439",
|
|
782
|
+
"\u0441\u043E\u0437\u0434\u0430\u0442\u044C",
|
|
783
|
+
"\u0441\u043E\u0437\u0434\u0430\u0439",
|
|
784
|
+
"\u0440\u0435\u0430\u043B\u0438\u0437\u043E\u0432\u0430\u0442\u044C",
|
|
785
|
+
"\u0440\u0435\u0430\u043B\u0438\u0437\u0443\u0439",
|
|
786
|
+
"\u0441\u043F\u0440\u043E\u0435\u043A\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
|
|
787
|
+
"\u0440\u0430\u0437\u0440\u0430\u0431\u043E\u0442\u0430\u0442\u044C",
|
|
788
|
+
"\u0440\u0430\u0437\u0440\u0430\u0431\u043E\u0442\u0430\u0439",
|
|
789
|
+
"\u0441\u043A\u043E\u043D\u0441\u0442\u0440\u0443\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
|
|
790
|
+
"\u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u043E\u0432\u0430\u0442\u044C",
|
|
791
|
+
"\u0441\u0433\u0435\u043D\u0435\u0440\u0438\u0440\u0443\u0439",
|
|
792
|
+
"\u0440\u0430\u0437\u0432\u0435\u0440\u043D\u0443\u0442\u044C",
|
|
793
|
+
"\u0440\u0430\u0437\u0432\u0435\u0440\u043D\u0438",
|
|
794
|
+
"\u043D\u0430\u0441\u0442\u0440\u043E\u0438\u0442\u044C",
|
|
795
|
+
"\u043D\u0430\u0441\u0442\u0440\u043E\u0439",
|
|
796
|
+
// German
|
|
797
|
+
"erstellen",
|
|
798
|
+
"bauen",
|
|
799
|
+
"implementieren",
|
|
800
|
+
"entwerfen",
|
|
801
|
+
"entwickeln",
|
|
802
|
+
"konstruieren",
|
|
803
|
+
"generieren",
|
|
804
|
+
"bereitstellen",
|
|
805
|
+
"konfigurieren",
|
|
806
|
+
"einrichten"
|
|
807
|
+
],
|
|
808
|
+
constraintIndicators: [
|
|
809
|
+
// English
|
|
810
|
+
"under",
|
|
811
|
+
"at most",
|
|
812
|
+
"at least",
|
|
813
|
+
"within",
|
|
814
|
+
"no more than",
|
|
815
|
+
"o(",
|
|
816
|
+
"maximum",
|
|
817
|
+
"minimum",
|
|
818
|
+
"limit",
|
|
819
|
+
"budget",
|
|
820
|
+
// Chinese
|
|
821
|
+
"\u4E0D\u8D85\u8FC7",
|
|
822
|
+
"\u81F3\u5C11",
|
|
823
|
+
"\u6700\u591A",
|
|
824
|
+
"\u5728\u5185",
|
|
825
|
+
"\u6700\u5927",
|
|
826
|
+
"\u6700\u5C0F",
|
|
827
|
+
"\u9650\u5236",
|
|
828
|
+
"\u9884\u7B97",
|
|
829
|
+
// Japanese
|
|
830
|
+
"\u4EE5\u4E0B",
|
|
831
|
+
"\u6700\u5927",
|
|
832
|
+
"\u6700\u5C0F",
|
|
833
|
+
"\u5236\u9650",
|
|
834
|
+
"\u4E88\u7B97",
|
|
835
|
+
// Russian
|
|
836
|
+
"\u043D\u0435 \u0431\u043E\u043B\u0435\u0435",
|
|
837
|
+
"\u043D\u0435 \u043C\u0435\u043D\u0435\u0435",
|
|
838
|
+
"\u043A\u0430\u043A \u043C\u0438\u043D\u0438\u043C\u0443\u043C",
|
|
839
|
+
"\u0432 \u043F\u0440\u0435\u0434\u0435\u043B\u0430\u0445",
|
|
840
|
+
"\u043C\u0430\u043A\u0441\u0438\u043C\u0443\u043C",
|
|
841
|
+
"\u043C\u0438\u043D\u0438\u043C\u0443\u043C",
|
|
842
|
+
"\u043E\u0433\u0440\u0430\u043D\u0438\u0447\u0435\u043D\u0438\u0435",
|
|
843
|
+
"\u0431\u044E\u0434\u0436\u0435\u0442",
|
|
844
|
+
// German
|
|
845
|
+
"h\xF6chstens",
|
|
846
|
+
"mindestens",
|
|
847
|
+
"innerhalb",
|
|
848
|
+
"nicht mehr als",
|
|
849
|
+
"maximal",
|
|
850
|
+
"minimal",
|
|
851
|
+
"grenze",
|
|
852
|
+
"budget"
|
|
853
|
+
],
|
|
854
|
+
outputFormatKeywords: [
|
|
855
|
+
// English
|
|
856
|
+
"json",
|
|
857
|
+
"yaml",
|
|
858
|
+
"xml",
|
|
859
|
+
"table",
|
|
860
|
+
"csv",
|
|
861
|
+
"markdown",
|
|
862
|
+
"schema",
|
|
863
|
+
"format as",
|
|
864
|
+
"structured",
|
|
865
|
+
// Chinese
|
|
866
|
+
"\u8868\u683C",
|
|
867
|
+
"\u683C\u5F0F\u5316\u4E3A",
|
|
868
|
+
"\u7ED3\u6784\u5316",
|
|
869
|
+
// Japanese
|
|
870
|
+
"\u30C6\u30FC\u30D6\u30EB",
|
|
871
|
+
"\u30D5\u30A9\u30FC\u30DE\u30C3\u30C8",
|
|
872
|
+
"\u69CB\u9020\u5316",
|
|
873
|
+
// Russian
|
|
874
|
+
"\u0442\u0430\u0431\u043B\u0438\u0446\u0430",
|
|
875
|
+
"\u0444\u043E\u0440\u043C\u0430\u0442\u0438\u0440\u043E\u0432\u0430\u0442\u044C \u043A\u0430\u043A",
|
|
876
|
+
"\u0441\u0442\u0440\u0443\u043A\u0442\u0443\u0440\u0438\u0440\u043E\u0432\u0430\u043D\u043D\u044B\u0439",
|
|
877
|
+
// German
|
|
878
|
+
"tabelle",
|
|
879
|
+
"formatieren als",
|
|
880
|
+
"strukturiert"
|
|
881
|
+
],
|
|
882
|
+
referenceKeywords: [
|
|
883
|
+
// English
|
|
884
|
+
"above",
|
|
885
|
+
"below",
|
|
886
|
+
"previous",
|
|
887
|
+
"following",
|
|
888
|
+
"the docs",
|
|
889
|
+
"the api",
|
|
890
|
+
"the code",
|
|
891
|
+
"earlier",
|
|
892
|
+
"attached",
|
|
893
|
+
// Chinese
|
|
894
|
+
"\u4E0A\u9762",
|
|
895
|
+
"\u4E0B\u9762",
|
|
896
|
+
"\u4E4B\u524D",
|
|
897
|
+
"\u63A5\u4E0B\u6765",
|
|
898
|
+
"\u6587\u6863",
|
|
899
|
+
"\u4EE3\u7801",
|
|
900
|
+
"\u9644\u4EF6",
|
|
901
|
+
// Japanese
|
|
902
|
+
"\u4E0A\u8A18",
|
|
903
|
+
"\u4E0B\u8A18",
|
|
904
|
+
"\u524D\u306E",
|
|
905
|
+
"\u6B21\u306E",
|
|
906
|
+
"\u30C9\u30AD\u30E5\u30E1\u30F3\u30C8",
|
|
907
|
+
"\u30B3\u30FC\u30C9",
|
|
908
|
+
// Russian
|
|
909
|
+
"\u0432\u044B\u0448\u0435",
|
|
910
|
+
"\u043D\u0438\u0436\u0435",
|
|
911
|
+
"\u043F\u0440\u0435\u0434\u044B\u0434\u0443\u0449\u0438\u0439",
|
|
912
|
+
"\u0441\u043B\u0435\u0434\u0443\u044E\u0449\u0438\u0439",
|
|
913
|
+
"\u0434\u043E\u043A\u0443\u043C\u0435\u043D\u0442\u0430\u0446\u0438\u044F",
|
|
914
|
+
"\u043A\u043E\u0434",
|
|
915
|
+
"\u0440\u0430\u043D\u0435\u0435",
|
|
916
|
+
"\u0432\u043B\u043E\u0436\u0435\u043D\u0438\u0435",
|
|
917
|
+
// German
|
|
918
|
+
"oben",
|
|
919
|
+
"unten",
|
|
920
|
+
"vorherige",
|
|
921
|
+
"folgende",
|
|
922
|
+
"dokumentation",
|
|
923
|
+
"der code",
|
|
924
|
+
"fr\xFCher",
|
|
925
|
+
"anhang"
|
|
926
|
+
],
|
|
927
|
+
negationKeywords: [
|
|
928
|
+
// English
|
|
929
|
+
"don't",
|
|
930
|
+
"do not",
|
|
931
|
+
"avoid",
|
|
932
|
+
"never",
|
|
933
|
+
"without",
|
|
934
|
+
"except",
|
|
935
|
+
"exclude",
|
|
936
|
+
"no longer",
|
|
937
|
+
// Chinese
|
|
938
|
+
"\u4E0D\u8981",
|
|
939
|
+
"\u907F\u514D",
|
|
940
|
+
"\u4ECE\u4E0D",
|
|
941
|
+
"\u6CA1\u6709",
|
|
942
|
+
"\u9664\u4E86",
|
|
943
|
+
"\u6392\u9664",
|
|
944
|
+
// Japanese
|
|
945
|
+
"\u3057\u306A\u3044\u3067",
|
|
946
|
+
"\u907F\u3051\u308B",
|
|
947
|
+
"\u6C7A\u3057\u3066",
|
|
948
|
+
"\u306A\u3057\u3067",
|
|
949
|
+
"\u9664\u304F",
|
|
950
|
+
// Russian
|
|
951
|
+
"\u043D\u0435 \u0434\u0435\u043B\u0430\u0439",
|
|
952
|
+
"\u043D\u0435 \u043D\u0430\u0434\u043E",
|
|
953
|
+
"\u043D\u0435\u043B\u044C\u0437\u044F",
|
|
954
|
+
"\u0438\u0437\u0431\u0435\u0433\u0430\u0442\u044C",
|
|
955
|
+
"\u043D\u0438\u043A\u043E\u0433\u0434\u0430",
|
|
956
|
+
"\u0431\u0435\u0437",
|
|
957
|
+
"\u043A\u0440\u043E\u043C\u0435",
|
|
958
|
+
"\u0438\u0441\u043A\u043B\u044E\u0447\u0438\u0442\u044C",
|
|
959
|
+
"\u0431\u043E\u043B\u044C\u0448\u0435 \u043D\u0435",
|
|
960
|
+
// German
|
|
961
|
+
"nicht",
|
|
962
|
+
"vermeide",
|
|
963
|
+
"niemals",
|
|
964
|
+
"ohne",
|
|
965
|
+
"au\xDFer",
|
|
966
|
+
"ausschlie\xDFen",
|
|
967
|
+
"nicht mehr"
|
|
968
|
+
],
|
|
969
|
+
domainSpecificKeywords: [
|
|
970
|
+
// English
|
|
971
|
+
"quantum",
|
|
972
|
+
"fpga",
|
|
973
|
+
"vlsi",
|
|
974
|
+
"risc-v",
|
|
975
|
+
"asic",
|
|
976
|
+
"photonics",
|
|
977
|
+
"genomics",
|
|
978
|
+
"proteomics",
|
|
979
|
+
"topological",
|
|
980
|
+
"homomorphic",
|
|
981
|
+
"zero-knowledge",
|
|
982
|
+
"lattice-based",
|
|
983
|
+
// Chinese
|
|
984
|
+
"\u91CF\u5B50",
|
|
985
|
+
"\u5149\u5B50\u5B66",
|
|
986
|
+
"\u57FA\u56E0\u7EC4\u5B66",
|
|
987
|
+
"\u86CB\u767D\u8D28\u7EC4\u5B66",
|
|
988
|
+
"\u62D3\u6251",
|
|
989
|
+
"\u540C\u6001",
|
|
990
|
+
"\u96F6\u77E5\u8BC6",
|
|
991
|
+
"\u683C\u5BC6\u7801",
|
|
992
|
+
// Japanese
|
|
993
|
+
"\u91CF\u5B50",
|
|
994
|
+
"\u30D5\u30A9\u30C8\u30CB\u30AF\u30B9",
|
|
995
|
+
"\u30B2\u30CE\u30DF\u30AF\u30B9",
|
|
996
|
+
"\u30C8\u30DD\u30ED\u30B8\u30AB\u30EB",
|
|
997
|
+
// Russian
|
|
998
|
+
"\u043A\u0432\u0430\u043D\u0442\u043E\u0432\u044B\u0439",
|
|
999
|
+
"\u0444\u043E\u0442\u043E\u043D\u0438\u043A\u0430",
|
|
1000
|
+
"\u0433\u0435\u043D\u043E\u043C\u0438\u043A\u0430",
|
|
1001
|
+
"\u043F\u0440\u043E\u0442\u0435\u043E\u043C\u0438\u043A\u0430",
|
|
1002
|
+
"\u0442\u043E\u043F\u043E\u043B\u043E\u0433\u0438\u0447\u0435\u0441\u043A\u0438\u0439",
|
|
1003
|
+
"\u0433\u043E\u043C\u043E\u043C\u043E\u0440\u0444\u043D\u044B\u0439",
|
|
1004
|
+
"\u0441 \u043D\u0443\u043B\u0435\u0432\u044B\u043C \u0440\u0430\u0437\u0433\u043B\u0430\u0448\u0435\u043D\u0438\u0435\u043C",
|
|
1005
|
+
"\u043D\u0430 \u043E\u0441\u043D\u043E\u0432\u0435 \u0440\u0435\u0448\u0451\u0442\u043E\u043A",
|
|
1006
|
+
// German
|
|
1007
|
+
"quanten",
|
|
1008
|
+
"photonik",
|
|
1009
|
+
"genomik",
|
|
1010
|
+
"proteomik",
|
|
1011
|
+
"topologisch",
|
|
1012
|
+
"homomorph",
|
|
1013
|
+
"zero-knowledge",
|
|
1014
|
+
"gitterbasiert"
|
|
1015
|
+
],
|
|
1016
|
+
// Agentic task keywords - file ops, execution, multi-step, iterative work
|
|
1017
|
+
// Pruned: removed overly common words like "then", "first", "run", "test", "build"
|
|
1018
|
+
agenticTaskKeywords: [
|
|
1019
|
+
// English - File operations (clearly agentic)
|
|
1020
|
+
"read file",
|
|
1021
|
+
"read the file",
|
|
1022
|
+
"look at",
|
|
1023
|
+
"check the",
|
|
1024
|
+
"open the",
|
|
1025
|
+
"edit",
|
|
1026
|
+
"modify",
|
|
1027
|
+
"update the",
|
|
1028
|
+
"change the",
|
|
1029
|
+
"write to",
|
|
1030
|
+
"create file",
|
|
1031
|
+
// English - Execution (specific commands only)
|
|
1032
|
+
"execute",
|
|
1033
|
+
"deploy",
|
|
1034
|
+
"install",
|
|
1035
|
+
"npm",
|
|
1036
|
+
"pip",
|
|
1037
|
+
"compile",
|
|
1038
|
+
// English - Multi-step patterns (specific only)
|
|
1039
|
+
"after that",
|
|
1040
|
+
"and also",
|
|
1041
|
+
"once done",
|
|
1042
|
+
"step 1",
|
|
1043
|
+
"step 2",
|
|
1044
|
+
// English - Iterative work
|
|
1045
|
+
"fix",
|
|
1046
|
+
"debug",
|
|
1047
|
+
"until it works",
|
|
1048
|
+
"keep trying",
|
|
1049
|
+
"iterate",
|
|
1050
|
+
"make sure",
|
|
1051
|
+
"verify",
|
|
1052
|
+
"confirm",
|
|
1053
|
+
// Chinese (keep specific ones)
|
|
1054
|
+
"\u8BFB\u53D6\u6587\u4EF6",
|
|
1055
|
+
"\u67E5\u770B",
|
|
1056
|
+
"\u6253\u5F00",
|
|
1057
|
+
"\u7F16\u8F91",
|
|
1058
|
+
"\u4FEE\u6539",
|
|
1059
|
+
"\u66F4\u65B0",
|
|
1060
|
+
"\u521B\u5EFA",
|
|
1061
|
+
"\u6267\u884C",
|
|
1062
|
+
"\u90E8\u7F72",
|
|
1063
|
+
"\u5B89\u88C5",
|
|
1064
|
+
"\u7B2C\u4E00\u6B65",
|
|
1065
|
+
"\u7B2C\u4E8C\u6B65",
|
|
1066
|
+
"\u4FEE\u590D",
|
|
1067
|
+
"\u8C03\u8BD5",
|
|
1068
|
+
"\u76F4\u5230",
|
|
1069
|
+
"\u786E\u8BA4",
|
|
1070
|
+
"\u9A8C\u8BC1"
|
|
1071
|
+
],
|
|
1072
|
+
// Dimension weights (sum to 1.0)
|
|
1073
|
+
dimensionWeights: {
|
|
1074
|
+
tokenCount: 0.08,
|
|
1075
|
+
codePresence: 0.15,
|
|
1076
|
+
reasoningMarkers: 0.18,
|
|
1077
|
+
technicalTerms: 0.1,
|
|
1078
|
+
creativeMarkers: 0.05,
|
|
1079
|
+
simpleIndicators: 0.02,
|
|
1080
|
+
// Reduced from 0.12 to make room for agenticTask
|
|
1081
|
+
multiStepPatterns: 0.12,
|
|
1082
|
+
questionComplexity: 0.05,
|
|
1083
|
+
imperativeVerbs: 0.03,
|
|
1084
|
+
constraintCount: 0.04,
|
|
1085
|
+
outputFormat: 0.03,
|
|
1086
|
+
referenceComplexity: 0.02,
|
|
1087
|
+
negationComplexity: 0.01,
|
|
1088
|
+
domainSpecificity: 0.02,
|
|
1089
|
+
agenticTask: 0.04
|
|
1090
|
+
// Reduced - agentic signals influence tier selection, not dominate it
|
|
1091
|
+
},
|
|
1092
|
+
// Tier boundaries on weighted score axis
|
|
1093
|
+
tierBoundaries: {
|
|
1094
|
+
simpleMedium: 0,
|
|
1095
|
+
mediumComplex: 0.18,
|
|
1096
|
+
complexReasoning: 0.4
|
|
1097
|
+
// Raised from 0.25 - requires strong reasoning signals
|
|
1098
|
+
},
|
|
1099
|
+
// Sigmoid steepness for confidence calibration
|
|
1100
|
+
confidenceSteepness: 12,
|
|
1101
|
+
// Below this confidence → ambiguous (null tier)
|
|
1102
|
+
confidenceThreshold: 0.7
|
|
1103
|
+
},
|
|
1104
|
+
tiers: {
|
|
1105
|
+
SIMPLE: {
|
|
1106
|
+
primary: "google/gemini-2.5-flash",
|
|
1107
|
+
fallback: ["nvidia/gpt-oss-120b", "deepseek/deepseek-chat", "openai/gpt-4o-mini"]
|
|
1108
|
+
},
|
|
1109
|
+
MEDIUM: {
|
|
1110
|
+
primary: "xai/grok-code-fast-1",
|
|
1111
|
+
// Code specialist, $0.20/$1.50
|
|
1112
|
+
fallback: [
|
|
1113
|
+
"deepseek/deepseek-chat",
|
|
1114
|
+
"xai/grok-4-fast-non-reasoning",
|
|
1115
|
+
"google/gemini-2.5-flash"
|
|
1116
|
+
]
|
|
1117
|
+
},
|
|
1118
|
+
COMPLEX: {
|
|
1119
|
+
primary: "google/gemini-2.5-pro",
|
|
1120
|
+
fallback: ["anthropic/claude-sonnet-4", "xai/grok-4-0709", "openai/gpt-4o"]
|
|
1121
|
+
},
|
|
1122
|
+
REASONING: {
|
|
1123
|
+
primary: "xai/grok-4-fast-reasoning",
|
|
1124
|
+
// Ultra-cheap reasoning $0.20/$0.50
|
|
1125
|
+
fallback: ["deepseek/deepseek-reasoner", "moonshot/kimi-k2.5", "google/gemini-2.5-pro"]
|
|
1126
|
+
}
|
|
1127
|
+
},
|
|
1128
|
+
// Agentic tier configs - models that excel at multi-step autonomous tasks
|
|
1129
|
+
agenticTiers: {
|
|
1130
|
+
SIMPLE: {
|
|
1131
|
+
primary: "anthropic/claude-haiku-4.5",
|
|
1132
|
+
fallback: ["moonshot/kimi-k2.5", "xai/grok-4-fast-non-reasoning", "openai/gpt-4o-mini"]
|
|
1133
|
+
},
|
|
1134
|
+
MEDIUM: {
|
|
1135
|
+
primary: "xai/grok-code-fast-1",
|
|
1136
|
+
// Code specialist for agentic coding
|
|
1137
|
+
fallback: ["moonshot/kimi-k2.5", "anthropic/claude-haiku-4.5", "anthropic/claude-sonnet-4"]
|
|
1138
|
+
},
|
|
1139
|
+
COMPLEX: {
|
|
1140
|
+
primary: "anthropic/claude-sonnet-4",
|
|
1141
|
+
fallback: ["anthropic/claude-opus-4", "xai/grok-4-0709", "openai/gpt-4o"]
|
|
1142
|
+
},
|
|
1143
|
+
REASONING: {
|
|
1144
|
+
primary: "anthropic/claude-sonnet-4",
|
|
1145
|
+
// Strong tool use + reasoning for agentic tasks
|
|
1146
|
+
fallback: ["xai/grok-4-fast-reasoning", "moonshot/kimi-k2.5", "deepseek/deepseek-reasoner"]
|
|
1147
|
+
}
|
|
1148
|
+
},
|
|
1149
|
+
overrides: {
|
|
1150
|
+
maxTokensForceComplex: 1e5,
|
|
1151
|
+
structuredOutputMinTier: "MEDIUM",
|
|
1152
|
+
ambiguousDefaultTier: "MEDIUM",
|
|
1153
|
+
agenticMode: false
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
// src/router/index.ts
|
|
1158
|
+
function route(prompt, systemPrompt, maxOutputTokens, options) {
|
|
1159
|
+
const { config, modelPricing } = options;
|
|
1160
|
+
const fullText = `${systemPrompt ?? ""} ${prompt}`;
|
|
1161
|
+
const estimatedTokens = Math.ceil(fullText.length / 4);
|
|
1162
|
+
const ruleResult = classifyByRules(prompt, systemPrompt, estimatedTokens, config.scoring);
|
|
1163
|
+
const agenticScore = ruleResult.agenticScore ?? 0;
|
|
1164
|
+
const isAutoAgentic = agenticScore >= 0.6;
|
|
1165
|
+
const isExplicitAgentic = config.overrides.agenticMode ?? false;
|
|
1166
|
+
const useAgenticTiers = (isAutoAgentic || isExplicitAgentic) && config.agenticTiers != null;
|
|
1167
|
+
const tierConfigs = useAgenticTiers ? config.agenticTiers : config.tiers;
|
|
1168
|
+
if (estimatedTokens > config.overrides.maxTokensForceComplex) {
|
|
1169
|
+
return selectModel(
|
|
1170
|
+
"COMPLEX",
|
|
1171
|
+
0.95,
|
|
1172
|
+
"rules",
|
|
1173
|
+
`Input exceeds ${config.overrides.maxTokensForceComplex} tokens${useAgenticTiers ? " | agentic" : ""}`,
|
|
1174
|
+
tierConfigs,
|
|
1175
|
+
modelPricing,
|
|
1176
|
+
estimatedTokens,
|
|
1177
|
+
maxOutputTokens
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
const hasStructuredOutput = systemPrompt ? /json|structured|schema/i.test(systemPrompt) : false;
|
|
1181
|
+
let tier;
|
|
1182
|
+
let confidence;
|
|
1183
|
+
const method = "rules";
|
|
1184
|
+
let reasoning = `score=${ruleResult.score.toFixed(2)} | ${ruleResult.signals.join(", ")}`;
|
|
1185
|
+
if (ruleResult.tier !== null) {
|
|
1186
|
+
tier = ruleResult.tier;
|
|
1187
|
+
confidence = ruleResult.confidence;
|
|
1188
|
+
} else {
|
|
1189
|
+
tier = config.overrides.ambiguousDefaultTier;
|
|
1190
|
+
confidence = 0.5;
|
|
1191
|
+
reasoning += ` | ambiguous -> default: ${tier}`;
|
|
1192
|
+
}
|
|
1193
|
+
if (hasStructuredOutput) {
|
|
1194
|
+
const tierRank = { SIMPLE: 0, MEDIUM: 1, COMPLEX: 2, REASONING: 3 };
|
|
1195
|
+
const minTier = config.overrides.structuredOutputMinTier;
|
|
1196
|
+
if (tierRank[tier] < tierRank[minTier]) {
|
|
1197
|
+
reasoning += ` | upgraded to ${minTier} (structured output)`;
|
|
1198
|
+
tier = minTier;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
if (isAutoAgentic) {
|
|
1202
|
+
reasoning += " | auto-agentic";
|
|
1203
|
+
} else if (isExplicitAgentic) {
|
|
1204
|
+
reasoning += " | agentic";
|
|
1205
|
+
}
|
|
1206
|
+
return selectModel(
|
|
1207
|
+
tier,
|
|
1208
|
+
confidence,
|
|
1209
|
+
method,
|
|
1210
|
+
reasoning,
|
|
1211
|
+
tierConfigs,
|
|
1212
|
+
modelPricing,
|
|
1213
|
+
estimatedTokens,
|
|
1214
|
+
maxOutputTokens
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
// src/models.ts
|
|
1219
|
+
var MODEL_ALIASES = {
|
|
1220
|
+
// Claude
|
|
1221
|
+
claude: "anthropic/claude-sonnet-4",
|
|
1222
|
+
sonnet: "anthropic/claude-sonnet-4",
|
|
1223
|
+
opus: "anthropic/claude-opus-4",
|
|
1224
|
+
haiku: "anthropic/claude-haiku-4.5",
|
|
1225
|
+
// OpenAI
|
|
1226
|
+
gpt: "openai/gpt-4o",
|
|
1227
|
+
gpt4: "openai/gpt-4o",
|
|
1228
|
+
gpt5: "openai/gpt-5.2",
|
|
1229
|
+
mini: "openai/gpt-4o-mini",
|
|
1230
|
+
o3: "openai/o3",
|
|
1231
|
+
// DeepSeek
|
|
1232
|
+
deepseek: "deepseek/deepseek-chat",
|
|
1233
|
+
reasoner: "deepseek/deepseek-reasoner",
|
|
1234
|
+
// Kimi / Moonshot
|
|
1235
|
+
kimi: "moonshot/kimi-k2.5",
|
|
1236
|
+
// Google
|
|
1237
|
+
gemini: "google/gemini-2.5-pro",
|
|
1238
|
+
flash: "google/gemini-2.5-flash",
|
|
1239
|
+
// xAI
|
|
1240
|
+
grok: "xai/grok-3",
|
|
1241
|
+
"grok-fast": "xai/grok-4-fast-reasoning",
|
|
1242
|
+
"grok-code": "xai/grok-code-fast-1",
|
|
1243
|
+
// NVIDIA (free)
|
|
1244
|
+
nvidia: "nvidia/gpt-oss-120b",
|
|
1245
|
+
"gpt-120b": "nvidia/gpt-oss-120b",
|
|
1246
|
+
"gpt-20b": "nvidia/gpt-oss-20b",
|
|
1247
|
+
free: "nvidia/gpt-oss-120b"
|
|
1248
|
+
};
|
|
1249
|
+
function resolveModelAlias(model) {
|
|
1250
|
+
const normalized = model.trim().toLowerCase();
|
|
1251
|
+
const resolved = MODEL_ALIASES[normalized];
|
|
1252
|
+
if (resolved) return resolved;
|
|
1253
|
+
if (normalized.startsWith("blockrun/")) {
|
|
1254
|
+
const withoutPrefix = normalized.slice("blockrun/".length);
|
|
1255
|
+
const resolvedWithoutPrefix = MODEL_ALIASES[withoutPrefix];
|
|
1256
|
+
if (resolvedWithoutPrefix) return resolvedWithoutPrefix;
|
|
1257
|
+
}
|
|
1258
|
+
return model;
|
|
1259
|
+
}
|
|
1260
|
+
var BLOCKRUN_MODELS = [
|
|
1261
|
+
// Smart routing meta-model — proxy replaces with actual model
|
|
1262
|
+
// NOTE: Model IDs are WITHOUT provider prefix (OpenClaw adds "blockrun/" automatically)
|
|
1263
|
+
{
|
|
1264
|
+
id: "auto",
|
|
1265
|
+
name: "BlockRun Smart Router",
|
|
1266
|
+
inputPrice: 0,
|
|
1267
|
+
outputPrice: 0,
|
|
1268
|
+
contextWindow: 105e4,
|
|
1269
|
+
maxOutput: 128e3
|
|
1270
|
+
},
|
|
1271
|
+
// OpenAI GPT-5 Family
|
|
1272
|
+
{
|
|
1273
|
+
id: "openai/gpt-5.2",
|
|
1274
|
+
name: "GPT-5.2",
|
|
1275
|
+
inputPrice: 1.75,
|
|
1276
|
+
outputPrice: 14,
|
|
1277
|
+
contextWindow: 4e5,
|
|
1278
|
+
maxOutput: 128e3,
|
|
1279
|
+
reasoning: true,
|
|
1280
|
+
vision: true,
|
|
1281
|
+
agentic: true
|
|
1282
|
+
},
|
|
1283
|
+
{
|
|
1284
|
+
id: "openai/gpt-5-mini",
|
|
1285
|
+
name: "GPT-5 Mini",
|
|
1286
|
+
inputPrice: 0.25,
|
|
1287
|
+
outputPrice: 2,
|
|
1288
|
+
contextWindow: 2e5,
|
|
1289
|
+
maxOutput: 65536
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
id: "openai/gpt-5-nano",
|
|
1293
|
+
name: "GPT-5 Nano",
|
|
1294
|
+
inputPrice: 0.05,
|
|
1295
|
+
outputPrice: 0.4,
|
|
1296
|
+
contextWindow: 128e3,
|
|
1297
|
+
maxOutput: 32768
|
|
1298
|
+
},
|
|
1299
|
+
{
|
|
1300
|
+
id: "openai/gpt-5.2-pro",
|
|
1301
|
+
name: "GPT-5.2 Pro",
|
|
1302
|
+
inputPrice: 21,
|
|
1303
|
+
outputPrice: 168,
|
|
1304
|
+
contextWindow: 4e5,
|
|
1305
|
+
maxOutput: 128e3,
|
|
1306
|
+
reasoning: true
|
|
1307
|
+
},
|
|
1308
|
+
// OpenAI GPT-4 Family
|
|
1309
|
+
{
|
|
1310
|
+
id: "openai/gpt-4.1",
|
|
1311
|
+
name: "GPT-4.1",
|
|
1312
|
+
inputPrice: 2,
|
|
1313
|
+
outputPrice: 8,
|
|
1314
|
+
contextWindow: 128e3,
|
|
1315
|
+
maxOutput: 16384,
|
|
1316
|
+
vision: true
|
|
1317
|
+
},
|
|
1318
|
+
{
|
|
1319
|
+
id: "openai/gpt-4.1-mini",
|
|
1320
|
+
name: "GPT-4.1 Mini",
|
|
1321
|
+
inputPrice: 0.4,
|
|
1322
|
+
outputPrice: 1.6,
|
|
1323
|
+
contextWindow: 128e3,
|
|
1324
|
+
maxOutput: 16384
|
|
1325
|
+
},
|
|
1326
|
+
{
|
|
1327
|
+
id: "openai/gpt-4.1-nano",
|
|
1328
|
+
name: "GPT-4.1 Nano",
|
|
1329
|
+
inputPrice: 0.1,
|
|
1330
|
+
outputPrice: 0.4,
|
|
1331
|
+
contextWindow: 128e3,
|
|
1332
|
+
maxOutput: 16384
|
|
1333
|
+
},
|
|
1334
|
+
{
|
|
1335
|
+
id: "openai/gpt-4o",
|
|
1336
|
+
name: "GPT-4o",
|
|
1337
|
+
inputPrice: 2.5,
|
|
1338
|
+
outputPrice: 10,
|
|
1339
|
+
contextWindow: 128e3,
|
|
1340
|
+
maxOutput: 16384,
|
|
1341
|
+
vision: true,
|
|
1342
|
+
agentic: true
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
id: "openai/gpt-4o-mini",
|
|
1346
|
+
name: "GPT-4o Mini",
|
|
1347
|
+
inputPrice: 0.15,
|
|
1348
|
+
outputPrice: 0.6,
|
|
1349
|
+
contextWindow: 128e3,
|
|
1350
|
+
maxOutput: 16384
|
|
1351
|
+
},
|
|
1352
|
+
// OpenAI O-series (Reasoning)
|
|
1353
|
+
{
|
|
1354
|
+
id: "openai/o1",
|
|
1355
|
+
name: "o1",
|
|
1356
|
+
inputPrice: 15,
|
|
1357
|
+
outputPrice: 60,
|
|
1358
|
+
contextWindow: 2e5,
|
|
1359
|
+
maxOutput: 1e5,
|
|
1360
|
+
reasoning: true
|
|
1361
|
+
},
|
|
1362
|
+
{
|
|
1363
|
+
id: "openai/o1-mini",
|
|
1364
|
+
name: "o1-mini",
|
|
1365
|
+
inputPrice: 1.1,
|
|
1366
|
+
outputPrice: 4.4,
|
|
1367
|
+
contextWindow: 128e3,
|
|
1368
|
+
maxOutput: 65536,
|
|
1369
|
+
reasoning: true
|
|
1370
|
+
},
|
|
1371
|
+
{
|
|
1372
|
+
id: "openai/o3",
|
|
1373
|
+
name: "o3",
|
|
1374
|
+
inputPrice: 2,
|
|
1375
|
+
outputPrice: 8,
|
|
1376
|
+
contextWindow: 2e5,
|
|
1377
|
+
maxOutput: 1e5,
|
|
1378
|
+
reasoning: true
|
|
1379
|
+
},
|
|
1380
|
+
{
|
|
1381
|
+
id: "openai/o3-mini",
|
|
1382
|
+
name: "o3-mini",
|
|
1383
|
+
inputPrice: 1.1,
|
|
1384
|
+
outputPrice: 4.4,
|
|
1385
|
+
contextWindow: 128e3,
|
|
1386
|
+
maxOutput: 65536,
|
|
1387
|
+
reasoning: true
|
|
1388
|
+
},
|
|
1389
|
+
{
|
|
1390
|
+
id: "openai/o4-mini",
|
|
1391
|
+
name: "o4-mini",
|
|
1392
|
+
inputPrice: 1.1,
|
|
1393
|
+
outputPrice: 4.4,
|
|
1394
|
+
contextWindow: 128e3,
|
|
1395
|
+
maxOutput: 65536,
|
|
1396
|
+
reasoning: true
|
|
1397
|
+
},
|
|
1398
|
+
// Anthropic - all Claude models excel at agentic workflows
|
|
1399
|
+
{
|
|
1400
|
+
id: "anthropic/claude-haiku-4.5",
|
|
1401
|
+
name: "Claude Haiku 4.5",
|
|
1402
|
+
inputPrice: 1,
|
|
1403
|
+
outputPrice: 5,
|
|
1404
|
+
contextWindow: 2e5,
|
|
1405
|
+
maxOutput: 8192,
|
|
1406
|
+
agentic: true
|
|
1407
|
+
},
|
|
1408
|
+
{
|
|
1409
|
+
id: "anthropic/claude-sonnet-4",
|
|
1410
|
+
name: "Claude Sonnet 4",
|
|
1411
|
+
inputPrice: 3,
|
|
1412
|
+
outputPrice: 15,
|
|
1413
|
+
contextWindow: 2e5,
|
|
1414
|
+
maxOutput: 64e3,
|
|
1415
|
+
reasoning: true,
|
|
1416
|
+
agentic: true
|
|
1417
|
+
},
|
|
1418
|
+
{
|
|
1419
|
+
id: "anthropic/claude-opus-4",
|
|
1420
|
+
name: "Claude Opus 4",
|
|
1421
|
+
inputPrice: 15,
|
|
1422
|
+
outputPrice: 75,
|
|
1423
|
+
contextWindow: 2e5,
|
|
1424
|
+
maxOutput: 32e3,
|
|
1425
|
+
reasoning: true,
|
|
1426
|
+
agentic: true
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
id: "anthropic/claude-opus-4.5",
|
|
1430
|
+
name: "Claude Opus 4.5",
|
|
1431
|
+
inputPrice: 5,
|
|
1432
|
+
outputPrice: 25,
|
|
1433
|
+
contextWindow: 2e5,
|
|
1434
|
+
maxOutput: 32e3,
|
|
1435
|
+
reasoning: true,
|
|
1436
|
+
agentic: true
|
|
1437
|
+
},
|
|
1438
|
+
// Google
|
|
1439
|
+
{
|
|
1440
|
+
id: "google/gemini-3-pro-preview",
|
|
1441
|
+
name: "Gemini 3 Pro Preview",
|
|
1442
|
+
inputPrice: 2,
|
|
1443
|
+
outputPrice: 12,
|
|
1444
|
+
contextWindow: 105e4,
|
|
1445
|
+
maxOutput: 65536,
|
|
1446
|
+
reasoning: true,
|
|
1447
|
+
vision: true
|
|
1448
|
+
},
|
|
1449
|
+
{
|
|
1450
|
+
id: "google/gemini-2.5-pro",
|
|
1451
|
+
name: "Gemini 2.5 Pro",
|
|
1452
|
+
inputPrice: 1.25,
|
|
1453
|
+
outputPrice: 10,
|
|
1454
|
+
contextWindow: 105e4,
|
|
1455
|
+
maxOutput: 65536,
|
|
1456
|
+
reasoning: true,
|
|
1457
|
+
vision: true
|
|
1458
|
+
},
|
|
1459
|
+
{
|
|
1460
|
+
id: "google/gemini-2.5-flash",
|
|
1461
|
+
name: "Gemini 2.5 Flash",
|
|
1462
|
+
inputPrice: 0.15,
|
|
1463
|
+
outputPrice: 0.6,
|
|
1464
|
+
contextWindow: 1e6,
|
|
1465
|
+
maxOutput: 65536
|
|
1466
|
+
},
|
|
1467
|
+
// DeepSeek
|
|
1468
|
+
{
|
|
1469
|
+
id: "deepseek/deepseek-chat",
|
|
1470
|
+
name: "DeepSeek V3.2 Chat",
|
|
1471
|
+
inputPrice: 0.28,
|
|
1472
|
+
outputPrice: 0.42,
|
|
1473
|
+
contextWindow: 128e3,
|
|
1474
|
+
maxOutput: 8192
|
|
1475
|
+
},
|
|
1476
|
+
{
|
|
1477
|
+
id: "deepseek/deepseek-reasoner",
|
|
1478
|
+
name: "DeepSeek V3.2 Reasoner",
|
|
1479
|
+
inputPrice: 0.28,
|
|
1480
|
+
outputPrice: 0.42,
|
|
1481
|
+
contextWindow: 128e3,
|
|
1482
|
+
maxOutput: 8192,
|
|
1483
|
+
reasoning: true
|
|
1484
|
+
},
|
|
1485
|
+
// Moonshot / Kimi - optimized for agentic workflows
|
|
1486
|
+
{
|
|
1487
|
+
id: "moonshot/kimi-k2.5",
|
|
1488
|
+
name: "Kimi K2.5",
|
|
1489
|
+
inputPrice: 0.5,
|
|
1490
|
+
outputPrice: 2.4,
|
|
1491
|
+
contextWindow: 262144,
|
|
1492
|
+
maxOutput: 8192,
|
|
1493
|
+
reasoning: true,
|
|
1494
|
+
vision: true,
|
|
1495
|
+
agentic: true
|
|
1496
|
+
},
|
|
1497
|
+
// xAI / Grok
|
|
1498
|
+
{
|
|
1499
|
+
id: "xai/grok-3",
|
|
1500
|
+
name: "Grok 3",
|
|
1501
|
+
inputPrice: 3,
|
|
1502
|
+
outputPrice: 15,
|
|
1503
|
+
contextWindow: 131072,
|
|
1504
|
+
maxOutput: 16384,
|
|
1505
|
+
reasoning: true
|
|
1506
|
+
},
|
|
1507
|
+
{
|
|
1508
|
+
id: "xai/grok-3-fast",
|
|
1509
|
+
name: "Grok 3 Fast",
|
|
1510
|
+
inputPrice: 5,
|
|
1511
|
+
outputPrice: 25,
|
|
1512
|
+
contextWindow: 131072,
|
|
1513
|
+
maxOutput: 16384,
|
|
1514
|
+
reasoning: true
|
|
1515
|
+
},
|
|
1516
|
+
{
|
|
1517
|
+
id: "xai/grok-3-mini",
|
|
1518
|
+
name: "Grok 3 Mini",
|
|
1519
|
+
inputPrice: 0.3,
|
|
1520
|
+
outputPrice: 0.5,
|
|
1521
|
+
contextWindow: 131072,
|
|
1522
|
+
maxOutput: 16384
|
|
1523
|
+
},
|
|
1524
|
+
// xAI Grok 4 Family - Ultra-cheap fast models
|
|
1525
|
+
{
|
|
1526
|
+
id: "xai/grok-4-fast-reasoning",
|
|
1527
|
+
name: "Grok 4 Fast Reasoning",
|
|
1528
|
+
inputPrice: 0.2,
|
|
1529
|
+
outputPrice: 0.5,
|
|
1530
|
+
contextWindow: 131072,
|
|
1531
|
+
maxOutput: 16384,
|
|
1532
|
+
reasoning: true
|
|
1533
|
+
},
|
|
1534
|
+
{
|
|
1535
|
+
id: "xai/grok-4-fast-non-reasoning",
|
|
1536
|
+
name: "Grok 4 Fast",
|
|
1537
|
+
inputPrice: 0.2,
|
|
1538
|
+
outputPrice: 0.5,
|
|
1539
|
+
contextWindow: 131072,
|
|
1540
|
+
maxOutput: 16384
|
|
1541
|
+
},
|
|
1542
|
+
{
|
|
1543
|
+
id: "xai/grok-4-1-fast-reasoning",
|
|
1544
|
+
name: "Grok 4.1 Fast Reasoning",
|
|
1545
|
+
inputPrice: 0.2,
|
|
1546
|
+
outputPrice: 0.5,
|
|
1547
|
+
contextWindow: 131072,
|
|
1548
|
+
maxOutput: 16384,
|
|
1549
|
+
reasoning: true
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
id: "xai/grok-4-1-fast-non-reasoning",
|
|
1553
|
+
name: "Grok 4.1 Fast",
|
|
1554
|
+
inputPrice: 0.2,
|
|
1555
|
+
outputPrice: 0.5,
|
|
1556
|
+
contextWindow: 131072,
|
|
1557
|
+
maxOutput: 16384
|
|
1558
|
+
},
|
|
1559
|
+
{
|
|
1560
|
+
id: "xai/grok-code-fast-1",
|
|
1561
|
+
name: "Grok Code Fast",
|
|
1562
|
+
inputPrice: 0.2,
|
|
1563
|
+
outputPrice: 1.5,
|
|
1564
|
+
contextWindow: 131072,
|
|
1565
|
+
maxOutput: 16384,
|
|
1566
|
+
agentic: true
|
|
1567
|
+
// Good for coding tasks
|
|
1568
|
+
},
|
|
1569
|
+
{
|
|
1570
|
+
id: "xai/grok-4-0709",
|
|
1571
|
+
name: "Grok 4 (0709)",
|
|
1572
|
+
inputPrice: 3,
|
|
1573
|
+
outputPrice: 15,
|
|
1574
|
+
contextWindow: 131072,
|
|
1575
|
+
maxOutput: 16384,
|
|
1576
|
+
reasoning: true
|
|
1577
|
+
},
|
|
1578
|
+
{
|
|
1579
|
+
id: "xai/grok-2-vision",
|
|
1580
|
+
name: "Grok 2 Vision",
|
|
1581
|
+
inputPrice: 2,
|
|
1582
|
+
outputPrice: 10,
|
|
1583
|
+
contextWindow: 131072,
|
|
1584
|
+
maxOutput: 16384,
|
|
1585
|
+
vision: true
|
|
1586
|
+
},
|
|
1587
|
+
// NVIDIA - Free/cheap models
|
|
1588
|
+
{
|
|
1589
|
+
id: "nvidia/gpt-oss-120b",
|
|
1590
|
+
name: "NVIDIA GPT-OSS 120B",
|
|
1591
|
+
inputPrice: 0,
|
|
1592
|
+
outputPrice: 0,
|
|
1593
|
+
contextWindow: 128e3,
|
|
1594
|
+
maxOutput: 16384
|
|
1595
|
+
},
|
|
1596
|
+
{
|
|
1597
|
+
id: "nvidia/gpt-oss-20b",
|
|
1598
|
+
name: "NVIDIA GPT-OSS 20B",
|
|
1599
|
+
inputPrice: 0,
|
|
1600
|
+
outputPrice: 0,
|
|
1601
|
+
contextWindow: 128e3,
|
|
1602
|
+
maxOutput: 16384
|
|
1603
|
+
},
|
|
1604
|
+
{
|
|
1605
|
+
id: "nvidia/kimi-k2.5",
|
|
1606
|
+
name: "NVIDIA Kimi K2.5",
|
|
1607
|
+
inputPrice: 1e-3,
|
|
1608
|
+
outputPrice: 1e-3,
|
|
1609
|
+
contextWindow: 262144,
|
|
1610
|
+
maxOutput: 16384
|
|
1611
|
+
}
|
|
1612
|
+
];
|
|
1613
|
+
function toOpenClawModel(m) {
|
|
1614
|
+
return {
|
|
1615
|
+
id: m.id,
|
|
1616
|
+
name: m.name,
|
|
1617
|
+
api: "openai-completions",
|
|
1618
|
+
reasoning: m.reasoning ?? false,
|
|
1619
|
+
input: m.vision ? ["text", "image"] : ["text"],
|
|
1620
|
+
cost: {
|
|
1621
|
+
input: m.inputPrice,
|
|
1622
|
+
output: m.outputPrice,
|
|
1623
|
+
cacheRead: 0,
|
|
1624
|
+
cacheWrite: 0
|
|
1625
|
+
},
|
|
1626
|
+
contextWindow: m.contextWindow,
|
|
1627
|
+
maxTokens: m.maxOutput
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
var ALIAS_MODELS = Object.entries(MODEL_ALIASES).map(([alias, targetId]) => {
|
|
1631
|
+
const target = BLOCKRUN_MODELS.find((m) => m.id === targetId);
|
|
1632
|
+
if (!target) return null;
|
|
1633
|
+
return toOpenClawModel({ ...target, id: alias, name: `${alias} \u2192 ${target.name}` });
|
|
1634
|
+
}).filter((m) => m !== null);
|
|
1635
|
+
var OPENCLAW_MODELS = [
|
|
1636
|
+
...BLOCKRUN_MODELS.map(toOpenClawModel),
|
|
1637
|
+
...ALIAS_MODELS
|
|
1638
|
+
];
|
|
1639
|
+
function getModelContextWindow(modelId) {
|
|
1640
|
+
const normalized = modelId.replace("blockrun/", "");
|
|
1641
|
+
const model = BLOCKRUN_MODELS.find((m) => m.id === normalized);
|
|
1642
|
+
return model?.contextWindow;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
// src/logger.ts
|
|
1646
|
+
import { appendFile, mkdir } from "fs/promises";
|
|
1647
|
+
import { join } from "path";
|
|
1648
|
+
import { homedir } from "os";
|
|
1649
|
+
var LOG_DIR = join(homedir(), ".openclaw", "blockrun", "logs");
|
|
1650
|
+
var dirReady = false;
|
|
1651
|
+
async function ensureDir() {
|
|
1652
|
+
if (dirReady) return;
|
|
1653
|
+
await mkdir(LOG_DIR, { recursive: true });
|
|
1654
|
+
dirReady = true;
|
|
1655
|
+
}
|
|
1656
|
+
async function logUsage(entry) {
|
|
1657
|
+
try {
|
|
1658
|
+
await ensureDir();
|
|
1659
|
+
const date = entry.timestamp.slice(0, 10);
|
|
1660
|
+
const file = join(LOG_DIR, `usage-${date}.jsonl`);
|
|
1661
|
+
await appendFile(file, JSON.stringify(entry) + "\n");
|
|
1662
|
+
} catch {
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/stats.ts
|
|
1667
|
+
import { readFile, readdir } from "fs/promises";
|
|
1668
|
+
import { join as join2 } from "path";
|
|
1669
|
+
import { homedir as homedir2 } from "os";
|
|
1670
|
+
var LOG_DIR2 = join2(homedir2(), ".openclaw", "blockrun", "logs");
|
|
1671
|
+
async function parseLogFile(filePath) {
|
|
1672
|
+
try {
|
|
1673
|
+
const content = await readFile(filePath, "utf-8");
|
|
1674
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
1675
|
+
return lines.map((line) => {
|
|
1676
|
+
const entry = JSON.parse(line);
|
|
1677
|
+
return {
|
|
1678
|
+
timestamp: entry.timestamp || (/* @__PURE__ */ new Date()).toISOString(),
|
|
1679
|
+
model: entry.model || "unknown",
|
|
1680
|
+
tier: entry.tier || "UNKNOWN",
|
|
1681
|
+
cost: entry.cost || 0,
|
|
1682
|
+
baselineCost: entry.baselineCost || entry.cost || 0,
|
|
1683
|
+
savings: entry.savings || 0,
|
|
1684
|
+
latencyMs: entry.latencyMs || 0
|
|
1685
|
+
};
|
|
1686
|
+
});
|
|
1687
|
+
} catch {
|
|
1688
|
+
return [];
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
async function getLogFiles() {
|
|
1692
|
+
try {
|
|
1693
|
+
const files = await readdir(LOG_DIR2);
|
|
1694
|
+
return files.filter((f) => f.startsWith("usage-") && f.endsWith(".jsonl")).sort().reverse();
|
|
1695
|
+
} catch {
|
|
1696
|
+
return [];
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
function aggregateDay(date, entries) {
|
|
1700
|
+
const byTier = {};
|
|
1701
|
+
const byModel = {};
|
|
1702
|
+
let totalLatency = 0;
|
|
1703
|
+
for (const entry of entries) {
|
|
1704
|
+
if (!byTier[entry.tier]) byTier[entry.tier] = { count: 0, cost: 0 };
|
|
1705
|
+
byTier[entry.tier].count++;
|
|
1706
|
+
byTier[entry.tier].cost += entry.cost;
|
|
1707
|
+
if (!byModel[entry.model]) byModel[entry.model] = { count: 0, cost: 0 };
|
|
1708
|
+
byModel[entry.model].count++;
|
|
1709
|
+
byModel[entry.model].cost += entry.cost;
|
|
1710
|
+
totalLatency += entry.latencyMs;
|
|
1711
|
+
}
|
|
1712
|
+
const totalCost = entries.reduce((sum, e) => sum + e.cost, 0);
|
|
1713
|
+
const totalBaselineCost = entries.reduce((sum, e) => sum + e.baselineCost, 0);
|
|
1714
|
+
return {
|
|
1715
|
+
date,
|
|
1716
|
+
totalRequests: entries.length,
|
|
1717
|
+
totalCost,
|
|
1718
|
+
totalBaselineCost,
|
|
1719
|
+
totalSavings: totalBaselineCost - totalCost,
|
|
1720
|
+
avgLatencyMs: entries.length > 0 ? totalLatency / entries.length : 0,
|
|
1721
|
+
byTier,
|
|
1722
|
+
byModel
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
async function getStats(days = 7) {
|
|
1726
|
+
const logFiles = await getLogFiles();
|
|
1727
|
+
const filesToRead = logFiles.slice(0, days);
|
|
1728
|
+
const dailyBreakdown = [];
|
|
1729
|
+
const allByTier = {};
|
|
1730
|
+
const allByModel = {};
|
|
1731
|
+
let totalRequests = 0;
|
|
1732
|
+
let totalCost = 0;
|
|
1733
|
+
let totalBaselineCost = 0;
|
|
1734
|
+
let totalLatency = 0;
|
|
1735
|
+
for (const file of filesToRead) {
|
|
1736
|
+
const date = file.replace("usage-", "").replace(".jsonl", "");
|
|
1737
|
+
const filePath = join2(LOG_DIR2, file);
|
|
1738
|
+
const entries = await parseLogFile(filePath);
|
|
1739
|
+
if (entries.length === 0) continue;
|
|
1740
|
+
const dayStats = aggregateDay(date, entries);
|
|
1741
|
+
dailyBreakdown.push(dayStats);
|
|
1742
|
+
totalRequests += dayStats.totalRequests;
|
|
1743
|
+
totalCost += dayStats.totalCost;
|
|
1744
|
+
totalBaselineCost += dayStats.totalBaselineCost;
|
|
1745
|
+
totalLatency += dayStats.avgLatencyMs * dayStats.totalRequests;
|
|
1746
|
+
for (const [tier, stats] of Object.entries(dayStats.byTier)) {
|
|
1747
|
+
if (!allByTier[tier]) allByTier[tier] = { count: 0, cost: 0 };
|
|
1748
|
+
allByTier[tier].count += stats.count;
|
|
1749
|
+
allByTier[tier].cost += stats.cost;
|
|
1750
|
+
}
|
|
1751
|
+
for (const [model, stats] of Object.entries(dayStats.byModel)) {
|
|
1752
|
+
if (!allByModel[model]) allByModel[model] = { count: 0, cost: 0 };
|
|
1753
|
+
allByModel[model].count += stats.count;
|
|
1754
|
+
allByModel[model].cost += stats.cost;
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const byTierWithPercentage = {};
|
|
1758
|
+
for (const [tier, stats] of Object.entries(allByTier)) {
|
|
1759
|
+
byTierWithPercentage[tier] = {
|
|
1760
|
+
...stats,
|
|
1761
|
+
percentage: totalRequests > 0 ? stats.count / totalRequests * 100 : 0
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
const byModelWithPercentage = {};
|
|
1765
|
+
for (const [model, stats] of Object.entries(allByModel)) {
|
|
1766
|
+
byModelWithPercentage[model] = {
|
|
1767
|
+
...stats,
|
|
1768
|
+
percentage: totalRequests > 0 ? stats.count / totalRequests * 100 : 0
|
|
1769
|
+
};
|
|
1770
|
+
}
|
|
1771
|
+
const totalSavings = totalBaselineCost - totalCost;
|
|
1772
|
+
const savingsPercentage = totalBaselineCost > 0 ? totalSavings / totalBaselineCost * 100 : 0;
|
|
1773
|
+
return {
|
|
1774
|
+
period: days === 1 ? "today" : `last ${days} days`,
|
|
1775
|
+
totalRequests,
|
|
1776
|
+
totalCost,
|
|
1777
|
+
totalBaselineCost,
|
|
1778
|
+
totalSavings,
|
|
1779
|
+
savingsPercentage,
|
|
1780
|
+
avgLatencyMs: totalRequests > 0 ? totalLatency / totalRequests : 0,
|
|
1781
|
+
avgCostPerRequest: totalRequests > 0 ? totalCost / totalRequests : 0,
|
|
1782
|
+
byTier: byTierWithPercentage,
|
|
1783
|
+
byModel: byModelWithPercentage,
|
|
1784
|
+
dailyBreakdown: dailyBreakdown.reverse()
|
|
1785
|
+
// Oldest first for charts
|
|
1786
|
+
};
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// src/dedup.ts
|
|
1790
|
+
import { createHash } from "crypto";
|
|
1791
|
+
var DEFAULT_TTL_MS2 = 3e4;
|
|
1792
|
+
var MAX_BODY_SIZE = 1048576;
|
|
1793
|
+
function canonicalize(obj) {
|
|
1794
|
+
if (obj === null || typeof obj !== "object") {
|
|
1795
|
+
return obj;
|
|
1796
|
+
}
|
|
1797
|
+
if (Array.isArray(obj)) {
|
|
1798
|
+
return obj.map(canonicalize);
|
|
1799
|
+
}
|
|
1800
|
+
const sorted = {};
|
|
1801
|
+
for (const key of Object.keys(obj).sort()) {
|
|
1802
|
+
sorted[key] = canonicalize(obj[key]);
|
|
1803
|
+
}
|
|
1804
|
+
return sorted;
|
|
1805
|
+
}
|
|
1806
|
+
var TIMESTAMP_PATTERN = /^\[\w{3}\s+\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}\s+\w+\]\s*/;
|
|
1807
|
+
function stripTimestamps(obj) {
|
|
1808
|
+
if (obj === null || typeof obj !== "object") {
|
|
1809
|
+
return obj;
|
|
1810
|
+
}
|
|
1811
|
+
if (Array.isArray(obj)) {
|
|
1812
|
+
return obj.map(stripTimestamps);
|
|
1813
|
+
}
|
|
1814
|
+
const result = {};
|
|
1815
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1816
|
+
if (key === "content" && typeof value === "string") {
|
|
1817
|
+
result[key] = value.replace(TIMESTAMP_PATTERN, "");
|
|
1818
|
+
} else {
|
|
1819
|
+
result[key] = stripTimestamps(value);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return result;
|
|
1823
|
+
}
|
|
1824
|
+
var RequestDeduplicator = class {
|
|
1825
|
+
inflight = /* @__PURE__ */ new Map();
|
|
1826
|
+
completed = /* @__PURE__ */ new Map();
|
|
1827
|
+
ttlMs;
|
|
1828
|
+
constructor(ttlMs = DEFAULT_TTL_MS2) {
|
|
1829
|
+
this.ttlMs = ttlMs;
|
|
1830
|
+
}
|
|
1831
|
+
/** Hash request body to create a dedup key. */
|
|
1832
|
+
static hash(body) {
|
|
1833
|
+
let content = body;
|
|
1834
|
+
try {
|
|
1835
|
+
const parsed = JSON.parse(body.toString());
|
|
1836
|
+
const stripped = stripTimestamps(parsed);
|
|
1837
|
+
const canonical = canonicalize(stripped);
|
|
1838
|
+
content = Buffer.from(JSON.stringify(canonical));
|
|
1839
|
+
} catch {
|
|
1840
|
+
}
|
|
1841
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
1842
|
+
}
|
|
1843
|
+
/** Check if a response is cached for this key. */
|
|
1844
|
+
getCached(key) {
|
|
1845
|
+
const entry = this.completed.get(key);
|
|
1846
|
+
if (!entry) return void 0;
|
|
1847
|
+
if (Date.now() - entry.completedAt > this.ttlMs) {
|
|
1848
|
+
this.completed.delete(key);
|
|
1849
|
+
return void 0;
|
|
1850
|
+
}
|
|
1851
|
+
return entry;
|
|
1852
|
+
}
|
|
1853
|
+
/** Check if a request with this key is currently in-flight. Returns a promise to wait on. */
|
|
1854
|
+
getInflight(key) {
|
|
1855
|
+
const entry = this.inflight.get(key);
|
|
1856
|
+
if (!entry) return void 0;
|
|
1857
|
+
const promise = new Promise((resolve) => {
|
|
1858
|
+
entry.waiters.push(
|
|
1859
|
+
new Promise((r) => {
|
|
1860
|
+
const orig = entry.resolve;
|
|
1861
|
+
entry.resolve = (result) => {
|
|
1862
|
+
orig(result);
|
|
1863
|
+
resolve(result);
|
|
1864
|
+
r(result);
|
|
1865
|
+
};
|
|
1866
|
+
})
|
|
1867
|
+
);
|
|
1868
|
+
});
|
|
1869
|
+
return promise;
|
|
1870
|
+
}
|
|
1871
|
+
/** Mark a request as in-flight. */
|
|
1872
|
+
markInflight(key) {
|
|
1873
|
+
this.inflight.set(key, {
|
|
1874
|
+
resolve: () => {
|
|
1875
|
+
},
|
|
1876
|
+
waiters: []
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
/** Complete an in-flight request — cache result and notify waiters. */
|
|
1880
|
+
complete(key, result) {
|
|
1881
|
+
if (result.body.length <= MAX_BODY_SIZE) {
|
|
1882
|
+
this.completed.set(key, result);
|
|
1883
|
+
}
|
|
1884
|
+
const entry = this.inflight.get(key);
|
|
1885
|
+
if (entry) {
|
|
1886
|
+
entry.resolve(result);
|
|
1887
|
+
this.inflight.delete(key);
|
|
1888
|
+
}
|
|
1889
|
+
this.prune();
|
|
1890
|
+
}
|
|
1891
|
+
/** Remove an in-flight entry on error (don't cache failures). */
|
|
1892
|
+
removeInflight(key) {
|
|
1893
|
+
this.inflight.delete(key);
|
|
1894
|
+
}
|
|
1895
|
+
/** Prune expired completed entries. */
|
|
1896
|
+
prune() {
|
|
1897
|
+
const now = Date.now();
|
|
1898
|
+
for (const [key, entry] of this.completed) {
|
|
1899
|
+
if (now - entry.completedAt > this.ttlMs) {
|
|
1900
|
+
this.completed.delete(key);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
};
|
|
1905
|
+
|
|
1906
|
+
// src/balance.ts
|
|
1907
|
+
import { createPublicClient, http, erc20Abi } from "viem";
|
|
1908
|
+
import { base } from "viem/chains";
|
|
1909
|
+
|
|
1910
|
+
// src/errors.ts
|
|
1911
|
+
var RpcError = class extends Error {
|
|
1912
|
+
code = "RPC_ERROR";
|
|
1913
|
+
originalError;
|
|
1914
|
+
constructor(message, originalError) {
|
|
1915
|
+
super(`RPC error: ${message}. Check network connectivity.`);
|
|
1916
|
+
this.name = "RpcError";
|
|
1917
|
+
this.originalError = originalError;
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
|
|
1921
|
+
// src/balance.ts
|
|
1922
|
+
var USDC_BASE2 = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
1923
|
+
var CACHE_TTL_MS = 3e4;
|
|
1924
|
+
var BALANCE_THRESHOLDS = {
|
|
1925
|
+
/** Low balance warning threshold: $1.00 */
|
|
1926
|
+
LOW_BALANCE_MICROS: 1000000n,
|
|
1927
|
+
/** Effectively zero threshold: $0.0001 (covers dust/rounding) */
|
|
1928
|
+
ZERO_THRESHOLD: 100n
|
|
1929
|
+
};
|
|
1930
|
+
var BalanceMonitor = class {
|
|
1931
|
+
client;
|
|
1932
|
+
walletAddress;
|
|
1933
|
+
/** Cached balance (null = not yet fetched) */
|
|
1934
|
+
cachedBalance = null;
|
|
1935
|
+
/** Timestamp when cache was last updated */
|
|
1936
|
+
cachedAt = 0;
|
|
1937
|
+
constructor(walletAddress) {
|
|
1938
|
+
this.walletAddress = walletAddress;
|
|
1939
|
+
this.client = createPublicClient({
|
|
1940
|
+
chain: base,
|
|
1941
|
+
transport: http(void 0, {
|
|
1942
|
+
timeout: 1e4
|
|
1943
|
+
// 10 second timeout to prevent hanging on slow RPC
|
|
1944
|
+
})
|
|
1945
|
+
});
|
|
1946
|
+
}
|
|
1947
|
+
/**
|
|
1948
|
+
* Check current USDC balance.
|
|
1949
|
+
* Uses cache if valid, otherwise fetches from RPC.
|
|
1950
|
+
*/
|
|
1951
|
+
async checkBalance() {
|
|
1952
|
+
const now = Date.now();
|
|
1953
|
+
if (this.cachedBalance !== null && now - this.cachedAt < CACHE_TTL_MS) {
|
|
1954
|
+
return this.buildInfo(this.cachedBalance);
|
|
1955
|
+
}
|
|
1956
|
+
const balance = await this.fetchBalance();
|
|
1957
|
+
this.cachedBalance = balance;
|
|
1958
|
+
this.cachedAt = now;
|
|
1959
|
+
return this.buildInfo(balance);
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* Check if balance is sufficient for an estimated cost.
|
|
1963
|
+
*
|
|
1964
|
+
* @param estimatedCostMicros - Estimated cost in USDC smallest unit (6 decimals)
|
|
1965
|
+
*/
|
|
1966
|
+
async checkSufficient(estimatedCostMicros) {
|
|
1967
|
+
const info = await this.checkBalance();
|
|
1968
|
+
if (info.balance >= estimatedCostMicros) {
|
|
1969
|
+
return { sufficient: true, info };
|
|
1970
|
+
}
|
|
1971
|
+
const shortfall = estimatedCostMicros - info.balance;
|
|
1972
|
+
return {
|
|
1973
|
+
sufficient: false,
|
|
1974
|
+
info,
|
|
1975
|
+
shortfall: this.formatUSDC(shortfall)
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* Optimistically deduct estimated cost from cached balance.
|
|
1980
|
+
* Call this after a successful payment to keep cache accurate.
|
|
1981
|
+
*
|
|
1982
|
+
* @param amountMicros - Amount to deduct in USDC smallest unit
|
|
1983
|
+
*/
|
|
1984
|
+
deductEstimated(amountMicros) {
|
|
1985
|
+
if (this.cachedBalance !== null && this.cachedBalance >= amountMicros) {
|
|
1986
|
+
this.cachedBalance -= amountMicros;
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
/**
|
|
1990
|
+
* Invalidate cache, forcing next checkBalance() to fetch from RPC.
|
|
1991
|
+
* Call this after a payment failure to get accurate balance.
|
|
1992
|
+
*/
|
|
1993
|
+
invalidate() {
|
|
1994
|
+
this.cachedBalance = null;
|
|
1995
|
+
this.cachedAt = 0;
|
|
1996
|
+
}
|
|
1997
|
+
/**
|
|
1998
|
+
* Force refresh balance from RPC (ignores cache).
|
|
1999
|
+
*/
|
|
2000
|
+
async refresh() {
|
|
2001
|
+
this.invalidate();
|
|
2002
|
+
return this.checkBalance();
|
|
2003
|
+
}
|
|
2004
|
+
/**
|
|
2005
|
+
* Format USDC amount (in micros) as "$X.XX".
|
|
2006
|
+
*/
|
|
2007
|
+
formatUSDC(amountMicros) {
|
|
2008
|
+
const dollars = Number(amountMicros) / 1e6;
|
|
2009
|
+
return `$${dollars.toFixed(2)}`;
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Get the wallet address being monitored.
|
|
2013
|
+
*/
|
|
2014
|
+
getWalletAddress() {
|
|
2015
|
+
return this.walletAddress;
|
|
2016
|
+
}
|
|
2017
|
+
/** Fetch balance from RPC */
|
|
2018
|
+
async fetchBalance() {
|
|
2019
|
+
try {
|
|
2020
|
+
const balance = await this.client.readContract({
|
|
2021
|
+
address: USDC_BASE2,
|
|
2022
|
+
abi: erc20Abi,
|
|
2023
|
+
functionName: "balanceOf",
|
|
2024
|
+
args: [this.walletAddress]
|
|
2025
|
+
});
|
|
2026
|
+
return balance;
|
|
2027
|
+
} catch (error) {
|
|
2028
|
+
throw new RpcError(error instanceof Error ? error.message : "Unknown error", error);
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
/** Build BalanceInfo from raw balance */
|
|
2032
|
+
buildInfo(balance) {
|
|
2033
|
+
return {
|
|
2034
|
+
balance,
|
|
2035
|
+
balanceUSD: this.formatUSDC(balance),
|
|
2036
|
+
isLow: balance < BALANCE_THRESHOLDS.LOW_BALANCE_MICROS,
|
|
2037
|
+
isEmpty: balance < BALANCE_THRESHOLDS.ZERO_THRESHOLD,
|
|
2038
|
+
walletAddress: this.walletAddress
|
|
2039
|
+
};
|
|
2040
|
+
}
|
|
2041
|
+
};
|
|
2042
|
+
|
|
2043
|
+
// src/version.ts
|
|
2044
|
+
import { createRequire } from "module";
|
|
2045
|
+
import { fileURLToPath } from "url";
|
|
2046
|
+
import { dirname, join as join3 } from "path";
|
|
2047
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
2048
|
+
var __dirname = dirname(__filename);
|
|
2049
|
+
var require2 = createRequire(import.meta.url);
|
|
2050
|
+
var pkg = require2(join3(__dirname, "..", "package.json"));
|
|
2051
|
+
var VERSION = pkg.version;
|
|
2052
|
+
var USER_AGENT = `clawrouter/${VERSION}`;
|
|
2053
|
+
|
|
2054
|
+
// src/session.ts
|
|
2055
|
+
var DEFAULT_SESSION_CONFIG = {
|
|
2056
|
+
enabled: false,
|
|
2057
|
+
timeoutMs: 30 * 60 * 1e3,
|
|
2058
|
+
// 30 minutes
|
|
2059
|
+
headerName: "x-session-id"
|
|
2060
|
+
};
|
|
2061
|
+
var SessionStore = class {
|
|
2062
|
+
sessions = /* @__PURE__ */ new Map();
|
|
2063
|
+
config;
|
|
2064
|
+
cleanupInterval = null;
|
|
2065
|
+
constructor(config = {}) {
|
|
2066
|
+
this.config = { ...DEFAULT_SESSION_CONFIG, ...config };
|
|
2067
|
+
if (this.config.enabled) {
|
|
2068
|
+
this.cleanupInterval = setInterval(() => this.cleanup(), 5 * 60 * 1e3);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
/**
|
|
2072
|
+
* Get the pinned model for a session, if any.
|
|
2073
|
+
*/
|
|
2074
|
+
getSession(sessionId) {
|
|
2075
|
+
if (!this.config.enabled || !sessionId) {
|
|
2076
|
+
return void 0;
|
|
2077
|
+
}
|
|
2078
|
+
const entry = this.sessions.get(sessionId);
|
|
2079
|
+
if (!entry) {
|
|
2080
|
+
return void 0;
|
|
2081
|
+
}
|
|
2082
|
+
const now = Date.now();
|
|
2083
|
+
if (now - entry.lastUsedAt > this.config.timeoutMs) {
|
|
2084
|
+
this.sessions.delete(sessionId);
|
|
2085
|
+
return void 0;
|
|
2086
|
+
}
|
|
2087
|
+
return entry;
|
|
2088
|
+
}
|
|
2089
|
+
/**
|
|
2090
|
+
* Pin a model to a session.
|
|
2091
|
+
*/
|
|
2092
|
+
setSession(sessionId, model, tier) {
|
|
2093
|
+
if (!this.config.enabled || !sessionId) {
|
|
2094
|
+
return;
|
|
2095
|
+
}
|
|
2096
|
+
const existing = this.sessions.get(sessionId);
|
|
2097
|
+
const now = Date.now();
|
|
2098
|
+
if (existing) {
|
|
2099
|
+
existing.lastUsedAt = now;
|
|
2100
|
+
existing.requestCount++;
|
|
2101
|
+
if (existing.model !== model) {
|
|
2102
|
+
existing.model = model;
|
|
2103
|
+
existing.tier = tier;
|
|
2104
|
+
}
|
|
2105
|
+
} else {
|
|
2106
|
+
this.sessions.set(sessionId, {
|
|
2107
|
+
model,
|
|
2108
|
+
tier,
|
|
2109
|
+
createdAt: now,
|
|
2110
|
+
lastUsedAt: now,
|
|
2111
|
+
requestCount: 1
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Touch a session to extend its timeout.
|
|
2117
|
+
*/
|
|
2118
|
+
touchSession(sessionId) {
|
|
2119
|
+
if (!this.config.enabled || !sessionId) {
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
const entry = this.sessions.get(sessionId);
|
|
2123
|
+
if (entry) {
|
|
2124
|
+
entry.lastUsedAt = Date.now();
|
|
2125
|
+
entry.requestCount++;
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
/**
|
|
2129
|
+
* Clear a specific session.
|
|
2130
|
+
*/
|
|
2131
|
+
clearSession(sessionId) {
|
|
2132
|
+
this.sessions.delete(sessionId);
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Clear all sessions.
|
|
2136
|
+
*/
|
|
2137
|
+
clearAll() {
|
|
2138
|
+
this.sessions.clear();
|
|
2139
|
+
}
|
|
2140
|
+
/**
|
|
2141
|
+
* Get session stats for debugging.
|
|
2142
|
+
*/
|
|
2143
|
+
getStats() {
|
|
2144
|
+
const now = Date.now();
|
|
2145
|
+
const sessions = Array.from(this.sessions.entries()).map(([id, entry]) => ({
|
|
2146
|
+
id: id.slice(0, 8) + "...",
|
|
2147
|
+
model: entry.model,
|
|
2148
|
+
age: Math.round((now - entry.createdAt) / 1e3)
|
|
2149
|
+
}));
|
|
2150
|
+
return { count: this.sessions.size, sessions };
|
|
2151
|
+
}
|
|
2152
|
+
/**
|
|
2153
|
+
* Clean up expired sessions.
|
|
2154
|
+
*/
|
|
2155
|
+
cleanup() {
|
|
2156
|
+
const now = Date.now();
|
|
2157
|
+
for (const [id, entry] of this.sessions) {
|
|
2158
|
+
if (now - entry.lastUsedAt > this.config.timeoutMs) {
|
|
2159
|
+
this.sessions.delete(id);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
/**
|
|
2164
|
+
* Stop the cleanup interval.
|
|
2165
|
+
*/
|
|
2166
|
+
close() {
|
|
2167
|
+
if (this.cleanupInterval) {
|
|
2168
|
+
clearInterval(this.cleanupInterval);
|
|
2169
|
+
this.cleanupInterval = null;
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
function getSessionId(headers, headerName = DEFAULT_SESSION_CONFIG.headerName) {
|
|
2174
|
+
const value = headers[headerName] || headers[headerName.toLowerCase()];
|
|
2175
|
+
if (typeof value === "string" && value.length > 0) {
|
|
2176
|
+
return value;
|
|
2177
|
+
}
|
|
2178
|
+
if (Array.isArray(value) && value.length > 0) {
|
|
2179
|
+
return value[0];
|
|
2180
|
+
}
|
|
2181
|
+
return void 0;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// src/proxy.ts
|
|
2185
|
+
var BLOCKRUN_API = "https://blockrun.ai/api";
|
|
2186
|
+
var AUTO_MODEL = "blockrun/auto";
|
|
2187
|
+
var AUTO_MODEL_SHORT = "auto";
|
|
2188
|
+
var FREE_MODEL = "nvidia/gpt-oss-120b";
|
|
2189
|
+
var HEARTBEAT_INTERVAL_MS = 2e3;
|
|
2190
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 18e4;
|
|
2191
|
+
var DEFAULT_PORT = 8402;
|
|
2192
|
+
var MAX_FALLBACK_ATTEMPTS = 3;
|
|
2193
|
+
var HEALTH_CHECK_TIMEOUT_MS = 2e3;
|
|
2194
|
+
var RATE_LIMIT_COOLDOWN_MS = 6e4;
|
|
2195
|
+
var PORT_RETRY_ATTEMPTS = 5;
|
|
2196
|
+
var PORT_RETRY_DELAY_MS = 1e3;
|
|
2197
|
+
var rateLimitedModels = /* @__PURE__ */ new Map();
|
|
2198
|
+
function isRateLimited(modelId) {
|
|
2199
|
+
const hitTime = rateLimitedModels.get(modelId);
|
|
2200
|
+
if (!hitTime) return false;
|
|
2201
|
+
const elapsed = Date.now() - hitTime;
|
|
2202
|
+
if (elapsed >= RATE_LIMIT_COOLDOWN_MS) {
|
|
2203
|
+
rateLimitedModels.delete(modelId);
|
|
2204
|
+
return false;
|
|
2205
|
+
}
|
|
2206
|
+
return true;
|
|
2207
|
+
}
|
|
2208
|
+
function markRateLimited(modelId) {
|
|
2209
|
+
rateLimitedModels.set(modelId, Date.now());
|
|
2210
|
+
console.log(`[ClawRouter] Model ${modelId} rate-limited, will deprioritize for 60s`);
|
|
2211
|
+
}
|
|
2212
|
+
function prioritizeNonRateLimited(models) {
|
|
2213
|
+
const available = [];
|
|
2214
|
+
const rateLimited = [];
|
|
2215
|
+
for (const model of models) {
|
|
2216
|
+
if (isRateLimited(model)) {
|
|
2217
|
+
rateLimited.push(model);
|
|
2218
|
+
} else {
|
|
2219
|
+
available.push(model);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
return [...available, ...rateLimited];
|
|
2223
|
+
}
|
|
2224
|
+
function canWrite(res) {
|
|
2225
|
+
return !res.writableEnded && !res.destroyed && res.socket !== null && !res.socket.destroyed && res.socket.writable;
|
|
2226
|
+
}
|
|
2227
|
+
function safeWrite(res, data) {
|
|
2228
|
+
if (!canWrite(res)) {
|
|
2229
|
+
return false;
|
|
2230
|
+
}
|
|
2231
|
+
return res.write(data);
|
|
2232
|
+
}
|
|
2233
|
+
var BALANCE_CHECK_BUFFER = 1.5;
|
|
2234
|
+
function getProxyPort() {
|
|
2235
|
+
const envPort = process.env.BLOCKRUN_PROXY_PORT;
|
|
2236
|
+
if (envPort) {
|
|
2237
|
+
const parsed = parseInt(envPort, 10);
|
|
2238
|
+
if (!isNaN(parsed) && parsed > 0 && parsed < 65536) {
|
|
2239
|
+
return parsed;
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
return DEFAULT_PORT;
|
|
2243
|
+
}
|
|
2244
|
+
async function checkExistingProxy(port) {
|
|
2245
|
+
const controller = new AbortController();
|
|
2246
|
+
const timeoutId = setTimeout(() => controller.abort(), HEALTH_CHECK_TIMEOUT_MS);
|
|
2247
|
+
try {
|
|
2248
|
+
const response = await fetch(`http://127.0.0.1:${port}/health`, {
|
|
2249
|
+
signal: controller.signal
|
|
2250
|
+
});
|
|
2251
|
+
clearTimeout(timeoutId);
|
|
2252
|
+
if (response.ok) {
|
|
2253
|
+
const data = await response.json();
|
|
2254
|
+
if (data.status === "ok" && data.wallet) {
|
|
2255
|
+
return data.wallet;
|
|
2256
|
+
}
|
|
2257
|
+
}
|
|
2258
|
+
return void 0;
|
|
2259
|
+
} catch {
|
|
2260
|
+
clearTimeout(timeoutId);
|
|
2261
|
+
return void 0;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
var PROVIDER_ERROR_PATTERNS = [
|
|
2265
|
+
/billing/i,
|
|
2266
|
+
/insufficient.*balance/i,
|
|
2267
|
+
/credits/i,
|
|
2268
|
+
/quota.*exceeded/i,
|
|
2269
|
+
/rate.*limit/i,
|
|
2270
|
+
/model.*unavailable/i,
|
|
2271
|
+
/model.*not.*available/i,
|
|
2272
|
+
/service.*unavailable/i,
|
|
2273
|
+
/capacity/i,
|
|
2274
|
+
/overloaded/i,
|
|
2275
|
+
/temporarily.*unavailable/i,
|
|
2276
|
+
/api.*key.*invalid/i,
|
|
2277
|
+
/authentication.*failed/i
|
|
2278
|
+
];
|
|
2279
|
+
var FALLBACK_STATUS_CODES = [
|
|
2280
|
+
400,
|
|
2281
|
+
// Bad request - sometimes used for billing errors
|
|
2282
|
+
401,
|
|
2283
|
+
// Unauthorized - provider API key issues
|
|
2284
|
+
402,
|
|
2285
|
+
// Payment required - but from upstream, not x402
|
|
2286
|
+
403,
|
|
2287
|
+
// Forbidden - provider restrictions
|
|
2288
|
+
429,
|
|
2289
|
+
// Rate limited
|
|
2290
|
+
500,
|
|
2291
|
+
// Internal server error
|
|
2292
|
+
502,
|
|
2293
|
+
// Bad gateway
|
|
2294
|
+
503,
|
|
2295
|
+
// Service unavailable
|
|
2296
|
+
504
|
|
2297
|
+
// Gateway timeout
|
|
2298
|
+
];
|
|
2299
|
+
function isProviderError(status, body) {
|
|
2300
|
+
if (!FALLBACK_STATUS_CODES.includes(status)) {
|
|
2301
|
+
return false;
|
|
2302
|
+
}
|
|
2303
|
+
if (status >= 500) {
|
|
2304
|
+
return true;
|
|
2305
|
+
}
|
|
2306
|
+
return PROVIDER_ERROR_PATTERNS.some((pattern) => pattern.test(body));
|
|
2307
|
+
}
|
|
2308
|
+
var VALID_ROLES = /* @__PURE__ */ new Set(["system", "user", "assistant", "tool", "function"]);
|
|
2309
|
+
var ROLE_MAPPINGS = {
|
|
2310
|
+
developer: "system",
|
|
2311
|
+
// OpenAI's newer API uses "developer" for system messages
|
|
2312
|
+
model: "assistant"
|
|
2313
|
+
// Some APIs use "model" instead of "assistant"
|
|
2314
|
+
};
|
|
2315
|
+
var VALID_TOOL_ID_PATTERN = /^[a-zA-Z0-9_-]+$/;
|
|
2316
|
+
function sanitizeToolId(id) {
|
|
2317
|
+
if (!id || typeof id !== "string") return id;
|
|
2318
|
+
if (VALID_TOOL_ID_PATTERN.test(id)) return id;
|
|
2319
|
+
return id.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
2320
|
+
}
|
|
2321
|
+
function sanitizeToolIds(messages) {
|
|
2322
|
+
if (!messages || messages.length === 0) return messages;
|
|
2323
|
+
let hasChanges = false;
|
|
2324
|
+
const sanitized = messages.map((msg) => {
|
|
2325
|
+
const typedMsg = msg;
|
|
2326
|
+
let msgChanged = false;
|
|
2327
|
+
let newMsg = { ...msg };
|
|
2328
|
+
if (typedMsg.tool_calls && Array.isArray(typedMsg.tool_calls)) {
|
|
2329
|
+
const newToolCalls = typedMsg.tool_calls.map((tc) => {
|
|
2330
|
+
if (tc.id && typeof tc.id === "string") {
|
|
2331
|
+
const sanitized2 = sanitizeToolId(tc.id);
|
|
2332
|
+
if (sanitized2 !== tc.id) {
|
|
2333
|
+
msgChanged = true;
|
|
2334
|
+
return { ...tc, id: sanitized2 };
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
return tc;
|
|
2338
|
+
});
|
|
2339
|
+
if (msgChanged) {
|
|
2340
|
+
newMsg = { ...newMsg, tool_calls: newToolCalls };
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
if (typedMsg.tool_call_id && typeof typedMsg.tool_call_id === "string") {
|
|
2344
|
+
const sanitized2 = sanitizeToolId(typedMsg.tool_call_id);
|
|
2345
|
+
if (sanitized2 !== typedMsg.tool_call_id) {
|
|
2346
|
+
msgChanged = true;
|
|
2347
|
+
newMsg = { ...newMsg, tool_call_id: sanitized2 };
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
if (Array.isArray(typedMsg.content)) {
|
|
2351
|
+
const newContent = typedMsg.content.map((block) => {
|
|
2352
|
+
if (!block || typeof block !== "object") return block;
|
|
2353
|
+
let blockChanged = false;
|
|
2354
|
+
let newBlock = { ...block };
|
|
2355
|
+
if (block.type === "tool_use" && block.id && typeof block.id === "string") {
|
|
2356
|
+
const sanitized2 = sanitizeToolId(block.id);
|
|
2357
|
+
if (sanitized2 !== block.id) {
|
|
2358
|
+
blockChanged = true;
|
|
2359
|
+
newBlock = { ...newBlock, id: sanitized2 };
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
if (block.type === "tool_result" && block.tool_use_id && typeof block.tool_use_id === "string") {
|
|
2363
|
+
const sanitized2 = sanitizeToolId(block.tool_use_id);
|
|
2364
|
+
if (sanitized2 !== block.tool_use_id) {
|
|
2365
|
+
blockChanged = true;
|
|
2366
|
+
newBlock = { ...newBlock, tool_use_id: sanitized2 };
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
if (blockChanged) {
|
|
2370
|
+
msgChanged = true;
|
|
2371
|
+
return newBlock;
|
|
2372
|
+
}
|
|
2373
|
+
return block;
|
|
2374
|
+
});
|
|
2375
|
+
if (msgChanged) {
|
|
2376
|
+
newMsg = { ...newMsg, content: newContent };
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
if (msgChanged) {
|
|
2380
|
+
hasChanges = true;
|
|
2381
|
+
return newMsg;
|
|
2382
|
+
}
|
|
2383
|
+
return msg;
|
|
2384
|
+
});
|
|
2385
|
+
return hasChanges ? sanitized : messages;
|
|
2386
|
+
}
|
|
2387
|
+
function normalizeMessageRoles(messages) {
|
|
2388
|
+
if (!messages || messages.length === 0) return messages;
|
|
2389
|
+
let hasChanges = false;
|
|
2390
|
+
const normalized = messages.map((msg) => {
|
|
2391
|
+
if (VALID_ROLES.has(msg.role)) return msg;
|
|
2392
|
+
const mappedRole = ROLE_MAPPINGS[msg.role];
|
|
2393
|
+
if (mappedRole) {
|
|
2394
|
+
hasChanges = true;
|
|
2395
|
+
return { ...msg, role: mappedRole };
|
|
2396
|
+
}
|
|
2397
|
+
hasChanges = true;
|
|
2398
|
+
return { ...msg, role: "user" };
|
|
2399
|
+
});
|
|
2400
|
+
return hasChanges ? normalized : messages;
|
|
2401
|
+
}
|
|
2402
|
+
function normalizeMessagesForGoogle(messages) {
|
|
2403
|
+
if (!messages || messages.length === 0) return messages;
|
|
2404
|
+
let firstNonSystemIdx = -1;
|
|
2405
|
+
for (let i = 0; i < messages.length; i++) {
|
|
2406
|
+
if (messages[i].role !== "system") {
|
|
2407
|
+
firstNonSystemIdx = i;
|
|
2408
|
+
break;
|
|
2409
|
+
}
|
|
2410
|
+
}
|
|
2411
|
+
if (firstNonSystemIdx === -1) return messages;
|
|
2412
|
+
const firstRole = messages[firstNonSystemIdx].role;
|
|
2413
|
+
if (firstRole === "user") return messages;
|
|
2414
|
+
if (firstRole === "assistant" || firstRole === "model") {
|
|
2415
|
+
const normalized = [...messages];
|
|
2416
|
+
normalized.splice(firstNonSystemIdx, 0, {
|
|
2417
|
+
role: "user",
|
|
2418
|
+
content: "(continuing conversation)"
|
|
2419
|
+
});
|
|
2420
|
+
return normalized;
|
|
2421
|
+
}
|
|
2422
|
+
return messages;
|
|
2423
|
+
}
|
|
2424
|
+
function isGoogleModel(modelId) {
|
|
2425
|
+
return modelId.startsWith("google/") || modelId.startsWith("gemini");
|
|
2426
|
+
}
|
|
2427
|
+
function normalizeMessagesForThinking(messages) {
|
|
2428
|
+
if (!messages || messages.length === 0) return messages;
|
|
2429
|
+
let hasChanges = false;
|
|
2430
|
+
const normalized = messages.map((msg) => {
|
|
2431
|
+
if (msg.role === "assistant" && msg.tool_calls && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0 && msg.reasoning_content === void 0) {
|
|
2432
|
+
hasChanges = true;
|
|
2433
|
+
return { ...msg, reasoning_content: "" };
|
|
2434
|
+
}
|
|
2435
|
+
return msg;
|
|
2436
|
+
});
|
|
2437
|
+
return hasChanges ? normalized : messages;
|
|
2438
|
+
}
|
|
2439
|
+
var KIMI_BLOCK_RE = /<[||][^<>]*begin[^<>]*[||]>[\s\S]*?<[||][^<>]*end[^<>]*[||]>/gi;
|
|
2440
|
+
var KIMI_TOKEN_RE = /<[||][^<>]*[||]>/g;
|
|
2441
|
+
var THINKING_TAG_RE = /<\s*\/?\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>/gi;
|
|
2442
|
+
var THINKING_BLOCK_RE = /<\s*(?:think(?:ing)?|thought|antthinking)\b[^>]*>[\s\S]*?<\s*\/\s*(?:think(?:ing)?|thought|antthinking)\s*>/gi;
|
|
2443
|
+
function stripThinkingTokens(content) {
|
|
2444
|
+
if (!content) return content;
|
|
2445
|
+
let cleaned = content.replace(KIMI_BLOCK_RE, "");
|
|
2446
|
+
cleaned = cleaned.replace(KIMI_TOKEN_RE, "");
|
|
2447
|
+
cleaned = cleaned.replace(THINKING_BLOCK_RE, "");
|
|
2448
|
+
cleaned = cleaned.replace(THINKING_TAG_RE, "");
|
|
2449
|
+
return cleaned;
|
|
2450
|
+
}
|
|
2451
|
+
function buildModelPricing() {
|
|
2452
|
+
const map = /* @__PURE__ */ new Map();
|
|
2453
|
+
for (const m of BLOCKRUN_MODELS) {
|
|
2454
|
+
if (m.id === AUTO_MODEL) continue;
|
|
2455
|
+
map.set(m.id, { inputPrice: m.inputPrice, outputPrice: m.outputPrice });
|
|
2456
|
+
}
|
|
2457
|
+
return map;
|
|
2458
|
+
}
|
|
2459
|
+
function mergeRoutingConfig(overrides) {
|
|
2460
|
+
if (!overrides) return DEFAULT_ROUTING_CONFIG;
|
|
2461
|
+
return {
|
|
2462
|
+
...DEFAULT_ROUTING_CONFIG,
|
|
2463
|
+
...overrides,
|
|
2464
|
+
classifier: { ...DEFAULT_ROUTING_CONFIG.classifier, ...overrides.classifier },
|
|
2465
|
+
scoring: { ...DEFAULT_ROUTING_CONFIG.scoring, ...overrides.scoring },
|
|
2466
|
+
tiers: { ...DEFAULT_ROUTING_CONFIG.tiers, ...overrides.tiers },
|
|
2467
|
+
overrides: { ...DEFAULT_ROUTING_CONFIG.overrides, ...overrides.overrides }
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
function estimateAmount(modelId, bodyLength, maxTokens) {
|
|
2471
|
+
const model = BLOCKRUN_MODELS.find((m) => m.id === modelId);
|
|
2472
|
+
if (!model) return void 0;
|
|
2473
|
+
const estimatedInputTokens = Math.ceil(bodyLength / 4);
|
|
2474
|
+
const estimatedOutputTokens = maxTokens || model.maxOutput || 4096;
|
|
2475
|
+
const costUsd = estimatedInputTokens / 1e6 * model.inputPrice + estimatedOutputTokens / 1e6 * model.outputPrice;
|
|
2476
|
+
const amountMicros = Math.max(100, Math.ceil(costUsd * 1.2 * 1e6));
|
|
2477
|
+
return amountMicros.toString();
|
|
2478
|
+
}
|
|
2479
|
+
async function startProxy(options) {
|
|
2480
|
+
const apiBase = options.apiBase ?? BLOCKRUN_API;
|
|
2481
|
+
const listenPort = options.port ?? getProxyPort();
|
|
2482
|
+
const existingWallet = await checkExistingProxy(listenPort);
|
|
2483
|
+
if (existingWallet) {
|
|
2484
|
+
const account2 = privateKeyToAccount2(options.walletKey);
|
|
2485
|
+
const balanceMonitor2 = new BalanceMonitor(account2.address);
|
|
2486
|
+
const baseUrl2 = `http://127.0.0.1:${listenPort}`;
|
|
2487
|
+
if (existingWallet !== account2.address) {
|
|
2488
|
+
console.warn(
|
|
2489
|
+
`[ClawRouter] Existing proxy on port ${listenPort} uses wallet ${existingWallet}, but current config uses ${account2.address}. Reusing existing proxy.`
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
options.onReady?.(listenPort);
|
|
2493
|
+
return {
|
|
2494
|
+
port: listenPort,
|
|
2495
|
+
baseUrl: baseUrl2,
|
|
2496
|
+
walletAddress: existingWallet,
|
|
2497
|
+
balanceMonitor: balanceMonitor2,
|
|
2498
|
+
close: async () => {
|
|
2499
|
+
}
|
|
2500
|
+
};
|
|
2501
|
+
}
|
|
2502
|
+
const account = privateKeyToAccount2(options.walletKey);
|
|
2503
|
+
const { fetch: payFetch } = createPaymentFetch(options.walletKey);
|
|
2504
|
+
const balanceMonitor = new BalanceMonitor(account.address);
|
|
2505
|
+
const routingConfig = mergeRoutingConfig(options.routingConfig);
|
|
2506
|
+
const modelPricing = buildModelPricing();
|
|
2507
|
+
const routerOpts = {
|
|
2508
|
+
config: routingConfig,
|
|
2509
|
+
modelPricing
|
|
2510
|
+
};
|
|
2511
|
+
const deduplicator = new RequestDeduplicator();
|
|
2512
|
+
const sessionStore = new SessionStore(options.sessionConfig);
|
|
2513
|
+
const connections = /* @__PURE__ */ new Set();
|
|
2514
|
+
const server = createServer(async (req, res) => {
|
|
2515
|
+
req.on("error", (err) => {
|
|
2516
|
+
console.error(`[ClawRouter] Request stream error: ${err.message}`);
|
|
2517
|
+
});
|
|
2518
|
+
res.on("error", (err) => {
|
|
2519
|
+
console.error(`[ClawRouter] Response stream error: ${err.message}`);
|
|
2520
|
+
});
|
|
2521
|
+
finished(res, (err) => {
|
|
2522
|
+
if (err && err.code !== "ERR_STREAM_DESTROYED") {
|
|
2523
|
+
console.error(`[ClawRouter] Response finished with error: ${err.message}`);
|
|
2524
|
+
}
|
|
2525
|
+
});
|
|
2526
|
+
finished(req, (err) => {
|
|
2527
|
+
if (err && err.code !== "ERR_STREAM_DESTROYED") {
|
|
2528
|
+
console.error(`[ClawRouter] Request finished with error: ${err.message}`);
|
|
2529
|
+
}
|
|
2530
|
+
});
|
|
2531
|
+
if (req.url === "/health" || req.url?.startsWith("/health?")) {
|
|
2532
|
+
const url = new URL(req.url, "http://localhost");
|
|
2533
|
+
const full = url.searchParams.get("full") === "true";
|
|
2534
|
+
const response = {
|
|
2535
|
+
status: "ok",
|
|
2536
|
+
wallet: account.address
|
|
2537
|
+
};
|
|
2538
|
+
if (full) {
|
|
2539
|
+
try {
|
|
2540
|
+
const balanceInfo = await balanceMonitor.checkBalance();
|
|
2541
|
+
response.balance = balanceInfo.balanceUSD;
|
|
2542
|
+
response.isLow = balanceInfo.isLow;
|
|
2543
|
+
response.isEmpty = balanceInfo.isEmpty;
|
|
2544
|
+
} catch {
|
|
2545
|
+
response.balanceError = "Could not fetch balance";
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2549
|
+
res.end(JSON.stringify(response));
|
|
2550
|
+
return;
|
|
2551
|
+
}
|
|
2552
|
+
if (req.url === "/stats" || req.url?.startsWith("/stats?")) {
|
|
2553
|
+
try {
|
|
2554
|
+
const url = new URL(req.url, "http://localhost");
|
|
2555
|
+
const days = parseInt(url.searchParams.get("days") || "7", 10);
|
|
2556
|
+
const stats = await getStats(Math.min(days, 30));
|
|
2557
|
+
res.writeHead(200, {
|
|
2558
|
+
"Content-Type": "application/json",
|
|
2559
|
+
"Cache-Control": "no-cache"
|
|
2560
|
+
});
|
|
2561
|
+
res.end(JSON.stringify(stats, null, 2));
|
|
2562
|
+
} catch (err) {
|
|
2563
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2564
|
+
res.end(
|
|
2565
|
+
JSON.stringify({
|
|
2566
|
+
error: `Failed to get stats: ${err instanceof Error ? err.message : String(err)}`
|
|
2567
|
+
})
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
if (req.url === "/v1/models" && req.method === "GET") {
|
|
2573
|
+
const models = BLOCKRUN_MODELS.filter((m) => m.id !== "blockrun/auto").map((m) => ({
|
|
2574
|
+
id: m.id,
|
|
2575
|
+
object: "model",
|
|
2576
|
+
created: Math.floor(Date.now() / 1e3),
|
|
2577
|
+
owned_by: m.id.split("/")[0] || "unknown"
|
|
2578
|
+
}));
|
|
2579
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2580
|
+
res.end(JSON.stringify({ object: "list", data: models }));
|
|
2581
|
+
return;
|
|
2582
|
+
}
|
|
2583
|
+
if (!req.url?.startsWith("/v1")) {
|
|
2584
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
2585
|
+
res.end(JSON.stringify({ error: "Not found" }));
|
|
2586
|
+
return;
|
|
2587
|
+
}
|
|
2588
|
+
try {
|
|
2589
|
+
await proxyRequest(
|
|
2590
|
+
req,
|
|
2591
|
+
res,
|
|
2592
|
+
apiBase,
|
|
2593
|
+
payFetch,
|
|
2594
|
+
options,
|
|
2595
|
+
routerOpts,
|
|
2596
|
+
deduplicator,
|
|
2597
|
+
balanceMonitor,
|
|
2598
|
+
sessionStore
|
|
2599
|
+
);
|
|
2600
|
+
} catch (err) {
|
|
2601
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
2602
|
+
options.onError?.(error);
|
|
2603
|
+
if (!res.headersSent) {
|
|
2604
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
2605
|
+
res.end(
|
|
2606
|
+
JSON.stringify({
|
|
2607
|
+
error: { message: `Proxy error: ${error.message}`, type: "proxy_error" }
|
|
2608
|
+
})
|
|
2609
|
+
);
|
|
2610
|
+
} else if (!res.writableEnded) {
|
|
2611
|
+
res.write(
|
|
2612
|
+
`data: ${JSON.stringify({ error: { message: error.message, type: "proxy_error" } })}
|
|
2613
|
+
|
|
2614
|
+
`
|
|
2615
|
+
);
|
|
2616
|
+
res.write("data: [DONE]\n\n");
|
|
2617
|
+
res.end();
|
|
2618
|
+
}
|
|
2619
|
+
}
|
|
2620
|
+
});
|
|
2621
|
+
const tryListen = (attempt) => {
|
|
2622
|
+
return new Promise((resolveAttempt, rejectAttempt) => {
|
|
2623
|
+
const onError = async (err) => {
|
|
2624
|
+
server.removeListener("error", onError);
|
|
2625
|
+
if (err.code === "EADDRINUSE") {
|
|
2626
|
+
const existingWallet2 = await checkExistingProxy(listenPort);
|
|
2627
|
+
if (existingWallet2) {
|
|
2628
|
+
console.log(`[ClawRouter] Existing proxy detected on port ${listenPort}, reusing`);
|
|
2629
|
+
rejectAttempt({ code: "REUSE_EXISTING", wallet: existingWallet2 });
|
|
2630
|
+
return;
|
|
2631
|
+
}
|
|
2632
|
+
if (attempt < PORT_RETRY_ATTEMPTS) {
|
|
2633
|
+
console.log(
|
|
2634
|
+
`[ClawRouter] Port ${listenPort} in TIME_WAIT, retrying in ${PORT_RETRY_DELAY_MS}ms (attempt ${attempt}/${PORT_RETRY_ATTEMPTS})`
|
|
2635
|
+
);
|
|
2636
|
+
rejectAttempt({ code: "RETRY", attempt });
|
|
2637
|
+
return;
|
|
2638
|
+
}
|
|
2639
|
+
console.error(
|
|
2640
|
+
`[ClawRouter] Port ${listenPort} still in use after ${PORT_RETRY_ATTEMPTS} attempts`
|
|
2641
|
+
);
|
|
2642
|
+
rejectAttempt(err);
|
|
2643
|
+
return;
|
|
2644
|
+
}
|
|
2645
|
+
rejectAttempt(err);
|
|
2646
|
+
};
|
|
2647
|
+
server.once("error", onError);
|
|
2648
|
+
server.listen(listenPort, "127.0.0.1", () => {
|
|
2649
|
+
server.removeListener("error", onError);
|
|
2650
|
+
resolveAttempt();
|
|
2651
|
+
});
|
|
2652
|
+
});
|
|
2653
|
+
};
|
|
2654
|
+
let lastError;
|
|
2655
|
+
for (let attempt = 1; attempt <= PORT_RETRY_ATTEMPTS; attempt++) {
|
|
2656
|
+
try {
|
|
2657
|
+
await tryListen(attempt);
|
|
2658
|
+
break;
|
|
2659
|
+
} catch (err) {
|
|
2660
|
+
const error = err;
|
|
2661
|
+
if (error.code === "REUSE_EXISTING" && error.wallet) {
|
|
2662
|
+
const baseUrl2 = `http://127.0.0.1:${listenPort}`;
|
|
2663
|
+
options.onReady?.(listenPort);
|
|
2664
|
+
return {
|
|
2665
|
+
port: listenPort,
|
|
2666
|
+
baseUrl: baseUrl2,
|
|
2667
|
+
walletAddress: error.wallet,
|
|
2668
|
+
balanceMonitor,
|
|
2669
|
+
close: async () => {
|
|
2670
|
+
}
|
|
2671
|
+
};
|
|
2672
|
+
}
|
|
2673
|
+
if (error.code === "RETRY") {
|
|
2674
|
+
await new Promise((r) => setTimeout(r, PORT_RETRY_DELAY_MS));
|
|
2675
|
+
continue;
|
|
2676
|
+
}
|
|
2677
|
+
lastError = err;
|
|
2678
|
+
break;
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
2681
|
+
if (lastError) {
|
|
2682
|
+
throw lastError;
|
|
2683
|
+
}
|
|
2684
|
+
const addr = server.address();
|
|
2685
|
+
const port = addr.port;
|
|
2686
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
2687
|
+
options.onReady?.(port);
|
|
2688
|
+
server.on("error", (err) => {
|
|
2689
|
+
console.error(`[ClawRouter] Server runtime error: ${err.message}`);
|
|
2690
|
+
options.onError?.(err);
|
|
2691
|
+
});
|
|
2692
|
+
server.on("clientError", (err, socket) => {
|
|
2693
|
+
console.error(`[ClawRouter] Client error: ${err.message}`);
|
|
2694
|
+
if (socket.writable && !socket.destroyed) {
|
|
2695
|
+
socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
|
|
2696
|
+
}
|
|
2697
|
+
});
|
|
2698
|
+
server.on("connection", (socket) => {
|
|
2699
|
+
connections.add(socket);
|
|
2700
|
+
socket.setTimeout(3e5);
|
|
2701
|
+
socket.on("timeout", () => {
|
|
2702
|
+
console.error(`[ClawRouter] Socket timeout, destroying connection`);
|
|
2703
|
+
socket.destroy();
|
|
2704
|
+
});
|
|
2705
|
+
socket.on("end", () => {
|
|
2706
|
+
});
|
|
2707
|
+
socket.on("error", (err) => {
|
|
2708
|
+
console.error(`[ClawRouter] Socket error: ${err.message}`);
|
|
2709
|
+
});
|
|
2710
|
+
socket.on("close", () => {
|
|
2711
|
+
connections.delete(socket);
|
|
2712
|
+
});
|
|
2713
|
+
});
|
|
2714
|
+
return {
|
|
2715
|
+
port,
|
|
2716
|
+
baseUrl,
|
|
2717
|
+
walletAddress: account.address,
|
|
2718
|
+
balanceMonitor,
|
|
2719
|
+
close: () => new Promise((res, rej) => {
|
|
2720
|
+
const timeout = setTimeout(() => {
|
|
2721
|
+
rej(new Error("[ClawRouter] Close timeout after 4s"));
|
|
2722
|
+
}, 4e3);
|
|
2723
|
+
sessionStore.close();
|
|
2724
|
+
for (const socket of connections) {
|
|
2725
|
+
socket.destroy();
|
|
2726
|
+
}
|
|
2727
|
+
connections.clear();
|
|
2728
|
+
server.close((err) => {
|
|
2729
|
+
clearTimeout(timeout);
|
|
2730
|
+
if (err) {
|
|
2731
|
+
rej(err);
|
|
2732
|
+
} else {
|
|
2733
|
+
res();
|
|
2734
|
+
}
|
|
2735
|
+
});
|
|
2736
|
+
})
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
async function tryModelRequest(upstreamUrl, method, headers, body, modelId, maxTokens, payFetch, balanceMonitor, signal) {
|
|
2740
|
+
let requestBody = body;
|
|
2741
|
+
try {
|
|
2742
|
+
const parsed = JSON.parse(body.toString());
|
|
2743
|
+
parsed.model = modelId;
|
|
2744
|
+
if (Array.isArray(parsed.messages)) {
|
|
2745
|
+
parsed.messages = normalizeMessageRoles(parsed.messages);
|
|
2746
|
+
}
|
|
2747
|
+
if (Array.isArray(parsed.messages)) {
|
|
2748
|
+
parsed.messages = sanitizeToolIds(parsed.messages);
|
|
2749
|
+
}
|
|
2750
|
+
if (isGoogleModel(modelId) && Array.isArray(parsed.messages)) {
|
|
2751
|
+
parsed.messages = normalizeMessagesForGoogle(parsed.messages);
|
|
2752
|
+
}
|
|
2753
|
+
if (parsed.thinking && Array.isArray(parsed.messages)) {
|
|
2754
|
+
parsed.messages = normalizeMessagesForThinking(parsed.messages);
|
|
2755
|
+
}
|
|
2756
|
+
requestBody = Buffer.from(JSON.stringify(parsed));
|
|
2757
|
+
} catch {
|
|
2758
|
+
}
|
|
2759
|
+
const estimated = estimateAmount(modelId, requestBody.length, maxTokens);
|
|
2760
|
+
const preAuth = estimated ? { estimatedAmount: estimated } : void 0;
|
|
2761
|
+
try {
|
|
2762
|
+
const response = await payFetch(
|
|
2763
|
+
upstreamUrl,
|
|
2764
|
+
{
|
|
2765
|
+
method,
|
|
2766
|
+
headers,
|
|
2767
|
+
body: requestBody.length > 0 ? new Uint8Array(requestBody) : void 0,
|
|
2768
|
+
signal
|
|
2769
|
+
},
|
|
2770
|
+
preAuth
|
|
2771
|
+
);
|
|
2772
|
+
if (response.status !== 200) {
|
|
2773
|
+
const errorBody = await response.text();
|
|
2774
|
+
const isProviderErr = isProviderError(response.status, errorBody);
|
|
2775
|
+
return {
|
|
2776
|
+
success: false,
|
|
2777
|
+
errorBody,
|
|
2778
|
+
errorStatus: response.status,
|
|
2779
|
+
isProviderError: isProviderErr
|
|
2780
|
+
};
|
|
2781
|
+
}
|
|
2782
|
+
return { success: true, response };
|
|
2783
|
+
} catch (err) {
|
|
2784
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
2785
|
+
return {
|
|
2786
|
+
success: false,
|
|
2787
|
+
errorBody: errorMsg,
|
|
2788
|
+
errorStatus: 500,
|
|
2789
|
+
isProviderError: true
|
|
2790
|
+
// Network errors are retryable
|
|
2791
|
+
};
|
|
2792
|
+
}
|
|
2793
|
+
}
|
|
2794
|
+
async function proxyRequest(req, res, apiBase, payFetch, options, routerOpts, deduplicator, balanceMonitor, sessionStore) {
|
|
2795
|
+
const startTime = Date.now();
|
|
2796
|
+
const upstreamUrl = `${apiBase}${req.url}`;
|
|
2797
|
+
const bodyChunks = [];
|
|
2798
|
+
for await (const chunk of req) {
|
|
2799
|
+
bodyChunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
2800
|
+
}
|
|
2801
|
+
let body = Buffer.concat(bodyChunks);
|
|
2802
|
+
let routingDecision;
|
|
2803
|
+
let isStreaming = false;
|
|
2804
|
+
let modelId = "";
|
|
2805
|
+
let maxTokens = 4096;
|
|
2806
|
+
const isChatCompletion = req.url?.includes("/chat/completions");
|
|
2807
|
+
if (isChatCompletion && body.length > 0) {
|
|
2808
|
+
try {
|
|
2809
|
+
const parsed = JSON.parse(body.toString());
|
|
2810
|
+
isStreaming = parsed.stream === true;
|
|
2811
|
+
modelId = parsed.model || "";
|
|
2812
|
+
maxTokens = parsed.max_tokens || 4096;
|
|
2813
|
+
let bodyModified = false;
|
|
2814
|
+
if (parsed.stream === true) {
|
|
2815
|
+
parsed.stream = false;
|
|
2816
|
+
bodyModified = true;
|
|
2817
|
+
}
|
|
2818
|
+
const normalizedModel = typeof parsed.model === "string" ? parsed.model.trim().toLowerCase() : "";
|
|
2819
|
+
const resolvedModel = resolveModelAlias(normalizedModel);
|
|
2820
|
+
const wasAlias = resolvedModel !== normalizedModel;
|
|
2821
|
+
const isAutoModel = normalizedModel === AUTO_MODEL.toLowerCase() || normalizedModel === AUTO_MODEL_SHORT.toLowerCase();
|
|
2822
|
+
console.log(
|
|
2823
|
+
`[ClawRouter] Received model: "${parsed.model}" -> normalized: "${normalizedModel}"${wasAlias ? ` -> alias: "${resolvedModel}"` : ""}, isAuto: ${isAutoModel}`
|
|
2824
|
+
);
|
|
2825
|
+
if (wasAlias && !isAutoModel) {
|
|
2826
|
+
parsed.model = resolvedModel;
|
|
2827
|
+
modelId = resolvedModel;
|
|
2828
|
+
bodyModified = true;
|
|
2829
|
+
}
|
|
2830
|
+
if (isAutoModel) {
|
|
2831
|
+
const sessionId = getSessionId(
|
|
2832
|
+
req.headers
|
|
2833
|
+
);
|
|
2834
|
+
const existingSession = sessionId ? sessionStore.getSession(sessionId) : void 0;
|
|
2835
|
+
if (existingSession) {
|
|
2836
|
+
console.log(
|
|
2837
|
+
`[ClawRouter] Session ${sessionId?.slice(0, 8)}... using pinned model: ${existingSession.model}`
|
|
2838
|
+
);
|
|
2839
|
+
parsed.model = existingSession.model;
|
|
2840
|
+
modelId = existingSession.model;
|
|
2841
|
+
bodyModified = true;
|
|
2842
|
+
sessionStore.touchSession(sessionId);
|
|
2843
|
+
} else {
|
|
2844
|
+
const messages = parsed.messages;
|
|
2845
|
+
let lastUserMsg;
|
|
2846
|
+
if (messages) {
|
|
2847
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
2848
|
+
if (messages[i].role === "user") {
|
|
2849
|
+
lastUserMsg = messages[i];
|
|
2850
|
+
break;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
}
|
|
2854
|
+
const systemMsg = messages?.find((m) => m.role === "system");
|
|
2855
|
+
const prompt = typeof lastUserMsg?.content === "string" ? lastUserMsg.content : "";
|
|
2856
|
+
const systemPrompt = typeof systemMsg?.content === "string" ? systemMsg.content : void 0;
|
|
2857
|
+
const tools = parsed.tools;
|
|
2858
|
+
const hasTools = Array.isArray(tools) && tools.length > 0;
|
|
2859
|
+
if (hasTools) {
|
|
2860
|
+
console.log(`[ClawRouter] Tools detected (${tools.length}), agentic mode via keywords`);
|
|
2861
|
+
}
|
|
2862
|
+
routingDecision = route(prompt, systemPrompt, maxTokens, routerOpts);
|
|
2863
|
+
parsed.model = routingDecision.model;
|
|
2864
|
+
modelId = routingDecision.model;
|
|
2865
|
+
bodyModified = true;
|
|
2866
|
+
if (sessionId) {
|
|
2867
|
+
sessionStore.setSession(sessionId, routingDecision.model, routingDecision.tier);
|
|
2868
|
+
console.log(
|
|
2869
|
+
`[ClawRouter] Session ${sessionId.slice(0, 8)}... pinned to model: ${routingDecision.model}`
|
|
2870
|
+
);
|
|
2871
|
+
}
|
|
2872
|
+
options.onRouted?.(routingDecision);
|
|
2873
|
+
}
|
|
2874
|
+
}
|
|
2875
|
+
if (bodyModified) {
|
|
2876
|
+
body = Buffer.from(JSON.stringify(parsed));
|
|
2877
|
+
}
|
|
2878
|
+
} catch (err) {
|
|
2879
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
2880
|
+
console.error(`[ClawRouter] Routing error: ${errorMsg}`);
|
|
2881
|
+
options.onError?.(new Error(`Routing failed: ${errorMsg}`));
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
const dedupKey = RequestDeduplicator.hash(body);
|
|
2885
|
+
const cached = deduplicator.getCached(dedupKey);
|
|
2886
|
+
if (cached) {
|
|
2887
|
+
res.writeHead(cached.status, cached.headers);
|
|
2888
|
+
res.end(cached.body);
|
|
2889
|
+
return;
|
|
2890
|
+
}
|
|
2891
|
+
const inflight = deduplicator.getInflight(dedupKey);
|
|
2892
|
+
if (inflight) {
|
|
2893
|
+
const result = await inflight;
|
|
2894
|
+
res.writeHead(result.status, result.headers);
|
|
2895
|
+
res.end(result.body);
|
|
2896
|
+
return;
|
|
2897
|
+
}
|
|
2898
|
+
deduplicator.markInflight(dedupKey);
|
|
2899
|
+
let estimatedCostMicros;
|
|
2900
|
+
const isFreeModel = modelId === FREE_MODEL;
|
|
2901
|
+
if (modelId && !options.skipBalanceCheck && !isFreeModel) {
|
|
2902
|
+
const estimated = estimateAmount(modelId, body.length, maxTokens);
|
|
2903
|
+
if (estimated) {
|
|
2904
|
+
estimatedCostMicros = BigInt(estimated);
|
|
2905
|
+
const bufferedCostMicros = estimatedCostMicros * BigInt(Math.ceil(BALANCE_CHECK_BUFFER * 100)) / 100n;
|
|
2906
|
+
const sufficiency = await balanceMonitor.checkSufficient(bufferedCostMicros);
|
|
2907
|
+
if (sufficiency.info.isEmpty || !sufficiency.sufficient) {
|
|
2908
|
+
const originalModel = modelId;
|
|
2909
|
+
console.log(
|
|
2910
|
+
`[ClawRouter] Wallet ${sufficiency.info.isEmpty ? "empty" : "insufficient"} ($${sufficiency.info.balanceUSD}), falling back to free model: ${FREE_MODEL} (requested: ${originalModel})`
|
|
2911
|
+
);
|
|
2912
|
+
modelId = FREE_MODEL;
|
|
2913
|
+
const parsed = JSON.parse(body.toString());
|
|
2914
|
+
parsed.model = FREE_MODEL;
|
|
2915
|
+
body = Buffer.from(JSON.stringify(parsed));
|
|
2916
|
+
options.onLowBalance?.({
|
|
2917
|
+
balanceUSD: sufficiency.info.balanceUSD,
|
|
2918
|
+
walletAddress: sufficiency.info.walletAddress
|
|
2919
|
+
});
|
|
2920
|
+
} else if (sufficiency.info.isLow) {
|
|
2921
|
+
options.onLowBalance?.({
|
|
2922
|
+
balanceUSD: sufficiency.info.balanceUSD,
|
|
2923
|
+
walletAddress: sufficiency.info.walletAddress
|
|
2924
|
+
});
|
|
2925
|
+
}
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
let heartbeatInterval;
|
|
2929
|
+
let headersSentEarly = false;
|
|
2930
|
+
if (isStreaming) {
|
|
2931
|
+
res.writeHead(200, {
|
|
2932
|
+
"content-type": "text/event-stream",
|
|
2933
|
+
"cache-control": "no-cache",
|
|
2934
|
+
connection: "keep-alive"
|
|
2935
|
+
});
|
|
2936
|
+
headersSentEarly = true;
|
|
2937
|
+
safeWrite(res, ": heartbeat\n\n");
|
|
2938
|
+
heartbeatInterval = setInterval(() => {
|
|
2939
|
+
if (canWrite(res)) {
|
|
2940
|
+
safeWrite(res, ": heartbeat\n\n");
|
|
2941
|
+
} else {
|
|
2942
|
+
clearInterval(heartbeatInterval);
|
|
2943
|
+
heartbeatInterval = void 0;
|
|
2944
|
+
}
|
|
2945
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
2946
|
+
}
|
|
2947
|
+
const headers = {};
|
|
2948
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
2949
|
+
if (key === "host" || key === "connection" || key === "transfer-encoding" || key === "content-length")
|
|
2950
|
+
continue;
|
|
2951
|
+
if (typeof value === "string") {
|
|
2952
|
+
headers[key] = value;
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
if (!headers["content-type"]) {
|
|
2956
|
+
headers["content-type"] = "application/json";
|
|
2957
|
+
}
|
|
2958
|
+
headers["user-agent"] = USER_AGENT;
|
|
2959
|
+
let completed = false;
|
|
2960
|
+
res.on("close", () => {
|
|
2961
|
+
if (heartbeatInterval) {
|
|
2962
|
+
clearInterval(heartbeatInterval);
|
|
2963
|
+
heartbeatInterval = void 0;
|
|
2964
|
+
}
|
|
2965
|
+
if (!completed) {
|
|
2966
|
+
deduplicator.removeInflight(dedupKey);
|
|
2967
|
+
}
|
|
2968
|
+
});
|
|
2969
|
+
const timeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
2970
|
+
const controller = new AbortController();
|
|
2971
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
2972
|
+
try {
|
|
2973
|
+
let modelsToTry;
|
|
2974
|
+
if (routingDecision) {
|
|
2975
|
+
const estimatedInputTokens = Math.ceil(body.length / 4);
|
|
2976
|
+
const estimatedTotalTokens = estimatedInputTokens + maxTokens;
|
|
2977
|
+
const useAgenticTiers = routingDecision.reasoning?.includes("agentic") && routerOpts.config.agenticTiers;
|
|
2978
|
+
const tierConfigs = useAgenticTiers ? routerOpts.config.agenticTiers : routerOpts.config.tiers;
|
|
2979
|
+
const fullChain = getFallbackChain(routingDecision.tier, tierConfigs);
|
|
2980
|
+
const contextFiltered = getFallbackChainFiltered(
|
|
2981
|
+
routingDecision.tier,
|
|
2982
|
+
tierConfigs,
|
|
2983
|
+
estimatedTotalTokens,
|
|
2984
|
+
getModelContextWindow
|
|
2985
|
+
);
|
|
2986
|
+
const contextExcluded = fullChain.filter((m) => !contextFiltered.includes(m));
|
|
2987
|
+
if (contextExcluded.length > 0) {
|
|
2988
|
+
console.log(
|
|
2989
|
+
`[ClawRouter] Context filter (~${estimatedTotalTokens} tokens): excluded ${contextExcluded.join(", ")}`
|
|
2990
|
+
);
|
|
2991
|
+
}
|
|
2992
|
+
modelsToTry = contextFiltered.slice(0, MAX_FALLBACK_ATTEMPTS);
|
|
2993
|
+
modelsToTry = prioritizeNonRateLimited(modelsToTry);
|
|
2994
|
+
} else {
|
|
2995
|
+
modelsToTry = modelId ? [modelId] : [];
|
|
2996
|
+
}
|
|
2997
|
+
let upstream;
|
|
2998
|
+
let lastError;
|
|
2999
|
+
let actualModelUsed = modelId;
|
|
3000
|
+
for (let i = 0; i < modelsToTry.length; i++) {
|
|
3001
|
+
const tryModel = modelsToTry[i];
|
|
3002
|
+
const isLastAttempt = i === modelsToTry.length - 1;
|
|
3003
|
+
console.log(`[ClawRouter] Trying model ${i + 1}/${modelsToTry.length}: ${tryModel}`);
|
|
3004
|
+
const result = await tryModelRequest(
|
|
3005
|
+
upstreamUrl,
|
|
3006
|
+
req.method ?? "POST",
|
|
3007
|
+
headers,
|
|
3008
|
+
body,
|
|
3009
|
+
tryModel,
|
|
3010
|
+
maxTokens,
|
|
3011
|
+
payFetch,
|
|
3012
|
+
balanceMonitor,
|
|
3013
|
+
controller.signal
|
|
3014
|
+
);
|
|
3015
|
+
if (result.success && result.response) {
|
|
3016
|
+
upstream = result.response;
|
|
3017
|
+
actualModelUsed = tryModel;
|
|
3018
|
+
console.log(`[ClawRouter] Success with model: ${tryModel}`);
|
|
3019
|
+
break;
|
|
3020
|
+
}
|
|
3021
|
+
lastError = {
|
|
3022
|
+
body: result.errorBody || "Unknown error",
|
|
3023
|
+
status: result.errorStatus || 500
|
|
3024
|
+
};
|
|
3025
|
+
if (result.isProviderError && !isLastAttempt) {
|
|
3026
|
+
if (result.errorStatus === 429) {
|
|
3027
|
+
markRateLimited(tryModel);
|
|
3028
|
+
}
|
|
3029
|
+
console.log(
|
|
3030
|
+
`[ClawRouter] Provider error from ${tryModel}, trying fallback: ${result.errorBody?.slice(0, 100)}`
|
|
3031
|
+
);
|
|
3032
|
+
continue;
|
|
3033
|
+
}
|
|
3034
|
+
if (!result.isProviderError) {
|
|
3035
|
+
console.log(
|
|
3036
|
+
`[ClawRouter] Non-provider error from ${tryModel}, not retrying: ${result.errorBody?.slice(0, 100)}`
|
|
3037
|
+
);
|
|
3038
|
+
}
|
|
3039
|
+
break;
|
|
3040
|
+
}
|
|
3041
|
+
clearTimeout(timeoutId);
|
|
3042
|
+
if (heartbeatInterval) {
|
|
3043
|
+
clearInterval(heartbeatInterval);
|
|
3044
|
+
heartbeatInterval = void 0;
|
|
3045
|
+
}
|
|
3046
|
+
if (routingDecision && actualModelUsed !== routingDecision.model) {
|
|
3047
|
+
routingDecision = {
|
|
3048
|
+
...routingDecision,
|
|
3049
|
+
model: actualModelUsed,
|
|
3050
|
+
reasoning: `${routingDecision.reasoning} | fallback to ${actualModelUsed}`
|
|
3051
|
+
};
|
|
3052
|
+
options.onRouted?.(routingDecision);
|
|
3053
|
+
}
|
|
3054
|
+
if (!upstream) {
|
|
3055
|
+
const errBody = lastError?.body || "All models in fallback chain failed";
|
|
3056
|
+
const errStatus = lastError?.status || 502;
|
|
3057
|
+
if (headersSentEarly) {
|
|
3058
|
+
const errEvent = `data: ${JSON.stringify({ error: { message: errBody, type: "provider_error", status: errStatus } })}
|
|
3059
|
+
|
|
3060
|
+
`;
|
|
3061
|
+
safeWrite(res, errEvent);
|
|
3062
|
+
safeWrite(res, "data: [DONE]\n\n");
|
|
3063
|
+
res.end();
|
|
3064
|
+
const errBuf = Buffer.from(errEvent + "data: [DONE]\n\n");
|
|
3065
|
+
deduplicator.complete(dedupKey, {
|
|
3066
|
+
status: 200,
|
|
3067
|
+
headers: { "content-type": "text/event-stream" },
|
|
3068
|
+
body: errBuf,
|
|
3069
|
+
completedAt: Date.now()
|
|
3070
|
+
});
|
|
3071
|
+
} else {
|
|
3072
|
+
res.writeHead(errStatus, { "Content-Type": "application/json" });
|
|
3073
|
+
res.end(
|
|
3074
|
+
JSON.stringify({
|
|
3075
|
+
error: { message: errBody, type: "provider_error" }
|
|
3076
|
+
})
|
|
3077
|
+
);
|
|
3078
|
+
deduplicator.complete(dedupKey, {
|
|
3079
|
+
status: errStatus,
|
|
3080
|
+
headers: { "content-type": "application/json" },
|
|
3081
|
+
body: Buffer.from(
|
|
3082
|
+
JSON.stringify({ error: { message: errBody, type: "provider_error" } })
|
|
3083
|
+
),
|
|
3084
|
+
completedAt: Date.now()
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
return;
|
|
3088
|
+
}
|
|
3089
|
+
const responseChunks = [];
|
|
3090
|
+
if (headersSentEarly) {
|
|
3091
|
+
if (upstream.body) {
|
|
3092
|
+
const reader = upstream.body.getReader();
|
|
3093
|
+
const chunks = [];
|
|
3094
|
+
try {
|
|
3095
|
+
while (true) {
|
|
3096
|
+
const { done, value } = await reader.read();
|
|
3097
|
+
if (done) break;
|
|
3098
|
+
chunks.push(value);
|
|
3099
|
+
}
|
|
3100
|
+
} finally {
|
|
3101
|
+
reader.releaseLock();
|
|
3102
|
+
}
|
|
3103
|
+
const jsonBody = Buffer.concat(chunks);
|
|
3104
|
+
const jsonStr = jsonBody.toString();
|
|
3105
|
+
try {
|
|
3106
|
+
const rsp = JSON.parse(jsonStr);
|
|
3107
|
+
const baseChunk = {
|
|
3108
|
+
id: rsp.id ?? `chatcmpl-${Date.now()}`,
|
|
3109
|
+
object: "chat.completion.chunk",
|
|
3110
|
+
created: rsp.created ?? Math.floor(Date.now() / 1e3),
|
|
3111
|
+
model: rsp.model ?? "unknown",
|
|
3112
|
+
system_fingerprint: null
|
|
3113
|
+
};
|
|
3114
|
+
if (rsp.choices && Array.isArray(rsp.choices)) {
|
|
3115
|
+
for (const choice of rsp.choices) {
|
|
3116
|
+
const rawContent = choice.message?.content ?? choice.delta?.content ?? "";
|
|
3117
|
+
const content = stripThinkingTokens(rawContent);
|
|
3118
|
+
const role = choice.message?.role ?? choice.delta?.role ?? "assistant";
|
|
3119
|
+
const index = choice.index ?? 0;
|
|
3120
|
+
const roleChunk = {
|
|
3121
|
+
...baseChunk,
|
|
3122
|
+
choices: [{ index, delta: { role }, logprobs: null, finish_reason: null }]
|
|
3123
|
+
};
|
|
3124
|
+
const roleData = `data: ${JSON.stringify(roleChunk)}
|
|
3125
|
+
|
|
3126
|
+
`;
|
|
3127
|
+
safeWrite(res, roleData);
|
|
3128
|
+
responseChunks.push(Buffer.from(roleData));
|
|
3129
|
+
if (content) {
|
|
3130
|
+
const contentChunk = {
|
|
3131
|
+
...baseChunk,
|
|
3132
|
+
choices: [{ index, delta: { content }, logprobs: null, finish_reason: null }]
|
|
3133
|
+
};
|
|
3134
|
+
const contentData = `data: ${JSON.stringify(contentChunk)}
|
|
3135
|
+
|
|
3136
|
+
`;
|
|
3137
|
+
safeWrite(res, contentData);
|
|
3138
|
+
responseChunks.push(Buffer.from(contentData));
|
|
3139
|
+
}
|
|
3140
|
+
const toolCalls = choice.message?.tool_calls ?? choice.delta?.tool_calls;
|
|
3141
|
+
if (toolCalls && toolCalls.length > 0) {
|
|
3142
|
+
const toolCallChunk = {
|
|
3143
|
+
...baseChunk,
|
|
3144
|
+
choices: [
|
|
3145
|
+
{
|
|
3146
|
+
index,
|
|
3147
|
+
delta: { tool_calls: toolCalls },
|
|
3148
|
+
logprobs: null,
|
|
3149
|
+
finish_reason: null
|
|
3150
|
+
}
|
|
3151
|
+
]
|
|
3152
|
+
};
|
|
3153
|
+
const toolCallData = `data: ${JSON.stringify(toolCallChunk)}
|
|
3154
|
+
|
|
3155
|
+
`;
|
|
3156
|
+
safeWrite(res, toolCallData);
|
|
3157
|
+
responseChunks.push(Buffer.from(toolCallData));
|
|
3158
|
+
}
|
|
3159
|
+
const finishChunk = {
|
|
3160
|
+
...baseChunk,
|
|
3161
|
+
choices: [
|
|
3162
|
+
{
|
|
3163
|
+
index,
|
|
3164
|
+
delta: {},
|
|
3165
|
+
logprobs: null,
|
|
3166
|
+
finish_reason: choice.finish_reason ?? "stop"
|
|
3167
|
+
}
|
|
3168
|
+
]
|
|
3169
|
+
};
|
|
3170
|
+
const finishData = `data: ${JSON.stringify(finishChunk)}
|
|
3171
|
+
|
|
3172
|
+
`;
|
|
3173
|
+
safeWrite(res, finishData);
|
|
3174
|
+
responseChunks.push(Buffer.from(finishData));
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
} catch {
|
|
3178
|
+
const sseData = `data: ${jsonStr}
|
|
3179
|
+
|
|
3180
|
+
`;
|
|
3181
|
+
safeWrite(res, sseData);
|
|
3182
|
+
responseChunks.push(Buffer.from(sseData));
|
|
3183
|
+
}
|
|
3184
|
+
}
|
|
3185
|
+
safeWrite(res, "data: [DONE]\n\n");
|
|
3186
|
+
responseChunks.push(Buffer.from("data: [DONE]\n\n"));
|
|
3187
|
+
res.end();
|
|
3188
|
+
deduplicator.complete(dedupKey, {
|
|
3189
|
+
status: 200,
|
|
3190
|
+
headers: { "content-type": "text/event-stream" },
|
|
3191
|
+
body: Buffer.concat(responseChunks),
|
|
3192
|
+
completedAt: Date.now()
|
|
3193
|
+
});
|
|
3194
|
+
} else {
|
|
3195
|
+
const responseHeaders = {};
|
|
3196
|
+
upstream.headers.forEach((value, key) => {
|
|
3197
|
+
if (key === "transfer-encoding" || key === "connection" || key === "content-encoding")
|
|
3198
|
+
return;
|
|
3199
|
+
responseHeaders[key] = value;
|
|
3200
|
+
});
|
|
3201
|
+
res.writeHead(upstream.status, responseHeaders);
|
|
3202
|
+
if (upstream.body) {
|
|
3203
|
+
const reader = upstream.body.getReader();
|
|
3204
|
+
try {
|
|
3205
|
+
while (true) {
|
|
3206
|
+
const { done, value } = await reader.read();
|
|
3207
|
+
if (done) break;
|
|
3208
|
+
const chunk = Buffer.from(value);
|
|
3209
|
+
safeWrite(res, chunk);
|
|
3210
|
+
responseChunks.push(chunk);
|
|
3211
|
+
}
|
|
3212
|
+
} finally {
|
|
3213
|
+
reader.releaseLock();
|
|
3214
|
+
}
|
|
3215
|
+
}
|
|
3216
|
+
res.end();
|
|
3217
|
+
deduplicator.complete(dedupKey, {
|
|
3218
|
+
status: upstream.status,
|
|
3219
|
+
headers: responseHeaders,
|
|
3220
|
+
body: Buffer.concat(responseChunks),
|
|
3221
|
+
completedAt: Date.now()
|
|
3222
|
+
});
|
|
3223
|
+
}
|
|
3224
|
+
if (estimatedCostMicros !== void 0) {
|
|
3225
|
+
balanceMonitor.deductEstimated(estimatedCostMicros);
|
|
3226
|
+
}
|
|
3227
|
+
completed = true;
|
|
3228
|
+
} catch (err) {
|
|
3229
|
+
clearTimeout(timeoutId);
|
|
3230
|
+
if (heartbeatInterval) {
|
|
3231
|
+
clearInterval(heartbeatInterval);
|
|
3232
|
+
heartbeatInterval = void 0;
|
|
3233
|
+
}
|
|
3234
|
+
deduplicator.removeInflight(dedupKey);
|
|
3235
|
+
balanceMonitor.invalidate();
|
|
3236
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
3237
|
+
throw new Error(`Request timed out after ${timeoutMs}ms`);
|
|
3238
|
+
}
|
|
3239
|
+
throw err;
|
|
3240
|
+
}
|
|
3241
|
+
if (routingDecision) {
|
|
3242
|
+
const entry = {
|
|
3243
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3244
|
+
model: routingDecision.model,
|
|
3245
|
+
tier: routingDecision.tier,
|
|
3246
|
+
cost: routingDecision.costEstimate,
|
|
3247
|
+
baselineCost: routingDecision.baselineCost,
|
|
3248
|
+
savings: routingDecision.savings,
|
|
3249
|
+
latencyMs: Date.now() - startTime
|
|
3250
|
+
};
|
|
3251
|
+
logUsage(entry).catch(() => {
|
|
3252
|
+
});
|
|
3253
|
+
}
|
|
3254
|
+
}
|
|
3255
|
+
|
|
3256
|
+
// src/auth.ts
|
|
3257
|
+
import { writeFile, readFile as readFile2, mkdir as mkdir2 } from "fs/promises";
|
|
3258
|
+
import { join as join4 } from "path";
|
|
3259
|
+
import { homedir as homedir3 } from "os";
|
|
3260
|
+
import { generatePrivateKey, privateKeyToAccount as privateKeyToAccount3 } from "viem/accounts";
|
|
3261
|
+
var WALLET_DIR = join4(homedir3(), ".openclaw", "blockrun");
|
|
3262
|
+
var WALLET_FILE = join4(WALLET_DIR, "wallet.key");
|
|
3263
|
+
async function loadSavedWallet() {
|
|
3264
|
+
try {
|
|
3265
|
+
const key = (await readFile2(WALLET_FILE, "utf-8")).trim();
|
|
3266
|
+
if (key.startsWith("0x") && key.length === 66) return key;
|
|
3267
|
+
} catch {
|
|
3268
|
+
}
|
|
3269
|
+
return void 0;
|
|
3270
|
+
}
|
|
3271
|
+
async function generateAndSaveWallet() {
|
|
3272
|
+
const key = generatePrivateKey();
|
|
3273
|
+
const account = privateKeyToAccount3(key);
|
|
3274
|
+
await mkdir2(WALLET_DIR, { recursive: true });
|
|
3275
|
+
await writeFile(WALLET_FILE, key + "\n", { mode: 384 });
|
|
3276
|
+
return { key, address: account.address };
|
|
3277
|
+
}
|
|
3278
|
+
async function resolveOrGenerateWalletKey() {
|
|
3279
|
+
const saved = await loadSavedWallet();
|
|
3280
|
+
if (saved) {
|
|
3281
|
+
const account = privateKeyToAccount3(saved);
|
|
3282
|
+
return { key: saved, address: account.address, source: "saved" };
|
|
3283
|
+
}
|
|
3284
|
+
const envKey = process.env.BLOCKRUN_WALLET_KEY;
|
|
3285
|
+
if (typeof envKey === "string" && envKey.startsWith("0x") && envKey.length === 66) {
|
|
3286
|
+
const account = privateKeyToAccount3(envKey);
|
|
3287
|
+
return { key: envKey, address: account.address, source: "env" };
|
|
3288
|
+
}
|
|
3289
|
+
const { key, address } = await generateAndSaveWallet();
|
|
3290
|
+
return { key, address, source: "generated" };
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
// src/cli.ts
|
|
3294
|
+
function printHelp() {
|
|
3295
|
+
console.log(`
|
|
3296
|
+
ClawRouter v${VERSION} - Smart LLM Router
|
|
3297
|
+
|
|
3298
|
+
Usage:
|
|
3299
|
+
clawrouter [options]
|
|
3300
|
+
|
|
3301
|
+
Options:
|
|
3302
|
+
--version, -v Show version number
|
|
3303
|
+
--help, -h Show this help message
|
|
3304
|
+
--port <number> Port to listen on (default: ${getProxyPort()})
|
|
3305
|
+
|
|
3306
|
+
Examples:
|
|
3307
|
+
# Start standalone proxy (survives gateway restarts)
|
|
3308
|
+
npx @blockrun/clawrouter
|
|
3309
|
+
|
|
3310
|
+
# Start on custom port
|
|
3311
|
+
npx @blockrun/clawrouter --port 9000
|
|
3312
|
+
|
|
3313
|
+
# Production deployment with PM2
|
|
3314
|
+
pm2 start "npx @blockrun/clawrouter" --name clawrouter
|
|
3315
|
+
|
|
3316
|
+
Environment Variables:
|
|
3317
|
+
BLOCKRUN_WALLET_KEY Private key for x402 payments (auto-generated if not set)
|
|
3318
|
+
BLOCKRUN_PROXY_PORT Default proxy port (default: 8402)
|
|
3319
|
+
|
|
3320
|
+
For more info: https://github.com/BlockRunAI/ClawRouter
|
|
3321
|
+
`);
|
|
3322
|
+
}
|
|
3323
|
+
function parseArgs(args) {
|
|
3324
|
+
const result = { version: false, help: false, port: void 0 };
|
|
3325
|
+
for (let i = 0; i < args.length; i++) {
|
|
3326
|
+
const arg = args[i];
|
|
3327
|
+
if (arg === "--version" || arg === "-v") {
|
|
3328
|
+
result.version = true;
|
|
3329
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
3330
|
+
result.help = true;
|
|
3331
|
+
} else if (arg === "--port" && args[i + 1]) {
|
|
3332
|
+
result.port = parseInt(args[i + 1], 10);
|
|
3333
|
+
i++;
|
|
3334
|
+
}
|
|
3335
|
+
}
|
|
3336
|
+
return result;
|
|
3337
|
+
}
|
|
3338
|
+
async function main() {
|
|
3339
|
+
const args = parseArgs(process.argv.slice(2));
|
|
3340
|
+
if (args.version) {
|
|
3341
|
+
console.log(VERSION);
|
|
3342
|
+
process.exit(0);
|
|
3343
|
+
}
|
|
3344
|
+
if (args.help) {
|
|
3345
|
+
printHelp();
|
|
3346
|
+
process.exit(0);
|
|
3347
|
+
}
|
|
3348
|
+
const { key: walletKey, address, source } = await resolveOrGenerateWalletKey();
|
|
3349
|
+
if (source === "generated") {
|
|
3350
|
+
console.log(`[ClawRouter] Generated new wallet: ${address}`);
|
|
3351
|
+
} else if (source === "saved") {
|
|
3352
|
+
console.log(`[ClawRouter] Using saved wallet: ${address}`);
|
|
3353
|
+
} else {
|
|
3354
|
+
console.log(`[ClawRouter] Using wallet from BLOCKRUN_WALLET_KEY: ${address}`);
|
|
3355
|
+
}
|
|
3356
|
+
const proxy = await startProxy({
|
|
3357
|
+
walletKey,
|
|
3358
|
+
port: args.port,
|
|
3359
|
+
onReady: (port) => {
|
|
3360
|
+
console.log(`[ClawRouter] Proxy listening on http://127.0.0.1:${port}`);
|
|
3361
|
+
console.log(`[ClawRouter] Health check: http://127.0.0.1:${port}/health`);
|
|
3362
|
+
},
|
|
3363
|
+
onError: (error) => {
|
|
3364
|
+
console.error(`[ClawRouter] Error: ${error.message}`);
|
|
3365
|
+
},
|
|
3366
|
+
onRouted: (decision) => {
|
|
3367
|
+
const cost = decision.costEstimate.toFixed(4);
|
|
3368
|
+
const saved = (decision.savings * 100).toFixed(0);
|
|
3369
|
+
console.log(`[ClawRouter] [${decision.tier}] ${decision.model} $${cost} (saved ${saved}%)`);
|
|
3370
|
+
},
|
|
3371
|
+
onLowBalance: (info) => {
|
|
3372
|
+
console.warn(`[ClawRouter] Low balance: ${info.balanceUSD}. Fund: ${info.walletAddress}`);
|
|
3373
|
+
},
|
|
3374
|
+
onInsufficientFunds: (info) => {
|
|
3375
|
+
console.error(
|
|
3376
|
+
`[ClawRouter] Insufficient funds. Balance: ${info.balanceUSD}, Need: ${info.requiredUSD}`
|
|
3377
|
+
);
|
|
3378
|
+
}
|
|
3379
|
+
});
|
|
3380
|
+
const monitor = new BalanceMonitor(address);
|
|
3381
|
+
try {
|
|
3382
|
+
const balance = await monitor.checkBalance();
|
|
3383
|
+
if (balance.isEmpty) {
|
|
3384
|
+
console.log(`[ClawRouter] Wallet balance: $0.00 (using FREE model)`);
|
|
3385
|
+
console.log(`[ClawRouter] Fund wallet for premium models: ${address}`);
|
|
3386
|
+
} else if (balance.isLow) {
|
|
3387
|
+
console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD} (low)`);
|
|
3388
|
+
} else {
|
|
3389
|
+
console.log(`[ClawRouter] Wallet balance: ${balance.balanceUSD}`);
|
|
3390
|
+
}
|
|
3391
|
+
} catch {
|
|
3392
|
+
console.log(`[ClawRouter] Wallet: ${address} (balance check pending)`);
|
|
3393
|
+
}
|
|
3394
|
+
console.log(`[ClawRouter] Ready - Ctrl+C to stop`);
|
|
3395
|
+
const shutdown = async (signal) => {
|
|
3396
|
+
console.log(`
|
|
3397
|
+
[ClawRouter] Received ${signal}, shutting down...`);
|
|
3398
|
+
try {
|
|
3399
|
+
await proxy.close();
|
|
3400
|
+
console.log(`[ClawRouter] Proxy closed`);
|
|
3401
|
+
process.exit(0);
|
|
3402
|
+
} catch (err) {
|
|
3403
|
+
console.error(`[ClawRouter] Error during shutdown: ${err}`);
|
|
3404
|
+
process.exit(1);
|
|
3405
|
+
}
|
|
3406
|
+
};
|
|
3407
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
3408
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
3409
|
+
await new Promise(() => {
|
|
3410
|
+
});
|
|
3411
|
+
}
|
|
3412
|
+
main().catch((err) => {
|
|
3413
|
+
console.error(`[ClawRouter] Fatal error: ${err.message}`);
|
|
3414
|
+
process.exit(1);
|
|
3415
|
+
});
|
|
3416
|
+
//# sourceMappingURL=cli.js.map
|