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:
+73
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user