| ... | ... | @@ -7,7 +7,12 @@ import * as NodePath from "node:path"; |
| 7 | 7 | import { assert, describe, it } from "@effect/vitest"; |
| 8 | 8 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 9 | 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 | 16 | import * as Duration from "effect/Duration"; |
| 12 | 17 | import * as Deferred from "effect/Deferred"; |
| 13 | 18 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -16,6 +21,7 @@ import * as Fiber from "effect/Fiber"; |
| 16 | 21 | import * as FileSystem from "effect/FileSystem"; |
| 17 | 22 | import * as Layer from "effect/Layer"; |
| 18 | 23 | import * as Scheduler from "effect/Scheduler"; |
| 24 | import * as Schema from "effect/Schema"; |
| 19 | 25 | import * as TestClock from "effect/testing/TestClock"; |
| 20 | 26 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; |
| 21 | 27 | |
| ... | ... | @@ -23,6 +29,8 @@ import * as ServerConfig from "../config.ts"; |
| 23 | 29 | import * as ServerSettings from "../serverSettings.ts"; |
| 24 | 30 | import * as UsageService from "./UsageService.ts"; |
| 25 | 31 | |
| 32 | const encodeUnknownJsonString = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 33 | |
| 26 | 34 | function claudeLine(id: number, outputTokens: number, model = "claude-fable-5"): string { |
| 27 | 35 | return `${JSON.stringify({ |
| 28 | 36 | type: "assistant", |
| ... | ... | @@ -71,6 +79,7 @@ const serviceLayers = (input: { |
| 71 | 79 | readonly onRatesFetch?: () => void; |
| 72 | 80 | /** Defaults to an unparsable document so every scan retries the fetch. */ |
| 73 | 81 | readonly ratesDocument?: unknown; |
| 82 | readonly environment?: NodeJS.ProcessEnv; |
| 74 | 83 | }) => |
| 75 | 84 | ServerConfig.layerTest(process.cwd(), { prefix: input.prefix }).pipe( |
| 76 | 85 | Layer.provideMerge(NodeServices.layer), |
| ... | ... | @@ -89,7 +98,10 @@ const serviceLayers = (input: { |
| 89 | 98 | ), |
| 90 | 99 | ), |
| 91 | 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 | 110 | } |
| 99 | 111 | |
| 100 | 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 | 335 | it.live("reprices unchanged transcripts when custom prices are added, edited, or removed", () => |
| 102 | 336 | Effect.gen(function* () { |
| 103 | 337 | const { transcript, settings, home } = yield* setup; |
| ... | ... | @@ -177,8 +411,7 @@ describe("UsageService", () => { |
| 177 | 411 | exists: (path) => |
| 178 | 412 | fileSystem.exists(path).pipe( |
| 179 | 413 | Effect.tap(() => { |
| 180 | | if (path !== NodePath.join(home, "claude", ".claude", "projects")) |
| 181 | | return Effect.void; |
| 414 | if (path !== NodePath.join(home, "claude", "projects")) return Effect.void; |
| 182 | 415 | homeProbes += 1; |
| 183 | 416 | return Deferred.succeed( |
| 184 | 417 | homeProbes === 1 ? firstScanStarted : secondScanStarted, |