@prakashpro1/auto-modal 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/.env.example +10 -0
- package/LICENSE +21 -0
- package/README.md +282 -0
- package/bin/cli.mjs +138 -0
- package/claude-router.sh +28 -0
- package/config.default.yaml +23 -0
- package/package.json +63 -0
- package/scripts/free-port.mjs +26 -0
- package/src/anthropic.js +186 -0
- package/src/config.js +101 -0
- package/src/dashboard.js +560 -0
- package/src/envfile.js +60 -0
- package/src/loadenv.js +5 -0
- package/src/server.js +543 -0
- package/src/usage.js +131 -0
package/src/anthropic.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// Translation between the Anthropic Messages API (what Claude Code speaks) and
|
|
2
|
+
// the OpenAI Chat Completions API (what the router forwards to OpenRouter/HF).
|
|
3
|
+
|
|
4
|
+
let _seq = 0;
|
|
5
|
+
const rid = () => (++_seq).toString(36) + Date.now().toString(36);
|
|
6
|
+
|
|
7
|
+
const STOP_MAP = { stop: "end_turn", length: "max_tokens", tool_calls: "tool_use", content_filter: "end_turn" };
|
|
8
|
+
const mapStop = (r) => STOP_MAP[r] || "end_turn";
|
|
9
|
+
|
|
10
|
+
// ---- Request: Anthropic /v1/messages -> OpenAI /v1/chat/completions ----
|
|
11
|
+
|
|
12
|
+
function convertContentBlocks(content) {
|
|
13
|
+
// Returns { text: [openai content parts], toolCalls: [...], toolResults: [...] }
|
|
14
|
+
const parts = [], toolCalls = [], toolResults = [];
|
|
15
|
+
for (const b of content) {
|
|
16
|
+
if (b.type === "text") parts.push({ type: "text", text: b.text });
|
|
17
|
+
else if (b.type === "image") {
|
|
18
|
+
const s = b.source || {};
|
|
19
|
+
const url = s.type === "base64" ? `data:${s.media_type};base64,${s.data}` : s.url;
|
|
20
|
+
parts.push({ type: "image_url", image_url: { url } });
|
|
21
|
+
} else if (b.type === "tool_use") {
|
|
22
|
+
toolCalls.push({ id: b.id, type: "function", function: { name: b.name, arguments: JSON.stringify(b.input || {}) } });
|
|
23
|
+
} else if (b.type === "tool_result") {
|
|
24
|
+
const c = b.content;
|
|
25
|
+
const text = typeof c === "string" ? c : Array.isArray(c) ? c.map((x) => x.text || "").join("\n") : "";
|
|
26
|
+
toolResults.push({ role: "tool", tool_call_id: b.tool_use_id, content: text });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { parts, toolCalls, toolResults };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function convertMessage(m) {
|
|
33
|
+
if (typeof m.content === "string") return [{ role: m.role, content: m.content }];
|
|
34
|
+
const { parts, toolCalls, toolResults } = convertContentBlocks(m.content || []);
|
|
35
|
+
const out = [];
|
|
36
|
+
|
|
37
|
+
if (m.role === "assistant") {
|
|
38
|
+
const msg = { role: "assistant" };
|
|
39
|
+
const text = parts.filter((p) => p.type === "text").map((p) => p.text).join("");
|
|
40
|
+
msg.content = text || null;
|
|
41
|
+
if (toolCalls.length) msg.tool_calls = toolCalls;
|
|
42
|
+
out.push(msg);
|
|
43
|
+
} else {
|
|
44
|
+
// user: tool_result blocks become standalone "tool" messages (must precede
|
|
45
|
+
// any new user text per OpenAI's ordering), then the remaining text/images.
|
|
46
|
+
for (const tr of toolResults) out.push(tr);
|
|
47
|
+
if (parts.length) {
|
|
48
|
+
const onlyText = parts.every((p) => p.type === "text");
|
|
49
|
+
out.push({ role: "user", content: onlyText ? parts.map((p) => p.text).join("") : parts });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function anthropicToOpenAI(a) {
|
|
56
|
+
const messages = [];
|
|
57
|
+
if (a.system) {
|
|
58
|
+
const sys = typeof a.system === "string" ? a.system : a.system.map((b) => b.text || "").join("\n");
|
|
59
|
+
if (sys) messages.push({ role: "system", content: sys });
|
|
60
|
+
}
|
|
61
|
+
for (const m of a.messages || []) messages.push(...convertMessage(m));
|
|
62
|
+
|
|
63
|
+
const o = { model: a.model, messages, max_tokens: a.max_tokens, stream: a.stream === true };
|
|
64
|
+
if (a.temperature != null) o.temperature = a.temperature;
|
|
65
|
+
if (a.top_p != null) o.top_p = a.top_p;
|
|
66
|
+
if (a.stop_sequences) o.stop = a.stop_sequences;
|
|
67
|
+
if (Array.isArray(a.tools) && a.tools.length) {
|
|
68
|
+
o.tools = a.tools.map((t) => ({
|
|
69
|
+
type: "function",
|
|
70
|
+
function: { name: t.name, description: t.description, parameters: t.input_schema || { type: "object", properties: {} } },
|
|
71
|
+
}));
|
|
72
|
+
if (a.tool_choice) {
|
|
73
|
+
const tc = a.tool_choice;
|
|
74
|
+
o.tool_choice = tc.type === "tool" ? { type: "function", function: { name: tc.name } }
|
|
75
|
+
: tc.type === "any" ? "required" : tc.type === "auto" ? "auto" : "auto";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return o;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- Response (non-streaming): OpenAI -> Anthropic ----
|
|
82
|
+
|
|
83
|
+
export function openAIToAnthropic(o, model) {
|
|
84
|
+
const choice = o.choices?.[0] || {};
|
|
85
|
+
const msg = choice.message || {};
|
|
86
|
+
const content = [];
|
|
87
|
+
if (msg.content) content.push({ type: "text", text: msg.content });
|
|
88
|
+
for (const tc of msg.tool_calls || []) {
|
|
89
|
+
let input = {};
|
|
90
|
+
try { input = JSON.parse(tc.function?.arguments || "{}"); } catch { /* leave {} */ }
|
|
91
|
+
content.push({ type: "tool_use", id: tc.id || "toolu_" + rid(), name: tc.function?.name, input });
|
|
92
|
+
}
|
|
93
|
+
if (!content.length) content.push({ type: "text", text: "" });
|
|
94
|
+
return {
|
|
95
|
+
id: o.id || "msg_" + rid(),
|
|
96
|
+
type: "message",
|
|
97
|
+
role: "assistant",
|
|
98
|
+
model,
|
|
99
|
+
content,
|
|
100
|
+
stop_reason: mapStop(choice.finish_reason),
|
|
101
|
+
stop_sequence: null,
|
|
102
|
+
usage: { input_tokens: o.usage?.prompt_tokens || 0, output_tokens: o.usage?.completion_tokens || 0 },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---- Response (streaming): OpenAI SSE -> Anthropic SSE ----
|
|
107
|
+
// Writes the Anthropic event sequence to `res` and ends it.
|
|
108
|
+
export async function streamAnthropic(upstream, res, model) {
|
|
109
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
110
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
111
|
+
res.setHeader("Connection", "keep-alive");
|
|
112
|
+
const send = (event, data) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
113
|
+
|
|
114
|
+
const msgId = "msg_" + rid();
|
|
115
|
+
let started = false, idx = -1, textOpen = false;
|
|
116
|
+
const tools = {}; // openai tool index -> { index (anthropic block), id, name }
|
|
117
|
+
let stopReason = "end_turn", inTokens = 0, outTokens = 0;
|
|
118
|
+
|
|
119
|
+
const ensureStart = () => {
|
|
120
|
+
if (started) return;
|
|
121
|
+
started = true;
|
|
122
|
+
send("message_start", {
|
|
123
|
+
type: "message_start",
|
|
124
|
+
message: { id: msgId, type: "message", role: "assistant", model, content: [],
|
|
125
|
+
stop_reason: null, stop_sequence: null, usage: { input_tokens: inTokens, output_tokens: 0 } },
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
const closeText = () => { if (textOpen) { send("content_block_stop", { type: "content_block_stop", index: idx }); textOpen = false; } };
|
|
129
|
+
|
|
130
|
+
const reader = upstream.body.getReader();
|
|
131
|
+
const dec = new TextDecoder();
|
|
132
|
+
let buf = "";
|
|
133
|
+
for (;;) {
|
|
134
|
+
const { done, value } = await reader.read();
|
|
135
|
+
if (done) break;
|
|
136
|
+
buf += dec.decode(value, { stream: true });
|
|
137
|
+
const lines = buf.split("\n");
|
|
138
|
+
buf = lines.pop();
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
const t = line.trim();
|
|
141
|
+
if (!t.startsWith("data:")) continue;
|
|
142
|
+
const payload = t.slice(5).trim();
|
|
143
|
+
if (payload === "[DONE]") continue;
|
|
144
|
+
let chunk;
|
|
145
|
+
try { chunk = JSON.parse(payload); } catch { continue; }
|
|
146
|
+
if (chunk.usage) {
|
|
147
|
+
inTokens = chunk.usage.prompt_tokens || inTokens;
|
|
148
|
+
outTokens = chunk.usage.completion_tokens || outTokens;
|
|
149
|
+
}
|
|
150
|
+
const choice = chunk.choices?.[0];
|
|
151
|
+
if (!choice) continue;
|
|
152
|
+
const delta = choice.delta || {};
|
|
153
|
+
ensureStart();
|
|
154
|
+
|
|
155
|
+
if (delta.content) {
|
|
156
|
+
if (!textOpen) {
|
|
157
|
+
idx++; textOpen = true;
|
|
158
|
+
send("content_block_start", { type: "content_block_start", index: idx, content_block: { type: "text", text: "" } });
|
|
159
|
+
}
|
|
160
|
+
send("content_block_delta", { type: "content_block_delta", index: idx, delta: { type: "text_delta", text: delta.content } });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
for (const tc of delta.tool_calls || []) {
|
|
164
|
+
const k = tc.index ?? 0;
|
|
165
|
+
if (!tools[k]) {
|
|
166
|
+
closeText();
|
|
167
|
+
idx++;
|
|
168
|
+
tools[k] = { index: idx, id: tc.id || "toolu_" + rid(), name: tc.function?.name || "" };
|
|
169
|
+
send("content_block_start", { type: "content_block_start", index: idx,
|
|
170
|
+
content_block: { type: "tool_use", id: tools[k].id, name: tools[k].name, input: {} } });
|
|
171
|
+
}
|
|
172
|
+
const args = tc.function?.arguments;
|
|
173
|
+
if (args) send("content_block_delta", { type: "content_block_delta", index: tools[k].index, delta: { type: "input_json_delta", partial_json: args } });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (choice.finish_reason) stopReason = mapStop(choice.finish_reason);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
ensureStart();
|
|
181
|
+
closeText();
|
|
182
|
+
for (const k of Object.keys(tools)) send("content_block_stop", { type: "content_block_stop", index: tools[k].index });
|
|
183
|
+
send("message_delta", { type: "message_delta", delta: { stop_reason: stopReason, stop_sequence: null }, usage: { output_tokens: outTokens } });
|
|
184
|
+
send("message_stop", { type: "message_stop" });
|
|
185
|
+
res.end();
|
|
186
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const CONFIG_PATH = process.env.ROUTER_CONFIG || join(__dirname, "..", "config.yaml");
|
|
8
|
+
|
|
9
|
+
// Both OpenRouter and HuggingFace expose an OpenAI-compatible
|
|
10
|
+
// /v1/chat/completions endpoint, so a provider just maps to a base URL.
|
|
11
|
+
const PROVIDER_BASE = {
|
|
12
|
+
openrouter: "https://openrouter.ai/api/v1",
|
|
13
|
+
huggingface: "https://router.huggingface.co/v1",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Replace ${VAR} with process.env.VAR.
|
|
17
|
+
function expandEnv(value) {
|
|
18
|
+
if (typeof value !== "string") return value;
|
|
19
|
+
return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => process.env[name] ?? "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Build a model's key pool. Accepts (in priority order):
|
|
23
|
+
// apiKeys: [ ${A}, ${B} ] -> a YAML list
|
|
24
|
+
// apiKeys: ${KEYS} -> one env var holding comma-separated keys
|
|
25
|
+
// apiKey: ${A} -> single key (back-compat)
|
|
26
|
+
// Returns a de-duplicated, non-empty array of resolved key strings.
|
|
27
|
+
export function resolveKeys(m) {
|
|
28
|
+
let keys = [];
|
|
29
|
+
if (Array.isArray(m.apiKeys)) {
|
|
30
|
+
keys = m.apiKeys.map(expandEnv);
|
|
31
|
+
} else if (typeof m.apiKeys === "string") {
|
|
32
|
+
keys = expandEnv(m.apiKeys).split(",");
|
|
33
|
+
} else if (m.apiKey != null) {
|
|
34
|
+
keys = [expandEnv(m.apiKey)];
|
|
35
|
+
}
|
|
36
|
+
keys = [...new Set(keys.map((k) => k.trim()).filter(Boolean))];
|
|
37
|
+
return keys;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Known providers (used to validate dashboard input).
|
|
41
|
+
export const PROVIDERS = Object.keys(PROVIDER_BASE);
|
|
42
|
+
|
|
43
|
+
// Read the raw YAML as a plain object (keeps ${ENV} references as-is — we never
|
|
44
|
+
// persist resolved secrets back to disk).
|
|
45
|
+
export function readRaw() {
|
|
46
|
+
return YAML.parse(readFileSync(CONFIG_PATH, "utf8")) ?? {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Write the raw object back to config.yaml. NOTE: this normalizes formatting and
|
|
50
|
+
// drops inline comments — expected once you manage models via the dashboard.
|
|
51
|
+
export function writeRaw(raw) {
|
|
52
|
+
writeFileSync(CONFIG_PATH, YAML.stringify(raw));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Resolve a raw object into the runtime config (secrets expanded, slots built).
|
|
56
|
+
export function buildConfig(raw) {
|
|
57
|
+
const cfg = {
|
|
58
|
+
port: raw.port ?? 8787,
|
|
59
|
+
cooldownMs: raw.cooldownMs ?? 3_600_000,
|
|
60
|
+
transientRetries: raw.transientRetries ?? 2,
|
|
61
|
+
chain: [],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
for (const m of raw.chain ?? []) {
|
|
65
|
+
const provider = m.provider;
|
|
66
|
+
if (!PROVIDER_BASE[provider]) {
|
|
67
|
+
throw new Error(`Unknown provider "${provider}" for model "${m.id}"`);
|
|
68
|
+
}
|
|
69
|
+
const keys = resolveKeys(m);
|
|
70
|
+
// A model with no usable keys (e.g. its env var isn't set) is simply
|
|
71
|
+
// skipped, not fatal — so you can leave HF/OpenRouter slots in the config
|
|
72
|
+
// and just fill the keys you actually have.
|
|
73
|
+
if (keys.length === 0) {
|
|
74
|
+
console.warn(`config: skipping model "${m.id}" — no API keys set`);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
cfg.chain.push({
|
|
78
|
+
id: m.id,
|
|
79
|
+
provider,
|
|
80
|
+
model: m.model,
|
|
81
|
+
// Per-model `baseUrl` override wins (self-hosted / proxy / testing);
|
|
82
|
+
// otherwise use the provider default.
|
|
83
|
+
baseUrl: expandEnv(m.baseUrl) || PROVIDER_BASE[provider],
|
|
84
|
+
keys, // pool of API keys; the router rotates through them on limit errors
|
|
85
|
+
dailyLimit: m.dailyLimit ?? Infinity, // applied per key
|
|
86
|
+
rpm: m.rpm ?? Infinity, // requests-per-minute token bucket, per key
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (cfg.chain.length === 0) {
|
|
91
|
+
// Not fatal: the server still boots and serves the dashboard so you can add
|
|
92
|
+
// keys/models. Requests just 503 until at least one slot is usable.
|
|
93
|
+
console.warn("config: no usable models yet — add an API key or model from the dashboard");
|
|
94
|
+
}
|
|
95
|
+
return cfg;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Read + build in one step (used at startup and on hot-reload).
|
|
99
|
+
export function loadConfig() {
|
|
100
|
+
return buildConfig(readRaw());
|
|
101
|
+
}
|