Record token usage per turn and show daily, weekly and all-time totals by host

This commit is contained in:
Gmer4Lfe
2026-08-04 18:04:46 -04:00
parent 961c57c6f0
commit 969a85f303
8 changed files with 340 additions and 3 deletions
+126
View File
@@ -554,6 +554,132 @@ function vv_ai_memory_write(string $text): array {
return ['ok' => true, 'chars' => $len];
}
// ── Token accounting ─────────────────────────────────────────────────────────────────────────
// One appended row per completed turn: date|time|host|profile|source|prompt|completion|tok_s.
// Pipe-delimited under data/ to match arr_cleanup_stats.db and bandwidth_history.db rather than
// inventing a format for one feature.
//
// Recording is best-effort and never raises. A turn that answered correctly must not be reported
// as failed because its accounting row could not be written — the number is the cheap part here
// and the answer is the expensive one.
function vv_ai_token_db(): string {
$p = str_replace(['$DATA_DIR', '${DATA_DIR}'], DATA_DIR,
trim(vv_conf_vars()['AI_TOKEN_DB'] ?? ''));
return $p !== '' ? $p : DATA_DIR . '/ai_token_history.db';
}
function vv_ai_token_retain(): int {
$n = (int)(vv_conf_vars()['AI_TOKEN_RETAIN_ROWS'] ?? 20000);
return max(500, min($n, 500000));
}
// LOCK_EX because two turns can finish together — the composer allows a second question while
// the first is still generating, and each runs in its own detached worker.
function vv_ai_token_record(string $profile, string $source, int $prompt, int $completion, ?float $tokS): void {
if ($prompt <= 0 && $completion <= 0) return; // nothing generated; not a turn worth a row
$db = vv_ai_token_db();
$dir = dirname($db);
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return;
$row = implode('|', [
date('Y-m-d'), date('H:i:s'),
vv_detect_host(),
preg_replace('/[^a-z0-9_-]/i', '', $profile) ?: 'unknown',
preg_replace('/[^a-z0-9_-]/i', '', $source) ?: 'unknown',
max(0, $prompt), max(0, $completion),
$tokS === null ? '' : number_format($tokS, 1, '.', ''),
]) . "\n";
@file_put_contents($db, $row, FILE_APPEND | LOCK_EX);
vv_ai_token_prune($db);
}
// Pruning is gated on filesize rather than done every turn: filesize() is a stat, whereas
// reading and rewriting the whole file is not something a chat turn should pay for per message.
// The threshold is derived from the row cap so raising the cap does not silently disable it.
function vv_ai_token_prune(string $db): void {
$keep = vv_ai_token_retain();
if ((int)@filesize($db) < $keep * 60) return; // ~60 bytes a row, generously
$rows = @file($db, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (!is_array($rows) || count($rows) <= $keep) return;
$tmp = $db . '.vv.tmp';
if (@file_put_contents($tmp, implode("\n", array_slice($rows, -$keep)) . "\n") === false) return;
@rename($tmp, $db);
}
// Aggregates the file into today / last 7 days / all time, both overall and per host.
//
// Every detected host is present in 'hosts' whether or not it has rows, with 'seen' telling the
// two apart. A host with no rows is not the same fact as a host with zero tokens, and the page
// must be able to say "nothing collected here" rather than draw a confident 0.
function vv_ai_token_stats(): array {
$db = vv_ai_token_db();
$today = date('Y-m-d');
$week = date('Y-m-d', strtotime('-6 days')); // 7 days inclusive of today
$blank = ['turns' => 0, 'prompt' => 0, 'completion' => 0, 'total' => 0];
$out = [
'exists' => file_exists($db),
'today' => $blank, 'week' => $blank, 'all' => $blank,
'hosts' => [], 'profiles' => [], 'sources' => [],
'first' => null, 'last' => null, 'best_tok_s' => null,
];
foreach (vv_known_hosts() as $id => $name) {
$out['hosts'][$id] = ['name' => $name, 'seen' => false, 'self' => $id === vv_detect_host(),
'today' => $blank, 'week' => $blank, 'all' => $blank];
}
$fh = @fopen($db, 'r');
if (!$fh) return $out;
$add = function (array $b, int $p, int $c): array {
$b['turns']++; $b['prompt'] += $p; $b['completion'] += $c; $b['total'] += $p + $c;
return $b;
};
while (($line = fgets($fh)) !== false) {
$f = explode('|', rtrim($line, "\r\n"));
if (count($f) < 8) continue; // partial write, or a format change
[$date, , $host, $profile, $source, $p, $c, $tokS] = $f;
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) continue;
$p = (int)$p; $c = (int)$c;
if (!isset($out['hosts'][$host])) {
// A host in the file that conf no longer lists — renamed, or a row copied in. Shown
// rather than dropped, so the totals always reconcile with the per-host rows.
$out['hosts'][$host] = ['name' => $host, 'seen' => false, 'self' => false,
'today' => $blank, 'week' => $blank, 'all' => $blank];
}
$out['hosts'][$host]['seen'] = true;
foreach ([['all', true], ['week', $date >= $week], ['today', $date === $today]] as [$k, $hit]) {
if (!$hit) continue;
$out[$k] = $add($out[$k], $p, $c);
$out['hosts'][$host][$k] = $add($out['hosts'][$host][$k], $p, $c);
}
$out['profiles'][$profile] = ($out['profiles'][$profile] ?? 0) + $p + $c;
$out['sources'][$source] = ($out['sources'][$source] ?? 0) + $p + $c;
if ($tokS !== '' && (float)$tokS > (float)($out['best_tok_s'] ?? 0)) $out['best_tok_s'] = (float)$tokS;
// Min/max rather than first-row/last-row. Appends are chronological in practice, but a
// file that has been hand-edited, merged or restored out of order would otherwise report
// a span that reads backwards — and the row cap means the oldest row is not permanent.
if ($out['first'] === null || $date < $out['first']) $out['first'] = $date;
if ($out['last'] === null || $date > $out['last']) $out['last'] = $date;
}
fclose($fh);
arsort($out['profiles']);
arsort($out['sources']);
$out['days'] = $out['first'] ? max(1, (int)((strtotime($today) - strtotime($out['first'])) / 86400) + 1) : 0;
return $out;
}
function vv_ai_job_dir(): string {
if (!is_dir(VV_AI_JOB_DIR)) @mkdir(VV_AI_JOB_DIR, 0700, true);
return VV_AI_JOB_DIR;