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.
package/index.ts ADDED
@@ -0,0 +1,602 @@
1
+ /**
2
+ * Claw LLM Router — OpenClaw Plugin Entry Point
3
+ *
4
+ * On gateway load:
5
+ * 1. Registers provider at runtime via api.registerProvider()
6
+ * 2. Sets runtime config (api.config.models.providers)
7
+ * 3. Writes provider config to openclaw.json atomically (idempotent)
8
+ * 4. Injects auth profile placeholder
9
+ * 5. Auto-configures default tiers on first run
10
+ * 6. Starts in-process Node.js proxy via api.registerService()
11
+ * 7. Registers /router slash command with subcommands
12
+ */
13
+
14
+ import { readFileSync, writeFileSync, copyFileSync, existsSync, renameSync } from "node:fs";
15
+ import { type Server } from "node:http";
16
+ import { clawRouterProvider } from "./provider.js";
17
+ import { buildProviderConfig, PROXY_PORT, PROVIDER_ID } from "./models.js";
18
+ import { startProxy } from "./proxy.js";
19
+ import {
20
+ isTierConfigured,
21
+ writeTierConfig,
22
+ getTierStrings,
23
+ DEFAULT_TIERS,
24
+ resolveTierModel,
25
+ loadApiKey,
26
+ envVarName,
27
+ } from "./tier-config.js";
28
+ import { getIsRouterPrimary } from "./providers/index.js";
29
+ import { consumeOverride } from "./providers/model-override.js";
30
+
31
+ // ── Types (duck-typed to match OpenClaw plugin API) ───────────────────────────
32
+
33
+ type PluginLogger = {
34
+ info: (msg: string) => void;
35
+ warn: (msg: string) => void;
36
+ error: (msg: string) => void;
37
+ };
38
+
39
+ type ModelProviderConfig = {
40
+ baseUrl: string;
41
+ apiKey?: string;
42
+ api?: string;
43
+ models: unknown[];
44
+ };
45
+
46
+ type OpenClawConfig = Record<string, unknown> & {
47
+ models?: { providers?: Record<string, ModelProviderConfig> };
48
+ agents?: Record<string, unknown>;
49
+ };
50
+
51
+ type BeforeModelResolveEvent = {
52
+ prompt: string;
53
+ };
54
+
55
+ type BeforeModelResolveResult = {
56
+ modelOverride?: string;
57
+ providerOverride?: string;
58
+ };
59
+
60
+ type OpenClawPluginApi = {
61
+ id: string;
62
+ config: OpenClawConfig;
63
+ pluginConfig?: Record<string, unknown>;
64
+ logger: PluginLogger;
65
+ registerProvider: (provider: unknown) => void;
66
+ registerService: (service: {
67
+ id: string;
68
+ start: () => void | Promise<void>;
69
+ stop?: () => void | Promise<void>;
70
+ }) => void;
71
+ registerCommand: (command: {
72
+ name: string;
73
+ description: string;
74
+ acceptsArgs?: boolean;
75
+ requireAuth?: boolean;
76
+ handler: (ctx: {
77
+ senderId?: string;
78
+ channel: string;
79
+ isAuthorizedSender: boolean;
80
+ args?: string;
81
+ commandBody: string;
82
+ config: Record<string, unknown>;
83
+ }) => { text: string } | Promise<{ text: string }>;
84
+ }) => void;
85
+ on: (
86
+ hookName: string,
87
+ handler: (...args: unknown[]) => unknown,
88
+ opts?: { priority?: number },
89
+ ) => void;
90
+ };
91
+
92
+ // ── Config file paths ─────────────────────────────────────────────────────────
93
+
94
+ const HOME = process.env.HOME;
95
+ if (!HOME) throw new Error("[claw-llm-router] HOME environment variable not set");
96
+
97
+ const OPENCLAW_CONFIG_PATH = `${HOME}/.openclaw/openclaw.json`;
98
+ const AUTH_PROFILES_PATH = `${HOME}/.openclaw/agents/main/agent/auth-profiles.json`;
99
+
100
+ const LOG_PREFIX = "[claw-llm-router]";
101
+
102
+ // ── Atomic config write ───────────────────────────────────────────────────────
103
+
104
+ function atomicWriteJson(path: string, data: unknown): void {
105
+ const tmp = `${path}.tmp.${process.pid}`;
106
+ writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
107
+ // Validate it parses back cleanly before overwriting
108
+ JSON.parse(readFileSync(tmp, "utf8"));
109
+ // Atomic rename (POSIX guarantee)
110
+ renameSync(tmp, path);
111
+ }
112
+
113
+ function backupConfig(log: PluginLogger): void {
114
+ const timestamp = Date.now();
115
+ const backupPath = `${OPENCLAW_CONFIG_PATH}.bak.claw-llm-router.${timestamp}`;
116
+ if (existsSync(OPENCLAW_CONFIG_PATH)) {
117
+ copyFileSync(OPENCLAW_CONFIG_PATH, backupPath);
118
+ log.info(`${LOG_PREFIX} Config backed up to ${backupPath}`);
119
+ }
120
+ }
121
+
122
+ // ── injectModelsConfig ────────────────────────────────────────────────────────
123
+
124
+ function injectModelsConfig(log: PluginLogger): void {
125
+ let config: Record<string, unknown>;
126
+ try {
127
+ const raw = readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
128
+ config = JSON.parse(raw) as Record<string, unknown>;
129
+ } catch (err) {
130
+ log.warn(`${LOG_PREFIX} Could not read openclaw.json: ${err}. Skipping config injection.`);
131
+ return;
132
+ }
133
+
134
+ const providerConfig = buildProviderConfig();
135
+
136
+ // Ensure models.providers exists
137
+ if (!config.models || typeof config.models !== "object") {
138
+ config.models = { mode: "merge", providers: {} };
139
+ }
140
+ const models = config.models as { mode?: string; providers?: Record<string, unknown> };
141
+ if (!models.providers) models.providers = {};
142
+ if (!models.mode) models.mode = "merge";
143
+
144
+ // Check if already up to date (idempotent)
145
+ const existing = models.providers[PROVIDER_ID] as { baseUrl?: string } | undefined;
146
+ if (existing?.baseUrl === providerConfig.baseUrl) {
147
+ log.info(`${LOG_PREFIX} Config already up to date, skipping write`);
148
+ return;
149
+ }
150
+
151
+ backupConfig(log);
152
+
153
+ models.providers[PROVIDER_ID] = providerConfig;
154
+ try {
155
+ atomicWriteJson(OPENCLAW_CONFIG_PATH, config);
156
+ log.info(`${LOG_PREFIX} Provider config written to openclaw.json`);
157
+ } catch (err) {
158
+ log.error(`${LOG_PREFIX} Failed to write openclaw.json: ${err}`);
159
+ }
160
+ }
161
+
162
+ // ── injectAuthProfile ─────────────────────────────────────────────────────────
163
+
164
+ function injectAuthProfile(log: PluginLogger): void {
165
+ let profiles: Record<string, unknown>;
166
+ try {
167
+ const raw = readFileSync(AUTH_PROFILES_PATH, "utf8");
168
+ profiles = JSON.parse(raw) as Record<string, unknown>;
169
+ } catch {
170
+ profiles = {};
171
+ }
172
+
173
+ const profileKey = `${PROVIDER_ID}:default`;
174
+ const profileSection = profiles.profiles as Record<string, unknown> | undefined;
175
+
176
+ if (profileSection?.[profileKey]) {
177
+ return; // already exists
178
+ }
179
+
180
+ const existing = (profiles.profiles ?? {}) as Record<string, unknown>;
181
+ const updated = {
182
+ ...profiles,
183
+ profiles: {
184
+ ...existing,
185
+ [profileKey]: {
186
+ type: "api_key",
187
+ provider: PROVIDER_ID,
188
+ key: "proxy-handles-auth",
189
+ },
190
+ },
191
+ };
192
+
193
+ try {
194
+ atomicWriteJson(AUTH_PROFILES_PATH, updated);
195
+ log.info(`${LOG_PREFIX} Auth profile injected`);
196
+ } catch (err) {
197
+ log.warn(`${LOG_PREFIX} Could not inject auth profile: ${err}`);
198
+ }
199
+ }
200
+
201
+ // ── Startup API key warnings ─────────────────────────────────────────────────
202
+
203
+ function logApiKeyWarnings(log: PluginLogger): void {
204
+ const tiers = getTierStrings();
205
+ const tierNames = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] as const;
206
+ const missing: string[] = [];
207
+
208
+ for (const tier of tierNames) {
209
+ const modelStr = tiers[tier];
210
+ const slashIdx = modelStr.indexOf("/");
211
+ if (slashIdx <= 0) continue;
212
+ const provider = modelStr.slice(0, slashIdx);
213
+ const { key } = loadApiKey(provider);
214
+ if (!key) {
215
+ missing.push(`${tier} (${modelStr}) — set ${envVarName(provider)} or run /auth`);
216
+ }
217
+ }
218
+
219
+ if (missing.length > 0) {
220
+ log.warn(`${LOG_PREFIX} ⚠ Missing API keys for ${missing.length} tier(s):`);
221
+ for (const line of missing) {
222
+ log.warn(`${LOG_PREFIX} ${line}`);
223
+ }
224
+ log.warn(`${LOG_PREFIX} Run /router doctor for full diagnostics`);
225
+ }
226
+ }
227
+
228
+ // ── /router command handlers ─────────────────────────────────────────────────
229
+
230
+ const TIER_SUGGESTIONS: Record<string, string> = {
231
+ SIMPLE: "google/gemini-2.5-flash, openai/gpt-4o-mini, groq/llama-3.3-70b-versatile (fast, cheap)",
232
+ MEDIUM: "anthropic/claude-haiku-4-5-20251001, openai/gpt-4o-mini, xai/grok-3 (balanced)",
233
+ COMPLEX: "anthropic/claude-sonnet-4-6, openai/gpt-4o, xai/grok-3, minimax/MiniMax-M1 (capable)",
234
+ REASONING: "anthropic/claude-opus-4-6, openai/o1, moonshot/kimi-k2.5 (frontier reasoning)",
235
+ };
236
+
237
+ function handleHelpCommand(): { text: string } {
238
+ const lines = [
239
+ `Claw LLM Router — Commands`,
240
+ ``,
241
+ ` /router Show status (uptime, health, current tiers)`,
242
+ ` /router help Show this help`,
243
+ ` /router setup Show current tier config + suggested models`,
244
+ ` /router set <TIER> <provider/model>`,
245
+ ` Set a tier's model (SIMPLE, MEDIUM, COMPLEX, REASONING)`,
246
+ ` /router doctor Diagnose config, API keys, and proxy health`,
247
+ ``,
248
+ `Examples:`,
249
+ ` /router set SIMPLE google/gemini-2.5-flash`,
250
+ ` /router set REASONING anthropic/claude-opus-4-6`,
251
+ ];
252
+ return { text: lines.join("\n") };
253
+ }
254
+
255
+ function handleSetupCommand(): { text: string } {
256
+ const tiers = getTierStrings();
257
+ const lines = [
258
+ `Claw LLM Router — Tier Configuration`,
259
+ ``,
260
+ `Current configuration:`,
261
+ ` SIMPLE → ${tiers.SIMPLE}`,
262
+ ` MEDIUM → ${tiers.MEDIUM}`,
263
+ ` COMPLEX → ${tiers.COMPLEX}`,
264
+ ` REASONING → ${tiers.REASONING}`,
265
+ ``,
266
+ `To change a tier:`,
267
+ ` /router set SIMPLE <provider/model>`,
268
+ ` /router set MEDIUM <provider/model>`,
269
+ ` /router set COMPLEX <provider/model>`,
270
+ ` /router set REASONING <provider/model>`,
271
+ ``,
272
+ `Suggested models by tier:`,
273
+ ` SIMPLE → ${TIER_SUGGESTIONS.SIMPLE}`,
274
+ ` MEDIUM → ${TIER_SUGGESTIONS.MEDIUM}`,
275
+ ` COMPLEX → ${TIER_SUGGESTIONS.COMPLEX}`,
276
+ ` REASONING → ${TIER_SUGGESTIONS.REASONING}`,
277
+ ``,
278
+ `Any OpenAI-compatible provider works. Anthropic uses native API with format conversion.`,
279
+ ``,
280
+ `Diagnose issues: /router doctor`,
281
+ ];
282
+ return { text: lines.join("\n") };
283
+ }
284
+
285
+ function handleSetCommand(args: string): { text: string } {
286
+ const parts = args.split(/\s+/);
287
+ if (parts.length !== 2) {
288
+ return {
289
+ text: `Usage: /router set <TIER> <provider/model>\nExample: /router set SIMPLE google/gemini-2.5-flash`,
290
+ };
291
+ }
292
+
293
+ const tierName = parts[0].toUpperCase();
294
+ const modelString = parts[1];
295
+
296
+ const validTiers = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"];
297
+ if (!validTiers.includes(tierName)) {
298
+ return { text: `Invalid tier "${tierName}". Must be one of: ${validTiers.join(", ")}` };
299
+ }
300
+
301
+ if (!modelString.includes("/")) {
302
+ return {
303
+ text: `Invalid model format "${modelString}". Expected "provider/model-id" (e.g., google/gemini-2.5-flash)`,
304
+ };
305
+ }
306
+
307
+ // Validate the model can be resolved
308
+ try {
309
+ resolveTierModel(modelString);
310
+ } catch (err) {
311
+ return {
312
+ text: `Could not resolve model "${modelString}": ${err instanceof Error ? err.message : String(err)}`,
313
+ };
314
+ }
315
+
316
+ // Update the tier
317
+ const current = getTierStrings();
318
+ current[tierName as keyof typeof current] = modelString;
319
+ writeTierConfig(current);
320
+
321
+ return {
322
+ text: `Updated ${tierName} tier to: ${modelString}\n\nCurrent configuration:\n SIMPLE → ${current.SIMPLE}\n MEDIUM → ${current.MEDIUM}\n COMPLEX → ${current.COMPLEX}\n REASONING → ${current.REASONING}`,
323
+ };
324
+ }
325
+
326
+ export async function handleDoctorCommand(): Promise<{ text: string }> {
327
+ const lines: string[] = ["Router Doctor", ""];
328
+ let issues = 0;
329
+
330
+ // ── Configuration ──────────────────────────────────────────────────────────
331
+ lines.push("Configuration");
332
+
333
+ const configOk = isTierConfigured();
334
+ if (configOk) {
335
+ lines.push(" ✓ Config file (router-config.json)");
336
+ } else {
337
+ lines.push(" ✗ Config file missing or incomplete");
338
+ lines.push(" → Run /router setup or set tiers with /router set <TIER> <provider/model>");
339
+ issues++;
340
+ }
341
+
342
+ const tiers = getTierStrings();
343
+ const tierNames = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"] as const;
344
+ const allPresent = tierNames.every((t) => !!tiers[t]);
345
+ if (allPresent) {
346
+ lines.push(" ✓ All 4 tiers configured");
347
+ } else {
348
+ const missing = tierNames.filter((t) => !tiers[t]);
349
+ lines.push(` ✗ Missing tiers: ${missing.join(", ")}`);
350
+ issues++;
351
+ }
352
+
353
+ // ── Per-tier checks ────────────────────────────────────────────────────────
354
+ lines.push("");
355
+ lines.push("Tiers");
356
+
357
+ for (const tier of tierNames) {
358
+ const modelStr = tiers[tier];
359
+ lines.push(` ${tier} → ${modelStr}`);
360
+
361
+ const checks: string[] = [];
362
+
363
+ // 1. Valid format
364
+ const slashIdx = modelStr.indexOf("/");
365
+ if (slashIdx === -1 || slashIdx === 0 || slashIdx === modelStr.length - 1) {
366
+ checks.push("✗ Valid format");
367
+ issues++;
368
+ lines.push(` ${checks.join(" ")}`);
369
+ lines.push(` → Expected "provider/model-id" format`);
370
+ continue;
371
+ }
372
+ checks.push("✓ Valid format");
373
+
374
+ const provider = modelStr.slice(0, slashIdx);
375
+
376
+ // 2. Base URL resolvable
377
+ try {
378
+ resolveTierModel(modelStr);
379
+ checks.push("✓ Base URL");
380
+ } catch {
381
+ checks.push("✗ Base URL (unknown provider)");
382
+ issues++;
383
+ lines.push(` ${checks.join(" ")}`);
384
+ lines.push(` → Add ${provider} to openclaw.json models.providers with a baseUrl`);
385
+ continue;
386
+ }
387
+
388
+ // 3. API key available
389
+ const { key, isOAuth } = loadApiKey(provider);
390
+ if (key) {
391
+ const suffix = isOAuth ? " (OAuth)" : "";
392
+ checks.push(`✓ API key${suffix}`);
393
+ } else {
394
+ checks.push("✗ API key");
395
+ issues++;
396
+ lines.push(` ${checks.join(" ")}`);
397
+ lines.push(` → Set ${envVarName(provider)} or add ${provider} credentials via /auth`);
398
+ continue;
399
+ }
400
+
401
+ lines.push(` ${checks.join(" ")}`);
402
+ }
403
+
404
+ // ── Runtime ────────────────────────────────────────────────────────────────
405
+ lines.push("");
406
+ lines.push("Runtime");
407
+
408
+ const healthy = await fetch(`http://127.0.0.1:${PROXY_PORT}/health`)
409
+ .then((r) => r.ok)
410
+ .catch(() => false);
411
+
412
+ if (healthy) {
413
+ lines.push(` ✓ Proxy healthy (port ${PROXY_PORT})`);
414
+ } else {
415
+ lines.push(` ✗ Proxy not responding (port ${PROXY_PORT})`);
416
+ lines.push(" → Restart gateway or check logs for proxy errors");
417
+ issues++;
418
+ }
419
+
420
+ if (getIsRouterPrimary()) {
421
+ lines.push(" ℹ Router is primary model");
422
+ } else {
423
+ lines.push(" ℹ Router is not primary model");
424
+ }
425
+
426
+ // ── Summary ────────────────────────────────────────────────────────────────
427
+ lines.push("");
428
+ if (issues === 0) {
429
+ lines.push("No issues found.");
430
+ } else {
431
+ lines.push(`Found ${issues} issue${issues === 1 ? "" : "s"}.`);
432
+ }
433
+
434
+ return { text: lines.join("\n") };
435
+ }
436
+
437
+ // ── Plugin registration ───────────────────────────────────────────────────────
438
+
439
+ let activeServer: Server | null = null;
440
+ let startTime = Date.now();
441
+
442
+ export default {
443
+ id: PROVIDER_ID,
444
+ name: "Claw LLM Router",
445
+ version: "1.0.0",
446
+ description:
447
+ "Local prompt classifier that routes to the cheapest capable model. 15-dimension weighted scoring, <1ms local routing. Direct to providers via your own API keys.",
448
+
449
+ register(api: OpenClawPluginApi): void {
450
+ const log = api.logger;
451
+
452
+ log.info(`${LOG_PREFIX} Loading plugin...`);
453
+
454
+ // 1. Register provider at runtime
455
+ api.registerProvider(clawRouterProvider);
456
+ log.info(`${LOG_PREFIX} Provider registered: ${PROVIDER_ID}`);
457
+
458
+ // 2. Set runtime provider config
459
+ if (!api.config.models) api.config.models = { providers: {} };
460
+ if (!api.config.models.providers) api.config.models.providers = {};
461
+ api.config.models.providers[PROVIDER_ID] = buildProviderConfig();
462
+ log.info(`${LOG_PREFIX} Runtime config set`);
463
+
464
+ // 3. Write to openclaw.json atomically (idempotent)
465
+ injectModelsConfig(log);
466
+
467
+ // 4. Inject auth profile placeholder
468
+ injectAuthProfile(log);
469
+
470
+ // 5. Auto-configure default tiers on first run
471
+ if (!isTierConfigured()) {
472
+ writeTierConfig(DEFAULT_TIERS);
473
+ log.info(
474
+ `${LOG_PREFIX} First run: default tier config written. Use /router setup to customize.`,
475
+ );
476
+ }
477
+
478
+ // 5b. Warn about missing API keys (non-blocking)
479
+ logApiKeyWarnings(log);
480
+
481
+ // 6. Register service (manages proxy lifecycle with gateway)
482
+ api.registerService({
483
+ id: `${PROVIDER_ID}-proxy`,
484
+
485
+ async start(): Promise<void> {
486
+ try {
487
+ activeServer = await startProxy(log);
488
+ startTime = Date.now();
489
+ const tiers = getTierStrings();
490
+ log.info(`${LOG_PREFIX} Proxy started on port ${PROXY_PORT}`);
491
+ log.info(`${LOG_PREFIX} SIMPLE → ${tiers.SIMPLE}`);
492
+ log.info(`${LOG_PREFIX} MEDIUM → ${tiers.MEDIUM}`);
493
+ log.info(`${LOG_PREFIX} COMPLEX → ${tiers.COMPLEX}`);
494
+ log.info(`${LOG_PREFIX} REASONING → ${tiers.REASONING}`);
495
+ } catch (err: unknown) {
496
+ const e = err as NodeJS.ErrnoException;
497
+ if (e.code === "EADDRINUSE") {
498
+ log.warn(
499
+ `${LOG_PREFIX} Port ${PROXY_PORT} already in use — another instance may be running`,
500
+ );
501
+ } else {
502
+ log.error(`${LOG_PREFIX} Failed to start proxy: ${err}`);
503
+ }
504
+ }
505
+ },
506
+
507
+ async stop(): Promise<void> {
508
+ if (!activeServer) return;
509
+ await new Promise<void>((resolve, reject) => {
510
+ activeServer!.close((err) => {
511
+ if (err) reject(err);
512
+ else resolve();
513
+ });
514
+ });
515
+ activeServer = null;
516
+ log.info(`${LOG_PREFIX} Proxy stopped, port ${PROXY_PORT} released`);
517
+ },
518
+ });
519
+
520
+ // 7. Register /router command with subcommands
521
+ api.registerCommand({
522
+ name: "router",
523
+ description: "Show router status, configure tiers, or run setup",
524
+ acceptsArgs: true,
525
+ requireAuth: true,
526
+ handler: async (ctx) => {
527
+ const args = (ctx.args ?? ctx.commandBody ?? "").trim();
528
+
529
+ if (args === "help") {
530
+ return handleHelpCommand();
531
+ }
532
+
533
+ if (args === "setup") {
534
+ return handleSetupCommand();
535
+ }
536
+
537
+ if (args === "doctor") {
538
+ return handleDoctorCommand();
539
+ }
540
+
541
+ if (args.startsWith("set ")) {
542
+ return handleSetCommand(args.slice("set ".length));
543
+ }
544
+
545
+ // Default: show status
546
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
547
+ const uptimeStr =
548
+ uptime > 3600
549
+ ? `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`
550
+ : `${Math.floor(uptime / 60)}m ${uptime % 60}s`;
551
+
552
+ const healthy = await fetch(`http://127.0.0.1:${PROXY_PORT}/health`)
553
+ .then((r) => r.ok)
554
+ .catch(() => false);
555
+
556
+ const tiers = getTierStrings();
557
+
558
+ return {
559
+ text: [
560
+ `Claw LLM Router`,
561
+ `Status: ${healthy ? "running" : "not responding"} | Uptime: ${uptimeStr}`,
562
+ ``,
563
+ `SIMPLE → ${tiers.SIMPLE}`,
564
+ `MEDIUM → ${tiers.MEDIUM}`,
565
+ `COMPLEX → ${tiers.COMPLEX}`,
566
+ `REASONING → ${tiers.REASONING}`,
567
+ ``,
568
+ `Port: ${PROXY_PORT} | To switch: /model claw-llm-router/auto`,
569
+ `Configure: /router setup | Set tier: /router set <TIER> <provider/model> | Diagnose: /router doctor`,
570
+ ].join("\n"),
571
+ };
572
+ },
573
+ });
574
+
575
+ // 8. Register before_model_resolve hook for OAuth model override
576
+ // When the router is the primary model and Anthropic OAuth is detected,
577
+ // the proxy sets a pending model override before calling the gateway.
578
+ // This hook intercepts the agent session and redirects it to the actual
579
+ // Anthropic model, breaking the recursion loop.
580
+ api.on(
581
+ "before_model_resolve",
582
+ (event: unknown, _ctx: unknown) => {
583
+ const { prompt } = event as BeforeModelResolveEvent;
584
+ if (!prompt) return;
585
+ const override = consumeOverride(prompt);
586
+ if (override) {
587
+ log.info(
588
+ `${LOG_PREFIX} Model override: ${override.provider}/${override.model} (via before_model_resolve hook)`,
589
+ );
590
+ return {
591
+ modelOverride: override.model,
592
+ providerOverride: override.provider,
593
+ } as BeforeModelResolveResult;
594
+ }
595
+ },
596
+ { priority: 100 }, // High priority to run before other hooks
597
+ );
598
+ log.info(`${LOG_PREFIX} Registered before_model_resolve hook for OAuth override`);
599
+
600
+ log.info(`${LOG_PREFIX} Plugin ready`);
601
+ },
602
+ };
package/models.ts ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Claw LLM Router — Model Definitions
3
+ *
4
+ * Full ModelDefinitionConfig for each tier. OpenClaw uses this metadata
5
+ * for cost display, capability routing, and the /v1/models endpoint.
6
+ */
7
+
8
+ export type ModelApi =
9
+ | "openai-completions"
10
+ | "openai-responses"
11
+ | "anthropic-messages"
12
+ | "google-generative-ai"
13
+ | "github-copilot"
14
+ | "bedrock-converse-stream";
15
+
16
+ export type ModelDefinitionConfig = {
17
+ id: string;
18
+ name: string;
19
+ api?: ModelApi;
20
+ reasoning: boolean;
21
+ input: Array<"text" | "image">;
22
+ cost: {
23
+ input: number;
24
+ output: number;
25
+ cacheRead: number;
26
+ cacheWrite: number;
27
+ };
28
+ contextWindow: number;
29
+ maxTokens: number;
30
+ };
31
+
32
+ export type ModelProviderConfig = {
33
+ baseUrl: string;
34
+ apiKey?: string;
35
+ api?: ModelApi;
36
+ models: ModelDefinitionConfig[];
37
+ };
38
+
39
+ export const PROXY_PORT = 8401;
40
+ export const PROVIDER_ID = "claw-llm-router";
41
+ export const BASE_URL = `http://127.0.0.1:${PROXY_PORT}/v1`;
42
+
43
+ /** Virtual model definitions exposed to OpenClaw */
44
+ export const ROUTER_MODELS: ModelDefinitionConfig[] = [
45
+ {
46
+ id: "auto",
47
+ name: "Smart Router (auto)",
48
+ api: "openai-completions",
49
+ reasoning: false,
50
+ input: ["text"],
51
+ cost: { input: 0.075, output: 0.6, cacheRead: 0, cacheWrite: 0 }, // blended estimate
52
+ contextWindow: 1_000_000,
53
+ maxTokens: 65_536,
54
+ },
55
+ {
56
+ id: "simple",
57
+ name: "Simple — Gemini 2.5 Flash",
58
+ api: "openai-completions",
59
+ reasoning: false,
60
+ input: ["text", "image"],
61
+ cost: { input: 0.075, output: 0.6, cacheRead: 0, cacheWrite: 0 },
62
+ contextWindow: 1_000_000,
63
+ maxTokens: 65_536,
64
+ },
65
+ {
66
+ id: "medium",
67
+ name: "Medium — Claude Haiku 4.5",
68
+ api: "openai-completions",
69
+ reasoning: false,
70
+ input: ["text", "image"],
71
+ cost: { input: 1.0, output: 5.0, cacheRead: 0.1, cacheWrite: 1.25 },
72
+ contextWindow: 200_000,
73
+ maxTokens: 16_384,
74
+ },
75
+ {
76
+ id: "complex",
77
+ name: "Complex — Claude Sonnet 4.6",
78
+ api: "openai-completions",
79
+ reasoning: true,
80
+ input: ["text", "image"],
81
+ cost: { input: 3.0, output: 15.0, cacheRead: 0.3, cacheWrite: 3.75 },
82
+ contextWindow: 200_000,
83
+ maxTokens: 16_384,
84
+ },
85
+ {
86
+ id: "reasoning",
87
+ name: "Reasoning — Claude Opus 4.6",
88
+ api: "openai-completions",
89
+ reasoning: true,
90
+ input: ["text", "image"],
91
+ cost: { input: 5.0, output: 25.0, cacheRead: 0.5, cacheWrite: 6.25 },
92
+ contextWindow: 200_000,
93
+ maxTokens: 16_384,
94
+ },
95
+ ];
96
+
97
+ export function buildProviderConfig(): ModelProviderConfig {
98
+ return {
99
+ baseUrl: BASE_URL,
100
+ apiKey: "local",
101
+ api: "openai-completions",
102
+ models: ROUTER_MODELS,
103
+ };
104
+ }