diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 6093935ea..04bad06f1 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -37,6 +37,7 @@ import { probeClaudeCapabilities, readClaudeNormalAuthEmail, } from "../Layers/ClaudeProvider.ts"; +import { makeClaudeTokenUsageHistoryReader } from "../Layers/ClaudeTokenUsageHistory.ts"; import { applyClaudeRateLimitInfoToAccountUsage, fetchClaudeAccountUsage, @@ -309,6 +310,7 @@ export const ClaudeDriver: ProviderDriver = { platform: process.platform, }); const continuationGroupKey = yield* makeClaudeContinuationGroupKey(effectiveConfig); + const readTokenUsage = yield* makeClaudeTokenUsageHistoryReader(effectiveConfig); const stampIdentity = withInstanceIdentity({ instanceId, displayName, @@ -346,6 +348,7 @@ export const ClaudeDriver: ProviderDriver = { Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), Effect.provideService(Path.Path, path), ), + () => readTokenUsage, ).pipe( Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index b29d13513..b0eedfbd0 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -6,6 +6,7 @@ import { type ProviderOptionDescriptor, ProviderDriverKind, RUNTIME_MODES, + type ServerProviderAccountTokenUsage, type ServerProviderAccountUsage, type ServerProviderAuthCapabilities, type ServerProviderModel, @@ -989,6 +990,9 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings: ClaudeSettings, ) => Effect.Effect, resolveUsageAuthEmail?: (claudeSettings: ClaudeSettings) => Effect.Effect, + resolveTokenUsage?: ( + claudeSettings: ClaudeSettings, + ) => Effect.Effect, ): Effect.fn.Return< ServerProviderDraft, never, @@ -1102,8 +1106,20 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const modelCatalogSource = discoveredCatalog.hasLiveModels ? "live" : "fallback"; const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); + const tokenUsageEffect = resolveTokenUsage + ? resolveTokenUsage(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) + : Effect.succeed(undefined); if (!capabilities) { + const tokenUsage = yield* tokenUsageEffect; + const localTokenAccountUsage = tokenUsage + ? { + source: "claude-oauth-usage" as const, + checkedAt: tokenUsage.checkedAt, + limits: [], + tokenUsage, + } + : undefined; return buildServerProvider({ presentation: CLAUDE_PRESENTATION, enabled: claudeSettings.enabled, @@ -1116,6 +1132,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( version: parsedVersion, status: "warning", auth: { status: "unknown" }, + ...(localTokenAccountUsage ? { accountUsage: localTokenAccountUsage } : {}), message: "Could not verify Claude authentication status from initialization result.", }, }); @@ -1132,10 +1149,26 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const isLongLivedOAuthAuth = authMetadata?.type === "longLivedOAuthToken"; // Subscription usage comes from the Claude Code OAuth credential, which // does not exist for API-key auth — skip the lookup entirely there. - const accountUsage = - resolveAccountUsage && !isApiKeyAuth - ? yield* resolveAccountUsage(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) - : undefined; + const [accountUsage, tokenUsage] = yield* Effect.all( + [ + resolveAccountUsage && !isApiKeyAuth + ? resolveAccountUsage(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) + : Effect.succeed(undefined), + tokenUsageEffect, + ], + { concurrency: "unbounded" }, + ); + const localTokenAccountUsage = tokenUsage + ? { + source: "claude-oauth-usage" as const, + checkedAt: tokenUsage.checkedAt, + limits: [], + tokenUsage, + } + : undefined; + const combinedAccountUsage = accountUsage + ? { ...accountUsage, ...(tokenUsage ? { tokenUsage } : {}) } + : localTokenAccountUsage; const resolvedUsageEmail = resolveUsageAuthEmail && isLongLivedOAuthAuth ? yield* resolveUsageAuthEmail(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) @@ -1166,7 +1199,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( hasAccountUsage: accountUsage !== undefined, }), }, - ...(accountUsage ? { accountUsage } : {}), + ...(combinedAccountUsage ? { accountUsage: combinedAccountUsage } : {}), ...(upgradeMessage ? { message: upgradeMessage } : {}), }, }); diff --git a/apps/server/src/provider/Layers/ClaudeTokenUsageHistory.ts b/apps/server/src/provider/Layers/ClaudeTokenUsageHistory.ts new file mode 100644 index 000000000..46872480c --- /dev/null +++ b/apps/server/src/provider/Layers/ClaudeTokenUsageHistory.ts @@ -0,0 +1,292 @@ +/** + * Local Claude token history for the provider settings heatmap. + * + * Claude's OAuth usage endpoint only reports rolling subscription windows. Its + * calendar history lives on the paired computer instead: Claude Code maintains + * `stats-cache.json`, and the JSONL transcripts remain the source of truth when + * that cache has not been generated yet. This reader imports both without + * coupling the provider card to the all-environment `/usage` projection. + * + * The returned buckets are merged into the provider status cache by + * `ProviderRegistry`, which turns the initial import into a rolling 366-day + * Threadlines history across restarts. + * + * @module provider/Layers/ClaudeTokenUsageHistory + */ +import type { + ClaudeSettings, + ServerProviderAccountTokenUsage, + ServerProviderAccountTokenUsageDailyBucket, +} from "@threadlines/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; + +import { resolveClaudeHomePath } from "../Drivers/ClaudeHome.ts"; +import { + formatProviderTokenUsageDateKey, + parseProviderTokenUsageDateKey, + PROVIDER_TOKEN_USAGE_RETENTION_DAYS, + retainProviderTokenUsageBuckets, + shiftProviderTokenUsageDateKey, + summarizeProviderTokenUsageBuckets, +} from "../providerTokenUsageHistory.ts"; +import { UsageAggregator } from "../../usage/usageAggregation.ts"; +import { dedupeWithinFile, type ScanCache } from "../../usage/usageScanCache.ts"; +import { listTranscriptFiles, readTranscriptRecords } from "../../usage/usageTranscriptReader.ts"; +import { totalTokens } from "../../usage/usageTranscripts.ts"; + +const MTIME_SLACK_MS = 36 * 60 * 60 * 1000; + +interface ClaudeStatsTokenUsageHistory { + readonly dailyBuckets: ReadonlyArray; + readonly firstSessionDate?: string; + readonly lastComputedDate?: string; + readonly lifetimeTokens?: number; +} + +interface TranscriptHistoryScan { + readonly buckets: ReadonlyArray; + readonly complete: boolean; +} + +const isRecord = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const nonNegativeNumber = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.trunc(value) : undefined; + +const normalizeDateKey = (value: unknown): string | undefined => + typeof value === "string" && parseProviderTokenUsageDateKey(value) !== null ? value : undefined; + +const dateKeyFromUnknownInstant = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + const ms = Date.parse(value); + return Number.isFinite(ms) ? formatProviderTokenUsageDateKey(ms) : undefined; +}; + +const sumTokenFields = (value: unknown): number | undefined => { + if (!isRecord(value)) return undefined; + const fields = [ + value["inputTokens"], + value["outputTokens"], + value["cacheReadInputTokens"], + value["cacheCreationInputTokens"], + ]; + if (!fields.some((field) => nonNegativeNumber(field) !== undefined)) return undefined; + return fields.reduce((sum, field) => sum + (nonNegativeNumber(field) ?? 0), 0); +}; + +/** Narrow Claude Code's deliberately undocumented, rebuildable stats cache. */ +export function parseClaudeStatsTokenUsage(document: unknown): ClaudeStatsTokenUsageHistory | null { + if (!isRecord(document) || !Array.isArray(document["dailyModelTokens"])) return null; + + const tokensByDay = new Map(); + for (const entry of document["dailyModelTokens"]) { + if (!isRecord(entry)) continue; + const date = normalizeDateKey(entry["date"]); + const tokensByModel = entry["tokensByModel"]; + if (!date || !isRecord(tokensByModel)) continue; + const tokens = Object.values(tokensByModel).reduce( + (sum, value) => sum + (nonNegativeNumber(value) ?? 0), + 0, + ); + tokensByDay.set(date, (tokensByDay.get(date) ?? 0) + tokens); + } + + const modelUsage = document["modelUsage"]; + let lifetimeTokens: number | undefined; + if (isRecord(modelUsage)) { + const totals = Object.values(modelUsage) + .map(sumTokenFields) + .filter((value): value is number => value !== undefined); + if (totals.length > 0) lifetimeTokens = totals.reduce((sum, value) => sum + value, 0); + } + const firstSessionDate = dateKeyFromUnknownInstant(document["firstSessionDate"]); + const lastComputedDate = normalizeDateKey(document["lastComputedDate"]); + + return { + dailyBuckets: [...tokensByDay.entries()] + .map(([startDate, tokens]) => ({ startDate, tokens })) + .toSorted((left, right) => left.startDate.localeCompare(right.startDate)), + ...(firstSessionDate ? { firstSessionDate } : {}), + ...(lastComputedDate ? { lastComputedDate } : {}), + ...(lifetimeTokens !== undefined ? { lifetimeTokens } : {}), + }; +} + +const sumBuckets = (buckets: ReadonlyArray): number => + buckets.reduce((sum, bucket) => sum + bucket.tokens, 0); + +const clampDateKey = (value: string, minimum: string, maximum: string): string => + value < minimum ? minimum : value > maximum ? maximum : value; + +export const makeClaudeTokenUsageHistoryReader = Effect.fn("ClaudeTokenUsageHistory.makeReader")( + function* (settings: Pick) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const resolvedHomePath = yield* resolveClaudeHomePath(settings); + const nestedDataDir = path.join(resolvedHomePath, ".claude"); + const nestedDataDirExists = yield* fileSystem + .exists(nestedDataDir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + const dataDir = nestedDataDirExists ? nestedDataDir : resolvedHomePath; + const statsCachePath = path.join(dataDir, "stats-cache.json"); + const transcriptDir = path.join(dataDir, "projects"); + const fileCache: ScanCache = new Map(); + + const readStatsCache = Effect.gen(function* () { + const raw = yield* fileSystem + .readFileString(statsCachePath) + .pipe(Effect.catchCause(() => Effect.succeed(null))); + if (raw === null) return null; + try { + return parseClaudeStatsTokenUsage(JSON.parse(raw) as unknown); + } catch { + return null; + } + }); + + const scanTranscripts = ( + sinceDay: string, + untilDay: string, + ): Effect.Effect => + Effect.promise(async () => { + const sinceMs = parseProviderTokenUsageDateKey(sinceDay); + if (sinceMs === null) return { buckets: [], complete: false }; + const files = await listTranscriptFiles(transcriptDir, sinceMs - MTIME_SLACK_MS); + const livePaths = new Set(files.map((file) => file.path)); + const aggregator = new UsageAggregator({ + timeZone: "UTC", + sinceDay, + untilDay, + rates: new Map(), + }); + let complete = true; + + for (const file of files) { + const cached = fileCache.get(file.path); + let records = + cached?.provider === "claude" && + cached.size === file.size && + cached.mtimeMs === file.mtimeMs + ? cached.records + : undefined; + if (!records) { + const parsed = await readTranscriptRecords(file.path, "claude"); + if (parsed === null) { + complete = false; + continue; + } + records = dedupeWithinFile(parsed); + fileCache.set(file.path, { + provider: "claude", + size: file.size, + mtimeMs: file.mtimeMs, + records, + }); + } + for (const record of records) aggregator.add(record); + } + + for (const cachedPath of fileCache.keys()) { + if (!livePaths.has(cachedPath)) fileCache.delete(cachedPath); + } + + const tokensByDay = new Map(); + for (const bucket of aggregator.finish().buckets) { + tokensByDay.set( + bucket.day, + (tokensByDay.get(bucket.day) ?? 0) + totalTokens(bucket.totals), + ); + } + return { + complete, + buckets: [...tokensByDay.entries()] + .map(([startDate, tokens]) => ({ startDate, tokens })) + .toSorted((left, right) => left.startDate.localeCompare(right.startDate)), + }; + }); + + return Effect.gen(function* () { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const today = checkedAt.slice(0, 10); + const firstRetainedDate = shiftProviderTokenUsageDateKey( + today, + -(PROVIDER_TOKEN_USAGE_RETENTION_DAYS - 1), + ); + if (!firstRetainedDate) return undefined; + + const stats = yield* readStatsCache; + const transcriptDirExists = yield* fileSystem + .exists(transcriptDir) + .pipe(Effect.catchCause(() => Effect.succeed(false))); + if (!stats && !transcriptDirExists) return undefined; + + const scanStartDate = clampDateKey( + stats?.lastComputedDate ?? firstRetainedDate, + firstRetainedDate, + today, + ); + const scan = transcriptDirExists + ? yield* scanTranscripts(scanStartDate, today).pipe( + Effect.catchCause(() => Effect.succeed({ buckets: [], complete: false })), + ) + : { buckets: [], complete: false }; + if (!stats && !scan.complete) return undefined; + + const allBucketsByDay = new Map( + (stats?.dailyBuckets ?? []).map((bucket) => [bucket.startDate, bucket] as const), + ); + if (scan.complete) { + for (const date of allBucketsByDay.keys()) { + if (date >= scanStartDate) allBucketsByDay.delete(date); + } + for (const bucket of scan.buckets) allBucketsByDay.set(bucket.startDate, bucket); + } + const allBuckets = [...allBucketsByDay.values()].toSorted((left, right) => + left.startDate.localeCompare(right.startDate), + ); + const retainedBuckets = retainProviderTokenUsageBuckets(allBuckets, today); + + const statsReplacedTokens = sumBuckets( + (stats?.dailyBuckets ?? []).filter((bucket) => bucket.startDate >= scanStartDate), + ); + const lifetimeTokens = + stats?.lifetimeTokens !== undefined + ? Math.max(0, stats.lifetimeTokens - statsReplacedTokens) + + (scan.complete ? sumBuckets(scan.buckets) : statsReplacedTokens) + : sumBuckets(allBuckets); + const coverageStartDate = scan.complete + ? firstRetainedDate + : stats?.firstSessionDate + ? clampDateKey(stats.firstSessionDate, firstRetainedDate, today) + : retainedBuckets[0]?.startDate; + const coverageEndDate = scan.complete + ? today + : stats?.lastComputedDate + ? clampDateKey(stats.lastComputedDate, firstRetainedDate, today) + : undefined; + const completeLifetimeHistory = Boolean( + stats?.firstSessionDate && + stats.firstSessionDate >= firstRetainedDate && + coverageEndDate === today, + ); + + return { + checkedAt, + scope: "local", + ...(coverageStartDate ? { coverageStartDate } : {}), + ...(coverageEndDate ? { coverageEndDate } : {}), + completeLifetimeHistory, + dailyBuckets: retainedBuckets, + summary: summarizeProviderTokenUsageBuckets({ + buckets: allBuckets, + anchorDateKey: today, + lifetimeTokens, + }), + } satisfies ServerProviderAccountTokenUsage; + }); + }, +); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index bad7511ab..95f187f01 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -42,6 +42,7 @@ import { } from "../providerSnapshot.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import packageJson from "../../../package.json" with { type: "json" }; +import { retainProviderTokenUsageBuckets } from "../providerTokenUsageHistory.ts"; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); const CODEX_PRESENTATION = { @@ -294,17 +295,17 @@ function normalizeCodexTokenUsage( ): ServerProviderAccountTokenUsage | undefined { if (!tokenUsage) return undefined; - const dailyBuckets = (tokenUsage.dailyUsageBuckets ?? []) - .map((bucket) => { - const startDate = optionalString(bucket.startDate); - const tokens = optionalNonNegativeInt(bucket.tokens); - return startDate && tokens !== undefined ? { startDate, tokens } : undefined; - }) - .filter((bucket): bucket is { readonly startDate: string; readonly tokens: number } => - Boolean(bucket), - ) - .toSorted((left, right) => left.startDate.localeCompare(right.startDate)) - .slice(-30); + const dailyBuckets = retainProviderTokenUsageBuckets( + (tokenUsage.dailyUsageBuckets ?? []) + .map((bucket) => { + const startDate = optionalString(bucket.startDate); + const tokens = optionalNonNegativeInt(bucket.tokens); + return startDate && tokens !== undefined ? { startDate, tokens } : undefined; + }) + .filter((bucket): bucket is { readonly startDate: string; readonly tokens: number } => + Boolean(bucket), + ), + ); const summary = { ...(optionalNonNegativeInt(tokenUsage.summary.currentStreakDays) !== undefined @@ -330,6 +331,7 @@ function normalizeCodexTokenUsage( return { checkedAt, + scope: "account", dailyBuckets, summary, }; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3169874b7..1ac8b9836 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -41,6 +41,10 @@ import { type CodexAppServerProviderSnapshot, } from "./CodexProvider.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; +import { + makeClaudeTokenUsageHistoryReader, + parseClaudeStatsTokenUsage, +} from "./ClaudeTokenUsageHistory.ts"; import { CLAUDE_CODE_OAUTH_TOKEN_ENV } from "./ClaudeUsage.ts"; import { OpenCodeRuntimeLive } from "../opencodeRuntime.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "./ProviderEventLoggers.ts"; @@ -706,6 +710,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T assert.deepStrictEqual(status.accountUsage?.tokenUsage, { checkedAt: status.checkedAt, + scope: "account", dailyBuckets: [ { startDate: "2026-06-16", tokens: 1200 }, { startDate: "2026-06-17", tokens: 3400 }, @@ -721,6 +726,32 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T }), ); + it.effect("retains a calendar year of sparse Codex token usage", () => + Effect.gen(function* () { + const firstBucketMs = Date.UTC(2025, 5, 28); + const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => + Effect.succeed( + makeCodexProbeSnapshot({ + tokenUsage: { + dailyUsageBuckets: Array.from({ length: 368 }, (_, index) => ({ + startDate: new Date(firstBucketMs + index * 86_400_000) + .toISOString() + .slice(0, 10), + tokens: index + 1, + })), + summary: {}, + }, + }), + ), + ); + + const buckets = status.accountUsage?.tokenUsage?.dailyBuckets ?? []; + assert.strictEqual(buckets.length, 366); + assert.strictEqual(buckets[0]?.startDate, "2025-06-30"); + assert.strictEqual(buckets.at(-1)?.startDate, "2026-06-30"); + }), + ); + it.effect("does not expose Codex monthly spend controls for non-enterprise plans", () => Effect.gen(function* () { const status = yield* checkCodexProviderStatus(defaultCodexSettings, () => @@ -985,6 +1016,116 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ); }); + it("merges sparse token history across same-account usage refreshes", () => { + const providerBase = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { + status: "authenticated", + email: "same@example.com", + type: "chatgpt", + }, + version: "0.147.0", + models: [], + slashCommands: [], + skills: [], + } as const; + const previousProvider = { + ...providerBase, + checkedAt: "2026-08-11T12:00:00.000Z", + accountUsage: { + source: "codex-rate-limits", + checkedAt: "2026-08-11T12:00:00.000Z", + limits: [], + tokenUsage: { + checkedAt: "2026-08-11T12:00:00.000Z", + dailyBuckets: [ + { startDate: "2025-08-11", tokens: 10 }, + { startDate: "2025-08-12", tokens: 20 }, + { startDate: "2026-02-08", tokens: 30 }, + { startDate: "2026-08-01", tokens: 40 }, + ], + summary: { lifetimeTokens: 100 }, + }, + }, + } as const satisfies ServerProvider; + const nextProvider = { + ...providerBase, + checkedAt: "2026-08-12T12:00:00.000Z", + accountUsage: { + source: "codex-rate-limits", + checkedAt: "2026-08-12T12:00:00.000Z", + limits: [], + tokenUsage: { + checkedAt: "2026-08-12T12:00:00.000Z", + dailyBuckets: [ + { startDate: "2026-08-01", tokens: 45 }, + { startDate: "2026-08-11", tokens: 50 }, + ], + summary: { lifetimeTokens: 115 }, + }, + }, + } as const satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, nextProvider).accountUsage?.tokenUsage, + { + checkedAt: "2026-08-12T12:00:00.000Z", + dailyBuckets: [ + { startDate: "2025-08-12", tokens: 20 }, + { startDate: "2026-02-08", tokens: 30 }, + { startDate: "2026-08-01", tokens: 45 }, + { startDate: "2026-08-11", tokens: 50 }, + ], + summary: { lifetimeTokens: 115 }, + }, + ); + }); + + it("keeps same-account token history when a refresh only reports limits", () => { + const usage: ServerProviderAccountUsage = { + source: "codex-rate-limits", + checkedAt: "2026-08-11T12:00:00.000Z", + limits: [], + tokenUsage: { + checkedAt: "2026-08-11T12:00:00.000Z", + dailyBuckets: [{ startDate: "2026-08-10", tokens: 42 }], + summary: { lifetimeTokens: 42 }, + }, + }; + const previousProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated", email: "same@example.com", type: "chatgpt" }, + checkedAt: "2026-08-11T12:00:00.000Z", + version: "0.147.0", + models: [], + slashCommands: [], + skills: [], + accountUsage: usage, + } as const satisfies ServerProvider; + const nextProvider = { + ...previousProvider, + checkedAt: "2026-08-12T12:00:00.000Z", + accountUsage: { + source: "codex-rate-limits", + checkedAt: "2026-08-12T12:00:00.000Z", + limits: [], + }, + } as const satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, nextProvider).accountUsage?.tokenUsage, + usage.tokenUsage, + ); + }); + it("preserves authoritative live chat auth across local-only provider probes", () => { const providerBase = { instanceId: ProviderInstanceId.make("claudeAgent"), @@ -2515,6 +2656,124 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T ), ); + it.effect("attaches machine-scoped Claude token history without subscription usage", () => + Effect.gen(function* () { + const tokenUsage = { + checkedAt: "2026-08-12T12:00:00.000Z", + scope: "local" as const, + coverageStartDate: "2025-08-12", + coverageEndDate: "2026-08-12", + completeLifetimeHistory: false, + dailyBuckets: [{ startDate: "2026-08-11", tokens: 4200 }], + summary: { lifetimeTokens: 4200, peakDailyTokens: 4200 }, + }; + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ subscriptionType: "pro" }), + process.env, + undefined, + undefined, + () => Effect.succeed(tokenUsage), + ); + + assert.deepStrictEqual(status.accountUsage, { + source: "claude-oauth-usage", + checkedAt: tokenUsage.checkedAt, + limits: [], + tokenUsage, + }); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + if (args.join(" ") === "--version") { + return { stdout: "2.1.228\n", stderr: "", code: 0 }; + } + throw new Error(`Unexpected args: ${args.join(" ")}`); + }), + ), + ), + ); + + it("normalizes Claude's local stats cache into daily token buckets", () => { + assert.deepStrictEqual( + parseClaudeStatsTokenUsage({ + version: 2, + lastComputedDate: "2026-08-11", + firstSessionDate: "2025-12-03T09:30:00.000Z", + dailyModelTokens: [ + { + date: "2026-08-10", + tokensByModel: { "claude-opus-4-6": 1200, "claude-sonnet-4-6": 800 }, + }, + { date: "invalid", tokensByModel: { "claude-opus-4-6": 999 } }, + { date: "2026-08-11", tokensByModel: { "claude-opus-4-6": 3000 } }, + ], + modelUsage: { + "claude-opus-4-6": { + inputTokens: 100, + outputTokens: 200, + cacheReadInputTokens: 300, + cacheCreationInputTokens: 400, + }, + }, + totalSessions: 4, + totalMessages: 12, + }), + { + dailyBuckets: [ + { startDate: "2026-08-10", tokens: 2000 }, + { startDate: "2026-08-11", tokens: 3000 }, + ], + firstSessionDate: "2025-12-03", + lastComputedDate: "2026-08-11", + lifetimeTokens: 1000, + }, + ); + }); + + it.effect("falls back to the configured Claude home's local transcripts", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "threadlines-claude-history-", + }); + const projectDir = path.join(homePath, ".claude", "projects", "project"); + yield* fileSystem.makeDirectory(projectDir, { recursive: true }); + const now = new Date(); + yield* TestClock.setTime(now.getTime()); + const today = now.toISOString().slice(0, 10); + yield* fileSystem.writeFileString( + path.join(projectDir, "session.jsonl"), + `${JSON.stringify({ + type: "assistant", + timestamp: now.toISOString(), + sessionId: "session-1", + requestId: "request-1", + message: { + id: "message-1", + model: "claude-opus-4-6", + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 25, + cache_creation_input_tokens: 10, + }, + }, + })}\n`, + ); + + const readHistory = yield* makeClaudeTokenUsageHistoryReader({ homePath }); + const history = yield* readHistory; + + assert.strictEqual(history?.scope, "local"); + assert.strictEqual(history?.coverageEndDate, today); + assert.deepStrictEqual(history?.dailyBuckets, [{ startDate: today, tokens: 185 }]); + assert.strictEqual(history?.summary.lifetimeTokens, 185); + assert.strictEqual(history?.summary.peakDailyTokens, 185); + }), + ); + it.effect("does not request account usage for API key auth", () => Effect.gen(function* () { let usageRequested = false; diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index ef8bb1a3f..724a4c0a5 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -60,6 +60,10 @@ import { import type { ProviderInstance } from "../ProviderDriver.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; import type { ProviderSnapshotSource } from "../builtInProviderCatalog.ts"; +import { + dateKeyFromIsoDateTime, + mergeProviderTokenUsageBuckets, +} from "../providerTokenUsageHistory.ts"; const loadProviders = ( providerSources: ReadonlyArray, @@ -170,6 +174,69 @@ const shouldPreservePreviousChatAuth = ( ); }; +const canReusePreviousAccountUsage = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): boolean => { + if (!previousProvider.accountUsage || !nextProvider.enabled) return false; + if ( + previousProvider.auth.status !== "authenticated" || + nextProvider.auth.status !== "authenticated" + ) { + return false; + } + if (nextProvider.auth.type === "apiKey") return false; + if (!haveMatchingChatAuthIdentity(previousProvider, nextProvider)) return false; + if ( + previousProvider.auth.usageEmail && + nextProvider.auth.usageEmail && + previousProvider.auth.usageEmail !== nextProvider.auth.usageEmail + ) { + return false; + } + return true; +}; + +const mergeProviderAccountUsage = ( + previousProvider: ServerProvider, + nextProvider: ServerProvider, +): ServerProvider["accountUsage"] => { + const previousUsage = previousProvider.accountUsage; + const nextUsage = nextProvider.accountUsage; + if ( + !previousUsage || + !nextUsage || + !canReusePreviousAccountUsage(previousProvider, nextProvider) + ) { + return nextUsage; + } + if (previousUsage.source !== nextUsage.source) return nextUsage; + + const previousTokenUsage = previousUsage.tokenUsage; + const nextTokenUsage = nextUsage.tokenUsage; + if (!previousTokenUsage) return nextUsage; + if (!nextTokenUsage) { + return { ...nextUsage, tokenUsage: previousTokenUsage }; + } + + const anchorDateKey = dateKeyFromIsoDateTime(nextTokenUsage.checkedAt); + if (!anchorDateKey) return nextUsage; + return { + ...nextUsage, + tokenUsage: { + ...nextTokenUsage, + ...(!nextTokenUsage.coverageStartDate && previousTokenUsage.coverageStartDate + ? { coverageStartDate: previousTokenUsage.coverageStartDate } + : {}), + dailyBuckets: mergeProviderTokenUsageBuckets({ + previous: previousTokenUsage.dailyBuckets, + next: nextTokenUsage.dailyBuckets, + anchorDateKey, + }), + }, + }; +}; + export const mergeProviderSnapshot = ( previousProvider: ServerProvider | undefined, nextProvider: ServerProvider, @@ -178,6 +245,9 @@ export const mergeProviderSnapshot = ( if (previousProvider.checkedAt > nextProvider.checkedAt) return previousProvider; const preserveAccountUsage = shouldPreservePreviousAccountUsage(previousProvider, nextProvider); + const accountUsage = preserveAccountUsage + ? previousProvider.accountUsage + : mergeProviderAccountUsage(previousProvider, nextProvider); const preserveChatAuth = shouldPreservePreviousChatAuth(previousProvider, nextProvider); const previousChat = previousProvider.auth.capabilities?.chat; const previousUsage = @@ -210,7 +280,7 @@ export const mergeProviderSnapshot = ( models: mergeProviderModels(previousProvider.models, nextProvider.models, { preserveMissingPreviousModels: missingModelPreservation(nextProvider), }), - ...(preserveAccountUsage ? { accountUsage: previousProvider.accountUsage } : {}), + ...(accountUsage ? { accountUsage } : {}), ...(preserveChatFailure ? { status: previousProvider.status, @@ -225,23 +295,7 @@ const shouldPreservePreviousAccountUsage = ( nextProvider: ServerProvider, ): boolean => { if (nextProvider.accountUsage || !previousProvider.accountUsage) return false; - if (!nextProvider.enabled) return false; - if ( - previousProvider.auth.status !== "authenticated" || - nextProvider.auth.status !== "authenticated" - ) { - return false; - } - if (nextProvider.auth.type === "apiKey") return false; - if (!haveMatchingChatAuthIdentity(previousProvider, nextProvider)) return false; - if ( - previousProvider.auth.usageEmail && - nextProvider.auth.usageEmail && - previousProvider.auth.usageEmail !== nextProvider.auth.usageEmail - ) { - return false; - } - return true; + return canReusePreviousAccountUsage(previousProvider, nextProvider); }; const sanitizeHydratedProviderModels = ( diff --git a/apps/server/src/provider/providerTokenUsageHistory.ts b/apps/server/src/provider/providerTokenUsageHistory.ts new file mode 100644 index 000000000..fd428e743 --- /dev/null +++ b/apps/server/src/provider/providerTokenUsageHistory.ts @@ -0,0 +1,126 @@ +import type { + ServerProviderAccountTokenUsageDailyBucket, + ServerProviderAccountTokenUsageSummary, +} from "@threadlines/contracts"; + +const DAY_MS = 86_400_000; + +export const PROVIDER_TOKEN_USAGE_RETENTION_DAYS = 366; + +export const parseProviderTokenUsageDateKey = (dateKey: string): number | null => { + const match = /^(\d{4})-(\d{2})-(\d{2})$/u.exec(dateKey); + if (!match) return null; + + const [, year, month, day] = match; + const ms = Date.UTC(Number(year), Number(month) - 1, Number(day)); + return new Date(ms).toISOString().slice(0, 10) === dateKey ? ms : null; +}; + +export const formatProviderTokenUsageDateKey = (ms: number): string => + new Date(ms).toISOString().slice(0, 10); + +export const dateKeyFromIsoDateTime = (value: string): string | undefined => { + const ms = Date.parse(value); + return Number.isFinite(ms) ? formatProviderTokenUsageDateKey(ms) : undefined; +}; + +export const shiftProviderTokenUsageDateKey = ( + dateKey: string, + dayOffset: number, +): string | undefined => { + const ms = parseProviderTokenUsageDateKey(dateKey); + return ms === null + ? undefined + : formatProviderTokenUsageDateKey(ms + Math.trunc(dayOffset) * DAY_MS); +}; + +/** + * Keep a calendar-year window of sparse daily usage buckets. Codex omits + * inactive dates, so limiting by bucket count makes old active days disappear + * whenever a new active day is reported. + */ +export const retainProviderTokenUsageBuckets = ( + buckets: ReadonlyArray, + anchorDateKey?: string, +): ReadonlyArray => { + const validBuckets = buckets + .map((bucket) => { + const ms = parseProviderTokenUsageDateKey(bucket.startDate); + return ms === null ? undefined : { bucket, ms }; + }) + .filter( + ( + entry, + ): entry is { + readonly bucket: ServerProviderAccountTokenUsageDailyBucket; + readonly ms: number; + } => entry !== undefined, + ); + if (validBuckets.length === 0) return []; + + const parsedAnchorMs = anchorDateKey ? parseProviderTokenUsageDateKey(anchorDateKey) : null; + const anchorMs = parsedAnchorMs ?? Math.max(...validBuckets.map((entry) => entry.ms)); + const firstRetainedMs = anchorMs - (PROVIDER_TOKEN_USAGE_RETENTION_DAYS - 1) * DAY_MS; + const byDate = new Map(); + + for (const { bucket, ms } of validBuckets) { + if (ms < firstRetainedMs || ms > anchorMs) continue; + byDate.set(bucket.startDate, bucket); + } + + return [...byDate.values()].toSorted((left, right) => + left.startDate.localeCompare(right.startDate), + ); +}; + +export const mergeProviderTokenUsageBuckets = (input: { + readonly previous: ReadonlyArray; + readonly next: ReadonlyArray; + readonly anchorDateKey: string; +}): ReadonlyArray => + retainProviderTokenUsageBuckets([...input.previous, ...input.next], input.anchorDateKey); + +export const summarizeProviderTokenUsageBuckets = (input: { + readonly buckets: ReadonlyArray; + readonly anchorDateKey: string; + readonly lifetimeTokens?: number; + readonly longestRunningTurnSec?: number; +}): ServerProviderAccountTokenUsageSummary => { + const activeDays = input.buckets + .filter((bucket) => bucket.tokens > 0) + .map((bucket) => parseProviderTokenUsageDateKey(bucket.startDate)) + .filter((ms): ms is number => ms !== null) + .toSorted((left, right) => left - right); + const activeDaySet = new Set(activeDays); + let longestStreakDays = 0; + let runningStreakDays = 0; + let previousMs: number | undefined; + for (const ms of activeDays) { + runningStreakDays = + previousMs !== undefined && ms - previousMs === DAY_MS ? runningStreakDays + 1 : 1; + longestStreakDays = Math.max(longestStreakDays, runningStreakDays); + previousMs = ms; + } + + const anchorMs = parseProviderTokenUsageDateKey(input.anchorDateKey); + let currentStreakDays = 0; + if (anchorMs !== null) { + let cursorMs = activeDaySet.has(anchorMs) ? anchorMs : anchorMs - DAY_MS; + while (activeDaySet.has(cursorMs)) { + currentStreakDays += 1; + cursorMs -= DAY_MS; + } + } + + const observedTokens = input.buckets.reduce((sum, bucket) => sum + bucket.tokens, 0); + const peakDailyTokens = Math.max(0, ...input.buckets.map((bucket) => bucket.tokens)); + return { + lifetimeTokens: Math.max(0, Math.trunc(input.lifetimeTokens ?? observedTokens)), + peakDailyTokens, + ...(input.longestRunningTurnSec !== undefined && input.longestRunningTurnSec >= 0 + ? { longestRunningTurnSec: Math.trunc(input.longestRunningTurnSec) } + : {}), + ...(currentStreakDays > 0 ? { currentStreakDays } : {}), + ...(longestStreakDays > 0 ? { longestStreakDays } : {}), + }; +}; diff --git a/apps/web/src/components/ProviderUsageDashboard.tsx b/apps/web/src/components/ProviderUsageDashboard.tsx index ab669d007..7f2cbe08b 100644 --- a/apps/web/src/components/ProviderUsageDashboard.tsx +++ b/apps/web/src/components/ProviderUsageDashboard.tsx @@ -6,6 +6,7 @@ import { GaugeIcon, RotateCcwIcon } from "lucide-react"; import { formatProviderTokenCount, type ProviderAccountTokenUsageBucketPresentation, + type ProviderAccountTokenUsagePresentation, type ProviderAccountUsagePresentation, type ProviderAccountUsageWindowPresentation, } from "../lib/providerUsage"; @@ -26,7 +27,7 @@ const TOKEN_ACTIVITY_LEGEND_LEVELS = [0, 20, 40, 65, 90] as const; interface TokenActivityCell { readonly dateKey: string; - readonly value: number; + readonly value: number | null; readonly intensityPercent: number; readonly tooltip: string; } @@ -41,6 +42,7 @@ interface TokenActivityWeek { interface TokenActivityDay { readonly dateKey: string; readonly ms: number; + readonly reported: boolean; readonly dailyTokens: number; readonly weeklyTokens: number; readonly cumulativeTokens: number; @@ -83,7 +85,16 @@ function formatMonthLabel(ms: number): string { }).format(new Date(ms)); } -function formatTokenActivityTooltip(mode: TokenActivityMode, ms: number, value: number): string { +function formatTokenActivityTooltip( + mode: TokenActivityMode, + ms: number, + value: number | null, +): string { + if (value === null) { + return mode === "weekly" + ? `Usage unavailable for week of ${formatLongDate(startOfWeekUtc(ms))}` + : `Usage unavailable on ${formatLongDate(ms)}`; + } const tokenLabel = formatProviderTokenCount(value) ?? "0"; if (mode === "weekly") { return `${tokenLabel} tokens on week of ${formatLongDate(startOfWeekUtc(ms))}`; @@ -98,8 +109,11 @@ function formatTokenActivityWeekTooltip( mode: TokenActivityMode, weekStartMs: number, weekEndMs: number, - value: number, + value: number | null, ): string { + if (value === null) { + return `Usage unavailable for week of ${formatLongDate(weekStartMs)}`; + } const tokenLabel = formatProviderTokenCount(value) ?? "0"; if (mode === "weekly") { return `${tokenLabel} tokens on week of ${formatLongDate(weekStartMs)}`; @@ -110,7 +124,8 @@ function formatTokenActivityWeekTooltip( return formatTokenActivityTooltip(mode, weekEndMs, value); } -function tokenActivityColorClass(intensityPercent: number): string { +function tokenActivityColorClass(intensityPercent: number, reported = true): string { + if (!reported) return "bg-muted/25"; if (intensityPercent <= 0) return "bg-muted/70"; if (intensityPercent < 15) return "bg-primary-graph/30"; if (intensityPercent < 30) return "bg-primary-graph/45"; @@ -127,9 +142,11 @@ function scaleTokenActivityIntensity(value: number, maxValue: number): number { } function buildTokenActivityWeeks( - buckets: ReadonlyArray, + tokenUsage: ProviderAccountTokenUsagePresentation, mode: TokenActivityMode, + anchorMs: number, ): ReadonlyArray { + const buckets = tokenUsage.buckets; const datedBuckets = buckets .map((bucket) => { const ms = parseDateKey(bucket.startDate); @@ -145,9 +162,31 @@ function buildTokenActivityWeeks( ); if (datedBuckets.length === 0) return []; - const latestMs = Math.max(...datedBuckets.map((entry) => entry.ms)); - const endMs = endOfWeekUtc(latestMs); + const anchorDate = new Date(anchorMs); + const anchorDayMs = Date.UTC( + anchorDate.getUTCFullYear(), + anchorDate.getUTCMonth(), + anchorDate.getUTCDate(), + ); + const endMs = endOfWeekUtc(anchorDayMs); const startMs = startOfWeekUtc(endMs) - 51 * 7 * DAY_MS; + const checkedAtMs = Date.parse(tokenUsage.checkedAt); + const coverageStartMs = tokenUsage.coverageStartDate + ? parseDateKey(tokenUsage.coverageStartDate) + : null; + const coverageEndMs = tokenUsage.coverageEndDate + ? parseDateKey(tokenUsage.coverageEndDate) + : null; + const reportedStartMs = + coverageStartMs ?? + (tokenUsage.completeLifetimeHistory + ? startMs + : Math.min(...datedBuckets.map((entry) => entry.ms))); + const reportedEndMs = + coverageEndMs ?? + (Number.isNaN(checkedAtMs) + ? Math.max(...datedBuckets.map((entry) => entry.ms)) + : Math.min(anchorDayMs, checkedAtMs)); const tokenByDate = new Map( datedBuckets.map((entry) => [entry.bucket.startDate, entry.bucket.tokens]), ); @@ -171,6 +210,7 @@ function buildTokenActivityWeeks( days.push({ dateKey, ms, + reported: ms >= reportedStartMs && ms <= reportedEndMs, dailyTokens, weeklyTokens: weeklyTotals.get(formatDateKey(startOfWeekUtc(ms))) ?? 0, cumulativeTokens, @@ -179,25 +219,30 @@ function buildTokenActivityWeeks( const maxValue = Math.max( 0, - ...days.map((day) => - mode === "daily" - ? day.dailyTokens - : mode === "weekly" - ? day.weeklyTokens - : day.cumulativeTokens, - ), + ...days + .filter((day) => day.reported) + .map((day) => + mode === "daily" + ? day.dailyTokens + : mode === "weekly" + ? day.weeklyTokens + : day.cumulativeTokens, + ), ); const weeks: TokenActivityWeek[] = []; let previousMonth = -1; for (let weekStartMs = startMs; weekStartMs <= endMs; weekStartMs += 7 * DAY_MS) { const cells: TokenActivityCell[] = []; const weekDays = days.slice(weeks.length * 7, weeks.length * 7 + 7); + const reportedWeekDays = weekDays.filter((day) => day.reported); const weekValue = - mode === "weekly" - ? (weekDays[0]?.weeklyTokens ?? 0) - : mode === "cumulative" - ? (weekDays[weekDays.length - 1]?.cumulativeTokens ?? 0) - : weekDays.reduce((sum, day) => sum + day.dailyTokens, 0); + reportedWeekDays.length === 0 + ? null + : mode === "weekly" + ? (reportedWeekDays[0]?.weeklyTokens ?? 0) + : mode === "cumulative" + ? (reportedWeekDays[reportedWeekDays.length - 1]?.cumulativeTokens ?? 0) + : reportedWeekDays.reduce((sum, day) => sum + day.dailyTokens, 0); const weekTooltip = formatTokenActivityWeekTooltip( mode, weekDays[0]?.ms ?? weekStartMs, @@ -212,8 +257,9 @@ function buildTokenActivityWeeks( monthLabel = formatMonthLabel(day.ms); previousMonth = month; } - const value = - mode === "daily" + const value = !day.reported + ? null + : mode === "daily" ? day.dailyTokens : mode === "weekly" ? day.dailyTokens > 0 @@ -225,7 +271,7 @@ function buildTokenActivityWeeks( cells.push({ dateKey: day.dateKey, value, - intensityPercent: scaleTokenActivityIntensity(value, maxValue), + intensityPercent: scaleTokenActivityIntensity(value ?? 0, maxValue), tooltip: formatTokenActivityTooltip(mode, day.ms, value), }); } @@ -237,7 +283,7 @@ function buildTokenActivityWeeks( }); } - const latestMonthLabel = formatMonthLabel(latestMs); + const latestMonthLabel = formatMonthLabel(anchorDayMs); const firstWeek = weeks[0]; if (firstWeek?.monthLabel === latestMonthLabel) { weeks[0] = { @@ -263,7 +309,7 @@ function TokenActivitySquare(props: { readonly cell: TokenActivityCell }) { className={cn( "block aspect-square w-full min-w-0 cursor-default rounded-[var(--app-radius-tiny)] transition-[filter] duration-150 ease-out", "hover:brightness-125", - tokenActivityColorClass(props.cell.intensityPercent), + tokenActivityColorClass(props.cell.intensityPercent, props.cell.value !== null), )} /> } @@ -311,7 +357,7 @@ function TokenActivityWeekColumn(props: { className={cn( "block aspect-square w-full min-w-0 rounded-[var(--app-radius-tiny)] transition-[filter] duration-150 ease-out", "group-hover:brightness-125", - tokenActivityColorClass(cell.intensityPercent), + tokenActivityColorClass(cell.intensityPercent, cell.value !== null), )} /> ))} @@ -425,10 +471,14 @@ export function ProviderUsageDashboard(props: { }) { const [activityMode, setActivityMode] = useState("daily"); const [activityRange, setActivityRange] = useState("13-weeks"); + const [activityAnchorMs] = useState(() => Date.now()); const tokenUsage = props.usage.tokenUsage ?? null; const activityWeeks = useMemo( - () => buildTokenActivityWeeks(tokenUsage?.buckets ?? [], activityMode), - [activityMode, tokenUsage?.buckets], + () => + tokenUsage === null + ? [] + : buildTokenActivityWeeks(tokenUsage, activityMode, activityAnchorMs), + [activityAnchorMs, activityMode, tokenUsage], ); const mobileActivityWeeks = activityRange === "13-weeks" ? activityWeeks.slice(-13) : activityWeeks; @@ -524,7 +574,12 @@ export function ProviderUsageDashboard(props: { ) : null} {tokenUsage ? ( -
+
{tokenUsage.summary.length > 0 ? (
{tokenUsage.summary.map((entry, index) => { @@ -556,7 +611,16 @@ export function ProviderUsageDashboard(props: {
-

Token activity

+
+

+ {tokenUsage.scope === "local" ? "Local token activity" : "Token activity"} +

+ {tokenUsage.scope === "local" ? ( +

+ Observed on this computer from Claude Code history. +

+ ) : null} +
{(["daily", "weekly", "cumulative"] as const).map((mode) => (
) : null} diff --git a/apps/web/src/components/settings/SettingsPanels.browser.tsx b/apps/web/src/components/settings/SettingsPanels.browser.tsx index 22fad5891..fdac7c85c 100644 --- a/apps/web/src/components/settings/SettingsPanels.browser.tsx +++ b/apps/web/src/components/settings/SettingsPanels.browser.tsx @@ -418,6 +418,31 @@ function createClaudeProvider(): ServerProvider { }; } +function createClaudeProviderWithTokenHistory(): ServerProvider { + const checkedAt = new Date().toISOString(); + return { + ...createClaudeProvider(), + checkedAt, + accountUsage: { + source: "claude-oauth-usage", + checkedAt, + limits: [], + tokenUsage: { + checkedAt, + scope: "local", + coverageStartDate: utcDateKeyAtOffset(-365), + coverageEndDate: utcDateKeyAtOffset(0), + completeLifetimeHistory: false, + dailyBuckets: [ + { startDate: utcDateKeyAtOffset(-40), tokens: 1000 }, + { startDate: utcDateKeyAtOffset(-38), tokens: 3000 }, + ], + summary: { lifetimeTokens: 4000, peakDailyTokens: 3000 }, + }, + }, + }; +} + const CLAUDE_INSTALL_GUIDE_MESSAGE = "Claude Agent CLI (`claude`) is not installed or not on PATH. Install Claude Code from https://claude.com/product/claude-code and run `claude` to sign in."; @@ -506,6 +531,53 @@ function createCodexProviderWithResetCredits(): ServerProvider { }; } +function utcDateKeyAtOffset(dayOffset: number): string { + const now = new Date(); + return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + dayOffset)) + .toISOString() + .slice(0, 10); +} + +function formatTokenActivityTestDate(dateKey: string): string { + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + year: "numeric", + timeZone: "UTC", + }).format(new Date(`${dateKey}T00:00:00.000Z`)); +} + +function createCodexProviderWithTokenHistory(): ServerProvider { + const checkedAt = new Date().toISOString(); + return { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + displayName: "Codex", + enabled: true, + installed: true, + version: "0.147.0", + status: "ready", + auth: { status: "authenticated", type: "chatgpt" }, + checkedAt, + models: [], + slashCommands: [], + skills: [], + accountUsage: { + source: "codex-rate-limits", + checkedAt, + limits: [], + tokenUsage: { + checkedAt, + dailyBuckets: [ + { startDate: utcDateKeyAtOffset(-40), tokens: 1_000 }, + { startDate: utcDateKeyAtOffset(-38), tokens: 3_000 }, + ], + summary: { lifetimeTokens: 4_000 }, + }, + }, + }; +} + function makeUtc(value: string) { return DateTime.makeUnsafe(value); } @@ -1872,6 +1944,77 @@ describe("GeneralSettingsPanel observability", () => { await expect.element(page.getByText("1 additional reset")).toBeInTheDocument(); }); + it("fills a complete sparse lifetime history with zero-usage dates through today", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [createCodexProviderWithTokenHistory()], + }); + + mounted = await renderWithTestRouter( + + + , + ); + + await page.getByLabelText("Toggle Codex details").click(); + await page.getByRole("button", { name: "Usage" }).click(); + + const beforeFirstActivity = formatTokenActivityTestDate(utcDateKeyAtOffset(-41)); + const missingActivityDate = formatTokenActivityTestDate(utcDateKeyAtOffset(-39)); + const today = formatTokenActivityTestDate(utcDateKeyAtOffset(0)); + const visibleActivityGrid = page.getByLabelText("Token history daily token activity").last(); + await expect + .element(visibleActivityGrid.getByLabelText(`0 tokens on ${beforeFirstActivity}`)) + .toBeVisible(); + await expect + .element(visibleActivityGrid.getByLabelText(`0 tokens on ${missingActivityDate}`)) + .toBeVisible(); + await expect.element(visibleActivityGrid.getByLabelText(`0 tokens on ${today}`)).toBeVisible(); + }); + + it("shows paired-computer Claude token history with an all-machines link", async () => { + setServerConfigSnapshot({ + ...createBaseServerConfig(), + providers: [createClaudeProviderWithTokenHistory()], + }); + + mounted = await renderWithTestRouter( + + + , + ); + + await page.getByLabelText("Toggle Claude details").click(); + await page.getByRole("button", { name: "Usage" }).click(); + + await expect.element(page.getByText("Local token activity").first()).toBeVisible(); + await expect + .element(page.getByText("Observed on this computer from Claude Code history.")) + .toBeVisible(); + const allMachinesLink = page.getByRole("link", { name: "View all machines" }); + await expect.element(allMachinesLink).toHaveAttribute("href", "/usage"); + const root = document.documentElement; + const hadDarkClass = root.classList.contains("dark"); + root.classList.add("dark"); + try { + await allMachinesLink.hover(); + const expectedHoverColor = getComputedStyle(root) + .getPropertyValue("--primary-readable") + .trim(); + await vi.waitFor(() => { + expect(getComputedStyle(allMachinesLink.element()).color).toBe(expectedHoverColor); + }); + } finally { + if (!hadDarkClass) root.classList.remove("dark"); + } + + const today = formatTokenActivityTestDate(utcDateKeyAtOffset(0)); + const visibleActivityGrid = page + .getByLabelText("Local token activity daily token activity") + .last(); + await expect.element(visibleActivityGrid.getByLabelText(`0 tokens on ${today}`)).toBeVisible(); + }); + it("configures Claude fallback models from the provider models list", async () => { const updateSettings = vi .fn() diff --git a/apps/web/src/lib/providerUsage.test.ts b/apps/web/src/lib/providerUsage.test.ts index fd07e3168..f62f8d9b5 100644 --- a/apps/web/src/lib/providerUsage.test.ts +++ b/apps/web/src/lib/providerUsage.test.ts @@ -228,6 +228,9 @@ describe("deriveProviderAccountUsagePresentation", () => { }, tokenUsage: { label: "Token history", + checkedAt: "2026-06-18T00:00:00.000Z", + scope: "account", + completeLifetimeHistory: true, summary: [ { key: "lifetimeTokens", label: "Lifetime tokens", value: "4.6k" }, { key: "peakDailyTokens", label: "Peak tokens", value: "3.4k" }, @@ -256,6 +259,35 @@ describe("deriveProviderAccountUsagePresentation", () => { }); }); + it("labels machine-scoped Claude token history honestly", () => { + const usage: ServerProviderAccountUsage = { + source: "claude-oauth-usage", + checkedAt: "2026-08-12T12:00:00.000Z", + limits: [], + tokenUsage: { + checkedAt: "2026-08-12T12:00:00.000Z", + scope: "local", + coverageStartDate: "2025-08-12", + coverageEndDate: "2026-08-12", + completeLifetimeHistory: false, + dailyBuckets: [{ startDate: "2026-08-11", tokens: 4200 }], + summary: { lifetimeTokens: 4200, peakDailyTokens: 4200 }, + }, + }; + + expect(deriveProviderAccountUsagePresentation(usage)?.tokenUsage).toMatchObject({ + label: "Local token activity", + scope: "local", + coverageStartDate: "2025-08-12", + coverageEndDate: "2026-08-12", + completeLifetimeHistory: false, + summary: [ + { key: "lifetimeTokens", label: "Tracked tokens", value: "4.2k" }, + { key: "peakDailyTokens", label: "Peak tokens", value: "4.2k" }, + ], + }); + }); + it("does not add a monthly spend-control row to normal Codex usage windows", () => { const usage: ServerProviderAccountUsage = { source: "codex-rate-limits", diff --git a/apps/web/src/lib/providerUsage.ts b/apps/web/src/lib/providerUsage.ts index a6c04551b..b1e0baf80 100644 --- a/apps/web/src/lib/providerUsage.ts +++ b/apps/web/src/lib/providerUsage.ts @@ -111,6 +111,11 @@ export interface ProviderAccountTokenUsageSummaryPresentation { export interface ProviderAccountTokenUsagePresentation { readonly label: string; + readonly checkedAt: string; + readonly scope: "account" | "local"; + readonly coverageStartDate?: string; + readonly coverageEndDate?: string; + readonly completeLifetimeHistory: boolean; readonly summary: ReadonlyArray; readonly buckets: ReadonlyArray; } @@ -386,6 +391,11 @@ function formatTokenUsagePresentation( ): ProviderAccountTokenUsagePresentation | undefined { if (!usage) return undefined; + const reportedLifetimeTokens = usage.summary.lifetimeTokens; + const bucketLifetimeTokens = usage.dailyBuckets.reduce( + (total, bucket) => total + bucket.tokens, + 0, + ); const peakTokens = Math.max(0, ...usage.dailyBuckets.map((bucket) => bucket.tokens)); const buckets = usage.dailyBuckets.map((bucket) => ({ startDate: bucket.startDate, @@ -399,7 +409,11 @@ function formatTokenUsagePresentation( const summary: ProviderAccountTokenUsageSummaryPresentation[] = []; const lifetimeTokens = formatProviderTokenCount(usage.summary.lifetimeTokens); if (lifetimeTokens) { - summary.push({ key: "lifetimeTokens", label: "Lifetime tokens", value: lifetimeTokens }); + summary.push({ + key: "lifetimeTokens", + label: usage.scope === "local" ? "Tracked tokens" : "Lifetime tokens", + value: lifetimeTokens, + }); } const peakDailyTokens = formatProviderTokenCount(usage.summary.peakDailyTokens); if (peakDailyTokens) { @@ -420,7 +434,14 @@ function formatTokenUsagePresentation( if (summary.length === 0 && buckets.length === 0) return undefined; return { - label: "Token history", + label: usage.scope === "local" ? "Local token activity" : "Token history", + checkedAt: usage.checkedAt, + scope: usage.scope ?? "account", + ...(usage.coverageStartDate ? { coverageStartDate: usage.coverageStartDate } : {}), + ...(usage.coverageEndDate ? { coverageEndDate: usage.coverageEndDate } : {}), + completeLifetimeHistory: + usage.completeLifetimeHistory ?? + (reportedLifetimeTokens !== undefined && bucketLifetimeTokens === reportedLifetimeTokens), summary, buckets, }; diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index c13c235ee..815b86c58 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -187,6 +187,10 @@ describe("ServerProvider", () => { }, tokenUsage: { checkedAt: "2026-04-10T00:00:00.000Z", + scope: "local", + coverageStartDate: "2025-04-10", + coverageEndDate: "2026-04-10", + completeLifetimeHistory: false, dailyBuckets: [ { startDate: "2026-04-09", @@ -228,5 +232,9 @@ describe("ServerProvider", () => { expect(parsed.accountUsage?.rateLimitResetCredits?.credits?.[0]?.expiresAt).toBe(1_778_889_600); expect(parsed.accountUsage?.tokenUsage?.summary.lifetimeTokens).toBe(1200000); expect(parsed.accountUsage?.tokenUsage?.dailyBuckets[0]?.tokens).toBe(1200); + expect(parsed.accountUsage?.tokenUsage?.scope).toBe("local"); + expect(parsed.accountUsage?.tokenUsage?.coverageStartDate).toBe("2025-04-10"); + expect(parsed.accountUsage?.tokenUsage?.coverageEndDate).toBe("2026-04-10"); + expect(parsed.accountUsage?.tokenUsage?.completeLifetimeHistory).toBe(false); }); }); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 444327615..42f8484d2 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -193,8 +193,19 @@ export const ServerProviderAccountTokenUsageSummary = Schema.Struct({ export type ServerProviderAccountTokenUsageSummary = typeof ServerProviderAccountTokenUsageSummary.Type; +export const ServerProviderAccountTokenUsageScope = Schema.Literals(["account", "local"]); +export type ServerProviderAccountTokenUsageScope = typeof ServerProviderAccountTokenUsageScope.Type; + export const ServerProviderAccountTokenUsage = Schema.Struct({ checkedAt: IsoDateTime, + /** Whether the history represents an account or activity observed locally. */ + scope: Schema.optional(ServerProviderAccountTokenUsageScope), + /** First calendar day for which zero activity is also authoritative. */ + coverageStartDate: Schema.optional(TrimmedNonEmptyString), + /** Last calendar day for which zero activity is also authoritative. */ + coverageEndDate: Schema.optional(TrimmedNonEmptyString), + /** True when the retained buckets cover the provider's complete lifetime. */ + completeLifetimeHistory: Schema.optional(Schema.Boolean), dailyBuckets: Schema.Array(ServerProviderAccountTokenUsageDailyBucket), summary: ServerProviderAccountTokenUsageSummary, });