Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4e29888
fix: Read Anthropic's thinking_tokens into the reasoning counter
huang195 Sep 23, 2026
b3ec965
feat: Show reasoning spend in abctl's tier panel and detail pane
huang195 Sep 23, 2026
3c8fd1f
chore: Regenerate the README demo asset for the reasoning row
huang195 Sep 23, 2026
2996665
fix: Address review — couple reasoning's value to its presence bit
huang195 Sep 23, 2026
74dea02
fix: Review round 2 — a dead assertion, a lost ceiling, a dead clamp
huang195 Sep 23, 2026
d55075e
fix: Review round 3 — $0.00 for a reported split, and the untested bu…
huang195 Sep 23, 2026
0616001
fix: Review round 4 — three more assertions that could not fail
huang195 Sep 24, 2026
a5598f6
fix: Review round 5 — a narrow-terminal regression, and one place for…
huang195 Sep 24, 2026
98b8ac3
fix: Review round 6 — negative money escaped ApportionReasoning with …
huang195 Sep 24, 2026
7835205
fix: Review round 7 — the max wrapper put back the panic the min prev…
huang195 Sep 24, 2026
dafb18e
fix: Review round 8 — mirror the pointer on the OpenAI path, and trim
huang195 Sep 24, 2026
e40413a
fix: Review round 9 — the narrow-terminal fix was never tested
huang195 Sep 24, 2026
f0aeaca
fix: Review round 10 — the height floor never followed the drawer's g…
huang195 Sep 24, 2026
f6f9e84
fix: Review round 11 — pin the rank search and the README's threshold
huang195 Sep 24, 2026
422bf93
fix: Review round 12 — the README pin could not detect the drift it w…
huang195 Sep 24, 2026
ad100b5
fix: Review round 13 — two more self-comparisons, and mark the child …
huang195 Sep 24, 2026
8d1cc5e
fix: Review round 14 — a reported zero is a measurement, not an absence
huang195 Sep 24, 2026
31cecea
fix: Self-review caught a misaligned reported-zero row
huang195 Sep 24, 2026
50782a5
fix: Review round 15 — three classes swept, not six locations
huang195 Sep 24, 2026
8068d48
refactor: Trim out-of-scope changes — one provider, one claim
huang195 Sep 24, 2026
5733b7c
test: Fold the drawer's adjacency check into the orphan-glyph test
huang195 Sep 24, 2026
e9ddc89
test: Collapse the child cell's six states into one truth table
huang195 Sep 24, 2026
94c6739
fix: Restore three files the scope trim wrongly removed
huang195 Sep 24, 2026
d9a20fa
fix: Review round 16 — pin the drawer's height floor, and show a meas…
huang195 Sep 24, 2026
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
63 changes: 51 additions & 12 deletions authbridge/authlib/plugins/inferenceparser/anthropic.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,31 @@ func parseAnthropicRequest(body []byte) *pipeline.InferenceExtension {
//
// Cache fields are *int so an omitted field on the wire (e.g. message_start
// on the ?beta=true path) stays absent in Present rather than being asserted
// as a reported zero.
// as a reported zero. OutputTokensDetails is a pointer for the same reason, and
// it needs it more: it is absent on message_start and on message_stop, and
// present only on message_delta.
type anthropicUsage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
CacheCreationInputTokens *int `json:"cache_creation_input_tokens"`
CacheReadInputTokens *int `json:"cache_read_input_tokens"`

// OutputTokensDetails splits output_tokens by what generated it. Anthropic reports
// one sub-field, thinking_tokens: the share spent on internal reasoning, a SUBSET of
// OutputTokens rather than a sibling.
OutputTokensDetails *struct {
ThinkingTokens *int `json:"thinking_tokens"`
} `json:"output_tokens_details"`
}

// toNeutral maps Anthropic's usage onto TokenUsage. Input and Output are
// always emitted by the Messages API; cache sub-fields are observed via
// their pointers so an absent field stays absent in Present. Reasoning is
// not exposed by Anthropic.
// always emitted by the Messages API; cache sub-fields and the output-token
// details are observed via their pointers so an absent field stays absent in
// Present.
//
// Reasoning comes from output_tokens_details.thinking_tokens, verified against a live
// claude-opus-5 turn and documented at
// https://platform.claude.com/docs/en/build-with-claude/thinking-steering-and-cost
func (u anthropicUsage) toNeutral() parsercommon.TokenUsage {
n := parsercommon.TokenUsage{
Input: u.InputTokens,
Expand All @@ -155,6 +168,12 @@ func (u anthropicUsage) toNeutral() parsercommon.TokenUsage {
n.CacheWrite = *u.CacheCreationInputTokens
n.Present |= parsercommon.KindCacheWrite
}
// BOTH POINTERS: a details object forwarded without the count inside it reports
// nothing, and must not set the bit.
if u.OutputTokensDetails != nil && u.OutputTokensDetails.ThinkingTokens != nil {
n.Reasoning = *u.OutputTokensDetails.ThinkingTokens
n.Present |= parsercommon.KindReasoning
}
return n
}

Expand Down Expand Up @@ -333,7 +352,7 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline
switch ev.Type {
case "message_start":
if ev.Message != nil {
mergeAnthropicPromptMaxSeen(state, ev.Message.Usage.toNeutral())
mergeAnthropicUsageMaxSeen(state, ev.Message.Usage.toNeutral())
state.hasUsage = true
}
case "content_block_start":
Expand Down Expand Up @@ -377,7 +396,7 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline
// message_delta; non-beta path carries no input counts here.
// Max-seen per sub-field handles both without clobbering.
neutral := ev.Usage.toNeutral()
mergeAnthropicPromptMaxSeen(state, neutral)
mergeAnthropicUsageMaxSeen(state, neutral)
if neutral.Output > 0 {
state.usage.Output = neutral.Output // cumulative
}
Expand All @@ -386,14 +405,34 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline
}
}

// mergeAnthropicPromptMaxSeen updates prompt-side sub-fields with
// max-seen semantics so a later event carrying zero cannot clobber an
// earlier real count. See foldAnthropicFrame for why both events need
// this.
func mergeAnthropicPromptMaxSeen(state *inferenceStreamState, incoming parsercommon.TokenUsage) {
// mergeAnthropicUsageMaxSeen updates every sub-field this function owns with
// max-seen semantics so a later event carrying zero cannot clobber an earlier
// real count. See foldAnthropicFrame for why both events need this.
//
// EVERY SUB-FIELD IT OWNS: Present is unioned here for ALL kinds, so any kind whose bit
// this sets must have its value merged here too. Split across two places, a bit arrives
// set with a value of nothing — and `abctl cost` then prints "reasoning (of output) 0",
// the claim ThinkingTokensAbsent forbids.
//
// Output is deliberately NOT here: it is cumulative on the wire rather than max-seen,
// and foldAnthropicFrame assigns it directly.
func mergeAnthropicUsageMaxSeen(state *inferenceStreamState, incoming parsercommon.TokenUsage) {
// Presence is a union across events: once a sub-field is observed on
// the wire, later events that omit it must not clear the bit.
// the wire, later events that omit it must not clear the bit. Kept beside the
// value merges below so that every kind merged HERE has its bit and its value set
// in one place.
//
// KindOutput is the one exception, and naming it is the point: toNeutral asserts
// that bit unconditionally, so this union sets it while Output is filled in
// foldAnthropicFrame instead — it is cumulative on the wire, not max-seen. That
// split predates this function and pricing.outputUncounted depends on it. The
// exception is written down because the reasoning bug this signature was changed
// to fix was a bit set here and a value filled elsewhere; an unqualified claim
// that it cannot happen would hide the one place it still does.
state.usage.Present |= incoming.Present
if incoming.Reasoning > state.usage.Reasoning {
state.usage.Reasoning = incoming.Reasoning
}
if incoming.Input > state.usage.Input {
state.usage.Input = incoming.Input
}
Expand Down
273 changes: 273 additions & 0 deletions authbridge/authlib/plugins/inferenceparser/anthropic_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"testing"

"github.com/rossoctl/cortex/authbridge/authlib/pipeline"
"github.com/rossoctl/cortex/authbridge/authlib/plugins/internal/parsercommon"
)

func TestInferenceParser_AnthropicMessages_Request(t *testing.T) {
Expand Down Expand Up @@ -532,3 +533,275 @@ func TestInferenceParser_AnthropicMessages_QueryStringPath(t *testing.T) {
t.Errorf("FinishReason = %q, want end_turn", ext.FinishReason)
}
}

// TestInferenceParser_AnthropicMessages_NonStreamingThinkingTokens covers
// usage.output_tokens_details.thinking_tokens, which reports how much of
// output_tokens the model spent on internal reasoning. The usage block below is
// captured verbatim from a real claude-opus-5 turn at output_config.effort "max":
// 948 of 1593 generated tokens were thinking. Reasoning is a SUBSET of output,
// never a sibling — adding them double-counts every thinking token.
func TestInferenceParser_AnthropicMessages_NonStreamingThinkingTokens(t *testing.T) {
p := NewInferenceParser()
pctx := &pipeline.Context{Path: "/v1/messages"}
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true}

body := []byte(`{
"id": "msg_bdrk_1", "type": "message", "role": "assistant", "model": "claude-opus-5",
"content": [{"type": "thinking", "thinking": ""}, {"type": "text", "text": "582, 663, 744, 825, 906."}],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 56,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"output_tokens": 1593,
"output_tokens_details": {"thinking_tokens": 948}
}
}`)
p.OnResponseFrame(context.Background(), pctx, body, true)

ext := pctx.Extensions.Inference
if ext.ReasoningTokens != 948 {
t.Errorf("ReasoningTokens = %d, want 948", ext.ReasoningTokens)
}
// Reasoning must not inflate the totals it is a subset of.
if ext.OutputTokens != 1593 || ext.TotalTokens != 1649 {
t.Errorf("output %d / total %d, want 1593/1649 (reasoning must not be added)",
ext.OutputTokens, ext.TotalTokens)
}
if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 {
t.Errorf("PresentKinds = %#b, want KindReasoning set", ext.PresentKinds)
}
}

// TestInferenceParser_AnthropicMessages_StreamThinkingTokensOnMessageDelta pins
// where the field actually arrives when streaming — which is what every Claude
// Code turn does. The three usage blocks below are captured verbatim from a real
// streamed claude-opus-5 turn: message_start carries NO output_tokens_details,
// message_delta carries it, and the trailing message_stop carries a usage block
// with output_tokens but no details at all.
//
// That last frame is the trap: reading details from message_start finds nothing,
// and any last-wins fold over message_stop would zero a real count back out. This
// is the same failure shape as the ?beta=true prompt-cache counts.
func TestInferenceParser_AnthropicMessages_StreamThinkingTokensOnMessageDelta(t *testing.T) {

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 new max-seen merge for reasoning has no fixture that would catch its removal.

Every streaming fixture carries at most one non-zero thinking_tokens: 948 in the non-streaming test, and 119 here and in ThinkingTokensOnMessageStart — two different tests, never two competing deltas within one stream. So replacing

if incoming.Reasoning > state.usage.Reasoning {
    state.usage.Reasoning = incoming.Reasoning
}

with a plain assignment passes the entire suite.

Worth closing precisely because coupling the value to its presence bit in mergeAnthropicUsageMaxSeen was the round-4 fix — the mechanism the signature change exists for is the one part of it no fixture pins. A stream with two message_delta frames carrying 119 then 205 (asserting 205) would pin last-wins; a descending pair would additionally witness the max direction, which is the half a cumulative-looking counter hides.

p := NewInferenceParser()
pctx := &pipeline.Context{Path: "/v1/messages"}
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", Stream: true, IsAction: true}

frames := [][]byte{
[]byte(`{"type":"message_start","message":{"id":"msg_bdrk_2","type":"message","role":"assistant","usage":{"input_tokens":22,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":6}}}`),
[]byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`),
[]byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"1729 is a taxicab number."}}`),
[]byte(`{"type":"content_block_stop","index":0}`),
[]byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":22,"output_tokens":235,"output_tokens_details":{"thinking_tokens":119}}}`),
[]byte(`{"type":"message_stop","usage":{"input_tokens":22,"output_tokens":235}}`),
}
for _, f := range frames {
if action := p.OnResponseFrame(context.Background(), pctx, f, false); action.Type != pipeline.Continue {
t.Fatalf("frame action = %v, want Continue", action.Type)
}
}
p.OnResponseFrame(context.Background(), pctx, nil, true)

ext := pctx.Extensions.Inference
if ext.ReasoningTokens != 119 {
t.Errorf("ReasoningTokens = %d, want 119 (from message_delta, not cleared by message_stop)",
ext.ReasoningTokens)
}
if ext.OutputTokens != 235 {
t.Errorf("OutputTokens = %d, want 235", ext.OutputTokens)
}
if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 {
t.Errorf("PresentKinds = %#b, want KindReasoning set", ext.PresentKinds)
}
}

// TestInferenceParser_AnthropicMessages_ThinkingTokensAbsent guards the
// distinction PresentKinds exists to carry: a provider that never reports a
// reasoning split must leave the bit CLEAR, not assert a reported zero. Claude
// with thinking off, and every pre-details gateway, land here — and a set bit
// would make abctl print "reasoning (of output) 0", which claims the model did no
// reasoning when the truth is that nothing said.
func TestInferenceParser_AnthropicMessages_ThinkingTokensAbsent(t *testing.T) {
p := NewInferenceParser()
pctx := &pipeline.Context{Path: "/v1/messages"}
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true}

body := []byte(`{
"id": "msg_bdrk_3", "type": "message", "role": "assistant", "model": "claude-opus-5",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 4}
}`)
p.OnResponseFrame(context.Background(), pctx, body, true)

ext := pctx.Extensions.Inference
if ext.ReasoningTokens != 0 {
t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens)
}
if ext.PresentKinds&uint8(parsercommon.KindReasoning) != 0 {
t.Errorf("PresentKinds = %#b, want KindReasoning CLEAR when the wire omits details",
ext.PresentKinds)
}
}

// TestInferenceParser_AnthropicMessages_ThinkingTokensPartiallyAbsent pins the
// INNER nil check, which the outer one does not cover.
//
// A gateway can forward the output_tokens_details key without the count inside it
// — an empty object, or an explicit null. Both leave the struct pointer non-nil
// and the int pointer nil, so a guard that tested only the outer pointer would
// dereference nil and panic the parser on a well-formed HTTP response. Neither
// shape appeared in any fixture, so removing the inner check broke nothing that
// any test could see.
//
// The correct outcome is the same as fully absent: KindReasoning stays CLEAR,
// because a key with no number in it reported nothing.
func TestInferenceParser_AnthropicMessages_ThinkingTokensPartiallyAbsent(t *testing.T) {
for _, tc := range []struct{ name, details string }{
{"empty details object", `"output_tokens_details": {}`},
{"explicit null count", `"output_tokens_details": {"thinking_tokens": null}`},
{"unrelated sub-field only", `"output_tokens_details": {"something_else": 7}`},
} {
t.Run(tc.name, func(t *testing.T) {
p := NewInferenceParser()
pctx := &pipeline.Context{Path: "/v1/messages"}
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true}

body := []byte(`{
"id": "msg_bdrk_4", "type": "message", "role": "assistant", "model": "claude-opus-5",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 4, ` + tc.details + `}
}`)
// Must not panic, and must not claim a measurement.
p.OnResponseFrame(context.Background(), pctx, body, true)

ext := pctx.Extensions.Inference
if ext.ReasoningTokens != 0 {
t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens)
}
if ext.PresentKinds&uint8(parsercommon.KindReasoning) != 0 {
t.Errorf("PresentKinds = %#b, want KindReasoning CLEAR for a count-free details object",
ext.PresentKinds)
}
// The kinds that WERE reported must survive the partial details object.
if ext.OutputTokens != 4 {
t.Errorf("OutputTokens = %d, want 4", ext.OutputTokens)
}
})
}
}

// TestInferenceParser_AnthropicMessages_ThinkingTokensOnMessageStart pins that a
// value and its presence bit travel together.
//
// Anthropic puts output_tokens_details on message_delta today, which is why the
// streaming fixture above does. Nothing guarantees that: a gateway may relay it on
// message_start instead. When the value merge lived in the message_delta branch
// while Present was unioned for every event, this input set KindReasoning with a
// value of 0 — and `abctl cost` would print "reasoning (of output) 0", the exact
// claim ThinkingTokensAbsent forbids.
func TestInferenceParser_AnthropicMessages_ThinkingTokensOnMessageStart(t *testing.T) {
p := NewInferenceParser()
pctx := &pipeline.Context{Path: "/v1/messages"}
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", Stream: true, IsAction: true}

frames := [][]byte{
[]byte(`{"type":"message_start","message":{"id":"msg_bdrk_5","type":"message","role":"assistant","usage":{"input_tokens":22,"output_tokens":6,"output_tokens_details":{"thinking_tokens":119}}}}`),
[]byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`),
// No details here, so only the max-seen merge can carry the earlier value.
[]byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":22,"output_tokens":235}}`),
}
for _, f := range frames {
p.OnResponseFrame(context.Background(), pctx, f, false)
}
p.OnResponseFrame(context.Background(), pctx, nil, true)

ext := pctx.Extensions.Inference
if ext.ReasoningTokens != 119 {
t.Errorf("ReasoningTokens = %d, want 119 from message_start", ext.ReasoningTokens)
}
if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 {
t.Errorf("PresentKinds = %#b, want KindReasoning set", ext.PresentKinds)
}
// NO "the bit must never be set with a zero value" CHECK HERE, and deliberately so:
// that shape is LEGAL. TestInferenceParser_AnthropicMessages_ThinkingTokensReportedZero
// REQUIRES it — a present-and-zero count is a measurement, not an absence, which is the
// rule apportion.go and the drawer's reportedZero branch are both built on. An assertion
// forbidding it here stated the opposite of the design and was inert only because this
// fixture reports 119.
//
// The split-brain bug this test exists for is already caught above: a bit unioned in one
// place with the value merged in another leaves ReasoningTokens at 0 against a set bit,
// and the 119 assertion fails on exactly that.
}

// TestInferenceParser_AnthropicMessages_ThinkingTokensReportedZero pins the wire shape
// the partially-absent cases above cannot express: the count is PRESENT and it is zero.
//
// That is a measurement — the model was asked to think and spent nothing on it, which
// is the observation that says an effort setting is not reaching the model — so
// KindReasoning must be SET, unlike every absent shape. A parser that treated zero as
// absence would pass every other fixture here.
func TestInferenceParser_AnthropicMessages_ThinkingTokensReportedZero(t *testing.T) {
p := NewInferenceParser()
pctx := &pipeline.Context{Path: "/v1/messages"}
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true}

body := []byte(`{
"id": "msg_bdrk_6", "type": "message", "role": "assistant", "model": "claude-opus-5",
"content": [{"type": "text", "text": "ok"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 400,
"output_tokens_details": {"thinking_tokens": 0}}
}`)
p.OnResponseFrame(context.Background(), pctx, body, true)

ext := pctx.Extensions.Inference
if ext.ReasoningTokens != 0 {
t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens)
}
if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 {
t.Errorf("PresentKinds = %#b, want KindReasoning SET — a reported zero is a "+
"measurement, not an absence", ext.PresentKinds)
}
}

// TestInferenceParser_AnthropicMessages_ThinkingTokensStreamedZero is the STREAMING
// reported-zero shape, which the non-streaming case above cannot stand in for.
//
// It is the shape mergeAnthropicUsageMaxSeen's own doc is built around: a bit set while
// the value is nothing. Max-seen means a zero never raises the running total, so the
// only thing carrying the observation across frames is the Present union — and a fold
// that took presence from the value would drop it here while passing every other
// streaming fixture, all of which report a non-zero count.
func TestInferenceParser_AnthropicMessages_ThinkingTokensStreamedZero(t *testing.T) {
p := NewInferenceParser()
pctx := &pipeline.Context{Path: "/v1/messages"}
pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", Stream: true, IsAction: true}

frames := [][]byte{
[]byte(`{"type":"message_start","message":{"id":"msg_bdrk_7","type":"message","role":"assistant","usage":{"input_tokens":22,"output_tokens":6}}}`),
[]byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`),
[]byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":22,"output_tokens":400,"output_tokens_details":{"thinking_tokens":0}}}`),
// A trailing details-free block, which must not clear what was reported.
[]byte(`{"type":"message_stop","usage":{"input_tokens":22,"output_tokens":400}}`),
}
for _, f := range frames {
p.OnResponseFrame(context.Background(), pctx, f, false)
}
p.OnResponseFrame(context.Background(), pctx, nil, true)

ext := pctx.Extensions.Inference
if ext.ReasoningTokens != 0 {
t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens)
}
if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 {
t.Errorf("PresentKinds = %#b, want KindReasoning SET — the count was on the wire and "+
"it was zero, which is a measurement", ext.PresentKinds)
}
if ext.OutputTokens != 400 {
t.Errorf("OutputTokens = %d, want 400", ext.OutputTokens)
}
}
Loading
Loading