Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

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.

Expand Down Expand Up @@ -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 |
Expand All @@ -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

Expand Down
19 changes: 11 additions & 8 deletions app/cli/internal/trace/codex/TOKEN_ACCOUNTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Codex token accounting

Codex 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.
Expand Down Expand Up @@ -78,32 +79,34 @@ 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 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 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. | 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 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.

## 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; 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. |

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
Expand Down
36 changes: 36 additions & 0 deletions app/cli/internal/trace/e2e/checks_tokens.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -40,6 +42,40 @@ func checkCacheTokenAccounting(s *Session) error {
if err != nil {
return err
}
case pi.Name:
// 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 {
Expand Down
67 changes: 67 additions & 0 deletions app/cli/internal/trace/e2e/checks_tokens_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -110,12 +111,78 @@ 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
}},
} {
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 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.
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)
Expand Down
14 changes: 11 additions & 3 deletions app/cli/internal/trace/pi/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,6 @@ type evidenceAccumulator struct {
outputTokens int
cacheRead int
cacheWrite int
totalTokens int
totalCost float64
models map[string]struct{}
primaryModel string
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading