Bill a turn to the node that asked, not the node that generated

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.
This commit is contained in:
Gmer4Lfe
2026-08-20 20:04:08 -04:00
parent 0621f66889
commit 111c70d6b4
4 changed files with 46 additions and 15 deletions
+7 -7
View File
@@ -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
+13 -3
View File
@@ -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
+21 -4
View File
@@ -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;
+5 -1
View File
@@ -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 </dev/null &';
$out = []; $rc = 0;
exec($cmd, $out, $rc);