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[] = [
1212 "cursor",
1313 "opencode",
1414 "antigravity",
15 "pi",
1516];
1617
1718export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
......@@ -21,6 +22,7 @@ export const PROVIDER_LABEL: Record<UsageProviderKind, string> = {
2122 cursor: "Cursor",
2223 opencode: "OpenCode",
2324 antigravity: "Antigravity",
25 pi: "Pi",
2426};
2527
2628/**
......@@ -36,6 +38,7 @@ export function useProviderColors(): Record<UsageProviderKind, string> {
3638 cursor: "#8b8b8b",
3739 opencode: "#5b9bbd",
3840 antigravity: "#8c7bd1",
41 pi: "#4d9f8a",
3942 };
4043}
4144
apps/server/src/provider/builtInDrivers.ts+11-2
......@@ -77,7 +77,8 @@ export type BuiltInUsageReadersEnv =
7777 | ProviderUsageReaderEnv<typeof GrokDriver>
7878 | ProviderUsageReaderEnv<typeof OpenCodeDriver>
7979 | ProviderUsageReaderEnv<typeof AntigravityDriver>
80 | ProviderUsageReaderEnv<typeof CursorDriver>;
80 | ProviderUsageReaderEnv<typeof CursorDriver>
81 | ProviderUsageReaderEnv<typeof PiDriver>;
8182
8283/**
8384 * The drivers that keep usage history, in the order the usage page reads
......@@ -86,4 +87,12 @@ export type BuiltInUsageReadersEnv =
8687 */
8788export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray<
8889 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";
4040import * as BackgroundPolicy from "../background/BackgroundPolicy.ts";
4141import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts";
4242import * 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";
4444import * as UsageService from "./UsageService.ts";
4545
4646const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
......@@ -155,6 +155,7 @@ const layerService = (input: {
155155 Layer.succeed(HostProcess.Environment, {
156156 HOME: input.home,
157157 GROK_HOME: NodePath.join(input.home, "grok"),
158 PI_CODING_AGENT_DIR: NodePath.join(input.home, "pi"),
158159 OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"),
159160 ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"),
160161 XDG_CONFIG_HOME: NodePath.join(input.home, "config"),
......@@ -269,6 +270,318 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) {
269270}
270271
271272describe("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
272585 it.live("reads shared managed default and disabled extra account history once", () =>
273586 Effect.gen(function* () {
274587 const { home, settings } = yield* setup;
apps/server/src/usage/usageScanCache.ts+9
......@@ -80,6 +80,8 @@ type SerializedRecord = readonly [
8080 dedupeKey: string | null,
8181 reportedCostUsd: number | null,
8282 speed: number,
83 /** Optional trailing field keeps existing cache rows readable. */
84 rateModelIndex?: number | null,
8385];
8486
8587interface SerializedFile {
......@@ -138,6 +140,9 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile
138140 record.dedupeKey,
139141 record.reportedCostUsd,
140142 SPEEDS.indexOf(record.speed),
143 record.rateModel === undefined
144 ? null
145 : intern(tables.models, tables.modelIndex, record.rateModel),
141146 ];
142147 return {
143148 s: entry.size,
......@@ -264,14 +269,17 @@ export function decodeScanCache(
264269 dedupeKey,
265270 reportedCostUsd,
266271 speedIndex,
272 rateModelIndex,
267273 ] = row as SerializedRecord;
268274 const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined;
269275
270276 const model = typeof modelIndex === "number" ? models[modelIndex] : undefined;
277 const rateModel = typeof rateModelIndex === "number" ? models[rateModelIndex] : undefined;
271278 if (
272279 typeof timestampMs !== "number" ||
273280 !Number.isFinite(timestampMs) ||
274281 model === undefined ||
282 (rateModelIndex != null && rateModel === undefined) ||
275283 !Number.isFinite(uncached) ||
276284 !Number.isFinite(cached) ||
277285 !Number.isFinite(cacheCreation) ||
......@@ -286,6 +294,7 @@ export function decodeScanCache(
286294 provider,
287295 timestampMs,
288296 model,
297 ...(rateModel === undefined ? {} : { rateModel }),
289298 sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "",
290299 totals: {
291300 uncachedInputTokens: uncached,
apps/server/src/usage/usageTestFormats.ts+6-1
......@@ -5,15 +5,20 @@
55 */
66import type { UsageProviderKind } from "@t3tools/contracts";
77import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage";
8import { piUsageFormat } from "@t3tools/provider-pi/server/usage";
89import { grokUsageFormat } from "@t3tools/provider-grok/server/usage";
910
1011import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts";
1112import { 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> = {
1418 claude: claudeUsageFormat,
1519 codex: codexUsageFormat,
1620 grok: grokUsageFormat,
21 pi: piUsageFormat,
1722};
1823
1924export const TEST_FORMAT_MAP = new Map<UsageProviderKind, TranscriptUsageFormat<unknown>>(
apps/server/src/usage/usageTranscriptStreaming.test.ts+45-2
......@@ -15,7 +15,7 @@ import {
1515// external 65/517 MiB fixtures also exercise the production threshold.
1616const readTranscriptRecords = (
1717 path: string,
18 provider: "claude" | "codex" | "grok",
18 provider: keyof typeof TEST_FORMATS,
1919 position?: TranscriptParsePosition,
2020) =>
2121 readWithDefaultThreshold(path, TEST_FORMATS[provider], position, {
......@@ -99,7 +99,7 @@ const grok = {
9999
100100async function scan(
101101 lines: readonly unknown[],
102 provider: "claude" | "codex" | "grok",
102 provider: keyof typeof TEST_FORMATS,
103103 name = "history",
104104) {
105105 const path = NodePath.join(dir, `${name}.jsonl`);
......@@ -110,6 +110,49 @@ async function scan(
110110}
111111
112112describe("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
113156 it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => {
114157 const result = await scan([claude()], "claude");
115158 expect(result.records).toEqual([
apps/web/src/components/usage/UsageProviderChart.test.ts+6-4
......@@ -69,11 +69,12 @@ describe("buildPeriodColumns", () => {
6969 "2026-08-01",
7070 {
7171 day: "2026-08-01",
72 costUsd: 30,
73 totalTokens: 300,
72 costUsd: 35,
73 totalTokens: 350,
7474 byProvider: new Map([
7575 ["codex" as const, { costUsd: 10, totalTokens: 100 }],
7676 ["claude" as const, { costUsd: 20, totalTokens: 200 }],
77 ["pi" as const, { costUsd: 5, totalTokens: 50 }],
7778 ]),
7879 },
7980 ],
......@@ -91,13 +92,13 @@ describe("buildPeriodColumns", () => {
9192
9293 it("plots each day on its own", () => {
9394 expect(buildPeriodColumns(days, byDay, "cost").map((column) => column.total)).toEqual([
94 30, 0, 5,
95 35, 0, 5,
9596 ]);
9697 });
9798
9899 it("reads the requested metric", () => {
99100 expect(buildPeriodColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([
100 300, 0, 50,
101 350, 0, 50,
101102 ]);
102103 });
103104
......@@ -113,6 +114,7 @@ describe("buildPeriodColumns", () => {
113114 { provider: "cursor", value: 0 },
114115 { provider: "opencode", value: 0 },
115116 { provider: "antigravity", value: 0 },
117 { provider: "pi", value: 5 },
116118 ]);
117119 });
118120
apps/web/src/components/usage/usageProviders.ts+5
......@@ -41,6 +41,11 @@ export const PROVIDER_PRESENTATION = {
4141 color: "#8c7bd1",
4242 driverKind: ProviderDriverKind.make("antigravity"),
4343 },
44 pi: {
45 label: "Pi",
46 color: "#4d9f8a",
47 driverKind: ProviderDriverKind.make("pi"),
48 },
4449} satisfies Record<UsageProviderKind, UsageProviderPresentation>;
4550
4651/** 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
66
77## Understand your usage
88
9**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected
10environments. 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
10from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent
1111cost, split by token type and by speed. These estimates are not your subscription bill.
1212**Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be
1313split, 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.
3030
3131Usage includes each configured account's history, including disabled accounts. Custom homes follow
3232the 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; relative
34environment paths depend on each project's working directory and cannot be reliably discovered
35by Usage. Accounts sharing a history directory count once.
33variable. 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.
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
3741When your app and server support different providers, usage totals may cover only the providers
3842your 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([
3838 "cursor",
3939 "opencode",
4040 "antigravity",
41 "pi",
4142]);
4243export type UsageProviderKind = typeof UsageProviderKind.Type;
4344
packages/provider-pi/package.json+4
......@@ -14,6 +14,10 @@
1414 "./server": {
1515 "types": "./src/server.ts",
1616 "import": "./src/server.ts"
17 },
18 "./server/usage": {
19 "types": "./src/server/usage.ts",
20 "import": "./src/server/usage.ts"
1721 }
1822 },
1923 "scripts": {
packages/provider-pi/src/server/driver.ts+3-1
......@@ -17,6 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
1717
1818import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions";
1919import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost";
20import { piUsageReader } from "./usage.ts";
2021import { makePiTextGeneration } from "./textGeneration.ts";
2122import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts";
2223import { ProviderDriverError } from "@t3tools/provider-core/server/errors";
......@@ -80,12 +81,13 @@ const withInstanceIdentity =
8081 continuation: { groupKey: input.continuationGroupKey },
8182 });
8283
83export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv> = {
84export const PiDriver: ProviderDriver<PiSettings, PiDriverEnv, Path.Path> = {
8485 driverKind: DRIVER_KIND,
8586 metadata: {
8687 displayName: "Pi",
8788 supportsMultipleInstances: true,
8889 },
90 usage: piUsageReader,
8991 configSchema: PiSettings,
9092 defaultConfig: (): PiSettings => decodePiSettings({}),
9193 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};