Files
fusion/packages/engine/src/session-token-usage.ts
gsxdsm 711bf3e1b8 FN-6746: attribute token analytics per runtime model
Persist and expand per-model token usage so Command Center reports every model used by a task.

- Add a nullable per-model token usage column and task model bucket types.
- Merge session token deltas into provider/model buckets while retaining task-level totals.
- Expand model/provider analytics from per-model buckets with legacy fallbacks for older rows.
- Cover persistence, migration, aggregation, and session accumulation behavior with tests.
- Document the per-model storage contract alongside existing token analytics tables.

Files changed:
 docs/storage.md                                    |   2 +
 packages/core/src/__tests__/db-migrate.test.ts     |  62 ++++++++++
 .../core/src/__tests__/store-persistence.test.ts   |  49 ++++++++
 .../core/src/__tests__/token-analytics.test.ts     | 127 ++++++++++++++++++++-
 packages/core/src/db.ts                            |  13 ++-
 packages/core/src/index.ts                         |   2 +-
 packages/core/src/store.ts                         |   7 +-
 packages/core/src/token-analytics.ts               |  58 ++++++++--
 packages/core/src/types.ts                         |  32 ++++++
 .../src/__tests__/session-token-usage.test.ts      |  23 ++++
 packages/engine/src/executor.ts                    |  20 +++-
 packages/engine/src/session-token-usage.ts         |  50 +++++++-
 packages/engine/src/step-session-executor.ts       |   7 ++
 13 files changed, 429 insertions(+), 23 deletions(-)

Fusion-Task-Id: FN-6746

Fusion-Task-Lineage: 1b1a3b31-b669-4e89-ac38-a871f3349015
2026-06-19 16:47:34 -07:00

181 lines
6.8 KiB
TypeScript

import type { AgentRole, TaskStore, TaskTokenUsage, TaskTokenUsagePerModel } from "@fusion/core";
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { createLogger } from "./logger.js";
const log = createLogger("session-token-usage");
const cacheMetricsLog = createLogger("token-cache-metrics");
interface SessionBaseline {
input: number;
output: number;
cached: number;
cacheWrite: number;
}
// Per-session cumulative-token baselines so repeated calls only persist deltas.
// The session object is keyed weakly so disposed sessions get garbage-collected.
const sessionBaselines = new WeakMap<AgentSession, SessionBaseline>();
type TokenUsageDelta = Pick<TaskTokenUsage, "inputTokens" | "outputTokens" | "cachedTokens" | "cacheWriteTokens" | "totalTokens">;
type TokenUsageModelSnapshot = { provider?: string; id?: string } | undefined;
interface SessionStatsLike {
tokens?: {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
};
}
export function mergeTokenUsagePerModel(
existing: TaskTokenUsagePerModel[] | undefined,
delta: TokenUsageDelta,
model: TokenUsageModelSnapshot,
timestamp: string,
): TaskTokenUsagePerModel[] {
const perModel = [...(existing ?? [])];
const modelProvider = model?.provider;
const modelId = model?.id;
const matchesBucket = (bucket: TaskTokenUsagePerModel): boolean =>
(bucket.modelProvider ?? null) === (modelProvider ?? null)
&& (bucket.modelId ?? null) === (modelId ?? null);
const bucketIndex = perModel.findIndex(matchesBucket);
const previous = bucketIndex >= 0 ? perModel[bucketIndex] : undefined;
const next: TaskTokenUsagePerModel = {
modelProvider,
modelId,
inputTokens: (previous?.inputTokens ?? 0) + delta.inputTokens,
outputTokens: (previous?.outputTokens ?? 0) + delta.outputTokens,
cachedTokens: (previous?.cachedTokens ?? 0) + delta.cachedTokens,
cacheWriteTokens: (previous?.cacheWriteTokens ?? 0) + delta.cacheWriteTokens,
totalTokens: (previous?.totalTokens ?? 0) + delta.totalTokens,
firstUsedAt: previous?.firstUsedAt ?? timestamp,
lastUsedAt: timestamp,
};
if (bucketIndex >= 0) {
perModel[bucketIndex] = next;
} else {
perModel.push(next);
}
return perModel;
}
function readSessionStats(session: AgentSession): SessionStatsLike | undefined {
const accessor = (session as unknown as { getSessionStats?: () => SessionStatsLike }).getSessionStats;
if (typeof accessor !== "function") return undefined;
try {
return accessor.call(session);
} catch {
return undefined;
}
}
/**
* Capture the session's cumulative token usage and accumulate any *new* deltas
* onto `task.tokenUsage`. Safe to call repeatedly on the same session — each
* call only persists what's been added since the previous call (per-session
* baseline tracking). Failures are logged and swallowed so token bookkeeping
* never blocks the task pipeline.
*/
export async function accumulateSessionTokenUsage(
store: TaskStore,
taskId: string,
session: AgentSession,
options?: { agentId?: string; role?: AgentRole },
): Promise<void> {
try {
const stats = readSessionStats(session);
const tokens = stats?.tokens;
if (!tokens) return;
const currentInput = tokens.input ?? 0;
const currentOutput = tokens.output ?? 0;
const currentCached = tokens.cacheRead ?? 0;
const currentCacheWrite = tokens.cacheWrite ?? 0;
const baseline = sessionBaselines.get(session) ?? { input: 0, output: 0, cached: 0, cacheWrite: 0 };
const inputDelta = Math.max(0, currentInput - baseline.input);
const outputDelta = Math.max(0, currentOutput - baseline.output);
const cachedDelta = Math.max(0, currentCached - baseline.cached);
const cacheWriteDelta = Math.max(0, currentCacheWrite - baseline.cacheWrite);
sessionBaselines.set(session, {
input: currentInput,
output: currentOutput,
cached: currentCached,
cacheWrite: currentCacheWrite,
});
if (inputDelta === 0 && outputDelta === 0 && cachedDelta === 0 && cacheWriteDelta === 0) return;
const task = await store.getTask(taskId);
const now = new Date().toISOString();
const newInput = (task.tokenUsage?.inputTokens ?? 0) + inputDelta;
const newOutput = (task.tokenUsage?.outputTokens ?? 0) + outputDelta;
const newCached = (task.tokenUsage?.cachedTokens ?? 0) + cachedDelta;
const newCacheWrite = (task.tokenUsage?.cacheWriteTokens ?? 0) + cacheWriteDelta;
const role = options?.role ?? "executor";
const model = (session as { model?: { provider?: string; id?: string } }).model;
const tokenUsage = {
inputTokens: newInput,
outputTokens: newOutput,
cachedTokens: newCached,
cacheWriteTokens: newCacheWrite,
totalTokens: newInput + newOutput + newCached + newCacheWrite,
firstUsedAt: task.tokenUsage?.firstUsedAt ?? now,
lastUsedAt: now,
/*
* FNXC:TokenAnalytics 2026-06-18-16:23:
* Token accumulation must snapshot the actually-used session model for by-model analytics without touching task.modelProvider/task.modelId, which would pin future model resolution.
*/
modelProvider: model?.provider ?? task.tokenUsage?.modelProvider,
modelId: model?.id ?? task.tokenUsage?.modelId,
/*
* FNXC:TokenAnalytics 2026-06-19-15:52:
* Per-model buckets must add only the newly observed session delta so the bucket sum equals the task aggregate while Command Center grand nTasks continues to count this task once.
*/
perModel: mergeTokenUsagePerModel(task.tokenUsage?.perModel, {
inputTokens: inputDelta,
outputTokens: outputDelta,
cachedTokens: cachedDelta,
cacheWriteTokens: cacheWriteDelta,
totalTokens: inputDelta + outputDelta + cachedDelta + cacheWriteDelta,
}, model, now),
};
cacheMetricsLog.log(JSON.stringify({
taskId,
agentId: options?.agentId,
role,
inputTokens: tokenUsage.inputTokens,
cachedTokens: tokenUsage.cachedTokens,
cacheWriteTokens: tokenUsage.cacheWriteTokens,
hitRatio: computeCacheHitRatio(tokenUsage.inputTokens, tokenUsage.cachedTokens),
}));
await store.updateTask(taskId, { tokenUsage });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
log.warn(`${taskId}: session token usage accumulate failed: ${message}`);
}
}
/**
* Compute the cache hit ratio: `cachedTokens / (inputTokens + cachedTokens)`.
* Returns a number in [0, 1], or 0 when both arguments are 0.
*
* Compatible with canonical stored `task.tokenUsage` fields: pass raw
* `inputTokens` and cache-read `cachedTokens`.
*/
export function computeCacheHitRatio(
inputTokens: number,
cachedTokens: number,
): number {
const total = inputTokens + cachedTokens;
if (total === 0) return 0;
return cachedTokens / total;
}