From 98e451c6b8103becaed7876d0227bd1c594d9106 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 06:59:17 -0700 Subject: [PATCH 1/4] feat(web): usage page with real cost from local agent logs Adds a Usage page under settings that reports token spend across every connected environment. Cost comes from the session logs Claude Code and Codex already write to disk rather than T3 Code's event log. The event log only sees threads T3 Code drove and carries no cache-write counts for Claude, which makes exact cost impossible to derive from it. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/server.ts | 10 + apps/server/src/usage/UsageService.ts | 100 ++++ apps/server/src/usage/activityUsage.ts | 150 +++++ apps/server/src/usage/http.ts | 27 + apps/server/src/usage/localAgentUsage.test.ts | 186 ++++++ apps/server/src/usage/localAgentUsage.ts | 561 ++++++++++++++++++ apps/server/src/usage/pricing.ts | 100 ++++ .../settings/SettingsSidebarNav.tsx | 2 + .../src/components/settings/UsageSettings.tsx | 432 ++++++++++++++ .../src/components/settings/settingsSearch.ts | 2 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/settings.usage.tsx | 7 + apps/web/src/state/usage.ts | 253 ++++++++ packages/contracts/src/environmentHttp.ts | 11 + packages/contracts/src/index.ts | 1 + packages/contracts/src/usage.ts | 155 +++++ 16 files changed, 2018 insertions(+) create mode 100644 apps/server/src/usage/UsageService.ts create mode 100644 apps/server/src/usage/activityUsage.ts create mode 100644 apps/server/src/usage/http.ts create mode 100644 apps/server/src/usage/localAgentUsage.test.ts create mode 100644 apps/server/src/usage/localAgentUsage.ts create mode 100644 apps/server/src/usage/pricing.ts create mode 100644 apps/web/src/components/settings/UsageSettings.tsx create mode 100644 apps/web/src/routes/settings.usage.tsx create mode 100644 apps/web/src/state/usage.ts create mode 100644 packages/contracts/src/usage.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d824afbd1b..309d1ebf5e3 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -108,6 +108,8 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import { usageHttpApiLayer } from "./usage/http.ts"; +import { UsageServiceLayer } from "./usage/UsageService.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; @@ -431,6 +433,14 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(authHttpApiLayer), Layer.provide(connectHttpApiLayer), Layer.provide(orchestrationHttpApiLayer), + // Persistence is provided here rather than left as an outstanding + // requirement so the routes layer keeps the same shape it had before + // usage reporting existed. Layer memoization shares the one connection. + Layer.provide( + usageHttpApiLayer.pipe( + Layer.provide(UsageServiceLayer.pipe(Layer.provide(PersistenceLayerLive))), + ), + ), Layer.provide(serverEnvironmentHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts new file mode 100644 index 00000000000..6177e4b93cf --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,100 @@ +import type { EnvironmentUsageSnapshot } from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { readActivitySummary } from "./activityUsage.ts"; +import { readLocalAgentUsage } from "./localAgentUsage.ts"; + +/** Scans cost real IO, so a snapshot is reused briefly rather than recomputed per request. */ +const CACHE_TTL_MILLIS = Duration.toMillis(Duration.seconds(60)); +const DEFAULT_WINDOW_DAYS = 30; + +const emptyTokens = { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, +} as const; + +export class UsageService extends Context.Service< + UsageService, + { + readonly getSnapshot: ( + sinceDate: string | undefined, + ) => Effect.Effect; + } +>()("t3/usage/UsageService") {} + +export const UsageServiceLayer = Layer.effect(UsageService)( + Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + // Captured once so the returned effects carry no outstanding requirements. + const sql = yield* SqlClient.SqlClient; + + // Keyed by the resolved window so a narrower request cannot serve a wider + // cached answer, or the reverse. + const cache = new Map< + string, + { readonly at: number; readonly snapshot: EnvironmentUsageSnapshot } + >(); + + const build = (sinceDate: string): Effect.Effect => + Effect.gen(function* () { + const descriptor = yield* serverEnvironment.getDescriptor; + const generatedAt = DateTime.formatIso(yield* DateTime.now); + const activity = yield* readActivitySummary(`${sinceDate}T00:00:00.000Z`).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + ); + const local = yield* Effect.tryPromise(() => readLocalAgentUsage({ sinceDate })).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to read local agent usage logs", { cause }).pipe( + Effect.as(undefined), + ), + ), + ); + + return { + environmentId: descriptor.environmentId, + generatedAt, + firstDate: local?.firstDate, + lastDate: local?.lastDate, + costUsd: local?.costUsd ?? 0, + tokens: local?.tokens ?? emptyTokens, + messages: local?.messages ?? 0, + sessions: local?.sessions ?? 0, + models: local?.models ?? [], + daily: local?.daily ?? [], + projects: local?.projects ?? [], + rateLimits: local?.rateLimits ?? [], + sources: local?.sources ?? [], + activity, + } satisfies EnvironmentUsageSnapshot; + }); + + return { + getSnapshot: (sinceDate) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const resolved = + sinceDate ?? + DateTime.formatIso(DateTime.subtract(now, { days: DEFAULT_WINDOW_DAYS })).slice(0, 10); + + const nowMillis = yield* Clock.currentTimeMillis; + const cached = cache.get(resolved); + if (cached !== undefined && nowMillis - cached.at < CACHE_TTL_MILLIS) { + return cached.snapshot; + } + const snapshot = yield* build(resolved); + cache.set(resolved, { at: nowMillis, snapshot }); + return snapshot; + }), + }; + }), +); diff --git a/apps/server/src/usage/activityUsage.ts b/apps/server/src/usage/activityUsage.ts new file mode 100644 index 00000000000..f23cc02f741 --- /dev/null +++ b/apps/server/src/usage/activityUsage.ts @@ -0,0 +1,150 @@ +import type { UsageActivityCount, UsageActivitySummary } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Behavioral counters drawn from T3 Code's own projections. + * + * The agent session logs record tokens but know nothing about skills, plans, + * subagent types or checkpoint diffs, so this half of the usage page can only + * come from here. Counts are deliberately provider-shaped rather than merged: + * Claude reports a named tool per call while Codex reports an item type, so the + * two are labelled but never summed into a single "tool" figure. + */ + +const TOP_N = 12; + +const toCounts = ( + rows: ReadonlyArray<{ name: unknown; count: unknown }>, +): Array => + rows + .flatMap((row) => { + const name = typeof row.name === "string" ? row.name.trim() : ""; + const count = typeof row.count === "number" ? Math.trunc(row.count) : 0; + return name.length === 0 || count <= 0 ? [] : [{ name, count }]; + }) + .sort((a, b) => b.count - a.count); + +const firstNumber = (rows: ReadonlyArray>, key: string): number => { + const value = rows.at(0)?.[key]; + return typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : 0; +}; + +/** + * @param sinceIso inclusive lower bound on `created_at`, ISO 8601. + */ +export const readActivitySummary = ( + sinceIso: string, +): Effect.Effect => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Claude records a tool name; Codex records an item type. COALESCE keeps + // both on one axis without pretending they are the same vocabulary. + const tools = yield* sql<{ name: unknown; count: unknown }>` + SELECT + COALESCE( + json_extract(payload_json, '$.data.toolName'), + json_extract(payload_json, '$.itemType') + ) AS name, + COUNT(*) AS count + FROM projection_thread_activities + WHERE kind = 'tool.completed' AND created_at >= ${sinceIso} + GROUP BY name + ORDER BY count DESC + LIMIT ${TOP_N} + `; + + const skills = yield* sql<{ name: unknown; count: unknown }>` + SELECT + json_extract(payload_json, '$.data.input.skill') AS name, + COUNT(*) AS count + FROM projection_thread_activities + WHERE kind = 'tool.completed' + AND json_extract(payload_json, '$.data.toolName') = 'Skill' + AND created_at >= ${sinceIso} + GROUP BY name + ORDER BY count DESC + LIMIT ${TOP_N} + `; + + const subagents = yield* sql<{ name: unknown; count: unknown }>` + SELECT + json_extract(payload_json, '$.data.input.subagent_type') AS name, + COUNT(*) AS count + FROM projection_thread_activities + WHERE kind = 'tool.completed' + AND json_extract(payload_json, '$.data.toolName') = 'Agent' + AND created_at >= ${sinceIso} + GROUP BY name + ORDER BY count DESC + LIMIT ${TOP_N} + `; + + const hourRows = yield* sql<{ hour: unknown; count: unknown }>` + SELECT + CAST(strftime('%H', requested_at) AS INTEGER) AS hour, + COUNT(*) AS count + FROM projection_turns + WHERE requested_at >= ${sinceIso} + GROUP BY hour + `; + + const totals = yield* sql>` + SELECT + (SELECT COUNT(*) FROM projection_turns WHERE requested_at >= ${sinceIso}) AS totalTurns, + (SELECT COUNT(*) FROM projection_threads WHERE created_at >= ${sinceIso} AND deleted_at IS NULL) AS totalThreads, + (SELECT COUNT(*) FROM projection_thread_activities + WHERE kind = 'tool.completed' AND created_at >= ${sinceIso}) AS toolCalls, + (SELECT COUNT(*) FROM projection_thread_activities + WHERE kind = 'tool.completed' AND created_at >= ${sinceIso} + AND json_extract(payload_json, '$.data.item.exitCode') NOT IN (0)) AS toolFailures + `; + + const churn = yield* sql>` + SELECT + SUM(json_extract(file.value, '$.additions')) AS linesAdded, + SUM(json_extract(file.value, '$.deletions')) AS linesDeleted + FROM projection_turns AS turn, json_each(turn.checkpoint_files_json) AS file + WHERE turn.requested_at >= ${sinceIso} + `; + + const turnsByHour = Array.from({ length: 24 }, () => 0); + for (const row of hourRows) { + const hour = typeof row.hour === "number" ? row.hour : Number.NaN; + const count = typeof row.count === "number" ? Math.trunc(row.count) : 0; + if (Number.isInteger(hour) && hour >= 0 && hour < 24 && count > 0) turnsByHour[hour] = count; + } + + return { + tools: toCounts(tools), + skills: toCounts(skills), + subagents: toCounts(subagents), + turnsByHour, + totalTurns: firstNumber(totals, "totalTurns"), + totalThreads: firstNumber(totals, "totalThreads"), + toolCalls: firstNumber(totals, "toolCalls"), + toolFailures: firstNumber(totals, "toolFailures"), + linesAdded: firstNumber(churn, "linesAdded"), + linesDeleted: firstNumber(churn, "linesDeleted"), + } satisfies UsageActivitySummary; + }).pipe( + // Usage reporting is read-only and non-critical: a malformed payload or a + // schema drift should degrade the panel, never fail the request. + Effect.catchCause((cause) => + Effect.logWarning("Failed to read usage activity summary", { cause }).pipe( + Effect.as({ + tools: [], + skills: [], + subagents: [], + turnsByHour: Array.from({ length: 24 }, () => 0), + totalTurns: 0, + totalThreads: 0, + toolCalls: 0, + toolFailures: 0, + linesAdded: 0, + linesDeleted: 0, + } satisfies UsageActivitySummary), + ), + ), + ); diff --git a/apps/server/src/usage/http.ts b/apps/server/src/usage/http.ts new file mode 100644 index 00000000000..86bf4c4713c --- /dev/null +++ b/apps/server/src/usage/http.ts @@ -0,0 +1,27 @@ +import { AuthOrchestrationReadScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; +import { UsageService } from "./UsageService.ts"; + +/** + * Usage is read-only reporting over data the host already has, so it rides the + * orchestration read scope rather than introducing a new one. + */ +export const usageHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "usage", + Effect.fnUntraced(function* (handlers) { + const usage = yield* UsageService; + + return handlers.handle( + "snapshot", + Effect.fn("environment.usage.snapshot")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthOrchestrationReadScope); + return yield* usage.getSnapshot(args.payload.sinceDate); + }), + ); + }), +); diff --git a/apps/server/src/usage/localAgentUsage.test.ts b/apps/server/src/usage/localAgentUsage.test.ts new file mode 100644 index 00000000000..46ca77d42e1 --- /dev/null +++ b/apps/server/src/usage/localAgentUsage.test.ts @@ -0,0 +1,186 @@ +// Fixtures are written with the node APIs the reader itself uses. +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import { describe, expect, it } from "@effect/vitest"; + +import { readLocalAgentUsage } from "./localAgentUsage.ts"; +import { calculateCostUsd, lookupPricing } from "./pricing.ts"; + +const write = async (path: string, lines: ReadonlyArray): Promise => { + await NodeFSP.mkdir(NodePath.dirname(path), { recursive: true }); + await NodeFSP.writeFile(path, lines.map((line) => JSON.stringify(line)).join("\n")); +}; + +const claudeEntry = (options: { + id: string; + model?: string; + cacheRead?: number; + cacheWrite5m?: number; +}) => ({ + type: "assistant", + timestamp: "2099-01-02T03:04:05.000Z", + cwd: "/home/dev/acme", + message: { + id: options.id, + model: options.model ?? "claude-opus-5", + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: options.cacheRead ?? 0, + cache_creation: { + ephemeral_5m_input_tokens: options.cacheWrite5m ?? 0, + ephemeral_1h_input_tokens: 0, + }, + }, + }, +}); + +const codexTokenCount = (total: { + input: number; + cached: number; + output: number; + reasoning?: number; +}) => ({ + timestamp: "2099-01-02T03:04:05.000Z", + type: "event_msg", + payload: { + type: "token_count", + info: { + total_token_usage: { + input_tokens: total.input, + cached_input_tokens: total.cached, + output_tokens: total.output, + reasoning_output_tokens: total.reasoning ?? 0, + }, + }, + rate_limits: { + plan_type: "pro", + primary: { used_percent: 42, window_minutes: 10080, resets_at: 4102444800 }, + }, + }, +}); + +const withFixture = async ( + build: (dirs: { claude: string; codex: string }) => Promise, +): Promise>> => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-usage-")); + const claude = NodePath.join(root, "claude"); + const codex = NodePath.join(root, "codex-sessions"); + try { + await build({ claude, codex }); + return await readLocalAgentUsage({ claudeDirs: [claude], codexDir: codex }); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } +}; + +describe("readLocalAgentUsage", () => { + it("bills a Claude message once even when several session files repeat it", async () => { + const result = await withFixture(async ({ claude }) => { + const entry = claudeEntry({ id: "msg-1" }); + // A resumed session rewrites earlier turns into a new file. + await write(NodePath.join(claude, "projects", "acme", "a.jsonl"), [entry]); + await write(NodePath.join(claude, "projects", "acme", "b.jsonl"), [entry]); + }); + + expect(result.messages).toBe(1); + const claudeSource = result.sources.find((source) => source.provider === "claude"); + expect(claudeSource?.recordsRead).toBe(2); + expect(claudeSource?.duplicatesSkipped).toBe(1); + }); + + it("prices Claude cache writes above input and cache reads below it", async () => { + const result = await withFixture(async ({ claude }) => { + await write(NodePath.join(claude, "projects", "acme", "a.jsonl"), [ + claudeEntry({ id: "msg-1", cacheRead: 1_000_000, cacheWrite5m: 1_000_000 }), + ]); + }); + + const pricing = lookupPricing("claude-opus-5"); + expect(pricing).toBeDefined(); + const expected = calculateCostUsd( + { + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 1_000_000, + cacheWrite5mTokens: 1_000_000, + cacheWrite1hTokens: 0, + fast: false, + }, + pricing!, + ); + expect(result.costUsd).toBeCloseTo(expected, 6); + // Cache write is the dominant term: it costs more per token than input. + expect(result.costUsd).toBeGreaterThan(pricing!.cacheRead + pricing!.input); + }); + + it("turns cumulative Codex totals into per-turn deltas", async () => { + const result = await withFixture(async ({ codex }) => { + await write(NodePath.join(codex, "2099", "s.jsonl"), [ + { type: "turn_context", payload: { model: "gpt-5.6-sol", cwd: "/home/dev/acme" } }, + codexTokenCount({ input: 1000, cached: 800, output: 100 }), + codexTokenCount({ input: 2500, cached: 2000, output: 250 }), + ]); + }); + + const model = result.models.find((entry) => entry.model === "gpt-5.6-sol"); + // Totals are the last cumulative snapshot, not the sum of both snapshots. + expect(model?.tokens.outputTokens).toBe(250); + expect(model?.tokens.cacheReadTokens).toBe(2000); + // Codex counts cached inside input, so fresh input is the remainder. + expect(model?.tokens.inputTokens).toBe(500); + }); + + it("treats a backwards Codex snapshot as a compaction reset", async () => { + const result = await withFixture(async ({ codex }) => { + await write(NodePath.join(codex, "2099", "s.jsonl"), [ + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + codexTokenCount({ input: 5000, cached: 4000, output: 500 }), + // Context was compacted; the counter restarts rather than continuing. + codexTokenCount({ input: 900, cached: 400, output: 90 }), + ]); + }); + + const model = result.models.find((entry) => entry.model === "gpt-5.6-sol"); + expect(model?.tokens.outputTokens).toBe(590); + expect(model?.tokens.cacheReadTokens).toBe(4400); + }); + + it("reports the Codex rate limit window", async () => { + const result = await withFixture(async ({ codex }) => { + await write(NodePath.join(codex, "2099", "s.jsonl"), [ + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + codexTokenCount({ input: 100, cached: 0, output: 10 }), + ]); + }); + + expect(result.rateLimits).toHaveLength(1); + expect(result.rateLimits[0]?.planType).toBe("pro"); + expect(result.rateLimits[0]?.usedPercent).toBe(42); + expect(result.rateLimits[0]?.windowMinutes).toBe(10080); + }); + + it("keeps tokens but not cost for a model with no pricing entry", async () => { + const result = await withFixture(async ({ claude }) => { + await write(NodePath.join(claude, "projects", "acme", "a.jsonl"), [ + claudeEntry({ id: "msg-1", model: "some-unreleased-model" }), + ]); + }); + + const model = result.models.at(0); + expect(model?.pricingKnown).toBe(false); + expect(model?.tokens.outputTokens).toBe(50); + expect(result.costUsd).toBe(0); + }); + + it("returns empty totals when neither agent has logs on this host", async () => { + const result = await withFixture(async () => {}); + + expect(result.costUsd).toBe(0); + expect(result.models).toHaveLength(0); + expect(result.sources.every((source) => !source.available)).toBe(true); + }); +}); diff --git a/apps/server/src/usage/localAgentUsage.ts b/apps/server/src/usage/localAgentUsage.ts new file mode 100644 index 00000000000..84468499333 --- /dev/null +++ b/apps/server/src/usage/localAgentUsage.ts @@ -0,0 +1,561 @@ +// Streams large append-only JSONL logs line by line. Effect's FileSystem has no +// streaming line reader, and these files run to hundreds of megabytes, so the +// node primitives are used directly here. +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalDate:off +import * as NodeFS from "node:fs"; +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodeReadline from "node:readline"; + +import type { + UsageDailyTotals, + UsageModelTotals, + UsageProjectTotals, + UsageProvider, + UsageRateLimitWindow, + UsageSourceReport, + UsageTokenTotals, +} from "@t3tools/contracts"; + +import { calculateCostUsd, lookupPricing } from "./pricing.ts"; + +/** + * Reads the JSONL session logs Claude Code and Codex write locally and turns + * them into billable totals. + * + * Two details carry most of the correctness weight: + * + * 1. Claude duplicates records across files when a session is resumed or + * forked, so entries are deduped on message id. Skipping that step + * overcounts by roughly 2.7x on a busy machine. + * 2. Codex reports cumulative totals per session, so per-turn deltas come from + * subtracting the previous snapshot. A snapshot that moves backwards means + * the context was compacted, and the current value is the delta. + */ + +export type LocalUsageOptions = { + /** Inclusive lower bound, YYYY-MM-DD. Entries older than this are ignored. */ + readonly sinceDate?: string; + readonly homeDir?: string; + /** Overrides for tests; defaults derive from the home directory. */ + readonly claudeDirs?: ReadonlyArray; + readonly codexDir?: string; +}; + +type MutableTokens = { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + reasoningTokens: number; +}; + +type Bucket = MutableTokens & { costUsd: number; messages: number }; + +export type LocalUsageResult = { + readonly costUsd: number; + readonly tokens: UsageTokenTotals; + readonly messages: number; + readonly sessions: number; + readonly models: ReadonlyArray; + readonly daily: ReadonlyArray; + readonly projects: ReadonlyArray; + readonly rateLimits: ReadonlyArray; + readonly sources: ReadonlyArray; + readonly firstDate: string | undefined; + readonly lastDate: string | undefined; +}; + +const emptyBucket = (): Bucket => ({ + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + costUsd: 0, + messages: 0, +}); + +const addTokens = (target: Bucket, delta: MutableTokens, costUsd: number): void => { + target.inputTokens += delta.inputTokens; + target.outputTokens += delta.outputTokens; + target.cacheReadTokens += delta.cacheReadTokens; + target.cacheWriteTokens += delta.cacheWriteTokens; + target.reasoningTokens += delta.reasoningTokens; + target.costUsd += costUsd; + target.messages += 1; +}; + +const toInt = (value: unknown): number => + typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0; + +const dayOf = (timestamp: unknown): string | undefined => + typeof timestamp === "string" && timestamp.length >= 10 ? timestamp.slice(0, 10) : undefined; + +async function collectJsonlFiles(root: string): Promise> { + const found: Array = []; + const walk = async (dir: string): Promise => { + let entries; + try { + entries = await NodeFSP.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + const path = NodePath.join(dir, entry.name); + if (entry.isDirectory()) await walk(path); + else if (entry.name.endsWith(".jsonl")) found.push(path); + } + }; + await walk(root); + return found; +} + +async function* readJsonLines(path: string): AsyncGenerator> { + const stream = NodeFS.createReadStream(path, { encoding: "utf8" }); + try { + const lines = NodeReadline.createInterface({ input: stream, crlfDelay: Infinity }); + for await (const line of lines) { + if (line.length === 0) continue; + try { + const parsed: unknown = JSON.parse(line); + if (parsed !== null && typeof parsed === "object") { + yield parsed as Record; + } + } catch { + // Session logs are append-only; a torn final line is expected while an + // agent is mid-write and is not worth failing the whole scan over. + } + } + } finally { + stream.destroy(); + } +} + +/** + * A file whose newest write predates the cutoff cannot contain in-range + * entries, so it is skipped without being opened. + */ +async function isStale(path: string, sinceDate: string | undefined): Promise { + if (sinceDate === undefined) return false; + try { + const info = await NodeFSP.stat(path); + return info.mtime.toISOString().slice(0, 10) < sinceDate; + } catch { + return false; + } +} + +type Accumulator = { + readonly byModel: Map; + readonly byDayProvider: Map; + readonly byProject: Map; + readonly unpriced: Set; + dates: Array; +}; + +const newAccumulator = (): Accumulator => ({ + byModel: new Map(), + byDayProvider: new Map(), + byProject: new Map(), + unpriced: new Set(), + dates: [], +}); + +function record( + acc: Accumulator, + provider: UsageProvider, + model: string, + day: string | undefined, + project: string, + delta: MutableTokens, + costUsd: number, + pricingKnown: boolean, +): void { + const modelKey = `${provider}:${model}`; + let modelBucket = acc.byModel.get(modelKey); + if (modelBucket === undefined) { + modelBucket = { ...emptyBucket(), provider, pricingKnown }; + acc.byModel.set(modelKey, modelBucket); + } + modelBucket.pricingKnown = modelBucket.pricingKnown && pricingKnown; + addTokens(modelBucket, delta, costUsd); + + if (day !== undefined) { + const dayKey = `${day}:${provider}`; + let dayBucket = acc.byDayProvider.get(dayKey); + if (dayBucket === undefined) { + dayBucket = { ...emptyBucket(), provider }; + acc.byDayProvider.set(dayKey, dayBucket); + } + addTokens(dayBucket, delta, costUsd); + acc.dates.push(day); + } + + let projectBucket = acc.byProject.get(project); + if (projectBucket === undefined) { + projectBucket = emptyBucket(); + acc.byProject.set(project, projectBucket); + } + addTokens(projectBucket, delta, costUsd); +} + +const projectOf = (cwd: unknown): string => { + if (typeof cwd !== "string" || cwd.length === 0) return "unknown"; + const parts = cwd.split("/").filter((part) => part.length > 0); + return parts.at(-1) ?? "unknown"; +}; + +async function scanClaude( + acc: Accumulator, + roots: ReadonlyArray, + sinceDate: string | undefined, +): Promise<{ report: UsageSourceReport; sessions: number }> { + const files: Array = []; + for (const root of roots) + files.push(...(await collectJsonlFiles(NodePath.join(root, "projects")))); + + const seen = new Set(); + let recordsRead = 0; + let duplicatesSkipped = 0; + + for (const file of files) { + if (await isStale(file, sinceDate)) continue; + for await (const entry of readJsonLines(file)) { + if (entry["type"] !== "assistant") continue; + const message = entry["message"]; + if (message === null || typeof message !== "object") continue; + const messageRecord = message as Record; + const usage = messageRecord["usage"]; + if (usage === null || typeof usage !== "object") continue; + recordsRead += 1; + + const day = dayOf(entry["timestamp"]); + if (sinceDate !== undefined && day !== undefined && day < sinceDate) continue; + + const messageId = messageRecord["id"]; + if (typeof messageId === "string" && messageId.length > 0) { + const key = `${messageId}:${String(entry["requestId"] ?? "")}`; + if (seen.has(key)) { + duplicatesSkipped += 1; + continue; + } + seen.add(key); + } + + const model = typeof messageRecord["model"] === "string" ? messageRecord["model"] : "unknown"; + if (model === "") continue; + + const usageRecord = usage as Record; + const cacheCreation = usageRecord["cache_creation"]; + const creationRecord = + cacheCreation !== null && typeof cacheCreation === "object" + ? (cacheCreation as Record) + : undefined; + const write1h = toInt(creationRecord?.["ephemeral_1h_input_tokens"]); + const write5m = + creationRecord === undefined + ? toInt(usageRecord["cache_creation_input_tokens"]) + : toInt(creationRecord["ephemeral_5m_input_tokens"]); + + const delta: MutableTokens = { + inputTokens: toInt(usageRecord["input_tokens"]), + outputTokens: toInt(usageRecord["output_tokens"]), + cacheReadTokens: toInt(usageRecord["cache_read_input_tokens"]), + cacheWriteTokens: write5m + write1h, + reasoningTokens: 0, + }; + + const pricing = lookupPricing(model); + if (pricing === undefined) acc.unpriced.add(model); + const costUsd = + pricing === undefined + ? 0 + : calculateCostUsd( + { + inputTokens: delta.inputTokens, + outputTokens: delta.outputTokens, + cacheReadTokens: delta.cacheReadTokens, + cacheWrite5mTokens: write5m, + cacheWrite1hTokens: write1h, + fast: usageRecord["speed"] === "fast", + }, + pricing, + ); + + record( + acc, + "claude", + model, + day, + projectOf(entry["cwd"]), + delta, + costUsd, + pricing !== undefined, + ); + } + } + + return { + report: { + provider: "claude", + available: files.length > 0, + filesScanned: files.length, + recordsRead, + duplicatesSkipped, + modelsWithoutPricing: [], + }, + sessions: files.length, + }; +} + +type CodexSnapshot = { + input: number; + cached: number; + output: number; + reasoning: number; +}; + +async function scanCodex( + acc: Accumulator, + root: string, + sinceDate: string | undefined, +): Promise<{ + report: UsageSourceReport; + sessions: number; + rateLimits: Array; +}> { + const files = await collectJsonlFiles(root); + let recordsRead = 0; + let latest: UsageRateLimitWindow | undefined; + + for (const file of files) { + if (await isStale(file, sinceDate)) continue; + let previous: CodexSnapshot | undefined; + let model = "unknown"; + let cwd: unknown; + + for await (const entry of readJsonLines(file)) { + const payload = entry["payload"]; + const payloadRecord = + payload !== null && typeof payload === "object" + ? (payload as Record) + : undefined; + + if (entry["type"] === "session_meta" || entry["type"] === "turn_context") { + const candidate = payloadRecord?.["model"]; + if (typeof candidate === "string" && candidate.length > 0) model = candidate; + const candidateCwd = payloadRecord?.["cwd"]; + if (typeof candidateCwd === "string") cwd = candidateCwd; + } + if (payloadRecord?.["type"] !== "token_count") continue; + + const info = payloadRecord["info"]; + if (info === null || typeof info !== "object") continue; + const total = (info as Record)["total_token_usage"]; + if (total === null || typeof total !== "object") continue; + recordsRead += 1; + + const limits = payloadRecord["rate_limits"]; + if (limits !== null && typeof limits === "object") { + const parsed = parseRateLimit(limits as Record, entry["timestamp"]); + if ( + parsed !== undefined && + (latest === undefined || parsed.observedAt > latest.observedAt) + ) { + latest = parsed; + } + } + + const totalRecord = total as Record; + const inputTokens = toInt(totalRecord["input_tokens"]); + const current: CodexSnapshot = { + input: inputTokens, + // Bad data can report more cached than input; clamping keeps fresh input non-negative. + cached: Math.min(toInt(totalRecord["cached_input_tokens"]), inputTokens), + output: toInt(totalRecord["output_tokens"]), + reasoning: toInt(totalRecord["reasoning_output_tokens"]), + }; + + const rewound = + previous !== undefined && + (current.input < previous.input || current.output < previous.output); + const base = + rewound || previous === undefined + ? { input: 0, cached: 0, output: 0, reasoning: 0 } + : previous; + const cachedDelta = Math.max(0, current.cached - base.cached); + const inputDelta = Math.max(0, current.input - base.input); + previous = current; + if (inputDelta === 0 && current.output - base.output <= 0) continue; + + const day = dayOf(entry["timestamp"]); + if (sinceDate !== undefined && day !== undefined && day < sinceDate) continue; + + const delta: MutableTokens = { + // Codex counts cached inside input; the billable fresh portion is the remainder. + inputTokens: Math.max(0, inputDelta - cachedDelta), + outputTokens: Math.max(0, current.output - base.output), + cacheReadTokens: cachedDelta, + cacheWriteTokens: 0, + reasoningTokens: Math.max(0, current.reasoning - base.reasoning), + }; + + const pricing = lookupPricing(model); + if (pricing === undefined) acc.unpriced.add(model); + const costUsd = + pricing === undefined + ? 0 + : calculateCostUsd( + { + inputTokens: delta.inputTokens, + outputTokens: delta.outputTokens, + cacheReadTokens: delta.cacheReadTokens, + cacheWrite5mTokens: 0, + cacheWrite1hTokens: 0, + fast: true, + }, + pricing, + ); + + record(acc, "codex", model, day, projectOf(cwd), delta, costUsd, pricing !== undefined); + } + } + + return { + report: { + provider: "codex", + available: files.length > 0, + filesScanned: files.length, + recordsRead, + duplicatesSkipped: 0, + modelsWithoutPricing: [], + }, + sessions: files.length, + rateLimits: latest === undefined ? [] : [latest], + }; +} + +function parseRateLimit( + limits: Record, + timestamp: unknown, +): UsageRateLimitWindow | undefined { + const primary = limits["primary"]; + if (primary === null || typeof primary !== "object") return undefined; + const primaryRecord = primary as Record; + const usedPercent = primaryRecord["used_percent"]; + if (typeof usedPercent !== "number") return undefined; + const resetsAt = primaryRecord["resets_at"]; + const planType = limits["plan_type"]; + return { + provider: "codex", + planType: typeof planType === "string" && planType.length > 0 ? planType : undefined, + usedPercent, + windowMinutes: toInt(primaryRecord["window_minutes"]), + resetsAt: typeof resetsAt === "number" ? new Date(resetsAt * 1000).toISOString() : undefined, + observedAt: typeof timestamp === "string" ? timestamp : new Date(0).toISOString(), + }; +} + +export async function readLocalAgentUsage( + options: LocalUsageOptions = {}, +): Promise { + const home = options.homeDir ?? NodeOS.homedir(); + const claudeDirs = options.claudeDirs ?? [NodePath.join(home, ".claude")]; + const codexDir = options.codexDir ?? NodePath.join(home, ".codex", "sessions"); + const acc = newAccumulator(); + + const [claude, codex] = await Promise.all([ + scanClaude(acc, claudeDirs, options.sinceDate), + scanCodex(acc, codexDir, options.sinceDate), + ]); + + const unpricedList = [...acc.unpriced].sort(); + const sources: Array = [ + { ...claude.report, modelsWithoutPricing: unpricedList }, + { ...codex.report, modelsWithoutPricing: unpricedList }, + ]; + + const models: Array = [...acc.byModel.entries()] + .map(([key, bucket]) => ({ + model: key.slice(key.indexOf(":") + 1), + provider: bucket.provider, + tokens: { + inputTokens: bucket.inputTokens, + outputTokens: bucket.outputTokens, + cacheReadTokens: bucket.cacheReadTokens, + cacheWriteTokens: bucket.cacheWriteTokens, + reasoningTokens: bucket.reasoningTokens, + }, + costUsd: bucket.costUsd, + messages: bucket.messages, + pricingKnown: bucket.pricingKnown, + })) + .sort((a, b) => b.costUsd - a.costUsd || b.messages - a.messages); + + const dailyByDate = new Map< + string, + Array<{ provider: UsageProvider; costUsd: number; totalTokens: number }> + >(); + for (const [key, bucket] of acc.byDayProvider) { + const date = key.slice(0, key.indexOf(":")); + const totalTokens = + bucket.inputTokens + bucket.outputTokens + bucket.cacheReadTokens + bucket.cacheWriteTokens; + const list = dailyByDate.get(date) ?? []; + list.push({ provider: bucket.provider, costUsd: bucket.costUsd, totalTokens }); + dailyByDate.set(date, list); + } + const daily: Array = [...dailyByDate.entries()] + .map(([date, byProvider]) => ({ + date, + costUsd: byProvider.reduce((sum, item) => sum + item.costUsd, 0), + totalTokens: byProvider.reduce((sum, item) => sum + item.totalTokens, 0), + byProvider: byProvider.sort((a, b) => a.provider.localeCompare(b.provider)), + })) + .sort((a, b) => a.date.localeCompare(b.date)); + + const projects: Array = [...acc.byProject.entries()] + .map(([project, bucket]) => ({ + project, + costUsd: bucket.costUsd, + totalTokens: + bucket.inputTokens + bucket.outputTokens + bucket.cacheReadTokens + bucket.cacheWriteTokens, + messages: bucket.messages, + })) + .sort((a, b) => b.costUsd - a.costUsd) + .slice(0, 20); + + const totals = models.reduce( + (sum, model) => ({ + inputTokens: sum.inputTokens + model.tokens.inputTokens, + outputTokens: sum.outputTokens + model.tokens.outputTokens, + cacheReadTokens: sum.cacheReadTokens + model.tokens.cacheReadTokens, + cacheWriteTokens: sum.cacheWriteTokens + model.tokens.cacheWriteTokens, + reasoningTokens: sum.reasoningTokens + model.tokens.reasoningTokens, + }), + { + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + }, + ); + + const sortedDates = acc.dates.sort(); + return { + costUsd: models.reduce((sum, model) => sum + model.costUsd, 0), + tokens: totals, + messages: models.reduce((sum, model) => sum + model.messages, 0), + sessions: claude.sessions + codex.sessions, + models, + daily, + projects, + rateLimits: codex.rateLimits, + sources, + firstDate: sortedDates.at(0), + lastDate: sortedDates.at(-1), + }; +} diff --git a/apps/server/src/usage/pricing.ts b/apps/server/src/usage/pricing.ts new file mode 100644 index 00000000000..591e37cc5f4 --- /dev/null +++ b/apps/server/src/usage/pricing.ts @@ -0,0 +1,100 @@ +/** + * Per-million-token list prices, mirroring the model set ccusage resolves from + * models.dev and LiteLLM. Kept as a local table so usage reporting works + * offline; refresh it when a model ships or a price changes. + * + * Slugs are matched longest-prefix-first, so dated variants such as + * `claude-haiku-4-5-20251001` fall back to their family entry. + */ +export type ModelPricing = { + readonly input: number; + readonly output: number; + readonly cacheRead: number; + readonly cacheWrite: number; + /** + * Multiplier applied to the whole request in fast mode. Codex fast mode is + * billed at a premium; missing it understates spend by ~2x. + */ + readonly fastMultiplier?: number; +}; + +/** + * 1h cache writes cost more than the 5m default. Applied as a multiple of the + * model's input rate, matching ccusage's cost model. + */ +export const CACHE_WRITE_1H_INPUT_MULTIPLIER = 2; + +const PRICING: Readonly> = { + // Anthropic + "claude-fable-5": { input: 10, output: 50, cacheRead: 1, cacheWrite: 12.5 }, + "claude-opus-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25, fastMultiplier: 2 }, + "claude-opus-4-7": { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + fastMultiplier: 6, + }, + "claude-opus-4-6": { + input: 15, + output: 75, + cacheRead: 1.5, + cacheWrite: 18.75, + fastMultiplier: 6, + }, + "claude-opus-4": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 }, + "claude-sonnet-5": { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 }, + "claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + "claude-haiku-4": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + "claude-3-5-haiku": { input: 0.8, output: 4, cacheRead: 0.08, cacheWrite: 1 }, + + // OpenAI / Codex + "gpt-5.6-sol": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0, fastMultiplier: 2 }, + "gpt-5.6-terra": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0, fastMultiplier: 2 }, + "gpt-5.6-luna": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0, fastMultiplier: 2 }, + "gpt-5.6": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + "gpt-5.5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0, fastMultiplier: 2.5 }, + "gpt-5.4": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0, fastMultiplier: 2 }, + "gpt-5.3-codex": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0, fastMultiplier: 2 }, + "gpt-5": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + "kindle-alpha": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0, fastMultiplier: 2 }, + "codex-auto-review": { input: 1.25, output: 10, cacheRead: 0.125, cacheWrite: 0 }, + + // Other providers reachable through the same CLIs + "kimi-k3": { input: 0.6, output: 2.5, cacheRead: 0.06, cacheWrite: 0.75 }, +}; + +const SLUGS_BY_LENGTH = Object.keys(PRICING).sort((a, b) => b.length - a.length); + +/** Returns undefined for unknown slugs so callers can surface them explicitly. */ +export function lookupPricing(model: string): ModelPricing | undefined { + const slug = model.trim().toLowerCase(); + const direct = PRICING[slug]; + if (direct !== undefined) return direct; + const prefix = SLUGS_BY_LENGTH.find((candidate) => slug.startsWith(candidate)); + return prefix === undefined ? undefined : PRICING[prefix]; +} + +export type CostInput = { + readonly inputTokens: number; + readonly outputTokens: number; + readonly cacheReadTokens: number; + /** 5m ephemeral cache writes, billed at the model's cache-write rate. */ + readonly cacheWrite5mTokens: number; + /** 1h ephemeral cache writes, billed as a multiple of the input rate. */ + readonly cacheWrite1hTokens: number; + readonly fast: boolean; +}; + +export function calculateCostUsd(usage: CostInput, pricing: ModelPricing): number { + const perMillion = + usage.inputTokens * pricing.input + + usage.outputTokens * pricing.output + + usage.cacheReadTokens * pricing.cacheRead + + usage.cacheWrite5mTokens * pricing.cacheWrite + + usage.cacheWrite1hTokens * pricing.input * CACHE_WRITE_1H_INPUT_MULTIPLIER; + const multiplier = usage.fast ? (pricing.fastMultiplier ?? 1) : 1; + return (perMillion / 1_000_000) * multiplier; +} diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index b7ca9afcf83..ac525c2dc73 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -11,6 +11,7 @@ import { ArchiveIcon, ArrowLeftIcon, BotIcon, + ChartColumnIcon, FlaskConicalIcon, GitBranchIcon, KeyboardIcon, @@ -52,6 +53,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/providers": BotIcon, "/settings/source-control": GitBranchIcon, "/settings/connections": Link2Icon, + "/settings/usage": ChartColumnIcon, "/settings/beta": FlaskConicalIcon, "/settings/archived": ArchiveIcon, }; diff --git a/apps/web/src/components/settings/UsageSettings.tsx b/apps/web/src/components/settings/UsageSettings.tsx new file mode 100644 index 00000000000..094fb0aad08 --- /dev/null +++ b/apps/web/src/components/settings/UsageSettings.tsx @@ -0,0 +1,432 @@ +import type { UsageActivityCount, UsageModelTotals } from "@t3tools/contracts"; +import { RefreshCwIcon } from "lucide-react"; +import { useState } from "react"; + +import { cn } from "../../lib/utils"; +import { type EnvironmentUsageEntry, type MergedUsage, useUsage } from "../../state/usage"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; + +/** + * Usage overview: cost and tokens read from the agents' own local session + * logs, joined with behavioral counters from T3 Code's event log, merged + * across every connected environment. + */ + +const WINDOWS = [ + { label: "7d", days: 7 }, + { label: "30d", days: 30 }, + { label: "90d", days: 90 }, +] as const; + +const usd = (value: number): string => + value >= 1000 + ? `$${Math.round(value).toLocaleString("en-US")}` + : `$${value.toFixed(value < 10 ? 2 : 0)}`; + +const compactTokens = (value: number): string => { + if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`; + if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`; + if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`; + return String(value); +}; + +const percent = (numerator: number, denominator: number): string => + denominator === 0 ? "0%" : `${((numerator / denominator) * 100).toFixed(1)}%`; + +function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) { + return ( +
+
+ {label} +
+
+ {value} +
+ {detail === undefined ? null : ( +
{detail}
+ )} +
+ ); +} + +/** + * Stacked columns, one per day, split by provider. Heights are percentages of + * the busiest day so the chart needs no measurement pass. + */ +function DailyChart({ daily }: { daily: MergedUsage["daily"] }) { + if (daily.length === 0) { + return ( +
No usage recorded.
+ ); + } + const max = Math.max(...daily.map((day) => day.costUsd), 0.01); + return ( +
+
+ {daily.map((day) => { + const claude = day.byProvider.find((entry) => entry.provider === "claude")?.costUsd ?? 0; + const codex = day.byProvider.find((entry) => entry.provider === "codex")?.costUsd ?? 0; + return ( +
+
+
+
+ ); + })} +
+
+ {daily.at(0)?.date} + + + + Claude + + + + Codex + + + {daily.at(-1)?.date} +
+
+ ); +} + +function BarList({ + items, + emptyLabel, + accent, +}: { + items: ReadonlyArray; + emptyLabel: string; + accent: string; +}) { + if (items.length === 0) { + return
{emptyLabel}
; + } + const max = items[0]?.count ?? 1; + return ( +
+ {items.slice(0, 8).map((item) => ( +
+ {item.name} + + + + + {item.count.toLocaleString("en-US")} + +
+ ))} +
+ ); +} + +function ModelsTable({ + models, + totalCost, +}: { + models: ReadonlyArray; + totalCost: number; +}) { + if (models.length === 0) { + return ( +
No models recorded.
+ ); + } + return ( +
+ + + + + + + + + + + {models.map((model) => { + const tokens = + model.tokens.inputTokens + + model.tokens.outputTokens + + model.tokens.cacheReadTokens + + model.tokens.cacheWriteTokens; + return ( + + + + + + + ); + })} + +
ModelTokensMessagesCost
+ + + {model.model} + + + {compactTokens(tokens)} + + {model.messages.toLocaleString("en-US")} + + {model.pricingKnown ? ( + usd(model.costUsd) + ) : ( + + unpriced + + )} + {totalCost > 0 && model.pricingKnown ? ( + + {percent(model.costUsd, totalCost)} + + ) : null} +
+
+ ); +} + +function EnvironmentsTable({ entries }: { entries: ReadonlyArray }) { + return ( +
+ + + + + + + + + + + {entries.map((entry) => { + const snapshot = entry.snapshot; + const tokens = + snapshot === null + ? 0 + : snapshot.tokens.inputTokens + + snapshot.tokens.outputTokens + + snapshot.tokens.cacheReadTokens + + snapshot.tokens.cacheWriteTokens; + return ( + + + + + + + ); + })} + +
EnvironmentSessionsTokensCost
+ + + {entry.label} + {entry.error === null ? null : ( + {entry.error} + )} + + + {snapshot?.sessions.toLocaleString("en-US") ?? "-"} + + {snapshot === null ? "-" : compactTokens(tokens)} + + {snapshot === null ? "-" : usd(snapshot.costUsd)} +
+
+ ); +} + +function HourStrip({ turnsByHour }: { turnsByHour: ReadonlyArray }) { + const max = Math.max(...turnsByHour, 1); + return ( +
+
+ {turnsByHour.map((count, hour) => ( + + ))} +
+
+ 00 + 06 + 12 + 18 + 23 +
+
+ ); +} + +export function UsageSettings() { + const [windowDays, setWindowDays] = useState(30); + const { entries, merged, isLoading, refresh } = useUsage(windowDays); + + const rateLimit = entries + .flatMap((entry) => entry.snapshot?.rateLimits ?? []) + .sort((a, b) => b.usedPercent - a.usedPercent) + .at(0); + + const unpriced = merged.models.filter((model) => !model.pricingKnown); + + return ( + + + + {WINDOWS.map((option) => ( + + ))} + + + + } + > +
+ + + + + + +
+
+ + + + + + + + {unpriced.length === 0 ? null : ( +

+ No pricing entry for {unpriced.map((model) => model.model).join(", ")}. Those rows show + tokens but are excluded from the spend total. +

+ )} +
+ + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 2dcdd66f136..458335a0eba 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -5,6 +5,7 @@ export type SettingsPath = | "/settings/providers" | "/settings/source-control" | "/settings/connections" + | "/settings/usage" | "/settings/beta" | "/settings/archived"; @@ -26,6 +27,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/providers": "Providers", "/settings/source-control": "Source Control", "/settings/connections": "Connections", + "/settings/usage": "Usage", "/settings/beta": "Beta", "/settings/archived": "Archive", }; diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 58ab4c3a714..b14160c85ea 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -14,6 +14,7 @@ import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsUsageRouteImport } from './routes/settings.usage' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' @@ -51,6 +52,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsUsageRoute = SettingsUsageRouteImport.update({ + id: '/usage', + path: '/usage', + getParentRoute: () => SettingsRoute, +} as any) const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ id: '/source-control', path: '/source-control', @@ -128,6 +134,7 @@ export interface FileRoutesByFullPath { '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/usage': typeof SettingsUsageRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } @@ -145,6 +152,7 @@ export interface FileRoutesByTo { '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/usage': typeof SettingsUsageRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -165,6 +173,7 @@ export interface FileRoutesById { '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/usage': typeof SettingsUsageRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -186,6 +195,7 @@ export interface FileRouteTypes { | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' + | '/settings/usage' | '/$environmentId/$threadId' | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo @@ -203,6 +213,7 @@ export interface FileRouteTypes { | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' + | '/settings/usage' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' @@ -222,6 +233,7 @@ export interface FileRouteTypes { | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' + | '/settings/usage' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' @@ -272,6 +284,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/usage': { + id: '/settings/usage' + path: '/usage' + fullPath: '/settings/usage' + preLoaderRoute: typeof SettingsUsageRouteImport + parentRoute: typeof SettingsRoute + } '/settings/source-control': { id: '/settings/source-control' path: '/source-control' @@ -383,6 +402,7 @@ interface SettingsRouteChildren { SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute + SettingsUsageRoute: typeof SettingsUsageRoute } const SettingsRouteChildren: SettingsRouteChildren = { @@ -395,6 +415,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsKeybindingsRoute: SettingsKeybindingsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, + SettingsUsageRoute: SettingsUsageRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( diff --git a/apps/web/src/routes/settings.usage.tsx b/apps/web/src/routes/settings.usage.tsx new file mode 100644 index 00000000000..484df3909d7 --- /dev/null +++ b/apps/web/src/routes/settings.usage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { UsageSettings } from "../components/settings/UsageSettings"; + +export const Route = createFileRoute("/settings/usage")({ + component: UsageSettings, +}); diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts new file mode 100644 index 00000000000..b40679caab2 --- /dev/null +++ b/apps/web/src/state/usage.ts @@ -0,0 +1,253 @@ +import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import type { + EnvironmentId, + EnvironmentUsageSnapshot, + UsageActivityCount, + UsageDailyTotals, + UsageModelTotals, + UsageProvider, +} from "@t3tools/contracts"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; +import { readPreparedConnection } from "./session"; +import { useEnvironments } from "./environments"; + +/** + * Usage data lives on each host, because each one owns the agent session logs + * its own runs wrote. The page therefore queries every connected environment + * and merges client side. An environment that fails or is offline degrades to + * an error row instead of blanking the whole page. + */ + +export type EnvironmentUsageEntry = { + readonly environmentId: EnvironmentId; + readonly label: string; + readonly snapshot: EnvironmentUsageSnapshot | null; + readonly error: string | null; +}; + +export type MergedUsage = { + readonly costUsd: number; + readonly totalTokens: number; + readonly cacheReadTokens: number; + readonly billableInputTokens: number; + readonly messages: number; + readonly models: ReadonlyArray; + readonly daily: ReadonlyArray; + readonly tools: ReadonlyArray; + readonly skills: ReadonlyArray; + readonly subagents: ReadonlyArray; + readonly turnsByHour: ReadonlyArray; + readonly totalTurns: number; + readonly totalThreads: number; + readonly toolCalls: number; + readonly toolFailures: number; + readonly linesAdded: number; + readonly linesDeleted: number; + readonly firstDate: string | undefined; + readonly lastDate: string | undefined; +}; + +const sumCounts = ( + lists: ReadonlyArray>, +): Array => { + const totals = new Map(); + for (const list of lists) { + for (const item of list) totals.set(item.name, (totals.get(item.name) ?? 0) + item.count); + } + return [...totals.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count); +}; + +/** Same model on two hosts is one row; cost and tokens add. */ +const mergeModels = ( + snapshots: ReadonlyArray, +): Array => { + const merged = new Map(); + for (const snapshot of snapshots) { + for (const model of snapshot.models) { + const key = `${model.provider}:${model.model}`; + const existing = merged.get(key); + if (existing === undefined) { + merged.set(key, model); + continue; + } + merged.set(key, { + model: model.model, + provider: model.provider, + costUsd: existing.costUsd + model.costUsd, + messages: existing.messages + model.messages, + pricingKnown: existing.pricingKnown && model.pricingKnown, + tokens: { + inputTokens: existing.tokens.inputTokens + model.tokens.inputTokens, + outputTokens: existing.tokens.outputTokens + model.tokens.outputTokens, + cacheReadTokens: existing.tokens.cacheReadTokens + model.tokens.cacheReadTokens, + cacheWriteTokens: existing.tokens.cacheWriteTokens + model.tokens.cacheWriteTokens, + reasoningTokens: existing.tokens.reasoningTokens + model.tokens.reasoningTokens, + }, + }); + } + } + return [...merged.values()].sort((a, b) => b.costUsd - a.costUsd); +}; + +const mergeDaily = ( + snapshots: ReadonlyArray, +): Array => { + const byDate = new Map>(); + for (const snapshot of snapshots) { + for (const day of snapshot.daily) { + const providers = byDate.get(day.date) ?? new Map(); + for (const entry of day.byProvider) { + const existing = providers.get(entry.provider) ?? { costUsd: 0, totalTokens: 0 }; + providers.set(entry.provider, { + costUsd: existing.costUsd + entry.costUsd, + totalTokens: existing.totalTokens + entry.totalTokens, + }); + } + byDate.set(day.date, providers); + } + } + return [...byDate.entries()] + .map(([date, providers]) => { + const byProvider = [...providers.entries()] + .map(([provider, totals]) => ({ provider, ...totals })) + .sort((a, b) => a.provider.localeCompare(b.provider)); + return { + date, + costUsd: byProvider.reduce((sum, item) => sum + item.costUsd, 0), + totalTokens: byProvider.reduce((sum, item) => sum + item.totalTokens, 0), + byProvider, + }; + }) + .sort((a, b) => a.date.localeCompare(b.date)); +}; + +export function mergeUsage(entries: ReadonlyArray): MergedUsage { + const snapshots = entries.flatMap((entry) => (entry.snapshot === null ? [] : [entry.snapshot])); + const turnsByHour = Array.from({ length: 24 }, () => 0); + for (const snapshot of snapshots) { + snapshot.activity.turnsByHour.forEach((count, hour) => { + if (hour < 24) turnsByHour[hour] = (turnsByHour[hour] ?? 0) + count; + }); + } + const dates = snapshots + .flatMap((snapshot) => [snapshot.firstDate, snapshot.lastDate]) + .filter((date): date is string => date !== undefined) + .sort(); + + const sum = (pick: (snapshot: EnvironmentUsageSnapshot) => number): number => + snapshots.reduce((total, snapshot) => total + pick(snapshot), 0); + + return { + costUsd: sum((snapshot) => snapshot.costUsd), + totalTokens: sum( + (snapshot) => + snapshot.tokens.inputTokens + + snapshot.tokens.outputTokens + + snapshot.tokens.cacheReadTokens + + snapshot.tokens.cacheWriteTokens, + ), + cacheReadTokens: sum((snapshot) => snapshot.tokens.cacheReadTokens), + billableInputTokens: sum( + (snapshot) => + snapshot.tokens.inputTokens + + snapshot.tokens.cacheReadTokens + + snapshot.tokens.cacheWriteTokens, + ), + messages: sum((snapshot) => snapshot.messages), + models: mergeModels(snapshots), + daily: mergeDaily(snapshots), + tools: sumCounts(snapshots.map((snapshot) => snapshot.activity.tools)), + skills: sumCounts(snapshots.map((snapshot) => snapshot.activity.skills)), + subagents: sumCounts(snapshots.map((snapshot) => snapshot.activity.subagents)), + turnsByHour, + totalTurns: sum((snapshot) => snapshot.activity.totalTurns), + totalThreads: sum((snapshot) => snapshot.activity.totalThreads), + toolCalls: sum((snapshot) => snapshot.activity.toolCalls), + toolFailures: sum((snapshot) => snapshot.activity.toolFailures), + linesAdded: sum((snapshot) => snapshot.activity.linesAdded), + linesDeleted: sum((snapshot) => snapshot.activity.linesDeleted), + firstDate: dates.at(0), + lastDate: dates.at(-1), + }; +} + +/** Any failure reaching one environment, flattened so callers handle one type. */ +export class UsageFetchError extends Data.TaggedError("UsageFetchError")<{ + readonly cause: unknown; +}> {} + +const fetchSnapshot = ( + httpBaseUrl: string, + sinceDate: string, +): Effect.Effect => + Effect.gen(function* () { + const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); + return yield* client.usage.snapshot({ headers: {}, payload: { sinceDate } }); + }).pipe( + Effect.provide(primaryEnvironmentHttpLayer), + Effect.mapError((cause) => new UsageFetchError({ cause })), + ); + +const isoDaysAgo = (days: number): string => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - days); + return date.toISOString().slice(0, 10); +}; + +export function useUsage(windowDays: number) { + const { environments } = useEnvironments(); + const [entries, setEntries] = useState>([]); + const [isLoading, setIsLoading] = useState(true); + const requestId = useRef(0); + + const targets = useMemo( + () => + environments.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + })), + [environments], + ); + + const refresh = useCallback(async () => { + const id = requestId.current + 1; + requestId.current = id; + setIsLoading(true); + const sinceDate = isoDaysAgo(windowDays); + + const results = await Promise.all( + targets.map(async (target): Promise => { + const connection = readPreparedConnection(target.environmentId); + if (connection === null) { + return { ...target, snapshot: null, error: "Not connected" }; + } + try { + const snapshot = await Effect.runPromise( + fetchSnapshot(connection.httpBaseUrl, sinceDate), + ); + return { ...target, snapshot, error: null }; + } catch { + return { ...target, snapshot: null, error: "Unavailable" }; + } + }), + ); + + // A slower earlier request must not overwrite a newer one. + if (requestId.current !== id) return; + setEntries(results); + setIsLoading(false); + }, [targets, windowDays]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const merged = useMemo(() => mergeUsage(entries), [entries]); + return { entries, merged, isLoading, refresh }; +} diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index f385a2eff2c..ef45e10e4e3 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -33,6 +33,7 @@ import { OrchestrationShellSnapshot, OrchestrationThreadDetailSnapshot, } from "./orchestration.ts"; +import { EnvironmentUsageQuery, EnvironmentUsageSnapshot } from "./usage.ts"; import { RelayCloudEnvironmentHealthRequest, RelayCloudMintCredentialRequest, @@ -500,6 +501,15 @@ export class EnvironmentOrchestrationHttpApi extends HttpApiGroup.make("orchestr }).middleware(EnvironmentAuthenticatedAuth), ) {} +export class EnvironmentUsageHttpApi extends HttpApiGroup.make("usage").add( + HttpApiEndpoint.get("snapshot", "/api/usage/snapshot", { + headers: OptionalBearerHeaders, + payload: EnvironmentUsageQuery, + success: EnvironmentUsageSnapshot, + error: EnvironmentOrchestrationSnapshotErrors, + }).middleware(EnvironmentAuthenticatedAuth), +) {} + export class EnvironmentConnectHttpApi extends HttpApiGroup.make("connect") .add( HttpApiEndpoint.post("linkProof", "/api/connect/link-proof", { @@ -565,4 +575,5 @@ export class EnvironmentHttpApi extends HttpApi.make("environment") .add(EnvironmentMetadataHttpApi) .add(EnvironmentAuthHttpApi) .add(EnvironmentOrchestrationHttpApi) + .add(EnvironmentUsageHttpApi) .add(EnvironmentConnectHttpApi) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177..7e9c950207f 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -29,3 +29,4 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./rpc.ts"; +export * from "./usage.ts"; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts new file mode 100644 index 00000000000..6e817620c7a --- /dev/null +++ b/packages/contracts/src/usage.ts @@ -0,0 +1,155 @@ +import * as Schema from "effect/Schema"; + +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** + * Usage reporting reads the session logs the coding agents already write to + * disk (`~/.claude/projects`, `~/.codex/sessions`) rather than T3 Code's own + * event log. The event log only sees threads T3 Code drove and carries no + * cache-write counts, which makes exact cost impossible to derive from it. + * + * Every snapshot is scoped to one environment. The client fans out across + * connected environments and merges, so each server only reports its own host. + */ + +export const UsageProvider = Schema.Literals(["claude", "codex"]); +export type UsageProvider = typeof UsageProvider.Type; + +/** An ISO date (YYYY-MM-DD) in the host's local timezone. */ +export const UsageDate = TrimmedNonEmptyString; +export type UsageDate = typeof UsageDate.Type; + +/** + * Token counts are disjoint buckets: `inputTokens` excludes anything served + * from cache, so a total is the sum of every field. + */ +export const UsageTokenTotals = Schema.Struct({ + inputTokens: NonNegativeInt, + outputTokens: NonNegativeInt, + cacheReadTokens: NonNegativeInt, + cacheWriteTokens: NonNegativeInt, + /** Reasoning tokens are a subset of `outputTokens`, reported by Codex only. */ + reasoningTokens: NonNegativeInt, +}); +export type UsageTokenTotals = typeof UsageTokenTotals.Type; + +export const UsageModelTotals = Schema.Struct({ + model: TrimmedNonEmptyString, + provider: UsageProvider, + tokens: UsageTokenTotals, + costUsd: Schema.Number, + messages: NonNegativeInt, + /** + * False when no pricing entry matched the model slug. The row still carries + * token counts, but its cost is 0 and must be shown as unknown rather than free. + */ + pricingKnown: Schema.Boolean, +}); +export type UsageModelTotals = typeof UsageModelTotals.Type; + +export const UsageProviderDailyTotals = Schema.Struct({ + provider: UsageProvider, + costUsd: Schema.Number, + totalTokens: NonNegativeInt, +}); +export type UsageProviderDailyTotals = typeof UsageProviderDailyTotals.Type; + +export const UsageDailyTotals = Schema.Struct({ + date: UsageDate, + costUsd: Schema.Number, + totalTokens: NonNegativeInt, + byProvider: Schema.Array(UsageProviderDailyTotals), +}); +export type UsageDailyTotals = typeof UsageDailyTotals.Type; + +export const UsageProjectTotals = Schema.Struct({ + /** Directory basename of the session's cwd; "unknown" when the log omits it. */ + project: TrimmedNonEmptyString, + costUsd: Schema.Number, + totalTokens: NonNegativeInt, + messages: NonNegativeInt, +}); +export type UsageProjectTotals = typeof UsageProjectTotals.Type; + +/** + * Codex writes its remaining quota into every session log. Claude does not, + * so this list is usually Codex-only. + */ +export const UsageRateLimitWindow = Schema.Struct({ + provider: UsageProvider, + planType: Schema.optional(TrimmedNonEmptyString), + usedPercent: Schema.Number, + windowMinutes: NonNegativeInt, + resetsAt: Schema.optional(Schema.String), + observedAt: Schema.String, +}); +export type UsageRateLimitWindow = typeof UsageRateLimitWindow.Type; + +/** Per-provider scan bookkeeping, so the UI can distinguish "zero" from "not installed". */ +export const UsageSourceReport = Schema.Struct({ + provider: UsageProvider, + /** False when the provider's log directory is absent on this host. */ + available: Schema.Boolean, + filesScanned: NonNegativeInt, + recordsRead: NonNegativeInt, + /** + * Records dropped because another file already billed them. Resumed and + * forked sessions duplicate heavily; ignoring this overcounts by ~2.7x. + */ + duplicatesSkipped: NonNegativeInt, + modelsWithoutPricing: Schema.Array(TrimmedNonEmptyString), +}); +export type UsageSourceReport = typeof UsageSourceReport.Type; + +export const UsageActivityCount = Schema.Struct({ + name: TrimmedNonEmptyString, + count: NonNegativeInt, +}); +export type UsageActivityCount = typeof UsageActivityCount.Type; + +/** + * Behavioral counters from T3 Code's own event log. The agent session files + * carry no notion of skills, plans or diffs, so this half stays local. + */ +export const UsageActivitySummary = Schema.Struct({ + tools: Schema.Array(UsageActivityCount), + skills: Schema.Array(UsageActivityCount), + subagents: Schema.Array(UsageActivityCount), + /** 24 buckets, index 0 is midnight local time. */ + turnsByHour: Schema.Array(NonNegativeInt), + totalTurns: NonNegativeInt, + totalThreads: NonNegativeInt, + toolCalls: NonNegativeInt, + toolFailures: NonNegativeInt, + linesAdded: NonNegativeInt, + linesDeleted: NonNegativeInt, +}); +export type UsageActivitySummary = typeof UsageActivitySummary.Type; + +export const EnvironmentUsageSnapshot = Schema.Struct({ + environmentId: TrimmedNonEmptyString, + generatedAt: Schema.String, + /** Oldest and newest day with usage, absent when nothing was found. */ + firstDate: Schema.optional(UsageDate), + lastDate: Schema.optional(UsageDate), + costUsd: Schema.Number, + tokens: UsageTokenTotals, + messages: NonNegativeInt, + sessions: NonNegativeInt, + models: Schema.Array(UsageModelTotals), + daily: Schema.Array(UsageDailyTotals), + projects: Schema.Array(UsageProjectTotals), + rateLimits: Schema.Array(UsageRateLimitWindow), + sources: Schema.Array(UsageSourceReport), + activity: UsageActivitySummary, +}); +export type EnvironmentUsageSnapshot = typeof EnvironmentUsageSnapshot.Type; + +/** + * Clamps how far back the reader walks. Defaults to 30 days when omitted. + * Declared as loose fields rather than a Struct because GET payloads encode + * through the query string. + */ +export const EnvironmentUsageQuery = { + sinceDate: Schema.optional(UsageDate), +}; From 575d03fbefdd675de48b4e29c135673c27c4fef6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 07:15:16 -0700 Subject: [PATCH 2/4] fix(web): address review findings on usage reporting Per-environment auth, correct Codex fast-mode pricing, and several accuracy fixes raised by review. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/server.ts | 4 +- apps/server/src/usage/UsageService.ts | 134 ++++++++++-------- apps/server/src/usage/activityUsage.ts | 9 +- apps/server/src/usage/http.ts | 4 +- apps/server/src/usage/localAgentUsage.test.ts | 20 +++ apps/server/src/usage/localAgentUsage.ts | 16 ++- apps/server/src/usage/pricing.ts | 13 +- .../src/components/settings/UsageSettings.tsx | 46 +++++- apps/web/src/state/usage.ts | 63 +++++--- packages/client-runtime/package.json | 4 + .../src/state/usageSnapshotHttp.ts | 56 ++++++++ packages/contracts/src/usage.ts | 7 +- 12 files changed, 276 insertions(+), 100 deletions(-) create mode 100644 packages/client-runtime/src/state/usageSnapshotHttp.ts diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 309d1ebf5e3..41068a70322 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -109,7 +109,7 @@ import { } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; import { usageHttpApiLayer } from "./usage/http.ts"; -import { UsageServiceLayer } from "./usage/UsageService.ts"; +import * as UsageService from "./usage/UsageService.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; @@ -438,7 +438,7 @@ export const makeRoutesLayer = Layer.mergeAll( // usage reporting existed. Layer memoization shares the one connection. Layer.provide( usageHttpApiLayer.pipe( - Layer.provide(UsageServiceLayer.pipe(Layer.provide(PersistenceLayerLive))), + Layer.provide(UsageService.layer.pipe(Layer.provide(PersistenceLayerLive))), ), ), Layer.provide(serverEnvironmentHttpApiLayer), diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 6177e4b93cf..59415c7a464 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -14,6 +14,7 @@ import { readLocalAgentUsage } from "./localAgentUsage.ts"; /** Scans cost real IO, so a snapshot is reused briefly rather than recomputed per request. */ const CACHE_TTL_MILLIS = Duration.toMillis(Duration.seconds(60)); const DEFAULT_WINDOW_DAYS = 30; +const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; const emptyTokens = { inputTokens: 0, @@ -32,69 +33,82 @@ export class UsageService extends Context.Service< } >()("t3/usage/UsageService") {} -export const UsageServiceLayer = Layer.effect(UsageService)( - Effect.gen(function* () { - const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - // Captured once so the returned effects carry no outstanding requirements. - const sql = yield* SqlClient.SqlClient; +export const make = Effect.gen(function* () { + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + // Captured once so the returned effects carry no outstanding requirements. + const sql = yield* SqlClient.SqlClient; - // Keyed by the resolved window so a narrower request cannot serve a wider - // cached answer, or the reverse. - const cache = new Map< - string, - { readonly at: number; readonly snapshot: EnvironmentUsageSnapshot } - >(); + // Keyed by the resolved window so a narrower request cannot serve a wider + // cached answer, or the reverse. + const cache = new Map< + string, + { readonly at: number; readonly snapshot: EnvironmentUsageSnapshot } + >(); - const build = (sinceDate: string): Effect.Effect => - Effect.gen(function* () { - const descriptor = yield* serverEnvironment.getDescriptor; - const generatedAt = DateTime.formatIso(yield* DateTime.now); - const activity = yield* readActivitySummary(`${sinceDate}T00:00:00.000Z`).pipe( - Effect.provideService(SqlClient.SqlClient, sql), - ); - const local = yield* Effect.tryPromise(() => readLocalAgentUsage({ sinceDate })).pipe( - Effect.catchCause((cause) => - Effect.logWarning("Failed to read local agent usage logs", { cause }).pipe( - Effect.as(undefined), - ), + /** Snapshot plus whether the log scan actually succeeded. */ + const build = ( + sinceDate: string, + ): Effect.Effect<{ snapshot: EnvironmentUsageSnapshot; complete: boolean }> => + Effect.gen(function* () { + const descriptor = yield* serverEnvironment.getDescriptor; + const generatedAt = DateTime.formatIso(yield* DateTime.now); + const activity = yield* readActivitySummary(`${sinceDate}T00:00:00.000Z`).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + ); + const local = yield* Effect.tryPromise(() => readLocalAgentUsage({ sinceDate })).pipe( + Effect.catchCause((cause) => + Effect.logWarning("Failed to read local agent usage logs", { cause }).pipe( + Effect.as(undefined), ), - ); + ), + ); + + const snapshot = { + environmentId: descriptor.environmentId, + generatedAt, + firstDate: local?.firstDate, + lastDate: local?.lastDate, + costUsd: local?.costUsd ?? 0, + tokens: local?.tokens ?? emptyTokens, + messages: local?.messages ?? 0, + sessions: local?.sessions ?? 0, + models: local?.models ?? [], + daily: local?.daily ?? [], + projects: local?.projects ?? [], + rateLimits: local?.rateLimits ?? [], + sources: local?.sources ?? [], + activity, + } satisfies EnvironmentUsageSnapshot; + return { snapshot, complete: local !== undefined }; + }); - return { - environmentId: descriptor.environmentId, - generatedAt, - firstDate: local?.firstDate, - lastDate: local?.lastDate, - costUsd: local?.costUsd ?? 0, - tokens: local?.tokens ?? emptyTokens, - messages: local?.messages ?? 0, - sessions: local?.sessions ?? 0, - models: local?.models ?? [], - daily: local?.daily ?? [], - projects: local?.projects ?? [], - rateLimits: local?.rateLimits ?? [], - sources: local?.sources ?? [], - activity, - } satisfies EnvironmentUsageSnapshot; - }); + return { + getSnapshot: (sinceDate: string | undefined) => + Effect.gen(function* () { + const now = yield* DateTime.now; + const fallback = DateTime.formatIso( + DateTime.subtract(now, { days: DEFAULT_WINDOW_DAYS }), + ).slice(0, 10); + // The wire type is a plain string, so anything that is not a calendar + // date falls back to the default window instead of reaching the query. + const resolved = sinceDate !== undefined && ISO_DATE.test(sinceDate) ? sinceDate : fallback; - return { - getSnapshot: (sinceDate) => - Effect.gen(function* () { - const now = yield* DateTime.now; - const resolved = - sinceDate ?? - DateTime.formatIso(DateTime.subtract(now, { days: DEFAULT_WINDOW_DAYS })).slice(0, 10); + const startedAt = yield* Clock.currentTimeMillis; + const cached = cache.get(resolved); + if (cached !== undefined && startedAt - cached.at < CACHE_TTL_MILLIS) { + return cached.snapshot; + } + const { snapshot, complete } = yield* build(resolved); + // Only cache a complete read, and stamp it on completion so a slow + // scan does not immediately expire. A failed scan reports zeros once + // rather than pinning the page at $0 for the whole TTL. + if (complete) { + const finishedAt = yield* Clock.currentTimeMillis; + cache.set(resolved, { at: finishedAt, snapshot }); + } + return snapshot; + }), + }; +}); - const nowMillis = yield* Clock.currentTimeMillis; - const cached = cache.get(resolved); - if (cached !== undefined && nowMillis - cached.at < CACHE_TTL_MILLIS) { - return cached.snapshot; - } - const snapshot = yield* build(resolved); - cache.set(resolved, { at: nowMillis, snapshot }); - return snapshot; - }), - }; - }), -); +export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/activityUsage.ts b/apps/server/src/usage/activityUsage.ts index f23cc02f741..c2dad3f4338 100644 --- a/apps/server/src/usage/activityUsage.ts +++ b/apps/server/src/usage/activityUsage.ts @@ -81,6 +81,9 @@ export const readActivitySummary = ( LIMIT ${TOP_N} `; + // Timestamps are stored as ISO-8601 UTC, so these buckets are UTC hours. + // Keeping them UTC is what makes them safe to sum across environments in + // different timezones; the UI labels the axis accordingly. const hourRows = yield* sql<{ hour: unknown; count: unknown }>` SELECT CAST(strftime('%H', requested_at) AS INTEGER) AS hour, @@ -98,7 +101,11 @@ export const readActivitySummary = ( WHERE kind = 'tool.completed' AND created_at >= ${sinceIso}) AS toolCalls, (SELECT COUNT(*) FROM projection_thread_activities WHERE kind = 'tool.completed' AND created_at >= ${sinceIso} - AND json_extract(payload_json, '$.data.item.exitCode') NOT IN (0)) AS toolFailures + AND ( + json_extract(payload_json, '$.data.item.exitCode') NOT IN (0) + -- Claude tool results carry no exit code; they flag failure here. + OR json_extract(payload_json, '$.data.result.is_error') = 1 + )) AS toolFailures `; const churn = yield* sql>` diff --git a/apps/server/src/usage/http.ts b/apps/server/src/usage/http.ts index 86bf4c4713c..0a97d5d35c4 100644 --- a/apps/server/src/usage/http.ts +++ b/apps/server/src/usage/http.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; -import { UsageService } from "./UsageService.ts"; +import * as UsageService from "./UsageService.ts"; /** * Usage is read-only reporting over data the host already has, so it rides the @@ -13,7 +13,7 @@ export const usageHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "usage", Effect.fnUntraced(function* (handlers) { - const usage = yield* UsageService; + const usage = yield* UsageService.UsageService; return handlers.handle( "snapshot", diff --git a/apps/server/src/usage/localAgentUsage.test.ts b/apps/server/src/usage/localAgentUsage.test.ts index 46ca77d42e1..dea87058e4f 100644 --- a/apps/server/src/usage/localAgentUsage.test.ts +++ b/apps/server/src/usage/localAgentUsage.test.ts @@ -176,6 +176,26 @@ describe("readLocalAgentUsage", () => { expect(result.costUsd).toBe(0); }); + it("does not charge Codex fast rates when the log reports no premium tier", async () => { + const result = await withFixture(async ({ codex }) => { + await write(NodePath.join(codex, "2099", "s.jsonl"), [ + { type: "turn_context", payload: { model: "gpt-5.6-sol", service_tier: "default" } }, + codexTokenCount({ input: 1_000_000, cached: 0, output: 0 }), + ]); + }); + + const pricing = lookupPricing("gpt-5.6-sol"); + expect(pricing?.fastMultiplier).toBe(2); + // Standard tier bills the base rate, not the doubled fast rate. + expect(result.costUsd).toBeCloseTo(pricing!.input, 6); + }); + + it("resolves a dated model slug to its family but not an unrelated slug", () => { + expect(lookupPricing("claude-haiku-4-5-20251001")).toEqual(lookupPricing("claude-haiku-4-5")); + // `gpt-5000` starts with `gpt-5` but is a different model, not a variant. + expect(lookupPricing("gpt-5000-turbo")).toBeUndefined(); + }); + it("returns empty totals when neither agent has logs on this host", async () => { const result = await withFixture(async () => {}); diff --git a/apps/server/src/usage/localAgentUsage.ts b/apps/server/src/usage/localAgentUsage.ts index 84468499333..7adb610e4d6 100644 --- a/apps/server/src/usage/localAgentUsage.ts +++ b/apps/server/src/usage/localAgentUsage.ts @@ -142,7 +142,11 @@ async function isStale(path: string, sinceDate: string | undefined): Promise { if (typeof cwd !== "string" || cwd.length === 0) return "unknown"; - const parts = cwd.split("/").filter((part) => part.length > 0); + const parts = cwd.split(/[/\\]/).filter((part) => part.length > 0); return parts.at(-1) ?? "unknown"; }; @@ -336,6 +340,10 @@ async function scanCodex( let previous: CodexSnapshot | undefined; let model = "unknown"; let cwd: unknown; + // Codex only writes a service tier when the turn ran on a premium tier. + // Absent that marker the turn is standard, so cost is never inflated by + // assuming the multiplier applies. + let fast = false; for await (const entry of readJsonLines(file)) { const payload = entry["payload"]; @@ -349,6 +357,8 @@ async function scanCodex( if (typeof candidate === "string" && candidate.length > 0) model = candidate; const candidateCwd = payloadRecord?.["cwd"]; if (typeof candidateCwd === "string") cwd = candidateCwd; + const tier = payloadRecord?.["service_tier"]; + if (typeof tier === "string") fast = tier === "fast" || tier === "priority"; } if (payloadRecord?.["type"] !== "token_count") continue; @@ -415,7 +425,7 @@ async function scanCodex( cacheReadTokens: delta.cacheReadTokens, cacheWrite5mTokens: 0, cacheWrite1hTokens: 0, - fast: true, + fast, }, pricing, ); diff --git a/apps/server/src/usage/pricing.ts b/apps/server/src/usage/pricing.ts index 591e37cc5f4..3dfc0577969 100644 --- a/apps/server/src/usage/pricing.ts +++ b/apps/server/src/usage/pricing.ts @@ -68,13 +68,22 @@ const PRICING: Readonly> = { const SLUGS_BY_LENGTH = Object.keys(PRICING).sort((a, b) => b.length - a.length); +/** + * A prefix only matches on a version boundary, so `claude-haiku-4-5-20251001` + * resolves to the `claude-haiku-4-5` family while an unrelated `gpt-5000` does + * not silently inherit `gpt-5` pricing. + */ +const matchesFamily = (slug: string, family: string): boolean => + slug.startsWith(family) && + (slug.length === family.length || /[-_@:.]/.test(slug[family.length] ?? "")); + /** Returns undefined for unknown slugs so callers can surface them explicitly. */ export function lookupPricing(model: string): ModelPricing | undefined { const slug = model.trim().toLowerCase(); const direct = PRICING[slug]; if (direct !== undefined) return direct; - const prefix = SLUGS_BY_LENGTH.find((candidate) => slug.startsWith(candidate)); - return prefix === undefined ? undefined : PRICING[prefix]; + const family = SLUGS_BY_LENGTH.find((candidate) => matchesFamily(slug, candidate)); + return family === undefined ? undefined : PRICING[family]; } export type CostInput = { diff --git a/apps/web/src/components/settings/UsageSettings.tsx b/apps/web/src/components/settings/UsageSettings.tsx index 094fb0aad08..7ba17bfe35b 100644 --- a/apps/web/src/components/settings/UsageSettings.tsx +++ b/apps/web/src/components/settings/UsageSettings.tsx @@ -33,6 +33,37 @@ const compactTokens = (value: number): string => { const percent = (numerator: number, denominator: number): string => denominator === 0 ? "0%" : `${((numerator / denominator) * 100).toFixed(1)}%`; +/** Providers report differently sized quota windows, so the tile names the real one. */ +const limitWindowLabel = (windowMinutes: number): string => { + if (windowMinutes >= 10080) return `${Math.round(windowMinutes / 10080)}w`; + if (windowMinutes >= 1440) return `${Math.round(windowMinutes / 1440)}d`; + if (windowMinutes >= 60) return `${Math.round(windowMinutes / 60)}h`; + return `${windowMinutes}m`; +}; + +/** + * Days with no usage are absent from the series, so they are filled back in. + * Without this the columns would sit adjacent and a quiet week would read as + * continuous activity. + */ +const fillMissingDays = (daily: MergedUsage["daily"]): MergedUsage["daily"] => { + const first = daily.at(0)?.date; + const last = daily.at(-1)?.date; + if (first === undefined || last === undefined) return daily; + const byDate = new Map(daily.map((day) => [day.date, day])); + const startMs = Date.parse(`${first}T00:00:00.000Z`); + const endMs = Date.parse(`${last}T00:00:00.000Z`); + if (Number.isNaN(startMs) || Number.isNaN(endMs) || endMs < startMs) return daily; + + const DAY_MS = 86_400_000; + // Bounded so a stray date can never produce an unbounded series. + const span = Math.min(Math.round((endMs - startMs) / DAY_MS) + 1, 400); + return Array.from({ length: span }, (_unused, index) => { + const date = new Date(startMs + index * DAY_MS).toISOString().slice(0, 10); + return byDate.get(date) ?? { date, costUsd: 0, totalTokens: 0, byProvider: [] }; + }); +}; + function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) { return (
@@ -53,12 +84,13 @@ function StatTile({ label, value, detail }: { label: string; value: string; deta * Stacked columns, one per day, split by provider. Heights are percentages of * the busiest day so the chart needs no measurement pass. */ -function DailyChart({ daily }: { daily: MergedUsage["daily"] }) { - if (daily.length === 0) { +function DailyChart({ daily: input }: { daily: MergedUsage["daily"] }) { + if (input.length === 0) { return (
No usage recorded.
); } + const daily = fillMissingDays(input); const max = Math.max(...daily.map((day) => day.costUsd), 0.01); return (
@@ -276,7 +308,7 @@ function HourStrip({ turnsByHour }: { turnsByHour: ReadonlyArray }) { const max = Math.max(...turnsByHour, 1); return (
-
+
{turnsByHour.map((count, hour) => ( - + diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index b40679caab2..336381e96d5 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -1,4 +1,5 @@ -import { makeEnvironmentHttpApiClient } from "@t3tools/client-runtime/rpc"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { fetchEnvironmentUsageSnapshot } from "@t3tools/client-runtime/state/usage"; import type { EnvironmentId, EnvironmentUsageSnapshot, @@ -7,11 +8,11 @@ import type { UsageModelTotals, UsageProvider, } from "@t3tools/contracts"; -import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; +import { runtime } from "../lib/runtime"; import { readPreparedConnection } from "./session"; import { useEnvironments } from "./environments"; @@ -178,21 +179,15 @@ export function mergeUsage(entries: ReadonlyArray): Merge } /** Any failure reaching one environment, flattened so callers handle one type. */ -export class UsageFetchError extends Data.TaggedError("UsageFetchError")<{ - readonly cause: unknown; -}> {} - -const fetchSnapshot = ( - httpBaseUrl: string, - sinceDate: string, -): Effect.Effect => - Effect.gen(function* () { - const client = yield* makeEnvironmentHttpApiClient(httpBaseUrl); - return yield* client.usage.snapshot({ headers: {}, payload: { sinceDate } }); - }).pipe( - Effect.provide(primaryEnvironmentHttpLayer), - Effect.mapError((cause) => new UsageFetchError({ cause })), - ); +export class UsageFetchError extends Schema.TaggedErrorClass()("UsageFetchError", { + environmentId: Schema.String, + sinceDate: Schema.String, + cause: Schema.Defect(), +}) { + override get message(): string { + return `Failed to read usage since ${this.sinceDate} from environment ${this.environmentId}.`; + } +} const isoDaysAgo = (days: number): string => { const date = new Date(); @@ -206,13 +201,20 @@ export function useUsage(windowDays: number) { const [isLoading, setIsLoading] = useState(true); const requestId = useRef(0); + // Keyed on connection phase as well as identity: an environment that finishes + // connecting after first paint must trigger a refetch, otherwise it would sit + // at "Not connected" until the user manually refreshed. + const targetKey = environments + .map((environment) => `${environment.environmentId}:${environment.connection.phase}`) + .join(","); + const targets = useMemo( () => environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, })), - [environments], + [targetKey], ); const refresh = useCallback(async () => { @@ -223,13 +225,28 @@ export function useUsage(windowDays: number) { const results = await Promise.all( targets.map(async (target): Promise => { - const connection = readPreparedConnection(target.environmentId); - if (connection === null) { + const prepared = readPreparedConnection(target.environmentId); + if (prepared === null) { return { ...target, snapshot: null, error: "Not connected" }; } try { - const snapshot = await Effect.runPromise( - fetchSnapshot(connection.httpBaseUrl, sinceDate), + // Runs on the shared runtime, which supplies the HTTP client and the + // relay DPoP signer, so the request carries this environment's own + // credential (cookie, bearer, or DPoP) rather than the primary one's. + const snapshot = await runtime.runPromise( + Effect.gen(function* () { + const signer = yield* Effect.serviceOption(ManagedRelay.ManagedRelayDpopSigner); + return yield* fetchEnvironmentUsageSnapshot({ prepared, sinceDate, signer }); + }).pipe( + Effect.mapError( + (cause) => + new UsageFetchError({ + environmentId: target.environmentId, + sinceDate, + cause, + }), + ), + ), ); return { ...target, snapshot, error: null }; } catch { diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index d1daa871652..d3b34be1bf9 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -119,6 +119,10 @@ "types": "./src/state/terminal.ts", "default": "./src/state/terminal.ts" }, + "./state/usage": { + "types": "./src/state/usageSnapshotHttp.ts", + "default": "./src/state/usageSnapshotHttp.ts" + }, "./state/threads": { "types": "./src/state/threads.ts", "default": "./src/state/threads.ts" diff --git a/packages/client-runtime/src/state/usageSnapshotHttp.ts b/packages/client-runtime/src/state/usageSnapshotHttp.ts new file mode 100644 index 00000000000..e9b107a8d50 --- /dev/null +++ b/packages/client-runtime/src/state/usageSnapshotHttp.ts @@ -0,0 +1,56 @@ +import type { EnvironmentUsageSnapshot } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; + +import type { PreparedConnection } from "../connection/model.ts"; +import { environmentEndpointUrl } from "../environment/endpoint.ts"; +import type { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; +import { + executeEnvironmentHttpRequest, + makeEnvironmentHttpApiClient, + type RemoteEnvironmentRequestError, +} from "../rpc/http.ts"; +import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; + +/** + * Scanning a host's agent session logs is disk-bound and can take seconds on a + * machine with a long history, so this allows considerably more time than the + * interactive snapshot fetches. + */ +const DEFAULT_USAGE_SNAPSHOT_TIMEOUT_MS = 30_000; + +/** + * Read one environment's usage snapshot over HTTP, authenticated with whatever + * credential that connection was prepared with (cookie, bearer, or DPoP). + * + * Each host owns the agent logs its own runs wrote, so a usage page covering + * several environments calls this once per environment and merges the results. + */ +export const fetchEnvironmentUsageSnapshot = Effect.fn( + "clientRuntime.state.fetchEnvironmentUsageSnapshot", +)(function* (input: { + readonly prepared: PreparedConnection; + readonly sinceDate: string; + readonly signer: Option.Option; + readonly timeoutMs?: number; +}) { + const requestUrl = environmentEndpointUrl(input.prepared.httpBaseUrl, "/api/usage/snapshot"); + const client = yield* makeEnvironmentHttpApiClient(input.prepared.httpBaseUrl); + const headers = yield* buildEnvironmentAuthHeaders( + input.prepared.httpAuthorization, + "GET", + requestUrl, + input.signer, + ); + return yield* executeEnvironmentHttpRequest( + requestUrl, + input.timeoutMs ?? DEFAULT_USAGE_SNAPSHOT_TIMEOUT_MS, + withEnvironmentCredentials( + input.prepared.httpAuthorization, + client.usage.snapshot({ payload: { sinceDate: input.sinceDate }, headers }), + ), + ); +}); + +export type FetchEnvironmentUsageSnapshotError = RemoteEnvironmentRequestError; +export type FetchedEnvironmentUsageSnapshot = EnvironmentUsageSnapshot; diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 6e817620c7a..f24073084f6 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -15,7 +15,7 @@ import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; export const UsageProvider = Schema.Literals(["claude", "codex"]); export type UsageProvider = typeof UsageProvider.Type; -/** An ISO date (YYYY-MM-DD) in the host's local timezone. */ +/** An ISO calendar date, `YYYY-MM-DD`, in UTC. */ export const UsageDate = TrimmedNonEmptyString; export type UsageDate = typeof UsageDate.Type; @@ -115,7 +115,10 @@ export const UsageActivitySummary = Schema.Struct({ tools: Schema.Array(UsageActivityCount), skills: Schema.Array(UsageActivityCount), subagents: Schema.Array(UsageActivityCount), - /** 24 buckets, index 0 is midnight local time. */ + /** + * 24 buckets, index 0 is midnight UTC. UTC rather than host-local so buckets + * from environments in different timezones can be summed by index. + */ turnsByHour: Schema.Array(NonNegativeInt), totalTurns: NonNegativeInt, totalThreads: NonNegativeInt, From 8afa8ee5761ee8f15b622196948e391446a670ef Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 07:16:14 -0700 Subject: [PATCH 3/4] test(server): pin Codex window-boundary billing behavior Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/usage/localAgentUsage.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/apps/server/src/usage/localAgentUsage.test.ts b/apps/server/src/usage/localAgentUsage.test.ts index dea87058e4f..42b6e959871 100644 --- a/apps/server/src/usage/localAgentUsage.test.ts +++ b/apps/server/src/usage/localAgentUsage.test.ts @@ -176,6 +176,37 @@ describe("readLocalAgentUsage", () => { expect(result.costUsd).toBe(0); }); + it("bills only the in-window increment when a Codex session predates the window", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3-usage-")); + const codex = NodePath.join(root, "codex-sessions"); + try { + await write(NodePath.join(codex, "2099", "s.jsonl"), [ + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + // Out of window: establishes the baseline without being billed. + { + ...codexTokenCount({ input: 1_000_000, cached: 0, output: 100_000 }), + timestamp: "2020-01-01T00:00:00.000Z", + }, + // In window: only the increment over the previous snapshot counts. + { + ...codexTokenCount({ input: 1_000_500, cached: 0, output: 100_050 }), + timestamp: "2026-06-01T00:00:00.000Z", + }, + ]); + const result = await readLocalAgentUsage({ + claudeDirs: [NodePath.join(root, "claude")], + codexDir: codex, + sinceDate: "2026-01-01", + }); + + const model = result.models.find((entry) => entry.model === "gpt-5.6-sol"); + expect(model?.tokens.inputTokens).toBe(500); + expect(model?.tokens.outputTokens).toBe(50); + } finally { + await NodeFSP.rm(root, { recursive: true, force: true }); + } + }); + it("does not charge Codex fast rates when the log reports no premium tier", async () => { const result = await withFixture(async ({ codex }) => { await write(NodePath.join(codex, "2099", "s.jsonl"), [ From ae37ed728b31f78c2a61a30188c7faa266760f4e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 7 Aug 2026 07:22:18 -0700 Subject: [PATCH 4/4] fix(server): bound the usage cache and stop latching Codex fast pricing Adds an end-to-end usage endpoint test and a T3CODE_AGENT_LOG_HOME override so agent log reads can be isolated. Co-Authored-By: Claude Opus 5 (1M context) --- apps/server/src/server.test.ts | 45 +++++++++++++++++++ apps/server/src/usage/UsageService.ts | 28 +++++++++++- apps/server/src/usage/localAgentUsage.test.ts | 16 +++++++ apps/server/src/usage/localAgentUsage.ts | 4 +- 4 files changed, 91 insertions(+), 2 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4ddb01e09dd..1f6b00abb16 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -3326,6 +3326,51 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.scoped, Effect.provide(NodeHttpServerTestWithWsDeflate)), ); + it.effect("serves a usage snapshot to an authenticated session", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const bearerToken = yield* getAuthenticatedBearerSessionToken(); + const usageUrl = yield* getHttpServerUrl("/api/usage/snapshot?sinceDate=2020-01-01"); + const response = yield* fetchEffect(usageUrl, { + headers: { authorization: `Bearer ${bearerToken}` }, + }); + assert.equal(response.status, 200); + + const body = yield* responseJsonEffect<{ + readonly environmentId: string; + readonly costUsd: number; + readonly models: ReadonlyArray; + readonly activity: { readonly turnsByHour: ReadonlyArray }; + readonly sources: ReadonlyArray<{ readonly provider: string }>; + }>(response); + + // Asserts the contract shape rather than values: this reads whatever + // agent logs the host actually has, which is empty on CI and populated on + // a developer machine. Both must produce a well-formed snapshot. + assert.isString(body.environmentId); + assert.isAtLeast(body.costUsd, 0); + assert.isArray(body.models); + assert.equal(body.activity.turnsByHour.length, 24); + // Both slots always report, so the client can tell "nothing used" from + // "provider not installed on this host". + assert.deepEqual(body.sources.map((source) => source.provider).toSorted(), [ + "claude", + "codex", + ]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("rejects an unauthenticated usage snapshot request", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const usageUrl = yield* getHttpServerUrl("/api/usage/snapshot"); + const response = yield* fetchEffect(usageUrl); + assert.equal(response.status, 401); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("issues short-lived websocket tickets for authenticated bearer sessions", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 59415c7a464..34a3af53867 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -1,10 +1,12 @@ import type { EnvironmentUsageSnapshot } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; +import * as Config from "effect/Config"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; @@ -15,6 +17,8 @@ import { readLocalAgentUsage } from "./localAgentUsage.ts"; const CACHE_TTL_MILLIS = Duration.toMillis(Duration.seconds(60)); const DEFAULT_WINDOW_DAYS = 30; const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/; +/** Distinct windows a client realistically asks for; anything past this evicts oldest-first. */ +const MAX_CACHE_ENTRIES = 8; const emptyTokens = { inputTokens: 0, @@ -35,6 +39,10 @@ export class UsageService extends Context.Service< export const make = Effect.gen(function* () { const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + // Agent session logs live under the OS home, not the server's base directory, + // so isolating them needs an explicit override. Tests and sandboxed runs set + // this; in normal operation it is absent and the OS home is used. + const agentLogHome = yield* Config.string("T3CODE_AGENT_LOG_HOME").pipe(Config.option); // Captured once so the returned effects carry no outstanding requirements. const sql = yield* SqlClient.SqlClient; @@ -55,7 +63,12 @@ export const make = Effect.gen(function* () { const activity = yield* readActivitySummary(`${sinceDate}T00:00:00.000Z`).pipe( Effect.provideService(SqlClient.SqlClient, sql), ); - const local = yield* Effect.tryPromise(() => readLocalAgentUsage({ sinceDate })).pipe( + const local = yield* Effect.tryPromise(() => + readLocalAgentUsage({ + sinceDate, + ...(Option.isSome(agentLogHome) ? { homeDir: agentLogHome.value } : {}), + }), + ).pipe( Effect.catchCause((cause) => Effect.logWarning("Failed to read local agent usage logs", { cause }).pipe( Effect.as(undefined), @@ -98,12 +111,25 @@ export const make = Effect.gen(function* () { if (cached !== undefined && startedAt - cached.at < CACHE_TTL_MILLIS) { return cached.snapshot; } + // Callers choose the window, so evict expired entries before inserting; + // otherwise one entry accumulates per distinct date string and the map + // grows without bound. + for (const [key, entry] of cache) { + if (startedAt - entry.at >= CACHE_TTL_MILLIS) cache.delete(key); + } + const { snapshot, complete } = yield* build(resolved); // Only cache a complete read, and stamp it on completion so a slow // scan does not immediately expire. A failed scan reports zeros once // rather than pinning the page at $0 for the whole TTL. if (complete) { const finishedAt = yield* Clock.currentTimeMillis; + // Bounded even within one TTL window, so a burst of distinct dates + // cannot pin unbounded memory until the entries age out. + if (cache.size >= MAX_CACHE_ENTRIES) { + const oldest = cache.keys().next(); + if (!oldest.done) cache.delete(oldest.value); + } cache.set(resolved, { at: finishedAt, snapshot }); } return snapshot; diff --git a/apps/server/src/usage/localAgentUsage.test.ts b/apps/server/src/usage/localAgentUsage.test.ts index 42b6e959871..e2baf26891e 100644 --- a/apps/server/src/usage/localAgentUsage.test.ts +++ b/apps/server/src/usage/localAgentUsage.test.ts @@ -221,6 +221,22 @@ describe("readLocalAgentUsage", () => { expect(result.costUsd).toBeCloseTo(pricing!.input, 6); }); + it("does not latch fast pricing onto standard turns later in the same session", async () => { + const result = await withFixture(async ({ codex }) => { + await write(NodePath.join(codex, "2099", "s.jsonl"), [ + { type: "turn_context", payload: { model: "gpt-5.6-sol", service_tier: "priority" } }, + codexTokenCount({ input: 1_000_000, cached: 0, output: 0 }), + // Back to standard: this turn must not inherit the premium multiplier. + { type: "turn_context", payload: { model: "gpt-5.6-sol", service_tier: "default" } }, + codexTokenCount({ input: 2_000_000, cached: 0, output: 0 }), + ]); + }); + + const pricing = lookupPricing("gpt-5.6-sol"); + // First 1M at the doubled rate, second 1M at the base rate. + expect(result.costUsd).toBeCloseTo(pricing!.input * 2 + pricing!.input, 6); + }); + it("resolves a dated model slug to its family but not an unrelated slug", () => { expect(lookupPricing("claude-haiku-4-5-20251001")).toEqual(lookupPricing("claude-haiku-4-5")); // `gpt-5000` starts with `gpt-5` but is a different model, not a variant. diff --git a/apps/server/src/usage/localAgentUsage.ts b/apps/server/src/usage/localAgentUsage.ts index 7adb610e4d6..17efb621f5a 100644 --- a/apps/server/src/usage/localAgentUsage.ts +++ b/apps/server/src/usage/localAgentUsage.ts @@ -357,8 +357,10 @@ async function scanCodex( if (typeof candidate === "string" && candidate.length > 0) model = candidate; const candidateCwd = payloadRecord?.["cwd"]; if (typeof candidateCwd === "string") cwd = candidateCwd; + // Recomputed per turn rather than latched: a session can mix tiers, and + // a premium turn must not price the standard turns that follow it. const tier = payloadRecord?.["service_tier"]; - if (typeof tier === "string") fast = tier === "fast" || tier === "priority"; + fast = tier === "fast" || tier === "priority"; } if (payloadRecord?.["type"] !== "token_count") continue;