Skip to content

fix: Snapshot inference tokens on inbound session events - #1166

Open
webchang wants to merge 1 commit into
mainfrom
fix/inbound-inference-tokens
Open

webchang wants to merge 1 commit into
mainfrom
fix/inbound-inference-tokens

Conversation

@webchang

@webchang webchang commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Fixes #1165.

The problem

pipeline.SnapshotInference was called only from the outbound recorders — forwardproxy's
request/response sites and extproc's recordOutbound* pair. No inbound recorder called it, so
reverseproxy and extproc.recordInbound* published session events with no token counts, and
cost/usage reads every figure it reports off SessionEvent.Inference (foldInto).

A turn served through the reverse proxy therefore reaches /v1/usage with a whole, correctly
priced cost and zero tokens
: the cost record is published into Extensions.Custom by
settle.Publish and snapshotted by every recorder through SnapshotPlugins — so the money
arrives — while the counts live only on the inference extension and do not. pricedRequests == priceableRequests, empty unpricedBy, empty incompleteBy: every completeness signal says the
figure is whole, and it is. The measurement that is missing is the one nothing reports on.

It is not one listener forgetting a field. The design assumed inference traffic is egress, so a
reverse proxy in front of a model endpoint — inbound inference, which is all reverseproxy
serves — was never covered.

recorder direction before after
forwardproxy request / response outbound yes yes
extproc.recordOutbound{,Response}Session outbound yes yes
extproc.recordInbound{,Response}Session inbound no yes
reverseproxy request / buffered / streaming onClose inbound no yes

The solution

Five inbound recording sites gain
Inference: pipeline.SnapshotInference(pctx.Extensions.Inference). A snapshot rather than the
live pointer for the reason the helper already documents: response-phase assignments to the
token fields would otherwise appear on the already-appended request event.

SnapshotInference's doc comment gains the normative rule — every recorder must call it, in
either direction — and why the omission does not look like one, so the next recorder cannot
repeat it by simply not thinking of it.

Then the guard. The parity suite was green throughout because observation compares the cost
record (through PluginEventJSON, the route that works) and never compared Inference — the
same root cause #936 diagnoses for itself. A pairwise check alone could not have caught this:
both inbound listeners shared the gap, so they agreed with each other while reporting
nothing. So:

  • observation gains Inference (flattened to the fields a cost or usage consumer reads,
    PresentKinds included, since that is what separates "used no cache" from "reported no
    cache"), which covers the case where listeners diverge;
  • cost_parity_test.go gains an absolute per-fixture token expectation, which covers the case
    where they share a gap. The two /v1/embeddings fixtures assert wantTokens: nil explicitly
    — a believed cost with no counts behind it is the one shape where money without tokens is
    honest, and saying so is what stops the new check from being satisfied by an absence.

Deliberately unchanged, both noted in the code:

  • the SessionDenied recorders — no listener records protocol extensions on a deny, and a
    denied request has no counts;
  • the recording gates — the inbound gates still fire on A2A / invocations / plugin-public
    Custom exactly as before, so nothing newly appears in the session stream. This change only
    fills in a field on events that were already recorded. (cost_parity_test.go's own header
    documents a test that depends on the current gate divergence.)

Worth recording for consumers, because it is the tempting workaround for the released behaviour:
do not divide a cost by its rate to recover the counts. That yields a restatement of the rate
table rather than a measurement — it agrees with itself even on a mispriced turn, and nothing
downstream can distinguish a derived count from an observed one.

Testing

Red first, on the unmodified recorders, with the new expectation in place:

--- FAIL: TestCostRecordParity/inbound/buffered,_modelled_from_the_counters
    cost_parity_test.go:366: extproc: the response event carried NO token report, want
      {Model:claude-opus-5 TotalTokens:1700 InputTokens:1000 CacheReadTokens:200
       CacheWriteTokens:0 OutputTokens:500 ReasoningTokens:0 PresentKinds:11} — the counts
       the cost was priced from reach no consumer, and a reader sees a whole cost for zero tokens
    cost_parity_test.go:366: reverseproxy: the response event carried NO token report, want {…}

Four inference fixtures × both inbound listeners fail; every outbound/… subtest passes; and
no pairwise Inference: diff appears anywhere — the suite demonstrating in its own terms why
the pairwise check could not have found this. Green after the fix.

The expectations are pinned from what the known-good outbound listeners actually record, not
composed by hand.

Then, in core/ with GOWORK=off:

  • go test ./... — all packages pass
  • go vet ./... — clean
  • gofmt -l . — no new entries (five pre-existing offenders unchanged, confirmed by stashing)
  • go mod tidy -diff — clean
  • go build ./cmd/authbridge-proxy/... against the modified core — ok

Found by a third-party harness metering agent spend through the reverse proxy on v0.7.0. Its
probe printed "the cost is whole: $0.084471 for None token(s)" and reported success — the
arithmetic in #1165 shows the counts existed at pricing time and only the snapshot was missing.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Inference token details are now included in inbound request and response events, including buffered and streaming responses. Streaming reports reflect token counts after the closing frame is processed.
    • Token reporting is now checked alongside cost records across listener implementations, helping identify differences in reported inference data.

SnapshotInference was called only from the outbound recorders — forwardproxy's
request/response sites and extproc's recordOutbound* pair. No inbound recorder
called it, so neither reverseproxy nor extproc's recordInbound* pair carried
token counts, and cost/usage reads every figure it reports off
SessionEvent.Inference (usage.go's foldInto).

A turn served through the reverse proxy therefore reached /v1/usage with a
whole, correctly priced cost and no counts at all: the cost record is published
into Extensions.Custom by settle.Publish and snapshotted by every recorder via
SnapshotPlugins, so the money arrives while the counts — which live only on the
inference extension — do not. pricedRequests == priceableRequests, empty
unpricedBy and empty incompleteBy all report the figure whole, and it is; the
tokens are simply absent. Unreported traffic reads exactly like free traffic.

The omission is not one listener forgetting a field. The design assumed
inference traffic is egress, so a reverse proxy in front of a model endpoint —
inbound inference, which is all reverseproxy serves — was never covered.

Fill in the five inbound recording sites. Snapshot rather than the live pointer
for the reason the helper already documents: response-phase assignments to the
token fields would otherwise appear on the request event.

Guard it in the parity suite, which was green throughout because observation
compares the cost record (through PluginEventJSON, the route that works) and
never compared Inference — the same root cause as #936. A pairwise check alone
would not have caught this: both inbound listeners shared the gap, so they
agreed with each other while reporting nothing. So observation gains Inference
for the split case, and cost_parity_test.go gains an absolute per-fixture token
expectation for the shared one. Before this change that expectation fails on
four fixtures across both inbound listeners and passes on every outbound one.

Deliberately unchanged: the SessionDenied recorders, since no listener records
protocol extensions on a deny and a denied request has no counts; and the
recording gates, so nothing newly appears in the session stream — this only
fills a field on events that were already recorded.

Fixes #1165

Signed-off-by: Rong Chang <rong@us.ibm.com>
@coderabbitai

coderabbitai Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 0c45f2af-671d-4d69-be24-206ca9d418fc

📥 Commits

Reviewing files that changed from the base of the PR and between 67021e3 and d7cbcf2.

📒 Files selected for processing (6)
  • core/listener/extproc/server.go
  • core/listener/parity/cost_parity_test.go
  • core/listener/parity/drivers_test.go
  • core/listener/parity/parity_test.go
  • core/listener/reverseproxy/server.go
  • core/pipeline/snapshot.go

Included review availability: This review used your included allowance. Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

Inbound reverse proxy and ext_proc session events now include inference snapshots. Parity observations and fixture assertions now check inference token reports alongside cost records.

Changes

Inbound inference reporting

Layer / File(s) Summary
Snapshot inference on inbound events
core/listener/extproc/server.go, core/listener/reverseproxy/server.go, core/pipeline/snapshot.go
Inbound request and response events include inference snapshots. Streaming response events snapshot inference after closing-frame counts are folded. The pipeline documentation describes the snapshot requirement.
Compare inference observations
core/listener/parity/drivers_test.go, core/listener/parity/parity_test.go
Parity observations capture inference model, token counts, and presence kinds. Pairwise comparison reports inference differences.
Assert fixture token reports
core/listener/parity/cost_parity_test.go
Fixtures specify expected token reports for parsed and unparsed paths. Assertions check report presence, model, token counts, and presence kinds.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix · Severity of issue fixed: Medium

Suggested reviewers: huang195

Merge Risk: ⚪ Minimal · up to d7cbc

The supplied evidence identifies no actionable issue with inbound token reporting or its parity checks; the PR appears mergeable after normal checks.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to d7cbc

Inbound model requests can now place prompts and completions in session records, not just token counts. The session endpoint relies on restricted network access rather than authentication. The actual deployment exposure is unconfirmed.

Retained concerns

  • Medium · security · inferred: Adding whole inference snapshots to inbound session events can make inbound prompts, tool data, and completions available through the unauthenticated session API. Exposure is conditional on access to that API, whose deployment boundary was not verified.
Security review details

Security Blast Radius

  • inferred — A party able to reach a service instance’s session API could read newly recorded inbound inference content from that instance’s sessions or live stream, without a per-session authorization check. No evidence establishes reachability from outside the intended operator network.

Security Findings and Attack Paths

  • inferred — The newly added inbound snapshot provides a path from a caller’s parsed model request to stored event content and then to full-event or live-stream responses. Outbound events already had this behavior; the introduced difference is coverage of inbound traffic.

Trust Boundaries and Controls

  • observed — The documented protection for session payloads is network placement, not API authentication. The parser leaves the inference extension absent for unrecognized or invalid requests, limiting this new payload path to requests for which inference data is populated.

Resilience and Maintainability Implications

  • observed — Session append serializes publication and recorder calls under the store lock, and the usage aggregator records response events separately from request events. The change adds data to those existing paths rather than changing their ordering or authority checks.

Hardening Proposals

  • proposed — Confirm the session API’s bind address and network restrictions in each deployment, and consider limiting inbound session snapshots to the inference metadata consumers need or protecting access to full events.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 8 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: adding inference token snapshots to inbound session events. It is concise and directly matches the pull request objectives and code changes.
Linked Issues check ✅ Passed The PR satisfies the coding requirements in [#1165]. It snapshots pctx.Extensions.Inference at all five inbound recording sites in reverseproxy and extproc. It uses SnapshotInference, which pr…
Out of Scope Changes check ✅ Passed The changes stay within [#1165]. Production changes add inbound inference snapshots and document the snapshot requirement. Test changes verify inference parity and absolute token expectations. No unre…
  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@huang195 huang195 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The production change is correct and safe — I verified the mechanism end to end, including the two things that could have gone wrong and didn't: usage.go:1043 returns before foldInto for SessionRequest, so the new request-phase snapshots cannot double-count in /v1/usage; and modifyResponse returns at :488 after installing the streaming body, so the buffered and onClose recorders never both fire. foldInto really does take every token figure off e.Inference and nothing else, so the diagnosis holds.

One must-fix, in the guard rather than the fix. assertTokens runs only on l.run(t, cf.fixture, pipeline.SessionResponse), so of the five sites this PR adds, the two request-phase ones are covered by observationDiff's pairwise check alone — which is exactly the check parity_test.go:439 documents as unable to catch a gap a direction's whole listener set shares. Measured: dropping Inference: from both inbound request recorders (deletion proved by git diff --stat) leaves ./listener/parity/ at EXIT:0, and core/listener/extproc and core/listener/reverseproxy's own packages pass too.

Mutation gate: 10 mutated, 9 killed, 1 survived. Every response-phase site is genuinely pinned, in both directions, and the wantTokens: nil pins on the /v1/embeddings fixtures are live rather than vacuous. Red-state re-derivation matches the body exactly — 4 inbound fixtures, 8 carried NO token report lines, 0 outbound failures, 0 pairwise Inference: diffs. gofmt/vet/tidy and 60/60 core packages confirmed as stated.

Also: snapshot.go:77's "EVERY RECORDER MUST CALL THIS" is absolute while 5 of the 14 non-test recorders deliberately don't, and the body's "both noted in the code" isn't true of the four *Reject ones — the reason lives only in the PR description, which is the one place the next recorder author won't read.

// And the counts the figure was computed from, on the same event. Absolute
// for the reason above, sharpened: the listeners of ONE DIRECTION shared
// this gap, so they agreed with each other while reporting nothing.
assertTokens(t, l.name, obs.Inference, cf.wantTokens)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

must-fix — this absolute check runs on one phase only, so two of the five sites this PR adds are unguarded against the exact shape it was written for.

obs comes from l.run(t, cf.fixture, pipeline.SessionResponse) at :352, so the three response-phase additions are pinned and the two request-phase ones — extproc/server.go:360 and reverseproxy/server.go:440 — are covered only by observationDiff's new pairwise comparison. parity_test.go:439 says of that check: "a gap shared by a direction's whole listener set passes here … The absolute expectation in cost_parity_test.go covers the shared case." True for the response event; not for the request event.

Measured, with the deletion proved before the run:

removed Inference: from extproc.recordInboundSession AND reverseproxy.handleRequest
 core/listener/extproc/server.go      | 1 -
 core/listener/reverseproxy/server.go | 1 -
GOWORK=off go test -count=1 ./listener/parity/   EXIT:0   ** SURVIVED **

Nothing else covers them: with all five sites reverted, core/listener/extproc and core/listener/reverseproxy's own packages still pass — only parity fails.

Either remedy works. (a) extend this loop with a request-phase pass — the fixtures already carry wantTokens, and a request event should assert Model and PresentKinds present with the counts still zero, which is what SnapshotInference's original doc comment promises. (b) narrow the claim at parity_test.go:439 to "…covers the shared case for the response event" and say the request half rides on pairwise alone. (a) is worth more, since (b) leaves the new rule in snapshot.go unenforceable for two of its own call sites — but (b) is honest and is not a blocker by itself.

Comment thread core/pipeline/snapshot.go
// extension during OnResponse; without snapshotting, the request event's
// view would contain the eventual response's token counts and completion.
//
// EVERY RECORDER MUST CALL THIS, IN EITHER DIRECTION. It is the only route by which

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion — the rule is absolute and five recorders in this tree are exempt, four of them silently.

Enumerating every non-test pipeline.SessionEvent{ site: nine call SnapshotInference; forwardproxy.recordTunnelOpened and the four *Reject recorders do not. The tunnel one already carries the right kind of note in place ("MCP/Inference snapshots are nil by definition (the bytes are opaque)"). The four deny recorders carry none — I grepped each for infer|token|count|extension|protocol|snapshot.

The PR body says they are "Deliberately unchanged, both noted in the code"; the deny half isn't — that reason exists only in the description. It matters more than usual here because this comment's stated purpose is "so the next recorder cannot repeat it by simply not thinking of it", and someone who checks the rule against recordInboundReject finds a contradiction with nothing in the tree to resolve it.

Cheapest fix is to carry the body's own sentence up here: except the SessionDenied recorders and recordTunnelOpened, which record no protocol extensions at all.


// anthropicTokens is the count report for the turn every fixture below sends, buffered or
// streamed: the same tokens the rate table turns into modelledWholeUSD. Shared so that a
// fixture changing its counters without changing its expectation is a compile-time edit here

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — "a compile-time edit here" is a runtime failure. The counters live in JSON string literals, so nothing couples them to anthropicTokens at compile time. Verified by changing one:

cache_read_input_tokens 200 -> 250
go vet ./listener/parity/   EXIT:0    (compiles fine)
go test ...                 EXIT:1    "CacheReadTokens = 250, want 200"

Sharing the var is still the right call and the failure is loud — it is just a failing expectation, not a compile error. "one failing expectation here" would be accurate.

// but the status code and plugin invocations are always meaningful.
plugins := pipeline.SnapshotPlugins(pctx.Extensions.Custom)
// Always pair every inbound request with a response row (carries StatusCode).
// Inference is the token report, and it is what a cost consumer reads: without it

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit — this note sits above the if s.Sessions != nil guard, twelve lines from the Inference: field it explains. The sibling note in recordInboundResponseEvent (:743) sits directly above its Append, which reads better.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: New/ToDo

Development

Successfully merging this pull request may close these issues.

bug: inbound session events carry no inference tokens, so a reverse-proxied turn reports a priced cost for zero tokens

3 participants