| 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,6 +12,7 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ |
| 12 | "cursor", | 12 | "cursor", |
| 13 | "opencode", | 13 | "opencode", |
| 14 | "antigravity", | 14 | "antigravity", |
| 15 | "pi", | ||
| 15 | ]; | 16 | ]; |
| 16 | 17 | ||
| 17 | export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { | 18 | export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { |
| ... | @@ -21,6 +22,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { | ... | @@ -21,6 +22,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = { |
| 21 | cursor: "Cursor", | 22 | cursor: "Cursor", |
| 22 | opencode: "OpenCode", | 23 | opencode: "OpenCode", |
| 23 | antigravity: "Antigravity", | 24 | antigravity: "Antigravity", |
| 25 | pi: "Pi", | ||
| 24 | }; | 26 | }; |
| 25 | 27 | ||
| 26 | /** | 28 | /** |
| ... | @@ -36,6 +38,7 @@ export function useProviderColors(): Record<UsageProviderKind, string> { | ... | @@ -36,6 +38,7 @@ export function useProviderColors(): Record<UsageProviderKind, string> { |
| 36 | cursor: "#8b8b8b", | 38 | cursor: "#8b8b8b", |
| 37 | opencode: "#5b9bbd", | 39 | opencode: "#5b9bbd", |
| 38 | antigravity: "#8c7bd1", | 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,7 +77,8 @@ export type BuiltInUsageReadersEnv = |
| 77 | | ProviderUsageReaderEnv<typeof GrokDriver> | 77 | | ProviderUsageReaderEnv<typeof GrokDriver> |
| 78 | | ProviderUsageReaderEnv<typeof OpenCodeDriver> | 78 | | ProviderUsageReaderEnv<typeof OpenCodeDriver> |
| 79 | | ProviderUsageReaderEnv<typeof AntigravityDriver> | 79 | | ProviderUsageReaderEnv<typeof AntigravityDriver> |
| 80 | | ProviderUsageReaderEnv<typeof CursorDriver>; | 80 | | ProviderUsageReaderEnv<typeof CursorDriver> |
| 81 | | ProviderUsageReaderEnv<typeof PiDriver>; | ||
| 81 | 82 | ||
| 82 | /** | 83 | /** |
| 83 | * The drivers that keep usage history, in the order the usage page reads | 84 | * The drivers that keep usage history, in the order the usage page reads |
| ... | @@ -86,4 +87,12 @@ export type BuiltInUsageReadersEnv = | ... | @@ -86,4 +87,12 @@ export type BuiltInUsageReadersEnv = |
| 86 | */ | 87 | */ |
| 87 | export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< | 88 | export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< |
| 88 | AnyProviderDriver<BuiltInDriversEnv, BuiltInUsageReadersEnv> | 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,7 +40,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 40 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; | 40 | import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; |
| 41 | import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; | 41 | import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; |
| 42 | import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; | 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 | import * as UsageService from "./UsageService.ts"; | 44 | import * as UsageService from "./UsageService.ts"; |
| 45 | 45 | ||
| 46 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | 46 | const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); |
| ... | @@ -155,6 +155,7 @@ const layerService = (input: { | ... | @@ -155,6 +155,7 @@ const layerService = (input: { |
| 155 | Layer.succeed(HostProcess.Environment, { | 155 | Layer.succeed(HostProcess.Environment, { |
| 156 | HOME: input.home, | 156 | HOME: input.home, |
| 157 | GROK_HOME: NodePath.join(input.home, "grok"), | 157 | GROK_HOME: NodePath.join(input.home, "grok"), |
| 158 | PI_CODING_AGENT_DIR: NodePath.join(input.home, "pi"), | ||
| 158 | OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), | 159 | OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), |
| 159 | ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), | 160 | ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), |
| 160 | XDG_CONFIG_HOME: NodePath.join(input.home, "config"), | 161 | XDG_CONFIG_HOME: NodePath.join(input.home, "config"), |
| ... | @@ -269,6 +270,318 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { | ... | @@ -269,6 +270,318 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { |
| 269 | } | 270 | } |
| 270 | 271 | ||
| 271 | describe("UsageService", () => { | 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 | it.live("reads shared managed default and disabled extra account history once", () => | 585 | it.live("reads shared managed default and disabled extra account history once", () => |
| 273 | Effect.gen(function* () { | 586 | Effect.gen(function* () { |
| 274 | const { home, settings } = yield* setup; | 587 | const { home, settings } = yield* setup; |
apps/server/src/usage/usageScanCache.ts+9| ... | @@ -80,6 +80,8 @@ type SerializedRecord = readonly [ | ... | @@ -80,6 +80,8 @@ type SerializedRecord = readonly [ |
| 80 | dedupeKey: string | null, | 80 | dedupeKey: string | null, |
| 81 | reportedCostUsd: number | null, | 81 | reportedCostUsd: number | null, |
| 82 | speed: number, | 82 | speed: number, |
| 83 | /** Optional trailing field keeps existing cache rows readable. */ | ||
| 84 | rateModelIndex?: number | null, | ||
| 83 | ]; | 85 | ]; |
| 84 | 86 | ||
| 85 | interface SerializedFile { | 87 | interface SerializedFile { |
| ... | @@ -138,6 +140,9 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile | ... | @@ -138,6 +140,9 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile |
| 138 | record.dedupeKey, | 140 | record.dedupeKey, |
| 139 | record.reportedCostUsd, | 141 | record.reportedCostUsd, |
| 140 | SPEEDS.indexOf(record.speed), | 142 | SPEEDS.indexOf(record.speed), |
| 143 | record.rateModel === undefined | ||
| 144 | ? null | ||
| 145 | : intern(tables.models, tables.modelIndex, record.rateModel), | ||
| 141 | ]; | 146 | ]; |
| 142 | return { | 147 | return { |
| 143 | s: entry.size, | 148 | s: entry.size, |
| ... | @@ -264,14 +269,17 @@ export function decodeScanCache( | ... | @@ -264,14 +269,17 @@ export function decodeScanCache( |
| 264 | dedupeKey, | 269 | dedupeKey, |
| 265 | reportedCostUsd, | 270 | reportedCostUsd, |
| 266 | speedIndex, | 271 | speedIndex, |
| 272 | rateModelIndex, | ||
| 267 | ] = row as SerializedRecord; | 273 | ] = row as SerializedRecord; |
| 268 | const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; | 274 | const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; |
| 269 | 275 | ||
| 270 | const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; | 276 | const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; |
| 277 | const rateModel = typeof rateModelIndex === "number" ? models[rateModelIndex] : undefined; | ||
| 271 | if ( | 278 | if ( |
| 272 | typeof timestampMs !== "number" || | 279 | typeof timestampMs !== "number" || |
| 273 | !Number.isFinite(timestampMs) || | 280 | !Number.isFinite(timestampMs) || |
| 274 | model === undefined || | 281 | model === undefined || |
| 282 | (rateModelIndex != null && rateModel === undefined) || | ||
| 275 | !Number.isFinite(uncached) || | 283 | !Number.isFinite(uncached) || |
| 276 | !Number.isFinite(cached) || | 284 | !Number.isFinite(cached) || |
| 277 | !Number.isFinite(cacheCreation) || | 285 | !Number.isFinite(cacheCreation) || |
| ... | @@ -286,6 +294,7 @@ export function decodeScanCache( | ... | @@ -286,6 +294,7 @@ export function decodeScanCache( |
| 286 | provider, | 294 | provider, |
| 287 | timestampMs, | 295 | timestampMs, |
| 288 | model, | 296 | model, |
| 297 | ...(rateModel === undefined ? {} : { rateModel }), | ||
| 289 | sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", | 298 | sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", |
| 290 | totals: { | 299 | totals: { |
| 291 | uncachedInputTokens: uncached, | 300 | uncachedInputTokens: uncached, |
apps/server/src/usage/usageTestFormats.ts+6-1| ... | @@ -5,15 +5,20 @@ | ... | @@ -5,15 +5,20 @@ |
| 5 | */ | 5 | */ |
| 6 | import type { UsageProviderKind } from "@t3tools/contracts"; | 6 | import type { UsageProviderKind } from "@t3tools/contracts"; |
| 7 | import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; | 7 | import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; |
| 8 | import { piUsageFormat } from "@t3tools/provider-pi/server/usage"; | ||
| 8 | import { grokUsageFormat } from "@t3tools/provider-grok/server/usage"; | 9 | import { grokUsageFormat } from "@t3tools/provider-grok/server/usage"; |
| 9 | 10 | ||
| 10 | import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts"; | 11 | import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts"; |
| 11 | import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts"; | 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 | claude: claudeUsageFormat, | 18 | claude: claudeUsageFormat, |
| 15 | codex: codexUsageFormat, | 19 | codex: codexUsageFormat, |
| 16 | grok: grokUsageFormat, | 20 | grok: grokUsageFormat, |
| 21 | pi: piUsageFormat, | ||
| 17 | }; | 22 | }; |
| 18 | 23 | ||
| 19 | export const TEST_FORMAT_MAP = new Map<UsageProviderKind, TranscriptUsageFormat<unknown>>( | 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,7 +15,7 @@ import { |
| 15 | // external 65/517 MiB fixtures also exercise the production threshold. | 15 | // external 65/517 MiB fixtures also exercise the production threshold. |
| 16 | const readTranscriptRecords = ( | 16 | const readTranscriptRecords = ( |
| 17 | path: string, | 17 | path: string, |
| 18 | provider: "claude" | "codex" | "grok", | 18 | provider: keyof typeof TEST_FORMATS, |
| 19 | position?: TranscriptParsePosition, | 19 | position?: TranscriptParsePosition, |
| 20 | ) => | 20 | ) => |
| 21 | readWithDefaultThreshold(path, TEST_FORMATS[provider], position, { | 21 | readWithDefaultThreshold(path, TEST_FORMATS[provider], position, { |
| ... | @@ -99,7 +99,7 @@ const grok = { | ... | @@ -99,7 +99,7 @@ const grok = { |
| 99 | 99 | ||
| 100 | async function scan( | 100 | async function scan( |
| 101 | lines: readonly unknown[], | 101 | lines: readonly unknown[], |
| 102 | provider: "claude" | "codex" | "grok", | 102 | provider: keyof typeof TEST_FORMATS, |
| 103 | name = "history", | 103 | name = "history", |
| 104 | ) { | 104 | ) { |
| 105 | const path = NodePath.join(dir, `${name}.jsonl`); | 105 | const path = NodePath.join(dir, `${name}.jsonl`); |
| ... | @@ -110,6 +110,49 @@ async function scan( | ... | @@ -110,6 +110,49 @@ async function scan( |
| 110 | } | 110 | } |
| 111 | 111 | ||
| 112 | describe("large usage records", () => { | 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 | it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => { | 156 | it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => { |
| 114 | const result = await scan([claude()], "claude"); | 157 | const result = await scan([claude()], "claude"); |
| 115 | expect(result.records).toEqual([ | 158 | expect(result.records).toEqual([ |
apps/web/src/components/usage/UsageProviderChart.test.ts+6-4| ... | @@ -69,11 +69,12 @@ describe("buildPeriodColumns", () => { | ... | @@ -69,11 +69,12 @@ describe("buildPeriodColumns", () => { |
| 69 | "2026-08-01", | 69 | "2026-08-01", |
| 70 | { | 70 | { |
| 71 | day: "2026-08-01", | 71 | day: "2026-08-01", |
| 72 | costUsd: 30, | 72 | costUsd: 35, |
| 73 | totalTokens: 300, | 73 | totalTokens: 350, |
| 74 | byProvider: new Map([ | 74 | byProvider: new Map([ |
| 75 | ["codex" as const, { costUsd: 10, totalTokens: 100 }], | 75 | ["codex" as const, { costUsd: 10, totalTokens: 100 }], |
| 76 | ["claude" as const, { costUsd: 20, totalTokens: 200 }], | 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,13 +92,13 @@ describe("buildPeriodColumns", () => { |
| 91 | 92 | ||
| 92 | it("plots each day on its own", () => { | 93 | it("plots each day on its own", () => { |
| 93 | expect(buildPeriodColumns(days, byDay, "cost").map((column) => column.total)).toEqual([ | 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 | it("reads the requested metric", () => { | 99 | it("reads the requested metric", () => { |
| 99 | expect(buildPeriodColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ | 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,6 +114,7 @@ describe("buildPeriodColumns", () => { |
| 113 | { provider: "cursor", value: 0 }, | 114 | { provider: "cursor", value: 0 }, |
| 114 | { provider: "opencode", value: 0 }, | 115 | { provider: "opencode", value: 0 }, |
| 115 | { provider: "antigravity", value: 0 }, | 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,6 +41,11 @@ export const PROVIDER_PRESENTATION = { |
| 41 | color: "#8c7bd1", | 41 | color: "#8c7bd1", |
| 42 | driverKind: ProviderDriverKind.make("antigravity"), | 42 | driverKind: ProviderDriverKind.make("antigravity"), |
| 43 | }, | 43 | }, |
| 44 | pi: { | ||
| 45 | label: "Pi", | ||
| 46 | color: "#4d9f8a", | ||
| 47 | driverKind: ProviderDriverKind.make("pi"), | ||
| 48 | }, | ||
| 44 | } satisfies Record<UsageProviderKind, UsageProviderPresentation>; | 49 | } satisfies Record<UsageProviderKind, UsageProviderPresentation>; |
| 45 | 50 | ||
| 46 | /** Stable provider reading order across charts, summaries, tables, and hover rows. */ | 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,8 +6,8 @@ desktop when the terminal is not focused. Customize `usage.open` in |
| 6 | 6 | ||
| 7 | ## Understand your usage | 7 | ## Understand your usage |
| 8 | 8 | ||
| 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected | 9 | **Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, and Cursor history |
| 10 | environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent | 10 | from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent |
| 11 | cost, split by token type and by speed. These estimates are not your subscription bill. | 11 | cost, split by token type and by speed. These estimates are not your subscription bill. |
| 12 | **Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be | 12 | **Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be |
| 13 | split, such as a provider-reported cost for a model without public rates, shows as **Other**. | 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,9 +30,13 @@ you to allow access on the server Mac. |
| 30 | 30 | ||
| 31 | Usage includes each configured account's history, including disabled accounts. Custom homes follow | 31 | Usage includes each configured account's history, including disabled accounts. Custom homes follow |
| 32 | the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment | 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 | 33 | variable. Pi reads `~/.pi/agent/sessions`, or the `sessions` directory under |
| 34 | environment paths depend on each project's working directory and cannot be reliably discovered | 34 | `PI_CODING_AGENT_DIR`. Set `PI_CODING_AGENT_SESSION_DIR` to read a different session directory. |
| 35 | by Usage. Accounts sharing a history directory count once. | 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 | When your app and server support different providers, usage totals may cover only the providers | 41 | When your app and server support different providers, usage totals may cover only the providers |
| 38 | your app understands. Update the app to include newly supported providers. | 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,6 +38,7 @@ export const UsageProviderKind = Schema.Literals([ |
| 38 | "cursor", | 38 | "cursor", |
| 39 | "opencode", | 39 | "opencode", |
| 40 | "antigravity", | 40 | "antigravity", |
| 41 | "pi", | ||
| 41 | ]); | 42 | ]); |
| 42 | export type UsageProviderKind = typeof UsageProviderKind.Type; | 43 | export type UsageProviderKind = typeof UsageProviderKind.Type; |
| 43 | 44 |
packages/provider-pi/package.json+4| ... | @@ -14,6 +14,10 @@ | ... | @@ -14,6 +14,10 @@ |
| 14 | "./server": { | 14 | "./server": { |
| 15 | "types": "./src/server.ts", | 15 | "types": "./src/server.ts", |
| 16 | "import": "./src/server.ts" | 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 | "scripts": { | 23 | "scripts": { |
packages/provider-pi/src/server/driver.ts+3-1| ... | @@ -17,6 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; | ... | @@ -17,6 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 17 | 17 | ||
| 18 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; | 18 | import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; |
| 19 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; | 19 | import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; |
| 20 | import { piUsageReader } from "./usage.ts"; | ||
| 20 | import { makePiTextGeneration } from "./textGeneration.ts"; | 21 | import { makePiTextGeneration } from "./textGeneration.ts"; |
| 21 | import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; | 22 | import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; |
| 22 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; | 23 | import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; |
| ... | @@ -80,12 +81,13 @@ const withInstanceIdentity = | ... | @@ -80,12 +81,13 @@ const withInstanceIdentity = |
| 80 | continuation: { groupKey: input.continuationGroupKey }, | 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 | driverKind: DRIVER_KIND, | 85 | driverKind: DRIVER_KIND, |
| 85 | metadata: { | 86 | metadata: { |
| 86 | displayName: "Pi", | 87 | displayName: "Pi", |
| 87 | supportsMultipleInstances: true, | 88 | supportsMultipleInstances: true, |
| 88 | }, | 89 | }, |
| 90 | usage: piUsageReader, | ||
| 89 | configSchema: PiSettings, | 91 | configSchema: PiSettings, |
| 90 | defaultConfig: (): PiSettings => decodePiSettings({}), | 92 | defaultConfig: (): PiSettings => decodePiSettings({}), |
| 91 | create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => | 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 | }; | ||