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/LICENSE +21 -0
- package/README.md +336 -0
- package/classifier.ts +516 -0
- package/docs/ARCHITECTURE.md +82 -0
- package/docs/CLASSIFIER.md +146 -0
- package/docs/PROVIDERS.md +228 -0
- package/index.ts +602 -0
- package/models.ts +104 -0
- package/openclaw.plugin.json +55 -0
- package/package.json +52 -0
- package/provider.ts +30 -0
- package/providers/anthropic.ts +332 -0
- package/providers/gateway.ts +128 -0
- package/providers/index.ts +135 -0
- package/providers/model-override.ts +81 -0
- package/providers/openai-compatible.ts +126 -0
- package/providers/types.ts +29 -0
- package/proxy.ts +282 -0
- package/router-logger.ts +101 -0
- package/tier-config.ts +288 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — Provider Registry
|
|
3
|
+
*
|
|
4
|
+
* Resolves the correct provider based on model spec:
|
|
5
|
+
* 1. Anthropic + OAuth token (sk-ant-oat01-*) → GatewayProvider
|
|
6
|
+
* - When router is NOT the primary model → plain gateway call
|
|
7
|
+
* - When router IS the primary model → gateway with model override
|
|
8
|
+
* (uses before_model_resolve hook to break recursion)
|
|
9
|
+
* 2. Anthropic + direct API key → AnthropicProvider
|
|
10
|
+
* 3. Everything else → OpenAICompatibleProvider
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import type { ServerResponse } from "node:http";
|
|
15
|
+
import type { LLMProvider, PluginLogger } from "./types.js";
|
|
16
|
+
import type { TierModelSpec } from "../tier-config.js";
|
|
17
|
+
import { envVarName } from "../tier-config.js";
|
|
18
|
+
import { OpenAICompatibleProvider } from "./openai-compatible.js";
|
|
19
|
+
import { AnthropicProvider } from "./anthropic.js";
|
|
20
|
+
import { GatewayProvider } from "./gateway.js";
|
|
21
|
+
import { setPendingOverride, extractUserPromptFromBody } from "./model-override.js";
|
|
22
|
+
import { RouterLogger } from "../router-logger.js";
|
|
23
|
+
|
|
24
|
+
const openaiCompatibleProvider = new OpenAICompatibleProvider();
|
|
25
|
+
const anthropicProvider = new AnthropicProvider();
|
|
26
|
+
const gatewayProvider = new GatewayProvider();
|
|
27
|
+
|
|
28
|
+
const HOME = process.env.HOME ?? "";
|
|
29
|
+
const OPENCLAW_CONFIG_PATH = `${HOME}/.openclaw/openclaw.json`;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Check if the router is set as the primary model.
|
|
33
|
+
* When it is, gateway calls will recurse (gateway creates agent sessions
|
|
34
|
+
* using the primary model → calls the router → calls gateway → loop).
|
|
35
|
+
*/
|
|
36
|
+
function isRouterPrimaryModel(): boolean {
|
|
37
|
+
try {
|
|
38
|
+
const raw = readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
|
|
39
|
+
const config = JSON.parse(raw) as {
|
|
40
|
+
agents?: { defaults?: { model?: { primary?: string } } };
|
|
41
|
+
};
|
|
42
|
+
const primary = config.agents?.defaults?.model?.primary ?? "";
|
|
43
|
+
return primary.startsWith("claw-llm-router");
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let cachedIsRouterPrimary: boolean | undefined;
|
|
50
|
+
function getIsRouterPrimary(): boolean {
|
|
51
|
+
if (cachedIsRouterPrimary === undefined) {
|
|
52
|
+
cachedIsRouterPrimary = isRouterPrimaryModel();
|
|
53
|
+
}
|
|
54
|
+
return cachedIsRouterPrimary;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Refresh the cache periodically (every 30s) in case config changes
|
|
58
|
+
const _cacheInterval = setInterval(() => {
|
|
59
|
+
cachedIsRouterPrimary = undefined;
|
|
60
|
+
}, 30_000);
|
|
61
|
+
_cacheInterval.unref?.();
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Gateway-with-override provider: routes through the gateway but sets a
|
|
65
|
+
* pending model override so the before_model_resolve hook can redirect
|
|
66
|
+
* the agent session to the actual Anthropic model (breaking the recursion).
|
|
67
|
+
*/
|
|
68
|
+
const gatewayOverrideProvider: LLMProvider = {
|
|
69
|
+
name: "gateway-with-override",
|
|
70
|
+
async chatCompletion(body, spec, stream, res, log): Promise<void> {
|
|
71
|
+
const rlog = new RouterLogger(log);
|
|
72
|
+
const fullSpec = spec as TierModelSpec;
|
|
73
|
+
const userPrompt = extractUserPromptFromBody(body);
|
|
74
|
+
if (!userPrompt) {
|
|
75
|
+
log.warn("Gateway override: no user prompt found — override may not match");
|
|
76
|
+
}
|
|
77
|
+
rlog.override({ provider: fullSpec.provider, model: spec.modelId });
|
|
78
|
+
setPendingOverride(userPrompt, spec.modelId, fullSpec.provider);
|
|
79
|
+
await gatewayProvider.chatCompletion(body, spec, stream, res, log);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export class MissingApiKeyError extends Error {
|
|
84
|
+
public readonly provider: string;
|
|
85
|
+
public readonly modelId: string;
|
|
86
|
+
public readonly envVar: string;
|
|
87
|
+
|
|
88
|
+
constructor(provider: string, modelId: string, envVar: string) {
|
|
89
|
+
super(
|
|
90
|
+
`No API key for ${provider}/${modelId}. ` +
|
|
91
|
+
`Set ${envVar} or run /auth to add ${provider} credentials. ` +
|
|
92
|
+
`Details: /router doctor`,
|
|
93
|
+
);
|
|
94
|
+
this.name = "MissingApiKeyError";
|
|
95
|
+
this.provider = provider;
|
|
96
|
+
this.modelId = modelId;
|
|
97
|
+
this.envVar = envVar;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function resolveProvider(spec: TierModelSpec): LLMProvider {
|
|
102
|
+
// Any provider with OAuth credentials → route through gateway
|
|
103
|
+
// (gateway handles token refresh and API format conversion)
|
|
104
|
+
if (spec.isOAuth) {
|
|
105
|
+
if (getIsRouterPrimary()) {
|
|
106
|
+
return gatewayOverrideProvider;
|
|
107
|
+
}
|
|
108
|
+
return gatewayProvider;
|
|
109
|
+
}
|
|
110
|
+
if (spec.isAnthropic) {
|
|
111
|
+
return anthropicProvider;
|
|
112
|
+
}
|
|
113
|
+
return openaiCompatibleProvider;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function callProvider(
|
|
117
|
+
spec: TierModelSpec,
|
|
118
|
+
body: Record<string, unknown>,
|
|
119
|
+
stream: boolean,
|
|
120
|
+
res: ServerResponse,
|
|
121
|
+
log: PluginLogger,
|
|
122
|
+
): Promise<void> {
|
|
123
|
+
if (!spec.apiKey) {
|
|
124
|
+
throw new MissingApiKeyError(spec.provider, spec.modelId, envVarName(spec.provider));
|
|
125
|
+
}
|
|
126
|
+
const rlog = new RouterLogger(log);
|
|
127
|
+
const provider = resolveProvider(spec);
|
|
128
|
+
rlog.provider({ name: provider.name, provider: spec.provider, model: spec.modelId });
|
|
129
|
+
await provider.chatCompletion(body, spec, stream, res, log);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export { openaiCompatibleProvider, anthropicProvider, gatewayProvider };
|
|
133
|
+
|
|
134
|
+
// Export for testing
|
|
135
|
+
export { getIsRouterPrimary };
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — In-Process Model Override Store
|
|
3
|
+
*
|
|
4
|
+
* When the router is the primary model AND Anthropic OAuth is detected,
|
|
5
|
+
* direct gateway calls cause recursion (gateway creates agent sessions
|
|
6
|
+
* using the primary model → routes back through the router).
|
|
7
|
+
*
|
|
8
|
+
* Solution: Before calling the gateway, store a pending model override
|
|
9
|
+
* keyed by the user prompt. The plugin's `before_model_resolve` hook
|
|
10
|
+
* consumes the override and tells the gateway to use the actual Anthropic
|
|
11
|
+
* model instead of routing back through the router.
|
|
12
|
+
*
|
|
13
|
+
* Key = first 500 chars of the user prompt (enough for uniqueness).
|
|
14
|
+
* Entries auto-expire after 30 seconds.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const pendingOverrides = new Map<string, { model: string; provider: string; expires: number }>();
|
|
18
|
+
|
|
19
|
+
function makeKey(prompt: string): string {
|
|
20
|
+
return prompt.slice(0, 500);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function setPendingOverride(prompt: string, model: string, provider: string): void {
|
|
24
|
+
const key = makeKey(prompt);
|
|
25
|
+
pendingOverrides.set(key, {
|
|
26
|
+
model,
|
|
27
|
+
provider,
|
|
28
|
+
expires: Date.now() + 30_000,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function consumeOverride(prompt: string): { model: string; provider: string } | undefined {
|
|
33
|
+
const key = makeKey(prompt);
|
|
34
|
+
const entry = pendingOverrides.get(key);
|
|
35
|
+
if (!entry) return undefined;
|
|
36
|
+
pendingOverrides.delete(key);
|
|
37
|
+
if (Date.now() > entry.expires) return undefined;
|
|
38
|
+
return { model: entry.model, provider: entry.provider };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Extract the last user message from a chat completion request body.
|
|
43
|
+
* Used to generate the override key.
|
|
44
|
+
*/
|
|
45
|
+
export function extractUserPromptFromBody(body: Record<string, unknown>): string {
|
|
46
|
+
const messages = (body.messages ?? []) as Array<{
|
|
47
|
+
role: string;
|
|
48
|
+
content: string | unknown;
|
|
49
|
+
}>;
|
|
50
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
51
|
+
if (messages[i].role === "user") {
|
|
52
|
+
const content = messages[i].content;
|
|
53
|
+
if (typeof content === "string") return content;
|
|
54
|
+
if (Array.isArray(content)) {
|
|
55
|
+
return (content as Array<{ type: string; text?: string }>)
|
|
56
|
+
.filter((c) => c.type === "text")
|
|
57
|
+
.map((c) => c.text ?? "")
|
|
58
|
+
.join(" ");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return "";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Cleanup expired entries periodically
|
|
66
|
+
const cleanupInterval = setInterval(() => {
|
|
67
|
+
const now = Date.now();
|
|
68
|
+
for (const [key, val] of pendingOverrides) {
|
|
69
|
+
if (now > val.expires) pendingOverrides.delete(key);
|
|
70
|
+
}
|
|
71
|
+
}, 60_000);
|
|
72
|
+
cleanupInterval.unref?.();
|
|
73
|
+
|
|
74
|
+
// For testing
|
|
75
|
+
export function clearOverrides(): void {
|
|
76
|
+
pendingOverrides.clear();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function pendingCount(): number {
|
|
80
|
+
return pendingOverrides.size;
|
|
81
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — OpenAI-Compatible Provider
|
|
3
|
+
*
|
|
4
|
+
* Handles: Google Gemini, OpenAI, Groq, Mistral, DeepSeek, Together,
|
|
5
|
+
* Fireworks, Perplexity, xAI, and any other OpenAI-compatible API.
|
|
6
|
+
*
|
|
7
|
+
* POST to {baseUrl}/chat/completions with Bearer auth.
|
|
8
|
+
* Forwards request body with only standard OpenAI chat completion params
|
|
9
|
+
* (non-standard fields like `store` cause 400 errors on Google/Groq/etc).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ServerResponse } from "node:http";
|
|
13
|
+
import { REQUEST_TIMEOUT_MS, type LLMProvider, type PluginLogger } from "./types.js";
|
|
14
|
+
import { RouterLogger } from "../router-logger.js";
|
|
15
|
+
|
|
16
|
+
// Standard OpenAI chat completion parameters that providers generally accept.
|
|
17
|
+
// Non-standard or provider-specific fields (e.g. `store`, `metadata`) are stripped
|
|
18
|
+
// to avoid 400 errors from providers like Google Gemini.
|
|
19
|
+
const ALLOWED_PARAMS = new Set([
|
|
20
|
+
"messages",
|
|
21
|
+
"model",
|
|
22
|
+
"stream",
|
|
23
|
+
"max_tokens",
|
|
24
|
+
"max_completion_tokens",
|
|
25
|
+
"temperature",
|
|
26
|
+
"top_p",
|
|
27
|
+
"n",
|
|
28
|
+
"stop",
|
|
29
|
+
"presence_penalty",
|
|
30
|
+
"frequency_penalty",
|
|
31
|
+
"logit_bias",
|
|
32
|
+
"logprobs",
|
|
33
|
+
"top_logprobs",
|
|
34
|
+
"response_format",
|
|
35
|
+
"seed",
|
|
36
|
+
"tools",
|
|
37
|
+
"tool_choice",
|
|
38
|
+
"parallel_tool_calls",
|
|
39
|
+
"user",
|
|
40
|
+
"stream_options",
|
|
41
|
+
"service_tier",
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
function sanitizeBody(body: Record<string, unknown>): Record<string, unknown> {
|
|
45
|
+
const clean: Record<string, unknown> = {};
|
|
46
|
+
for (const [key, value] of Object.entries(body)) {
|
|
47
|
+
if (ALLOWED_PARAMS.has(key)) {
|
|
48
|
+
clean[key] = value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return clean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class OpenAICompatibleProvider implements LLMProvider {
|
|
55
|
+
readonly name = "openai-compatible";
|
|
56
|
+
|
|
57
|
+
async chatCompletion(
|
|
58
|
+
body: Record<string, unknown>,
|
|
59
|
+
spec: { modelId: string; apiKey: string; baseUrl: string },
|
|
60
|
+
stream: boolean,
|
|
61
|
+
res: ServerResponse,
|
|
62
|
+
log: PluginLogger,
|
|
63
|
+
): Promise<void> {
|
|
64
|
+
const url = `${spec.baseUrl}/chat/completions`;
|
|
65
|
+
const payload = { ...sanitizeBody(body), model: spec.modelId, stream };
|
|
66
|
+
|
|
67
|
+
const controller = new AbortController();
|
|
68
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const resp = await fetch(url, {
|
|
72
|
+
method: "POST",
|
|
73
|
+
headers: {
|
|
74
|
+
Authorization: `Bearer ${spec.apiKey}`,
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(payload),
|
|
78
|
+
signal: controller.signal,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
const errText = await resp.text();
|
|
83
|
+
throw new Error(`${spec.modelId} ${resp.status}: ${errText.slice(0, 300)}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rlog = new RouterLogger(log);
|
|
87
|
+
|
|
88
|
+
if (stream) {
|
|
89
|
+
res.writeHead(200, {
|
|
90
|
+
"Content-Type": "text/event-stream",
|
|
91
|
+
"Cache-Control": "no-cache",
|
|
92
|
+
"X-Accel-Buffering": "no",
|
|
93
|
+
});
|
|
94
|
+
const reader = resp.body?.getReader();
|
|
95
|
+
if (!reader) throw new Error(`No response body from ${spec.modelId}`);
|
|
96
|
+
const decoder = new TextDecoder();
|
|
97
|
+
while (true) {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done) break;
|
|
100
|
+
if (!res.writableEnded) res.write(decoder.decode(value, { stream: true }));
|
|
101
|
+
}
|
|
102
|
+
if (!res.writableEnded) res.end();
|
|
103
|
+
rlog.done({ model: spec.modelId, via: "direct", streamed: true });
|
|
104
|
+
} else {
|
|
105
|
+
const data = (await resp.json()) as Record<string, unknown>;
|
|
106
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
107
|
+
res.end(JSON.stringify(data));
|
|
108
|
+
const usage = (data.usage ?? {}) as Record<string, number>;
|
|
109
|
+
rlog.done({
|
|
110
|
+
model: spec.modelId,
|
|
111
|
+
via: "direct",
|
|
112
|
+
streamed: false,
|
|
113
|
+
tokensIn: usage.prompt_tokens ?? "?",
|
|
114
|
+
tokensOut: usage.completion_tokens ?? "?",
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
119
|
+
throw new Error(`${spec.modelId} request timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
|
120
|
+
}
|
|
121
|
+
throw err;
|
|
122
|
+
} finally {
|
|
123
|
+
clearTimeout(timeoutId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — Provider Types
|
|
3
|
+
*
|
|
4
|
+
* Shared interface and types for all LLM providers.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ServerResponse } from "node:http";
|
|
8
|
+
|
|
9
|
+
export type PluginLogger = {
|
|
10
|
+
info: (msg: string) => void;
|
|
11
|
+
warn: (msg: string) => void;
|
|
12
|
+
error: (msg: string) => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type ChatMessage = { role: string; content: string | unknown };
|
|
16
|
+
|
|
17
|
+
/** Default timeout for outbound provider requests (3 minutes). */
|
|
18
|
+
export const REQUEST_TIMEOUT_MS = 180_000;
|
|
19
|
+
|
|
20
|
+
export interface LLMProvider {
|
|
21
|
+
readonly name: string;
|
|
22
|
+
chatCompletion(
|
|
23
|
+
body: Record<string, unknown>,
|
|
24
|
+
spec: { modelId: string; apiKey: string; baseUrl: string },
|
|
25
|
+
stream: boolean,
|
|
26
|
+
res: ServerResponse,
|
|
27
|
+
log: PluginLogger,
|
|
28
|
+
): Promise<void>;
|
|
29
|
+
}
|
package/proxy.ts
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — In-Process HTTP Proxy
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the OpenClaw gateway process (no subprocess).
|
|
5
|
+
* Classifies prompts locally, then routes to the right model via
|
|
6
|
+
* direct provider calls (OpenAI-compatible, Anthropic Messages API,
|
|
7
|
+
* or gateway fallback for OAuth tokens).
|
|
8
|
+
*
|
|
9
|
+
* Auth is NEVER stored in the plugin — keys are read from OpenClaw's auth stores.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
13
|
+
import { classify, tierFromModelId, FALLBACK_CHAIN, type Tier } from "./classifier.js";
|
|
14
|
+
import { PROXY_PORT } from "./models.js";
|
|
15
|
+
import { loadTierConfig } from "./tier-config.js";
|
|
16
|
+
import { callProvider, MissingApiKeyError } from "./providers/index.js";
|
|
17
|
+
import type { PluginLogger, ChatMessage } from "./providers/types.js";
|
|
18
|
+
import { RouterLogger } from "./router-logger.js";
|
|
19
|
+
|
|
20
|
+
// ── Message extraction ───────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function extractUserPrompt(messages: ChatMessage[]): string {
|
|
23
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
24
|
+
const m = messages[i];
|
|
25
|
+
if (m.role === "user") {
|
|
26
|
+
if (typeof m.content === "string") return m.content;
|
|
27
|
+
if (Array.isArray(m.content)) {
|
|
28
|
+
return (m.content as Array<{ type: string; text?: string }>)
|
|
29
|
+
.filter((c) => c.type === "text")
|
|
30
|
+
.map((c) => c.text ?? "")
|
|
31
|
+
.join(" ");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return "";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function extractSystemPrompt(messages: ChatMessage[]): string {
|
|
39
|
+
return messages
|
|
40
|
+
.filter((m) => m.role === "system")
|
|
41
|
+
.map((m) => (typeof m.content === "string" ? m.content : ""))
|
|
42
|
+
.join(" ");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Request router ────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
async function handleChatCompletion(
|
|
48
|
+
_req: IncomingMessage,
|
|
49
|
+
res: ServerResponse,
|
|
50
|
+
body: Record<string, unknown>,
|
|
51
|
+
log: PluginLogger,
|
|
52
|
+
): Promise<void> {
|
|
53
|
+
const messages = (body.messages ?? []) as ChatMessage[];
|
|
54
|
+
const stream = (body.stream as boolean) ?? false;
|
|
55
|
+
const modelId = ((body.model as string) ?? "auto").replace("claw-llm-router/", "");
|
|
56
|
+
|
|
57
|
+
const rlog = new RouterLogger(log);
|
|
58
|
+
const userPrompt = extractUserPrompt(messages);
|
|
59
|
+
const systemPrompt = extractSystemPrompt(messages);
|
|
60
|
+
|
|
61
|
+
// ── Extract classifiable prompt ──────────────────────────────────────────
|
|
62
|
+
// The user message may contain more than just the user's input:
|
|
63
|
+
// 1. Packed context (group chats / subagents): history + current message
|
|
64
|
+
// 2. Embedded system prompt: system instructions prepended to user text
|
|
65
|
+
// We need to isolate the actual user text for accurate classification.
|
|
66
|
+
|
|
67
|
+
const isPackedContext =
|
|
68
|
+
userPrompt.startsWith("[Chat messages since") || userPrompt.startsWith("[chat messages since");
|
|
69
|
+
|
|
70
|
+
let classifiablePrompt = userPrompt;
|
|
71
|
+
|
|
72
|
+
// Case 0: Strip "Conversation info (untrusted metadata)" wrapper
|
|
73
|
+
// OpenClaw prepends message metadata as a fenced JSON block:
|
|
74
|
+
// Conversation info (untrusted metadata): ```json { ... }```\n\nActual prompt
|
|
75
|
+
// Strip it before classification so ```/json don't pollute scoring.
|
|
76
|
+
const metadataPrefix = "Conversation info (untrusted metadata):";
|
|
77
|
+
if (classifiablePrompt.startsWith(metadataPrefix)) {
|
|
78
|
+
const closingFence = classifiablePrompt.indexOf("```", metadataPrefix.length + 4); // skip opening ```
|
|
79
|
+
if (closingFence !== -1) {
|
|
80
|
+
classifiablePrompt = classifiablePrompt.slice(closingFence + 3).trim();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (isPackedContext) {
|
|
85
|
+
// Case 1: Packed context — extract text after the current-message marker
|
|
86
|
+
const marker = "[Current message - respond to this]";
|
|
87
|
+
const markerIdx = userPrompt.indexOf(marker);
|
|
88
|
+
if (markerIdx !== -1) {
|
|
89
|
+
classifiablePrompt = userPrompt.slice(markerIdx + marker.length).trim();
|
|
90
|
+
}
|
|
91
|
+
} else if (systemPrompt && userPrompt.length > systemPrompt.length) {
|
|
92
|
+
// Case 2: System prompt embedded in user message — strip it
|
|
93
|
+
// Some paths (e.g. webchat) prepend the system prompt to the user message
|
|
94
|
+
// instead of sending it as a separate system-role message.
|
|
95
|
+
const sysIdx = userPrompt.indexOf(systemPrompt);
|
|
96
|
+
if (sysIdx !== -1) {
|
|
97
|
+
const stripped = (
|
|
98
|
+
userPrompt.slice(0, sysIdx) + userPrompt.slice(sysIdx + systemPrompt.length)
|
|
99
|
+
).trim();
|
|
100
|
+
if (stripped) classifiablePrompt = stripped;
|
|
101
|
+
}
|
|
102
|
+
} else if (!systemPrompt && userPrompt.length > 500) {
|
|
103
|
+
// Case 3: No separate system message and user message is suspiciously long.
|
|
104
|
+
// The system prompt is likely embedded. The actual user text is at the end,
|
|
105
|
+
// after the last paragraph break.
|
|
106
|
+
const lastBreak = userPrompt.lastIndexOf("\n\n");
|
|
107
|
+
if (lastBreak !== -1) {
|
|
108
|
+
const tail = userPrompt.slice(lastBreak).trim();
|
|
109
|
+
if (tail && tail.length < 500) {
|
|
110
|
+
classifiablePrompt = tail;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Classify ────────────────────────────────────────────────────────────
|
|
116
|
+
const extracted = classifiablePrompt !== userPrompt;
|
|
117
|
+
rlog.request({
|
|
118
|
+
model: modelId,
|
|
119
|
+
stream,
|
|
120
|
+
prompt: classifiablePrompt,
|
|
121
|
+
extraction: extracted ? { from: userPrompt.length, to: classifiablePrompt.length } : undefined,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
let tier: Tier;
|
|
125
|
+
let classificationMethod: string;
|
|
126
|
+
|
|
127
|
+
const tierOverride = tierFromModelId(modelId);
|
|
128
|
+
if (tierOverride) {
|
|
129
|
+
tier = tierOverride;
|
|
130
|
+
classificationMethod = "forced";
|
|
131
|
+
rlog.classify({ tier, method: "forced", detail: `(model=${modelId})` });
|
|
132
|
+
} else if (isPackedContext && !classifiablePrompt) {
|
|
133
|
+
tier = "MEDIUM";
|
|
134
|
+
classificationMethod = "packed-default";
|
|
135
|
+
rlog.classify({
|
|
136
|
+
tier: "MEDIUM",
|
|
137
|
+
method: "packed-default",
|
|
138
|
+
detail: `(no current-message marker in ${userPrompt.length}-char packed context)`,
|
|
139
|
+
});
|
|
140
|
+
} else {
|
|
141
|
+
const result = classify(classifiablePrompt);
|
|
142
|
+
tier = result.tier;
|
|
143
|
+
classificationMethod = "rule-based";
|
|
144
|
+
rlog.classify({
|
|
145
|
+
tier,
|
|
146
|
+
method: "rule-based",
|
|
147
|
+
score: result.score,
|
|
148
|
+
confidence: result.confidence,
|
|
149
|
+
signals: result.signals,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Route ──────────────────────────────────────────────────────────────
|
|
154
|
+
const tierConfig = loadTierConfig();
|
|
155
|
+
const chain = FALLBACK_CHAIN[tier];
|
|
156
|
+
const targetSpec = tierConfig[tier];
|
|
157
|
+
rlog.route({
|
|
158
|
+
tier,
|
|
159
|
+
provider: targetSpec.provider,
|
|
160
|
+
model: targetSpec.modelId,
|
|
161
|
+
method: classificationMethod,
|
|
162
|
+
chain,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
let lastError: Error | undefined;
|
|
166
|
+
let allMissingKeys = true;
|
|
167
|
+
|
|
168
|
+
for (const attemptTier of chain) {
|
|
169
|
+
const spec = tierConfig[attemptTier];
|
|
170
|
+
try {
|
|
171
|
+
await callProvider(spec, body, stream, res, log);
|
|
172
|
+
return; // success
|
|
173
|
+
} catch (err) {
|
|
174
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
175
|
+
if (!(err instanceof MissingApiKeyError)) allMissingKeys = false;
|
|
176
|
+
rlog.fallback({
|
|
177
|
+
tier: attemptTier,
|
|
178
|
+
provider: spec.provider,
|
|
179
|
+
model: spec.modelId,
|
|
180
|
+
error: lastError.message,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
rlog.failed({ chain, error: lastError?.message ?? "unknown" });
|
|
186
|
+
if (!res.headersSent) {
|
|
187
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
188
|
+
}
|
|
189
|
+
if (!res.writableEnded) {
|
|
190
|
+
const message = allMissingKeys
|
|
191
|
+
? `No API keys configured. Run /router doctor to see what's needed, or set API keys for your providers (e.g. GEMINI_API_KEY, ANTHROPIC_API_KEY). See: https://github.com/anthropics/claw-llm-router#setup`
|
|
192
|
+
: `All providers failed: ${lastError?.message}`;
|
|
193
|
+
res.end(
|
|
194
|
+
JSON.stringify({
|
|
195
|
+
error: { message, type: "router_error" },
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Server ────────────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const chunks: Buffer[] = [];
|
|
206
|
+
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
207
|
+
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
208
|
+
req.on("error", reject);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const CREATED_AT = Math.floor(Date.now() / 1000);
|
|
213
|
+
|
|
214
|
+
export async function startProxy(log: PluginLogger): Promise<Server> {
|
|
215
|
+
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
216
|
+
try {
|
|
217
|
+
// Health check
|
|
218
|
+
if (req.url === "/health" || req.url?.startsWith("/health?")) {
|
|
219
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
220
|
+
res.end(JSON.stringify({ status: "ok", version: "1.0.0" }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Models list
|
|
225
|
+
if (req.url === "/v1/models" && req.method === "GET") {
|
|
226
|
+
const { ROUTER_MODELS, PROVIDER_ID } = await import("./models.js");
|
|
227
|
+
const models = ROUTER_MODELS.map((m) => ({
|
|
228
|
+
id: m.id,
|
|
229
|
+
object: "model",
|
|
230
|
+
created: CREATED_AT,
|
|
231
|
+
owned_by: PROVIDER_ID,
|
|
232
|
+
}));
|
|
233
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
234
|
+
res.end(JSON.stringify({ object: "list", data: models }));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Chat completions
|
|
239
|
+
if (req.url === "/v1/chat/completions" && req.method === "POST") {
|
|
240
|
+
const rawBody = await readBody(req);
|
|
241
|
+
let body: Record<string, unknown>;
|
|
242
|
+
try {
|
|
243
|
+
body = JSON.parse(rawBody.toString()) as Record<string, unknown>;
|
|
244
|
+
} catch {
|
|
245
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
246
|
+
res.end(JSON.stringify({ error: { message: "Invalid JSON", type: "invalid_request" } }));
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await handleChatCompletion(req, res, body, log);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
254
|
+
res.end(JSON.stringify({ error: { message: "Not found", type: "not_found" } }));
|
|
255
|
+
} catch (err) {
|
|
256
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
257
|
+
log.error(`Proxy error: ${msg}`);
|
|
258
|
+
if (!res.headersSent) {
|
|
259
|
+
res.writeHead(502, { "Content-Type": "application/json" });
|
|
260
|
+
}
|
|
261
|
+
if (!res.writableEnded) {
|
|
262
|
+
res.end(JSON.stringify({ error: { message: msg, type: "proxy_error" } }));
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
server.on("error", (err: NodeJS.ErrnoException) => {
|
|
269
|
+
if (err.code === "EADDRINUSE") {
|
|
270
|
+
log.warn(`Port ${PROXY_PORT} already in use — proxy may already be running`);
|
|
271
|
+
reject(err);
|
|
272
|
+
} else {
|
|
273
|
+
reject(err);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
server.listen(PROXY_PORT, "127.0.0.1", () => {
|
|
278
|
+
log.info(`Proxy started on http://127.0.0.1:${PROXY_PORT}`);
|
|
279
|
+
resolve(server);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|