@rungate/llmrouter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +97 -0
- package/dist/scripts/proxy.d.ts +1 -0
- package/dist/scripts/proxy.js +3 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +5 -0
- package/dist/src/openclaw/config.d.ts +1 -0
- package/dist/src/openclaw/config.js +38 -0
- package/dist/src/openclaw/plugin.d.ts +30 -0
- package/dist/src/openclaw/plugin.js +47 -0
- package/dist/src/openclaw/runtime.d.ts +8 -0
- package/dist/src/openclaw/runtime.js +86 -0
- package/dist/src/payment/network.d.ts +619 -0
- package/dist/src/payment/network.js +16 -0
- package/dist/src/payment/wallet.d.ts +7 -0
- package/dist/src/payment/wallet.js +96 -0
- package/dist/src/payment/x402.d.ts +4 -0
- package/dist/src/payment/x402.js +37 -0
- package/dist/src/proxy/openai.d.ts +12 -0
- package/dist/src/proxy/openai.js +33 -0
- package/dist/src/proxy/server.d.ts +11 -0
- package/dist/src/proxy/server.js +206 -0
- package/dist/src/router/classify.d.ts +3 -0
- package/dist/src/router/classify.js +81 -0
- package/dist/src/router/models.d.ts +4 -0
- package/dist/src/router/models.js +27 -0
- package/dist/src/router/normalize.d.ts +1 -0
- package/dist/src/router/normalize.js +63 -0
- package/dist/src/router/route.d.ts +2 -0
- package/dist/src/router/route.js +25 -0
- package/dist/src/types.d.ts +65 -0
- package/dist/src/types.js +1 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +55 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { createPublicClient, formatEther, formatUnits, http } from 'viem';
|
|
5
|
+
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
|
|
6
|
+
import { resolveChain, resolveConfiguredNetwork, usdcAddressForNetwork } from './network.js';
|
|
7
|
+
const WALLET_DIR = join(homedir(), '.openclaw', 'llmrouter');
|
|
8
|
+
const WALLET_FILE = join(WALLET_DIR, 'wallet.key');
|
|
9
|
+
const ERC20_ABI = [
|
|
10
|
+
{
|
|
11
|
+
type: 'function',
|
|
12
|
+
name: 'balanceOf',
|
|
13
|
+
stateMutability: 'view',
|
|
14
|
+
inputs: [{ name: 'account', type: 'address' }],
|
|
15
|
+
outputs: [{ name: 'balance', type: 'uint256' }],
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
// Make sure the wallet directory exists before we read or write the key file.
|
|
19
|
+
function ensureWalletDir() {
|
|
20
|
+
mkdirSync(dirname(WALLET_FILE), { recursive: true });
|
|
21
|
+
}
|
|
22
|
+
// Expose the wallet file path for status commands and docs.
|
|
23
|
+
export function walletFilePath() {
|
|
24
|
+
return WALLET_FILE;
|
|
25
|
+
}
|
|
26
|
+
// Resolve the wallet in priority order: env override, saved key, then auto-generate.
|
|
27
|
+
export function resolveOrGenerateWalletKey(env = process.env) {
|
|
28
|
+
const envKey = env.LLM_ROUTER_WALLET_KEY;
|
|
29
|
+
if (typeof envKey === 'string' && envKey.startsWith('0x') && envKey.length === 66) {
|
|
30
|
+
const account = privateKeyToAccount(envKey);
|
|
31
|
+
return {
|
|
32
|
+
key: envKey,
|
|
33
|
+
info: {
|
|
34
|
+
address: account.address,
|
|
35
|
+
walletFile: WALLET_FILE,
|
|
36
|
+
network: resolveConfiguredNetwork(env.X402_NETWORK),
|
|
37
|
+
source: 'env',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
ensureWalletDir();
|
|
42
|
+
try {
|
|
43
|
+
const savedKey = readFileSync(WALLET_FILE, 'utf8').trim();
|
|
44
|
+
if (savedKey.startsWith('0x') && savedKey.length === 66) {
|
|
45
|
+
const account = privateKeyToAccount(savedKey);
|
|
46
|
+
return {
|
|
47
|
+
key: savedKey,
|
|
48
|
+
info: {
|
|
49
|
+
address: account.address,
|
|
50
|
+
walletFile: WALLET_FILE,
|
|
51
|
+
network: resolveConfiguredNetwork(env.X402_NETWORK),
|
|
52
|
+
source: 'saved',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// ignore and generate below
|
|
59
|
+
}
|
|
60
|
+
const key = generatePrivateKey();
|
|
61
|
+
writeFileSync(WALLET_FILE, `${key}\n`, { mode: 0o600 });
|
|
62
|
+
const account = privateKeyToAccount(key);
|
|
63
|
+
return {
|
|
64
|
+
key,
|
|
65
|
+
info: {
|
|
66
|
+
address: account.address,
|
|
67
|
+
walletFile: WALLET_FILE,
|
|
68
|
+
network: resolveConfiguredNetwork(env.X402_NETWORK),
|
|
69
|
+
source: 'generated',
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Fetch native and USDC balances for the active llm_router wallet.
|
|
74
|
+
export async function getWalletInfo(env = process.env) {
|
|
75
|
+
const wallet = resolveOrGenerateWalletKey(env);
|
|
76
|
+
const chain = resolveChain(wallet.info.network);
|
|
77
|
+
const client = createPublicClient({
|
|
78
|
+
chain,
|
|
79
|
+
transport: http(),
|
|
80
|
+
});
|
|
81
|
+
const [nativeBalance, usdcBalance] = await Promise.all([
|
|
82
|
+
client.getBalance({ address: wallet.info.address }),
|
|
83
|
+
client.readContract({
|
|
84
|
+
address: usdcAddressForNetwork(wallet.info.network),
|
|
85
|
+
abi: ERC20_ABI,
|
|
86
|
+
functionName: 'balanceOf',
|
|
87
|
+
args: [wallet.info.address],
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
return {
|
|
91
|
+
...wallet.info,
|
|
92
|
+
nativeBalance: formatEther(nativeBalance),
|
|
93
|
+
nativeSymbol: chain.nativeCurrency.symbol,
|
|
94
|
+
usdcBalance: formatUnits(usdcBalance, 6),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { x402Client, x402HTTPClient } from '@x402/fetch';
|
|
2
|
+
import { registerExactEvmScheme } from '@x402/evm/exact/client';
|
|
3
|
+
import { toClientEvmSigner } from '@x402/evm';
|
|
4
|
+
import { privateKeyToAccount } from 'viem/accounts';
|
|
5
|
+
import { createPublicClient, http } from 'viem';
|
|
6
|
+
import { resolveChain } from './network.js';
|
|
7
|
+
import { resolveOrGenerateWalletKey } from './wallet.js';
|
|
8
|
+
// Wrap fetch so a single upstream 402 challenge is paid and retried transparently.
|
|
9
|
+
export function createPaymentFetch(baseFetch, env = process.env) {
|
|
10
|
+
const wallet = resolveOrGenerateWalletKey(env);
|
|
11
|
+
const account = privateKeyToAccount(wallet.key);
|
|
12
|
+
const client = new x402Client();
|
|
13
|
+
const publicClient = createPublicClient({
|
|
14
|
+
chain: resolveChain(wallet.info.network),
|
|
15
|
+
transport: http(),
|
|
16
|
+
});
|
|
17
|
+
registerExactEvmScheme(client, { signer: toClientEvmSigner(account, publicClient) });
|
|
18
|
+
const httpClient = new x402HTTPClient(client);
|
|
19
|
+
return async (input, init) => {
|
|
20
|
+
const firstRequest = new Request(input, init);
|
|
21
|
+
const retryRequest = firstRequest.clone();
|
|
22
|
+
const firstResponse = await baseFetch(firstRequest);
|
|
23
|
+
if (firstResponse.status !== 402) {
|
|
24
|
+
return firstResponse;
|
|
25
|
+
}
|
|
26
|
+
const getHeader = (name) => firstResponse.headers.get(name);
|
|
27
|
+
const bodyText = await firstResponse.text();
|
|
28
|
+
const body = bodyText ? JSON.parse(bodyText) : undefined;
|
|
29
|
+
const paymentRequired = httpClient.getPaymentRequiredResponse(getHeader, body);
|
|
30
|
+
const payload = await client.createPaymentPayload(paymentRequired);
|
|
31
|
+
const paymentHeaders = httpClient.encodePaymentSignatureHeader(payload);
|
|
32
|
+
for (const [key, value] of Object.entries(paymentHeaders)) {
|
|
33
|
+
retryRequest.headers.set(key, value);
|
|
34
|
+
}
|
|
35
|
+
return baseFetch(retryRequest);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { RouterRequest } from '../types.js';
|
|
2
|
+
export type OpenAIMessage = {
|
|
3
|
+
role: string;
|
|
4
|
+
content: unknown;
|
|
5
|
+
};
|
|
6
|
+
export type OpenAIChatRequest = Record<string, unknown> & {
|
|
7
|
+
model: string;
|
|
8
|
+
messages: OpenAIMessage[];
|
|
9
|
+
stream?: boolean;
|
|
10
|
+
};
|
|
11
|
+
export declare function isChatCompletionRequest(body: unknown): body is OpenAIChatRequest;
|
|
12
|
+
export declare function toRouterRequest(body: OpenAIChatRequest): RouterRequest;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Normalize OpenAI message content into the smaller router request shape.
|
|
2
|
+
function normalizeContent(content) {
|
|
3
|
+
if (typeof content === 'string')
|
|
4
|
+
return content;
|
|
5
|
+
if (Array.isArray(content)) {
|
|
6
|
+
return content.filter((part) => typeof part === 'object' && part !== null);
|
|
7
|
+
}
|
|
8
|
+
return '';
|
|
9
|
+
}
|
|
10
|
+
// Guard that the incoming payload looks like a chat completion request before routing it.
|
|
11
|
+
export function isChatCompletionRequest(body) {
|
|
12
|
+
if (typeof body !== 'object' || body === null)
|
|
13
|
+
return false;
|
|
14
|
+
const record = body;
|
|
15
|
+
return typeof record.model === 'string' && Array.isArray(record.messages);
|
|
16
|
+
}
|
|
17
|
+
// Convert the OpenAI request payload into the router's internal request shape.
|
|
18
|
+
export function toRouterRequest(body) {
|
|
19
|
+
return {
|
|
20
|
+
model: body.model,
|
|
21
|
+
messages: body.messages.map((message) => ({
|
|
22
|
+
role: (typeof message.role === 'string' ? message.role : 'user'),
|
|
23
|
+
content: normalizeContent(message.content),
|
|
24
|
+
})),
|
|
25
|
+
...(Array.isArray(body.tools) ? { tools: body.tools } : {}),
|
|
26
|
+
...(typeof body.response_format === 'object' &&
|
|
27
|
+
body.response_format !== null &&
|
|
28
|
+
body.response_format.type === 'json_object'
|
|
29
|
+
? { responseFormat: 'json' }
|
|
30
|
+
: {}),
|
|
31
|
+
...(typeof body.max_tokens === 'number' ? { maxTokens: body.max_tokens } : {}),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type ProxyOptions = {
|
|
2
|
+
host?: string;
|
|
3
|
+
port?: number;
|
|
4
|
+
upstreamBaseUrl?: string;
|
|
5
|
+
};
|
|
6
|
+
export type RunningProxyServer = {
|
|
7
|
+
host: string;
|
|
8
|
+
port: number;
|
|
9
|
+
close: () => Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export declare function startProxyServer(options?: ProxyOptions): Promise<RunningProxyServer>;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { Readable } from 'node:stream';
|
|
3
|
+
import { createPaymentFetch } from '../payment/x402.js';
|
|
4
|
+
import { getWalletInfo } from '../payment/wallet.js';
|
|
5
|
+
import { normalizePromptText } from '../router/normalize.js';
|
|
6
|
+
import { LOGICAL_MODELS } from '../router/models.js';
|
|
7
|
+
import { routeRequest } from '../router/route.js';
|
|
8
|
+
import { isChatCompletionRequest, toRouterRequest } from './openai.js';
|
|
9
|
+
// Strip OpenClaw's metadata wrapper from the latest user turn for routing/logging only.
|
|
10
|
+
function normalizeLatestUserMessageForRouting(body) {
|
|
11
|
+
const latestUserIndex = [...body.messages].map((message, index) => ({ message, index })).reverse()
|
|
12
|
+
.find(({ message }) => message.role === 'user')?.index;
|
|
13
|
+
if (latestUserIndex === undefined) {
|
|
14
|
+
return {
|
|
15
|
+
body,
|
|
16
|
+
preview: '',
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const previewParts = [];
|
|
20
|
+
let changed = false;
|
|
21
|
+
const messages = body.messages.map((message, index) => {
|
|
22
|
+
if (index !== latestUserIndex)
|
|
23
|
+
return message;
|
|
24
|
+
if (typeof message.content === 'string') {
|
|
25
|
+
const normalized = normalizePromptText(message.content);
|
|
26
|
+
previewParts.push(normalized);
|
|
27
|
+
changed ||= normalized !== message.content;
|
|
28
|
+
return {
|
|
29
|
+
...message,
|
|
30
|
+
content: normalized,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (Array.isArray(message.content)) {
|
|
34
|
+
return {
|
|
35
|
+
...message,
|
|
36
|
+
content: message.content.map((part) => {
|
|
37
|
+
if (typeof part !== 'object' || part === null)
|
|
38
|
+
return part;
|
|
39
|
+
if ((part.type === 'text' || part.type === 'input_text') && typeof part.text === 'string') {
|
|
40
|
+
const normalized = normalizePromptText(part.text);
|
|
41
|
+
previewParts.push(normalized);
|
|
42
|
+
changed ||= normalized !== part.text;
|
|
43
|
+
return {
|
|
44
|
+
...part,
|
|
45
|
+
text: normalized,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
return part;
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return message;
|
|
53
|
+
});
|
|
54
|
+
return {
|
|
55
|
+
body: changed ? { ...body, messages } : body,
|
|
56
|
+
preview: previewParts.join('\n').slice(0, 120),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
// Read the full JSON request body before we normalize and route it.
|
|
60
|
+
function collectBody(req) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
let body = '';
|
|
63
|
+
req.setEncoding('utf8');
|
|
64
|
+
req.on('data', (chunk) => {
|
|
65
|
+
body += chunk;
|
|
66
|
+
});
|
|
67
|
+
req.on('end', () => resolve(body));
|
|
68
|
+
req.on('error', reject);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// Send a small JSON response for health, errors, and simple plugin endpoints.
|
|
72
|
+
function writeJson(res, statusCode, payload) {
|
|
73
|
+
res.statusCode = statusCode;
|
|
74
|
+
res.setHeader('content-type', 'application/json');
|
|
75
|
+
res.end(JSON.stringify(payload));
|
|
76
|
+
}
|
|
77
|
+
// Forward caller headers upstream, but strip transport/auth headers the proxy owns.
|
|
78
|
+
function copyRequestHeaders(req) {
|
|
79
|
+
const headers = {};
|
|
80
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
81
|
+
if (value === undefined)
|
|
82
|
+
continue;
|
|
83
|
+
if (['host', 'content-length', 'connection', 'authorization', 'x-provider-key', 'x-payment'].includes(key)) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
headers[key] = Array.isArray(value) ? value.join(', ') : value;
|
|
87
|
+
}
|
|
88
|
+
return headers;
|
|
89
|
+
}
|
|
90
|
+
// Mirror upstream headers back to the caller while leaving framing headers to Node.
|
|
91
|
+
function copyResponseHeaders(upstream, res) {
|
|
92
|
+
for (const [key, value] of upstream.headers.entries()) {
|
|
93
|
+
if (['content-length', 'connection', 'transfer-encoding'].includes(key))
|
|
94
|
+
continue;
|
|
95
|
+
res.setHeader(key, value);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Handle the only routed endpoint in this minimal version: chat completions.
|
|
99
|
+
async function handleChat(req, res, upstreamBaseUrl, payFetch) {
|
|
100
|
+
const raw = await collectBody(req);
|
|
101
|
+
const body = JSON.parse(raw);
|
|
102
|
+
if (!isChatCompletionRequest(body)) {
|
|
103
|
+
writeJson(res, 400, { error: { message: 'Invalid chat completion request' } });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
const normalizedLatestUser = normalizeLatestUserMessageForRouting(body);
|
|
107
|
+
const decision = routeRequest(toRouterRequest(normalizedLatestUser.body));
|
|
108
|
+
const upstreamBody = {
|
|
109
|
+
...body,
|
|
110
|
+
model: decision.resolvedModel,
|
|
111
|
+
};
|
|
112
|
+
console.info(JSON.stringify({
|
|
113
|
+
component: 'llm_router',
|
|
114
|
+
event: 'route_request',
|
|
115
|
+
requestPath: req.url ?? '/v1/chat/completions',
|
|
116
|
+
incomingModel: normalizedLatestUser.body.model,
|
|
117
|
+
latestUserPreview: normalizedLatestUser.preview,
|
|
118
|
+
logicalModel: decision.logicalModel,
|
|
119
|
+
category: decision.category,
|
|
120
|
+
resolvedModel: decision.resolvedModel,
|
|
121
|
+
reason: decision.reason,
|
|
122
|
+
hasTools: decision.hasTools,
|
|
123
|
+
wantsJson: decision.wantsJson,
|
|
124
|
+
hasImage: decision.hasImage,
|
|
125
|
+
}));
|
|
126
|
+
const upstreamResponse = await payFetch(new URL('/v1/chat/completions', upstreamBaseUrl), {
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: {
|
|
129
|
+
...copyRequestHeaders(req),
|
|
130
|
+
'content-type': 'application/json',
|
|
131
|
+
},
|
|
132
|
+
body: JSON.stringify(upstreamBody),
|
|
133
|
+
});
|
|
134
|
+
copyResponseHeaders(upstreamResponse, res);
|
|
135
|
+
res.setHeader('x-llm-router-logical-model', decision.logicalModel);
|
|
136
|
+
res.setHeader('x-llm-router-category', decision.category);
|
|
137
|
+
res.setHeader('x-llm-router-resolved-model', decision.resolvedModel);
|
|
138
|
+
res.statusCode = upstreamResponse.status;
|
|
139
|
+
if (!upstreamResponse.body) {
|
|
140
|
+
res.end();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
Readable.fromWeb(upstreamResponse.body).pipe(res);
|
|
144
|
+
}
|
|
145
|
+
// Start the local proxy that OpenClaw points at.
|
|
146
|
+
export async function startProxyServer(options = {}) {
|
|
147
|
+
const host = options.host ?? process.env.LLM_ROUTER_HOST ?? '127.0.0.1';
|
|
148
|
+
const port = options.port ?? Number(process.env.LLM_ROUTER_PORT ?? 3000);
|
|
149
|
+
const upstreamBaseUrl = options.upstreamBaseUrl ?? process.env.INFERENCE_PROVIDER_BASE_URL ?? 'http://127.0.0.1:8787';
|
|
150
|
+
const payFetch = createPaymentFetch(fetch, process.env);
|
|
151
|
+
const server = createServer(async (req, res) => {
|
|
152
|
+
try {
|
|
153
|
+
if (req.method === 'OPTIONS') {
|
|
154
|
+
res.writeHead(204, {
|
|
155
|
+
'access-control-allow-origin': '*',
|
|
156
|
+
'access-control-allow-methods': 'GET,POST,OPTIONS',
|
|
157
|
+
'access-control-allow-headers': 'Authorization, Content-Type, X-Provider-Key',
|
|
158
|
+
});
|
|
159
|
+
res.end();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
res.setHeader('access-control-allow-origin', '*');
|
|
163
|
+
res.setHeader('access-control-allow-methods', 'GET,POST,OPTIONS');
|
|
164
|
+
res.setHeader('access-control-allow-headers', 'Authorization, Content-Type, X-Provider-Key');
|
|
165
|
+
if (req.method === 'GET' && req.url === '/healthz') {
|
|
166
|
+
writeJson(res, 200, { ok: true });
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (req.method === 'GET' && (req.url === '/wallet' || req.url === '/v1/wallet')) {
|
|
170
|
+
writeJson(res, 200, await getWalletInfo(process.env));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (req.method === 'GET' && req.url === '/v1/models') {
|
|
174
|
+
writeJson(res, 200, {
|
|
175
|
+
data: LOGICAL_MODELS.map((id) => ({
|
|
176
|
+
id,
|
|
177
|
+
object: 'model',
|
|
178
|
+
owned_by: 'llm_router',
|
|
179
|
+
})),
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (req.method === 'POST' && req.url === '/v1/chat/completions') {
|
|
184
|
+
await handleChat(req, res, upstreamBaseUrl, payFetch);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
writeJson(res, 404, { error: { message: 'Not found' } });
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
writeJson(res, 500, {
|
|
191
|
+
error: {
|
|
192
|
+
message: error instanceof Error ? error.message : 'Internal server error',
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
await new Promise((resolve, reject) => {
|
|
198
|
+
server.once('error', reject);
|
|
199
|
+
server.listen(port, host, () => resolve());
|
|
200
|
+
});
|
|
201
|
+
return {
|
|
202
|
+
host,
|
|
203
|
+
port,
|
|
204
|
+
close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { PromptClassification, RouterRequest } from '../types.js';
|
|
2
|
+
export declare function requestSignals(request: RouterRequest): Pick<PromptClassification, 'hasTools' | 'wantsJson' | 'hasImage'>;
|
|
3
|
+
export declare function classifyPrompt(request: RouterRequest): PromptClassification;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { normalizePromptText } from './normalize.js';
|
|
2
|
+
const CODING_MARKERS = ['typescript', 'javascript', 'python', 'rust', 'bug', 'refactor', 'function', 'class'];
|
|
3
|
+
const REASONING_MARKERS = ['analyze', 'compare', 'tradeoff', 'deep dive', 'step by step', 'think more'];
|
|
4
|
+
// Flatten OpenAI-style content parts into plain text for simple keyword checks.
|
|
5
|
+
function extractText(content) {
|
|
6
|
+
if (typeof content === 'string')
|
|
7
|
+
return content;
|
|
8
|
+
return content
|
|
9
|
+
.flatMap((part) => {
|
|
10
|
+
if (typeof part !== 'object' || part === null)
|
|
11
|
+
return [];
|
|
12
|
+
if ((part.type === 'text' || part.type === 'input_text') && typeof part.text === 'string') {
|
|
13
|
+
return [part.text];
|
|
14
|
+
}
|
|
15
|
+
return [];
|
|
16
|
+
})
|
|
17
|
+
.join('\n');
|
|
18
|
+
}
|
|
19
|
+
// Detect whether any user message contains an image input part.
|
|
20
|
+
function hasImageInput(request) {
|
|
21
|
+
return request.messages.some((message) => Array.isArray(message.content) &&
|
|
22
|
+
message.content.some((part) => typeof part === 'object' &&
|
|
23
|
+
part !== null &&
|
|
24
|
+
(part.type === 'image_url' || part.type === 'input_image')));
|
|
25
|
+
}
|
|
26
|
+
// Minimal keyword matcher for the first-pass classifier.
|
|
27
|
+
function includesAny(text, markers) {
|
|
28
|
+
const normalized = text.toLowerCase();
|
|
29
|
+
return markers.some((marker) => normalized.includes(marker));
|
|
30
|
+
}
|
|
31
|
+
export function requestSignals(request) {
|
|
32
|
+
return {
|
|
33
|
+
hasTools: Boolean(request.tools && request.tools.length > 0),
|
|
34
|
+
wantsJson: request.responseFormat === 'json',
|
|
35
|
+
hasImage: hasImageInput(request),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
// Classify against the latest user prompt so older turns do not keep contaminating routing.
|
|
39
|
+
function latestUserText(request) {
|
|
40
|
+
const latestUserMessage = [...request.messages].reverse().find((message) => message.role === 'user');
|
|
41
|
+
return latestUserMessage ? normalizePromptText(extractText(latestUserMessage.content)).toLowerCase() : '';
|
|
42
|
+
}
|
|
43
|
+
// Classify the request into one of the few coarse routing buckets we support today.
|
|
44
|
+
export function classifyPrompt(request) {
|
|
45
|
+
const text = latestUserText(request);
|
|
46
|
+
const { hasTools, wantsJson, hasImage } = requestSignals(request);
|
|
47
|
+
if (hasImage) {
|
|
48
|
+
return {
|
|
49
|
+
category: 'vision',
|
|
50
|
+
reason: 'image input detected',
|
|
51
|
+
hasTools,
|
|
52
|
+
wantsJson,
|
|
53
|
+
hasImage,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
if (includesAny(text, CODING_MARKERS)) {
|
|
57
|
+
return {
|
|
58
|
+
category: 'coding',
|
|
59
|
+
reason: 'coding keywords detected',
|
|
60
|
+
hasTools,
|
|
61
|
+
wantsJson,
|
|
62
|
+
hasImage,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (includesAny(text, REASONING_MARKERS)) {
|
|
66
|
+
return {
|
|
67
|
+
category: 'reasoning',
|
|
68
|
+
reason: 'reasoning keywords detected',
|
|
69
|
+
hasTools,
|
|
70
|
+
wantsJson,
|
|
71
|
+
hasImage,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
category: 'simple',
|
|
76
|
+
reason: 'default simple route',
|
|
77
|
+
hasTools,
|
|
78
|
+
wantsJson,
|
|
79
|
+
hasImage,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { RouteCategory } from '../types.js';
|
|
2
|
+
export declare const LOGICAL_MODELS: readonly ["llmrouter/auto", "llmrouter/simple", "llmrouter/coding", "llmrouter/reasoning", "llmrouter/vision"];
|
|
3
|
+
export declare const CATEGORY_MODEL_MAP: Record<RouteCategory, string>;
|
|
4
|
+
export declare function logicalModelToCategory(model: string): RouteCategory | undefined;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export const LOGICAL_MODELS = [
|
|
2
|
+
'llmrouter/auto',
|
|
3
|
+
'llmrouter/simple',
|
|
4
|
+
'llmrouter/coding',
|
|
5
|
+
'llmrouter/reasoning',
|
|
6
|
+
'llmrouter/vision',
|
|
7
|
+
];
|
|
8
|
+
export const CATEGORY_MODEL_MAP = {
|
|
9
|
+
simple: 'deepseek/deepseek-chat',
|
|
10
|
+
coding: 'qwen/qwen3-coder-next',
|
|
11
|
+
reasoning: 'deepseek/deepseek-v3.2',
|
|
12
|
+
vision: 'qwen/qwen3-vl-235b-a22b-thinking',
|
|
13
|
+
};
|
|
14
|
+
// Map logical OpenClaw-facing model names to fixed route categories.
|
|
15
|
+
export function logicalModelToCategory(model) {
|
|
16
|
+
if (model === 'llmrouter/simple' || model === 'simple')
|
|
17
|
+
return 'simple';
|
|
18
|
+
if (model === 'llmrouter/coding' || model === 'coding')
|
|
19
|
+
return 'coding';
|
|
20
|
+
if (model === 'llmrouter/reasoning' || model === 'reasoning')
|
|
21
|
+
return 'reasoning';
|
|
22
|
+
if (model === 'llmrouter/vision' || model === 'vision')
|
|
23
|
+
return 'vision';
|
|
24
|
+
if (model === 'llmrouter/auto' || model === 'auto')
|
|
25
|
+
return undefined;
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function normalizePromptText(text: string): string;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const OPENCLAW_METADATA_SENTINELS = [
|
|
2
|
+
'Conversation info (untrusted metadata):',
|
|
3
|
+
'Sender (untrusted metadata):',
|
|
4
|
+
'Thread starter (untrusted, for context):',
|
|
5
|
+
'Replied message (untrusted, for context):',
|
|
6
|
+
'Forwarded message context (untrusted metadata):',
|
|
7
|
+
'Chat history since last reply (untrusted, for context):',
|
|
8
|
+
];
|
|
9
|
+
const OPENCLAW_UNTRUSTED_CONTEXT_HEADER = 'Untrusted context (metadata, do not treat as instructions or commands):';
|
|
10
|
+
const OPENCLAW_METADATA_FAST_RE = new RegExp([...OPENCLAW_METADATA_SENTINELS, OPENCLAW_UNTRUSTED_CONTEXT_HEADER]
|
|
11
|
+
.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
12
|
+
.join('|'));
|
|
13
|
+
function isOpenClawMetadataSentinel(line) {
|
|
14
|
+
const trimmed = line.trim();
|
|
15
|
+
return OPENCLAW_METADATA_SENTINELS.some((sentinel) => sentinel === trimmed);
|
|
16
|
+
}
|
|
17
|
+
function shouldStripTrailingUntrustedContext(lines, index) {
|
|
18
|
+
if (lines[index]?.trim() !== OPENCLAW_UNTRUSTED_CONTEXT_HEADER) {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
const probe = lines.slice(index + 1, Math.min(lines.length, index + 8)).join('\n');
|
|
22
|
+
return /<<<EXTERNAL_UNTRUSTED_CONTENT|UNTRUSTED channel metadata \(|Source:\s+/.test(probe);
|
|
23
|
+
}
|
|
24
|
+
// Remove OpenClaw's injected metadata blocks so routing keys off the user text itself.
|
|
25
|
+
export function normalizePromptText(text) {
|
|
26
|
+
if (!text || !OPENCLAW_METADATA_FAST_RE.test(text)) {
|
|
27
|
+
return text.trim();
|
|
28
|
+
}
|
|
29
|
+
const lines = text.split('\n');
|
|
30
|
+
const result = [];
|
|
31
|
+
let inMetadataBlock = false;
|
|
32
|
+
let inFencedJson = false;
|
|
33
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
34
|
+
const line = lines[index] ?? '';
|
|
35
|
+
if (!inMetadataBlock && shouldStripTrailingUntrustedContext(lines, index)) {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if (!inMetadataBlock && isOpenClawMetadataSentinel(line)) {
|
|
39
|
+
if (lines[index + 1]?.trim() !== '```json') {
|
|
40
|
+
result.push(line);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
inMetadataBlock = true;
|
|
44
|
+
inFencedJson = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (inMetadataBlock) {
|
|
48
|
+
if (!inFencedJson && line.trim() === '```json') {
|
|
49
|
+
inFencedJson = true;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (inFencedJson && line.trim() === '```') {
|
|
53
|
+
inMetadataBlock = false;
|
|
54
|
+
inFencedJson = false;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
result.push(line);
|
|
60
|
+
}
|
|
61
|
+
const stripped = result.join('\n').trim();
|
|
62
|
+
return stripped.length > 0 ? stripped : text.trim();
|
|
63
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { classifyPrompt, requestSignals } from './classify.js';
|
|
2
|
+
import { CATEGORY_MODEL_MAP, logicalModelToCategory } from './models.js';
|
|
3
|
+
function forcedClassification(request, category) {
|
|
4
|
+
return {
|
|
5
|
+
category,
|
|
6
|
+
reason: `logical model forced ${category}`,
|
|
7
|
+
...requestSignals(request),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
// Resolve a request to one concrete model using either a forced logical route or the basic classifier.
|
|
11
|
+
export function routeRequest(request) {
|
|
12
|
+
const forcedCategory = logicalModelToCategory(request.model);
|
|
13
|
+
const classification = forcedCategory
|
|
14
|
+
? forcedClassification(request, forcedCategory)
|
|
15
|
+
: classifyPrompt(request);
|
|
16
|
+
return {
|
|
17
|
+
logicalModel: request.model,
|
|
18
|
+
category: classification.category,
|
|
19
|
+
resolvedModel: CATEGORY_MODEL_MAP[classification.category],
|
|
20
|
+
reason: classification.reason,
|
|
21
|
+
hasTools: classification.hasTools,
|
|
22
|
+
wantsJson: classification.wantsJson,
|
|
23
|
+
hasImage: classification.hasImage,
|
|
24
|
+
};
|
|
25
|
+
}
|