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/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
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Model Definitions — Direct API (No BlockRun/x402)
2
+ * Model Definitions — FreeRouter
3
3
  *
4
4
  * Maps YOUR provider models with pricing for the cost calculator.
5
5
  * These match the models configured in your openclaw.json.