openclaw-freerouter 1.3.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 +26 -0
- package/LICENSE +21 -0
- package/README.md +103 -0
- package/index.ts +63 -0
- package/openclaw.plugin.json +25 -0
- package/package.json +37 -0
- package/src/auth.ts +128 -0
- package/src/config.ts +220 -0
- package/src/logger.ts +32 -0
- package/src/models.ts +142 -0
- package/src/provider.ts +676 -0
- package/src/router/config.ts +242 -0
- package/src/router/index.ts +114 -0
- package/src/router/rules.ts +299 -0
- package/src/router/selector.ts +117 -0
- package/src/router/types.ts +84 -0
- package/src/server.ts +381 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 OpenFreeRouter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# @openfreerouter/openclaw-freerouter
|
|
2
|
+
|
|
3
|
+
**FreeRouter** — Smart model router for OpenClaw. Auto-classifies requests using a 14-dimension weighted scorer and routes to the cheapest capable model using your own API keys.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
openclaw plugins install @openfreerouter/openclaw-freerouter
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install from local path:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
openclaw plugins install ./openclaw-freerouter
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Add to your `openclaw.json`:
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"plugins": {
|
|
24
|
+
"entries": {
|
|
25
|
+
"freerouter": {
|
|
26
|
+
"config": {
|
|
27
|
+
"port": 18800,
|
|
28
|
+
"host": "127.0.0.1",
|
|
29
|
+
"providers": {
|
|
30
|
+
"anthropic": {
|
|
31
|
+
"baseUrl": "https://api.anthropic.com",
|
|
32
|
+
"api": "anthropic"
|
|
33
|
+
},
|
|
34
|
+
"kimi-coding": {
|
|
35
|
+
"baseUrl": "https://api.kimi.com/coding/v1",
|
|
36
|
+
"api": "openai",
|
|
37
|
+
"headers": { "User-Agent": "KimiCLI/0.77" }
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"tiers": {
|
|
41
|
+
"SIMPLE": { "primary": "kimi-coding/kimi-for-coding", "fallback": ["anthropic/claude-haiku-4-5"] },
|
|
42
|
+
"MEDIUM": { "primary": "anthropic/claude-sonnet-4-5", "fallback": ["anthropic/claude-opus-4-6"] },
|
|
43
|
+
"COMPLEX": { "primary": "anthropic/claude-opus-4-6", "fallback": ["anthropic/claude-haiku-4-5"] },
|
|
44
|
+
"REASONING": { "primary": "anthropic/claude-opus-4-6", "fallback": ["anthropic/claude-haiku-4-5"] }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
All config fields are optional — sensible defaults are built in.
|
|
54
|
+
|
|
55
|
+
## How It Works
|
|
56
|
+
|
|
57
|
+
1. **Classify**: Each request is scored across 14 dimensions (code presence, reasoning markers, technical terms, creativity, etc.)
|
|
58
|
+
2. **Route**: Score maps to a tier (SIMPLE → MEDIUM → COMPLEX → REASONING)
|
|
59
|
+
3. **Forward**: Request is forwarded to the tier's primary model, with automatic fallback
|
|
60
|
+
4. **Translate**: Anthropic Messages API ↔ OpenAI format translation happens transparently
|
|
61
|
+
|
|
62
|
+
## Tiers
|
|
63
|
+
|
|
64
|
+
| Tier | Default Model | Use Case |
|
|
65
|
+
|------|--------------|----------|
|
|
66
|
+
| SIMPLE | Kimi K2.5 | Greetings, facts, translations |
|
|
67
|
+
| MEDIUM | Claude Sonnet 4.5 | Code, conversation, tool use |
|
|
68
|
+
| COMPLEX | Claude Opus 4.6 | Architecture, debugging, analysis |
|
|
69
|
+
| REASONING | Claude Opus 4.6 | Proofs, formal reasoning, deep analysis |
|
|
70
|
+
|
|
71
|
+
## Mode Overrides
|
|
72
|
+
|
|
73
|
+
Force a specific tier in your prompt:
|
|
74
|
+
|
|
75
|
+
- `/max prove that P ≠ NP` → REASONING
|
|
76
|
+
- `simple mode: what's 2+2` → SIMPLE
|
|
77
|
+
- `[complex] review this architecture` → COMPLEX
|
|
78
|
+
|
|
79
|
+
## Endpoints
|
|
80
|
+
|
|
81
|
+
| Endpoint | Method | Description |
|
|
82
|
+
|----------|--------|-------------|
|
|
83
|
+
| `/v1/chat/completions` | POST | OpenAI-compatible chat |
|
|
84
|
+
| `/v1/models` | GET | List available models |
|
|
85
|
+
| `/health` | GET | Health check |
|
|
86
|
+
| `/stats` | GET | Request statistics |
|
|
87
|
+
| `/config` | GET | Show sanitized config |
|
|
88
|
+
| `/reload` | POST | Reload auth keys |
|
|
89
|
+
| `/reload-config` | POST | Reload config + auth |
|
|
90
|
+
|
|
91
|
+
## Use as Default Model
|
|
92
|
+
|
|
93
|
+
Set your default model to `freerouter/auto` in openclaw.json:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"model": "freerouter/auto"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## License
|
|
102
|
+
|
|
103
|
+
MIT
|
package/index.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
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";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "freerouter",
|
|
3
|
+
"name": "FreeRouter",
|
|
4
|
+
"description": "Smart model router — auto-classify requests and route to the cheapest capable model using your own API keys",
|
|
5
|
+
"version": "1.3.0",
|
|
6
|
+
"providers": ["freerouter"],
|
|
7
|
+
"configSchema": {
|
|
8
|
+
"type": "object",
|
|
9
|
+
"additionalProperties": false,
|
|
10
|
+
"properties": {
|
|
11
|
+
"port": { "type": "number", "default": 18800 },
|
|
12
|
+
"host": { "type": "string", "default": "127.0.0.1" },
|
|
13
|
+
"providers": { "type": "object" },
|
|
14
|
+
"tiers": { "type": "object" },
|
|
15
|
+
"agenticTiers": { "type": "object" },
|
|
16
|
+
"tierBoundaries": { "type": "object" },
|
|
17
|
+
"thinking": { "type": "object" },
|
|
18
|
+
"auth": { "type": "object" }
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"uiHints": {
|
|
22
|
+
"port": { "label": "Proxy Port", "placeholder": "18800" },
|
|
23
|
+
"host": { "label": "Bind Address", "placeholder": "127.0.0.1" }
|
|
24
|
+
}
|
|
25
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-freerouter",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "FreeRouter plugin for OpenClaw — smart model routing with your own API keys",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"index.ts",
|
|
12
|
+
"src/",
|
|
13
|
+
"openclaw.plugin.json",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"CHANGELOG.md"
|
|
17
|
+
],
|
|
18
|
+
"keywords": [
|
|
19
|
+
"openclaw",
|
|
20
|
+
"openclaw-plugin",
|
|
21
|
+
"ai-router",
|
|
22
|
+
"model-router",
|
|
23
|
+
"freerouter",
|
|
24
|
+
"llm",
|
|
25
|
+
"proxy"
|
|
26
|
+
],
|
|
27
|
+
"author": "OpenFreeRouter",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "https://github.com/openfreerouter/openclaw-freerouter"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.19.11",
|
|
35
|
+
"typescript": "^5.9.3"
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
};
|