- anthropicModus (alt|openrouter), Normalisierung der IDs (Datum, [1m], Punkte), Client-Effort aus output_config.effort als Stufe, HEAD /api/hello -> 200 - Auswahl: nicht gemessene Stufe faellt auf die naechste gemessene darunter (sonst darueber, sonst Modellzahl) -- Sonnet 5 hat nur max/none - /models: alt/ nur bei bestimmbarer Klasse; opencode-normalize nimmt openrouter/+alt/ genau dann (GLM 5.3 Flash war weggefallen) - README: Abschnitt Claude Code und Claude Desktop Verifiziert per claude -p mit claude-sonnet-5, claude-opus-5, claude-haiku-4-5-20251001. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
322 lines
18 KiB
TypeScript
322 lines
18 KiB
TypeScript
/**
|
|
* server.ts — der llmrouter: OpenAI-kompatibler Reverse-Proxy mit vier
|
|
* Eingriffen je Anfrage: Client-Key pruefen, Namen aufloesen und Modell
|
|
* umschreiben, Usage/Kosten aus der Antwort mitlesen, eine Zeile loggen.
|
|
*
|
|
* Namensraeume (Reihenfolge der Aufloesung):
|
|
* role/<name> -> Ziel aus config.rollen, dann weiter
|
|
* 42i/... (config.aliases) -> fest / schwelle / alternative (State)
|
|
* openrouter/<id>[:effort] -> 1:1 an OpenRouter
|
|
* alt/<id>[:effort] -> guenstigste Alternative (auf Abruf, State)
|
|
* sonst -> fallback (LiteLLM), unveraendert
|
|
*
|
|
* Keine Format-Uebersetzung: alle Upstreams sprechen OpenAI-Format.
|
|
* Umgebung: PORT, KEYS_FILE, ADMIN_KEY, OPENROUTER_API_KEY, MISTRAL_API_KEY,
|
|
* KIRA_*_KEY, ARTIFICIALANALYSIS_API_KEY, LLMROUTER_CONFIG, MODEL_BLOCKLIST.
|
|
*/
|
|
import { configLaden, aclLaden, tokenLaden, upstreamKey, erlaubt, type Config, type Upstream } from "./config.ts";
|
|
import { aufloesen, alternativeAufloesen, datenHolen, stateLaden, stateVeraltet, type State } from "./resolver.ts";
|
|
import { Log, usageLesen, type Eintrag } from "./log.ts";
|
|
import { referenzFormat, referenzParsen, EFFORTS, type Effort } from "../../llmlite/openrouter-auswahl.ts";
|
|
|
|
const config: Config = configLaden();
|
|
const acl = aclLaden();
|
|
let tokens = tokenLaden();
|
|
let state: State = stateLaden(config);
|
|
const log = new Log(config.logDir);
|
|
const jetzt = () => new Date().toISOString();
|
|
const sagen = (z: string) => console.log(`${jetzt()} ${z}`);
|
|
|
|
// --- Zuordnung frisch halten ------------------------------------------------
|
|
let laeuft = false;
|
|
async function auffrischen(grund: string): Promise<void> {
|
|
if (laeuft) return;
|
|
laeuft = true;
|
|
try {
|
|
sagen(`Zuordnung auffrischen (${grund})`);
|
|
state = await aufloesen(config, state, { log: sagen });
|
|
} catch (e) {
|
|
sagen(`Auffrischen fehlgeschlagen: ${(e as Error).message} -- behalte Stand ${state.aktualisiert}`);
|
|
} finally {
|
|
laeuft = false;
|
|
}
|
|
}
|
|
if (stateVeraltet(config, state)) void auffrischen("Start, state veraltet");
|
|
else void datenHolen(config).catch((e) => sagen(`Daten laden fehlgeschlagen: ${(e as Error).message}`));
|
|
setInterval(() => { if (stateVeraltet(config, state)) void auffrischen("Takt"); tokens = tokenLaden(); }, 15 * 60_000);
|
|
|
|
// alt/-Aufloesungen auf Abruf, je Referenz nur einmal gleichzeitig
|
|
const altLaeuft = new Map<string, Promise<void>>();
|
|
async function altSicherstellen(ref: string): Promise<{ model: string; effort: Effort | null } | { fehler: string }> {
|
|
const z = state.zuordnung[`alt/${ref}`];
|
|
if (z !== undefined) return { model: z.model, effort: z.effort };
|
|
let p = altLaeuft.get(ref);
|
|
if (p === undefined) {
|
|
p = alternativeAufloesen(config, state, ref, sagen).then(() => undefined).finally(() => altLaeuft.delete(ref));
|
|
altLaeuft.set(ref, p);
|
|
}
|
|
try { await p; } catch (e) { return { fehler: (e as Error).message }; }
|
|
const neu = state.zuordnung[`alt/${ref}`];
|
|
return neu === undefined ? { fehler: `alt/${ref}: keine Zuordnung` } : { model: neu.model, effort: neu.effort };
|
|
}
|
|
|
|
// --- Nebenlaeufigkeit je Upstream (lokales Qwen: ein Slot) ------------------
|
|
const semaphoren = new Map<string, { frei: number; warteschlange: (() => void)[] }>();
|
|
async function belegen(name: string, u: Upstream): Promise<() => void> {
|
|
if (u.maxParallel === undefined) return () => {};
|
|
let s = semaphoren.get(name);
|
|
if (s === undefined) { s = { frei: u.maxParallel, warteschlange: [] }; semaphoren.set(name, s); }
|
|
if (s.frei > 0) s.frei -= 1;
|
|
else await new Promise<void>((res) => s!.warteschlange.push(res));
|
|
return () => { const n = s!.warteschlange.shift(); if (n) n(); else s!.frei += 1; };
|
|
}
|
|
|
|
// --- Aufloesung Name -> Ziel ---------------------------------------------------
|
|
type Ziel = { alias: string; upstreamName: string; upstream: Upstream; model: string; effort: Effort | null; body?: Record<string, unknown>; effortSetzen: boolean };
|
|
type Fehler = { fehler: string; status: number };
|
|
const OR = (): Upstream => config.upstreams.openrouter;
|
|
|
|
/**
|
|
* Nackte Anthropic-IDs, wie Claude Code/Desktop sie schickt ("claude-sonnet-5",
|
|
* "claude-opus-4-1-20250805", "claude-sonnet-4-5[1m]"), auf unseren Namensraum
|
|
* abbilden: Datum und [1m] weg, Versionsziffern mit Punkt (OpenRouter-Schreibweise),
|
|
* dann je config.anthropicModus als alt/anthropic/<id> (Klasse -> guenstigstes
|
|
* Modell) oder openrouter/anthropic/<id> (1:1). Ein vom Client gesetzter Effort
|
|
* (Anthropic output_config.effort) wird als Stufe angehaengt.
|
|
*/
|
|
function anthropicName(name: string, effort: Effort | null): string | undefined {
|
|
if (!/^claude-/u.test(name)) return undefined;
|
|
let id = name.replace(/\[1m\]$/u, "").replace(/-\d{8}$/u, "").replace(/-latest$/u, "");
|
|
id = id.replace(/^(claude-[a-z]+)-(\d+(?:-\d+)*)$/u, (_, fam: string, ver: string) => `${fam}-${ver.replace(/-/gu, ".")}`);
|
|
const modus = config.anthropicModus ?? "alt";
|
|
return `${modus}/anthropic/${id}${effort !== null ? `:${effort}` : ""}`;
|
|
}
|
|
|
|
/** Bedient der Router diesen Namen selbst? Sonst Durchreiche -- und dann prueft LiteLLM die Rechte, nicht wir. */
|
|
function eigenerName(name: string): boolean {
|
|
return name.startsWith("role/") || name.startsWith("openrouter/") || name.startsWith("alt/") || /^claude-/u.test(name) || config.aliases[name] !== undefined;
|
|
}
|
|
|
|
async function zielBestimmen(angefragtRoh: string, keyName: string, clientEffort: Effort | null = null): Promise<Ziel | Fehler> {
|
|
const angefragt = anthropicName(angefragtRoh, clientEffort) ?? angefragtRoh;
|
|
if (!eigenerName(angefragt)) {
|
|
if (config.fallback !== undefined) {
|
|
const u = config.fallback.upstream;
|
|
return { alias: "(durchreiche)", upstreamName: u, upstream: config.upstreams[u], model: angefragt, effort: null, effortSetzen: false };
|
|
}
|
|
return { fehler: `Unbekanntes Modell ${angefragt}`, status: 404 };
|
|
}
|
|
if (!erlaubt(acl.get(keyName) ?? [], angefragt)) return { fehler: `${keyName} darf ${angefragt} nicht rufen`, status: 403 };
|
|
let name = angefragt;
|
|
if (name.startsWith("role/")) {
|
|
const ziel = config.rollen?.[name.slice(5)];
|
|
if (ziel === undefined) return { fehler: `Unbekannte Rolle ${name}`, status: 404 };
|
|
name = ziel;
|
|
}
|
|
const def = config.aliases[name];
|
|
if (def !== undefined) {
|
|
if (def.art === "fest") return { alias: name, upstreamName: def.upstream, upstream: config.upstreams[def.upstream], model: def.model, effort: null, body: def.body, effortSetzen: false };
|
|
const z = state.zuordnung[name];
|
|
if (z === undefined) return { fehler: `${name} ist noch nicht aufgeloest (Zuordnung fehlt) -- spaeter erneut`, status: 503 };
|
|
return { alias: name, upstreamName: "openrouter", upstream: OR(), model: z.model, effort: z.effort, effortSetzen: true };
|
|
}
|
|
if (name.startsWith("openrouter/")) {
|
|
const { id, effort } = referenzParsen(name.slice(11));
|
|
return { alias: name, upstreamName: "openrouter", upstream: OR(), model: id, effort, effortSetzen: effort !== null };
|
|
}
|
|
if (name.startsWith("alt/")) {
|
|
const z = await altSicherstellen(name.slice(4));
|
|
if ("fehler" in z) return { fehler: z.fehler, status: 404 };
|
|
return { alias: name, upstreamName: "openrouter", upstream: OR(), model: z.model, effort: z.effort, effortSetzen: true };
|
|
}
|
|
return { fehler: `Unbekanntes Modell ${angefragt}`, status: 404 }; // z. B. Rolle zeigt auf einen Namen, den es nicht gibt
|
|
}
|
|
|
|
// --- Anfrage bauen -------------------------------------------------------------
|
|
// /messages + /messages/count_tokens: Anthropic-Messages-Format (Claude Code). OpenRouter
|
|
// bietet denselben Endpunkt fuer alle Modelle -- die einzige Format-Uebersetzung, die wir
|
|
// nicht selbst schreiben. Nur openrouter-Ziele; lokale llama-server koennen das nicht.
|
|
const JSON_PFADE = new Set(["/chat/completions", "/completions", "/embeddings", "/audio/speech", "/responses", "/messages", "/messages/count_tokens"]);
|
|
const FORM_PFADE = new Set(["/audio/transcriptions", "/audio/translations"]);
|
|
|
|
function jsonBody(orig: Record<string, unknown>, ziel: Ziel, pfad: string): Record<string, unknown> {
|
|
const b: Record<string, unknown> = { ...orig, model: ziel.model, ...(ziel.body ?? {}) };
|
|
if (pfad.startsWith("/messages")) {
|
|
// Anthropic-Format: kein usage.include, Effort als reasoning.effort (OpenRouter nimmt es dort an)
|
|
if (ziel.effortSetzen) { delete b.thinking; delete b.output_config; if (ziel.effort !== null) b.reasoning = { effort: ziel.effort }; }
|
|
return b;
|
|
}
|
|
if (ziel.upstreamName === "openrouter") {
|
|
b.usage = { include: true }; // Kosten kommen in der Antwort mit
|
|
if (b.stream === true) b.stream_options = { ...(b.stream_options as object ?? {}), include_usage: true };
|
|
}
|
|
if (ziel.effortSetzen) {
|
|
// Die Stufe gehoert zum Namen, nicht zum Client: wer 42i/marvin-2609:low
|
|
// oder openrouter/x:medium ruft, bekommt genau diese Stufe.
|
|
delete b.reasoning_effort; delete b.reasoning;
|
|
if (ziel.effort !== null) b.reasoning = { effort: ziel.effort };
|
|
}
|
|
return b;
|
|
}
|
|
|
|
// --- Modellliste ---------------------------------------------------------------
|
|
async function modellListe(keyName: string, auth: string): Promise<{ id: string; object: "model"; owned_by: string }[]> {
|
|
const liste = acl.get(keyName) ?? [];
|
|
const data: { id: string; object: "model"; owned_by: string }[] = [];
|
|
const add = (id: string, owned_by: string) => { if (erlaubt(liste, id)) data.push({ id, object: "model", owned_by }); };
|
|
for (const id of Object.keys(config.aliases)) add(id, "42i");
|
|
for (const r of Object.keys(config.rollen ?? {})) add(`role/${r}`, "42i");
|
|
try {
|
|
const d = await datenHolen(config);
|
|
const stufen = new Map<string, Effort[]>();
|
|
for (const v of d.varianten) if (v.effort !== null) stufen.set(v.id, [...(stufen.get(v.id) ?? []), v.effort]);
|
|
const gemessen = new Set(d.varianten.map((v) => v.id));
|
|
for (const m of d.konto) {
|
|
if (m.id.includes(":")) { add(`openrouter/${m.id}`, "openrouter"); continue; } // :free, :exacto -- keine Stufen
|
|
add(`openrouter/${m.id}`, "openrouter");
|
|
// alt/ nur, wo eine Klasse bestimmbar ist (Index bei OpenRouter oder Stufen bei AA) -- sonst wuerde die Anfrage scheitern
|
|
const k = d.alle.get(m.id);
|
|
if ((k !== undefined && Number.isFinite(k.index)) || gemessen.has(m.id)) add(`alt/${m.id}`, "alt");
|
|
for (const e of stufen.get(m.id) ?? []) { add(`openrouter/${m.id}:${e}`, "openrouter"); add(`alt/${m.id}:${e}`, "alt"); }
|
|
}
|
|
} catch (e) { sagen(`/models ohne OpenRouter-Liste: ${(e as Error).message}`); }
|
|
if (config.fallback !== undefined) {
|
|
// Im Parallelbetrieb auch die LiteLLM-Liste des Clients, sonst verlieren
|
|
// Clients, die Modelle per /models entdecken, die 2608-Namen.
|
|
try {
|
|
const u = config.upstreams[config.fallback.upstream];
|
|
const r = await fetch(`${u.base}/models`, { headers: { authorization: `Bearer ${auth}` }, signal: AbortSignal.timeout(10_000) });
|
|
const bekannt = new Set(data.map((m) => m.id));
|
|
if (r.ok) for (const m of ((await r.json()) as { data?: { id: string }[] }).data ?? []) if (!bekannt.has(m.id)) data.push({ id: m.id, object: "model", owned_by: config.fallback.upstream });
|
|
} catch { /* LiteLLM nicht da: nur eigene Liste */ }
|
|
}
|
|
return data;
|
|
}
|
|
|
|
// --- Server -------------------------------------------------------------------
|
|
Bun.serve({
|
|
port: Number(process.env.PORT ?? config.port),
|
|
idleTimeout: 255,
|
|
async fetch(req) {
|
|
const url = new URL(req.url);
|
|
const pfad = url.pathname.replace(/^\/v1/u, "");
|
|
if (pfad === "/health") return Response.json({ ok: true, zuordnung: state.aktualisiert });
|
|
if (url.pathname === "/api/hello") return new Response(null, { status: 200 }); // Claude Code pingt das beim Start
|
|
|
|
// Admin
|
|
const auth = (req.headers.get("authorization")?.replace(/^Bearer\s+/iu, "") ?? req.headers.get("x-api-key") ?? "").trim();
|
|
const admin = process.env.ADMIN_KEY !== undefined && auth === process.env.ADMIN_KEY;
|
|
if (pfad === "/stats") return admin ? Response.json(log.statistik(Number(url.searchParams.get("days") ?? 7))) : new Response("admin", { status: 401 });
|
|
if (pfad === "/zuordnung") return admin ? Response.json(state) : new Response("admin", { status: 401 });
|
|
if (pfad === "/auffrischen" && req.method === "POST") { if (!admin) return new Response("admin", { status: 401 }); void auffrischen("manuell"); return Response.json({ gestartet: !laeuft }); }
|
|
|
|
// Client
|
|
const keyName = tokens.get(auth);
|
|
if (keyName === undefined) { sagen(`401 ${req.method} ${pfad} (Key ${auth.length} Zeichen, ${auth.slice(0, 6)}…)`); return Response.json({ error: { message: "Ungueltiger API-Key" } }, { status: 401 }); }
|
|
if (pfad === "/models") return Response.json({ object: "list", data: await modellListe(keyName, auth) });
|
|
if (req.method !== "POST" || !(JSON_PFADE.has(pfad) || FORM_PFADE.has(pfad))) return Response.json({ error: { message: `Pfad ${pfad} wird nicht bedient` } }, { status: 404 });
|
|
|
|
const start = Date.now();
|
|
let angefragt = "";
|
|
let upstreamBody: BodyInit;
|
|
let stream = false;
|
|
let ziel: Ziel;
|
|
const headers = new Headers();
|
|
|
|
if (JSON_PFADE.has(pfad)) {
|
|
let orig: Record<string, unknown>;
|
|
try { orig = (await req.json()) as Record<string, unknown>; } catch { return Response.json({ error: { message: "Body ist kein JSON" } }, { status: 400 }); }
|
|
angefragt = String(orig.model ?? "");
|
|
const oc = orig.output_config as { effort?: string } | undefined;
|
|
const ce = String(oc?.effort ?? (orig.reasoning as { effort?: string } | undefined)?.effort ?? orig.reasoning_effort ?? "");
|
|
const z = await zielBestimmen(angefragt, keyName, (EFFORTS as readonly string[]).includes(ce) ? (ce as Effort) : null);
|
|
if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status });
|
|
ziel = z;
|
|
stream = orig.stream === true;
|
|
upstreamBody = JSON.stringify(ziel.upstream.clientKey === true ? orig : jsonBody(orig, ziel, pfad));
|
|
headers.set("content-type", "application/json");
|
|
} else {
|
|
const form = await req.formData();
|
|
angefragt = String(form.get("model") ?? "");
|
|
const z = await zielBestimmen(angefragt, keyName);
|
|
if ("fehler" in z) return Response.json({ error: { message: z.fehler } }, { status: z.status });
|
|
ziel = z;
|
|
if (ziel.upstream.clientKey !== true) form.set("model", ziel.model);
|
|
upstreamBody = form;
|
|
}
|
|
const k = upstreamKey(ziel.upstream);
|
|
if (k !== undefined) headers.set("authorization", `Bearer ${k}`);
|
|
if (ziel.upstream.clientKey === true) headers.set("authorization", `Bearer ${auth}`);
|
|
if (pfad.startsWith("/messages")) {
|
|
if (ziel.upstreamName !== "openrouter") return Response.json({ error: { type: "invalid_request_error", message: `${angefragt} liegt auf ${ziel.upstreamName}, das kein Messages-Format spricht` } }, { status: 400 });
|
|
if (k !== undefined) headers.set("x-api-key", k);
|
|
headers.set("anthropic-version", req.headers.get("anthropic-version") ?? "2023-06-01");
|
|
const beta = req.headers.get("anthropic-beta"); if (beta) headers.set("anthropic-beta", beta);
|
|
}
|
|
if (ziel.upstreamName === "openrouter") { headers.set("http-referer", "https://llm.lan"); headers.set("x-title", `llmrouter/${keyName}`); }
|
|
const accept = req.headers.get("accept"); if (accept) headers.set("accept", accept);
|
|
|
|
const eintrag: Eintrag = {
|
|
ts: jetzt(), key: keyName, alias: ziel.alias, angefragt, model: ziel.model, upstream: ziel.upstreamName, effort: ziel.effort,
|
|
pfad, status: 0, ms: 0, stream, prompt_tokens: null, completion_tokens: null, cached_tokens: null, cost: null,
|
|
};
|
|
const abschliessen = (usage?: unknown) => {
|
|
eintrag.ms = Date.now() - start;
|
|
if (usage !== undefined) Object.assign(eintrag, usageLesen(usage));
|
|
try { log.schreiben(eintrag); } catch (e) { sagen(`Log-Fehler: ${(e as Error).message}`); }
|
|
};
|
|
|
|
const freigeben = await belegen(ziel.upstreamName, ziel.upstream);
|
|
let antwort: Response;
|
|
try {
|
|
antwort = await fetch(`${ziel.upstream.base}${pfad}`, { method: "POST", headers, body: upstreamBody, signal: AbortSignal.timeout(600_000) });
|
|
} catch (e) {
|
|
freigeben();
|
|
eintrag.status = 502; eintrag.fehler = (e as Error).message; abschliessen();
|
|
return Response.json({ error: { message: `Upstream ${ziel.upstreamName} nicht erreichbar: ${eintrag.fehler}` } }, { status: 502 });
|
|
}
|
|
eintrag.status = antwort.status;
|
|
const ct = antwort.headers.get("content-type") ?? "";
|
|
const weiter = new Headers(antwort.headers);
|
|
weiter.delete("content-length"); weiter.delete("content-encoding"); weiter.delete("transfer-encoding");
|
|
weiter.set("x-llmrouter-model", referenzFormat(ziel.model, ziel.effort));
|
|
|
|
if (ct.startsWith("text/event-stream") && antwort.body !== null) {
|
|
// Durchreichen und nebenbei den letzten usage-Chunk lesen.
|
|
let rest = ""; let usage: unknown;
|
|
const dec = new TextDecoder();
|
|
const tee = new TransformStream<Uint8Array, Uint8Array>({
|
|
transform(chunk, ctrl) {
|
|
ctrl.enqueue(chunk);
|
|
rest += dec.decode(chunk, { stream: true });
|
|
let i: number;
|
|
while ((i = rest.indexOf("\n")) >= 0) {
|
|
const zeile = rest.slice(0, i).trim(); rest = rest.slice(i + 1);
|
|
if (zeile.startsWith("data:") && zeile.includes('"usage"')) {
|
|
try {
|
|
const j = JSON.parse(zeile.slice(5));
|
|
const u = j.usage ?? j.message?.usage; // OpenAI: usage | Anthropic: message_start.message.usage, message_delta.usage
|
|
if (u) usage = { ...(usage as object ?? {}), ...u };
|
|
} catch { /* Teilzeile */ }
|
|
}
|
|
}
|
|
},
|
|
flush() { freigeben(); abschliessen(usage); },
|
|
});
|
|
return new Response(antwort.body.pipeThrough(tee), { status: antwort.status, headers: weiter });
|
|
}
|
|
freigeben();
|
|
if (ct.includes("application/json")) {
|
|
const text = await antwort.text();
|
|
let usage: unknown; let fehler: string | undefined;
|
|
try { const j = JSON.parse(text); usage = j.usage; if (j.error) fehler = JSON.stringify(j.error).slice(0, 300); } catch { /* egal */ }
|
|
if (fehler) eintrag.fehler = fehler;
|
|
abschliessen(usage);
|
|
return new Response(text, { status: antwort.status, headers: weiter });
|
|
}
|
|
abschliessen();
|
|
return new Response(antwort.body, { status: antwort.status, headers: weiter });
|
|
},
|
|
});
|
|
sagen(`llmrouter lauscht auf :${process.env.PORT ?? config.port}, ${Object.keys(config.aliases).length} Aliasse, ${Object.keys(config.rollen ?? {}).length} Rollen, ${tokens.size} Token, Zuordnung von ${state.aktualisiert}`);
|