Der Log wusste bisher WER gerufen hat (Key, Rolle), aber nicht WORAN gearbeitet
wurde. Damit liess sich "was kostet ein Vorgang" gar nicht bilden -- die
Voraussetzung fuer die Frage, ob die teuren Tickets die sind, die ein Mensch
wollte.
- server.ts liest den Header x-42i-ticket und legt ihn in den Log-Eintrag
- log.ts: Feld `ticket` in JSONL und SQLite; die Spalte wird per ALTER
nachgezogen (Bestandstabellen haben sie nicht), dazu ein Index darauf.
Das INSERT nennt jetzt Spaltennamen statt Positionen -- sonst haette die
neue Spalte die Reihenfolge zerschossen.
- opencode.json und der Container-Entrypoint schicken den Header aus
{env:AGENT_TICKET}; job.sh exportiert die Variable ohnehin seit 2026-09-02.
Wer keines mitschickt (Chat, Handbetrieb), steht als null im Log -- auch das
ist eine Auskunft.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
4.1 KiB
TypeScript
92 lines
4.1 KiB
TypeScript
/**
|
|
* 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;
|
|
/**
|
|
* Woran gearbeitet wurde, z.B. "live/live#1573" -- aus dem Header
|
|
* x-42i-ticket. Der Key allein sagt nur, WER gerufen hat; ohne das Ticket
|
|
* laesst sich "was kostet ein Vorgang" gar nicht bilden (42i/intern#1291).
|
|
* null, wenn der Aufrufer keines mitschickt (Chat, Handbetrieb).
|
|
*/
|
|
ticket: string | null;
|
|
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)`);
|
|
// Nachtraeglich ergaenzt (42i/intern#1291): Bestandstabellen haben die
|
|
// Spalte nicht. ALTER schlaegt fehl, wenn sie schon da ist -- das ist der
|
|
// Normalfall und kein Grund, den Start abzubrechen.
|
|
try { this.db.run(`ALTER TABLE requests ADD COLUMN ticket TEXT`); } catch { /* Spalte existiert bereits */ }
|
|
this.db.run(`CREATE INDEX IF NOT EXISTS requests_ts_key ON requests (ts, key)`);
|
|
this.db.run(`CREATE INDEX IF NOT EXISTS requests_ticket ON requests (ticket)`);
|
|
}
|
|
schreiben(e: Eintrag): void {
|
|
appendFileSync(this.jsonl, JSON.stringify(e) + "\n");
|
|
this.db.run(
|
|
`INSERT INTO requests (ts,key,alias,angefragt,model,upstream,effort,pfad,status,ms,stream,
|
|
prompt_tokens,completion_tokens,cached_tokens,cost,fehler,ticket)
|
|
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, e.ticket ?? 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"> {
|
|
// 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,
|
|
};
|
|
}
|