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,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "claw-llm-router",
|
|
3
|
+
"name": "Claw LLM Router",
|
|
4
|
+
"description": "Local prompt classifier that routes to the cheapest capable model. 15-dimension weighted scoring, <1ms local routing. Supports any OpenAI-compatible provider + Anthropic native API. Direct to providers via your own API keys — no third parties.",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"author": "Donn Felker",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": "https://github.com/donnfelker/claw-llm-router",
|
|
9
|
+
"entryPoint": "index.ts",
|
|
10
|
+
"providers": ["claw-llm-router"],
|
|
11
|
+
"configSchema": {
|
|
12
|
+
"type": "object",
|
|
13
|
+
"additionalProperties": false,
|
|
14
|
+
"properties": {
|
|
15
|
+
"port": {
|
|
16
|
+
"type": "number",
|
|
17
|
+
"description": "Local proxy port (default: 8401)"
|
|
18
|
+
},
|
|
19
|
+
"defaultModel": {
|
|
20
|
+
"type": "string",
|
|
21
|
+
"description": "Default routing model (default: claw-llm-router/auto)"
|
|
22
|
+
},
|
|
23
|
+
"tiers": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"description": "Model assignment per tier (format: provider/model-id)",
|
|
26
|
+
"properties": {
|
|
27
|
+
"SIMPLE": {
|
|
28
|
+
"type": "string",
|
|
29
|
+
"description": "Model for simple tasks (e.g., google/gemini-2.5-flash)"
|
|
30
|
+
},
|
|
31
|
+
"MEDIUM": {
|
|
32
|
+
"type": "string",
|
|
33
|
+
"description": "Model for medium tasks (e.g., anthropic/claude-haiku-4-5-20251001)"
|
|
34
|
+
},
|
|
35
|
+
"COMPLEX": {
|
|
36
|
+
"type": "string",
|
|
37
|
+
"description": "Model for complex tasks (e.g., anthropic/claude-sonnet-4-6)"
|
|
38
|
+
},
|
|
39
|
+
"REASONING": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Model for reasoning tasks (e.g., anthropic/claude-opus-4-6)"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
"classifierModel": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Model for LLM-assisted classification when rule-based confidence is low (e.g., google/gemini-2.5-flash)"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"uiHints": {
|
|
52
|
+
"port": { "label": "Proxy Port", "placeholder": "8401" },
|
|
53
|
+
"defaultModel": { "label": "Default Model", "placeholder": "claw-llm-router/auto" }
|
|
54
|
+
}
|
|
55
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claw-llm-router",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "OpenClaw plugin that cuts LLM costs 40-80% by routing prompts to the cheapest capable model. 15-dimension classifier, <1ms local routing, automatic fallback chain.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Donn Felker",
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/donnfelker/claw-llm-router.git"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"openclaw",
|
|
14
|
+
"openclaw-plugin",
|
|
15
|
+
"llm",
|
|
16
|
+
"router",
|
|
17
|
+
"classifier",
|
|
18
|
+
"ai"
|
|
19
|
+
],
|
|
20
|
+
"openclaw": {
|
|
21
|
+
"extensions": [
|
|
22
|
+
"./index.ts"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"*.ts",
|
|
27
|
+
"providers/",
|
|
28
|
+
"openclaw.plugin.json",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE",
|
|
31
|
+
"docs/"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "npx tsx --test tests/providers/*.test.ts tests/classifier.test.ts tests/proxy.test.ts tests/tier-config.test.ts tests/router-logger.test.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"lint": "eslint .",
|
|
37
|
+
"format": "prettier --check .",
|
|
38
|
+
"format:fix": "prettier --write .",
|
|
39
|
+
"check": "npm run format && npm run lint && npm run typecheck && npm run test"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"tsx": "^4.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@eslint/js": "^9.0.0",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"eslint": "^9.0.0",
|
|
48
|
+
"prettier": "^3.0.0",
|
|
49
|
+
"typescript": "^5.5.0",
|
|
50
|
+
"typescript-eslint": "^8.0.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/provider.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — Provider Plugin Definition
|
|
3
|
+
*
|
|
4
|
+
* Registered at runtime via api.registerProvider().
|
|
5
|
+
* No auth required from OpenClaw's perspective — the proxy handles
|
|
6
|
+
* routing to providers using credentials from OpenClaw's auth stores
|
|
7
|
+
* (auth-profiles.json, auth.json, env vars). Never stores credentials itself.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { buildProviderConfig, PROVIDER_ID } from "./models.js";
|
|
11
|
+
|
|
12
|
+
export type ProviderPlugin = {
|
|
13
|
+
id: string;
|
|
14
|
+
label: string;
|
|
15
|
+
docsPath?: string;
|
|
16
|
+
aliases?: string[];
|
|
17
|
+
envVars?: string[];
|
|
18
|
+
models?: ReturnType<typeof buildProviderConfig>;
|
|
19
|
+
auth: unknown[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const clawRouterProvider: ProviderPlugin = {
|
|
23
|
+
id: PROVIDER_ID,
|
|
24
|
+
label: "Claw LLM Router",
|
|
25
|
+
docsPath: "https://github.com/donnfelker/claw-llm-router",
|
|
26
|
+
aliases: ["clawrouter", "router"],
|
|
27
|
+
envVars: ["ANTHROPIC_API_KEY", "GEMINI_API_KEY"],
|
|
28
|
+
models: buildProviderConfig(),
|
|
29
|
+
auth: [], // No auth needed — proxy handles it
|
|
30
|
+
};
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — Anthropic Messages API Provider
|
|
3
|
+
*
|
|
4
|
+
* Handles Anthropic models when the user has a direct API key (not OAuth).
|
|
5
|
+
* Converts OpenAI chat completion format ↔ Anthropic Messages API format.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ServerResponse } from "node:http";
|
|
9
|
+
import {
|
|
10
|
+
REQUEST_TIMEOUT_MS,
|
|
11
|
+
type LLMProvider,
|
|
12
|
+
type PluginLogger,
|
|
13
|
+
type ChatMessage,
|
|
14
|
+
} from "./types.js";
|
|
15
|
+
import { RouterLogger } from "../router-logger.js";
|
|
16
|
+
|
|
17
|
+
// ── OpenAI → Anthropic request conversion ────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
type AnthropicMessage = { role: "user" | "assistant"; content: string };
|
|
20
|
+
|
|
21
|
+
function convertMessages(messages: ChatMessage[]): {
|
|
22
|
+
system: string | undefined;
|
|
23
|
+
messages: AnthropicMessage[];
|
|
24
|
+
} {
|
|
25
|
+
const systemParts: string[] = [];
|
|
26
|
+
const converted: AnthropicMessage[] = [];
|
|
27
|
+
|
|
28
|
+
for (const msg of messages) {
|
|
29
|
+
const content =
|
|
30
|
+
typeof msg.content === "string"
|
|
31
|
+
? msg.content
|
|
32
|
+
: Array.isArray(msg.content)
|
|
33
|
+
? (msg.content as Array<{ type: string; text?: string }>)
|
|
34
|
+
.filter((c) => c.type === "text")
|
|
35
|
+
.map((c) => c.text ?? "")
|
|
36
|
+
.join("")
|
|
37
|
+
: String(msg.content ?? "");
|
|
38
|
+
|
|
39
|
+
if (msg.role === "system") {
|
|
40
|
+
systemParts.push(content);
|
|
41
|
+
} else if (msg.role === "user" || msg.role === "assistant") {
|
|
42
|
+
converted.push({ role: msg.role, content });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
system: systemParts.length > 0 ? systemParts.join("\n\n") : undefined,
|
|
48
|
+
messages: converted,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Remove OpenAI-only params that Anthropic doesn't accept
|
|
53
|
+
const OPENAI_ONLY_PARAMS = new Set([
|
|
54
|
+
"n",
|
|
55
|
+
"frequency_penalty",
|
|
56
|
+
"presence_penalty",
|
|
57
|
+
"logprobs",
|
|
58
|
+
"top_logprobs",
|
|
59
|
+
"logit_bias",
|
|
60
|
+
"response_format",
|
|
61
|
+
"seed",
|
|
62
|
+
"service_tier",
|
|
63
|
+
"tools",
|
|
64
|
+
"tool_choice",
|
|
65
|
+
"parallel_tool_calls",
|
|
66
|
+
"user",
|
|
67
|
+
"store",
|
|
68
|
+
"metadata",
|
|
69
|
+
"stream_options",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
function buildAnthropicBody(
|
|
73
|
+
body: Record<string, unknown>,
|
|
74
|
+
modelId: string,
|
|
75
|
+
systemText: string | undefined,
|
|
76
|
+
messages: AnthropicMessage[],
|
|
77
|
+
): Record<string, unknown> {
|
|
78
|
+
const anthropicBody: Record<string, unknown> = {};
|
|
79
|
+
|
|
80
|
+
// Copy non-OpenAI-only params
|
|
81
|
+
for (const [key, value] of Object.entries(body)) {
|
|
82
|
+
if (key === "model" || key === "messages" || key === "stream" || OPENAI_ONLY_PARAMS.has(key)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
anthropicBody[key] = value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
anthropicBody.model = modelId;
|
|
89
|
+
anthropicBody.messages = messages;
|
|
90
|
+
if (systemText) anthropicBody.system = systemText;
|
|
91
|
+
anthropicBody.max_tokens = (body.max_tokens as number) ?? 8192;
|
|
92
|
+
|
|
93
|
+
return anthropicBody;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── Anthropic → OpenAI response conversion (non-streaming) ──────────────────
|
|
97
|
+
|
|
98
|
+
type AnthropicResponse = {
|
|
99
|
+
id: string;
|
|
100
|
+
type: "message";
|
|
101
|
+
role: "assistant";
|
|
102
|
+
content: Array<{ type: string; text?: string }>;
|
|
103
|
+
model: string;
|
|
104
|
+
stop_reason: string | null;
|
|
105
|
+
usage: { input_tokens: number; output_tokens: number };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function mapStopReason(reason: string | null): string {
|
|
109
|
+
switch (reason) {
|
|
110
|
+
case "end_turn":
|
|
111
|
+
return "stop";
|
|
112
|
+
case "max_tokens":
|
|
113
|
+
return "length";
|
|
114
|
+
case "stop_sequence":
|
|
115
|
+
return "stop";
|
|
116
|
+
default:
|
|
117
|
+
return "stop";
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function toOpenAIResponse(anthropic: AnthropicResponse): Record<string, unknown> {
|
|
122
|
+
const text = anthropic.content
|
|
123
|
+
.filter((c) => c.type === "text")
|
|
124
|
+
.map((c) => c.text ?? "")
|
|
125
|
+
.join("");
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
id: `chatcmpl-${anthropic.id}`,
|
|
129
|
+
object: "chat.completion",
|
|
130
|
+
created: Math.floor(Date.now() / 1000),
|
|
131
|
+
model: anthropic.model,
|
|
132
|
+
choices: [
|
|
133
|
+
{
|
|
134
|
+
index: 0,
|
|
135
|
+
message: { role: "assistant", content: text },
|
|
136
|
+
finish_reason: mapStopReason(anthropic.stop_reason),
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
usage: {
|
|
140
|
+
prompt_tokens: anthropic.usage.input_tokens,
|
|
141
|
+
completion_tokens: anthropic.usage.output_tokens,
|
|
142
|
+
total_tokens: anthropic.usage.input_tokens + anthropic.usage.output_tokens,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── Anthropic → OpenAI streaming SSE conversion ─────────────────────────────
|
|
148
|
+
|
|
149
|
+
function buildStreamChunk(
|
|
150
|
+
id: string,
|
|
151
|
+
model: string,
|
|
152
|
+
delta: Record<string, unknown>,
|
|
153
|
+
finishReason: string | null = null,
|
|
154
|
+
): string {
|
|
155
|
+
const chunk = {
|
|
156
|
+
id: `chatcmpl-${id}`,
|
|
157
|
+
object: "chat.completion.chunk",
|
|
158
|
+
created: Math.floor(Date.now() / 1000),
|
|
159
|
+
model,
|
|
160
|
+
choices: [
|
|
161
|
+
{
|
|
162
|
+
index: 0,
|
|
163
|
+
delta,
|
|
164
|
+
finish_reason: finishReason,
|
|
165
|
+
},
|
|
166
|
+
],
|
|
167
|
+
};
|
|
168
|
+
return `data: ${JSON.stringify(chunk)}\n\n`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function convertAnthropicStream(
|
|
172
|
+
reader: ReadableStreamDefaultReader<Uint8Array>,
|
|
173
|
+
res: ServerResponse,
|
|
174
|
+
_log: PluginLogger,
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
const decoder = new TextDecoder();
|
|
177
|
+
let buffer = "";
|
|
178
|
+
let messageId = "";
|
|
179
|
+
let model = "";
|
|
180
|
+
|
|
181
|
+
while (true) {
|
|
182
|
+
const { done, value } = await reader.read();
|
|
183
|
+
if (done) break;
|
|
184
|
+
|
|
185
|
+
buffer += decoder.decode(value, { stream: true });
|
|
186
|
+
const lines = buffer.split("\n");
|
|
187
|
+
buffer = lines.pop() ?? "";
|
|
188
|
+
|
|
189
|
+
for (const line of lines) {
|
|
190
|
+
if (!line.startsWith("data: ")) continue;
|
|
191
|
+
const dataStr = line.slice(6).trim();
|
|
192
|
+
if (!dataStr || dataStr === "[DONE]") continue;
|
|
193
|
+
|
|
194
|
+
let event: Record<string, unknown>;
|
|
195
|
+
try {
|
|
196
|
+
event = JSON.parse(dataStr) as Record<string, unknown>;
|
|
197
|
+
} catch {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const eventType = event.type as string;
|
|
202
|
+
|
|
203
|
+
if (eventType === "message_start") {
|
|
204
|
+
const msg = event.message as Record<string, unknown>;
|
|
205
|
+
messageId = (msg.id as string) ?? "";
|
|
206
|
+
model = (msg.model as string) ?? "";
|
|
207
|
+
// Send initial chunk with role
|
|
208
|
+
if (!res.writableEnded) {
|
|
209
|
+
res.write(buildStreamChunk(messageId, model, { role: "assistant" }));
|
|
210
|
+
}
|
|
211
|
+
} else if (eventType === "content_block_delta") {
|
|
212
|
+
const delta = event.delta as Record<string, unknown>;
|
|
213
|
+
if (delta.type === "text_delta") {
|
|
214
|
+
const text = delta.text as string;
|
|
215
|
+
if (!res.writableEnded) {
|
|
216
|
+
res.write(buildStreamChunk(messageId, model, { content: text }));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
} else if (eventType === "message_delta") {
|
|
220
|
+
const delta = event.delta as Record<string, unknown>;
|
|
221
|
+
const stopReason = delta.stop_reason as string | null;
|
|
222
|
+
if (!res.writableEnded) {
|
|
223
|
+
res.write(buildStreamChunk(messageId, model, {}, mapStopReason(stopReason)));
|
|
224
|
+
}
|
|
225
|
+
} else if (eventType === "message_stop") {
|
|
226
|
+
if (!res.writableEnded) {
|
|
227
|
+
res.write("data: [DONE]\n\n");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Flush any remaining buffered data
|
|
234
|
+
if (buffer.trim()) {
|
|
235
|
+
const line = buffer.trim();
|
|
236
|
+
if (line.startsWith("data: ")) {
|
|
237
|
+
const dataStr = line.slice(6).trim();
|
|
238
|
+
if (dataStr && dataStr !== "[DONE]") {
|
|
239
|
+
try {
|
|
240
|
+
const event = JSON.parse(dataStr) as Record<string, unknown>;
|
|
241
|
+
if (event.type === "message_stop" && !res.writableEnded) {
|
|
242
|
+
res.write("data: [DONE]\n\n");
|
|
243
|
+
}
|
|
244
|
+
} catch {
|
|
245
|
+
// ignore
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!res.writableEnded) res.end();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Provider implementation ──────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
export class AnthropicProvider implements LLMProvider {
|
|
257
|
+
readonly name = "anthropic";
|
|
258
|
+
|
|
259
|
+
async chatCompletion(
|
|
260
|
+
body: Record<string, unknown>,
|
|
261
|
+
spec: { modelId: string; apiKey: string; baseUrl: string },
|
|
262
|
+
stream: boolean,
|
|
263
|
+
res: ServerResponse,
|
|
264
|
+
log: PluginLogger,
|
|
265
|
+
): Promise<void> {
|
|
266
|
+
const messages = (body.messages ?? []) as ChatMessage[];
|
|
267
|
+
const { system, messages: convertedMessages } = convertMessages(messages);
|
|
268
|
+
const anthropicBody = buildAnthropicBody(body, spec.modelId, system, convertedMessages);
|
|
269
|
+
anthropicBody.stream = stream;
|
|
270
|
+
|
|
271
|
+
const url = `${spec.baseUrl}/messages`;
|
|
272
|
+
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
275
|
+
|
|
276
|
+
try {
|
|
277
|
+
const resp = await fetch(url, {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: {
|
|
280
|
+
"x-api-key": spec.apiKey,
|
|
281
|
+
"anthropic-version": "2023-06-01",
|
|
282
|
+
"content-type": "application/json",
|
|
283
|
+
},
|
|
284
|
+
body: JSON.stringify(anthropicBody),
|
|
285
|
+
signal: controller.signal,
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
if (!resp.ok) {
|
|
289
|
+
const errText = await resp.text();
|
|
290
|
+
throw new Error(`Anthropic ${spec.modelId} ${resp.status}: ${errText.slice(0, 300)}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const rlog = new RouterLogger(log);
|
|
294
|
+
|
|
295
|
+
if (stream) {
|
|
296
|
+
res.writeHead(200, {
|
|
297
|
+
"Content-Type": "text/event-stream",
|
|
298
|
+
"Cache-Control": "no-cache",
|
|
299
|
+
"X-Accel-Buffering": "no",
|
|
300
|
+
});
|
|
301
|
+
const reader = resp.body?.getReader();
|
|
302
|
+
if (!reader) throw new Error(`No response body from Anthropic ${spec.modelId}`);
|
|
303
|
+
await convertAnthropicStream(reader, res, log);
|
|
304
|
+
rlog.done({ model: spec.modelId, via: "anthropic", streamed: true });
|
|
305
|
+
} else {
|
|
306
|
+
const data = (await resp.json()) as AnthropicResponse;
|
|
307
|
+
const openaiResponse = toOpenAIResponse(data);
|
|
308
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
309
|
+
res.end(JSON.stringify(openaiResponse));
|
|
310
|
+
rlog.done({
|
|
311
|
+
model: spec.modelId,
|
|
312
|
+
via: "anthropic",
|
|
313
|
+
streamed: false,
|
|
314
|
+
tokensIn: data.usage.input_tokens,
|
|
315
|
+
tokensOut: data.usage.output_tokens,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
} catch (err) {
|
|
319
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
320
|
+
throw new Error(
|
|
321
|
+
`Anthropic ${spec.modelId} request timed out after ${REQUEST_TIMEOUT_MS}ms`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
throw err;
|
|
325
|
+
} finally {
|
|
326
|
+
clearTimeout(timeoutId);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Exported for testing
|
|
332
|
+
export { convertMessages, buildAnthropicBody, toOpenAIResponse, mapStopReason, buildStreamChunk };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claw LLM Router — Gateway Fallback Provider
|
|
3
|
+
*
|
|
4
|
+
* Routes requests through the OpenClaw gateway instead of calling providers directly.
|
|
5
|
+
* Used when:
|
|
6
|
+
* - Anthropic OAuth token (sk-ant-oat01-*) is detected (can't be used directly)
|
|
7
|
+
* - Direct provider calls fail and we need a fallback
|
|
8
|
+
*
|
|
9
|
+
* The gateway handles all provider-specific auth and format conversion.
|
|
10
|
+
* Auth: Bearer {gateway.token} (gateway token, not provider token)
|
|
11
|
+
* Model: {provider}/{modelId}
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import type { ServerResponse } from "node:http";
|
|
16
|
+
import { REQUEST_TIMEOUT_MS, type LLMProvider, type PluginLogger } from "./types.js";
|
|
17
|
+
import { RouterLogger } from "../router-logger.js";
|
|
18
|
+
|
|
19
|
+
const HOME = process.env.HOME;
|
|
20
|
+
if (!HOME) throw new Error("[claw-llm-router] HOME environment variable not set");
|
|
21
|
+
const OPENCLAW_CONFIG_PATH = `${HOME}/.openclaw/openclaw.json`;
|
|
22
|
+
|
|
23
|
+
type GatewayInfo = { port: number; token: string };
|
|
24
|
+
|
|
25
|
+
export function getGatewayInfo(): GatewayInfo {
|
|
26
|
+
try {
|
|
27
|
+
const raw = readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
|
|
28
|
+
const config = JSON.parse(raw) as { gateway?: { port?: number; auth?: { token?: string } } };
|
|
29
|
+
return {
|
|
30
|
+
port: config.gateway?.port ?? 18789,
|
|
31
|
+
token: config.gateway?.auth?.token ?? "",
|
|
32
|
+
};
|
|
33
|
+
} catch {
|
|
34
|
+
return { port: 18789, token: "" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let hasWarnedGatewayFallback = false;
|
|
39
|
+
|
|
40
|
+
export class GatewayProvider implements LLMProvider {
|
|
41
|
+
readonly name = "gateway";
|
|
42
|
+
|
|
43
|
+
async chatCompletion(
|
|
44
|
+
body: Record<string, unknown>,
|
|
45
|
+
spec: { modelId: string; apiKey: string; baseUrl: string; provider?: string },
|
|
46
|
+
stream: boolean,
|
|
47
|
+
res: ServerResponse,
|
|
48
|
+
log: PluginLogger,
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
if (!hasWarnedGatewayFallback) {
|
|
51
|
+
log.warn(
|
|
52
|
+
`Using gateway fallback for ${(spec as { provider?: string }).provider ?? "unknown"} — direct API key recommended for use as primary model`,
|
|
53
|
+
);
|
|
54
|
+
hasWarnedGatewayFallback = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const gw = getGatewayInfo();
|
|
58
|
+
const provider = (spec as { provider?: string }).provider ?? "";
|
|
59
|
+
const modelId = `${provider}/${spec.modelId}`;
|
|
60
|
+
const url = `http://127.0.0.1:${gw.port}/v1/chat/completions`;
|
|
61
|
+
|
|
62
|
+
const payload = { ...body, model: modelId, stream };
|
|
63
|
+
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const resp = await fetch(url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${gw.token}`,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(payload),
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!resp.ok) {
|
|
79
|
+
const errText = await resp.text();
|
|
80
|
+
throw new Error(`Gateway → ${modelId} ${resp.status}: ${errText.slice(0, 300)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const rlog = new RouterLogger(log);
|
|
84
|
+
|
|
85
|
+
if (stream) {
|
|
86
|
+
res.writeHead(200, {
|
|
87
|
+
"Content-Type": "text/event-stream",
|
|
88
|
+
"Cache-Control": "no-cache",
|
|
89
|
+
"X-Accel-Buffering": "no",
|
|
90
|
+
});
|
|
91
|
+
const reader = resp.body?.getReader();
|
|
92
|
+
if (!reader) throw new Error(`No response body from gateway for ${modelId}`);
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
while (true) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done) break;
|
|
97
|
+
if (!res.writableEnded) res.write(decoder.decode(value, { stream: true }));
|
|
98
|
+
}
|
|
99
|
+
if (!res.writableEnded) res.end();
|
|
100
|
+
rlog.done({ model: modelId, via: "gateway", streamed: true });
|
|
101
|
+
} else {
|
|
102
|
+
const data = (await resp.json()) as Record<string, unknown>;
|
|
103
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify(data));
|
|
105
|
+
const usage = (data.usage ?? {}) as Record<string, number>;
|
|
106
|
+
rlog.done({
|
|
107
|
+
model: modelId,
|
|
108
|
+
via: "gateway",
|
|
109
|
+
streamed: false,
|
|
110
|
+
tokensIn: usage.prompt_tokens ?? "?",
|
|
111
|
+
tokensOut: usage.completion_tokens ?? "?",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err instanceof Error && err.name === "AbortError") {
|
|
116
|
+
throw new Error(`Gateway → ${modelId} request timed out after ${REQUEST_TIMEOUT_MS}ms`);
|
|
117
|
+
}
|
|
118
|
+
throw err;
|
|
119
|
+
} finally {
|
|
120
|
+
clearTimeout(timeoutId);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Export for testing
|
|
126
|
+
export function resetGatewayWarning(): void {
|
|
127
|
+
hasWarnedGatewayFallback = false;
|
|
128
|
+
}
|