From 111c70d6b44b4b399d2096f63e88b2576fea453d Mon Sep 17 00:00:00 2001 From: Gmer4Lfe Date: Thu, 20 Aug 2026 20:04:08 -0400 Subject: [PATCH] Bill a turn to the node that asked, not the node that generated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every turn runs on the AI owner now, so the ledger's per-host column had collapsed to a single name and the fleet breakdown said nothing. The asking node travels with the request and is validated before it is written, which also retires ai_token_sync.sh — a partner ledger is empty by construction. --- Deployment/master.conf.template | 14 +++++++------- Plugin/unraid/Tools/ai_chat_worker.php | 16 +++++++++++++--- Plugin/unraid/include/ai.php | 25 +++++++++++++++++++++---- Plugin/unraid/include/ai_actions.php | 6 +++++- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 5acf508..483255a 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -471,7 +471,6 @@ # to 1 day). 30min was true "always fresh" but had no consumer that needed it that often. "Arrs_Stack/arr_cache_prefill.sh ARR_PREFILL_WAIT_MINUTES=1" # keep the shared arr tracked-data cache fresh "Arrs_Stack/arrs_failed_stalled_recovery.sh" # blocklist + re-search failed/stalled arr queue items - "AI/ai_token_sync.sh" # pull partner AI token ledgers into the tmpfs cache ) # arr_sync.sh runs as a fixed first step in intermediate_sync_maintenance.sh — not listed here. # It is controlled by ARR_SYNC_ENABLED (see Arr Sync section above). @@ -1966,12 +1965,13 @@ AI_TOKEN_DB="${AI_DATA_DIR}/ai_token_history.db" AI_TOKEN_RETAIN_ROWS=20000 # oldest rows dropped past this — years of ordinary use -# AI/ai_token_sync.sh pulls each partner's ledger into the tmpfs cache the tab reads, so the -# fleet total is a fleet total. Same trick conf_sync.sh uses for partner confs, minus the push: -# nothing here is needed by anyone else, so the reader fetches its own data and controls its -# own freshness. An unreachable partner is a quiet skip, not a warning — a partner is expected -# to be down for long stretches, and a four-hourly warn trains you to ignore the script. - AI_TOKEN_SYNC_ENABLED=true +# AI/ai_token_sync.sh pulled each partner's ledger into a tmpfs cache so the fleet total was a +# fleet total. The mesh shares one AI now: every turn generates on the owner and is recorded +# there against the node that asked, so the owner's own ledger already holds the whole fleet and +# a partner ledger is empty by construction. Left off the schedule rather than deleted — the +# reader still merges any cache from before the change, which describes real turns. + AI_TOKEN_SYNC_ENABLED=false + # ━━━ AI Feature Toggles ━━━ # Tier 1 is narration — it cannot change a decision. Tier 2 adds context to a decision a script diff --git a/Plugin/unraid/Tools/ai_chat_worker.php b/Plugin/unraid/Tools/ai_chat_worker.php index d7e67dc..dabfcf2 100644 --- a/Plugin/unraid/Tools/ai_chat_worker.php +++ b/Plugin/unraid/Tools/ai_chat_worker.php @@ -122,8 +122,17 @@ if ($explain) { exit(2); } } else { - [$jobFile, $question, $historyJson, $kind, $think, $profile, $scope, $webArg] = - array_slice($argv, 1, 8) + array_fill(0, 8, ''); + // Ninth argument is the node that asked, which is not necessarily the node generating: the + // mesh shares one AI, so a turn started on a mirror runs here. Without it every row in the + // ledger reads as the owner's and the tab can no longer say who spent what. + // + // Optional, and validated rather than trusted. It arrives from the RPC layer's own view of + // the caller, but this is a command line and an unrecognisable value must fall back to "this + // node" rather than write a slot name nobody can place. + [$jobFile, $question, $historyJson, $kind, $think, $profile, $scope, $webArg, $askNode] = + array_slice($argv, 1, 9) + array_fill(0, 9, ''); + + if (!preg_match('/^host\d+$/', (string)$askNode)) $askNode = ''; if ($jobFile === '' || $question === '') exit(1); if (!preg_match('#/[0-9a-f]{32}\.json$#', $jobFile)) exit(1); @@ -1239,7 +1248,8 @@ $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); +vv_ai_token_record($profile, 'webgui', (int)($d['prompt_eval_count'] ?? 0), $evalCount, $tokS, + $askNode ?? ''); // profile is the one that actually answered, not the one that was asked for — they differ on a // handoff. Reported so the page can show which contract produced the answer rather than the diff --git a/Plugin/unraid/include/ai.php b/Plugin/unraid/include/ai.php index 10def53..995bb25 100644 --- a/Plugin/unraid/include/ai.php +++ b/Plugin/unraid/include/ai.php @@ -872,16 +872,26 @@ function vv_ai_token_retain(): int { // 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 { +// +// $node is who *asked*, which stopped being the same as who generated when the mesh began sharing +// one AI: a turn started on a mirror runs on the owner and would otherwise be billed to the owner, +// making the per-host breakdown a column of the owner's own name. Defaults to this node, so every +// caller that does not know about the mesh keeps its previous meaning exactly. +function vv_ai_token_record(string $profile, string $source, int $prompt, int $completion, + ?float $tokS, string $node = ''): 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; + // Validated, not trusted: the host column is what the per-host totals group on, and a value + // that is not a host slot would create a column nobody can place. + $node = preg_match('/^host\d+$/', $node) ? $node : vv_detect_host(); + $row = implode('|', [ date('Y-m-d'), date('H:i:s'), - vv_detect_host(), + $node, preg_replace('/[^a-z0-9_-]/i', '', $profile) ?: 'unknown', preg_replace('/[^a-z0-9_-]/i', '', $source) ?: 'unknown', max(0, $prompt), max(0, $completion), @@ -938,13 +948,20 @@ function vv_ai_token_stats(): array { 'synced' => null, 'today' => $blank, 'week' => $blank, 'all' => $blank]; } - // Ledgers to read: our own, plus whatever AI/ai_token_sync.sh has pulled from partners. + // Ledgers to read: our own, plus any partner ledger still cached from when each node kept its + // own. The mesh shares one AI now — every turn generates on the owner and is recorded in the + // owner's ledger against the node that asked — so on a current install this loop finds + // nothing and the local file already holds the whole fleet. + // + // Kept rather than deleted: a cache from before the change still describes real turns, and + // dropping the read would silently retire history the tab is showing today. // // Each entry carries the host slot it is allowed to contribute rows for. A partner file may // only add rows whose host column matches its filename — a ledger copied into the wrong slot, // or a partner that somehow cached ours, would otherwise be counted twice against a total // that still looked plausible. Our own file is trusted for any host, because it is the only - // one written here and its host column is written by vv_detect_host(). + // one written here and its host column is written by vv_ai_token_record(), which validates + // the slot before it writes one. $ledgers = [[$db, null]]; foreach ((array)@glob(VV_AI_TOKEN_CACHE_DIR . '/host*.tokens.db') as $partnerDb) { if (!preg_match('/(host\d+)\.tokens\.db$/', $partnerDb, $m)) continue; diff --git a/Plugin/unraid/include/ai_actions.php b/Plugin/unraid/include/ai_actions.php index a57a47f..08d67bf 100644 --- a/Plugin/unraid/include/ai_actions.php +++ b/Plugin/unraid/include/ai_actions.php @@ -525,7 +525,11 @@ function vv_ai_dispatch(string $action, array $p, bool $isPost, int &$httpStatus . escapeshellarg($scope) . ' ' // Asked for per turn. Only meaningful on a profile holding web_search — the worker // checks that, so a crafted web=1 against any other profile changes nothing. - . escapeshellarg(($p['web'] ?? '') === '1' ? '1' : '0') + . escapeshellarg(($p['web'] ?? '') === '1' ? '1' : '0') . ' ' + // Who asked. Set by the RPC layer for a forwarded turn and absent for a local one, + // where the worker's own default is already correct. This is what keeps the token + // ledger's per-host column meaningful once every turn generates on the owner. + . escapeshellarg((string)($p['_vv_node'] ?? '')) . ' >/dev/null 2>&1