@blockrun/franklin 3.3.3 → 3.5.1
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/README.md +65 -25
- package/dist/agent/commands.d.ts +1 -1
- package/dist/agent/commands.js +128 -17
- package/dist/agent/compact.d.ts +2 -2
- package/dist/agent/compact.js +148 -22
- package/dist/agent/context.d.ts +8 -3
- package/dist/agent/context.js +301 -108
- package/dist/agent/error-classifier.d.ts +11 -2
- package/dist/agent/error-classifier.js +64 -10
- package/dist/agent/llm.d.ts +8 -1
- package/dist/agent/llm.js +114 -19
- package/dist/agent/loop.d.ts +1 -2
- package/dist/agent/loop.js +509 -61
- package/dist/agent/optimize.d.ts +2 -2
- package/dist/agent/optimize.js +9 -7
- package/dist/agent/permissions.d.ts +1 -1
- package/dist/agent/permissions.js +1 -1
- package/dist/agent/planner.d.ts +42 -0
- package/dist/agent/planner.js +110 -0
- package/dist/agent/reduce.d.ts +7 -1
- package/dist/agent/reduce.js +85 -3
- package/dist/agent/streaming-executor.d.ts +6 -1
- package/dist/agent/streaming-executor.js +83 -5
- package/dist/agent/tokens.d.ts +11 -2
- package/dist/agent/tokens.js +38 -5
- package/dist/agent/tool-guard.d.ts +27 -0
- package/dist/agent/tool-guard.js +324 -0
- package/dist/agent/types.d.ts +7 -1
- package/dist/agent/types.js +1 -1
- package/dist/brain/extract.d.ts +11 -0
- package/dist/brain/extract.js +154 -0
- package/dist/brain/index.d.ts +3 -0
- package/dist/brain/index.js +2 -0
- package/dist/brain/store.d.ts +42 -0
- package/dist/brain/store.js +225 -0
- package/dist/brain/types.d.ts +45 -0
- package/dist/brain/types.js +5 -0
- package/dist/commands/daemon.js +2 -1
- package/dist/commands/start.js +19 -7
- package/dist/config.js +1 -1
- package/dist/index.js +27 -2
- package/dist/learnings/extractor.d.ts +13 -0
- package/dist/learnings/extractor.js +69 -8
- package/dist/learnings/index.d.ts +1 -1
- package/dist/learnings/index.js +1 -1
- package/dist/learnings/store.js +42 -13
- package/dist/learnings/types.d.ts +1 -1
- package/dist/mcp/client.d.ts +1 -1
- package/dist/mcp/client.js +5 -5
- package/dist/mcp/config.d.ts +1 -1
- package/dist/mcp/config.js +1 -1
- package/dist/panel/html.d.ts +2 -0
- package/dist/panel/html.js +409 -146
- package/dist/panel/server.js +19 -0
- package/dist/pricing.js +3 -2
- package/dist/proxy/fallback.d.ts +3 -1
- package/dist/proxy/fallback.js +4 -4
- package/dist/proxy/server.js +29 -11
- package/dist/proxy/sse-translator.js +1 -1
- package/dist/router/categories.d.ts +21 -0
- package/dist/router/categories.js +96 -0
- package/dist/router/index.d.ts +9 -2
- package/dist/router/index.js +106 -27
- package/dist/router/local-elo.d.ts +32 -0
- package/dist/router/local-elo.js +107 -0
- package/dist/router/selector.d.ts +46 -0
- package/dist/router/selector.js +106 -0
- package/dist/session/storage.d.ts +5 -1
- package/dist/session/storage.js +24 -2
- package/dist/social/a11y.d.ts +1 -1
- package/dist/social/a11y.js +5 -1
- package/dist/social/browser.d.ts +5 -0
- package/dist/social/browser.js +22 -0
- package/dist/social/preflight.d.ts +4 -0
- package/dist/social/preflight.js +42 -3
- package/dist/stats/failures.d.ts +20 -0
- package/dist/stats/failures.js +63 -0
- package/dist/stats/format.d.ts +6 -0
- package/dist/stats/format.js +23 -0
- package/dist/stats/insights.js +1 -21
- package/dist/stats/session-tracker.d.ts +21 -0
- package/dist/stats/session-tracker.js +28 -0
- package/dist/stats/tracker.d.ts +1 -1
- package/dist/stats/tracker.js +1 -1
- package/dist/tools/bash.d.ts +14 -1
- package/dist/tools/bash.js +132 -7
- package/dist/tools/edit.js +77 -14
- package/dist/tools/glob.js +13 -3
- package/dist/tools/grep.js +30 -12
- package/dist/tools/imagegen.js +5 -5
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +5 -1
- package/dist/tools/read.d.ts +16 -2
- package/dist/tools/read.js +36 -8
- package/dist/tools/searchx.d.ts +6 -2
- package/dist/tools/searchx.js +221 -44
- package/dist/tools/subagent.js +37 -3
- package/dist/tools/task.js +43 -7
- package/dist/tools/validate.d.ts +11 -0
- package/dist/tools/validate.js +42 -0
- package/dist/tools/webfetch.js +18 -7
- package/dist/tools/websearch.js +41 -7
- package/dist/tools/write.js +26 -6
- package/dist/ui/app.js +31 -6
- package/dist/ui/model-picker.d.ts +1 -1
- package/dist/ui/model-picker.js +1 -1
- package/dist/ui/terminal.d.ts +1 -1
- package/dist/ui/terminal.js +1 -1
- package/package.json +2 -2
package/dist/panel/server.js
CHANGED
|
@@ -44,6 +44,25 @@ export function createPanelServer(port) {
|
|
|
44
44
|
res.end(html);
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
|
+
// ─── Static assets ──
|
|
48
|
+
if (p.startsWith('/assets/') && p.endsWith('.jpg')) {
|
|
49
|
+
const filename = path.basename(p);
|
|
50
|
+
const assetsDir = path.join(path.dirname(path.dirname(new URL(import.meta.url).pathname)), '..', 'assets');
|
|
51
|
+
const imgPath = path.join(assetsDir, filename);
|
|
52
|
+
try {
|
|
53
|
+
const img = fs.readFileSync(imgPath);
|
|
54
|
+
res.writeHead(200, {
|
|
55
|
+
'Content-Type': 'image/jpeg',
|
|
56
|
+
'Cache-Control': 'public, max-age=86400',
|
|
57
|
+
});
|
|
58
|
+
res.end(img);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
res.writeHead(404);
|
|
62
|
+
res.end('Not found');
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
47
66
|
// ─── SSE ──
|
|
48
67
|
if (p === '/api/events') {
|
|
49
68
|
res.writeHead(200, {
|
package/dist/pricing.js
CHANGED
|
@@ -69,9 +69,10 @@ export const MODEL_PRICING = {
|
|
|
69
69
|
// Others
|
|
70
70
|
'moonshot/kimi-k2.5': { input: 0.6, output: 3.0 },
|
|
71
71
|
'nvidia/kimi-k2.5': { input: 0.55, output: 2.5 },
|
|
72
|
-
// PROMOTION (active ~2026-04): flat $0.001/call
|
|
72
|
+
// PROMOTION (active ~2026-04): flat $0.001/call for all GLM models
|
|
73
|
+
'zai/glm-5': { input: 0, output: 0, perCall: 0.001 },
|
|
73
74
|
'zai/glm-5.1': { input: 0, output: 0, perCall: 0.001 },
|
|
74
|
-
|
|
75
|
+
'zai/glm-5-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
75
76
|
'zai/glm-5.1-turbo': { input: 0, output: 0, perCall: 0.001 },
|
|
76
77
|
};
|
|
77
78
|
/** Opus pricing for savings calculations */
|
package/dist/proxy/fallback.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fallback chain for
|
|
2
|
+
* Fallback chain for Franklin
|
|
3
3
|
* Automatically switches to backup models when primary fails (429, 5xx, etc.)
|
|
4
4
|
*/
|
|
5
5
|
export interface FallbackConfig {
|
|
@@ -30,6 +30,8 @@ export declare function fetchWithFallback(url: string, init: RequestInit, origin
|
|
|
30
30
|
* Get the current model from fallback chain based on parsed request
|
|
31
31
|
*/
|
|
32
32
|
export declare function getCurrentModelFromChain(requestedModel: string | undefined, config?: FallbackConfig): string;
|
|
33
|
+
/** Routing profiles that must never be sent to the backend directly */
|
|
34
|
+
export declare const ROUTING_PROFILES: Set<string>;
|
|
33
35
|
/**
|
|
34
36
|
* Build fallback chain starting from a specific model.
|
|
35
37
|
* Filters out routing profiles (blockrun/auto etc.) since the backend
|
package/dist/proxy/fallback.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fallback chain for
|
|
2
|
+
* Fallback chain for Franklin
|
|
3
3
|
* Automatically switches to backup models when primary fails (429, 5xx, etc.)
|
|
4
4
|
*/
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import os from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
const LOG_FILE = path.join(os.homedir(), '.blockrun', '
|
|
8
|
+
const LOG_FILE = path.join(os.homedir(), '.blockrun', 'franklin-debug.log');
|
|
9
9
|
// eslint-disable-next-line no-control-regex
|
|
10
10
|
const ANSI_RE = /\x1B\[[0-9;]*[A-Za-z]|\x1B\][^\x07]*\x07|\x1B[()][A-B]|\r/g;
|
|
11
11
|
function appendLog(msg) {
|
|
@@ -93,7 +93,7 @@ export async function fetchWithFallback(url, init, originalBody, config = DEFAUL
|
|
|
93
93
|
if (nextModel && onFallback) {
|
|
94
94
|
const errMsg = err instanceof Error ? err.message : 'Network error';
|
|
95
95
|
onFallback(model, 0, nextModel);
|
|
96
|
-
appendLog(`[
|
|
96
|
+
appendLog(`[franklin] [fallback] ${model} network error: ${errMsg}`);
|
|
97
97
|
}
|
|
98
98
|
if (i < config.chain.length - 1) {
|
|
99
99
|
await sleep(config.retryDelayMs);
|
|
@@ -120,7 +120,7 @@ export function getCurrentModelFromChain(requestedModel, config = DEFAULT_FALLBA
|
|
|
120
120
|
return config.chain[0];
|
|
121
121
|
}
|
|
122
122
|
/** Routing profiles that must never be sent to the backend directly */
|
|
123
|
-
const ROUTING_PROFILES = new Set([
|
|
123
|
+
export const ROUTING_PROFILES = new Set([
|
|
124
124
|
'blockrun/auto', 'blockrun/eco', 'blockrun/premium', 'blockrun/free',
|
|
125
125
|
]);
|
|
126
126
|
/**
|
package/dist/proxy/server.js
CHANGED
|
@@ -4,14 +4,14 @@ import path from 'node:path';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
6
6
|
import { recordUsage } from '../stats/tracker.js';
|
|
7
|
-
import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, } from './fallback.js';
|
|
7
|
+
import { fetchWithFallback, buildFallbackChain, DEFAULT_FALLBACK_CONFIG, ROUTING_PROFILES, } from './fallback.js';
|
|
8
8
|
import { routeRequest, parseRoutingProfile, } from '../router/index.js';
|
|
9
9
|
import { estimateCost } from '../pricing.js';
|
|
10
10
|
import { VERSION } from '../config.js';
|
|
11
11
|
// User-Agent for backend requests
|
|
12
|
-
const USER_AGENT = `
|
|
13
|
-
const
|
|
14
|
-
const LOG_FILE = path.join(os.homedir(), '.blockrun', '
|
|
12
|
+
const USER_AGENT = `franklin/${VERSION}`;
|
|
13
|
+
const X_FRANKLIN_VERSION = VERSION;
|
|
14
|
+
const LOG_FILE = path.join(os.homedir(), '.blockrun', 'franklin-debug.log');
|
|
15
15
|
// Strip ANSI escape codes so log file doesn't distort terminal on replay
|
|
16
16
|
function stripAnsi(str) {
|
|
17
17
|
// eslint-disable-next-line no-control-regex
|
|
@@ -30,9 +30,9 @@ function debug(options, ...args) {
|
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
32
|
function log(...args) {
|
|
33
|
-
const msg = `[
|
|
34
|
-
// Do NOT print to stdout —
|
|
35
|
-
// Use `
|
|
33
|
+
const msg = `[franklin] ${args.map(String).join(' ')}`;
|
|
34
|
+
// Do NOT print to stdout — the terminal is owned by the parent process (stdio: inherit).
|
|
35
|
+
// Use `franklin logs` to read runtime messages.
|
|
36
36
|
try {
|
|
37
37
|
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
38
38
|
fs.appendFileSync(LOG_FILE, `[${new Date().toISOString()}] ${stripAnsi(msg)}\n`);
|
|
@@ -193,7 +193,7 @@ export function createProxy(options) {
|
|
|
193
193
|
currentModel = switchCmd;
|
|
194
194
|
debug(options, `model switched to: ${currentModel}`);
|
|
195
195
|
const fakeResponse = {
|
|
196
|
-
id: `
|
|
196
|
+
id: `msg_franklin_${Date.now()}`,
|
|
197
197
|
type: 'message',
|
|
198
198
|
role: 'assistant',
|
|
199
199
|
model: currentModel,
|
|
@@ -277,7 +277,7 @@ export function createProxy(options) {
|
|
|
277
277
|
const headers = {
|
|
278
278
|
'Content-Type': 'application/json',
|
|
279
279
|
'User-Agent': USER_AGENT,
|
|
280
|
-
'X-
|
|
280
|
+
'X-Franklin-Version': X_FRANKLIN_VERSION,
|
|
281
281
|
};
|
|
282
282
|
for (const [key, value] of Object.entries(req.headers)) {
|
|
283
283
|
if (key.toLowerCase() !== 'host' &&
|
|
@@ -287,6 +287,24 @@ export function createProxy(options) {
|
|
|
287
287
|
headers[key] = Array.isArray(value) ? value[0] : value;
|
|
288
288
|
}
|
|
289
289
|
}
|
|
290
|
+
// Safety net: if requestModel is still a routing profile (blockrun/auto etc.)
|
|
291
|
+
// after all resolution attempts, force-route it to a concrete model.
|
|
292
|
+
// This prevents 404s from the backend which doesn't recognize virtual model names.
|
|
293
|
+
if (ROUTING_PROFILES.has(requestModel) && body) {
|
|
294
|
+
const virtualName = requestModel;
|
|
295
|
+
const profile = parseRoutingProfile(requestModel);
|
|
296
|
+
if (profile) {
|
|
297
|
+
const fallbackRouting = routeRequest('', profile);
|
|
298
|
+
requestModel = fallbackRouting.model;
|
|
299
|
+
try {
|
|
300
|
+
const parsed = JSON.parse(body);
|
|
301
|
+
parsed.model = requestModel;
|
|
302
|
+
body = JSON.stringify(parsed);
|
|
303
|
+
}
|
|
304
|
+
catch { /* body not JSON, skip */ }
|
|
305
|
+
log(`⚠️ Safety net: resolved unrouted ${virtualName} → ${requestModel}`);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
290
308
|
// Build request init
|
|
291
309
|
const requestInit = {
|
|
292
310
|
method: req.method || 'POST',
|
|
@@ -475,7 +493,7 @@ export function createProxy(options) {
|
|
|
475
493
|
async function handleBasePayment(response, url, method, headers, body, privateKey, fromAddress) {
|
|
476
494
|
const paymentHeader = await extractPaymentHeader(response);
|
|
477
495
|
if (!paymentHeader) {
|
|
478
|
-
throw new Error('402 Payment Required — wallet may need funding. Run:
|
|
496
|
+
throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
|
|
479
497
|
}
|
|
480
498
|
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
481
499
|
const details = extractPaymentDetails(paymentRequired);
|
|
@@ -500,7 +518,7 @@ async function handleBasePayment(response, url, method, headers, body, privateKe
|
|
|
500
518
|
async function handleSolanaPayment(response, url, method, headers, body, privateKey, fromAddress) {
|
|
501
519
|
const paymentHeader = await extractPaymentHeader(response);
|
|
502
520
|
if (!paymentHeader) {
|
|
503
|
-
throw new Error('402 Payment Required — wallet may need funding. Run:
|
|
521
|
+
throw new Error('402 Payment Required — wallet may need funding. Run: franklin balance');
|
|
504
522
|
}
|
|
505
523
|
const paymentRequired = parsePaymentRequired(paymentHeader);
|
|
506
524
|
const details = extractPaymentDetails(paymentRequired, SOLANA_NETWORK);
|
|
@@ -12,7 +12,7 @@ export class SSETranslator {
|
|
|
12
12
|
buffer = '';
|
|
13
13
|
constructor(model = 'unknown') {
|
|
14
14
|
this.state = {
|
|
15
|
-
messageId: `
|
|
15
|
+
messageId: `msg_franklin_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
|
|
16
16
|
model,
|
|
17
17
|
blockIndex: 0,
|
|
18
18
|
activeToolCalls: new Map(),
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request category detection for the learned router.
|
|
3
|
+
* Classifies requests into categories (coding, trading, reasoning, etc.)
|
|
4
|
+
* using keyword matching from router weights or built-in defaults.
|
|
5
|
+
*/
|
|
6
|
+
export type Category = 'coding' | 'trading' | 'reasoning' | 'chat' | 'creative' | 'research';
|
|
7
|
+
interface CategoryResult {
|
|
8
|
+
category: Category;
|
|
9
|
+
confidence: number;
|
|
10
|
+
scores: Partial<Record<Category, number>>;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Detect the primary category of a request.
|
|
14
|
+
* Uses provided keywords (from learned weights) or built-in defaults.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectCategory(prompt: string, categoryKeywords?: Record<string, string[]>): CategoryResult;
|
|
17
|
+
/**
|
|
18
|
+
* Map a learned category to the legacy tier system (backward compat).
|
|
19
|
+
*/
|
|
20
|
+
export declare function mapCategoryToTier(category: Category): 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING';
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request category detection for the learned router.
|
|
3
|
+
* Classifies requests into categories (coding, trading, reasoning, etc.)
|
|
4
|
+
* using keyword matching from router weights or built-in defaults.
|
|
5
|
+
*/
|
|
6
|
+
// Built-in category keywords (used when no learned weights available)
|
|
7
|
+
const DEFAULT_CATEGORY_KEYWORDS = {
|
|
8
|
+
coding: [
|
|
9
|
+
'function', 'class', 'import', 'def', 'SELECT', 'async', 'await',
|
|
10
|
+
'const', 'let', 'var', 'return', '```', 'bug', 'error', 'fix',
|
|
11
|
+
'refactor', 'implement', 'test', 'npm', 'pip', 'git', 'deploy',
|
|
12
|
+
'API', 'endpoint', 'database', 'query', 'migration', 'lint',
|
|
13
|
+
'函数', '类', '导入', '修复', '调试', '部署',
|
|
14
|
+
],
|
|
15
|
+
trading: [
|
|
16
|
+
'BTC', 'ETH', 'SOL', 'bitcoin', 'ethereum', 'solana', 'crypto',
|
|
17
|
+
'price', 'market', 'signal', 'trade', 'buy', 'sell', 'RSI',
|
|
18
|
+
'MACD', 'volume', 'bullish', 'bearish', 'support', 'resistance',
|
|
19
|
+
'portfolio', 'risk', 'leverage', 'DeFi', 'token', 'swap',
|
|
20
|
+
'比特币', '以太坊', '价格', '市场', '交易', '信号',
|
|
21
|
+
],
|
|
22
|
+
reasoning: [
|
|
23
|
+
'prove', 'theorem', 'derive', 'step by step', 'chain of thought',
|
|
24
|
+
'formally', 'mathematical', 'proof', 'logically', 'analyze',
|
|
25
|
+
'compare', 'evaluate', 'trade-off', 'pros and cons', 'why',
|
|
26
|
+
'explain why', 'reasoning', 'logic', 'deduce', 'infer',
|
|
27
|
+
'证明', '定理', '推导', '分析', '比较',
|
|
28
|
+
],
|
|
29
|
+
creative: [
|
|
30
|
+
'write a story', 'poem', 'creative', 'brainstorm', 'imagine',
|
|
31
|
+
'generate an image', 'design', 'logo', 'illustration', 'art',
|
|
32
|
+
'narrative', 'fiction', 'song', 'lyrics', 'slogan', 'tagline',
|
|
33
|
+
'写一个故事', '诗', '创意', '设计', '头脑风暴',
|
|
34
|
+
],
|
|
35
|
+
research: [
|
|
36
|
+
'search', 'find', 'look up', 'what is', 'who is', 'when was',
|
|
37
|
+
'summarize', 'report', 'overview', 'comparison', 'review',
|
|
38
|
+
'article', 'paper', 'study', 'data', 'statistics', 'trend',
|
|
39
|
+
'搜索', '查找', '什么是', '总结', '报告',
|
|
40
|
+
],
|
|
41
|
+
chat: [
|
|
42
|
+
'hello', 'hi', 'thanks', 'thank you', 'how are you', 'help',
|
|
43
|
+
'translate', 'yes', 'no', 'ok', 'sure', 'good',
|
|
44
|
+
'你好', '谢谢', '帮我', '翻译',
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Detect the primary category of a request.
|
|
49
|
+
* Uses provided keywords (from learned weights) or built-in defaults.
|
|
50
|
+
*/
|
|
51
|
+
export function detectCategory(prompt, categoryKeywords) {
|
|
52
|
+
const keywords = (categoryKeywords ?? DEFAULT_CATEGORY_KEYWORDS);
|
|
53
|
+
const lower = prompt.toLowerCase();
|
|
54
|
+
const scores = {};
|
|
55
|
+
let maxScore = 0;
|
|
56
|
+
let maxCategory = 'chat'; // default fallback
|
|
57
|
+
for (const [cat, kws] of Object.entries(keywords)) {
|
|
58
|
+
let score = 0;
|
|
59
|
+
for (const kw of kws) {
|
|
60
|
+
if (lower.includes(kw.toLowerCase()))
|
|
61
|
+
score++;
|
|
62
|
+
}
|
|
63
|
+
// Bonus for code blocks (strong coding signal)
|
|
64
|
+
if (cat === 'coding') {
|
|
65
|
+
const codeBlocks = (prompt.match(/```/g) || []).length / 2;
|
|
66
|
+
score += codeBlocks * 3;
|
|
67
|
+
}
|
|
68
|
+
if (score > 0)
|
|
69
|
+
scores[cat] = score;
|
|
70
|
+
if (score > maxScore) {
|
|
71
|
+
maxScore = score;
|
|
72
|
+
maxCategory = cat;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Confidence: how much the winner leads the runner-up
|
|
76
|
+
const sortedScores = Object.values(scores).sort((a, b) => b - a);
|
|
77
|
+
const gap = sortedScores.length >= 2
|
|
78
|
+
? (sortedScores[0] - sortedScores[1]) / Math.max(sortedScores[0], 1)
|
|
79
|
+
: sortedScores.length === 1 ? 0.8 : 0;
|
|
80
|
+
const confidence = Math.min(0.95, 0.5 + gap * 0.5);
|
|
81
|
+
return { category: maxCategory, confidence, scores };
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Map a learned category to the legacy tier system (backward compat).
|
|
85
|
+
*/
|
|
86
|
+
export function mapCategoryToTier(category) {
|
|
87
|
+
switch (category) {
|
|
88
|
+
case 'chat': return 'SIMPLE';
|
|
89
|
+
case 'research': return 'MEDIUM';
|
|
90
|
+
case 'creative': return 'MEDIUM';
|
|
91
|
+
case 'coding': return 'COMPLEX';
|
|
92
|
+
case 'trading': return 'COMPLEX';
|
|
93
|
+
case 'reasoning': return 'REASONING';
|
|
94
|
+
default: return 'MEDIUM';
|
|
95
|
+
}
|
|
96
|
+
}
|
package/dist/router/index.d.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Smart Router for
|
|
3
|
-
*
|
|
2
|
+
* Smart Router for Franklin
|
|
3
|
+
*
|
|
4
|
+
* Two routing modes:
|
|
5
|
+
* 1. Learned — uses Elo scores from 2M+ gateway requests (router-weights.json)
|
|
6
|
+
* 2. Classic — 15-dimension keyword scoring (fallback when no weights)
|
|
7
|
+
*
|
|
8
|
+
* The learned router detects request category (coding, trading, reasoning, etc.)
|
|
9
|
+
* and picks the model with the best quality-to-cost ratio for that category.
|
|
10
|
+
* Local Elo adjustments personalize routing per user over time.
|
|
4
11
|
*/
|
|
5
12
|
export type Tier = 'SIMPLE' | 'MEDIUM' | 'COMPLEX' | 'REASONING';
|
|
6
13
|
export type RoutingProfile = 'auto' | 'eco' | 'premium' | 'free';
|
package/dist/router/index.js
CHANGED
|
@@ -1,8 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Smart Router for
|
|
3
|
-
*
|
|
2
|
+
* Smart Router for Franklin
|
|
3
|
+
*
|
|
4
|
+
* Two routing modes:
|
|
5
|
+
* 1. Learned — uses Elo scores from 2M+ gateway requests (router-weights.json)
|
|
6
|
+
* 2. Classic — 15-dimension keyword scoring (fallback when no weights)
|
|
7
|
+
*
|
|
8
|
+
* The learned router detects request category (coding, trading, reasoning, etc.)
|
|
9
|
+
* and picks the model with the best quality-to-cost ratio for that category.
|
|
10
|
+
* Local Elo adjustments personalize routing per user over time.
|
|
4
11
|
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
5
14
|
import { MODEL_PRICING, OPUS_PRICING } from '../pricing.js';
|
|
15
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
16
|
+
import { detectCategory, mapCategoryToTier } from './categories.js';
|
|
17
|
+
import { selectModel } from './selector.js';
|
|
18
|
+
import { computeLocalElo, blendElo } from './local-elo.js';
|
|
19
|
+
// ─── Learned Weights Loading ───
|
|
20
|
+
const WEIGHTS_FILE = path.join(BLOCKRUN_DIR, 'router-weights.json');
|
|
21
|
+
let cachedWeights; // undefined = not loaded yet
|
|
22
|
+
function loadLearnedWeights() {
|
|
23
|
+
if (cachedWeights !== undefined)
|
|
24
|
+
return cachedWeights;
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(WEIGHTS_FILE)) {
|
|
27
|
+
cachedWeights = JSON.parse(fs.readFileSync(WEIGHTS_FILE, 'utf-8'));
|
|
28
|
+
return cachedWeights;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch { /* fall through */ }
|
|
32
|
+
cachedWeights = null;
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
6
35
|
// ─── Tier Model Configs ───
|
|
7
36
|
const AUTO_TIERS = {
|
|
8
37
|
SIMPLE: {
|
|
@@ -78,7 +107,16 @@ const TECHNICAL_KEYWORDS = [
|
|
|
78
107
|
const AGENTIC_KEYWORDS = [
|
|
79
108
|
'read file', 'edit', 'modify', 'update', 'create file', 'execute',
|
|
80
109
|
'deploy', 'install', 'npm', 'pip', 'fix', 'debug', 'verify',
|
|
110
|
+
'commit', 'push', 'pull', 'merge', 'rename', 'replace', 'delete',
|
|
111
|
+
'remove', 'add', 'change', 'move', 'refactor', 'migrate',
|
|
81
112
|
'编辑', '修改', '部署', '安装', '修复', '调试',
|
|
113
|
+
'更新', '替换', '删除', '添加', '提交', '改',
|
|
114
|
+
];
|
|
115
|
+
// URL patterns that signal agentic/coding tasks
|
|
116
|
+
const AGENTIC_URL_PATTERNS = [
|
|
117
|
+
/github\.com/i, /gitlab\.com/i, /bitbucket\.org/i,
|
|
118
|
+
/npmjs\.com/i, /pypi\.org/i, /crates\.io/i,
|
|
119
|
+
/stackoverflow\.com/i, /docs\.\w+/i,
|
|
82
120
|
];
|
|
83
121
|
function countMatches(text, keywords) {
|
|
84
122
|
const lower = text.toLowerCase();
|
|
@@ -139,16 +177,22 @@ function classifyRequest(prompt, tokenCount) {
|
|
|
139
177
|
score += 0.2;
|
|
140
178
|
signals.push('technical-light');
|
|
141
179
|
}
|
|
142
|
-
// Agentic detection (
|
|
180
|
+
// Agentic detection — lowered thresholds (real tasks often have just 1-2 action words)
|
|
143
181
|
const agenticMatches = countMatches(prompt, AGENTIC_KEYWORDS);
|
|
144
|
-
|
|
182
|
+
const hasAgenticUrl = AGENTIC_URL_PATTERNS.some(p => p.test(prompt));
|
|
183
|
+
const agenticScore = agenticMatches + (hasAgenticUrl ? 1 : 0);
|
|
184
|
+
if (agenticScore >= 3) {
|
|
145
185
|
score += 0.35;
|
|
146
186
|
signals.push('agentic');
|
|
147
187
|
}
|
|
148
|
-
else if (
|
|
149
|
-
score += 0.
|
|
188
|
+
else if (agenticScore >= 2) {
|
|
189
|
+
score += 0.25;
|
|
150
190
|
signals.push('agentic-light');
|
|
151
191
|
}
|
|
192
|
+
else if (agenticScore >= 1) {
|
|
193
|
+
score += 0.15;
|
|
194
|
+
signals.push('agentic-hint');
|
|
195
|
+
}
|
|
152
196
|
// Multi-step patterns
|
|
153
197
|
if (/first.*then|step \d|\d\.\s/i.test(prompt)) {
|
|
154
198
|
score += 0.2;
|
|
@@ -187,18 +231,8 @@ function classifyRequest(prompt, tokenCount) {
|
|
|
187
231
|
const confidence = Math.min(0.95, 0.7 + Math.abs(score) * 0.3);
|
|
188
232
|
return { tier, confidence, signals };
|
|
189
233
|
}
|
|
190
|
-
// ───
|
|
191
|
-
|
|
192
|
-
// Free profile - always use free model
|
|
193
|
-
if (profile === 'free') {
|
|
194
|
-
return {
|
|
195
|
-
model: 'nvidia/nemotron-ultra-253b',
|
|
196
|
-
tier: 'SIMPLE',
|
|
197
|
-
confidence: 1.0,
|
|
198
|
-
signals: ['free-profile'],
|
|
199
|
-
savings: 1.0,
|
|
200
|
-
};
|
|
201
|
-
}
|
|
234
|
+
// ─── Classic Router (keyword-based fallback) ───
|
|
235
|
+
function classicRouteRequest(prompt, profile) {
|
|
202
236
|
// Estimate token count (use byte length / 4 for better accuracy with non-ASCII)
|
|
203
237
|
const byteLen = Buffer.byteLength(prompt, 'utf-8');
|
|
204
238
|
const tokenCount = Math.ceil(byteLen / 4);
|
|
@@ -217,20 +251,65 @@ export function routeRequest(prompt, profile = 'auto') {
|
|
|
217
251
|
tierConfigs = AUTO_TIERS;
|
|
218
252
|
}
|
|
219
253
|
const model = tierConfigs[tier].primary;
|
|
220
|
-
|
|
254
|
+
const savings = computeSavings(model);
|
|
255
|
+
return { model, tier, confidence, signals, savings };
|
|
256
|
+
}
|
|
257
|
+
// ─── Main Router ───
|
|
258
|
+
export function routeRequest(prompt, profile = 'auto') {
|
|
259
|
+
// Free profile — always use free model
|
|
260
|
+
if (profile === 'free') {
|
|
261
|
+
return {
|
|
262
|
+
model: 'nvidia/nemotron-ultra-253b',
|
|
263
|
+
tier: 'SIMPLE',
|
|
264
|
+
confidence: 1.0,
|
|
265
|
+
signals: ['free-profile'],
|
|
266
|
+
savings: 1.0,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// ── Learned routing (if weights available) ──
|
|
270
|
+
const weights = loadLearnedWeights();
|
|
271
|
+
if (weights) {
|
|
272
|
+
const { category, confidence } = detectCategory(prompt, weights.category_keywords);
|
|
273
|
+
// Apply local Elo adjustments
|
|
274
|
+
const localElo = computeLocalElo();
|
|
275
|
+
const localCatMap = localElo.get(category);
|
|
276
|
+
// Create adjusted weights with blended Elo scores
|
|
277
|
+
const adjustedWeights = localCatMap
|
|
278
|
+
? {
|
|
279
|
+
...weights,
|
|
280
|
+
model_scores: {
|
|
281
|
+
...weights.model_scores,
|
|
282
|
+
[category]: (weights.model_scores[category] || []).map(s => ({
|
|
283
|
+
...s,
|
|
284
|
+
elo: blendElo(s.elo, localCatMap.get(s.model) ?? 0),
|
|
285
|
+
})),
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
: weights;
|
|
289
|
+
const selected = selectModel(category, profile, adjustedWeights);
|
|
290
|
+
if (selected) {
|
|
291
|
+
const tier = mapCategoryToTier(category);
|
|
292
|
+
const savings = computeSavings(selected.model);
|
|
293
|
+
return {
|
|
294
|
+
model: selected.model,
|
|
295
|
+
tier,
|
|
296
|
+
confidence,
|
|
297
|
+
signals: [category],
|
|
298
|
+
savings,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Fall through to classic if selectModel returns null (no candidates for category)
|
|
302
|
+
}
|
|
303
|
+
// ── Classic routing (keyword-based fallback) ──
|
|
304
|
+
return classicRouteRequest(prompt, profile);
|
|
305
|
+
}
|
|
306
|
+
function computeSavings(model) {
|
|
221
307
|
const opusCostPer1K = (OPUS_PRICING.input + OPUS_PRICING.output) / 2 / 1000;
|
|
222
308
|
const modelPricing = MODEL_PRICING[model];
|
|
223
309
|
const modelCostPer1K = modelPricing
|
|
224
310
|
? (modelPricing.input + modelPricing.output) / 2 / 1000
|
|
225
311
|
: 0.005;
|
|
226
|
-
|
|
227
|
-
return {
|
|
228
|
-
model,
|
|
229
|
-
tier,
|
|
230
|
-
confidence,
|
|
231
|
-
signals,
|
|
232
|
-
savings,
|
|
233
|
-
};
|
|
312
|
+
return Math.max(0, (opusCostPer1K - modelCostPer1K) / opusCostPer1K);
|
|
234
313
|
}
|
|
235
314
|
/**
|
|
236
315
|
* Get fallback models for a tier
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Elo learning — adapts routing to the user's own usage patterns.
|
|
3
|
+
* Tracks model outcomes per category and adjusts Elo ratings locally.
|
|
4
|
+
*
|
|
5
|
+
* Storage: ~/.blockrun/router-history.jsonl (append-only, capped 2000 records)
|
|
6
|
+
* Never uploaded — purely local personalization.
|
|
7
|
+
*/
|
|
8
|
+
export type Outcome = 'continued' | 'switched' | 'retried' | 'error' | 'max_turns' | 'payment';
|
|
9
|
+
/**
|
|
10
|
+
* Record a model outcome for local learning.
|
|
11
|
+
*/
|
|
12
|
+
export declare function recordOutcome(category: string, model: string, outcome: Outcome, toolCalls?: number): void;
|
|
13
|
+
/**
|
|
14
|
+
* Compute local Elo adjustments from history.
|
|
15
|
+
* Returns a map of (category → model → elo_delta).
|
|
16
|
+
*
|
|
17
|
+
* Outcomes map to win/loss:
|
|
18
|
+
* continued → win (+K * 0.6)
|
|
19
|
+
* switched → loss (-K * 1.0)
|
|
20
|
+
* retried → loss (-K * 0.8)
|
|
21
|
+
* error → loss (-K * 0.5)
|
|
22
|
+
* payment → loss (-K * 1.5) — heavy penalty, guaranteed to repeat until funded
|
|
23
|
+
* max_turns → loss (-K * 0.3)
|
|
24
|
+
*/
|
|
25
|
+
export declare function computeLocalElo(): Map<string, Map<string, number>>;
|
|
26
|
+
/**
|
|
27
|
+
* Get the effective Elo for a model in a category,
|
|
28
|
+
* blending global (server-trained) and local (user-specific) scores.
|
|
29
|
+
*
|
|
30
|
+
* effective = 0.7 * global + 0.3 * (1200 + local_delta)
|
|
31
|
+
*/
|
|
32
|
+
export declare function blendElo(globalElo: number, localDelta: number): number;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Elo learning — adapts routing to the user's own usage patterns.
|
|
3
|
+
* Tracks model outcomes per category and adjusts Elo ratings locally.
|
|
4
|
+
*
|
|
5
|
+
* Storage: ~/.blockrun/router-history.jsonl (append-only, capped 2000 records)
|
|
6
|
+
* Never uploaded — purely local personalization.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { BLOCKRUN_DIR } from '../config.js';
|
|
11
|
+
const HISTORY_FILE = path.join(BLOCKRUN_DIR, 'router-history.jsonl');
|
|
12
|
+
const MAX_RECORDS = 2000;
|
|
13
|
+
const K_FACTOR = 32; // Elo K-factor — how much each outcome shifts the rating
|
|
14
|
+
/**
|
|
15
|
+
* Record a model outcome for local learning.
|
|
16
|
+
*/
|
|
17
|
+
export function recordOutcome(category, model, outcome, toolCalls) {
|
|
18
|
+
try {
|
|
19
|
+
fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
|
|
20
|
+
const record = { ts: Date.now(), category, model, outcome, toolCalls };
|
|
21
|
+
fs.appendFileSync(HISTORY_FILE, JSON.stringify(record) + '\n');
|
|
22
|
+
// Trim periodically (10% chance)
|
|
23
|
+
if (Math.random() < 0.1) {
|
|
24
|
+
trimHistory();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Fire-and-forget
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function trimHistory() {
|
|
32
|
+
try {
|
|
33
|
+
if (!fs.existsSync(HISTORY_FILE))
|
|
34
|
+
return;
|
|
35
|
+
const lines = fs.readFileSync(HISTORY_FILE, 'utf-8').trim().split('\n');
|
|
36
|
+
if (lines.length > MAX_RECORDS) {
|
|
37
|
+
fs.writeFileSync(HISTORY_FILE, lines.slice(-MAX_RECORDS).join('\n') + '\n');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch { /* ignore */ }
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Compute local Elo adjustments from history.
|
|
44
|
+
* Returns a map of (category → model → elo_delta).
|
|
45
|
+
*
|
|
46
|
+
* Outcomes map to win/loss:
|
|
47
|
+
* continued → win (+K * 0.6)
|
|
48
|
+
* switched → loss (-K * 1.0)
|
|
49
|
+
* retried → loss (-K * 0.8)
|
|
50
|
+
* error → loss (-K * 0.5)
|
|
51
|
+
* payment → loss (-K * 1.5) — heavy penalty, guaranteed to repeat until funded
|
|
52
|
+
* max_turns → loss (-K * 0.3)
|
|
53
|
+
*/
|
|
54
|
+
export function computeLocalElo() {
|
|
55
|
+
const adjustments = new Map();
|
|
56
|
+
try {
|
|
57
|
+
if (!fs.existsSync(HISTORY_FILE))
|
|
58
|
+
return adjustments;
|
|
59
|
+
const lines = fs.readFileSync(HISTORY_FILE, 'utf-8').trim().split('\n').filter(Boolean);
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
try {
|
|
62
|
+
const record = JSON.parse(line);
|
|
63
|
+
if (!adjustments.has(record.category)) {
|
|
64
|
+
adjustments.set(record.category, new Map());
|
|
65
|
+
}
|
|
66
|
+
const catMap = adjustments.get(record.category);
|
|
67
|
+
const current = catMap.get(record.model) ?? 0;
|
|
68
|
+
let delta;
|
|
69
|
+
switch (record.outcome) {
|
|
70
|
+
case 'continued':
|
|
71
|
+
delta = K_FACTOR * 0.6;
|
|
72
|
+
break;
|
|
73
|
+
case 'switched':
|
|
74
|
+
delta = -K_FACTOR * 1.0;
|
|
75
|
+
break;
|
|
76
|
+
case 'retried':
|
|
77
|
+
delta = -K_FACTOR * 0.8;
|
|
78
|
+
break;
|
|
79
|
+
case 'error':
|
|
80
|
+
delta = -K_FACTOR * 0.5;
|
|
81
|
+
break;
|
|
82
|
+
case 'payment':
|
|
83
|
+
delta = -K_FACTOR * 1.5;
|
|
84
|
+
break;
|
|
85
|
+
case 'max_turns':
|
|
86
|
+
delta = -K_FACTOR * 0.3;
|
|
87
|
+
break;
|
|
88
|
+
default: delta = 0;
|
|
89
|
+
}
|
|
90
|
+
catMap.set(record.model, current + delta);
|
|
91
|
+
}
|
|
92
|
+
catch { /* skip malformed lines */ }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch { /* ignore read errors */ }
|
|
96
|
+
return adjustments;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Get the effective Elo for a model in a category,
|
|
100
|
+
* blending global (server-trained) and local (user-specific) scores.
|
|
101
|
+
*
|
|
102
|
+
* effective = 0.7 * global + 0.3 * (1200 + local_delta)
|
|
103
|
+
*/
|
|
104
|
+
export function blendElo(globalElo, localDelta) {
|
|
105
|
+
const localElo = 1200 + localDelta;
|
|
106
|
+
return 0.7 * globalElo + 0.3 * localElo;
|
|
107
|
+
}
|