From e54b79ac82b33b0d1ab4190cecace6c6b7066e50 Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Sun, 25 Oct 2026 02:50:43 +0530 Subject: [PATCH 1/3] fix(trace): separate Pi cache token accounting Recompute Pi v2 totals from native input and output counters, preserve cache categories, and verify them against cache-active raw session records. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/README.md | 8 ++- .../internal/trace/codex/TOKEN_ACCOUNTING.md | 12 ++-- app/cli/internal/trace/e2e/checks_tokens.go | 39 +++++++++++++ .../internal/trace/e2e/checks_tokens_test.go | 57 ++++++++++++++++++- app/cli/internal/trace/pi/parse.go | 16 ++++-- app/cli/internal/trace/pi/parse_test.go | 42 +++++++++++++- .../trace/pi/testdata/session-v3.golden.json | 4 +- 7 files changed, 162 insertions(+), 16 deletions(-) diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index 1936c7d0d..ae4a65946 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -12,7 +12,9 @@ Pi uses the project-local `.pi/extensions/chainloop-trace.ts` extension. Run `ch Pi supports the JSONL v3 format used by Pi 0.80.10. The compatibility suite pins Pi 1.3.0 with `openrouter/anthropic/claude-haiku-5.5`. Pi cells begin at **Unknown** and move only from end-to-end results or an explicit manual check. -Pi 1.3.0 passed 25 feature checks in the local end-to-end suite. Pi 0.80.10 also completed targeted TUI and JSON-headless smokes that exercised session start, `write`, `edit`, `bash`, commit attribution and session shutdown; the TUI showed the local-mode welcome message. Subagents, skills tracking, spec source pointers and pasted image pointers are not implemented. The headless suite cannot verify the UI-only welcome message, cache accounting without cache activity, the control-plane credentials warning, the session link after a control-plane push or control-plane attestation. The multi-agent feature has no registered check. +Pi evidence with `schema_version: v2` preserves native `input`, `output`, `cacheRead`, and `cacheWrite` as separate categories. Evidence `total_tokens` is recomputed as base input + output, excluding both cache categories, regardless of supplied `totalTokens`. The 100/20/10/5 fixture therefore records total 120, not 135. Supplied `cost.total` remains authoritative, without repricing. Invalid or overflowing counters are ignored with deterministic warnings; if input + output overflows, the evidence total is omitted with a warning while individual counters remain. Missing usage contributes zero. Only the selected branch is counted, including its history after resume, not abandoned branches or complete session spend. Pi `v1` used supplied totals, which may include cache; normalize from raw records before combining versions. + +Pi 1.3.0 passed 26 feature checks in the local end-to-end suite, including a cache-active accounting run. Pi 0.80.10 also completed targeted TUI and JSON-headless smokes that exercised session start, `write`, `edit`, `bash`, commit attribution and session shutdown; the TUI showed the local-mode welcome message. Subagents, skills tracking, spec source pointers and pasted image pointers are not implemented. The headless suite cannot verify the UI-only welcome message, the control-plane credentials warning, the session link after a control-plane push or control-plane attestation. The multi-agent feature has no registered check. Codex uses native hooks in `.codex/hooks.json` (Codex CLI 0.163.0). Run `chainloop trace init --codex` or `chainloop trace run --local --codex -- codex exec …`. Review and trust the installed hooks in Codex before starting a session. See the [official hooks documentation](https://learn.chatgpt.com/docs/hooks). Installed hooks have a `Chainloop Trace` status label in Codex. Re-running init adds labels to existing Chainloop hooks. Changing the hooks requires a new trust review. @@ -49,7 +51,7 @@ What the session evidence holds about the conversation. |---------|---------------|-------------|--------|--------------|------------|---|---| | Transcript | The evidence holds the conversation (`raw_session`): the messages and the tool calls of the agent. | Yes | Yes | Partial | Partial | Yes | Yes | | Token usage and cost | The evidence holds the tokens that the session used and their cost in USD. | Yes | No | Yes | Yes | Yes | Yes | -| Cache token accounting | Cache reads and writes stay in separate evidence fields. Base input and total exclude cache. The suite compares stored token counts with the raw usage records. | Unknown | No | Unknown | Unknown | Unknown | Unknown | +| Cache token accounting | Cache reads and writes stay in separate evidence fields. Base input and total exclude cache. The suite compares stored token counts with the raw usage records. | Unknown | No | Unknown | Unknown | Yes | Unknown | | Model detection | The evidence names the primary model and the other models that the session used. | Yes | Partial | Yes | Yes | Yes | Yes | | Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No | No | No | | Missed session start | A session whose session-start hook did not fire is still recorded, from its first tool hook. | Yes | Unknown | Unknown | Yes | Yes | Unknown | @@ -69,7 +71,7 @@ Why not Yes: - **Subagents, OpenCode:** a subagent runs in a child session with its own export. It is not part of the evidence of its parent session. - **Missed session start, Cursor and OpenCode 1.x:** not verified yet. The end-to-end suite finds the values (spec issue-3635). -The cache accounting check reuses the edit-and-commit session, so it adds no agent runs. It compares the stored evidence with source usage, including unique Codex responses and Claude subagents, and fails if input or total includes cache or if a cache category is lost. The Codex check also rejects inflated usage by comparing with deduplicated responses or accepted cumulative snapshots. The Claude check sums all assistant records, so matching duplicated records in both source and evidence will pass; Claude deduplication remains a gap. A session with no cache activity is reported as not covered. Regression fixtures cover the compaction overcount, duplicate response IDs, resumed sessions, cumulative fallbacks, reasoning, and cache pricing. The live agent matrix must be rerun to record the new check's result. +The cache accounting check reuses the edit-and-commit session, so it adds no agent runs. It compares the stored evidence with source usage, including unique Codex responses and Claude subagents, and fails if input or total includes cache or if a cache category is lost. The Codex check also rejects inflated usage by comparing with deduplicated responses or accepted cumulative snapshots. The Claude check sums all assistant records, so matching duplicated records in both source and evidence will pass; Claude deduplication remains a gap. A session with no cache activity is reported as not covered. Regression fixtures cover the compaction overcount, duplicate response IDs, resumed sessions, cumulative fallbacks, reasoning, and cache pricing. A live cache-active Pi 1.3.0 run passed with `openrouter/anthropic/claude-haiku-5.5`. ## Code attribution diff --git a/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md b/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md index 11a60e147..84d47d1dd 100644 --- a/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md +++ b/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md @@ -1,6 +1,6 @@ # Codex token accounting -Codex and Claude Code evidence use disjoint token categories: +Codex v2, Pi v2, and Claude Code evidence use disjoint token categories: - `input_tokens`: base input, excluding cache reads and cache writes. - `cache_read_input_tokens`: input served from cache. @@ -78,16 +78,16 @@ total tokens, $900.8926 estimated cost, and 147 false reset warnings. ## Other provider parsers -This table describes the current code. This fix changes Codex only. +This table describes the current code, including Pi v2 accounting. | Provider | Input/cache fields | Total and cost | Duplicate and reasoning handling | | --- | --- | --- | --- | | Claude Code | Base input, cache reads, and cache writes are separate. | Total is base input + output. Cost includes cache reads and both 5-minute and 1-hour writes. | Sums each assistant usage record, including subagents, without message-ID deduplication. No separate reasoning count is added. Duplicate usage records can inflate counts and cost. | | Cursor | The collected transcript supplies no usage. | Usage and cost are unavailable, with a warning. | No token accounting to deduplicate or normalize. | -| Pi parser | Copies input, output, cache reads, and cache writes separately. | Uses the supplied `totalTokens` and `cost.total` without reconciling categories. Its fixture total includes cache. | Rejects duplicate entry IDs and reads only the selected branch. No separate reasoning count is added. | +| Pi parser | Copies native input, output, cache reads, and cache writes separately. | v2 recomputes total as base input + output, excluding cache; supplied `cost.total` remains authoritative. v1 used supplied totals, which may include cache. | Rejects duplicate entry IDs and reads only the selected branch, including resumed history. No separate reasoning count is added. Independent E2E source comparison has regression coverage and passed a live cache-active Pi 1.3.0 run. | | OpenCode | Copies input, output, and cache read/write fields separately. | Total is input + output. Prefers session counters/cost over message sums. | Message sums have no ID deduplication. A separate source `reasoning` counter is not represented in the parser's token types. | -Pi's selected-branch scope and source totals differ from a complete session +Pi's selected-branch scope differs from a complete session spend report. OpenCode's separate reasoning counter needs a follow-up decision and normalization if it is to be included in output. Those behaviors and Claude's missing deduplication remain outside this Codex fix. @@ -102,8 +102,10 @@ Consumers must check both `data.agent.name` and `data.schema_version`: | --- | --- | --- | | Codex `v1` | Input includes cache; total includes input and output. | Use `total_tokens` without adding the cache fields. Counts may still contain the old overcount. | | Codex `v2` | Input and total exclude cache, matching the existing Claude convention. | Add both cache fields to `total_tokens` exactly once. | +| Pi `v1` | Input is separate; total was supplied by Pi and may include cache. | Recompute from native input, output, and cache fields; do not assume supplied totals are consistent. | +| Pi `v2` | Input and total exclude both cache categories. | Add both cache fields to `total_tokens` exactly once. | -Other providers retain their existing versions and semantics. An unknown +Providers other than Codex and Pi retain their existing versions and semantics. An unknown version requires an explicit compatibility decision; do not infer its meaning from whether cache fields are present. No new evidence fields are needed. Consumers must not treat inclusive input as base input for pricing, or aggregate diff --git a/app/cli/internal/trace/e2e/checks_tokens.go b/app/cli/internal/trace/e2e/checks_tokens.go index 3f0e88387..f2a1deea0 100644 --- a/app/cli/internal/trace/e2e/checks_tokens.go +++ b/app/cli/internal/trace/e2e/checks_tokens.go @@ -18,9 +18,11 @@ import ( "encoding/json" "errors" "fmt" + "math/big" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/codex" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/pi" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" ) @@ -40,6 +42,43 @@ func checkCacheTokenAccounting(s *Session) error { if err != nil { return err } + case pi.Name: + if d.SchemaVersion != "v2" { + return errors.New("Pi cache accounting requires evidence schema_version v2") + } + // Pi raw_session contains only the selected branch. Read native fields + // independently of the provider parser; source totalTokens may include cache. + for _, raw := range d.RawSession["main"] { + var r struct { + Type string `json:"type"` + Message struct { + Role string `json:"role"` + Usage map[string]json.RawMessage `json:"usage"` + } `json:"message"` + } + if json.Unmarshal(raw, &r) != nil || r.Type != "message" || r.Message.Role != "assistant" { + continue + } + for _, field := range []struct { + name string + total *int + }{ + {"input", &want.InputTokens}, {"output", &want.OutputTokens}, + {"cacheRead", &want.CacheReadInputTokens}, {"cacheWrite", &want.CacheCreationInputTokens}, + } { + value, ok := new(big.Rat).SetString(string(r.Message.Usage[field.name])) + if !ok || value.Sign() < 0 || !value.IsInt() || !value.Num().IsInt64() { + continue + } + count := value.Num().Int64() + if count <= int64(int(^uint(0)>>1)-*field.total) { + *field.total += int(count) + } + } + } + if want.OutputTokens > int(^uint(0)>>1)-want.InputTokens { + return errors.New("Pi source input + output token total overflows") + } case claude.Name: for _, records := range d.RawSession { for _, raw := range records { diff --git a/app/cli/internal/trace/e2e/checks_tokens_test.go b/app/cli/internal/trace/e2e/checks_tokens_test.go index 26632297c..8f4470ec4 100644 --- a/app/cli/internal/trace/e2e/checks_tokens_test.go +++ b/app/cli/internal/trace/e2e/checks_tokens_test.go @@ -23,6 +23,7 @@ import ( "github.com/chainloop-dev/chainloop/app/cli/internal/trace/claude" "github.com/chainloop-dev/chainloop/app/cli/internal/trace/codex" + "github.com/chainloop-dev/chainloop/app/cli/internal/trace/pi" "github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -37,7 +38,7 @@ func cacheSession(agent string, records ...string) *Session { d := &s.Stored.Evidence.Data d.Agent.Name = agent d.SchemaVersion = "v1" - if agent == codex.Name { + if agent == codex.Name || agent == pi.Name { d.SchemaVersion = "v2" } d.Usage = &aicodingsession.Usage{ @@ -110,12 +111,66 @@ func TestClaudeCacheComparisonDoesNotDeduplicate(t *testing.T) { func TestCacheAccountingNotCovered(t *testing.T) { for _, s := range []*Session{ cacheSession("cursor"), + cacheSession(pi.Name, `{"type":"message","message":{"role":"assistant","usage":{"input":100,"output":50}}}`), cacheSession(codex.Name, `{"type":"token_usage_record","payload":{"response_id":"a","usage":{"input_tokens":1000,"output_tokens":50}}}`), } { require.ErrorIs(t, checkCacheTokenAccounting(s), errNotCovered) } } +func TestPiCacheAccountingSourceComparison(t *testing.T) { + for _, tc := range []struct { + name string + change func(*aicodingsession.Data) + }{ + {name: "native disjoint counters"}, + {name: "inclusive total", change: func(d *aicodingsession.Data) { d.Usage.TotalTokens = 1050 }}, + {name: "cache subtracted from base input", change: func(d *aicodingsession.Data) { + d.Usage.InputTokens = 0 + d.Usage.TotalTokens = 50 + }}, + {name: "missing cache writes", change: func(d *aicodingsession.Data) { d.Usage.CacheCreationInputTokens = 0 }}, + {name: "inflated consistent counts", change: func(d *aicodingsession.Data) { + d.Usage.InputTokens = 200 + d.Usage.TotalTokens = 250 + }}, + {name: "legacy schema", change: func(d *aicodingsession.Data) { d.SchemaVersion = "v1" }}, + } { + t.Run(tc.name, func(t *testing.T) { + // Given: selected raw records include a continuation and non-assistant usage. + raw := `{"type":"message","message":{"role":"assistant","usage":{"input":50,"output":25,"cacheRead":400,"cacheWrite":50,"totalTokens":525}}}` + s := cacheSession(pi.Name, raw, raw, + `{"type":"message","message":{"role":"user","usage":{"input":999}}}`, + `{"type":"compaction","message":{"role":"assistant","usage":{"input":999}}}`) + if tc.change != nil { + tc.change(&s.Stored.Evidence.Data) + } + // When + err := checkCacheTokenAccounting(s) + // Then + if tc.change == nil { + require.NoError(t, err) + } else { + require.Error(t, err) + require.NotErrorIs(t, err, errNotCovered) + } + }) + } +} + +func TestPiCacheAccountingSelectedBranchFixture(t *testing.T) { + // Given: stored evidence retains selected raw records with invalid usage, + // compaction, and both cache categories from the parser fixture. + raw, err := os.ReadFile("../pi/testdata/session-v3.golden.json") + require.NoError(t, err) + s := &Session{Stored: &StoredSession{}} + require.NoError(t, json.Unmarshal(raw, &s.Stored.Evidence)) + // When + err = checkCacheTokenAccounting(s) + // Then + require.NoError(t, err) +} + func TestCacheAccountingCompactedSession(t *testing.T) { raw, err := os.ReadFile("../codex/testdata/compacted-session.jsonl") require.NoError(t, err) diff --git a/app/cli/internal/trace/pi/parse.go b/app/cli/internal/trace/pi/parse.go index b188b0b56..14ae0f30e 100644 --- a/app/cli/internal/trace/pi/parse.go +++ b/app/cli/internal/trace/pi/parse.go @@ -262,7 +262,6 @@ type evidenceAccumulator struct { outputTokens int cacheRead int cacheWrite int - totalTokens int totalCost float64 models map[string]struct{} primaryModel string @@ -307,6 +306,12 @@ func buildEvidence(parsed *parsedSession, agentVersion string) *aicodingsession. if parsed.TruncatedLine > 0 { acc.warnings = append(acc.warnings, fmt.Sprintf("ignored truncated final Pi session line %d", parsed.TruncatedLine)) } + totalTokens := 0 + if acc.outputTokens > int(^uint(0)>>1)-acc.inputTokens { + acc.warnings = append(acc.warnings, "Pi input + output token total overflows; total_tokens omitted") + } else { + totalTokens = acc.inputTokens + acc.outputTokens + } endedAt := parsed.StartedAt if len(parsed.Selected) > 0 { @@ -317,7 +322,7 @@ func buildEvidence(parsed *parsedSession, agentVersion string) *aicodingsession. duration := max(0, int(endedAt.Sub(parsed.StartedAt).Seconds())) return aicodingsession.NewEvidence(aicodingsession.Data{ - SchemaVersion: "v1", + SchemaVersion: "v2", Agent: aicodingsession.Agent{ Name: Name, Version: agentVersion, @@ -336,7 +341,7 @@ func buildEvidence(parsed *parsedSession, agentVersion string) *aicodingsession. Usage: &aicodingsession.Usage{ InputTokens: acc.inputTokens, OutputTokens: acc.outputTokens, - TotalTokens: acc.totalTokens, + TotalTokens: totalTokens, CacheReadInputTokens: acc.cacheRead, CacheCreationInputTokens: acc.cacheWrite, EstimatedCostUSD: roundedCost(acc.totalCost), @@ -449,7 +454,10 @@ func (a *evidenceAccumulator) addUsage(line int, message messageView) { a.addInt(line, "output", usage["output"], &a.outputTokens) a.addInt(line, "cacheRead", usage["cacheRead"], &a.cacheRead) a.addInt(line, "cacheWrite", usage["cacheWrite"], &a.cacheWrite) - a.addInt(line, "totalTokens", usage["totalTokens"], &a.totalTokens) + // Retain diagnostics for invalid source totals, but never use them for + // evidence: Pi totals can include both disjoint cache categories. + var suppliedTotal int + a.addInt(line, "totalTokens", usage["totalTokens"], &suppliedTotal) costRaw, exists := usage["cost"] if !exists || isNull(costRaw) { diff --git a/app/cli/internal/trace/pi/parse_test.go b/app/cli/internal/trace/pi/parse_test.go index 27d777f31..0301f4ff5 100644 --- a/app/cli/internal/trace/pi/parse_test.go +++ b/app/cli/internal/trace/pi/parse_test.go @@ -49,6 +49,7 @@ func TestParseSessionEvidence(t *testing.T) { require.NoError(t, err) assert.Equal(t, "pi", result.Data.Agent.Name) + assert.Equal(t, "v2", result.Data.SchemaVersion) assert.Equal(t, "0.80.10", result.Data.Agent.Version) assert.Equal(t, testSessionID, result.Data.Session.ID) assert.Equal(t, "2026-01-02T03:04:00Z", result.Data.Session.StartedAt) @@ -63,7 +64,7 @@ func TestParseSessionEvidence(t *testing.T) { require.NotNil(t, result.Data.Usage) assert.Equal(t, 300, result.Data.Usage.InputTokens) assert.Equal(t, 60, result.Data.Usage.OutputTokens) - assert.Equal(t, 395, result.Data.Usage.TotalTokens) + assert.Equal(t, 360, result.Data.Usage.TotalTokens) assert.Equal(t, 30, result.Data.Usage.CacheReadInputTokens) assert.Equal(t, 7, result.Data.Usage.CacheCreationInputTokens) assert.InDelta(t, 0.3, result.Data.Usage.EstimatedCostUSD, 0.0000001) @@ -209,13 +210,52 @@ func TestParseSessionUsageOverflow(t *testing.T) { require.NoError(t, err) assert.Equal(t, maxInt, result.Data.Usage.InputTokens) assert.Equal(t, 1000, result.Data.Usage.OutputTokens) + assert.Zero(t, result.Data.Usage.TotalTokens) assert.InDelta(t, 1.7e308, result.Data.Usage.EstimatedCostUSD, 1e292) assert.Equal(t, []string{ "Pi entry on line 3 has invalid usage.input; ignored", "Pi entry on line 3 has invalid usage.cost.total; ignored", + "Pi input + output token total overflows; total_tokens omitted", }, result.Data.Warnings) } +func TestParseSessionCacheExclusiveTotals(t *testing.T) { + for _, total := range []string{`135`, `0`, `null`, `"bad"`, `1e400`} { + t.Run(total, func(t *testing.T) { + // Given: native counters are disjoint, regardless of the supplied total. + content := fmt.Sprintf(`{"type":"session","version":3,"id":"pi-session-1","timestamp":"2026-01-02T03:04:00Z","cwd":"/work/repo"} +{"type":"message","id":"first001","parentId":null,"timestamp":"2026-01-02T03:04:01Z","message":{"role":"assistant","usage":{"input":100,"output":20,"cacheRead":10,"cacheWrite":5,"totalTokens":%s,"cost":{"total":0.1234}}}} +`, total) + // When + result, err := parseBytes(t, []byte(content)) + // Then + require.NoError(t, err) + assert.Equal(t, &aicodingsession.Usage{ + InputTokens: 100, OutputTokens: 20, TotalTokens: 120, + CacheReadInputTokens: 10, CacheCreationInputTokens: 5, EstimatedCostUSD: 0.1234, + }, result.Data.Usage) + assert.NoError(t, validateEvidenceData(result.Data)) + }) + } +} + +func TestParseSessionResumedUsage(t *testing.T) { + // Given: a resumed transcript extends the selected branch. + content := `{"type":"session","version":3,"id":"pi-session-1","timestamp":"2026-01-02T03:04:00Z","cwd":"/work/repo"} +{"type":"message","id":"first001","parentId":null,"timestamp":"2026-01-02T03:04:01Z","message":{"role":"assistant","usage":{"input":100,"output":20,"cacheRead":10,"cacheWrite":5,"cost":{"total":0.1}}}} +{"type":"message","id":"resume01","parentId":"first001","timestamp":"2026-01-03T03:04:01Z","message":{"role":"assistant","usage":{"input":30,"output":4,"cacheRead":50,"cacheWrite":2,"totalTokens":86,"cost":{"total":0.2}}}} +` + // When + result, err := parseBytes(t, []byte(content)) + // Then + require.NoError(t, err) + assert.Equal(t, &aicodingsession.Usage{ + InputTokens: 130, OutputTokens: 24, TotalTokens: 154, + CacheReadInputTokens: 60, CacheCreationInputTokens: 7, EstimatedCostUSD: 0.3, + }, result.Data.Usage) + assert.Empty(t, result.Data.Warnings) +} + func parseBytes(t *testing.T, content []byte) (*aicodingsession.Evidence, error) { t.Helper() rawDir := t.TempDir() diff --git a/app/cli/internal/trace/pi/testdata/session-v3.golden.json b/app/cli/internal/trace/pi/testdata/session-v3.golden.json index eff98a6e2..9020a19e0 100644 --- a/app/cli/internal/trace/pi/testdata/session-v3.golden.json +++ b/app/cli/internal/trace/pi/testdata/session-v3.golden.json @@ -2,7 +2,7 @@ "chainloop.material.evidence.id": "CHAINLOOP_AI_CODING_SESSION", "schema": "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", "data": { - "schema_version": "v1", + "schema_version": "v2", "agent": { "name": "pi", "version": "0.80.10" @@ -25,7 +25,7 @@ "usage": { "input_tokens": 300, "output_tokens": 60, - "total_tokens": 395, + "total_tokens": 360, "cache_read_input_tokens": 30, "cache_creation_input_tokens": 7, "estimated_cost_usd": 0.3 From 44ad91789a42c87e056bbc4e467e2129c6d69f6f Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Tue, 27 Oct 2026 02:47:25 +0530 Subject: [PATCH 2/3] fix(trace): keep Pi cache evidence on v1 Retain the existing Chainloop evidence version while normalizing Pi cache counters. Clarify that agent releases and evidence versions are distinct. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/README.md | 2 +- .../internal/trace/codex/TOKEN_ACCOUNTING.md | 17 +++++++++-------- app/cli/internal/trace/e2e/checks_tokens.go | 4 ++-- .../internal/trace/e2e/checks_tokens_test.go | 4 ++-- app/cli/internal/trace/pi/parse.go | 2 +- app/cli/internal/trace/pi/parse_test.go | 3 ++- .../trace/pi/testdata/session-v3.golden.json | 2 +- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/app/cli/internal/trace/README.md b/app/cli/internal/trace/README.md index ae4a65946..f1f648639 100644 --- a/app/cli/internal/trace/README.md +++ b/app/cli/internal/trace/README.md @@ -12,7 +12,7 @@ Pi uses the project-local `.pi/extensions/chainloop-trace.ts` extension. Run `ch Pi supports the JSONL v3 format used by Pi 0.80.10. The compatibility suite pins Pi 1.3.0 with `openrouter/anthropic/claude-haiku-5.5`. Pi cells begin at **Unknown** and move only from end-to-end results or an explicit manual check. -Pi evidence with `schema_version: v2` preserves native `input`, `output`, `cacheRead`, and `cacheWrite` as separate categories. Evidence `total_tokens` is recomputed as base input + output, excluding both cache categories, regardless of supplied `totalTokens`. The 100/20/10/5 fixture therefore records total 120, not 135. Supplied `cost.total` remains authoritative, without repricing. Invalid or overflowing counters are ignored with deterministic warnings; if input + output overflows, the evidence total is omitted with a warning while individual counters remain. Missing usage contributes zero. Only the selected branch is counted, including its history after resume, not abandoned branches or complete session spend. Pi `v1` used supplied totals, which may include cache; normalize from raw records before combining versions. +Pi evidence preserves native `input`, `output`, `cacheRead`, and `cacheWrite` as separate categories. Evidence `total_tokens` is recomputed as base input + output, excluding both cache categories, regardless of supplied `totalTokens`. The 100/20/10/5 fixture therefore records total 120, not 135. Supplied `cost.total` remains authoritative, without repricing. Invalid or overflowing counters are ignored with deterministic warnings; if input + output overflows, the evidence total is omitted with a warning while individual counters remain. Missing usage contributes zero. Only the selected branch is counted, including its history after resume, not abandoned branches or complete session spend. The Chainloop evidence field `schema_version` remains `v1`; it is not a Pi release or JSONL format version. Earlier Chainloop parsers used supplied totals, which may include cache, under the same evidence version; normalize historical evidence from raw records before combining totals. Pi 1.3.0 passed 26 feature checks in the local end-to-end suite, including a cache-active accounting run. Pi 0.80.10 also completed targeted TUI and JSON-headless smokes that exercised session start, `write`, `edit`, `bash`, commit attribution and session shutdown; the TUI showed the local-mode welcome message. Subagents, skills tracking, spec source pointers and pasted image pointers are not implemented. The headless suite cannot verify the UI-only welcome message, the control-plane credentials warning, the session link after a control-plane push or control-plane attestation. The multi-agent feature has no registered check. diff --git a/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md b/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md index 84d47d1dd..79d96d9a0 100644 --- a/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md +++ b/app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md @@ -1,6 +1,7 @@ # Codex token accounting -Codex v2, Pi v2, and Claude Code evidence use disjoint token categories: +The current Codex, Pi, and Claude Code parsers use disjoint token categories +in Chainloop evidence: - `input_tokens`: base input, excluding cache reads and cache writes. - `cache_read_input_tokens`: input served from cache. @@ -78,13 +79,13 @@ total tokens, $900.8926 estimated cost, and 147 false reset warnings. ## Other provider parsers -This table describes the current code, including Pi v2 accounting. +This table describes the current parsers, not agent release versions. | Provider | Input/cache fields | Total and cost | Duplicate and reasoning handling | | --- | --- | --- | --- | | Claude Code | Base input, cache reads, and cache writes are separate. | Total is base input + output. Cost includes cache reads and both 5-minute and 1-hour writes. | Sums each assistant usage record, including subagents, without message-ID deduplication. No separate reasoning count is added. Duplicate usage records can inflate counts and cost. | | Cursor | The collected transcript supplies no usage. | Usage and cost are unavailable, with a warning. | No token accounting to deduplicate or normalize. | -| Pi parser | Copies native input, output, cache reads, and cache writes separately. | v2 recomputes total as base input + output, excluding cache; supplied `cost.total` remains authoritative. v1 used supplied totals, which may include cache. | Rejects duplicate entry IDs and reads only the selected branch, including resumed history. No separate reasoning count is added. Independent E2E source comparison has regression coverage and passed a live cache-active Pi 1.3.0 run. | +| Pi parser | Copies native input, output, cache reads, and cache writes separately. | Recomputes total as base input + output, excluding cache; supplied `cost.total` remains authoritative. Earlier Chainloop parsers used supplied totals, which may include cache. | Rejects duplicate entry IDs and reads only the selected branch, including resumed history. No separate reasoning count is added. Independent E2E source comparison has regression coverage and passed a live cache-active Pi 1.3.0 run. | | OpenCode | Copies input, output, and cache read/write fields separately. | Total is input + output. Prefers session counters/cost over message sums. | Message sums have no ID deduplication. A separate source `reasoning` counter is not represented in the parser's token types. | Pi's selected-branch scope differs from a complete session @@ -94,16 +95,16 @@ Claude's missing deduplication remain outside this Codex fix. ## Existing evidence and display -Codex evidence now sets `data.schema_version` to `v2`. The JSON structure and -schema URL stay the same; the data version identifies the accounting semantics. -Consumers must check both `data.agent.name` and `data.schema_version`: +`data.schema_version` is a Chainloop evidence field, not a Codex or Pi release +version or the Pi JSONL session format version. Codex already emits `v2`; +Pi continues to emit `v1`. This Pi accounting fix changes neither the schema +nor its version. The JSON structure and schema URL stay the same. | Agent and data version | Meaning of input and total | Inclusive processed-token total | | --- | --- | --- | | Codex `v1` | Input includes cache; total includes input and output. | Use `total_tokens` without adding the cache fields. Counts may still contain the old overcount. | | Codex `v2` | Input and total exclude cache, matching the existing Claude convention. | Add both cache fields to `total_tokens` exactly once. | -| Pi `v1` | Input is separate; total was supplied by Pi and may include cache. | Recompute from native input, output, and cache fields; do not assume supplied totals are consistent. | -| Pi `v2` | Input and total exclude both cache categories. | Add both cache fields to `total_tokens` exactly once. | +| Pi `v1` | Input is separate; the fixed parser recomputes total as input + output. Earlier parsers used supplied totals, which may include cache. | Recompute from native input, output, and cache fields when handling historical evidence; the evidence version alone does not distinguish the two behaviors. | Providers other than Codex and Pi retain their existing versions and semantics. An unknown version requires an explicit compatibility decision; do not infer its meaning diff --git a/app/cli/internal/trace/e2e/checks_tokens.go b/app/cli/internal/trace/e2e/checks_tokens.go index f2a1deea0..f4c346b38 100644 --- a/app/cli/internal/trace/e2e/checks_tokens.go +++ b/app/cli/internal/trace/e2e/checks_tokens.go @@ -43,8 +43,8 @@ func checkCacheTokenAccounting(s *Session) error { return err } case pi.Name: - if d.SchemaVersion != "v2" { - return errors.New("Pi cache accounting requires evidence schema_version v2") + if d.SchemaVersion != "v1" { + return errors.New("Pi cache accounting requires evidence schema_version v1") } // Pi raw_session contains only the selected branch. Read native fields // independently of the provider parser; source totalTokens may include cache. diff --git a/app/cli/internal/trace/e2e/checks_tokens_test.go b/app/cli/internal/trace/e2e/checks_tokens_test.go index 8f4470ec4..8b638bac6 100644 --- a/app/cli/internal/trace/e2e/checks_tokens_test.go +++ b/app/cli/internal/trace/e2e/checks_tokens_test.go @@ -38,7 +38,7 @@ func cacheSession(agent string, records ...string) *Session { d := &s.Stored.Evidence.Data d.Agent.Name = agent d.SchemaVersion = "v1" - if agent == codex.Name || agent == pi.Name { + if agent == codex.Name { d.SchemaVersion = "v2" } d.Usage = &aicodingsession.Usage{ @@ -134,7 +134,7 @@ func TestPiCacheAccountingSourceComparison(t *testing.T) { d.Usage.InputTokens = 200 d.Usage.TotalTokens = 250 }}, - {name: "legacy schema", change: func(d *aicodingsession.Data) { d.SchemaVersion = "v1" }}, + {name: "unexpected schema", change: func(d *aicodingsession.Data) { d.SchemaVersion = "v2" }}, } { t.Run(tc.name, func(t *testing.T) { // Given: selected raw records include a continuation and non-assistant usage. diff --git a/app/cli/internal/trace/pi/parse.go b/app/cli/internal/trace/pi/parse.go index 14ae0f30e..4954227a9 100644 --- a/app/cli/internal/trace/pi/parse.go +++ b/app/cli/internal/trace/pi/parse.go @@ -322,7 +322,7 @@ func buildEvidence(parsed *parsedSession, agentVersion string) *aicodingsession. duration := max(0, int(endedAt.Sub(parsed.StartedAt).Seconds())) return aicodingsession.NewEvidence(aicodingsession.Data{ - SchemaVersion: "v2", + SchemaVersion: "v1", Agent: aicodingsession.Agent{ Name: Name, Version: agentVersion, diff --git a/app/cli/internal/trace/pi/parse_test.go b/app/cli/internal/trace/pi/parse_test.go index 0301f4ff5..f77c918a4 100644 --- a/app/cli/internal/trace/pi/parse_test.go +++ b/app/cli/internal/trace/pi/parse_test.go @@ -49,7 +49,7 @@ func TestParseSessionEvidence(t *testing.T) { require.NoError(t, err) assert.Equal(t, "pi", result.Data.Agent.Name) - assert.Equal(t, "v2", result.Data.SchemaVersion) + assert.Equal(t, "v1", result.Data.SchemaVersion) assert.Equal(t, "0.80.10", result.Data.Agent.Version) assert.Equal(t, testSessionID, result.Data.Session.ID) assert.Equal(t, "2026-01-02T03:04:00Z", result.Data.Session.StartedAt) @@ -230,6 +230,7 @@ func TestParseSessionCacheExclusiveTotals(t *testing.T) { result, err := parseBytes(t, []byte(content)) // Then require.NoError(t, err) + assert.Equal(t, "v1", result.Data.SchemaVersion) assert.Equal(t, &aicodingsession.Usage{ InputTokens: 100, OutputTokens: 20, TotalTokens: 120, CacheReadInputTokens: 10, CacheCreationInputTokens: 5, EstimatedCostUSD: 0.1234, diff --git a/app/cli/internal/trace/pi/testdata/session-v3.golden.json b/app/cli/internal/trace/pi/testdata/session-v3.golden.json index 9020a19e0..586c7db39 100644 --- a/app/cli/internal/trace/pi/testdata/session-v3.golden.json +++ b/app/cli/internal/trace/pi/testdata/session-v3.golden.json @@ -2,7 +2,7 @@ "chainloop.material.evidence.id": "CHAINLOOP_AI_CODING_SESSION", "schema": "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", "data": { - "schema_version": "v2", + "schema_version": "v1", "agent": { "name": "pi", "version": "0.80.10" From b86eeb1235a2356c3ecd26934976a28ce4479b1f Mon Sep 17 00:00:00 2001 From: Vibhav Bobade Date: Tue, 27 Oct 2026 03:03:13 +0530 Subject: [PATCH 3/3] fix(trace): avoid gating Pi cache checks on schema Compare Pi usage with the native session records regardless of the Chainloop evidence version; only Codex requires its v2 accounting guard. Assisted-by: Vibhav's Pi Signed-off-by: Vibhav Bobade --- app/cli/internal/trace/e2e/checks_tokens.go | 3 --- app/cli/internal/trace/e2e/checks_tokens_test.go | 14 +++++++++++++- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/app/cli/internal/trace/e2e/checks_tokens.go b/app/cli/internal/trace/e2e/checks_tokens.go index f4c346b38..bb73c4690 100644 --- a/app/cli/internal/trace/e2e/checks_tokens.go +++ b/app/cli/internal/trace/e2e/checks_tokens.go @@ -43,9 +43,6 @@ func checkCacheTokenAccounting(s *Session) error { return err } case pi.Name: - if d.SchemaVersion != "v1" { - return errors.New("Pi cache accounting requires evidence schema_version v1") - } // Pi raw_session contains only the selected branch. Read native fields // independently of the provider parser; source totalTokens may include cache. for _, raw := range d.RawSession["main"] { diff --git a/app/cli/internal/trace/e2e/checks_tokens_test.go b/app/cli/internal/trace/e2e/checks_tokens_test.go index 8b638bac6..1fcc893c9 100644 --- a/app/cli/internal/trace/e2e/checks_tokens_test.go +++ b/app/cli/internal/trace/e2e/checks_tokens_test.go @@ -134,7 +134,6 @@ func TestPiCacheAccountingSourceComparison(t *testing.T) { d.Usage.InputTokens = 200 d.Usage.TotalTokens = 250 }}, - {name: "unexpected schema", change: func(d *aicodingsession.Data) { d.SchemaVersion = "v2" }}, } { t.Run(tc.name, func(t *testing.T) { // Given: selected raw records include a continuation and non-assistant usage. @@ -158,6 +157,19 @@ func TestPiCacheAccountingSourceComparison(t *testing.T) { } } +func TestPiCacheAccountingIgnoresEvidenceVersion(t *testing.T) { + // Given: Pi accounting comes from native usage, not the evidence version. + s := cacheSession(pi.Name, + `{"type":"message","message":{"role":"assistant","usage":{"input":100,"output":50,"cacheRead":800,"cacheWrite":100,"totalTokens":1050}}}`) + evidence(s).SchemaVersion = "v2" + + // When + err := checkCacheTokenAccounting(s) + + // Then + require.NoError(t, err) +} + func TestPiCacheAccountingSelectedBranchFixture(t *testing.T) { // Given: stored evidence retains selected raw records with invalid usage, // compaction, and both cache categories from the parser fixture.