authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-10-11 00:51:23+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 17:51:23-05:00
log9afde1c6dbe00d2b87829732c88789c10f3429c0
treebff48e7e76dd39ba15a9c580a3a030d1fc1d9d85
parent6e9c5a851c92c52e2f4a8a80c655e60db133f9ac
signature Signed by PGP key B5690EEEBB952194

feat(pi): include native sessions in usage reports (#17848)


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];
1617
17export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {18export 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};
2527
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}
4144
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>;
8182
82/**83/**
83 * The drivers that keep usage history, in the order the usage page reads84 * 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 */
87export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<88export 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";
40import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";40import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
41import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts";41import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts";
42import * as ProviderHostLive from "../provider/ProviderHostLive.ts";42import * as ProviderHostLive from "../provider/ProviderHostLive.ts";
43import type { UsageRecord } from "@t3tools/provider-core/server/usage";43import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage";
44import * as UsageService from "./UsageService.ts";44import * as UsageService from "./UsageService.ts";
4545
46const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));46const 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}
270271
271describe("UsageService", () => {272describe("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];
8486
85interface SerializedFile {87interface 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;
269275
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 */
6import type { UsageProviderKind } from "@t3tools/contracts";6import type { UsageProviderKind } from "@t3tools/contracts";
7import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage";7import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage";
8import { piUsageFormat } from "@t3tools/provider-pi/server/usage";
8import { grokUsageFormat } from "@t3tools/provider-grok/server/usage";9import { grokUsageFormat } from "@t3tools/provider-grok/server/usage";
910
10import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts";11import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts";
11import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts";12import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts";
1213
13export const TEST_FORMATS: Record<"claude" | "codex" | "grok", TranscriptUsageFormat<unknown>> = {14export 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};
1823
19export const TEST_FORMAT_MAP = new Map<UsageProviderKind, TranscriptUsageFormat<unknown>>(24export 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.
16const readTranscriptRecords = (16const 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 = {
9999
100async function scan(100async 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}
111111
112describe("large usage records", () => {112describe("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", () => {
9192
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 });
9798
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 });
103104
...@@ -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 });
118120
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>;
4550
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
66
7## Understand your usage7## Understand your usage
88
9**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected9**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, and Cursor history
10environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent10from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent
11cost, split by token type and by speed. These estimates are not your subscription bill.11cost, 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 be12**Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be
13split, such as a provider-reported cost for a model without public rates, shows as **Other**.13split, 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.
3030
31Usage includes each configured account's history, including disabled accounts. Custom homes follow31Usage includes each configured account's history, including disabled accounts. Custom homes follow
32the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment32the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment
33variable. Use absolute paths or `~/` paths in the account's environment settings; relative33variable. Pi reads `~/.pi/agent/sessions`, or the `sessions` directory under
34environment paths depend on each project's working directory and cannot be reliably discovered34`PI_CODING_AGENT_DIR`. Set `PI_CODING_AGENT_SESSION_DIR` to read a different session directory.
35by Usage. Accounts sharing a history directory count once.35To include sessions saved through `--session-dir` or Pi's `sessionDir` setting, set this environment
36variable to the same directory. Pi includes recorded tool and summary costs under `Tools/summaries`.
37Use absolute paths or `~/` paths in the account's environment settings; relative environment paths
38depend on each project's working directory and cannot be reliably discovered by Usage. Accounts
39sharing a history directory count once.
3640
37When your app and server support different providers, usage totals may cover only the providers41When your app and server support different providers, usage totals may cover only the providers
38your app understands. Update the app to include newly supported providers.42your 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]);
42export type UsageProviderKind = typeof UsageProviderKind.Type;43export type UsageProviderKind = typeof UsageProviderKind.Type;
4344
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";
1717
18import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";18import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
19import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";19import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
20import { piUsageReader } from "./usage.ts";
20import { makePiTextGeneration } from "./textGeneration.ts";21import { makePiTextGeneration } from "./textGeneration.ts";
21import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts";22import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts";
22import { ProviderDriverError } from "@t3tools/provider-core/server/errors";23import { 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 });
8283
83export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = {84export 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. */
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Path from "effect/Path";
5import * as Schema from "effect/Schema";
6
7import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
8import {
9 parseTimestampMs,
10 tokenCount,
11 totalTokens,
12 type ProviderUsageReader,
13 type TranscriptUsageFormat,
14 type UsageRecord,
15} from "@t3tools/provider-core/server/usage";
16import * as HostProcess from "@t3tools/shared/HostProcess";
17import type { PiSettings } from "../settings.ts";
18
19const PiScanState = Schema.Struct({ sessionId: Schema.mutableKey(Schema.String) });
20const 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});
28const Identity = { id: Schema.NonEmptyString, timestamp: Schema.String };
29const Model = { provider: Schema.NonEmptyString, model: Schema.NonEmptyString };
30const 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]);
52const decodeEntry = Schema.decodeUnknownOption(Entry);
53
54function 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
108export 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
132export 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};