diff --git a/AI/README-AI.md b/AI/README-AI.md index 048f46c..72c9ea8 100644 --- a/AI/README-AI.md +++ b/AI/README-AI.md @@ -225,3 +225,38 @@ No cron entry and no `DAILY_MAINTENANCE_SCRIPTS` line are needed; the daily pull An incremental run on an unchanged repo is ~70 ms, so a daily entry costs effectively nothing and a pull that changed twelve files costs a few seconds. + +--- + +## ━━━ TOKEN ACCOUNTING ━━━ + +Every completed `ask` appends one row to `AI_TOKEN_DB` (`data/ai_token_history.db`): + +``` +date|time|host|profile|source|prompt_tokens|completion_tokens|tok_s +2026-08-04|22:03:51|host1|varaverk|cli|2041|318|61.4 +``` + +Both paths write it — this CLI (`source=cli`) and the WebGUI worker (`source=webgui`) — so the +totals are not quietly the tab's alone. `ai_query.sh` passes `--token-db` and `--token-host`; +called by hand without them, `cli.js` simply skips the row rather than guessing a path, because +this file never reads conf itself. + +Read it on the plugin's AI tab, which aggregates today / last 7 days / all time, per host. Or +straight from the shell, since it is just a delimited file: + +```bash +# tokens used today +awk -F'|' -v d="$(date +%F)" '$1==d {p+=$6; c+=$7} END {print p+c}' data/ai_token_history.db +``` + +**The host column is where the turn ran, not where the file is read.** Each host keeps its own +`data/` and nothing syncs it, so a host only ever sees its own rows — the partner shows as +"not collected here" on the tab, never as zero. Carrying a partner's totals would mean extending +the partnership payload fetch; the column exists so that stays a display change rather than a +migration. + +Pruning is by row count (`AI_TOKEN_RETAIN_ROWS`, default 20000) and happens on write, but only +once the file passes a size threshold — an ordinary turn costs a `stat()` and an append. The CLI +deliberately does not prune: duplicating a read-modify-write of the whole file in a second +language is how the two drift apart. diff --git a/AI/ai_query.sh b/AI/ai_query.sh index ff8cd40..9d5514a 100755 --- a/AI/ai_query.sh +++ b/AI/ai_query.sh @@ -198,8 +198,12 @@ if [[ "$SEARCH_ONLY" == true ]]; then "--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}" else [[ -z "$GEN_MODEL" ]] && { error "${MY_ID}_OLLAMA_MODEL is empty — needed for generation"; exit 1; } + # Token accounting. Passed in rather than re-read in node, so the conf stays the shell's job + # and cli.js keeps taking everything it needs as arguments. Omitting either flag simply + # skips the row — the CLI must still work when called by hand outside this wrapper. node --no-warnings "$CLI" ask "${_args[@]}" \ "--model=${GEN_MODEL}" "--embed-model=${EMBED_MODEL}" \ "--k=${AI_SEARCH_K:-8}" "--per-file=${AI_SEARCH_PER_FILE:-3}" \ - "--timeout=$(( ${AI_REQUEST_TIMEOUT:-240} * 1000 ))" + "--timeout=$(( ${AI_REQUEST_TIMEOUT:-240} * 1000 ))" \ + "--token-db=${AI_TOKEN_DB:-}" "--token-host=$(echo "$MY_ID" | tr '[:upper:]' '[:lower:]')" fi diff --git a/AI/lib/cli.js b/AI/lib/cli.js index 49d1a80..2992020 100644 --- a/AI/lib/cli.js +++ b/AI/lib/cli.js @@ -12,6 +12,7 @@ // are. // ═══════════════════════════════════════════════════════════════════════════════════════════════ +const fs = require('fs'); const { buildIndex } = require('./index.js'); const { search } = require('./search.js'); @@ -24,6 +25,27 @@ function flag(name) { return process.argv.includes(`--${name}`); } +// Token accounting. Writes the same row shape as the WebGUI worker into the same file — one +// ledger for both paths, or the totals quietly come to mean "whatever the tab happened to do". +// Skipped silently when the caller passes neither flag, because cli.js has to stay runnable by +// hand. Best-effort: a failed append must never cost a caller an answer it already has. +// +// Trimming is deliberately not done here. The PHP side prunes on write, and duplicating a +// read-modify-write of the whole file in a second language is how the two drift apart. +function recordTokens(profile, prompt, completion, tokS) { + const db = arg('token-db', ''), host = arg('token-host', ''); + if (!db || !host || (prompt <= 0 && completion <= 0)) return; + const d = new Date(); + const p2 = n => String(n).padStart(2, '0'); + const row = [ + `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`, + `${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`, + host, profile, 'cli', prompt, completion, + tokS === null ? '' : tokS.toFixed(1), + ].join('|') + '\n'; + try { fs.appendFileSync(db, row); } catch { /* accounting is not the answer */ } +} + function fail(msg, code = 1) { console.error(msg); process.exit(code); @@ -165,6 +187,11 @@ ANSWER`; if (!res.ok) fail(`generation HTTP ${res.status}`, 2); const j = await res.json(); + // 'varaverk' rather than a CLI-specific name: this path retrieves and cites, so it is the + // same kind of turn the tab's default profile runs, and the two should aggregate together. + recordTokens('varaverk', j.prompt_eval_count || 0, j.eval_count || 0, + j.eval_duration > 0 ? (j.eval_count / (j.eval_duration / 1e9)) : null); + if (flag('json')) { console.log(JSON.stringify({ answer: j.response, sources: r.results.map(x => ({ path: x.path, section: x.section, heading: x.heading, score: x.score })) })); return; diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index b4193ed..c6bb061 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -1644,6 +1644,23 @@ AI_MEMORY_FILE="$DATA_DIR/ai_memory.md" AI_MEMORY_MAX_CHARS=4000 # ~1000 tokens — truncated with a notice if exceeded +# ━━━ AI Token Accounting ━━━ +# One row per completed turn, appended by whichever path ran it — the WebGUI worker and the +# ai_query.sh CLI both write here, so the totals are not silently the tab's alone. Format is +# pipe-delimited to match the other data/*.db files: +# +# date|time|host|profile|source|prompt_tokens|completion_tokens|tok_s +# +# The host column records where the turn ran, not where it is read. Each host keeps its own +# data/ and nothing syncs it, so a host only ever sees its own rows — the column is there so +# the file is already shaped right if the partner payload fetch is ever extended to carry it. +# A column added later cannot be backfilled. +# +# Retention is by row count rather than age: pruning is considered only when the file passes a +# size threshold, so an ordinary turn costs one stat() and an append. + AI_TOKEN_DB="$DATA_DIR/ai_token_history.db" + AI_TOKEN_RETAIN_ROWS=20000 # oldest rows dropped past this — years of ordinary use + # ━━━ AI Feature Toggles ━━━ # Tier 1 is narration — it cannot change a decision. Tier 2 adds context to a decision a script # already made. Tier 3 assists a human. Enable in that order, and give each one weeks. diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index 322a70b..1c49236 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -363,6 +363,11 @@ if ($profile === 'code' && preg_match_all('/```(?:\w+)?\n(.*?)```/s', $answer, $ $evalCount = (int)($d['eval_count'] ?? 0); $evalNs = (int)($d['eval_duration'] ?? 0); +$tokS = $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null; + +// Accounting before the job file is written, so a turn is counted even if the tab has already +// been closed and nobody ever reads the result. Best-effort by contract — it cannot throw. +vv_ai_token_record($profile, 'webgui', (int)($d['prompt_eval_count'] ?? 0), $evalCount, $tokS); jw($jobFile, [ 'status' => 'done', @@ -374,6 +379,6 @@ jw($jobFile, [ 'retrieve_ms' => (int)round($tRetrieve * 1000), 'generate_ms' => (int)round((microtime(true) - $t1) * 1000), 'tokens' => $evalCount, - 'tok_s' => $evalNs > 0 ? round($evalCount / ($evalNs / 1e9), 1) : null, + 'tok_s' => $tokS, ], ]); diff --git a/Plugin/unraid/api/ai.php b/Plugin/unraid/api/ai.php index a14e53c..47cab86 100644 --- a/Plugin/unraid/api/ai.php +++ b/Plugin/unraid/api/ai.php @@ -134,6 +134,15 @@ if ($action === 'stats') { exit; } +// ── tokens ──────────────────────────────────────────────────────────────────── +// Separate from stats rather than folded into it. stats is polled every 30 seconds by every +// open tab; this reads a file that grows without bound between prunes. The totals only move +// when a turn completes, and the page knows exactly when that happened, so it asks then. +if ($action === 'tokens') { + echo json_encode(['ok' => true, 'tokens' => vv_ai_token_stats()]); + exit; +} + // ── poll ────────────────────────────────────────────────────────────────────── if ($action === 'poll') { $token = trim($_GET['token'] ?? ''); diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index 7f9c33e..a59cfff 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -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; diff --git a/Plugin/unraid/pages/ai.php b/Plugin/unraid/pages/ai.php index 7452b63..7f6f8e1 100644 --- a/Plugin/unraid/pages/ai.php +++ b/Plugin/unraid/pages/ai.php @@ -165,6 +165,28 @@ if (is_dir('/var/log/varaverk')) { .vv-ai-mdl-m { color:#444; font-family:monospace; margin-left:auto; flex-shrink:0; font-size:10px; } .vv-ai-none { font-size:11px; color:#3a3a3a; font-style:italic; } +/* ── Token accounting ───────────────────────────────────────────────────── */ +/* Splits on the second banner stat rather than the third, so the host window stays narrow and + the tiles get the room — still a line the banner already draws, so the rows read as a grid + rather than three unrelated cards. renderBanner() keeps --vv-tok-split in step. */ +.vv-ai-tok { display:grid; grid-template-columns:var(--vv-tok-split,2fr 3fr); gap:1px; background:#1a1a1a; + border:1px solid #262626; border-radius:6px; overflow:hidden; } +@media (max-width:900px) { .vv-ai-tok { grid-template-columns:1fr; } } +.vv-ai-hostrow { display:flex; align-items:center; gap:8px; font-size:11px; padding:4px 6px; + border-radius:4px; cursor:pointer; border:1px solid transparent; } +.vv-ai-hostrow:hover { background:#141414; } +.vv-ai-hostrow.active { background:#14202c; border-color:#2d4a6a; } +.vv-ai-hostrow-n { color:#8a8a8a; } +.vv-ai-hostrow-h { color:#4a4a4a; font-family:monospace; font-size:10px; } +.vv-ai-hostrow-v { margin-left:auto; color:#c8c8c8; font-family:monospace; font-size:11px; flex-shrink:0; } +.vv-ai-hostrow-v.none { color:#3a3a3a; font-style:italic; font-family:inherit; font-size:10px; } +.vv-ai-tok-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:12px; } +.vv-ai-tok-l { font-size:9px; letter-spacing:.08em; text-transform:uppercase; color:#4a4a4a; } +.vv-ai-tok-v { font-size:17px; font-weight:bold; color:#c8c8c8; font-family:monospace; line-height:1.3; } +.vv-ai-tok-s { font-size:10px; color:#5a5a5a; } +.vv-ai-tok-foot { margin-top:9px; padding-top:7px; border-top:1px solid #1a1a1a; font-size:10px; + color:#4a4a4a; display:flex; gap:14px; flex-wrap:wrap; } + .vv-ai-pending { font-size:12px; color:#5a5a5a; display:flex; align-items:center; gap:8px; } .vv-ai-dot { width:6px; height:6px; border-radius:50%; background:#6fcf97; animation:vvAiPulse 1.1s infinite; } @keyframes vvAiPulse { 0%,100%{opacity:.25;} 50%{opacity:1;} } @@ -213,6 +235,17 @@ if (is_dir('/var/log/varaverk')) { +