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/CHANGELOG.md DELETED
@@ -1,26 +0,0 @@
1
- # Changelog
2
-
3
- ## 1.3.0 (2026-02-14)
4
-
5
- Initial release as OpenClaw plugin.
6
-
7
- ### Features
8
- - 14-dimension weighted classifier for smart model routing
9
- - Auto-classify requests → route to cheapest capable model
10
- - Tier system: SIMPLE → MEDIUM → COMPLEX → REASONING
11
- - Agentic task detection with separate tier configs
12
- - Mode overrides (`/max`, `[simple]`, `reasoning mode:`)
13
- - Adaptive thinking for Opus 4.6+
14
- - Configurable providers, tiers, and boundaries via plugin config
15
- - Fallback chains per tier
16
- - Timeout + stall detection per tier
17
- - OpenAI-compatible API (Anthropic Messages ↔ OpenAI translation)
18
- - Tool call support (OpenAI ↔ Anthropic conversion)
19
- - Streaming with SSE format translation
20
- - Zero external dependencies
21
-
22
- ### Plugin Integration
23
- - Starts/stops with OpenClaw gateway lifecycle
24
- - Config via `plugins.entries.freerouter.config` in openclaw.json
25
- - Auto-registers as `freerouter` provider
26
- - Reads auth from OpenClaw's auth-profiles.json
package/index.ts DELETED
@@ -1,63 +0,0 @@
1
- /**
2
- * FreeRouter — OpenClaw Plugin Entry Point
3
- *
4
- * Starts the FreeRouter proxy server as a background service
5
- * and registers it as a model provider.
6
- */
7
-
8
- import { startServer, stopServer } from "./src/server.js";
9
-
10
- export default {
11
- id: "freerouter",
12
- name: "FreeRouter",
13
-
14
- async register(api: any) {
15
- // Read plugin config
16
- const pluginConfig = api.config?.plugins?.entries?.freerouter?.config ?? {};
17
- const port = pluginConfig.port ?? 18800;
18
- const host = pluginConfig.host ?? "127.0.0.1";
19
-
20
- api.log?.info?.(`[FreeRouter] Starting proxy on ${host}:${port}...`);
21
-
22
- try {
23
- // Start the proxy server
24
- await startServer({
25
- port,
26
- host,
27
- pluginConfig,
28
- });
29
-
30
- api.log?.info?.(`[FreeRouter] Proxy running on http://${host}:${port}`);
31
-
32
- // Register as a provider if the API supports it
33
- if (api.registerProvider) {
34
- api.registerProvider({
35
- id: "freerouter",
36
- name: "FreeRouter",
37
- baseUrl: `http://${host}:${port}/v1`,
38
- api: "openai",
39
- models: [
40
- { id: "freerouter/auto", name: "FreeRouter Auto", description: "Auto-routes to best model" },
41
- ],
42
- });
43
- api.log?.info?.("[FreeRouter] Registered as provider 'freerouter'");
44
- }
45
-
46
- // Register shutdown handler
47
- if (api.onShutdown) {
48
- api.onShutdown(async () => {
49
- api.log?.info?.("[FreeRouter] Shutting down proxy...");
50
- await stopServer();
51
- });
52
- }
53
- } catch (err: any) {
54
- api.log?.error?.(`[FreeRouter] Failed to start: ${err.message}`);
55
- throw err;
56
- }
57
- },
58
- };
59
-
60
- // Re-export router for programmatic use
61
- export { route, DEFAULT_ROUTING_CONFIG } from "./src/router/index.js";
62
- export { startServer, stopServer } from "./src/server.js";
63
- export type { RoutingDecision, Tier, RoutingConfig } from "./src/router/types.js";
package/src/auth.ts DELETED
@@ -1,128 +0,0 @@
1
- /**
2
- * ClawRouter Auth — loads API keys from OpenClaw auth-profiles.json
3
- * Zero-dep, reads from ~/.openclaw/agents/main/agent/auth-profiles.json
4
- */
5
-
6
- import { readFileSync, existsSync } from "node:fs";
7
- import { getConfig } from "./config.js";
8
- import { join } from "node:path";
9
- import { homedir } from "node:os";
10
- import { logger } from "./logger.js";
11
-
12
- export type ProviderAuth = {
13
- provider: string;
14
- profileName: string;
15
- token?: string; // Anthropic OAuth token
16
- apiKey?: string; // API key (Kimi, OpenAI)
17
- };
18
-
19
- type AuthProfilesFile = {
20
- version: number;
21
- profiles: Record<string, {
22
- type: "token" | "api_key";
23
- provider: string;
24
- token?: string;
25
- key?: string;
26
- }>;
27
- lastGood?: Record<string, string>;
28
- };
29
-
30
- let authCache: Map<string, ProviderAuth> | null = null;
31
-
32
- function loadAuthProfiles(): Map<string, ProviderAuth> {
33
- // Get path from config, fall back to default
34
- const cfg = getConfig();
35
- const authCfg = cfg.auth;
36
- const defaultAuth = authCfg[authCfg.default] as { type?: string; profilesPath?: string } | undefined;
37
- let filePath: string;
38
- if (defaultAuth?.profilesPath) {
39
- const p = defaultAuth.profilesPath;
40
- filePath = p.startsWith("~/") ? join(homedir(), p.slice(2)) : p;
41
- } else {
42
- filePath = join(homedir(), ".openclaw", "agents", "main", "agent", "auth-profiles.json");
43
- }
44
- try {
45
- const raw = readFileSync(filePath, "utf-8");
46
- const data: AuthProfilesFile = JSON.parse(raw);
47
- const map = new Map<string, ProviderAuth>();
48
-
49
- // Build a map of provider → best profile (prefer lastGood)
50
- const lastGood = data.lastGood ?? {};
51
-
52
- for (const [name, profile] of Object.entries(data.profiles)) {
53
- const provider = profile.provider;
54
- const existing = map.get(provider);
55
-
56
- // Prefer lastGood profile
57
- const isLastGood = lastGood[provider] === name;
58
- if (existing && !isLastGood) continue;
59
-
60
- map.set(provider, {
61
- provider,
62
- profileName: name,
63
- token: profile.type === "token" ? profile.token : undefined,
64
- apiKey: profile.type === "api_key" ? profile.key : undefined,
65
- });
66
- }
67
-
68
- logger.info(`Loaded auth for providers: ${[...map.keys()].join(", ")}`);
69
- return map;
70
- } catch (err) {
71
- logger.error("Failed to load auth-profiles.json:", err);
72
- return new Map();
73
- }
74
- }
75
-
76
- export function getAuth(provider: string): ProviderAuth | undefined {
77
- // Check env var auth first (per-provider config override)
78
- const envAuth = getEnvAuth(provider);
79
- if (envAuth) return envAuth;
80
-
81
- // Fall back to auth-profiles.json
82
- if (!authCache) {
83
- authCache = loadAuthProfiles();
84
- }
85
- return authCache.get(provider);
86
- }
87
-
88
-
89
-
90
- /**
91
- * Get auth from environment variable (for providers with auth.type=env in config).
92
- */
93
- function getEnvAuth(provider: string): ProviderAuth | undefined {
94
- const cfg = getConfig();
95
- const providerCfg = cfg.providers[provider];
96
- if (!providerCfg?.auth || providerCfg.auth.type !== "env") return undefined;
97
- const envKey = providerCfg.auth.key;
98
- if (!envKey) return undefined;
99
- const value = process.env[envKey];
100
- if (!value) return undefined;
101
- return {
102
- provider,
103
- profileName: envKey,
104
- apiKey: value,
105
- };
106
- }
107
-
108
- export function reloadAuth(): void {
109
- authCache = null;
110
- logger.info("Auth cache cleared, will reload on next access");
111
- }
112
-
113
- /**
114
- * Get the authorization header value for a provider.
115
- */
116
- export function getAuthHeader(provider: string): string | undefined {
117
- const auth = getAuth(provider);
118
- if (!auth) return undefined;
119
-
120
- if (auth.token) {
121
- // Anthropic uses x-api-key header, not Authorization
122
- return auth.token;
123
- }
124
- if (auth.apiKey) {
125
- return auth.apiKey;
126
- }
127
- return undefined;
128
- }
package/src/config.ts DELETED
@@ -1,220 +0,0 @@
1
- /**
2
- * FreeRouter Config — OpenClaw Plugin Edition
3
- *
4
- * Reads config from plugin API (plugins.entries.freerouter.config)
5
- * instead of freerouter.config.json. Falls back to built-in defaults.
6
- */
7
-
8
- import { readFileSync, existsSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { homedir } from "node:os";
11
- import { logger } from "./logger.js";
12
-
13
- // ═══ Config Types ═══
14
-
15
- export type AuthConfig = {
16
- type: "openclaw" | "env" | "file" | "keychain";
17
- key?: string;
18
- profilesPath?: string;
19
- filePath?: string;
20
- service?: string;
21
- account?: string;
22
- };
23
-
24
- export type ProviderConfigEntry = {
25
- baseUrl: string;
26
- api: "anthropic" | "openai";
27
- headers?: Record<string, string>;
28
- auth?: AuthConfig;
29
- };
30
-
31
- export type TierMapping = {
32
- primary: string;
33
- fallback: string[];
34
- };
35
-
36
- export type ThinkingConfig = {
37
- adaptive?: string[];
38
- enabled?: { models: string[]; budget: number };
39
- };
40
-
41
- export type FreeRouterConfig = {
42
- port: number;
43
- host: string;
44
- providers: Record<string, ProviderConfigEntry>;
45
- tiers: Record<string, TierMapping>;
46
- agenticTiers?: Record<string, TierMapping>;
47
- tierBoundaries?: {
48
- simpleMedium: number;
49
- mediumComplex: number;
50
- complexReasoning: number;
51
- };
52
- thinking?: ThinkingConfig;
53
- auth: {
54
- default: string;
55
- [strategy: string]: unknown;
56
- };
57
- scoring?: Record<string, unknown>;
58
- };
59
-
60
- // ═══ Defaults ═══
61
-
62
- const DEFAULT_CONFIG: FreeRouterConfig = {
63
- port: 18800,
64
- host: "127.0.0.1",
65
- providers: {
66
- anthropic: {
67
- baseUrl: "https://api.anthropic.com",
68
- api: "anthropic",
69
- },
70
- "kimi-coding": {
71
- baseUrl: "https://api.kimi.com/coding/v1",
72
- api: "openai",
73
- headers: { "User-Agent": "KimiCLI/0.77" },
74
- },
75
- },
76
- tiers: {
77
- SIMPLE: { primary: "kimi-coding/kimi-for-coding", fallback: ["anthropic/claude-haiku-4-5"] },
78
- MEDIUM: { primary: "anthropic/claude-sonnet-4-5", fallback: ["anthropic/claude-opus-4-6"] },
79
- COMPLEX: { primary: "anthropic/claude-opus-4-6", fallback: ["anthropic/claude-haiku-4-5"] },
80
- REASONING: { primary: "anthropic/claude-opus-4-6", fallback: ["anthropic/claude-haiku-4-5"] },
81
- },
82
- agenticTiers: {
83
- SIMPLE: { primary: "kimi-coding/kimi-for-coding", fallback: ["anthropic/claude-haiku-4-5"] },
84
- MEDIUM: { primary: "anthropic/claude-sonnet-4-5", fallback: ["anthropic/claude-opus-4-6"] },
85
- COMPLEX: { primary: "anthropic/claude-opus-4-6", fallback: ["anthropic/claude-haiku-4-5"] },
86
- REASONING: { primary: "anthropic/claude-opus-4-6", fallback: ["anthropic/claude-haiku-4-5"] },
87
- },
88
- thinking: {
89
- adaptive: ["claude-opus-4-6", "claude-opus-4.6"],
90
- enabled: { models: ["claude-sonnet-4-5"], budget: 4096 },
91
- },
92
- auth: {
93
- default: "openclaw",
94
- openclaw: {
95
- type: "openclaw",
96
- profilesPath: "~/.openclaw/agents/main/agent/auth-profiles.json",
97
- },
98
- },
99
- };
100
-
101
- // ═══ Singleton ═══
102
-
103
- let _config: FreeRouterConfig | null = null;
104
- let _configSource: string = "defaults";
105
-
106
- /**
107
- * Deep-merge source into target (source wins). Arrays are replaced, not merged.
108
- */
109
- function deepMerge(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
110
- const result = { ...target };
111
- for (const key of Object.keys(source)) {
112
- const sv = source[key];
113
- const tv = target[key];
114
- if (sv && typeof sv === "object" && !Array.isArray(sv) && tv && typeof tv === "object" && !Array.isArray(tv)) {
115
- result[key] = deepMerge(tv as Record<string, unknown>, sv as Record<string, unknown>);
116
- } else {
117
- result[key] = sv;
118
- }
119
- }
120
- return result;
121
- }
122
-
123
- /**
124
- * Load config from plugin config object (passed from OpenClaw plugin API).
125
- * Merges with defaults — user only needs to specify overrides.
126
- */
127
- export function loadConfigFromPlugin(pluginConfig: Record<string, unknown>): FreeRouterConfig {
128
- _config = deepMerge(
129
- DEFAULT_CONFIG as unknown as Record<string, unknown>,
130
- pluginConfig as unknown as Record<string, unknown>,
131
- ) as unknown as FreeRouterConfig;
132
- _configSource = "plugin";
133
- logger.info("Loaded config from OpenClaw plugin");
134
- logger.info(` Providers: ${Object.keys(_config.providers).join(", ")}`);
135
- logger.info(` Tiers: ${Object.keys(_config.tiers).join(", ")}`);
136
- return _config;
137
- }
138
-
139
- /**
140
- * Load config from file (standalone fallback).
141
- */
142
- export function loadConfig(): FreeRouterConfig {
143
- // Try file-based config as fallback
144
- const paths = [
145
- process.env.FREEROUTER_CONFIG,
146
- join(process.cwd(), "freerouter.config.json"),
147
- join(homedir(), ".config", "freerouter", "config.json"),
148
- ].filter(Boolean) as string[];
149
-
150
- for (const p of paths) {
151
- if (existsSync(p)) {
152
- try {
153
- const raw = readFileSync(p, "utf-8");
154
- const fileConfig = JSON.parse(raw) as Partial<FreeRouterConfig>;
155
- _config = deepMerge(DEFAULT_CONFIG as unknown as Record<string, unknown>, fileConfig as unknown as Record<string, unknown>) as unknown as FreeRouterConfig;
156
- _configSource = p;
157
- logger.info(`Loaded config from ${p}`);
158
- return _config;
159
- } catch (err) {
160
- logger.error(`Failed to load config from ${p}:`, err);
161
- }
162
- }
163
- }
164
-
165
- logger.info("No config file found, using built-in defaults");
166
- _config = { ...DEFAULT_CONFIG };
167
- _configSource = "defaults";
168
- return _config;
169
- }
170
-
171
- export function reloadConfig(): FreeRouterConfig {
172
- _config = null;
173
- return loadConfig();
174
- }
175
-
176
- export function getConfig(): FreeRouterConfig {
177
- if (!_config) return loadConfig();
178
- return _config;
179
- }
180
-
181
- export function getConfigPath(): string | null {
182
- return _configSource === "defaults" ? null : _configSource;
183
- }
184
-
185
- export function getSanitizedConfig(): Record<string, unknown> {
186
- const cfg = getConfig();
187
- const sanitized = JSON.parse(JSON.stringify(cfg));
188
- if (sanitized.auth) {
189
- for (const [key, val] of Object.entries(sanitized.auth)) {
190
- if (key === "default") continue;
191
- if (val && typeof val === "object" && (val as any).profilesPath) {
192
- (val as any).profilesPath = "***";
193
- }
194
- }
195
- }
196
- for (const prov of Object.values(sanitized.providers ?? {})) {
197
- if ((prov as any).auth?.key) (prov as any).auth.key = "***";
198
- }
199
- return sanitized;
200
- }
201
-
202
- export function toInternalApiType(api: "anthropic" | "openai"): "anthropic-messages" | "openai-completions" {
203
- return api === "anthropic" ? "anthropic-messages" : "openai-completions";
204
- }
205
-
206
- export function supportsAdaptiveThinking(modelId: string): boolean {
207
- const cfg = getConfig();
208
- const patterns = cfg.thinking?.adaptive ?? ["claude-opus-4-6", "claude-opus-4.6"];
209
- return patterns.some(p => modelId.includes(p));
210
- }
211
-
212
- export function getThinkingBudget(modelId: string): number | null {
213
- const cfg = getConfig();
214
- const enabled = cfg.thinking?.enabled;
215
- if (!enabled) return null;
216
- if (enabled.models.some(m => modelId.includes(m))) return enabled.budget;
217
- return null;
218
- }
219
-
220
- export { DEFAULT_CONFIG };
package/src/logger.ts DELETED
@@ -1,32 +0,0 @@
1
- /**
2
- * ClawRouter Logger — minimal, zero-dep
3
- */
4
-
5
- const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 } as const;
6
- type Level = keyof typeof LEVELS;
7
-
8
- let currentLevel: Level = "info";
9
-
10
- export function setLogLevel(level: Level) {
11
- currentLevel = level;
12
- }
13
-
14
- function log(level: Level, ...args: unknown[]) {
15
- if (LEVELS[level] < LEVELS[currentLevel]) return;
16
- const ts = new Date().toISOString().slice(11, 23);
17
- const prefix = `[${ts}] ${level.toUpperCase().padEnd(5)}`;
18
- if (level === "error") {
19
- console.error(prefix, ...args);
20
- } else if (level === "warn") {
21
- console.warn(prefix, ...args);
22
- } else {
23
- console.log(prefix, ...args);
24
- }
25
- }
26
-
27
- export const logger = {
28
- debug: (...args: unknown[]) => log("debug", ...args),
29
- info: (...args: unknown[]) => log("info", ...args),
30
- warn: (...args: unknown[]) => log("warn", ...args),
31
- error: (...args: unknown[]) => log("error", ...args),
32
- };