diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index 9d728c88cf42..27c3a3ff3efe 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -7,7 +7,12 @@ import * as NodePath from "node:path"; import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; -import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + UsageDay, + type UsageSummaryInput, +} from "@t3tools/contracts"; import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -16,6 +21,7 @@ import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Scheduler from "effect/Scheduler"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -23,6 +29,8 @@ import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as UsageService from "./UsageService.ts"; +const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { return `${JSON.stringify({ type: "assistant", @@ -71,6 +79,7 @@ const serviceLayers = (input: { readonly onRatesFetch?: () => void; /** Defaults to an unparsable document so every scan retries the fetch. */ readonly ratesDocument?: unknown; + readonly environment?: NodeJS.ProcessEnv; }) => ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( Layer.provideMerge(NodeServices.layer), @@ -89,7 +98,10 @@ const serviceLayers = (input: { ), ), Layer.provideMerge( - Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }), + Layer.succeed(HostProcessEnvironment, { + GROK_HOME: NodePath.join(input.home, "grok"), + ...input.environment, + }), ), ); @@ -98,6 +110,228 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens } describe("UsageService", () => { + it.live("reads configured and disabled accounts once across shared and aliased homes", () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const codexHome = NodePath.join(home, "codex-account"); + const alias = NodePath.join(home, "codex-alias"); + const claudeHome = NodePath.join(home, "claude-account"); + const grokHome = NodePath.join(home, "grok-account"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 5)); + await NodeFSP.mkdir(NodePath.join(claudeHome, "projects"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(claudeHome, "projects", "session.jsonl"), + claudeLine(2, 7), + ); + await NodeFSP.mkdir(NodePath.join(codexHome, "sessions"), { recursive: true }); + await NodeFSP.symlink(codexHome, alias, "junction"); + await NodeFSP.writeFile( + NodePath.join(codexHome, "sessions", "rollout.jsonl"), + [ + { type: "session_meta", payload: { id: "codex-account-session" } }, + { type: "turn_context", payload: { model: "gpt-5.6-sol" } }, + { + type: "event_msg", + timestamp: "2026-08-01T10:00:00Z", + payload: { + type: "token_count", + info: { last_token_usage: { input_tokens: 10, output_tokens: 11 } }, + }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + await NodeFSP.mkdir(NodePath.join(grokHome, "sessions", "session"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(grokHome, "sessions", "session", "updates.jsonl"), + encodeUnknownJsonString({ + timestamp: Date.parse("2026-08-01T10:00:00Z") / 1000, + method: "_x.ai/session/update", + params: { + sessionId: "grok-account-session", + update: { + sessionUpdate: "turn_completed", + prompt_id: "prompt-1", + usage: { inputTokens: 10, outputTokens: 13 }, + }, + }, + }) + "\n", + ); + }); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-accounts-test", + home, + settings: { + ...settings, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + environment: [{ name: "CLAUDE_CONFIG_DIR", value: claudeHome, sensitive: false }], + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }], + }, + [ProviderInstanceId.make("codex-alias")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: alias }, + }, + [ProviderInstanceId.make("codex-shadow")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome, shadowHomePath: NodePath.join(home, "shadow") }, + environment: [ + { name: "CODEX_HOME", value: NodePath.join(home, "ignored"), sensitive: false }, + ], + }, + [ProviderInstanceId.make("grok-work")]: { + driver: ProviderDriverKind.make("grok"), + environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }], + }, + }, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(summary), 36); + const sources = summary.sources.filter((source) => source.status === "ok"); + assert.strictEqual(sources.length, 4); + assert.strictEqual( + sources.reduce((sum, source) => sum + source.scannedFiles, 0), + 4, + ); + assert.strictEqual( + sources.filter((source) => source.fingerprint.provider === "codex").length, + 1, + ); + }).pipe(Effect.scoped), + ); + + it.live( + "uses explicit account settings before environment and legacy homes, then refreshes them", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + const configured = NodePath.join(home, "configured"); + const environmentHome = NodePath.join(home, "environment"); + yield* Effect.promise(async () => { + await NodeFSP.writeFile(transcript, claudeLine(1, 100)); + for (const [index, root] of [configured, environmentHome].entries()) { + await NodeFSP.mkdir(NodePath.join(root, "projects"), { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(root, "projects", "session.jsonl"), + claudeLine(index + 2, index + 7), + ); + } + await NodeFSP.mkdir(NodePath.join(configured, ".claude", "projects"), { + recursive: true, + }); + await NodeFSP.writeFile( + NodePath.join(configured, ".claude", "projects", "wrong.jsonl"), + claudeLine(4, 1000), + ); + }); + yield* Effect.gen(function* () { + const settingsService = yield* ServerSettings.ServerSettingsService; + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(first), 7); + assert.include( + first.sources.map((source) => source.fingerprint.resolvedHomePath), + NodePath.join(configured, "projects"), + ); + yield* settingsService.updateSettings({ + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: "" }, + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: environmentHome, sensitive: false }, + ], + }, + }, + }); + const second = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(second), 8); + assert.include( + second.sources.map((source) => source.fingerprint.resolvedHomePath), + NodePath.join(environmentHome, "projects"), + ); + }).pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-home-refresh-test", + home, + environment: { CLAUDE_CONFIG_DIR: NodePath.join(home, "host-ignored") }, + settings: { + ...settings, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: configured }, + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: environmentHome, sensitive: false }, + ], + }, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live( + "uses inherited home variables when explicit default accounts have no home settings", + () => + Effect.gen(function* () { + const { transcript, settings, home } = yield* setup; + yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5))); + const service = yield* UsageService.make.pipe( + Effect.provide( + serviceLayers({ + prefix: "usage-service-inherited-homes-test", + home, + environment: { + CODEX_HOME: NodePath.join(home, "inherited-codex"), + CLAUDE_CONFIG_DIR: NodePath.join(home, "claude"), + }, + settings: { + ...settings, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: {}, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: {}, + }, + }, + }, + }), + ), + ); + const summary = yield* service.readSummary(WINDOW); + assert.strictEqual(totalOutputTokens(summary), 5); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "codex")?.fingerprint + .resolvedHomePath, + NodePath.join(home, "inherited-codex", "sessions"), + ); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "grok")?.fingerprint + .resolvedHomePath, + NodePath.join(home, "grok", "sessions"), + ); + }).pipe(Effect.scoped), + ); + it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => Effect.gen(function* () { const { transcript, settings, home } = yield* setup; @@ -177,8 +411,7 @@ describe("UsageService", () => { exists: (path) => fileSystem.exists(path).pipe( Effect.tap(() => { - if (path !== NodePath.join(home, "claude", ".claude", "projects")) - return Effect.void; + if (path !== NodePath.join(home, "claude", "projects")) return Effect.void; homeProbes += 1; return Deferred.succeed( homeProbes === 1 ? firstScanStarted : secondScanStarted, diff --git a/apps/server/src/usage/UsageService.ts b/apps/server/src/usage/UsageService.ts index 0e6b0c1eecd6..7c942499996e 100644 --- a/apps/server/src/usage/UsageService.ts +++ b/apps/server/src/usage/UsageService.ts @@ -15,6 +15,9 @@ import * as NodeOS from "node:os"; import { + ClaudeSettings, + CodexSettings, + type ProviderInstanceConfig, USAGE_CONTRACT_VERSION, type ServerSettings as ServerSettingsValue, type UsageProviderKind, @@ -42,8 +45,8 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ServerConfig } from "../config.ts"; import { expandHomePath } from "../pathExpansion.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts"; import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; import { UsageAggregator } from "./usageAggregation.ts"; import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts"; import { @@ -79,6 +82,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000; /** Longest window the UI offers, plus slack. Older entries are pruned. */ const CACHE_RETENTION_DAYS = 90; +const decodeCodexSettings = Schema.decodeOption(CodexSettings); +const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings); + /** On-disk shape of the rate snapshot. */ const RatesCacheFile = Schema.Struct({ fetchedAtMs: Schema.Number, @@ -220,19 +226,6 @@ export const make = Effect.gen(function* () { Effect.withSpan("UsageService.refreshRates"), ); - /** - * Claude's config dir is the home itself when overridden, but a default - * install nests transcripts under `~/.claude/projects`. Probe both. - */ - const resolveClaudeTranscriptDir = (homePath: string) => - Effect.gen(function* () { - const nested = path.join(homePath, ".claude", "projects"); - const nestedExists = yield* fileSystem - .exists(nested) - .pipe(Effect.catchCause(() => Effect.succeed(false))); - return nestedExists ? nested : path.join(homePath, "projects"); - }); - // A settings failure must not silently discard custom rates or transcript homes. const readSettings = settingsService.getSettings.pipe( Effect.catchCause( @@ -249,26 +242,55 @@ export const make = Effect.gen(function* () { const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* ( settings: ServerSettingsValue, ) { - const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent); - const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome); - const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex); - // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`. - // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd. - const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? ""; - const grokHome = - grokHomeEnv.length > 0 - ? path.resolve(expandHomePath(grokHomeEnv)) - : path.join(NodeOS.homedir(), ".grok"); - - return [ - { provider: "claude" as const, dir: claudeDir }, - { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") }, - { - provider: "grok" as const, - dir: path.join(grokHome, "sessions"), - fileName: "updates.jsonl", - }, - ]; + const dirs: Array<{ provider: UsageProviderKind; dir: string; fileName?: string }> = []; + const seen = new Set(); + for (const driver of ["claudeAgent", "codex", "grok"] as const) { + // Disabled accounts still have history. Explicit default slots replace + // the legacy settings, just as they do in the provider registry. + const instances: Array> = + Object.values(settings.providerInstances).filter((instance) => instance.driver === driver); + if (!Object.hasOwn(settings.providerInstances, driver)) { + instances.push({ config: settings.providers[driver] }); + } + for (const instance of instances) { + const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment); + const provider = driver === "claudeAgent" ? "claude" : driver; + let home: string; + if (driver === "codex") { + const decoded = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(decoded)) continue; + const config = decoded.value; + const environmentHome = environment.CODEX_HOME?.trim(); + const layout = yield* resolveCodexHomeLayout( + !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome + ? { ...config, homePath: environmentHome } + : config, + ); + home = layout.sharedHomePath; + } else if (driver === "claudeAgent") { + const decoded = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(decoded)) continue; + const configured = decoded.value.homePath.trim(); + home = configured + ? expandHomePath(configured) + : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude"); + } else { + home = expandHomePath( + environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"), + ); + } + const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions"); + // Account aliases and Codex auth overlays can share the same history. + const dir = yield* fileSystem + .realPath(directory) + .pipe(Effect.orElseSucceed(() => directory)); + const key = `${provider}\0${dir}`; + if (seen.has(key)) continue; + seen.add(key); + dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) }); + } + } + return dirs; }); /** diff --git a/docs/user/usage.md b/docs/user/usage.md index fba493156dc2..f4fdb5cca002 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -9,6 +9,12 @@ cost. These estimates are not your subscription bill. Totals depend on the history available on each server. Grok turns without a saved completed-turn record are missing from the totals. +Usage includes each configured account's history, including disabled accounts. Custom homes follow +the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment +variable. Use absolute paths or `~/` paths in the account's environment settings; relative +environment paths depend on each project's working directory and cannot be reliably discovered +by Usage. Accounts sharing a history directory count once. + On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All environments are selected by default. The dropdown shows which environments are still scanning; results appear as each one responds.