claw-llm-router 1.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.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Claw LLM Router — Structured Router Logger
3
+ *
4
+ * Wraps PluginLogger with domain-specific methods for each phase of the
5
+ * routing pipeline. All output is prefixed with [claw-llm-router] for easy filtering.
6
+ */
7
+
8
+ import type { PluginLogger } from "./providers/types.js";
9
+ import type { Tier } from "./classifier.js";
10
+
11
+ const PREFIX = "[claw-llm-router]";
12
+
13
+ export class RouterLogger {
14
+ constructor(private log: PluginLogger) {}
15
+
16
+ /** Incoming request received */
17
+ request(opts: {
18
+ model: string;
19
+ stream: boolean;
20
+ prompt: string;
21
+ extraction?: { from: number; to: number };
22
+ }): void {
23
+ const snippet = opts.prompt.slice(0, 80).replace(/\n/g, " ");
24
+ const ext = opts.extraction
25
+ ? ` (extracted ${opts.extraction.to} chars from ${opts.extraction.from}-char message)`
26
+ : "";
27
+ this.log.info(
28
+ `${PREFIX} ── request ── model=${opts.model} stream=${opts.stream} prompt="${snippet}"${ext}`,
29
+ );
30
+ }
31
+
32
+ /** Rule-based classification result */
33
+ classify(opts: {
34
+ tier: Tier;
35
+ method: string;
36
+ score?: number;
37
+ confidence?: number;
38
+ signals?: string[];
39
+ detail?: string;
40
+ }): void {
41
+ const parts = [`tier=${opts.tier}`, `method=${opts.method}`];
42
+ if (opts.score !== undefined) parts.push(`score=${opts.score.toFixed(3)}`);
43
+ if (opts.confidence !== undefined) parts.push(`conf=${opts.confidence.toFixed(2)}`);
44
+ if (opts.signals?.length) parts.push(`signals=[${opts.signals.join(", ")}]`);
45
+ if (opts.detail) parts.push(opts.detail);
46
+ this.log.info(`${PREFIX} classify: ${parts.join(" ")}`);
47
+ }
48
+
49
+ /** Routing decision: which provider/model was selected */
50
+ route(opts: {
51
+ tier: Tier;
52
+ provider: string;
53
+ model: string;
54
+ method: string;
55
+ chain: string[];
56
+ }): void {
57
+ this.log.info(
58
+ `${PREFIX} route: tier=${opts.tier} → ${opts.provider}/${opts.model} (method=${opts.method}, chain=[${opts.chain.join(" → ")}])`,
59
+ );
60
+ }
61
+
62
+ /** Provider selected for the call */
63
+ provider(opts: { name: string; provider: string; model: string }): void {
64
+ this.log.info(`${PREFIX} provider: ${opts.name} for ${opts.provider}/${opts.model}`);
65
+ }
66
+
67
+ /** Gateway model override set */
68
+ override(opts: { provider: string; model: string }): void {
69
+ this.log.info(`${PREFIX} override: gateway pending override → ${opts.provider}/${opts.model}`);
70
+ }
71
+
72
+ /** Request completed successfully */
73
+ done(opts: {
74
+ model: string;
75
+ via: string;
76
+ streamed: boolean;
77
+ tokensIn?: number | string;
78
+ tokensOut?: number | string;
79
+ }): void {
80
+ const mode = opts.streamed ? "streamed" : "complete";
81
+ const tokens =
82
+ !opts.streamed && opts.tokensIn !== undefined
83
+ ? ` tokens=${opts.tokensIn}→${opts.tokensOut}`
84
+ : "";
85
+ this.log.info(`${PREFIX} done: ${opts.model} (${opts.via}, ${mode})${tokens}`);
86
+ }
87
+
88
+ /** A tier attempt failed, trying fallback */
89
+ fallback(opts: { tier: string; provider: string; model: string; error: string }): void {
90
+ this.log.warn(
91
+ `${PREFIX} fallback: ${opts.tier} ${opts.provider}/${opts.model} failed: ${opts.error}`,
92
+ );
93
+ }
94
+
95
+ /** All tiers exhausted */
96
+ failed(opts: { chain: string[]; error: string }): void {
97
+ this.log.error(
98
+ `${PREFIX} FAILED: all tiers exhausted [${opts.chain.join(" → ")}]. Last error: ${opts.error}`,
99
+ );
100
+ }
101
+ }
package/tier-config.ts ADDED
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Claw LLM Router — Tier Configuration
3
+ *
4
+ * Reads/writes tier-to-model mappings from a local config file (router-config.json).
5
+ * Resolves provider baseUrl and apiKey for any OpenClaw provider.
6
+ * Auth is NEVER stored in the plugin — always read from OpenClaw's auth stores.
7
+ *
8
+ * Config is stored in the plugin directory (not in openclaw.json) because
9
+ * OpenClaw validates plugins.entries schema and rejects unknown keys.
10
+ */
11
+
12
+ import { readFileSync, renameSync, writeFileSync, existsSync } from "node:fs";
13
+ import { dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type { Tier } from "./classifier.js";
16
+
17
+ const HOME = process.env.HOME;
18
+ if (!HOME) throw new Error("[claw-llm-router] HOME environment variable not set");
19
+
20
+ const OPENCLAW_CONFIG_PATH = `${HOME}/.openclaw/openclaw.json`;
21
+ const AUTH_PROFILES_PATH = `${HOME}/.openclaw/agents/main/agent/auth-profiles.json`;
22
+ const AUTH_JSON_PATH = `${HOME}/.openclaw/agents/main/agent/auth.json`;
23
+
24
+ // Router config stored in plugin directory to avoid openclaw.json schema conflicts
25
+ const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
26
+ const ROUTER_CONFIG_PATH = `${PLUGIN_DIR}/router-config.json`;
27
+
28
+ // ── Types ────────────────────────────────────────────────────────────────────
29
+
30
+ export type TierModelSpec = {
31
+ provider: string;
32
+ modelId: string;
33
+ baseUrl: string;
34
+ apiKey: string;
35
+ isAnthropic: boolean;
36
+ isOAuth: boolean;
37
+ };
38
+
39
+ export type TierConfig = Record<Tier, TierModelSpec>;
40
+
41
+ type RouterConfig = {
42
+ tiers: Record<Tier, string>;
43
+ };
44
+
45
+ // ── Defaults ─────────────────────────────────────────────────────────────────
46
+
47
+ export const DEFAULT_TIERS: Record<Tier, string> = {
48
+ SIMPLE: "google/gemini-2.5-flash",
49
+ MEDIUM: "anthropic/claude-haiku-4-5-20251001",
50
+ COMPLEX: "anthropic/claude-sonnet-4-6",
51
+ REASONING: "anthropic/claude-opus-4-6",
52
+ };
53
+
54
+ // ── Well-known provider base URLs ────────────────────────────────────────────
55
+
56
+ const WELL_KNOWN_BASE_URLS: Record<string, string> = {
57
+ anthropic: "https://api.anthropic.com/v1",
58
+ google: "https://generativelanguage.googleapis.com/v1beta/openai",
59
+ openai: "https://api.openai.com/v1",
60
+ groq: "https://api.groq.com/openai/v1",
61
+ mistral: "https://api.mistral.ai/v1",
62
+ deepseek: "https://api.deepseek.com/v1",
63
+ together: "https://api.together.xyz/v1",
64
+ fireworks: "https://api.fireworks.ai/inference/v1",
65
+ perplexity: "https://api.perplexity.ai",
66
+ xai: "https://api.x.ai/v1",
67
+ minimax: "https://api.minimax.io/v1",
68
+ moonshot: "https://api.moonshot.ai/v1",
69
+ };
70
+
71
+ // Provider name → env var name mapping (when not just PROVIDER_API_KEY)
72
+ const ENV_VAR_OVERRIDES: Record<string, string> = {
73
+ google: "GEMINI_API_KEY",
74
+ };
75
+
76
+ // Provider name → auth-profiles.json provider ID mapping
77
+ // Some providers use a different ID in OpenClaw's auth store
78
+ const AUTH_PROFILE_ALIASES: Record<string, string> = {
79
+ minimax: "minimax-portal",
80
+ };
81
+
82
+ // ── Helpers ──────────────────────────────────────────────────────────────────
83
+
84
+ function readOpenClawConfig(): Record<string, unknown> {
85
+ const raw = readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
86
+ return JSON.parse(raw) as Record<string, unknown>;
87
+ }
88
+
89
+ function readRouterConfig(): RouterConfig | undefined {
90
+ try {
91
+ if (!existsSync(ROUTER_CONFIG_PATH)) return undefined;
92
+ const raw = readFileSync(ROUTER_CONFIG_PATH, "utf8");
93
+ return JSON.parse(raw) as RouterConfig;
94
+ } catch {
95
+ return undefined;
96
+ }
97
+ }
98
+
99
+ function writeRouterConfigFile(config: RouterConfig): void {
100
+ const tmp = `${ROUTER_CONFIG_PATH}.tmp.${process.pid}`;
101
+ writeFileSync(tmp, JSON.stringify(config, null, 2), "utf8");
102
+ JSON.parse(readFileSync(tmp, "utf8")); // Validate
103
+ renameSync(tmp, ROUTER_CONFIG_PATH);
104
+ }
105
+
106
+ export function envVarName(provider: string): string {
107
+ return ENV_VAR_OVERRIDES[provider] ?? `${provider.toUpperCase()}_API_KEY`;
108
+ }
109
+
110
+ // ── API Key Loading ──────────────────────────────────────────────────────────
111
+ // Priority: env var → auth-profiles.json → auth.json → openclaw.json env.vars
112
+ // NEVER stores keys — always reads from existing OpenClaw auth stores.
113
+ // NEVER logs keys or any portion of them.
114
+
115
+ type LogFn = ((msg: string) => void) | undefined;
116
+
117
+ export type ApiKeyResult = { key: string; isOAuth: boolean };
118
+
119
+ // Exported for testing — parses a single auth profile entry into key + OAuth flag.
120
+ export type AuthProfile = { token?: string; key?: string; access?: string; type?: string };
121
+
122
+ export function parseProfileCredential(profile: AuthProfile): ApiKeyResult | null {
123
+ const profileIsOAuth = profile.type === "oauth";
124
+ // OAuth credentials (e.g., MiniMax) store the token in `access`.
125
+ // Only read `access` for type:"oauth" to avoid stale data on non-OAuth profiles.
126
+ const key = profileIsOAuth
127
+ ? (profile.access ?? profile.token ?? profile.key)
128
+ : (profile.token ?? profile.key);
129
+ if (!key || key === "proxy-handles-auth") return null;
130
+ // Detect OAuth by profile type OR key prefix (OpenClaw stores
131
+ // Anthropic OAuth tokens with type:"token", not type:"oauth")
132
+ const isOAuth = profileIsOAuth || key.startsWith("sk-ant-oat01-");
133
+ return { key, isOAuth };
134
+ }
135
+
136
+ export function loadApiKey(provider: string, log?: LogFn): ApiKeyResult {
137
+ const envKey = envVarName(provider);
138
+
139
+ // 1. Environment variable
140
+ if (process.env[envKey]) {
141
+ const key = process.env[envKey]!;
142
+ log?.(`[auth] ${provider}: using key from env var ${envKey}`);
143
+ // Detect OAuth tokens passed via env var
144
+ const isOAuth = key.startsWith("sk-ant-oat01-");
145
+ return { key, isOAuth };
146
+ }
147
+
148
+ // 2. auth-profiles.json (canonical credential store)
149
+ // Try both the provider name and any alias (e.g., minimax → minimax-portal)
150
+ const alias = AUTH_PROFILE_ALIASES[provider];
151
+ const profileNames = [`${provider}:default`];
152
+ if (alias) profileNames.push(`${alias}:default`);
153
+
154
+ try {
155
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
156
+ const store = JSON.parse(raw) as {
157
+ profiles?: Record<string, AuthProfile>;
158
+ };
159
+
160
+ for (const profileName of profileNames) {
161
+ const profile = store.profiles?.[profileName];
162
+ if (!profile) continue;
163
+
164
+ const result = parseProfileCredential(profile);
165
+ if (result) {
166
+ log?.(
167
+ `[auth] ${provider}: using key from auth-profiles.json (${profileName}${result.isOAuth ? ", OAuth" : ""})`,
168
+ );
169
+ return result;
170
+ }
171
+ }
172
+ } catch {
173
+ /* fall through */
174
+ }
175
+
176
+ // 3. auth.json (runtime cache)
177
+ try {
178
+ const raw = readFileSync(AUTH_JSON_PATH, "utf8");
179
+ const store = JSON.parse(raw) as Record<string, { key?: string; token?: string }>;
180
+ const entry = store[provider];
181
+ const key = entry?.key ?? entry?.token;
182
+ if (key && key !== "proxy-handles-auth") {
183
+ log?.(`[auth] ${provider}: using key from auth.json`);
184
+ return { key, isOAuth: false };
185
+ }
186
+ } catch {
187
+ /* fall through */
188
+ }
189
+
190
+ // 4. openclaw.json env.vars
191
+ try {
192
+ const config = readOpenClawConfig();
193
+ const env = config.env as { vars?: Record<string, string> } | undefined;
194
+ const val = env?.vars?.[envKey];
195
+ if (val) {
196
+ log?.(`[auth] ${provider}: using key from openclaw.json env.vars`);
197
+ return { key: val, isOAuth: false };
198
+ }
199
+ } catch {
200
+ /* fall through */
201
+ }
202
+
203
+ log?.(
204
+ `[auth] ${provider}: NO API KEY FOUND (checked: env ${envKey}, auth-profiles.json, auth.json, openclaw.json env.vars)`,
205
+ );
206
+ return { key: "", isOAuth: false };
207
+ }
208
+
209
+ // ── Provider Resolution ──────────────────────────────────────────────────────
210
+
211
+ function resolveBaseUrl(provider: string): string {
212
+ // First check openclaw.json models.providers for a configured baseUrl
213
+ try {
214
+ const config = readOpenClawConfig();
215
+ const models = config.models as
216
+ | { providers?: Record<string, { baseUrl?: string }> }
217
+ | undefined;
218
+ const providerConfig = models?.providers?.[provider];
219
+ if (providerConfig?.baseUrl) return providerConfig.baseUrl;
220
+ } catch {
221
+ /* fall through */
222
+ }
223
+
224
+ // Fall back to well-known URLs
225
+ const wellKnown = WELL_KNOWN_BASE_URLS[provider];
226
+ if (wellKnown) return wellKnown;
227
+
228
+ throw new Error(
229
+ `[claw-llm-router] Unknown provider "${provider}" — not in openclaw.json and no well-known URL. Configure it in openclaw.json models.providers or use /router set.`,
230
+ );
231
+ }
232
+
233
+ // ── Tier Model Resolution ────────────────────────────────────────────────────
234
+
235
+ export function resolveTierModel(tierString: string, log?: LogFn): TierModelSpec {
236
+ const slashIdx = tierString.indexOf("/");
237
+ if (slashIdx === -1) {
238
+ throw new Error(
239
+ `[claw-llm-router] Invalid tier model format: "${tierString}" — expected "provider/model-id"`,
240
+ );
241
+ }
242
+
243
+ const provider = tierString.slice(0, slashIdx);
244
+ const modelId = tierString.slice(slashIdx + 1);
245
+ const baseUrl = resolveBaseUrl(provider);
246
+ const { key: apiKey, isOAuth } = loadApiKey(provider, log);
247
+ const isAnthropic = provider === "anthropic" || baseUrl.includes("anthropic.com");
248
+
249
+ return { provider, modelId, baseUrl, apiKey, isAnthropic, isOAuth };
250
+ }
251
+
252
+ // ── Tier Config Read/Write ───────────────────────────────────────────────────
253
+
254
+ export function isTierConfigured(): boolean {
255
+ const config = readRouterConfig();
256
+ return !!config?.tiers && Object.keys(config.tiers).length === 4;
257
+ }
258
+
259
+ export function loadTierConfig(log?: LogFn): TierConfig {
260
+ const config = readRouterConfig();
261
+ const tiers = config?.tiers ?? DEFAULT_TIERS;
262
+
263
+ return {
264
+ SIMPLE: resolveTierModel(tiers.SIMPLE ?? DEFAULT_TIERS.SIMPLE, log),
265
+ MEDIUM: resolveTierModel(tiers.MEDIUM ?? DEFAULT_TIERS.MEDIUM, log),
266
+ COMPLEX: resolveTierModel(tiers.COMPLEX ?? DEFAULT_TIERS.COMPLEX, log),
267
+ REASONING: resolveTierModel(tiers.REASONING ?? DEFAULT_TIERS.REASONING, log),
268
+ };
269
+ }
270
+
271
+ export function getTierStrings(): Record<Tier, string> {
272
+ const config = readRouterConfig();
273
+ const tiers = config?.tiers;
274
+ return {
275
+ SIMPLE: tiers?.SIMPLE ?? DEFAULT_TIERS.SIMPLE,
276
+ MEDIUM: tiers?.MEDIUM ?? DEFAULT_TIERS.MEDIUM,
277
+ COMPLEX: tiers?.COMPLEX ?? DEFAULT_TIERS.COMPLEX,
278
+ REASONING: tiers?.REASONING ?? DEFAULT_TIERS.REASONING,
279
+ };
280
+ }
281
+
282
+ export function writeTierConfig(tiers: Record<Tier, string>): void {
283
+ const existing = readRouterConfig();
284
+ writeRouterConfigFile({
285
+ ...existing,
286
+ tiers,
287
+ });
288
+ }