llmrouter: leichtgewichtiger Gateway-Ersatz mit Alternativ-Auflösung

OpenAI-kompatibler Reverse-Proxy in Bun: Client-Key -> ACL, Alias ->
Modell (alternative: günstigstes Modell derselben Klasse über
llmlite/openrouter-auswahl inkl. Effort-Achse; fest: lokale Upstreams,
TTS/STT, Embeddings), Usage/Kosten aus der OpenRouter-Antwort (auch im
Stream), JSONL + SQLite-Log, Tagesstatistik je Key. Altnamen der
LiteLLM-Zeit werden übersetzt. Kein 3456/stdcmpt, kein role/*, kein -auto
(Andreas 2026-09-05). Lokal verifiziert: Chat, Stream, ACL, Altname,
Kosten im Log. Parallelbetrieb auf :4001 geplant.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 07:03:12 +02:00
co-authored by Claude Fable 5.1
commit 7897fa9651
11 changed files with 667 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
/**
* log.ts — ein Eintrag je Anfrage, zweimal: als JSONL (grep-bar, ueberlebt
* alles) und in SQLite (fuer die Tagesstatistik je Key). Die Kosten kommen
* von OpenRouter aus der Antwort (usage.cost), fuer lokale Upstreams bleibt
* das Feld null -- wir erfinden keine Preise.
*/
import { appendFileSync, mkdirSync } from "fs";
import { Database } from "bun:sqlite";
export type Eintrag = {
ts: string;
key: string;
alias: string;
angefragt: string;
model: string;
upstream: string;
effort: string | null;
pfad: string;
status: number;
ms: number;
stream: boolean;
prompt_tokens: number | null;
completion_tokens: number | null;
cached_tokens: number | null;
cost: number | null;
fehler?: string;
};
export class Log {
private db: Database;
private jsonl: string;
constructor(dir: string) {
mkdirSync(dir, { recursive: true });
this.jsonl = `${dir}/requests.jsonl`;
this.db = new Database(`${dir}/requests.sqlite`);
this.db.run(`CREATE TABLE IF NOT EXISTS requests (
ts TEXT, key TEXT, alias TEXT, angefragt TEXT, model TEXT, upstream TEXT, effort TEXT, pfad TEXT,
status INTEGER, ms INTEGER, stream INTEGER, prompt_tokens INTEGER, completion_tokens INTEGER,
cached_tokens INTEGER, cost REAL, fehler TEXT)`);
this.db.run(`CREATE INDEX IF NOT EXISTS requests_ts_key ON requests (ts, key)`);
}
schreiben(e: Eintrag): void {
appendFileSync(this.jsonl, JSON.stringify(e) + "\n");
this.db.run(
`INSERT INTO requests VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[e.ts, e.key, e.alias, e.angefragt, e.model, e.upstream, e.effort, e.pfad, e.status, e.ms, e.stream ? 1 : 0,
e.prompt_tokens, e.completion_tokens, e.cached_tokens, e.cost, e.fehler ?? null],
);
}
/** Je Tag und Key: Anfragen, Tokens, Kosten. */
statistik(tage: number): { tag: string; key: string; anfragen: number; prompt: number; completion: number; cost: number; modelle: string }[] {
return this.db.query(`
SELECT substr(ts,1,10) AS tag, key,
count(*) AS anfragen,
coalesce(sum(prompt_tokens),0) AS prompt,
coalesce(sum(completion_tokens),0) AS completion,
coalesce(sum(cost),0) AS cost,
group_concat(DISTINCT model) AS modelle
FROM requests WHERE ts >= datetime('now', ?)
GROUP BY tag, key ORDER BY tag DESC, cost DESC`).all(`-${tage} days`) as never;
}
}
/** Zieht Tokens und Kosten aus einer OpenAI-/OpenRouter-Usage. */
export function usageLesen(u: unknown): Pick<Eintrag, "prompt_tokens" | "completion_tokens" | "cached_tokens" | "cost"> {
const usage = (u ?? {}) as { prompt_tokens?: number; completion_tokens?: number; cost?: number; prompt_tokens_details?: { cached_tokens?: number } };
return {
prompt_tokens: usage.prompt_tokens ?? null,
completion_tokens: usage.completion_tokens ?? null,
cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? null,
cost: typeof usage.cost === "number" ? usage.cost : null,
};
}