From 9afde1c6dbe00d2b87829732c88789c10f3429c0 Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:51:23 +0200 Subject: [PATCH] feat(pi): include native sessions in usage reports (#17848) --- .../src/features/usage/usageProviders.ts | 3 + apps/server/src/provider/builtInDrivers.ts | 13 +- apps/server/src/usage/UsageService.test.ts | 315 +++++++++++++++++- apps/server/src/usage/usageScanCache.ts | 9 + apps/server/src/usage/usageTestFormats.ts | 7 +- .../usage/usageTranscriptStreaming.test.ts | 47 ++- .../usage/UsageProviderChart.test.ts | 10 +- .../src/components/usage/usageProviders.ts | 5 + docs/user/usage.md | 14 +- packages/contracts/src/usage.ts | 1 + packages/provider-pi/package.json | 4 + packages/provider-pi/src/server/driver.ts | 4 +- packages/provider-pi/src/server/usage.ts | 151 +++++++++ 13 files changed, 567 insertions(+), 16 deletions(-) create mode 100644 packages/provider-pi/src/server/usage.ts diff --git a/apps/mobile/src/features/usage/usageProviders.ts b/apps/mobile/src/features/usage/usageProviders.ts index 5742cbca1043368b48d98dfb4bcde2917aeeac2c..e8a58557cad74c2392d0eea6339ae0e6d16da22f 100644 --- a/apps/mobile/src/features/usage/usageProviders.ts +++ b/apps/mobile/src/features/usage/usageProviders.ts @@ -12,6 +12,7 @@ export const PROVIDER_ORDER: readonly UsageProviderKind[] = [ "cursor", "opencode", "antigravity", + "pi", ]; export const PROVIDER_LABEL: Record = { @@ -21,6 +22,7 @@ export const PROVIDER_LABEL: Record = { cursor: "Cursor", opencode: "OpenCode", antigravity: "Antigravity", + pi: "Pi", }; /** @@ -36,6 +38,7 @@ export function useProviderColors(): Record { cursor: "#8b8b8b", opencode: "#5b9bbd", antigravity: "#8c7bd1", + pi: "#4d9f8a", }; } diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 532bd602d1453cd3d1906fd9f3ba4ad2e7ce2547..d4c5c4474b8f84a5fd0147b18a5ddc0cb38d1e87 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -77,7 +77,8 @@ export type BuiltInUsageReadersEnv = | ProviderUsageReaderEnv | ProviderUsageReaderEnv | ProviderUsageReaderEnv - | ProviderUsageReaderEnv; + | ProviderUsageReaderEnv + | ProviderUsageReaderEnv; /** * The drivers that keep usage history, in the order the usage page reads @@ -86,4 +87,12 @@ export type BuiltInUsageReadersEnv = */ export const BUILT_IN_USAGE_DRIVERS: ReadonlyArray< AnyProviderDriver -> = [ClaudeDriver, CodexDriver, GrokDriver, OpenCodeDriver, AntigravityDriver, CursorDriver]; +> = [ + ClaudeDriver, + CodexDriver, + GrokDriver, + PiDriver, + OpenCodeDriver, + AntigravityDriver, + CursorDriver, +]; diff --git a/apps/server/src/usage/UsageService.test.ts b/apps/server/src/usage/UsageService.test.ts index e28e926d3f8907a416df32db5ffa8e55934feaeb..af26c30a477012a47f60f7d0fc6460e148e01be4 100644 --- a/apps/server/src/usage/UsageService.test.ts +++ b/apps/server/src/usage/UsageService.test.ts @@ -40,7 +40,7 @@ import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as AntigravityUsage from "../provider/Drivers/AntigravityUsage.ts"; import * as ProviderHostLive from "../provider/ProviderHostLive.ts"; -import type { UsageRecord } from "@t3tools/provider-core/server/usage"; +import { totalTokens, type UsageRecord } from "@t3tools/provider-core/server/usage"; import * as UsageService from "./UsageService.ts"; const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -155,6 +155,7 @@ const layerService = (input: { Layer.succeed(HostProcess.Environment, { HOME: input.home, GROK_HOME: NodePath.join(input.home, "grok"), + PI_CODING_AGENT_DIR: NodePath.join(input.home, "pi"), OPENCODE_DATA_DIR: NodePath.join(input.home, "opencode"), ANTIGRAVITY_DATA_DIR: NodePath.join(input.home, "antigravity"), XDG_CONFIG_HOME: NodePath.join(input.home, "config"), @@ -269,6 +270,318 @@ function cursorSource(summary: { readonly sources: readonly UsageSource[] }) { } describe("UsageService", () => { + it.live( + "reads Pi per-model usage and auxiliary costs once across forks and repeated entries", + () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + const dir = NodePath.join(home, "pi", "sessions", "project"); + const usage = { + input: 10, + output: 5, + cacheRead: 20, + cacheWrite: 3, + reasoning: 2, + totalTokens: 38, + cost: { input: 0.1, output: 0.2, cacheRead: 0.3, cacheWrite: 0.4, total: 1 }, + }; + const parent = { + type: "message", + id: "a123abcd", + parentId: null, + timestamp: "2026-08-01T10:00:00Z", + message: { + role: "assistant", + provider: "anthropic", + model: "requested-model", + responseModel: "actual-model", + usage, + }, + }; + const auxiliary = [ + { + type: "message", + id: "tool", + message: { role: "toolResult", toolName: "classify", usage }, + }, + { + type: "message", + id: "image", + message: { + role: "toolResult", + toolName: "image", + usage: { ...usage, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + }, + { type: "compaction", id: "compact", usage }, + { type: "branch_summary", id: "summary", usage }, + ].map((entry) => ({ ...entry, timestamp: "2026-08-01T11:00:00Z" })); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "parent.jsonl"), + [ + { type: "session", id: "parent" }, + parent, + ...auxiliary, + { + type: "usage", + id: "warm", + timestamp: "2026-08-01T12:00:00Z", + kind: "cache_warm", + provider: "openai", + model: "actual-model", + usage: { ...usage, cost: { ...usage.cost, total: 0 } }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + await NodeFSP.writeFile( + NodePath.join(dir, "fork.jsonl"), + [ + { type: "session", id: "fork", parentSession: NodePath.join(dir, "parent.jsonl") }, + { ...parent, parentId: "relinked" }, + parent, + ...auxiliary, + { + ...parent, + timestamp: "2026-08-01T13:00:00Z", + message: { ...parent.message, provider: "openai", responseModel: "another-model" }, + }, + { type: "get_session_stats", usage: { ...usage, cost: { total: 1000 } } }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const first = yield* service.readSummary(WINDOW); + const pi = first.buckets.filter((bucket) => bucket.provider === "pi"); + assert.strictEqual( + pi.reduce((total, bucket) => total + bucket.costUsd, 0), + 44, + ); + assert.strictEqual( + pi.reduce((total, bucket) => total + totalTokens(bucket.totals), 0), + 228, + ); + assert.deepStrictEqual(pi.map((bucket) => bucket.model).sort(), [ + "Tools/summaries", + "anthropic/actual-model", + "openai/actual-model", + "openai/another-model", + ]); + const assistant = pi.find((bucket) => bucket.model === "anthropic/actual-model"); + assert.deepStrictEqual(assistant?.totals, { + uncachedInputTokens: 10, + outputTokens: 5, + cachedInputTokens: 20, + cacheCreationTokens: 3, + reasoningTokens: 2, + }); + assert.strictEqual(assistant?.costSource, "providerReported"); + assert.approximately(assistant?.categoryCostUsd?.input ?? NaN, 10 / 38, 1e-6); + assert.approximately(assistant?.categoryCostUsd?.cacheRead ?? NaN, 2 / 38, 1e-6); + assert.approximately(assistant?.categoryCostUsd?.cacheWrite ?? NaN, 6 / 38, 1e-6); + assert.approximately(assistant?.categoryCostUsd?.output ?? NaN, 20 / 38, 1e-6); + assert.strictEqual(assistant?.cacheSavingsUsd, 18); + const warm = pi.find((bucket) => bucket.model === "openai/actual-model"); + assert.strictEqual(warm?.costSource, "modelPriced"); + assert.strictEqual(warm?.costUsd, 38); + assert.deepStrictEqual(warm?.categoryCostUsd, { + input: 10, + cacheRead: 2, + cacheWrite: 6, + output: 20, + }); + assert.strictEqual(warm?.cacheSavingsUsd, 18); + assert.strictEqual( + first.sources.filter((source) => source.fingerprint.provider === "pi").length, + 1, + ); + // Warm scans retain header state and only charge appended entries. + yield* Effect.promise(() => + NodeFSP.appendFile( + NodePath.join(dir, "fork.jsonl"), + encodeUnknownJsonString({ + ...parent, + id: "new", + timestamp: "2026-08-01T14:00:00Z", + message: { ...parent.message, responseModel: "latest-model" }, + }) + "\n", + ), + ); + const grown = yield* service.readSummary(WINDOW); + assert.strictEqual( + grown.buckets + .filter((bucket) => bucket.provider === "pi") + .reduce((total, bucket) => total + bucket.costUsd, 0), + 45, + ); + yield* service.awaitPersisted; + const restarted = yield* UsageService.make; + assert.deepStrictEqual((yield* restarted.readSummary(WINDOW)).buckets, grown.buckets); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-pi-fork", + home, + ratesDocument: { + "actual-model": { + input_cost_per_token: 1, + output_cost_per_token: 4, + cache_read_input_token_cost: 0.1, + cache_creation_input_token_cost: 2, + }, + }, + settings: { + ...settings, + providerInstances: { + ...settings.providerInstances, + [ProviderInstanceId.make("pi-extra")]: { + driver: ProviderDriverKind.make("pi"), + enabled: false, + environment: [ + { + name: "PI_CODING_AGENT_DIR", + value: NodePath.join(home, "pi"), + sensitive: false, + }, + ], + }, + }, + }, + }), + ), + ); + }).pipe(Effect.scoped), + ); + + it.live("reports missing Pi history and skips malformed records without losing valid usage", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const missing = yield* service.readSummary(WINDOW); + assert.strictEqual( + missing.sources.find((source) => source.fingerprint.provider === "pi")?.status, + "missing", + ); + const dir = NodePath.join(home, "pi", "sessions"); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "session.jsonl"), + [ + "{broken", + "null", + encodeUnknownJsonString({ type: "session", id: "valid" }), + encodeUnknownJsonString({ + type: "message", + id: "bad", + timestamp: "invalid", + message: { + role: "assistant", + provider: "openai", + model: "model", + usage: { input: 100 }, + }, + }), + encodeUnknownJsonString({ + type: "message", + id: "valid", + timestamp: "2026-08-01T10:00:00Z", + message: { + role: "assistant", + provider: "openai", + model: "model", + usage: { + input: -1, + cacheRead: "100", + output: 7, + reasoning: 50, + cost: { total: -1 }, + }, + }, + }), + ].join("\n") + "\n", + ); + }); + const summary = yield* service.readSummary(WINDOW); + const pi = summary.buckets.filter((bucket) => bucket.provider === "pi"); + assert.strictEqual(pi.length, 1); + assert.strictEqual(pi[0] && totalTokens(pi[0].totals), 7); + assert.strictEqual(pi[0]?.totals.reasoningTokens, 7); + assert.strictEqual(pi[0]?.costSource, "unpriced"); + }).pipe( + Effect.scoped, + Effect.provide(layerService({ prefix: "usage-pi-malformed", home, settings })), + ); + }).pipe(Effect.scoped), + ); + + it.live("discovers Pi history in its default home and directory overrides", () => + Effect.gen(function* () { + const { home, settings } = yield* setup; + for (const [agentDir, sessionDir, relativeDir] of [ + [undefined, undefined, ".pi/agent/sessions"], + ["~/custom-pi", undefined, "custom-pi/sessions"], + ["~/ignored-pi", "~/custom-sessions", "custom-sessions"], + ["~/ignored-pi", NodePath.join(home, "absolute-sessions"), "absolute-sessions"], + ] as const) { + const dir = NodePath.join(home, relativeDir); + yield* Effect.promise(async () => { + await NodeFSP.mkdir(dir, { recursive: true }); + await NodeFSP.writeFile( + NodePath.join(dir, "session.jsonl"), + [ + { type: "session", id: relativeDir }, + { + type: "usage", + id: "warm", + timestamp: "2026-08-01T10:00:00Z", + provider: "openai", + model: "model", + usage: { input: 7 }, + }, + ] + .map((line) => encodeUnknownJsonString(line)) + .join("\n") + "\n", + ); + }); + yield* Effect.gen(function* () { + const service = yield* UsageService.make; + const summary = yield* service.readSummary(WINDOW); + const pi = summary.buckets.filter((bucket) => bucket.provider === "pi"); + assert.strictEqual(pi.length, 1); + assert.strictEqual(pi[0]?.totals.uncachedInputTokens, 7); + assert.strictEqual( + summary.sources.find((source) => source.fingerprint.provider === "pi")?.fingerprint + .resolvedHomePath, + dir, + ); + }).pipe( + Effect.scoped, + Effect.provide( + layerService({ + prefix: "usage-pi-" + relativeDir.replaceAll("/", "-"), + home, + settings, + environment: { + PI_CODING_AGENT_DIR: agentDir, + PI_CODING_AGENT_SESSION_DIR: sessionDir, + }, + }), + ), + Effect.provideService(HostProcess.HomeDirectory, home), + ); + } + }).pipe(Effect.scoped), + ); + it.live("reads shared managed default and disabled extra account history once", () => Effect.gen(function* () { const { home, settings } = yield* setup; diff --git a/apps/server/src/usage/usageScanCache.ts b/apps/server/src/usage/usageScanCache.ts index 4673a4d8d07205ec4ef9f718d15dabf793d7c772..1163c58764b8c7a43dedf46d1133793ddf5375d1 100644 --- a/apps/server/src/usage/usageScanCache.ts +++ b/apps/server/src/usage/usageScanCache.ts @@ -80,6 +80,8 @@ type SerializedRecord = readonly [ dedupeKey: string | null, reportedCostUsd: number | null, speed: number, + /** Optional trailing field keeps existing cache rows readable. */ + rateModelIndex?: number | null, ]; interface SerializedFile { @@ -138,6 +140,9 @@ function serializeFile(entry: CachedFile, tables: InternTables): SerializedFile record.dedupeKey, record.reportedCostUsd, SPEEDS.indexOf(record.speed), + record.rateModel === undefined + ? null + : intern(tables.models, tables.modelIndex, record.rateModel), ]; return { s: entry.size, @@ -264,14 +269,17 @@ export function decodeScanCache( dedupeKey, reportedCostUsd, speedIndex, + rateModelIndex, ] = row as SerializedRecord; const speed = typeof speedIndex === "number" ? SPEEDS[speedIndex] : undefined; const model = typeof modelIndex === "number" ? models[modelIndex] : undefined; + const rateModel = typeof rateModelIndex === "number" ? models[rateModelIndex] : undefined; if ( typeof timestampMs !== "number" || !Number.isFinite(timestampMs) || model === undefined || + (rateModelIndex != null && rateModel === undefined) || !Number.isFinite(uncached) || !Number.isFinite(cached) || !Number.isFinite(cacheCreation) || @@ -286,6 +294,7 @@ export function decodeScanCache( provider, timestampMs, model, + ...(rateModel === undefined ? {} : { rateModel }), sessionId: (typeof sessionIndex === "number" ? sessions[sessionIndex] : undefined) ?? "", totals: { uncachedInputTokens: uncached, diff --git a/apps/server/src/usage/usageTestFormats.ts b/apps/server/src/usage/usageTestFormats.ts index be9a84a85b586fe3a9d2263c6ef4aa75d66b4423..8f75af1e8566cdaaa7146b2bf2200f4d8ede2a8e 100644 --- a/apps/server/src/usage/usageTestFormats.ts +++ b/apps/server/src/usage/usageTestFormats.ts @@ -5,15 +5,20 @@ */ import type { UsageProviderKind } from "@t3tools/contracts"; import type { TranscriptUsageFormat } from "@t3tools/provider-core/server/usage"; +import { piUsageFormat } from "@t3tools/provider-pi/server/usage"; import { grokUsageFormat } from "@t3tools/provider-grok/server/usage"; import { claudeUsageFormat } from "../provider/Drivers/claudeUsage.ts"; import { codexUsageFormat } from "../provider/Drivers/codexUsage.ts"; -export const TEST_FORMATS: Record<"claude" | "codex" | "grok", TranscriptUsageFormat> = { +export const TEST_FORMATS: Record< + "claude" | "codex" | "grok" | "pi", + TranscriptUsageFormat +> = { claude: claudeUsageFormat, codex: codexUsageFormat, grok: grokUsageFormat, + pi: piUsageFormat, }; export const TEST_FORMAT_MAP = new Map>( diff --git a/apps/server/src/usage/usageTranscriptStreaming.test.ts b/apps/server/src/usage/usageTranscriptStreaming.test.ts index 0467c17c5d49cd2f403a435c78f6159b66da04e4..47024ac0f748a7c80a913f1bfa5b3897eba0e176 100644 --- a/apps/server/src/usage/usageTranscriptStreaming.test.ts +++ b/apps/server/src/usage/usageTranscriptStreaming.test.ts @@ -15,7 +15,7 @@ import { // external 65/517 MiB fixtures also exercise the production threshold. const readTranscriptRecords = ( path: string, - provider: "claude" | "codex" | "grok", + provider: keyof typeof TEST_FORMATS, position?: TranscriptParsePosition, ) => readWithDefaultThreshold(path, TEST_FORMATS[provider], position, { @@ -99,7 +99,7 @@ const grok = { async function scan( lines: readonly unknown[], - provider: "claude" | "codex" | "grok", + provider: keyof typeof TEST_FORMATS, name = "history", ) { const path = NodePath.join(dir, `${name}.jsonl`); @@ -110,6 +110,49 @@ async function scan( } describe("large usage records", () => { + it("keeps Pi model, cost, and fork identity after large assistant content", async () => { + const entry = { + type: "message", + id: "a123abcd", + timestamp, + message: { + role: "assistant", + provider: "anthropic", + model: "requested", + responseModel: "actual", + content: [{ type: "text", text: content }], + usage: { + input: 100, + output: 99, + cacheRead: 20, + cacheWrite: 5, + reasoning: 10, + cost: { total: 0.25 }, + }, + }, + }; + const result = await scan([{ type: "session", id: "pi-session" }, entry], "pi"); + expect(result.records).toEqual([ + { + provider: "pi", + model: "anthropic/actual", + rateModel: "actual", + sessionId: "pi-session", + timestampMs: Date.parse(timestamp), + totals: { + uncachedInputTokens: 100, + outputTokens: 99, + cachedInputTokens: 20, + cacheCreationTokens: 5, + reasoningTokens: 10, + }, + reportedCostUsd: 0.25, + speed: "standard", + dedupeKey: JSON.stringify(["pi", entry.id, timestamp, "anthropic/actual"]), + }, + ]); + }); + it("keeps usage after large Claude tool input, including fast-mode cost and dedupe metadata", async () => { const result = await scan([claude()], "claude"); expect(result.records).toEqual([ diff --git a/apps/web/src/components/usage/UsageProviderChart.test.ts b/apps/web/src/components/usage/UsageProviderChart.test.ts index e3a453a5770a8ad3ed7aceb8a0e50f16e2496dcb..65358f238eb0b1582e95c1be7deaa22d793405aa 100644 --- a/apps/web/src/components/usage/UsageProviderChart.test.ts +++ b/apps/web/src/components/usage/UsageProviderChart.test.ts @@ -69,11 +69,12 @@ describe("buildPeriodColumns", () => { "2026-08-01", { day: "2026-08-01", - costUsd: 30, - totalTokens: 300, + costUsd: 35, + totalTokens: 350, byProvider: new Map([ ["codex" as const, { costUsd: 10, totalTokens: 100 }], ["claude" as const, { costUsd: 20, totalTokens: 200 }], + ["pi" as const, { costUsd: 5, totalTokens: 50 }], ]), }, ], @@ -91,13 +92,13 @@ describe("buildPeriodColumns", () => { it("plots each day on its own", () => { expect(buildPeriodColumns(days, byDay, "cost").map((column) => column.total)).toEqual([ - 30, 0, 5, + 35, 0, 5, ]); }); it("reads the requested metric", () => { expect(buildPeriodColumns(days, byDay, "tokens").map((column) => column.total)).toEqual([ - 300, 0, 50, + 350, 0, 50, ]); }); @@ -113,6 +114,7 @@ describe("buildPeriodColumns", () => { { provider: "cursor", value: 0 }, { provider: "opencode", value: 0 }, { provider: "antigravity", value: 0 }, + { provider: "pi", value: 5 }, ]); }); diff --git a/apps/web/src/components/usage/usageProviders.ts b/apps/web/src/components/usage/usageProviders.ts index 02bbfb40ffb49c61f726990fdf7d3b41c9dfc3fc..c7e3bb42b1adae542ef21f34a0bb0afb085ea8fe 100644 --- a/apps/web/src/components/usage/usageProviders.ts +++ b/apps/web/src/components/usage/usageProviders.ts @@ -41,6 +41,11 @@ export const PROVIDER_PRESENTATION = { color: "#8c7bd1", driverKind: ProviderDriverKind.make("antigravity"), }, + pi: { + label: "Pi", + color: "#4d9f8a", + driverKind: ProviderDriverKind.make("pi"), + }, } satisfies Record; /** Stable provider reading order across charts, summaries, tables, and hover rows. */ diff --git a/docs/user/usage.md b/docs/user/usage.md index 0cc4bcfe3ecba2d83bd98a3867ceac95a79fa4ed..ed1dd19ce1a109077a67ec401ce3eff060b8dc66 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -6,8 +6,8 @@ desktop when the terminal is not focused. Customize `usage.open` in ## Understand your usage -**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, and Cursor history from your connected -environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent +**Usage** combines Codex, Claude Code, Grok Build, OpenCode, Antigravity, Pi, and Cursor history +from your connected environments. It shows token use, cache savings, model breakdowns, and estimated API-equivalent cost, split by token type and by speed. These estimates are not your subscription bill. **Premium** is what Fast and Ultrafast requests cost above standard rates. Cost that cannot be 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. Usage includes each configured account's history, including disabled accounts. Custom homes follow the account's home setting or its `CODEX_HOME`, `CLAUDE_CONFIG_DIR`, or `GROK_HOME` environment -variable. Use absolute paths or `~/` paths in the account's environment settings; relative -environment paths depend on each project's working directory and cannot be reliably discovered -by Usage. Accounts sharing a history directory count once. +variable. Pi reads `~/.pi/agent/sessions`, or the `sessions` directory under +`PI_CODING_AGENT_DIR`. Set `PI_CODING_AGENT_SESSION_DIR` to read a different session directory. +To include sessions saved through `--session-dir` or Pi's `sessionDir` setting, set this environment +variable to the same directory. Pi includes recorded tool and summary costs under `Tools/summaries`. +Use absolute paths or `~/` paths in the account's environment settings; relative environment paths +depend on each project's working directory and cannot be reliably discovered by Usage. Accounts +sharing a history directory count once. When your app and server support different providers, usage totals may cover only the providers your app understands. Update the app to include newly supported providers. diff --git a/packages/contracts/src/usage.ts b/packages/contracts/src/usage.ts index 9aecc49ce5c31b9e8a671d7292eed9d3b4df3ffd..966acdaff77177cb82d4c62ef6bb29b1616da466 100644 --- a/packages/contracts/src/usage.ts +++ b/packages/contracts/src/usage.ts @@ -38,6 +38,7 @@ export const UsageProviderKind = Schema.Literals([ "cursor", "opencode", "antigravity", + "pi", ]); export type UsageProviderKind = typeof UsageProviderKind.Type; diff --git a/packages/provider-pi/package.json b/packages/provider-pi/package.json index 7387a33303f687d5482c5af5d7811963e46dea99..3eb548606a456a55ae31d0bb58b18b5639754f68 100644 --- a/packages/provider-pi/package.json +++ b/packages/provider-pi/package.json @@ -14,6 +14,10 @@ "./server": { "types": "./src/server.ts", "import": "./src/server.ts" + }, + "./server/usage": { + "types": "./src/server/usage.ts", + "import": "./src/server/usage.ts" } }, "scripts": { diff --git a/packages/provider-pi/src/server/driver.ts b/packages/provider-pi/src/server/driver.ts index c3790b40b125543d65ce774ac5887530226415cb..e36909fa3990278f785975c4597fdf99634bac58 100644 --- a/packages/provider-pi/src/server/driver.ts +++ b/packages/provider-pi/src/server/driver.ts @@ -17,6 +17,7 @@ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import { piUsageReader } from "./usage.ts"; import { makePiTextGeneration } from "./textGeneration.ts"; import { PiAdapterV2Driver, type PiAdapterV2DriverEnv } from "./adapter.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; @@ -80,12 +81,13 @@ const withInstanceIdentity = continuation: { groupKey: input.continuationGroupKey }, }); -export const PiDriver: ProviderDriver = { +export const PiDriver: ProviderDriver = { driverKind: DRIVER_KIND, metadata: { displayName: "Pi", supportsMultipleInstances: true, }, + usage: piUsageReader, configSchema: PiSettings, defaultConfig: (): PiSettings => decodePiSettings({}), create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => diff --git a/packages/provider-pi/src/server/usage.ts b/packages/provider-pi/src/server/usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c019cc68368b01713ec9b6843aac3bafaa167e5 --- /dev/null +++ b/packages/provider-pi/src/server/usage.ts @@ -0,0 +1,151 @@ +/** Native Pi session usage, including calls billed outside the main conversation. */ +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; +import { + parseTimestampMs, + tokenCount, + totalTokens, + type ProviderUsageReader, + type TranscriptUsageFormat, + type UsageRecord, +} from "@t3tools/provider-core/server/usage"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import type { PiSettings } from "../settings.ts"; + +const PiScanState = Schema.Struct({ sessionId: Schema.mutableKey(Schema.String) }); +const Usage = Schema.Struct({ + input: Schema.optional(Schema.Unknown), + output: Schema.optional(Schema.Unknown), + cacheRead: Schema.optional(Schema.Unknown), + cacheWrite: Schema.optional(Schema.Unknown), + reasoning: Schema.optional(Schema.Unknown), + cost: Schema.optional(Schema.Struct({ total: Schema.Unknown })), +}); +const Identity = { id: Schema.NonEmptyString, timestamp: Schema.String }; +const Model = { provider: Schema.NonEmptyString, model: Schema.NonEmptyString }; +const Entry = Schema.Union([ + Schema.Struct({ type: Schema.Literal("session"), id: Schema.NonEmptyString }), + Schema.Struct({ + ...Identity, + type: Schema.Literal("message"), + message: Schema.Union([ + Schema.Struct({ + ...Model, + role: Schema.Literal("assistant"), + responseModel: Schema.optional(Schema.NonEmptyString), + usage: Usage, + }), + Schema.Struct({ role: Schema.Literal("toolResult"), usage: Usage }), + ]), + }), + Schema.Struct({ ...Identity, ...Model, type: Schema.Literal("usage"), usage: Usage }), + Schema.Struct({ + ...Identity, + type: Schema.Literals(["compaction", "branch_summary"]), + usage: Usage, + }), +]); +const decodeEntry = Schema.decodeUnknownOption(Entry); + +function parseRecord(parsed: unknown, state: typeof PiScanState.Type): readonly UsageRecord[] { + const decoded = decodeEntry(parsed); + if (Option.isNone(decoded)) return []; + const entry = decoded.value; + if (entry.type === "session") { + state.sessionId = entry.id; + return []; + } + const timestampMs = parseTimestampMs(entry.timestamp); + if (timestampMs === null) return []; + const source = entry.type === "message" ? entry.message : entry; + const usage = source.usage; + // Pi's auxiliary tool and summary records carry no model attribution. Match + // Pi's own breakdown rather than assigning them to the conversation model. + const rateModel = + "model" in source + ? "responseModel" in source + ? (source.responseModel ?? source.model) + : source.model + : undefined; + const model = "provider" in source ? `${source.provider}/${rateModel}` : "Tools/summaries"; + const outputTokens = tokenCount(usage.output); + const totals = { + // Pi's input/cache counters are already disjoint. + uncachedInputTokens: tokenCount(usage.input), + cachedInputTokens: tokenCount(usage.cacheRead), + cacheCreationTokens: tokenCount(usage.cacheWrite), + outputTokens, + reasoningTokens: Math.min(outputTokens, tokenCount(usage.reasoning)), + }; + const cost = usage.cost?.total; + // Pi defaults omitted catalog rates to zero. Let shared pricing estimate + // API-equivalent cost when the transcript has no positive cost estimate. + const reportedCostUsd = + typeof cost === "number" && Number.isFinite(cost) && cost > 0 ? cost : null; + if (totalTokens(totals) === 0 && reportedCostUsd === null) return []; + return [ + { + provider: "pi", + timestampMs, + model, + ...(rateModel === undefined ? {} : { rateModel }), + sessionId: state.sessionId, + totals, + reportedCostUsd, + speed: "standard", + // Forks copy entries verbatim, including ID/timestamp. Entry IDs are only + // eight random hex digits, so retain timestamp/model to avoid collisions + // across unrelated sessions. Parent IDs may change when Pi branches. + dedupeKey: JSON.stringify(["pi", entry.id, entry.timestamp, model]), + }, + ]; +} + +export const piUsageFormat: TranscriptUsageFormat = { + selectFields: { + type: true, + id: true, + timestamp: true, + provider: true, + model: true, + usage: true, + message: { role: true, provider: true, model: true, responseModel: true, usage: true }, + }, + mightCarryUsage: (line) => line.includes('"usage"') || line.includes('"session"'), + parseLine: (line, state) => { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return []; + } + return parseRecord(parsed, state); + }, + parseProjected: parseRecord, + state: { initial: () => ({ sessionId: "" }), schema: PiScanState }, +}; + +export const piUsageReader: ProviderUsageReader = { + kind: "transcripts", + provider: "pi", + format: piUsageFormat, + directories: Effect.fn("piUsageReader.directories")(function* ({ environment }) { + const path = yield* Path.Path; + const home = yield* HostProcess.HomeDirectory; + const sessionDir = environment.PI_CODING_AGENT_SESSION_DIR?.trim(); + if (sessionDir) return [{ dir: path.resolve(expandHomePath(sessionDir, home)) }]; + const agentDir = environment.PI_CODING_AGENT_DIR?.trim(); + return [ + { + dir: path.resolve( + agentDir ? expandHomePath(agentDir, home) : path.join(home, ".pi", "agent"), + "sessions", + ), + }, + ]; + }), +}; -- 2.54.0