Skip to content

Feat: Record per-tool-call cost inputs on the Anthropic path - #814

Merged
huang195 merged 4 commits into
rossoctl:mainfrom
huang195:feat/per-tool-cost-accounting
Aug 27, 2026
Merged

Feat: Record per-tool-call cost inputs on the Anthropic path#814
huang195 merged 4 commits into
rossoctl:mainfrom
huang195:feat/per-tool-cost-accounting

Conversation

@huang195

@huang195 huang195 commented Aug 26, 2026

Copy link
Copy Markdown
Member

What

The inference-parser records enough to see that an inference turn happened, but not enough to say what it cost. This adds three fields' worth of accounting, all derived from data already on the wire — no new capture surface, no new request to any provider.

1. Streamed tool calls were dropped entirely. An Anthropic tool call arrives across three event types: id and name on content_block_start, arguments as input_json_delta fragments that are only valid JSON once concatenated. The stream folder modeled none of them. A streaming turn recorded finishReason: "tool_use" with an empty toolCalls list, while the equivalent non-streaming response recorded the call in full. Fragments are routed by content-block index, so interleaved calls (a text block plus two tool calls) don't concatenate into each other.

2. Non-text request messages read as free. InferenceMessage.Content keeps only text blocks, so a tool_result message flattens to "" — and in an agent loop that message is a whole file. Measured: a post-tool-call turn recorded system 27,772 chars, user 13,714, assistant 0, tool_result user 0, while the model billed 34,018 prompt tokens. ContentBytes is the wire size of the content value before that reduction. It is a byte count of raw JSON (syntax and escapes included), not a token count — a size signal, not an exact one.

3. Prompt-cache writes and reads were collapsed into PromptTokens. A provider that prices prompt caching charges a premium to write an entry (1.25× base for Anthropic) and a steep discount to read one (0.1×) — a 12.5× spread. Measured: a 5,213-token cache write and a 5,213-token cache read both recorded promptTokens = 24,038, while costing $0.4495 and $0.0405 respectively — 11.1× apart, identical recorded number. CacheWriteTokens / CacheReadTokens split it, read from the same usage block the totals already come from.

Why

PromptTokens answers "how big was the context." It cannot answer "what did this cost," and cost is what an operator watching an agent loop actually needs. These three fields are the minimum that makes per-tool-call cost accounting possible from session events alone.

Scope boundaries

  • OpenAI streaming tool calls remain uncaptured. That is a separate, pre-existing gap (the OpenAI non-streaming path captures them; the streaming folder never did). Not widened here. InferenceExtension.ToolCalls now carries a doc comment saying so, because a cost consumer that spans dialects must not read an empty list on a streamed OpenAI turn as "the model requested no tools."
  • Recording streamed tool-call Arguments is parity, not new exposure. parseAnthropicJSON already records Arguments: string(blk.Input) on the non-streaming path; this makes the streaming path match.
  • Zero means "not reported." Providers that don't price caching, and the OpenAI dialect (which reports cached tokens in a different shape), leave the cache fields unset while PromptTokens stays authoritative. content: null reports 0 bytes rather than the 4 bytes of the literal.

Rebase status

#811 has merged; this branch is rebased onto main and carries only its own three commits plus the review follow-up below. Diff against main is 6 files, 507+/34-.

Review follow-ups

Three findings from review, all latent rather than live today. Each source fix has a regression test that was verified to fail with the fix reverted.

  1. A stream carrying only tool calls was recorded as a skip. The "no response body" guard tested completion text, finish reason and usage but not ToolCalls, so a turn cancelled while the model was still emitting tool arguments — none of those three present, but a captured call — was labelled as having carried nothing and dropped out of any timeline filtered on observe. Latent because a real Anthropic stream opens with message_start, which sets usage; reachable on a truncated one.

  2. An OpenAI usage frame cleared the cache accumulator. foldOpenAIFrame assigned the decoded chunk's usage over state.usage wholesale. inferenceUsage doubles as the OpenAI wire shape and the dialect-neutral accumulator, and its two cache fields are json:"-", so they are always zero in a freshly decoded chunk — the assignment cleared whatever had accumulated. Now copied field by field. Latent because nothing on the OpenAI path fills those fields today, which is precisely what would make the clobber silent when something does.

  3. ContentBytes counts whatever whitespace the client's serializer emitted. Documented rather than changed: the measure is of what was sent, so compacting first would buy comparability across clients at the cost of an allocation per message on every request-body parse. Comparable across messages from one client, not across clients.

Testing

go test ./authlib/... ./cmd/abctl/... passes. Cases in inferenceparser:

  • streamed tool use — two input_json_delta fragments reassemble into complete arguments
  • interleaved indices — two concurrent tool calls whose deltas alternate stay separate
  • ContentBytes on both dialects (separate unmarshalers), including the tool_result message that flattens to "" but is the largest thing in the request
  • cache split on the non-streaming response and on the ?beta=true streaming path (real captured frames: write 3,755 / read 30,008)
  • (follow-up 1) a tool-call-only stream records observe, not skip, and keeps the truncated arguments the model left behind
  • (follow-up 2) an OpenAI usage frame lands its three wire-backed counts without clearing the two cache fields the accumulator held

The existing plain-path streaming test still asserts 25/15/40, which guards the max-not-assign behavior #811 introduced.

golangci-lint run on the changed packages is clean (three pre-existing QF1008 findings in pipeline/finisher_test.go, untouched by this PR).

Assisted-By: Claude (Anthropic AI) noreply@anthropic.com

Summary by CodeRabbit

  • New Features
    • Added cache read and write token usage details to inference responses.
    • Added raw content size tracking for inference messages.
    • Improved streaming support for tool calls, fragmented JSON arguments, and usage updates.
    • Added support for query-string and HTTP/2 path endpoint handling.
  • Bug Fixes
    • Improved handling of interrupted, empty, and streamed responses.
    • Preserved usage and tool-call details consistently across response formats.

@coderabbitai

coderabbitai Bot commented Aug 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e2d2ffa-a18a-4047-98cc-217221485b6f

📥 Commits

Reviewing files that changed from the base of the PR and between e0e7e74 and 78bc732.

📒 Files selected for processing (4)
  • authbridge/authlib/pipeline/extensions.go
  • authbridge/authlib/plugins/inferenceparser/anthropic_test.go
  • authbridge/authlib/plugins/inferenceparser/plugin.go
  • authbridge/authlib/plugins/inferenceparser/plugin_test.go

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


📝 Walkthrough

Walkthrough

The inference pipeline now records raw content sizes and separate cache token counts. It reconstructs streamed tool calls, folds incremental usage updates, finalizes interrupted streams, and preserves cache metrics in the TUI.

Changes

Inference parser metrics and streaming

Layer / File(s) Summary
Public metrics and content-size contracts
authbridge/authlib/pipeline/extensions.go, authbridge/authlib/plugins/inferenceparser/plugin.go, authbridge/authlib/plugins/inferenceparser/anthropic.go
InferenceExtension exposes cache read and write token counts. InferenceMessage and request parsers record raw JSON content sizes.
Shared streaming state and finalization
authbridge/authlib/plugins/inferenceparser/plugin.go
OpenAI streaming state preserves cache counters, assembles tool calls, and finalizes completion, usage, and response-content decisions.
Anthropic usage and tool-call folding
authbridge/authlib/plugins/inferenceparser/anthropic.go
Anthropic streaming combines usage from multiple events, reconstructs indexed and fragmented tool arguments, and finalizes buffered results.
Parser coverage and response display
authbridge/authlib/plugins/inferenceparser/*_test.go, authbridge/cmd/abctl/tui/detail_pane.go
Tests cover cache accounting, zero-output and interrupted streams, tool calls, content sizes, and OpenAI usage folding. The TUI preserves cache metrics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Merge Risk: ⚪ Minimal · up to 78bc7

The change adds per-tool-call cost accounting data for Anthropic requests without introducing a new provider call or capture surface. No actionable merge-blocking risk remains beyond normal checks and review.

Sequence Diagram(s)

sequenceDiagram
  participant AnthropicSSE
  participant AnthropicParser
  participant InferenceExtension
  AnthropicSSE->>AnthropicParser: send message, usage, and tool-call events
  AnthropicParser->>AnthropicParser: assemble indexed argument fragments and update totals
  AnthropicParser->>InferenceExtension: write completion, cache usage, and tool calls
Loading

Suggested reviewers: ibrahim2595, abigailgold

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 73.91% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 23 functions across 6 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the Anthropic accounting changes and highlights the capture of per-tool-call cost inputs. It is concise and directly related to the pull request.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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 force-pushed the feat/per-tool-cost-accounting branch from 4c1435f to e0e7e74 Compare August 26, 2026 20:35
@huang195
huang195 marked this pull request as ready for review August 26, 2026 20:41
@huang195
huang195 requested a review from a team as a code owner August 26, 2026 20:42
@huang195 huang195 added the ready-for-ai-review Request automated AI code review from clawgenti label Aug 26, 2026

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds CacheWriteTokens/CacheReadTokens, ContentBytes, and streamed tool-call capture to the Anthropic inference-parser path — all derived from data already on the wire, backed by real captured frames, and covered by focused tests for each failure mode.

All checks pass. Ready for human review.


Reviewed by clawgenti using the github-pr-review skill

@clawgenti clawgenti left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Adds three missing cost-accounting fields to the Anthropic inference parser (streamed tool calls, ContentBytes for non-text messages, and CacheWriteTokens/CacheReadTokens), all well-motivated by measured production data and backed by targeted regression tests. All checks pass. Ready for human review.


Reviewed by clawgenti using the github-pr-review skill

@pdettori pdettori 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.

Three well-scoped accounting additions, each traced to a measured gap, with tests built from real captured frames. I verified promptTotal() sums input + cache_creation + cache_read, so CacheWriteTokens + CacheReadTokens <= PromptTokens holds and the split is internally consistent with the total it decomposes. The pointer-held strings.Builder and the index-keyed fragment routing are both correct — the interleaved test is exactly the right thing to pin, since arrival-order accumulation is the failure mode that would silently concatenate one call's arguments into another.

No must-fix findings. Three inline notes, all polish; I fetched the branch and probed each one rather than eyeballing it.

One process note, not a code issue: #811 is now merged, so the PR body's "opened as a draft; once #811 merges I'll rebase onto main" is stale (it is no longer a draft either). The branch is 3 ahead / 1 behind main, and GitHub still renders the merge-base diff — 651+/44- displayed vs 507+/34- actual. Rebasing drops endpointPath and the message_delta hunk from the view, which is ~145 lines of already-merged code reviewers currently have to mentally subtract. Worth doing before merge so the diff matches the change.


Areas reviewed: Go (parser, pipeline types, TUI), tests
Agent/IDE config (.claude/.vscode): none
Commits: 5 listed / 3 net vs main, all signed off, no Co-authored-by
CI: all 20 checks passing (Spellcheck skipped)

Assisted-By: Claude Code

state.finalize(ext)
// Empty stream with no body and no chunks — record Skip to
// pair the response row with the request row.
if ext.Completion == "" && ext.FinishReason == "" && ext.TotalTokens == 0 {

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 — this guard predates streamed tool calls, and finalize can now populate ext.ToolCalls. So a stream whose only captured content is a tool call is recorded as a skip and the call is discarded.

Probed on this branch:

ToolCalls=1 Completion="" FinishReason="" TotalTokens=0
invocation: action=skip reason=no_response_body

Frames were just content_block_start (tool_use) + one input_json_delta, then EOF — a turn cancelled while the model was still emitting tool arguments.

Not live on a real Anthropic stream: message_start always precedes and its input_tokens pushes TotalTokens non-zero. But TestInferenceParser_AnthropicMessages_StreamToolUseInterleaved has no message_start and escapes this guard only via the trailing "output_tokens":60. Remove that one frame and the two tool calls the test just proved are captured vanish into a skip — which is the same class of loss point 1 of this PR set out to close.

if ext.Completion == "" && ext.FinishReason == "" && ext.TotalTokens == 0 && len(ext.ToolCalls) == 0 {

CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`

CacheWriteTokens int `json:"-"`

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 — the json:"-" rationale is right, but foldOpenAIFrame assigns the whole struct:

if chunk.Usage.TotalTokens > 0 {
	state.usage = chunk.Usage
}

chunk.Usage is JSON-decoded, so these two are always zero in it — the assignment clears whatever the accumulator held. Probed: seeded CacheWrite=111 CacheRead=222, folded one OpenAI usage frame, got 0/0.

No bug today, since nothing on the OpenAI path sets them. It becomes one the moment someone wires up prompt_tokens_details.cached_tokens — the exact follow-up this comment anticipates — because the failure is silent. Assigning the three JSON-backed fields individually there, or a one-line warning here, closes it.

// than the 4 bytes the literal `null` occupies — the field is a size signal
// for content that exists, and an assistant turn that carries only tool_calls
// has none.
func contentBytes(raw json.RawMessage) int {

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.

nitraw is a json.RawMessage, so len(raw) preserves the client's formatting verbatim. Identical content therefore measures differently depending on how the client serialises:

compact  ContentBytes=75
pretty   ContentBytes=89

(same single tool_result block, ~19% apart)

The doc comment covers "syntax and escapes included" and "a size signal rather than an exact one," which is the right framing — whitespace just isn't named, and it is the one source of variance that has nothing to do with payload size. SDK clients send compact JSON so real-world impact is small, but it does mean the counts aren't comparable across clients.

Either name it in the comment, or json.Compact into a scratch buffer before measuring if cross-client comparability matters.

The inference-parser records enough to see that a turn happened, but not
enough to say what it cost. Three gaps, all on data already on the wire:

Streamed tool calls were dropped. A tool call arrives across three event
types — id and name on content_block_start, arguments as input_json_delta
fragments that are only valid JSON once concatenated — and the stream
folder modeled none of them. A streaming turn recorded finishReason
"tool_use" with an empty toolCalls list while the equivalent
non-streaming response recorded the call in full. Fragments are routed by
content block index, so interleaved calls don't merge into each other.

Non-text request messages read as free. Content keeps only text blocks,
so a tool_result — a whole file, in an agent loop — flattens to "" while
the model was billed for every byte. InferenceMessage.ContentBytes is
the wire size of the content value before that reduction. It is a byte
count, not a token count: a size signal, not an exact one.

Prompt-cache writes and reads were collapsed into PromptTokens. A
provider that prices caching charges a premium to write an entry and a
steep discount to read one — 12.5x apart for Anthropic — so two turns
with identical PromptTokens can differ by an order of magnitude in cost.
CacheWriteTokens and CacheReadTokens split it, from the same usage block
the totals already come from.

Scope: OpenAI *streaming* tool calls remain uncaptured — a separate
pre-existing gap. Recording streamed tool-call Arguments is parity with
the non-streaming path, which already records them.

Depends on the message_delta usage fix (rossoctl#811) — same hunk.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
…tput

finalize gates the whole usage copy on TotalTokens > 0, but the Anthropic
fold only computed that total inside the output_tokens > 0 arm. Any stream
that reported a prompt and no completion therefore refreshed PromptTokens
and then threw it away.

Two streams do that, and the provider billed the prompt in both:

  - a terminal message_delta carrying the prompt with output_tokens == 0
    (a refusal, or a generation stopped immediately);
  - a turn the caller interrupted after message_start, which never reaches
    a message_delta at all — the shape an agent produces every time a user
    cancels a running turn.

Derive the total from the parts after any usage update instead. The
interrupted case now records a real response row with prompt tokens and
zero completion rather than skip/no_response_body; recovery there is
partial by construction, since the ?beta=true path defers the cache
counts to message_delta.

The OpenAI fold is untouched: it takes total_tokens off the wire, where it
can legitimately differ from prompt + completion.

Raised in review of rossoctl#811 by CodeRabbit and @mrsabath.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Adding tool-call capture to the Anthropic streaming path made ToolCalls
populated on three of the four response paths instead of two. Before that
it was uniformly empty on both streaming dialects, so a consumer could
read "empty" the same way everywhere; now an empty ToolCalls means
different things depending on which dialect streamed the response.

The asymmetry is structural, not an oversight: the OpenAI streaming
chunk shape decodes only `choices[].delta.content`, so assembling
`choices[].delta.tool_calls[]` would need a second index-keyed
accumulator — its own change. Until then the only record of the gap is
a PR description, which stops being reachable the moment it merges.

Document it on the field, in the shape the neighbouring CacheWriteTokens
and ContentBytes comments use: say what populates it, and say what its
zero value does and does not license a consumer to conclude.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
Three findings from review of rossoctl#814, all latent rather than live today.

1. A stream carrying only tool calls was recorded as a skip. The
   "no response body" guard tested completion text, finish reason and
   usage but not ToolCalls, so a turn cancelled while the model was
   still emitting tool arguments — none of those three present, but a
   captured call — was labelled as having carried nothing and dropped
   out of any timeline filtered on observe. Latent because a real
   Anthropic stream opens with message_start, which sets usage;
   reachable on a truncated one.

2. An OpenAI usage frame cleared the cache accumulator.
   foldOpenAIFrame assigned the decoded chunk's usage over state.usage
   wholesale. inferenceUsage doubles as the OpenAI wire shape and the
   dialect-neutral accumulator, and its two cache fields are json:"-",
   so they are always zero in a freshly decoded chunk — the assignment
   cleared whatever had accumulated. Now copied field by field.
   Latent because nothing on the OpenAI path fills those fields today,
   which is precisely what would make the clobber silent when
   something does.

3. ContentBytes counts whatever whitespace the client's serializer
   emitted. Documented rather than changed: the measure is of what was
   sent, so compacting first would buy comparability across clients at
   the cost of an allocation per message on every request-body parse.
   Comparable across messages from one client, not across clients.

Both source fixes carry a regression test, each verified to fail with
its fix reverted.

Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com>
Signed-off-by: Hai Huang <huang195@gmail.com>
@huang195
huang195 force-pushed the feat/per-tool-cost-accounting branch from d4bb581 to 78bc732 Compare August 26, 2026 23:41
@huang195
huang195 merged commit ccd2dc1 into rossoctl:main Aug 27, 2026
22 checks passed
@huang195
huang195 deleted the feat/per-tool-cost-accounting branch August 27, 2026 00:08
@github-project-automation github-project-automation Bot moved this from New/ToDo to Done in Rossoctl Issue Prioritization Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-ai-review Request automated AI code review from clawgenti

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants