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/server.ts b/apps/server/src/server.ts index 1d824afbd1b..41068a70322 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 * 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"; @@ -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(UsageService.layer.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..34a3af53867 --- /dev/null +++ b/apps/server/src/usage/UsageService.ts @@ -0,0 +1,140 @@ +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"; +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 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, + 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 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; + + // 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 } + >(); + + /** 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, + ...(Option.isSome(agentLogHome) ? { homeDir: agentLogHome.value } : {}), + }), + ).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 { + 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; + + const startedAt = yield* Clock.currentTimeMillis; + const cached = cache.get(resolved); + 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; + }), + }; +}); + +export const layer = Layer.effect(UsageService, make); diff --git a/apps/server/src/usage/activityUsage.ts b/apps/server/src/usage/activityUsage.ts new file mode 100644 index 00000000000..c2dad3f4338 --- /dev/null +++ b/apps/server/src/usage/activityUsage.ts @@ -0,0 +1,157 @@ +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} + `; + + // 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, + 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) + -- 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>` + 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..0a97d5d35c4 --- /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 * as 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.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..e2baf26891e --- /dev/null +++ b/apps/server/src/usage/localAgentUsage.test.ts @@ -0,0 +1,253 @@ +// 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("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"), [ + { 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("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. + expect(lookupPricing("gpt-5000-turbo")).toBeUndefined(); + }); + + 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..17efb621f5a --- /dev/null +++ b/apps/server/src/usage/localAgentUsage.ts @@ -0,0 +1,573 @@ +// 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); + // One day of slack: mtime is UTC while sinceDate is a calendar day, and a + // boundary file must never be dropped just because the two disagree. + const cutoff = new Date(`${sinceDate}T00:00:00.000Z`); + cutoff.setUTCDate(cutoff.getUTCDate() - 1); + return info.mtime < cutoff; + } 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; + // 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"]; + 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; + // 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"]; + fast = tier === "fast" || tier === "priority"; + } + 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, + }, + 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..3dfc0577969 --- /dev/null +++ b/apps/server/src/usage/pricing.ts @@ -0,0 +1,109 @@ +/** + * 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); + +/** + * 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 family = SLUGS_BY_LENGTH.find((candidate) => matchesFamily(slug, candidate)); + return family === undefined ? undefined : PRICING[family]; +} + +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..7ba17bfe35b --- /dev/null +++ b/apps/web/src/components/settings/UsageSettings.tsx @@ -0,0 +1,468 @@ +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)}%`; + +/** 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 ( +
+
+ {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: 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 ( +
+
+ {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..336381e96d5 --- /dev/null +++ b/apps/web/src/state/usage.ts @@ -0,0 +1,270 @@ +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { fetchEnvironmentUsageSnapshot } from "@t3tools/client-runtime/state/usage"; +import type { + EnvironmentId, + EnvironmentUsageSnapshot, + UsageActivityCount, + UsageDailyTotals, + UsageModelTotals, + UsageProvider, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { runtime } from "../lib/runtime"; +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 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(); + 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); + + // 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, + })), + [targetKey], + ); + + 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 prepared = readPreparedConnection(target.environmentId); + if (prepared === null) { + return { ...target, snapshot: null, error: "Not connected" }; + } + try { + // 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 { + 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/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/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..f24073084f6 --- /dev/null +++ b/packages/contracts/src/usage.ts @@ -0,0 +1,158 @@ +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 calendar date, `YYYY-MM-DD`, in UTC. */ +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 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, + 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), +};