| author | |
| committer | |
| log | 9afde1c6dbe00d2b87829732c88789c10f3429c0 |
| tree | bff48e7e76dd39ba15a9c580a3a030d1fc1d9d85 |
| parent | 6e9c5a851c92c52e2f4a8a80c655e60db133f9ac |
| signature | Signed by PGP key B5690EEEBB952194 |
13 files changed, 567 insertions(+), 16 deletions(-)
apps/mobile/src/features/usage/usageProviders.ts+3| ... | ... | @@ -12,6 +12,7 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ |
| 12 | 12 | "cursor", |
| 13 | 13 | "opencode", |
| 14 | 14 | "antigravity", |
| 15 | "pi", | |
| 15 | 16 | ]; |
| 16 | 17 | |
| 17 | 18 | export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { |
| ... | ... | @@ -21,6 +22,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { |
| 21 | 22 | cursor: "Cursor", |
| 22 | 23 | opencode: "OpenCode", |
| 23 | 24 | antigravity: "Antigravity", |
| 25 | pi: "Pi", | |
| 24 | 26 | }; |
| 25 | 27 | |
| 26 | 28 | /** |
| ... | ... | @@ -36,6 +38,7 @@ export function useProviderColors(): Record<UsageProviderKind, string> { |
| 36 | 38 | cursor: "#8b8b8b", |
| 37 | 39 | opencode: "#5b9bbd", |
| 38 | 40 | antigravity: "#8c7bd1", |
| 41 | pi: "#4d9f8a", | |
| 39 | 42 | }; |
| 40 | 43 | } |
| 41 | 44 |
apps/server/src/provider/builtInDrivers.ts+11-2| ... | ... | @@ -77,7 +77,8 @@ export type BuiltInUsageReadersEnv = |
| 77 | 77 | | ProviderUsageReaderEnv<typeof GrokDriver> |
| 78 | 78 | | ProviderUsageReaderEnv<typeof OpenCodeDriver> |
| 79 | 79 | | ProviderUsageReaderEnv<typeof AntigravityDriver> |
| 80 | | ProviderUsageReaderEnv<typeof CursorDriver>; | |
| 80 | | ProviderUsageReaderEnv<typeof CursorDriver> | |
| 81 | | ProviderUsageReaderEnv<typeof PiDriver>; | |
| 81 | 82 | |
| 82 | 83 | /** |
| 83 | 84 | * The drivers that keep usage history, in the order the usage page reads |
| ... | ... | @@ -86,4 +87,12 @@ export type BuiltInUsageReadersEnv = |
| 86 | 87 | */ |
| 87 | 88 | export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< |
| 88 | 89 | AnyProviderDriver<BuiltInDriversEnv, BuiltInUsageReadersEnv> |
| 89 | > = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver, AntigravityDriver, CursorDriver]; | |
| 90 | > = [ | |
| 91 | ClaudeDriver, | |
| 92 | CodexDriver, | |
| 93 | GrokDriver, | |
| 94 | PiDriver, | |
| 95 | OpenCodeDriver, | |
| 96 | AntigravityDriver, | |
| 97 | CursorDriver, | |
| 98 | ]; |
apps/server/src/usage/UsageService.test.ts+314-1| ... | ... | @@ -40,7 +40,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 40 | 40 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 41 | 41 | import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; |
| 42 | 42 | import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; |
| 43 | import type { UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 43 | import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage"; | |
| 44 | 44 | import * as UsageService from "./UsageService.ts"; |
| 45 | 45 | |
| 46 | 46 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| ... | ... | @@ -155,6 +155,7 @@ const layerService = (input: { |
| 155 | 155 | Layer.succeed(HostProcess.Environment, { |
| 156 | 156 | HOME: input.home, |
| 157 | 157 | GROK_HOME: NodePath.join(input.home, "grok"), |
| 158 | PI_CODING_AGENT_DIR: NodePath.join(input.home, "pi"), | |
| 158 | 159 | OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), |
| 159 | 160 | ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), |
| 160 | 161 | XDG_CONFIG_HOME: NodePath.join(input.home, "config"), |
| ... | ... | @@ -269,6 +270,318 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { |
| 269 | 270 | } |
| 270 | 271 | |
| 271 | 272 | describe("UsageService", () => { |
| 273 | it.live( | |
| 274 | "reads Pi per-model usage and auxiliary costs once across forks and repeated entries", | |
| 275 | () => | |
| 276 | Effect.gen(function* () { | |
| 277 | const { home, settings } = yield* setup; | |
| 278 | const dir = NodePath.join(home, "pi", "sessions", "project"); | |
| 279 | const usage = { | |
| 280 | input: 10, | |
| 281 | output: 5, | |
| 282 | cacheRead: 20, | |
| 283 | cacheWrite: 3, | |
| 284 | reasoning: 2, | |
| 285 | totalTokens: 38, | |
| 286 | cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, | |
| 287 | }; | |
| 288 | const parent = { | |
| 289 | type: "message", | |
| 290 | id: "a123abcd", | |
| 291 | parentId: null, | |
| 292 | timestamp: "2026-08-01T10:00:00Z", | |
| 293 | message: { | |
| 294 | role: "assistant", | |
| 295 | provider: "anthropic", | |
| 296 | model: "requested-model", | |
| 297 | responseModel: "actual-model", | |
| 298 | usage, | |
| 299 | }, | |
| 300 | }; | |
| 301 | const auxiliary = [ | |
| 302 | { | |
| 303 | type: "message", | |
| 304 | id: "tool", | |
| 305 | message: { role: "toolResult", toolName: "classify", usage }, | |
| 306 | }, | |
| 307 | { | |
| 308 | type: "message", | |
| 309 | id: "image", | |
| 310 | message: { | |
| 311 | role: "toolResult", | |
| 312 | toolName: "image", | |
| 313 | usage: { ...usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, | |
| 314 | }, | |
| 315 | }, | |
| 316 | { type: "compaction", id: "compact", usage }, | |
| 317 | { type: "branch_summary", id: "summary", usage }, | |
| 318 | ].map((entry) => ({ ...entry, timestamp: "2026-08-01T11:00:00Z" })); | |
| 319 | yield* Effect.promise(async () => { | |
| 320 | await NodeFSP.mkdir(dir, { recursive: true }); | |
| 321 | await NodeFSP.writeFile( | |
| 322 | NodePath.join(dir, "parent.jsonl"), | |
| 323 | [ | |
| 324 | { type: "session", id: "parent" }, | |
| 325 | parent, | |
| 326 | ...auxiliary, | |
| 327 | { | |
| 328 | type: "usage", | |
| 329 | id: "warm", | |
| 330 | timestamp: "2026-08-01T12:00:00Z", | |
| 331 | kind: "cache_warm", | |
| 332 | provider: "openai", | |
| 333 | model: "actual-model", | |
| 334 | usage: { ...usage, cost: { ...usage.cost, total: 0 } }, | |
| 335 | }, | |
| 336 | ] | |
| 337 | .map((line) => encodeUnknownJsonString(line)) | |
| 338 | .join("\n") + "\n", | |
| 339 | ); | |
| 340 | await NodeFSP.writeFile( | |
| 341 | NodePath.join(dir, "fork.jsonl"), | |
| 342 | [ | |
| 343 | { type: "session", id: "fork", parentSession: NodePath.join(dir, "parent.jsonl") }, | |
| 344 | { ...parent, parentId: "relinked" }, | |
| 345 | parent, | |
| 346 | ...auxiliary, | |
| 347 | { | |
| 348 | ...parent, | |
| 349 | timestamp: "2026-08-01T13:00:00Z", | |
| 350 | message: { ...parent.message, provider: "openai", responseModel: "another-model" }, | |
| 351 | }, | |
| 352 | { type: "get_session_stats", usage: { ...usage, cost: { total: 1000 } } }, | |
| 353 | ] | |
| 354 | .map((line) => encodeUnknownJsonString(line)) | |
| 355 | .join("\n") + "\n", | |
| 356 | ); | |
| 357 | }); | |
| 358 | yield* Effect.gen(function* () { | |
| 359 | const service = yield* UsageService.make; | |
| 360 | const first = yield* service.readSummary(WINDOW); | |
| 361 | const pi = first.buckets.filter((bucket) => bucket.provider === "pi"); | |
| 362 | assert.strictEqual( | |
| 363 | pi.reduce((total, bucket) => total + bucket.costUsd, 0), | |
| 364 | 44, | |
| 365 | ); | |
| 366 | assert.strictEqual( | |
| 367 | pi.reduce((total, bucket) => total + totalTokens(bucket.totals), 0), | |
| 368 | 228, | |
| 369 | ); | |
| 370 | assert.deepStrictEqual(pi.map((bucket) => bucket.model).sort(), [ | |
| 371 | "Tools/summaries", | |
| 372 | "anthropic/actual-model", | |
| 373 | "openai/actual-model", | |
| 374 | "openai/another-model", | |
| 375 | ]); | |
| 376 | const assistant = pi.find((bucket) => bucket.model === "anthropic/actual-model"); | |
| 377 | assert.deepStrictEqual(assistant?.totals, { | |
| 378 | uncachedInputTokens: 10, | |
| 379 | outputTokens: 5, | |
| 380 | cachedInputTokens: 20, | |
| 381 | cacheCreationTokens: 3, | |
| 382 | reasoningTokens: 2, | |
| 383 | }); | |
| 384 | assert.strictEqual(assistant?.costSource, "providerReported"); | |
| 385 | assert.approximately(assistant?.categoryCostUsd?.input ?? NaN, 10 / 38, 1e-6); | |
| 386 | assert.approximately(assistant?.categoryCostUsd?.cacheRead ?? NaN, 2 / 38, 1e-6); | |
| 387 | assert.approximately(assistant?.categoryCostUsd?.cacheWrite ?? NaN, 6 / 38, 1e-6); | |
| 388 | assert.approximately(assistant?.categoryCostUsd?.output ?? NaN, 20 / 38, 1e-6); | |
| 389 | assert.strictEqual(assistant?.cacheSavingsUsd, 18); | |
| 390 | const warm = pi.find((bucket) => bucket.model === "openai/actual-model"); | |
| 391 | assert.strictEqual(warm?.costSource, "modelPriced"); | |
| 392 | assert.strictEqual(warm?.costUsd, 38); | |
| 393 | assert.deepStrictEqual(warm?.categoryCostUsd, { | |
| 394 | input: 10, | |
| 395 | cacheRead: 2, | |
| 396 | cacheWrite: 6, | |
| 397 | output: 20, | |
| 398 | }); | |
| 399 | assert.strictEqual(warm?.cacheSavingsUsd, 18); | |
| 400 | assert.strictEqual( | |
| 401 | first.sources.filter((source) => source.fingerprint.provider === "pi").length, | |
| 402 | 1, | |
| 403 | ); | |
| 404 | // Warm scans retain header state and only charge appended entries. | |
| 405 | yield* Effect.promise(() => | |
| 406 | NodeFSP.appendFile( | |
| 407 | NodePath.join(dir, "fork.jsonl"), | |
| 408 | encodeUnknownJsonString({ | |
| 409 | ...parent, | |
| 410 | id: "new", | |
| 411 | timestamp: "2026-08-01T14:00:00Z", | |
| 412 | message: { ...parent.message, responseModel: "latest-model" }, | |
| 413 | }) + "\n", | |
| 414 | ), | |
| 415 | ); | |
| 416 | const grown = yield* service.readSummary(WINDOW); | |
| 417 | assert.strictEqual( | |
| 418 | grown.buckets | |
| 419 | .filter((bucket) => bucket.provider === "pi") | |
| 420 | .reduce((total, bucket) => total + bucket.costUsd, 0), | |
| 421 | 45, | |
| 422 | ); | |
| 423 | yield* service.awaitPersisted; | |
| 424 | const restarted = yield* UsageService.make; | |
| 425 | assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, grown.buckets); | |
| 426 | }).pipe( | |
| 427 | Effect.scoped, | |
| 428 | Effect.provide( | |
| 429 | layerService({ | |
| 430 | prefix: "usage-pi-fork", | |
| 431 | home, | |
| 432 | ratesDocument: { | |
| 433 | "actual-model": { | |
| 434 | input_cost_per_token: 1, | |
| 435 | output_cost_per_token: 4, | |
| 436 | cache_read_input_token_cost: 0.1, | |
| 437 | cache_creation_input_token_cost: 2, | |
| 438 | }, | |
| 439 | }, | |
| 440 | settings: { | |
| 441 | ...settings, | |
| 442 | providerInstances: { | |
| 443 | ...settings.providerInstances, | |
| 444 | [ProviderInstanceId.make("pi-extra")]: { | |
| 445 | driver: ProviderDriverKind.make("pi"), | |
| 446 | enabled: false, | |
| 447 | environment: [ | |
| 448 | { | |
| 449 | name: "PI_CODING_AGENT_DIR", | |
| 450 | value: NodePath.join(home, "pi"), | |
| 451 | sensitive: false, | |
| 452 | }, | |
| 453 | ], | |
| 454 | }, | |
| 455 | }, | |
| 456 | }, | |
| 457 | }), | |
| 458 | ), | |
| 459 | ); | |
| 460 | }).pipe(Effect.scoped), | |
| 461 | ); | |
| 462 | ||
| 463 | it.live("reports missing Pi history and skips malformed records without losing valid usage", () => | |
| 464 | Effect.gen(function* () { | |
| 465 | const { home, settings } = yield* setup; | |
| 466 | yield* Effect.gen(function* () { | |
| 467 | const service = yield* UsageService.make; | |
| 468 | const missing = yield* service.readSummary(WINDOW); | |
| 469 | assert.strictEqual( | |
| 470 | missing.sources.find((source) => source.fingerprint.provider === "pi")?.status, | |
| 471 | "missing", | |
| 472 | ); | |
| 473 | const dir = NodePath.join(home, "pi", "sessions"); | |
| 474 | yield* Effect.promise(async () => { | |
| 475 | await NodeFSP.mkdir(dir, { recursive: true }); | |
| 476 | await NodeFSP.writeFile( | |
| 477 | NodePath.join(dir, "session.jsonl"), | |
| 478 | [ | |
| 479 | "{broken", | |
| 480 | "null", | |
| 481 | encodeUnknownJsonString({ type: "session", id: "valid" }), | |
| 482 | encodeUnknownJsonString({ | |
| 483 | type: "message", | |
| 484 | id: "bad", | |
| 485 | timestamp: "invalid", | |
| 486 | message: { | |
| 487 | role: "assistant", | |
| 488 | provider: "openai", | |
| 489 | model: "model", | |
| 490 | usage: { input: 100 }, | |
| 491 | }, | |
| 492 | }), | |
| 493 | encodeUnknownJsonString({ | |
| 494 | type: "message", | |
| 495 | id: "valid", | |
| 496 | timestamp: "2026-08-01T10:00:00Z", | |
| 497 | message: { | |
| 498 | role: "assistant", | |
| 499 | provider: "openai", | |
| 500 | model: "model", | |
| 501 | usage: { | |
| 502 | input: -1, | |
| 503 | cacheRead: "100", | |
| 504 | output: 7, | |
| 505 | reasoning: 50, | |
| 506 | cost: { total: -1 }, | |
| 507 | }, | |
| 508 | }, | |
| 509 | }), | |
| 510 | ].join("\n") + "\n", | |
| 511 | ); | |
| 512 | }); | |
| 513 | const summary = yield* service.readSummary(WINDOW); | |
| 514 | const pi = summary.buckets.filter((bucket) => bucket.provider === "pi"); | |
| 515 | assert.strictEqual(pi.length, 1); | |
| 516 | assert.strictEqual(pi[0] && totalTokens(pi[0].totals), 7); | |
| 517 | assert.strictEqual(pi[0]?.totals.reasoningTokens, 7); | |
| 518 | assert.strictEqual(pi[0]?.costSource, "unpriced"); | |
| 519 | }).pipe( | |
| 520 | Effect.scoped, | |
| 521 | Effect.provide(layerService({ prefix: "usage-pi-malformed", home, settings })), | |
| 522 | ); | |
| 523 | }).pipe(Effect.scoped), | |
| 524 | ); | |
| 525 | ||
| 526 | it.live("discovers Pi history in its default home and directory overrides", () => | |
| 527 | Effect.gen(function* () { | |
| 528 | const { home, settings } = yield* setup; | |
| 529 | for (const [agentDir, sessionDir, relativeDir] of [ | |
| 530 | [undefined, undefined, ".pi/agent/sessions"], | |
| 531 | ["~/custom-pi", undefined, "custom-pi/sessions"], | |
| 532 | ["~/ignored-pi", "~/custom-sessions", "custom-sessions"], | |
| 533 | ["~/ignored-pi", NodePath.join(home, "absolute-sessions"), "absolute-sessions"], | |
| 534 | ] as const) { | |
| 535 | const dir = NodePath.join(home, relativeDir); | |
| 536 | yield* Effect.promise(async () => { | |
| 537 | await NodeFSP.mkdir(dir, { recursive: true }); | |
| 538 | await NodeFSP.writeFile( | |
| 539 | NodePath.join(dir, "session.jsonl"), | |
| 540 | [ | |
| 541 | { type: "session", id: relativeDir }, | |
| 542 | { | |
| 543 | type: "usage", | |
| 544 | id: "warm", | |
| 545 | timestamp: "2026-08-01T10:00:00Z", | |
| 546 | provider: "openai", | |
| 547 | model: "model", | |
| 548 | usage: { input: 7 }, | |
| 549 | }, | |
| 550 | ] | |
| 551 | .map((line) => encodeUnknownJsonString(line)) | |
| 552 | .join("\n") + "\n", | |
| 553 | ); | |
| 554 | }); | |
| 555 | yield* Effect.gen(function* () { | |
| 556 | const service = yield* UsageService.make; | |
| 557 | const summary = yield* service.readSummary(WINDOW); | |
| 558 | const pi = summary.buckets.filter((bucket) => bucket.provider === "pi"); | |
| 559 | assert.strictEqual(pi.length, 1); | |
| 560 | assert.strictEqual(pi[0]?.totals.uncachedInputTokens, 7); | |
| 561 | assert.strictEqual( | |
| 562 | summary.sources.find((source) => source.fingerprint.provider === "pi")?.fingerprint | |
| 563 | .resolvedHomePath, | |
| 564 | dir, | |
| 565 | ); | |
| 566 | }).pipe( | |
| 567 | Effect.scoped, | |
| 568 | Effect.provide( | |
| 569 | layerService({ | |
| 570 | prefix: "usage-pi-" + relativeDir.replaceAll("/", "-"), | |
| 571 | home, | |
| 572 | settings, | |
| 573 | environment: { | |
| 574 | PI_CODING_AGENT_DIR: agentDir, | |
| 575 | PI_CODING_AGENT_SESSION_DIR: sessionDir, | |
| 576 | }, | |
| 577 | }), | |
| 578 | ), | |
| 579 | Effect.provideService(HostProcess.HomeDirectory, home), | |
| 580 | ); | |
| 581 | } | |
| 582 | }).pipe(Effect.scoped), | |
| 583 | ); | |
| 584 | ||
| 272 | 585 | it.live("reads shared managed default and disabled extra account history once", () => |
| 273 | 586 | Effect.gen(function* () { |
| 274 | 587 | const { home, settings } = yield* setup; |
apps/server/src/usage/usageScanCache.ts+9| ... | ... | @@ -80,6 +80,8 @@ type SerializedRecord = readonly [ |
| 80 | 80 | dedupeKey: string | null, |
| 81 | 81 | reportedCostUsd: number | null, |
| 82 | 82 | speed: number, |
| 83 | /** Optional trailing field keeps existing cache rows readable. */ | |
| 84 | rateModelIndex?: number | null, | |
| 83 | 85 | ]; |
| 84 | 86 | |
| 85 | 87 | interface SerializedFile { |
| ... | ... | @@ -138,6 +140,9 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile |
| 138 | 140 | record.dedupeKey, |
| 139 | 141 | record.reportedCostUsd, |
| 140 | 142 | SPEEDS.indexOf(record.speed), |
| 143 | record.rateModel === undefined | |
| 144 | ? null | |
| 145 | : intern(tables.models, tables.modelIndex, record.rateModel), | |
| 141 | 146 | ]; |
| 142 | 147 | return { |
| 143 | 148 | s: entry.size, |
| ... | ... | @@ -264,14 +269,17 @@ export function decodeScanCache( |
| 264 | 269 | dedupeKey, |
| 265 | 270 | reportedCostUsd, |
| 266 | 271 | speedIndex, |
| 272 | rateModelIndex, | |
| 267 | 273 | ] = row as SerializedRecord; |
| 268 | 274 | const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; |
| 269 | 275 | |
| 270 | 276 | const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; |
| 277 | const rateModel = typeof rateModelIndex === "number" ? models[rateModelIndex] : undefined; | |
| 271 | 278 | if ( |
| 272 | 279 | typeof timestampMs !== "number" || |
| 273 | 280 | !Number.isFinite(timestampMs) || |
| 274 | 281 | model === undefined || |
| 282 | (rateModelIndex != null && rateModel === undefined) || | |
| 275 | 283 | !Number.isFinite(uncached) || |
| 276 | 284 | !Number.isFinite(cached) || |
| 277 | 285 | !Number.isFinite(cacheCreation) || |
| ... | ... | @@ -286,6 +294,7 @@ export function decodeScanCache( |
| 286 | 294 | provider, |
| 287 | 295 | timestampMs, |
| 288 | 296 | model, |
| 297 | ...(rateModel === undefined ? {} : { rateModel }), | |
| 289 | 298 | sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", |
| 290 | 299 | totals: { |
| 291 | 300 | uncachedInputTokens: uncached, |
apps/server/src/usage/usageTestFormats.ts+6-1| ... | ... | @@ -5,15 +5,20 @@ |
| 5 | 5 | */ |
| 6 | 6 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 7 | 7 | import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; |
| 8 | import { piUsageFormat } from "@t3tools/provider-pi/server/usage"; | |
| 8 | 9 | import { grokUsageFormat } from "@t3tools/provider-grok/server/usage"; |
| 9 | 10 | |
| 10 | 11 | import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts"; |
| 11 | 12 | import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts"; |
| 12 | 13 | |
| 13 | export const TEST_FORMATS: Record<"claude" | "codex" | "grok", TranscriptUsageFormat<unknown>> = { | |
| 14 | export const TEST_FORMATS: Record< | |
| 15 | "claude" | "codex" | "grok" | "pi", | |
| 16 | TranscriptUsageFormat<unknown> | |
| 17 | > = { | |
| 14 | 18 | claude: claudeUsageFormat, |
| 15 | 19 | codex: codexUsageFormat, |
| 16 | 20 | grok: grokUsageFormat, |
| 21 | pi: piUsageFormat, | |
| 17 | 22 | }; |
| 18 | 23 | |
| 19 | 24 | export const TEST_FORMAT_MAP = new Map<UsageProviderKind, TranscriptUsageFormat<unknown>>( |
apps/server/src/usage/usageTranscriptStreaming.test.ts+45-2| ... | ... | @@ -15,7 +15,7 @@ import { |
| 15 | 15 | // external 65/517 MiB fixtures also exercise the production threshold. |
| 16 | 16 | const readTranscriptRecords = ( |
| 17 | 17 | path: string, |
| 18 | provider: "claude" | "codex" | "grok", | |
| 18 | provider: keyof typeof TEST_FORMATS, | |
| 19 | 19 | position?: TranscriptParsePosition, |
| 20 | 20 | ) => |
| 21 | 21 | readWithDefaultThreshold(path, TEST_FORMATS[provider], position, { |
| ... | ... | @@ -99,7 +99,7 @@ const grok = { |
| 99 | 99 | |
| 100 | 100 | async function scan( |
| 101 | 101 | lines: readonly unknown[], |
| 102 | provider: "claude" | "codex" | "grok", | |
| 102 | provider: keyof typeof TEST_FORMATS, | |
| 103 | 103 | name = "history", |
| 104 | 104 | ) { |
| 105 | 105 | const path = NodePath.join(dir, `${name}.jsonl`); |
| ... | ... | @@ -110,6 +110,49 @@ async function scan( |
| 110 | 110 | } |
| 111 | 111 | |
| 112 | 112 | describe("large usage records", () => { |
| 113 | it("keeps Pi model, cost, and fork identity after large assistant content", async () => { | |
| 114 | const entry = { | |
| 115 | type: "message", | |
| 116 | id: "a123abcd", | |
| 117 | timestamp, | |
| 118 | message: { | |
| 119 | role: "assistant", | |
| 120 | provider: "anthropic", | |
| 121 | model: "requested", | |
| 122 | responseModel: "actual", | |
| 123 | content: [{ type: "text", text: content }], | |
| 124 | usage: { | |
| 125 | input: 100, | |
| 126 | output: 99, | |
| 127 | cacheRead: 20, | |
| 128 | cacheWrite: 5, | |
| 129 | reasoning: 10, | |
| 130 | cost: { total: 0.25 }, | |
| 131 | }, | |
| 132 | }, | |
| 133 | }; | |
| 134 | const result = await scan([{ type: "session", id: "pi-session" }, entry], "pi"); | |
| 135 | expect(result.records).toEqual([ | |
| 136 | { | |
| 137 | provider: "pi", | |
| 138 | model: "anthropic/actual", | |
| 139 | rateModel: "actual", | |
| 140 | sessionId: "pi-session", | |
| 141 | timestampMs: Date.parse(timestamp), | |
| 142 | totals: { | |
| 143 | uncachedInputTokens: 100, | |
| 144 | outputTokens: 99, | |
| 145 | cachedInputTokens: 20, | |
| 146 | cacheCreationTokens: 5, | |
| 147 | reasoningTokens: 10, | |
| 148 | }, | |
| 149 | reportedCostUsd: 0.25, | |
| 150 | speed: "standard", | |
| 151 | dedupeKey: JSON.stringify(["pi", entry.id, timestamp, "anthropic/actual"]), | |
| 152 | }, | |
| 153 | ]); | |
| 154 | }); | |
| 155 | ||
| 113 | 156 | it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => { |
| 114 | 157 | const result = await scan([claude()], "claude"); |
| 115 | 158 | expect(result.records).toEqual([ |
apps/web/src/components/usage/UsageProviderChart.test.ts+6-4| ... | ... | @@ -69,11 +69,12 @@ describe("buildPeriodColumns", () => { |
| 69 | 69 | "2026-08-01", |
| 70 | 70 | { |
| 71 | 71 | day: "2026-08-01", |
| 72 | costUsd: 30, | |
| 73 | totalTokens: 300, | |
| 72 | costUsd: 35, | |
| 73 | totalTokens: 350, | |
| 74 | 74 | byProvider: new Map([ |
| 75 | 75 | ["codex" as const, { costUsd: 10, totalTokens: 100 }], |
| 76 | 76 | ["claude" as const, { costUsd: 20, totalTokens: 200 }], |
| 77 | ["pi" as const, { costUsd: 5, totalTokens: 50 }], | |
| 77 | 78 | ]), |
| 78 | 79 | }, |
| 79 | 80 | ], |
| ... | ... | @@ -91,13 +92,13 @@ describe("buildPeriodColumns", () => { |
| 91 | 92 | |
| 92 | 93 | it("plots each day on its own", () => { |
| 93 | 94 | expect(buildPeriodColumns(days, byDay, "cost").map((column) => column.total)).toEqual([ |
| 94 | 30, 0, 5, | |
| 95 | 35, 0, 5, | |
| 95 | 96 | ]); |
| 96 | 97 | }); |
| 97 | 98 | |
| 98 | 99 | it("reads the requested metric", () => { |
| 99 | 100 | expect(buildPeriodColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ |
| 100 | 300, 0, 50, | |
| 101 | 350, 0, 50, | |
| 101 | 102 | ]); |
| 102 | 103 | }); |
| 103 | 104 | |
| ... | ... | @@ -113,6 +114,7 @@ describe("buildPeriodColumns", () => { |
| 113 | 114 | { provider: "cursor", value: 0 }, |
| 114 | 115 | { provider: "opencode", value: 0 }, |
| 115 | 116 | { provider: "antigravity", value: 0 }, |
| 117 | { provider: "pi", value: 5 }, | |
| 116 | 118 | ]); |
| 117 | 119 | }); |
| 118 | 120 |
apps/web/src/components/usage/usageProviders.ts+5| ... | ... | @@ -41,6 +41,11 @@ export const PROVIDER_PRESENTATION = { |
| 41 | 41 | color: "#8c7bd1", |
| 42 | 42 | driverKind: ProviderDriverKind.make("antigravity"), |
| 43 | 43 | }, |
| 44 | pi: { | |
| 45 | label: "Pi", | |
| 46 | color: "#4d9f8a", | |
| 47 | driverKind: ProviderDriverKind.make("pi"), | |
| 48 | }, | |
| 44 | 49 | } satisfies Record<UsageProviderKind, UsageProviderPresentation>; |
| 45 | 50 | |
| 46 | 51 | /** Stable provider reading order across charts, summaries, tables, and hover rows. */ |
docs/user/usage.md+9-5| ... | ... | @@ -6,8 +6,8 @@ desktop when the terminal is not focused. Customize `usage.open` in |
| 6 | 6 | |
| 7 | 7 | ## Understand your usage |
| 8 | 8 | |
| 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected | |
| 10 | environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent | |
| 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, and Cursor history | |
| 10 | from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent | |
| 11 | 11 | cost, split by token type and by speed. These estimates are not your subscription bill. |
| 12 | 12 | **Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be |
| 13 | 13 | split, such as a provider-reported cost for a model without public rates, shows as **Other**. |
| ... | ... | @@ -30,9 +30,13 @@ you to allow access on the server Mac. |
| 30 | 30 | |
| 31 | 31 | Usage includes each configured account's history, including disabled accounts. Custom homes follow |
| 32 | 32 | the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment |
| 33 | variable. Use absolute paths or `~/` paths in the account's environment settings; relative | |
| 34 | environment paths depend on each project's working directory and cannot be reliably discovered | |
| 35 | by Usage. Accounts sharing a history directory count once. | |
| 33 | variable. Pi reads `~/.pi/agent/sessions`, or the `sessions` directory under | |
| 34 | `PI_CODING_AGENT_DIR`. Set `PI_CODING_AGENT_SESSION_DIR` to read a different session directory. | |
| 35 | To include sessions saved through `--session-dir` or Pi's `sessionDir` setting, set this environment | |
| 36 | variable to the same directory. Pi includes recorded tool and summary costs under `Tools/summaries`. | |
| 37 | Use absolute paths or `~/` paths in the account's environment settings; relative environment paths | |
| 38 | depend on each project's working directory and cannot be reliably discovered by Usage. Accounts | |
| 39 | sharing a history directory count once. | |
| 36 | 40 | |
| 37 | 41 | When your app and server support different providers, usage totals may cover only the providers |
| 38 | 42 | your app understands. Update the app to include newly supported providers. |
packages/contracts/src/usage.ts+1| ... | ... | @@ -38,6 +38,7 @@ export const UsageProviderKind = Schema.Literals([ |
| 38 | 38 | "cursor", |
| 39 | 39 | "opencode", |
| 40 | 40 | "antigravity", |
| 41 | "pi", | |
| 41 | 42 | ]); |
| 42 | 43 | export type UsageProviderKind = typeof UsageProviderKind.Type; |
| 43 | 44 |
packages/provider-pi/package.json+4| ... | ... | @@ -14,6 +14,10 @@ |
| 14 | 14 | "./server": { |
| 15 | 15 | "types": "./src/server.ts", |
| 16 | 16 | "import": "./src/server.ts" |
| 17 | }, | |
| 18 | "./server/usage": { | |
| 19 | "types": "./src/server/usage.ts", | |
| 20 | "import": "./src/server/usage.ts" | |
| 17 | 21 | } |
| 18 | 22 | }, |
| 19 | 23 | "scripts": { |
packages/provider-pi/src/server/driver.ts+3-1| ... | ... | @@ -17,6 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 17 | 17 | |
| 18 | 18 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 19 | 19 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 20 | import { piUsageReader } from "./usage.ts"; | |
| 20 | 21 | import { makePiTextGeneration } from "./textGeneration.ts"; |
| 21 | 22 | import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; |
| 22 | 23 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; |
| ... | ... | @@ -80,12 +81,13 @@ const withInstanceIdentity = |
| 80 | 81 | continuation: { groupKey: input.continuationGroupKey }, |
| 81 | 82 | }); |
| 82 | 83 | |
| 83 | export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = { | |
| 84 | export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv, Path.Path> = { | |
| 84 | 85 | driverKind: DRIVER_KIND, |
| 85 | 86 | metadata: { |
| 86 | 87 | displayName: "Pi", |
| 87 | 88 | supportsMultipleInstances: true, |
| 88 | 89 | }, |
| 90 | usage: piUsageReader, | |
| 89 | 91 | configSchema: PiSettings, |
| 90 | 92 | defaultConfig: (): PiSettings => decodePiSettings({}), |
| 91 | 93 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => |
packages/provider-pi/src/server/usage.ts created+151| ... | ... | @@ -0,0 +1,151 @@ |
| 1 | /** Native Pi session usage, including calls billed outside the main conversation. */ | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Option from "effect/Option"; | |
| 4 | import * as Path from "effect/Path"; | |
| 5 | import * as Schema from "effect/Schema"; | |
| 6 | ||
| 7 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; | |
| 8 | import { | |
| 9 | parseTimestampMs, | |
| 10 | tokenCount, | |
| 11 | totalTokens, | |
| 12 | type ProviderUsageReader, | |
| 13 | type TranscriptUsageFormat, | |
| 14 | type UsageRecord, | |
| 15 | } from "@t3tools/provider-core/server/usage"; | |
| 16 | import * as HostProcess from "@t3tools/shared/HostProcess"; | |
| 17 | import type { PiSettings } from "../settings.ts"; | |
| 18 | ||
| 19 | const PiScanState = Schema.Struct({ sessionId: Schema.mutableKey(Schema.String) }); | |
| 20 | const Usage = Schema.Struct({ | |
| 21 | input: Schema.optional(Schema.Unknown), | |
| 22 | output: Schema.optional(Schema.Unknown), | |
| 23 | cacheRead: Schema.optional(Schema.Unknown), | |
| 24 | cacheWrite: Schema.optional(Schema.Unknown), | |
| 25 | reasoning: Schema.optional(Schema.Unknown), | |
| 26 | cost: Schema.optional(Schema.Struct({ total: Schema.Unknown })), | |
| 27 | }); | |
| 28 | const Identity = { id: Schema.NonEmptyString, timestamp: Schema.String }; | |
| 29 | const Model = { provider: Schema.NonEmptyString, model: Schema.NonEmptyString }; | |
| 30 | const Entry = Schema.Union([ | |
| 31 | Schema.Struct({ type: Schema.Literal("session"), id: Schema.NonEmptyString }), | |
| 32 | Schema.Struct({ | |
| 33 | ...Identity, | |
| 34 | type: Schema.Literal("message"), | |
| 35 | message: Schema.Union([ | |
| 36 | Schema.Struct({ | |
| 37 | ...Model, | |
| 38 | role: Schema.Literal("assistant"), | |
| 39 | responseModel: Schema.optional(Schema.NonEmptyString), | |
| 40 | usage: Usage, | |
| 41 | }), | |
| 42 | Schema.Struct({ role: Schema.Literal("toolResult"), usage: Usage }), | |
| 43 | ]), | |
| 44 | }), | |
| 45 | Schema.Struct({ ...Identity, ...Model, type: Schema.Literal("usage"), usage: Usage }), | |
| 46 | Schema.Struct({ | |
| 47 | ...Identity, | |
| 48 | type: Schema.Literals(["compaction", "branch_summary"]), | |
| 49 | usage: Usage, | |
| 50 | }), | |
| 51 | ]); | |
| 52 | const decodeEntry = Schema.decodeUnknownOption(Entry); | |
| 53 | ||
| 54 | function parseRecord(parsed: unknown, state: typeof PiScanState.Type): readonly UsageRecord[] { | |
| 55 | const decoded = decodeEntry(parsed); | |
| 56 | if (Option.isNone(decoded)) return []; | |
| 57 | const entry = decoded.value; | |
| 58 | if (entry.type === "session") { | |
| 59 | state.sessionId = entry.id; | |
| 60 | return []; | |
| 61 | } | |
| 62 | const timestampMs = parseTimestampMs(entry.timestamp); | |
| 63 | if (timestampMs === null) return []; | |
| 64 | const source = entry.type === "message" ? entry.message : entry; | |
| 65 | const usage = source.usage; | |
| 66 | // Pi's auxiliary tool and summary records carry no model attribution. Match | |
| 67 | // Pi's own breakdown rather than assigning them to the conversation model. | |
| 68 | const rateModel = | |
| 69 | "model" in source | |
| 70 | ? "responseModel" in source | |
| 71 | ? (source.responseModel ?? source.model) | |
| 72 | : source.model | |
| 73 | : undefined; | |
| 74 | const model = "provider" in source ? `${source.provider}/${rateModel}` : "Tools/summaries"; | |
| 75 | const outputTokens = tokenCount(usage.output); | |
| 76 | const totals = { | |
| 77 | // Pi's input/cache counters are already disjoint. | |
| 78 | uncachedInputTokens: tokenCount(usage.input), | |
| 79 | cachedInputTokens: tokenCount(usage.cacheRead), | |
| 80 | cacheCreationTokens: tokenCount(usage.cacheWrite), | |
| 81 | outputTokens, | |
| 82 | reasoningTokens: Math.min(outputTokens, tokenCount(usage.reasoning)), | |
| 83 | }; | |
| 84 | const cost = usage.cost?.total; | |
| 85 | // Pi defaults omitted catalog rates to zero. Let shared pricing estimate | |
| 86 | // API-equivalent cost when the transcript has no positive cost estimate. | |
| 87 | const reportedCostUsd = | |
| 88 | typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null; | |
| 89 | if (totalTokens(totals) === 0 && reportedCostUsd === null) return []; | |
| 90 | return [ | |
| 91 | { | |
| 92 | provider: "pi", | |
| 93 | timestampMs, | |
| 94 | model, | |
| 95 | ...(rateModel === undefined ? {} : { rateModel }), | |
| 96 | sessionId: state.sessionId, | |
| 97 | totals, | |
| 98 | reportedCostUsd, | |
| 99 | speed: "standard", | |
| 100 | // Forks copy entries verbatim, including ID/timestamp. Entry IDs are only | |
| 101 | // eight random hex digits, so retain timestamp/model to avoid collisions | |
| 102 | // across unrelated sessions. Parent IDs may change when Pi branches. | |
| 103 | dedupeKey: JSON.stringify(["pi", entry.id, entry.timestamp, model]), | |
| 104 | }, | |
| 105 | ]; | |
| 106 | } | |
| 107 | ||
| 108 | export const piUsageFormat: TranscriptUsageFormat<typeof PiScanState.Type> = { | |
| 109 | selectFields: { | |
| 110 | type: true, | |
| 111 | id: true, | |
| 112 | timestamp: true, | |
| 113 | provider: true, | |
| 114 | model: true, | |
| 115 | usage: true, | |
| 116 | message: { role: true, provider: true, model: true, responseModel: true, usage: true }, | |
| 117 | }, | |
| 118 | mightCarryUsage: (line) => line.includes('"usage"') || line.includes('"session"'), | |
| 119 | parseLine: (line, state) => { | |
| 120 | let parsed: unknown; | |
| 121 | try { | |
| 122 | parsed = JSON.parse(line); | |
| 123 | } catch { | |
| 124 | return []; | |
| 125 | } | |
| 126 | return parseRecord(parsed, state); | |
| 127 | }, | |
| 128 | parseProjected: parseRecord, | |
| 129 | state: { initial: () => ({ sessionId: "" }), schema: PiScanState }, | |
| 130 | }; | |
| 131 | ||
| 132 | export const piUsageReader: ProviderUsageReader<PiSettings, Path.Path> = { | |
| 133 | kind: "transcripts", | |
| 134 | provider: "pi", | |
| 135 | format: piUsageFormat, | |
| 136 | directories: Effect.fn("piUsageReader.directories")(function* ({ environment }) { | |
| 137 | const path = yield* Path.Path; | |
| 138 | const home = yield* HostProcess.HomeDirectory; | |
| 139 | const sessionDir = environment.PI_CODING_AGENT_SESSION_DIR?.trim(); | |
| 140 | if (sessionDir) return [{ dir: path.resolve(expandHomePath(sessionDir, home)) }]; | |
| 141 | const agentDir = environment.PI_CODING_AGENT_DIR?.trim(); | |
| 142 | return [ | |
| 143 | { | |
| 144 | dir: path.resolve( | |
| 145 | agentDir ? expandHomePath(agentDir, home) : path.join(home, ".pi", "agent"), | |
| 146 | "sessions", | |
| 147 | ), | |
| 148 | }, | |
| 149 | ]; | |
| 150 | }), | |
| 151 | }; |