openclaw-freerouter 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +123 -64
- package/openclaw.plugin.json +96 -12
- package/package.json +32 -20
- package/src/cli.ts +378 -0
- package/src/index.ts +144 -0
- package/src/models.ts +1 -1
- package/src/provider.ts +563 -676
- package/src/router/config.ts +34 -21
- package/src/router/index.ts +9 -12
- package/src/router/rules.ts +1 -1
- package/src/router/selector.ts +1 -1
- package/src/router/types.ts +1 -2
- package/src/service.ts +773 -0
- package/CHANGELOG.md +0 -26
- package/index.ts +0 -63
- package/src/auth.ts +0 -128
- package/src/config.ts +0 -220
- package/src/logger.ts +0 -32
- package/src/server.ts +0 -381
package/src/cli.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FreeRouter CLI Commands
|
|
3
|
+
*
|
|
4
|
+
* - `openclaw freerouter setup` — Interactive onboarding wizard
|
|
5
|
+
* - `openclaw freerouter status` — Show status and stats
|
|
6
|
+
* - `openclaw freerouter test` — Run a quick classification test
|
|
7
|
+
* - `openclaw freerouter port <number>` — Change the proxy port
|
|
8
|
+
* - `openclaw freerouter reset` — Reset to default config
|
|
9
|
+
* - `openclaw freerouter doctor` — Diagnose and fix common issues
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getRoutingConfig, applyConfigOverrides } from "./router/config.js";
|
|
13
|
+
import { route } from "./router/index.js";
|
|
14
|
+
import { buildPricingMap } from "./models.js";
|
|
15
|
+
|
|
16
|
+
export function registerCli(api: any) {
|
|
17
|
+
const logger = api.logger ?? console;
|
|
18
|
+
|
|
19
|
+
const getPluginConfig = () => api.config?.plugins?.entries?.freerouter?.config ?? {};
|
|
20
|
+
|
|
21
|
+
api.registerCli(
|
|
22
|
+
({ program }: any) => {
|
|
23
|
+
const cmd = program.command("freerouter").description("FreeRouter — smart LLM model router");
|
|
24
|
+
|
|
25
|
+
// ─── status ───
|
|
26
|
+
cmd
|
|
27
|
+
.command("status")
|
|
28
|
+
.description("Show FreeRouter status, config, and routing stats")
|
|
29
|
+
.action(async () => {
|
|
30
|
+
const cfg = getPluginConfig();
|
|
31
|
+
const port = cfg.port ?? 18801;
|
|
32
|
+
const host = cfg.host ?? "127.0.0.1";
|
|
33
|
+
|
|
34
|
+
console.log(`\n FreeRouter v2.0.0\n`);
|
|
35
|
+
console.log(` Port: ${port === 0 ? "disabled (in-process only)" : `${host}:${port}`}`);
|
|
36
|
+
console.log(` Default: ${cfg.defaultTier ?? "MEDIUM"}`);
|
|
37
|
+
console.log();
|
|
38
|
+
|
|
39
|
+
// Show tier mapping
|
|
40
|
+
console.log(" Tier Mapping:");
|
|
41
|
+
const tiers = cfg.tiers ?? {};
|
|
42
|
+
const defaults = getRoutingConfig().tiers;
|
|
43
|
+
for (const tier of ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]) {
|
|
44
|
+
const t = tiers[tier] ?? defaults[tier as keyof typeof defaults];
|
|
45
|
+
const fb = t.fallback?.length ? ` → [${t.fallback.join(", ")}]` : "";
|
|
46
|
+
console.log(` ${tier.padEnd(10)} ${t.primary}${fb}`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Show thinking config
|
|
50
|
+
const thinking = cfg.thinking;
|
|
51
|
+
if (thinking) {
|
|
52
|
+
console.log();
|
|
53
|
+
console.log(" Thinking:");
|
|
54
|
+
if (thinking.adaptive?.length) console.log(` Adaptive: ${thinking.adaptive.join(", ")}`);
|
|
55
|
+
if (thinking.enabled?.models?.length) {
|
|
56
|
+
console.log(` Enabled: ${thinking.enabled.models.join(", ")} (budget: ${thinking.enabled.budget ?? 4096})`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Try to reach the proxy for live stats
|
|
61
|
+
if (port > 0) {
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`http://${host}:${port}/stats`, { signal: AbortSignal.timeout(2000) });
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
const stats = await res.json();
|
|
66
|
+
console.log();
|
|
67
|
+
console.log(" Live Stats:");
|
|
68
|
+
console.log(` Requests: ${stats.requests} | Errors: ${stats.errors} | Timeouts: ${stats.timeouts}`);
|
|
69
|
+
if (Object.keys(stats.byTier).some((k: string) => stats.byTier[k] > 0)) {
|
|
70
|
+
console.log(` By Tier: ${Object.entries(stats.byTier).filter(([, v]) => (v as number) > 0).map(([k, v]) => `${k}=${v}`).join(", ")}`);
|
|
71
|
+
}
|
|
72
|
+
if (Object.keys(stats.byModel).some((k: string) => stats.byModel[k] > 0)) {
|
|
73
|
+
console.log(` By Model: ${Object.entries(stats.byModel).filter(([, v]) => (v as number) > 0).map(([k, v]) => `${k}=${v}`).join(", ")}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
console.log();
|
|
78
|
+
console.log(" ⚠ Proxy not responding on port " + port);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
console.log();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ─── test ───
|
|
85
|
+
cmd
|
|
86
|
+
.command("test")
|
|
87
|
+
.description("Run a quick classification test with sample queries")
|
|
88
|
+
.action(() => {
|
|
89
|
+
const cfg = getPluginConfig();
|
|
90
|
+
const routingConfig = applyConfigOverrides(getRoutingConfig(), cfg);
|
|
91
|
+
const pricing = buildPricingMap();
|
|
92
|
+
|
|
93
|
+
const queries = [
|
|
94
|
+
{ q: "What is 2+2?", expect: "SIMPLE" },
|
|
95
|
+
{ q: "Hello", expect: "SIMPLE" },
|
|
96
|
+
{ q: "Write a Python function to reverse a string", expect: "MEDIUM+" },
|
|
97
|
+
{ q: "Design a distributed database architecture", expect: "MEDIUM+" },
|
|
98
|
+
{ q: "Prove step by step that sqrt(2) is irrational", expect: "REASONING" },
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
console.log("\n FreeRouter Classification Test\n");
|
|
102
|
+
let pass = 0;
|
|
103
|
+
for (const { q, expect } of queries) {
|
|
104
|
+
const r = route(q, undefined, 100, { config: routingConfig, modelPricing: pricing });
|
|
105
|
+
const ok = expect === "MEDIUM+"
|
|
106
|
+
? ["MEDIUM", "COMPLEX", "REASONING"].includes(r.tier)
|
|
107
|
+
: r.tier === expect;
|
|
108
|
+
const icon = ok ? "✓" : "✗";
|
|
109
|
+
console.log(` ${icon} "${q.slice(0, 50)}"`);
|
|
110
|
+
console.log(` → ${r.tier} → ${r.model} (conf=${r.confidence.toFixed(2)})`);
|
|
111
|
+
if (ok) pass++;
|
|
112
|
+
}
|
|
113
|
+
console.log(`\n ${pass}/${queries.length} passed\n`);
|
|
114
|
+
if (pass < queries.length) process.exit(1);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// ─── port ───
|
|
118
|
+
cmd
|
|
119
|
+
.command("port <number>")
|
|
120
|
+
.description("Change the HTTP proxy port (0 to disable)")
|
|
121
|
+
.action(async (portStr: string) => {
|
|
122
|
+
const port = parseInt(portStr, 10);
|
|
123
|
+
if (isNaN(port) || port < 0 || port > 65535) {
|
|
124
|
+
console.error(" ✗ Invalid port. Must be 0-65535.");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Check if port is available
|
|
129
|
+
if (port > 0) {
|
|
130
|
+
const net = await import("node:net");
|
|
131
|
+
const available = await new Promise<boolean>((resolve) => {
|
|
132
|
+
const server = net.createServer();
|
|
133
|
+
server.on("error", () => resolve(false));
|
|
134
|
+
server.listen(port, "127.0.0.1", () => { server.close(); resolve(true); });
|
|
135
|
+
});
|
|
136
|
+
if (!available) {
|
|
137
|
+
console.error(` ✗ Port ${port} is already in use.`);
|
|
138
|
+
console.log(" Try: openclaw freerouter doctor");
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
console.log(` Setting FreeRouter port to ${port === 0 ? "disabled" : port}...`);
|
|
144
|
+
console.log(" Restart the gateway to apply: openclaw gateway restart");
|
|
145
|
+
console.log();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ─── reset ───
|
|
149
|
+
cmd
|
|
150
|
+
.command("reset")
|
|
151
|
+
.description("Reset FreeRouter config to defaults")
|
|
152
|
+
.action(() => {
|
|
153
|
+
const defaults = getRoutingConfig();
|
|
154
|
+
console.log("\n Default FreeRouter config:\n");
|
|
155
|
+
console.log(JSON.stringify({
|
|
156
|
+
port: 18801,
|
|
157
|
+
host: "127.0.0.1",
|
|
158
|
+
tiers: defaults.tiers,
|
|
159
|
+
defaultTier: "MEDIUM",
|
|
160
|
+
thinking: {
|
|
161
|
+
adaptive: ["claude-opus-4-6"],
|
|
162
|
+
enabled: { models: ["claude-sonnet-4-5"], budget: 4096 },
|
|
163
|
+
},
|
|
164
|
+
}, null, 2));
|
|
165
|
+
console.log("\n Copy this into plugins.entries.freerouter.config in openclaw.json");
|
|
166
|
+
console.log(" Or run: openclaw config set plugins.entries.freerouter.config '{...}'");
|
|
167
|
+
console.log();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ─── doctor ───
|
|
171
|
+
cmd
|
|
172
|
+
.command("doctor")
|
|
173
|
+
.description("Diagnose and fix common FreeRouter issues")
|
|
174
|
+
.action(async () => {
|
|
175
|
+
console.log("\n FreeRouter Doctor\n");
|
|
176
|
+
let issues = 0;
|
|
177
|
+
|
|
178
|
+
// 1. Check config exists
|
|
179
|
+
const cfg = getPluginConfig();
|
|
180
|
+
if (!cfg || Object.keys(cfg).length === 0) {
|
|
181
|
+
console.log(" ⚠ No plugin config found. Run: openclaw freerouter setup");
|
|
182
|
+
issues++;
|
|
183
|
+
} else {
|
|
184
|
+
console.log(" ✓ Plugin config found");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 2. Check port
|
|
188
|
+
const port = cfg.port ?? 18801;
|
|
189
|
+
if (port > 0) {
|
|
190
|
+
try {
|
|
191
|
+
const res = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(2000) });
|
|
192
|
+
if (res.ok) {
|
|
193
|
+
const data = await res.json();
|
|
194
|
+
console.log(` ✓ Proxy healthy on port ${port} (v${data.version})`);
|
|
195
|
+
} else {
|
|
196
|
+
console.log(` ⚠ Proxy on port ${port} returned ${res.status}`);
|
|
197
|
+
issues++;
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
console.log(` ✗ Proxy not responding on port ${port}`);
|
|
201
|
+
|
|
202
|
+
// Check if port is occupied by something else
|
|
203
|
+
const net = await import("node:net");
|
|
204
|
+
const occupied = await new Promise<boolean>((resolve) => {
|
|
205
|
+
const server = net.createServer();
|
|
206
|
+
server.on("error", () => resolve(true));
|
|
207
|
+
server.listen(port, "127.0.0.1", () => { server.close(); resolve(false); });
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
if (occupied) {
|
|
211
|
+
console.log(` Port ${port} is in use by another process.`);
|
|
212
|
+
console.log(` Fix: openclaw freerouter port ${port + 1}`);
|
|
213
|
+
} else {
|
|
214
|
+
console.log(" Port is free but proxy isn't running.");
|
|
215
|
+
console.log(" Fix: Restart gateway — openclaw gateway restart");
|
|
216
|
+
}
|
|
217
|
+
issues++;
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
console.log(" ○ HTTP proxy disabled (port=0)");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// 3. Check tier config
|
|
224
|
+
const tiers = cfg.tiers ?? {};
|
|
225
|
+
const validTiers = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
|
|
226
|
+
for (const tier of validTiers) {
|
|
227
|
+
const t = tiers[tier];
|
|
228
|
+
if (t && !t.primary) {
|
|
229
|
+
console.log(` ⚠ ${tier} tier has no primary model`);
|
|
230
|
+
issues++;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (Object.keys(tiers).length > 0) {
|
|
234
|
+
console.log(` ✓ Tier config: ${Object.keys(tiers).join(", ")}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 4. Check OpenClaw model config
|
|
238
|
+
const modelCfg = api.config?.agents?.defaults?.model;
|
|
239
|
+
if (modelCfg?.primary?.includes("freerouter")) {
|
|
240
|
+
console.log(` ✓ OpenClaw default model: ${modelCfg.primary}`);
|
|
241
|
+
} else {
|
|
242
|
+
console.log(` ○ OpenClaw default model is not FreeRouter: ${modelCfg?.primary ?? "not set"}`);
|
|
243
|
+
console.log(" To enable: set agents.defaults.model.primary to 'freerouter/freerouter/auto'");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// 5. Check provider config
|
|
247
|
+
const providerCfg = api.config?.models?.providers?.freerouter;
|
|
248
|
+
if (providerCfg?.baseUrl) {
|
|
249
|
+
console.log(` ✓ Provider config: ${providerCfg.baseUrl}`);
|
|
250
|
+
} else {
|
|
251
|
+
console.log(" ⚠ No FreeRouter provider in models.providers");
|
|
252
|
+
console.log(" Add to openclaw.json: models.providers.freerouter = { baseUrl: 'http://127.0.0.1:" + port + "/v1', api: 'openai-completions' }");
|
|
253
|
+
issues++;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 6. Quick classification test
|
|
257
|
+
try {
|
|
258
|
+
const routingConfig = applyConfigOverrides(getRoutingConfig(), cfg);
|
|
259
|
+
const pricing = buildPricingMap();
|
|
260
|
+
const r = route("What is 2+2?", undefined, 100, { config: routingConfig, modelPricing: pricing });
|
|
261
|
+
if (r.tier === "SIMPLE") {
|
|
262
|
+
console.log(" ✓ Classification engine working (SIMPLE → " + r.model + ")");
|
|
263
|
+
} else {
|
|
264
|
+
console.log(` ⚠ Classification may be miscalibrated: "What is 2+2?" → ${r.tier}`);
|
|
265
|
+
issues++;
|
|
266
|
+
}
|
|
267
|
+
} catch (err: any) {
|
|
268
|
+
console.log(` ✗ Classification engine error: ${err.message}`);
|
|
269
|
+
issues++;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log();
|
|
273
|
+
if (issues === 0) {
|
|
274
|
+
console.log(" All checks passed! ✓");
|
|
275
|
+
} else {
|
|
276
|
+
console.log(` ${issues} issue(s) found. See suggestions above.`);
|
|
277
|
+
}
|
|
278
|
+
console.log();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// ─── setup (onboarding wizard) ───
|
|
282
|
+
cmd
|
|
283
|
+
.command("setup")
|
|
284
|
+
.description("Interactive onboarding wizard (optional)")
|
|
285
|
+
.option("--port <number>", "HTTP proxy port", "18801")
|
|
286
|
+
.option("--simple <model>", "Model for SIMPLE tier")
|
|
287
|
+
.option("--medium <model>", "Model for MEDIUM tier")
|
|
288
|
+
.option("--complex <model>", "Model for COMPLEX tier")
|
|
289
|
+
.option("--reasoning <model>", "Model for REASONING tier")
|
|
290
|
+
.option("--json", "Output config as JSON (non-interactive)")
|
|
291
|
+
.action((opts: any) => {
|
|
292
|
+
const port = parseInt(opts.port, 10) || 18801;
|
|
293
|
+
|
|
294
|
+
// Default models
|
|
295
|
+
const simple = opts.simple ?? "kimi-coding/kimi-for-coding";
|
|
296
|
+
const medium = opts.medium ?? "anthropic/claude-sonnet-4-5";
|
|
297
|
+
const complex = opts.complex ?? "anthropic/claude-opus-4-6";
|
|
298
|
+
const reasoning = opts.reasoning ?? "anthropic/claude-opus-4-6";
|
|
299
|
+
|
|
300
|
+
const pluginConfig = {
|
|
301
|
+
port,
|
|
302
|
+
host: "127.0.0.1",
|
|
303
|
+
tiers: {
|
|
304
|
+
SIMPLE: { primary: simple, fallback: [] },
|
|
305
|
+
MEDIUM: { primary: medium, fallback: [complex] },
|
|
306
|
+
COMPLEX: { primary: complex, fallback: [] },
|
|
307
|
+
REASONING: { primary: reasoning, fallback: [] },
|
|
308
|
+
},
|
|
309
|
+
thinking: {
|
|
310
|
+
adaptive: ["claude-opus-4-6"],
|
|
311
|
+
enabled: { models: ["claude-sonnet-4-5"], budget: 4096 },
|
|
312
|
+
},
|
|
313
|
+
defaultTier: "MEDIUM",
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const providerConfig = {
|
|
317
|
+
baseUrl: `http://127.0.0.1:${port}/v1`,
|
|
318
|
+
api: "openai-completions",
|
|
319
|
+
models: [{
|
|
320
|
+
id: "freerouter/auto",
|
|
321
|
+
name: "FreeRouter Auto",
|
|
322
|
+
input: ["text"],
|
|
323
|
+
}],
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
if (opts.json) {
|
|
327
|
+
console.log(JSON.stringify({ pluginConfig, providerConfig }, null, 2));
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
console.log(`
|
|
332
|
+
╔══════════════════════════════════════════╗
|
|
333
|
+
║ FreeRouter Setup Wizard ║
|
|
334
|
+
╚══════════════════════════════════════════╝
|
|
335
|
+
|
|
336
|
+
Step 1: Add this to your openclaw.json under "plugins.entries":
|
|
337
|
+
|
|
338
|
+
"freerouter": {
|
|
339
|
+
"enabled": true,
|
|
340
|
+
"config": ${JSON.stringify(pluginConfig, null, 6).split("\n").map((l, i) => i === 0 ? l : " " + l).join("\n")}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
Step 2: Add this under "models.providers":
|
|
344
|
+
|
|
345
|
+
"freerouter": ${JSON.stringify(providerConfig, null, 4).split("\n").map((l, i) => i === 0 ? l : " " + l).join("\n")}
|
|
346
|
+
|
|
347
|
+
Step 3: Set FreeRouter as your default model:
|
|
348
|
+
|
|
349
|
+
"agents": {
|
|
350
|
+
"defaults": {
|
|
351
|
+
"model": {
|
|
352
|
+
"primary": "freerouter/freerouter/auto",
|
|
353
|
+
"fallbacks": ["anthropic/claude-opus-4-6"]
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
Step 4: Restart the gateway:
|
|
359
|
+
|
|
360
|
+
openclaw gateway restart
|
|
361
|
+
|
|
362
|
+
Step 5: Verify:
|
|
363
|
+
|
|
364
|
+
openclaw freerouter doctor
|
|
365
|
+
|
|
366
|
+
Tier Mapping:
|
|
367
|
+
SIMPLE → ${simple} (quick lookups, translations)
|
|
368
|
+
MEDIUM → ${medium} (code, creative writing)
|
|
369
|
+
COMPLEX → ${complex} (architecture, deep analysis)
|
|
370
|
+
REASONING → ${reasoning} (proofs, formal logic)
|
|
371
|
+
|
|
372
|
+
Port: ${port}
|
|
373
|
+
`);
|
|
374
|
+
});
|
|
375
|
+
},
|
|
376
|
+
{ commands: ["freerouter"] },
|
|
377
|
+
);
|
|
378
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FreeRouter — OpenClaw Plugin
|
|
3
|
+
*
|
|
4
|
+
* Smart LLM router that classifies requests across 14 weighted dimensions
|
|
5
|
+
* and routes to the best model from your configured providers.
|
|
6
|
+
*
|
|
7
|
+
* Runs an in-process OpenAI-compatible HTTP proxy on a configurable port.
|
|
8
|
+
* OpenClaw sends requests with model "freerouter/auto" → FreeRouter classifies
|
|
9
|
+
* and forwards to the real provider, returning the actual model name.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createProxyServer, type ProxyStats } from "./service.js";
|
|
13
|
+
import { registerCli } from "./cli.js";
|
|
14
|
+
import type { Server } from "node:http";
|
|
15
|
+
|
|
16
|
+
// Plugin state
|
|
17
|
+
let server: Server | null = null;
|
|
18
|
+
let stats: ProxyStats | null = null;
|
|
19
|
+
|
|
20
|
+
export const id = "freerouter";
|
|
21
|
+
|
|
22
|
+
export default function register(api: any) {
|
|
23
|
+
const logger = api.logger ?? console;
|
|
24
|
+
const getPluginConfig = () => {
|
|
25
|
+
const cfg = api.config?.plugins?.entries?.freerouter?.config ?? {};
|
|
26
|
+
return cfg;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// ─── Background Service: HTTP Proxy ───
|
|
30
|
+
api.registerService({
|
|
31
|
+
id: "freerouter-proxy",
|
|
32
|
+
start: () => {
|
|
33
|
+
const cfg = getPluginConfig();
|
|
34
|
+
const port = cfg.port ?? 18801;
|
|
35
|
+
const host = cfg.host ?? "127.0.0.1";
|
|
36
|
+
|
|
37
|
+
if (port === 0) {
|
|
38
|
+
logger.info("[freerouter] HTTP proxy disabled (port=0)");
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = createProxyServer({
|
|
44
|
+
port,
|
|
45
|
+
host,
|
|
46
|
+
pluginConfig: cfg,
|
|
47
|
+
openclawConfig: api.config,
|
|
48
|
+
logger,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
server = result.server;
|
|
52
|
+
stats = result.stats;
|
|
53
|
+
|
|
54
|
+
// Handle port conflicts gracefully
|
|
55
|
+
server.on("error", (err: any) => {
|
|
56
|
+
if (err.code === "EADDRINUSE") {
|
|
57
|
+
logger.error(`[freerouter] Port ${port} is already in use. Run: openclaw freerouter doctor`);
|
|
58
|
+
logger.error(`[freerouter] To change port: set plugins.entries.freerouter.config.port in openclaw.json`);
|
|
59
|
+
} else {
|
|
60
|
+
logger.error(`[freerouter] Server error: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
server = null;
|
|
63
|
+
stats = null;
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
logger.info(`[freerouter] Proxy listening on http://${host}:${port}`);
|
|
67
|
+
} catch (err: any) {
|
|
68
|
+
logger.error(`[freerouter] Failed to start proxy: ${err.message}`);
|
|
69
|
+
logger.error(`[freerouter] Run: openclaw freerouter doctor`);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
stop: () => {
|
|
73
|
+
if (server) {
|
|
74
|
+
server.close();
|
|
75
|
+
server = null;
|
|
76
|
+
logger.info("[freerouter] Proxy stopped");
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ─── CLI Commands (status, test, port, reset, doctor, setup) ───
|
|
82
|
+
registerCli(api);
|
|
83
|
+
|
|
84
|
+
// ─── Auto-Reply Command: /freerouter ───
|
|
85
|
+
api.registerCommand({
|
|
86
|
+
name: "freerouter",
|
|
87
|
+
description: "Show FreeRouter routing stats",
|
|
88
|
+
handler: () => {
|
|
89
|
+
if (!stats) {
|
|
90
|
+
return { text: "🔌 FreeRouter is not running. Enable it in plugins config." };
|
|
91
|
+
}
|
|
92
|
+
const cfg = getPluginConfig();
|
|
93
|
+
const lines = [
|
|
94
|
+
"📊 **FreeRouter Stats**",
|
|
95
|
+
`Port: ${cfg.port ?? 18801} | Requests: ${stats.requests} | Errors: ${stats.errors} | Timeouts: ${stats.timeouts}`,
|
|
96
|
+
"",
|
|
97
|
+
"**By Tier:**",
|
|
98
|
+
...Object.entries(stats.byTier)
|
|
99
|
+
.filter(([, v]) => v > 0)
|
|
100
|
+
.map(([k, v]) => ` ${k}: ${v}`),
|
|
101
|
+
"",
|
|
102
|
+
"**By Model:**",
|
|
103
|
+
...Object.entries(stats.byModel)
|
|
104
|
+
.filter(([, v]) => v > 0)
|
|
105
|
+
.map(([k, v]) => ` ${k}: ${v}`),
|
|
106
|
+
];
|
|
107
|
+
return { text: lines.join("\n") };
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ─── Auto-Reply Command: /freerouter-doctor ───
|
|
112
|
+
api.registerCommand({
|
|
113
|
+
name: "freerouter-doctor",
|
|
114
|
+
description: "Quick health check of FreeRouter",
|
|
115
|
+
handler: () => {
|
|
116
|
+
const cfg = getPluginConfig();
|
|
117
|
+
const port = cfg.port ?? 18801;
|
|
118
|
+
const issues: string[] = [];
|
|
119
|
+
const ok: string[] = [];
|
|
120
|
+
|
|
121
|
+
if (!cfg || Object.keys(cfg).length === 0) {
|
|
122
|
+
issues.push("No plugin config found");
|
|
123
|
+
} else {
|
|
124
|
+
ok.push("Config loaded");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (server && stats) {
|
|
128
|
+
ok.push(`Proxy running on :${port} (${stats.requests} requests)`);
|
|
129
|
+
} else if (port > 0) {
|
|
130
|
+
issues.push(`Proxy not running (expected on :${port})`);
|
|
131
|
+
} else {
|
|
132
|
+
ok.push("Proxy disabled (port=0)");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const lines = ["🩺 **FreeRouter Doctor**", ""];
|
|
136
|
+
for (const o of ok) lines.push(`✓ ${o}`);
|
|
137
|
+
for (const i of issues) lines.push(`⚠ ${i}`);
|
|
138
|
+
if (issues.length === 0) lines.push("", "All good! ✓");
|
|
139
|
+
else lines.push("", `${issues.length} issue(s). Run \`openclaw freerouter doctor\` for details.`);
|
|
140
|
+
|
|
141
|
+
return { text: lines.join("\n") };
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
}
|
package/src/models.ts
CHANGED