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 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
+ };