/** * 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 { // OpenAI-Form (prompt_tokens/completion_tokens) und Anthropic-Form (input_tokens/output_tokens) const usage = (u ?? {}) as { prompt_tokens?: number; completion_tokens?: number; cost?: number; prompt_tokens_details?: { cached_tokens?: number }; input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; }; return { prompt_tokens: usage.prompt_tokens ?? usage.input_tokens ?? null, completion_tokens: usage.completion_tokens ?? usage.output_tokens ?? null, cached_tokens: usage.prompt_tokens_details?.cached_tokens ?? usage.cache_read_input_tokens ?? null, cost: typeof usage.cost === "number" ? usage.cost : null, }; }