| ... | @@ -7,7 +7,12 @@ import * as NodePath from "node:path"; | ... | @@ -7,7 +7,12 @@ import * as NodePath from "node:path"; |
| 7 | import { assert, describe, it } from "@effect/vitest"; | 7 | import { assert, describe, it } from "@effect/vitest"; |
| 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; | 9 | import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; |
| 10 | import { UsageDay, type UsageSummaryInput } from "@t3tools/contracts"; | 10 | import { |
| | 11 | ProviderDriverKind, |
| | 12 | ProviderInstanceId, |
| | 13 | UsageDay, |
| | 14 | type UsageSummaryInput, |
| | 15 | } from "@t3tools/contracts"; |
| 11 | import * as Duration from "effect/Duration"; | 16 | import * as Duration from "effect/Duration"; |
| 12 | import * as Deferred from "effect/Deferred"; | 17 | import * as Deferred from "effect/Deferred"; |
| 13 | import * as Effect from "effect/Effect"; | 18 | import * 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"; |
| 16 | import * as FileSystem from "effect/FileSystem"; | 21 | import * as FileSystem from "effect/FileSystem"; |
| 17 | import * as Layer from "effect/Layer"; | 22 | import * as Layer from "effect/Layer"; |
| 18 | import * as Scheduler from "effect/Scheduler"; | 23 | import * as Scheduler from "effect/Scheduler"; |
| | 24 | import * as Schema from "effect/Schema"; |
| 19 | import * as TestClock from "effect/testing/TestClock"; | 25 | import * as TestClock from "effect/testing/TestClock"; |
| 20 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | 26 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 21 | | 27 | |
| ... | @@ -23,6 +29,8 @@ import * as ServerConfig from "../config.ts"; | ... | @@ -23,6 +29,8 @@ import * as ServerConfig from "../config.ts"; |
| 23 | import * as ServerSettings from "../serverSettings.ts"; | 29 | import * as ServerSettings from "../serverSettings.ts"; |
| 24 | import * as UsageService from "./UsageService.ts"; | 30 | import * as UsageService from "./UsageService.ts"; |
| 25 | | 31 | |
| | 32 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| | 33 | |
| 26 | function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { | 34 | function 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 | ); |
| 95 | | 107 | |
| ... | @@ -98,6 +110,228 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens | ... | @@ -98,6 +110,228 @@ function totalOutputTokens(summary: { buckets: readonly { totals: { outputTokens |
| 98 | } | 110 | } |
| 99 | | 111 | |
| 100 | describe("UsageService", () => { | 112 | describe("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, |