authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 21:56:59-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 21:56:59-03:00
log2db675aeffd9cb1e8b5ad76ddd018433b45b02e9
tree1c42b2d18d49b3666800ee9174adb870dfa0ec79
parent6cdbf76fa4b0509913046abe6105037ded9e52bb
signature Signed by PGP key B5690EEEBB952194

fix(usage): respect provider account homes (#11485)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

3 files changed, 299 insertions(+), 38 deletions(-)

apps/server/src/usage/UsageService.test.ts+237-4
...@@ -7,7 +7,12 @@ import * as NodePath from "node:path";...@@ -7,7 +7,12 @@ import * as NodePath from "node:path";
7import { assert, describe, it } from "@effect/vitest";7import { assert, describe, it } from "@effect/vitest";
8import * as NodeServices from "@effect/platform-node/NodeServices";8import * as NodeServices from "@effect/platform-node/NodeServices";
9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";9import { HostProcessEnvironment } from "@t3tools/shared/hostProcess";
10import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts";10import {
11 ProviderDriverKind,
12 ProviderInstanceId,
13 UsageDay,
14 type UsageSummaryInput,
15} from "@t3tools/contracts";
11import * as Duration from "effect/Duration";16import * as Duration from "effect/Duration";
12import * as Deferred from "effect/Deferred";17import * as Deferred from "effect/Deferred";
13import * as Effect from "effect/Effect";18import * as Effect from "effect/Effect";
...@@ -16,6 +21,7 @@ import * as Fiber from "effect/Fiber";...@@ -16,6 +21,7 @@ import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";21import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";22import * as Layer from "effect/Layer";
18import * as Scheduler from "effect/Scheduler";23import * as Scheduler from "effect/Scheduler";
24import * as Schema from "effect/Schema";
19import * as TestClock from "effect/testing/TestClock";25import * as TestClock from "effect/testing/TestClock";
20import { HttpClient, HttpClientResponse } from "effect/unstable/http";26import { HttpClient, HttpClientResponse } from "effect/unstable/http";
2127
...@@ -23,6 +29,8 @@ import * as ServerConfig from "../config.ts";...@@ -23,6 +29,8 @@ import * as ServerConfig from "../config.ts";
23import * as ServerSettings from "../serverSettings.ts";29import * as ServerSettings from "../serverSettings.ts";
24import * as UsageService from "./UsageService.ts";30import * as UsageService from "./UsageService.ts";
2531
32const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
33
26function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {34function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string {
27 return `${JSON.stringify({35 return `${JSON.stringify({
28 type: "assistant",36 type: "assistant",
...@@ -71,6 +79,7 @@ const serviceLayers = (input: {...@@ -71,6 +79,7 @@ const serviceLayers = (input: {
71 readonly onRatesFetch?: () => void;79 readonly onRatesFetch?: () => void;
72 /** Defaults to an unparsable document so every scan retries the fetch. */80 /** Defaults to an unparsable document so every scan retries the fetch. */
73 readonly ratesDocument?: unknown;81 readonly ratesDocument?: unknown;
82 readonly environment?: NodeJS.ProcessEnv;
74}) =>83}) =>
75 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(84 ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe(
76 Layer.provideMerge(NodeServices.layer),85 Layer.provideMerge(NodeServices.layer),
...@@ -89,7 +98,10 @@ const serviceLayers = (input: {...@@ -89,7 +98,10 @@ const serviceLayers = (input: {
89 ),98 ),
90 ),99 ),
91 Layer.provideMerge(100 Layer.provideMerge(
92 Layer.succeed(HostProcessEnvironment, { GROK_HOME: NodePath.join(input.home, "grok") }),101 Layer.succeed(HostProcessEnvironment, {
102 GROK_HOME: NodePath.join(input.home, "grok"),
103 ...input.environment,
104 }),
93 ),105 ),
94 );106 );
95107
...@@ -98,6 +110,228 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens...@@ -98,6 +110,228 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens
98}110}
99111
100describe("UsageService", () => {112describe("UsageService", () => {
113 it.live("reads configured and disabled accounts once across shared and aliased homes", () =>
114 Effect.gen(function* () {
115 const { transcript, settings, home } = yield* setup;
116 const codexHome = NodePath.join(home, "codex-account");
117 const alias = NodePath.join(home, "codex-alias");
118 const claudeHome = NodePath.join(home, "claude-account");
119 const grokHome = NodePath.join(home, "grok-account");
120 yield* Effect.promise(async () => {
121 await NodeFSP.writeFile(transcript, claudeLine(1, 5));
122 await NodeFSP.mkdir(NodePath.join(claudeHome, "projects"), { recursive: true });
123 await NodeFSP.writeFile(
124 NodePath.join(claudeHome, "projects", "session.jsonl"),
125 claudeLine(2, 7),
126 );
127 await NodeFSP.mkdir(NodePath.join(codexHome, "sessions"), { recursive: true });
128 await NodeFSP.symlink(codexHome, alias, "junction");
129 await NodeFSP.writeFile(
130 NodePath.join(codexHome, "sessions", "rollout.jsonl"),
131 [
132 { type: "session_meta", payload: { id: "codex-account-session" } },
133 { type: "turn_context", payload: { model: "gpt-5.6-sol" } },
134 {
135 type: "event_msg",
136 timestamp: "2026-08-01T10:00:00Z",
137 payload: {
138 type: "token_count",
139 info: { last_token_usage: { input_tokens: 10, output_tokens: 11 } },
140 },
141 },
142 ]
143 .map((line) => encodeUnknownJsonString(line))
144 .join("\n") + "\n",
145 );
146 await NodeFSP.mkdir(NodePath.join(grokHome, "sessions", "session"), { recursive: true });
147 await NodeFSP.writeFile(
148 NodePath.join(grokHome, "sessions", "session", "updates.jsonl"),
149 encodeUnknownJsonString({
150 timestamp: Date.parse("2026-08-01T10:00:00Z") / 1000,
151 method: "_x.ai/session/update",
152 params: {
153 sessionId: "grok-account-session",
154 update: {
155 sessionUpdate: "turn_completed",
156 prompt_id: "prompt-1",
157 usage: { inputTokens: 10, outputTokens: 13 },
158 },
159 },
160 }) + "\n",
161 );
162 });
163 const service = yield* UsageService.make.pipe(
164 Effect.provide(
165 serviceLayers({
166 prefix: "usage-service-accounts-test",
167 home,
168 settings: {
169 ...settings,
170 providerInstances: {
171 [ProviderInstanceId.make("claude-work")]: {
172 driver: ProviderDriverKind.make("claudeAgent"),
173 enabled: false,
174 environment: [{ name: "CLAUDE_CONFIG_DIR", value: claudeHome, sensitive: false }],
175 },
176 [ProviderInstanceId.make("codex-work")]: {
177 driver: ProviderDriverKind.make("codex"),
178 environment: [{ name: "CODEX_HOME", value: codexHome, sensitive: false }],
179 },
180 [ProviderInstanceId.make("codex-alias")]: {
181 driver: ProviderDriverKind.make("codex"),
182 config: { homePath: alias },
183 },
184 [ProviderInstanceId.make("codex-shadow")]: {
185 driver: ProviderDriverKind.make("codex"),
186 config: { homePath: codexHome, shadowHomePath: NodePath.join(home, "shadow") },
187 environment: [
188 { name: "CODEX_HOME", value: NodePath.join(home, "ignored"), sensitive: false },
189 ],
190 },
191 [ProviderInstanceId.make("grok-work")]: {
192 driver: ProviderDriverKind.make("grok"),
193 environment: [{ name: "GROK_HOME", value: grokHome, sensitive: false }],
194 },
195 },
196 },
197 }),
198 ),
199 );
200 const summary = yield* service.readSummary(WINDOW);
201 assert.strictEqual(totalOutputTokens(summary), 36);
202 const sources = summary.sources.filter((source) => source.status === "ok");
203 assert.strictEqual(sources.length, 4);
204 assert.strictEqual(
205 sources.reduce((sum, source) => sum + source.scannedFiles, 0),
206 4,
207 );
208 assert.strictEqual(
209 sources.filter((source) => source.fingerprint.provider === "codex").length,
210 1,
211 );
212 }).pipe(Effect.scoped),
213 );
214
215 it.live(
216 "uses explicit account settings before environment and legacy homes, then refreshes them",
217 () =>
218 Effect.gen(function* () {
219 const { transcript, settings, home } = yield* setup;
220 const configured = NodePath.join(home, "configured");
221 const environmentHome = NodePath.join(home, "environment");
222 yield* Effect.promise(async () => {
223 await NodeFSP.writeFile(transcript, claudeLine(1, 100));
224 for (const [index, root] of [configured, environmentHome].entries()) {
225 await NodeFSP.mkdir(NodePath.join(root, "projects"), { recursive: true });
226 await NodeFSP.writeFile(
227 NodePath.join(root, "projects", "session.jsonl"),
228 claudeLine(index + 2, index + 7),
229 );
230 }
231 await NodeFSP.mkdir(NodePath.join(configured, ".claude", "projects"), {
232 recursive: true,
233 });
234 await NodeFSP.writeFile(
235 NodePath.join(configured, ".claude", "projects", "wrong.jsonl"),
236 claudeLine(4, 1000),
237 );
238 });
239 yield* Effect.gen(function* () {
240 const settingsService = yield* ServerSettings.ServerSettingsService;
241 const service = yield* UsageService.make;
242 const first = yield* service.readSummary(WINDOW);
243 assert.strictEqual(totalOutputTokens(first), 7);
244 assert.include(
245 first.sources.map((source) => source.fingerprint.resolvedHomePath),
246 NodePath.join(configured, "projects"),
247 );
248 yield* settingsService.updateSettings({
249 providerInstances: {
250 [ProviderInstanceId.make("claudeAgent")]: {
251 driver: ProviderDriverKind.make("claudeAgent"),
252 config: { homePath: "" },
253 environment: [
254 { name: "CLAUDE_CONFIG_DIR", value: environmentHome, sensitive: false },
255 ],
256 },
257 },
258 });
259 const second = yield* service.readSummary(WINDOW);
260 assert.strictEqual(totalOutputTokens(second), 8);
261 assert.include(
262 second.sources.map((source) => source.fingerprint.resolvedHomePath),
263 NodePath.join(environmentHome, "projects"),
264 );
265 }).pipe(
266 Effect.provide(
267 serviceLayers({
268 prefix: "usage-service-home-refresh-test",
269 home,
270 environment: { CLAUDE_CONFIG_DIR: NodePath.join(home, "host-ignored") },
271 settings: {
272 ...settings,
273 providerInstances: {
274 [ProviderInstanceId.make("claudeAgent")]: {
275 driver: ProviderDriverKind.make("claudeAgent"),
276 config: { homePath: configured },
277 environment: [
278 { name: "CLAUDE_CONFIG_DIR", value: environmentHome, sensitive: false },
279 ],
280 },
281 },
282 },
283 }),
284 ),
285 );
286 }).pipe(Effect.scoped),
287 );
288
289 it.live(
290 "uses inherited home variables when explicit default accounts have no home settings",
291 () =>
292 Effect.gen(function* () {
293 const { transcript, settings, home } = yield* setup;
294 yield* Effect.promise(() => NodeFSP.writeFile(transcript, claudeLine(1, 5)));
295 const service = yield* UsageService.make.pipe(
296 Effect.provide(
297 serviceLayers({
298 prefix: "usage-service-inherited-homes-test",
299 home,
300 environment: {
301 CODEX_HOME: NodePath.join(home, "inherited-codex"),
302 CLAUDE_CONFIG_DIR: NodePath.join(home, "claude"),
303 },
304 settings: {
305 ...settings,
306 providerInstances: {
307 [ProviderInstanceId.make("codex")]: {
308 driver: ProviderDriverKind.make("codex"),
309 config: {},
310 },
311 [ProviderInstanceId.make("claudeAgent")]: {
312 driver: ProviderDriverKind.make("claudeAgent"),
313 config: {},
314 },
315 },
316 },
317 }),
318 ),
319 );
320 const summary = yield* service.readSummary(WINDOW);
321 assert.strictEqual(totalOutputTokens(summary), 5);
322 assert.strictEqual(
323 summary.sources.find((source) => source.fingerprint.provider === "codex")?.fingerprint
324 .resolvedHomePath,
325 NodePath.join(home, "inherited-codex", "sessions"),
326 );
327 assert.strictEqual(
328 summary.sources.find((source) => source.fingerprint.provider === "grok")?.fingerprint
329 .resolvedHomePath,
330 NodePath.join(home, "grok", "sessions"),
331 );
332 }).pipe(Effect.scoped),
333 );
334
101 it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () =>335 it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () =>
102 Effect.gen(function* () {336 Effect.gen(function* () {
103 const { transcript, settings, home } = yield* setup;337 const { transcript, settings, home } = yield* setup;
...@@ -177,8 +411,7 @@ describe("UsageService", () => {...@@ -177,8 +411,7 @@ describe("UsageService", () => {
177 exists: (path) =>411 exists: (path) =>
178 fileSystem.exists(path).pipe(412 fileSystem.exists(path).pipe(
179 Effect.tap(() => {413 Effect.tap(() => {
180 if (path !== NodePath.join(home, "claude", ".claude", "projects"))414 if (path !== NodePath.join(home, "claude", "projects")) return Effect.void;
181 return Effect.void;
182 homeProbes += 1;415 homeProbes += 1;
183 return Deferred.succeed(416 return Deferred.succeed(
184 homeProbes === 1 ? firstScanStarted : secondScanStarted,417 homeProbes === 1 ? firstScanStarted : secondScanStarted,
apps/server/src/usage/UsageService.ts+56-34
...@@ -15,6 +15,9 @@...@@ -15,6 +15,9 @@
15import * as NodeOS from "node:os";15import * as NodeOS from "node:os";
1616
17import {17import {
18 ClaudeSettings,
19 CodexSettings,
20 type ProviderInstanceConfig,
18 USAGE_CONTRACT_VERSION,21 USAGE_CONTRACT_VERSION,
19 type ServerSettings as ServerSettingsValue,22 type ServerSettings as ServerSettingsValue,
20 type UsageProviderKind,23 type UsageProviderKind,
...@@ -42,8 +45,8 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";...@@ -42,8 +45,8 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
42import { ServerConfig } from "../config.ts";45import { ServerConfig } from "../config.ts";
43import { expandHomePath } from "../pathExpansion.ts";46import { expandHomePath } from "../pathExpansion.ts";
44import * as ServerSettings from "../serverSettings.ts";47import * as ServerSettings from "../serverSettings.ts";
45import { resolveClaudeHomePath } from "../provider/Drivers/ClaudeHome.ts";
46import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";48import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts";
49import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts";
47import { UsageAggregator } from "./usageAggregation.ts";50import { UsageAggregator } from "./usageAggregation.ts";
48import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";51import { createOverrideRateTable, parseRateTable, type RateTable } from "./usagePricing.ts";
49import {52import {
...@@ -79,6 +82,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;...@@ -79,6 +82,9 @@ const MAX_HOURLY_WINDOW_MS = 24 * 60 * 60 * 1000;
79/** Longest window the UI offers, plus slack. Older entries are pruned. */82/** Longest window the UI offers, plus slack. Older entries are pruned. */
80const CACHE_RETENTION_DAYS = 90;83const CACHE_RETENTION_DAYS = 90;
8184
85const decodeCodexSettings = Schema.decodeOption(CodexSettings);
86const decodeClaudeSettings = Schema.decodeOption(ClaudeSettings);
87
82/** On-disk shape of the rate snapshot. */88/** On-disk shape of the rate snapshot. */
83const RatesCacheFile = Schema.Struct({89const RatesCacheFile = Schema.Struct({
84 fetchedAtMs: Schema.Number,90 fetchedAtMs: Schema.Number,
...@@ -220,19 +226,6 @@ export const make = Effect.gen(function* () {...@@ -220,19 +226,6 @@ export const make = Effect.gen(function* () {
220 Effect.withSpan("UsageService.refreshRates"),226 Effect.withSpan("UsageService.refreshRates"),
221 );227 );
222228
223 /**
224 * Claude's config dir is the home itself when overridden, but a default
225 * install nests transcripts under `~/.claude/projects`. Probe both.
226 */
227 const resolveClaudeTranscriptDir = (homePath: string) =>
228 Effect.gen(function* () {
229 const nested = path.join(homePath, ".claude", "projects");
230 const nestedExists = yield* fileSystem
231 .exists(nested)
232 .pipe(Effect.catchCause(() => Effect.succeed(false)));
233 return nestedExists ? nested : path.join(homePath, "projects");
234 });
235
236 // A settings failure must not silently discard custom rates or transcript homes.229 // A settings failure must not silently discard custom rates or transcript homes.
237 const readSettings = settingsService.getSettings.pipe(230 const readSettings = settingsService.getSettings.pipe(
238 Effect.catchCause(231 Effect.catchCause(
...@@ -249,26 +242,55 @@ export const make = Effect.gen(function* () {...@@ -249,26 +242,55 @@ export const make = Effect.gen(function* () {
249 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (242 const resolveTranscriptDirs = Effect.fn("UsageService.resolveTranscriptDirs")(function* (
250 settings: ServerSettingsValue,243 settings: ServerSettingsValue,
251 ) {244 ) {
252 const claudeHome = yield* resolveClaudeHomePath(settings.providers.claudeAgent);245 const dirs: Array<{ provider: UsageProviderKind; dir: string; fileName?: string }> = [];
253 const claudeDir = yield* resolveClaudeTranscriptDir(claudeHome);246 const seen = new Set<string>();
254 const codexLayout = yield* resolveCodexHomeLayout(settings.providers.codex);247 for (const driver of ["claudeAgent", "codex", "grok"] as const) {
255 // Grok Settings only expose the binary path; home is `$GROK_HOME` or `~/.grok`.248 // Disabled accounts still have history. Explicit default slots replace
256 // Empty/whitespace GROK_HOME must fall back: coalescing alone would scan cwd.249 // the legacy settings, just as they do in the provider registry.
257 const grokHomeEnv = hostEnvironment["GROK_HOME"]?.trim() ?? "";250 const instances: Array<Pick<ProviderInstanceConfig, "config" | "environment">> =
258 const grokHome =251 Object.values(settings.providerInstances).filter((instance) => instance.driver === driver);
259 grokHomeEnv.length > 0252 if (!Object.hasOwn(settings.providerInstances, driver)) {
260 ? path.resolve(expandHomePath(grokHomeEnv))253 instances.push({ config: settings.providers[driver] });
261 : path.join(NodeOS.homedir(), ".grok");254 }
262255 for (const instance of instances) {
263 return [256 const environment = mergeProviderInstanceEnvironment(instance.environment, hostEnvironment);
264 { provider: "claude" as const, dir: claudeDir },257 const provider = driver === "claudeAgent" ? "claude" : driver;
265 { provider: "codex" as const, dir: path.join(codexLayout.sharedHomePath, "sessions") },258 let home: string;
266 {259 if (driver === "codex") {
267 provider: "grok" as const,260 const decoded = decodeCodexSettings(instance.config ?? {});
268 dir: path.join(grokHome, "sessions"),261 if (Option.isNone(decoded)) continue;
269 fileName: "updates.jsonl",262 const config = decoded.value;
270 },263 const environmentHome = environment.CODEX_HOME?.trim();
271 ];264 const layout = yield* resolveCodexHomeLayout(
265 !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome
266 ? { ...config, homePath: environmentHome }
267 : config,
268 );
269 home = layout.sharedHomePath;
270 } else if (driver === "claudeAgent") {
271 const decoded = decodeClaudeSettings(instance.config ?? {});
272 if (Option.isNone(decoded)) continue;
273 const configured = decoded.value.homePath.trim();
274 home = configured
275 ? expandHomePath(configured)
276 : environment.CLAUDE_CONFIG_DIR?.trim() || path.join(NodeOS.homedir(), ".claude");
277 } else {
278 home = expandHomePath(
279 environment.GROK_HOME?.trim() || path.join(NodeOS.homedir(), ".grok"),
280 );
281 }
282 const directory = path.resolve(home, provider === "claude" ? "projects" : "sessions");
283 // Account aliases and Codex auth overlays can share the same history.
284 const dir = yield* fileSystem
285 .realPath(directory)
286 .pipe(Effect.orElseSucceed(() => directory));
287 const key = `${provider}\0${dir}`;
288 if (seen.has(key)) continue;
289 seen.add(key);
290 dirs.push({ provider, dir, ...(provider === "grok" ? { fileName: "updates.jsonl" } : {}) });
291 }
292 }
293 return dirs;
272 });294 });
273295
274 /**296 /**
docs/user/usage.md+6
...@@ -9,6 +9,12 @@ cost. These estimates are not your subscription bill....@@ -9,6 +9,12 @@ cost. These estimates are not your subscription bill.
9Totals depend on the history available on each server. Grok turns without a saved completed-turn9Totals depend on the history available on each server. Grok turns without a saved completed-turn
10record are missing from the totals.10record are missing from the totals.
1111
12Usage includes each configured account's history, including disabled accounts. Custom homes follow
13the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment
14variable. Use absolute paths or `~/` paths in the account's environment settings; relative
15environment paths depend on each project's working directory and cannot be reliably discovered
16by Usage. Accounts sharing a history directory count once.
17
12On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All18On web and desktop, use the environment dropdown to filter costs, tokens, and limits. All
13environments are selected by default. The dropdown shows which environments are still scanning;19environments are selected by default. The dropdown shows which environments are still scanning;
14results appear as each one responds.20results appear as each one responds.