From 4e29888ca73c695678b4d139ff0d97affd04ba69 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 15:12:12 -0400 Subject: [PATCH 01/24] fix: Read Anthropic's thinking_tokens into the reasoning counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic usage parser declared four fields and dropped usage.output_tokens_details on the floor, so ReasoningTokens was never populated for Claude traffic. Everything downstream was already built for it — usage.Counts.ReasoningTokens, KindReasoning in the PresentKinds bitmask, the Add fold, and abctl's tokenSplit, which has been rendering a "reasoning (of output)" line gated on a bit nothing ever set. The parser carried a comment asserting that Anthropic does not expose reasoning. That was true once. It is not now: the Messages API reports usage.output_tokens_details.thinking_tokens, the share of output_tokens spent on internal reasoning, and it arrives populated. The stale comment is why the field stayed unread long after the wire carried it, so it is replaced with the measured fact rather than deleted. Two details the wire forces: - thinking_tokens rides only on message_delta. message_start omits it and the trailing message_stop carries a details-free usage block, so the streaming fold takes it max-seen — the same shape as the ?beta=true prompt-cache counts, which had the same failure mode. - Both the details object and the count inside it are pointers. A gateway that forwards the key without the count reports nothing, and must leave KindReasoning clear rather than assert a reported zero — otherwise abctl prints "reasoning (of output) 0", which claims the model did no reasoning when the truth is that nothing said. Reasoning stays a subset of output, never a sibling: it is not added to any total and gets no pricing tier of its own, because thinking bills at the output rate and summing them would double-count every thinking token. Verified against live claude-opus-5 turns, streaming and non-streaming. Fixture usage blocks are captured verbatim from those turns. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic.go | 36 +++++- .../plugins/inferenceparser/anthropic_test.go | 112 ++++++++++++++++++ 2 files changed, 144 insertions(+), 4 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic.go b/authbridge/authlib/plugins/inferenceparser/anthropic.go index d5a1ba152..b956377b4 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic.go @@ -129,18 +129,33 @@ 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 exactly one sub-field, thinking_tokens: the share of output spent on + // internal reasoning. It is a SUBSET of OutputTokens, not 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. This parser used to +// carry a comment asserting Anthropic does not expose reasoning; that was true +// once and is not now, and the stale comment is why the field stayed unread long +// after the wire carried it. Verified against a live claude-opus-5 turn and +// documented under build-with-claude/thinking-steering-and-cost. func (u anthropicUsage) toNeutral() parsercommon.TokenUsage { n := parsercommon.TokenUsage{ Input: u.InputTokens, @@ -155,6 +170,12 @@ func (u anthropicUsage) toNeutral() parsercommon.TokenUsage { n.CacheWrite = *u.CacheCreationInputTokens n.Present |= parsercommon.KindCacheWrite } + // Both pointers are checked: a details object present but empty (a gateway that + // forwards the key without the count) 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 } @@ -381,6 +402,13 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline if neutral.Output > 0 { state.usage.Output = neutral.Output // cumulative } + // Max-seen, for the reason the prompt side is: thinking_tokens rides + // only on message_delta, and a later frame that omits it (message_stop + // carries a details-free usage block) must not clear a real count. + // mergeAnthropicPromptMaxSeen already unioned the Present bit. + if neutral.Reasoning > state.usage.Reasoning { + state.usage.Reasoning = neutral.Reasoning + } state.hasUsage = true } } diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go index 680040597..77747e43f 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go @@ -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) { @@ -532,3 +533,114 @@ 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) { + 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) + } +} From b3ec965c9bfe8b7598ad1999867c7a8b11d11367 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 17:34:54 -0400 Subject: [PATCH 02/24] feat: Show reasoning spend in abctl's tier panel and detail pane MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two surfaces, both existing, neither widened. THE SPEND DRAWER gains reasoning as an indented child of output: output 54% ████████████ $2.39 └ reasoning 31% ███████▏ $1.42 cache-read 35% ███████▉ $1.60 NOT a fifth tier, and that is the whole shape of it. Reasoning has no rate of its own — it is a subset of output, billed at the output rate — so numTierRows stays pinned to pricing.NumTiers, ApportionTiers still returns four figures that sum to the bill, and the child is excluded from the shares that total 100. Its money is apportioned from output's DISPLAYED figure so it divides into the row directly above it, and it is clamped to that parent because a child drawing a longer bar than its parent is the one lie this layout can tell while looking authoritative. The share stays denominated in the total, not in output. 56%-of-output is the more interesting number, but two denominators in one column is the defect renderTierRows already refuses, and the containment reads from the indent. The row is ALWAYS rendered, showing the not-known cell when no split was reported — never $0.00, which would claim the model did no reasoning when nothing said either way. A height that followed its data is what TestRenderTierRows_HeightIsConstant records as having overflowed this pane by five rows and under-filled it by six. THE DETAIL PANE gains reasoningTokens beside completionTokens, which is where the exact per-event figure now lives. The events table is deliberately untouched: it shows one total per row as it always has, because a column tight enough to be dropped on a 150-column terminal is the wrong home for a fifth figure, and this pane has room for the whole word. "reasoning", not "thinking". That is the word every other surface here uses — usage.Counts.ReasoningTokens, parsercommon's KindReasoning, and `abctl cost`'s own "reasoning (of output)" line. Anthropic's wire field is thinking_tokens and that name stays where it belongs, on the JSON tag that reads it. tierLabelWidth moves 11 to 12: the longest label is no longer "cache-write" but " └ reasoning", whose two leading columns are the indent. TWO PRIOR DECISIONS REVERSED, deliberately, with the invariants behind them kept: - TestRenderTierRows_ReasoningIsNotATier asserted reasoning was absent from this panel. It is now shown but still not a tier: the assertion moved from its absence to its exclusion from the 100% and its indent. - The panel's tree glyphs were removed for implying a parent that did not exist. Reasoning is the first row that has one, so the guard now checks a glyph's parent is on the preceding line rather than banning the glyph. Also covers tokenSplit's reasoning line, which was written but unreachable until the parser started setting KindReasoning. Found by rendering rather than by reading: the drawer's assembly loop was bounded by numTierRows, so the child row displaced a tier instead of adding to one — cheapest first, so `input` silently vanished from a panel still claiming to break down the whole bill. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/cost_token_split_test.go | 49 +++++ authbridge/cmd/abctl/tui/detail_pane.go | 10 +- .../cmd/abctl/tui/detail_reasoning_test.go | 67 +++++++ authbridge/cmd/abctl/tui/spend_drawer.go | 34 +++- authbridge/cmd/abctl/tui/spend_drawer_test.go | 28 ++- authbridge/cmd/abctl/tui/spend_tiers.go | 140 +++++++++++++- .../abctl/tui/spend_tiers_reasoning_test.go | 178 ++++++++++++++++++ authbridge/cmd/abctl/tui/spend_tiers_test.go | 86 +++++++-- 8 files changed, 551 insertions(+), 41 deletions(-) create mode 100644 authbridge/cmd/abctl/cost_token_split_test.go create mode 100644 authbridge/cmd/abctl/tui/detail_reasoning_test.go create mode 100644 authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go diff --git a/authbridge/cmd/abctl/cost_token_split_test.go b/authbridge/cmd/abctl/cost_token_split_test.go new file mode 100644 index 000000000..b88d7a547 --- /dev/null +++ b/authbridge/cmd/abctl/cost_token_split_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "strings" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/usage" +) + +// tokenSplit's reasoning line was unreachable surface until the Anthropic parser +// learned to read usage.output_tokens_details.thinking_tokens: the line was +// written, gated on KindReasoning, and no producer ever set that bit. These tests +// pin the behaviour now that it can fire, so the next parser regression shows up +// here instead of as a silently missing line in `abctl cost`. +func TestTokenSplit_RendersReasoningAsSubsetOfOutput(t *testing.T) { + // Captured from a live claude-opus-5 turn at effort "max". + got := tokenSplit(usage.Counts{ + InputTokens: 56, + OutputTokens: 1593, + ReasoningTokens: 948, + PresentKinds: uint8(usage.KindInput | usage.KindOutput | usage.KindReasoning), + }) + if !strings.Contains(got, "948") { + t.Errorf("tokenSplit = %q, want the 948 reasoning tokens", got) + } + // The label must keep saying "of output". Without it the line reads as a fifth + // sibling tier and invites summing it into the total, which double-counts every + // thinking token at the output rate. + if !strings.Contains(got, "of output") { + t.Errorf("tokenSplit = %q, want the reasoning label to say it is a subset of output", got) + } +} + +// The absent case is the one that matters for honesty: a provider that reports no +// split must produce NO reasoning line, not "reasoning (of output) 0". +func TestTokenSplit_OmitsReasoningWhenUnreported(t *testing.T) { + got := tokenSplit(usage.Counts{ + InputTokens: 56, + OutputTokens: 1593, + PresentKinds: uint8(usage.KindInput | usage.KindOutput), + }) + if strings.Contains(got, "reasoning") { + t.Errorf("tokenSplit = %q, want no reasoning line when KindReasoning is clear", got) + } + // The kinds that WERE reported must still be there. + if !strings.Contains(got, "output") { + t.Errorf("tokenSplit = %q, want the output line", got) + } +} diff --git a/authbridge/cmd/abctl/tui/detail_pane.go b/authbridge/cmd/abctl/tui/detail_pane.go index 6bc1e776f..0875b3fda 100644 --- a/authbridge/cmd/abctl/tui/detail_pane.go +++ b/authbridge/cmd/abctl/tui/detail_pane.go @@ -186,9 +186,17 @@ var ( "model", "messages", "temperature", "maxTokens", "topP", "stream", "tools", "toolChoice", } + // reasoningTokens is a SUBSET of completionTokens, not a sibling: it is the share + // of the generated tokens the model spent reasoning, billed at the output rate. + // Adding the two double-counts every one of them. + // + // THIS PANE IS WHERE THE EXACT FIGURE LIVES. The events table shows one total per + // row and the spend drawer shows a proportion, so a reader who wants the number + // rather than the shape comes here — which is also why the key needs no + // abbreviating: there is room for the whole word, unlike a table column. inferenceRespKeys = []string{ "model", "completion", "finishReason", "promptTokens", - "completionTokens", "totalTokens", "toolCalls", + "completionTokens", "reasoningTokens", "totalTokens", "toolCalls", "cacheWriteTokens", "cacheReadTokens", } mcpReqKeys = []string{"method", "rpcId", "params"} diff --git a/authbridge/cmd/abctl/tui/detail_reasoning_test.go b/authbridge/cmd/abctl/tui/detail_reasoning_test.go new file mode 100644 index 000000000..1a30e3d9e --- /dev/null +++ b/authbridge/cmd/abctl/tui/detail_reasoning_test.go @@ -0,0 +1,67 @@ +package tui + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/pipeline" +) + +// The detail pane is where the EXACT reasoning figure lives. +// +// The events table shows one total per row and the spend drawer shows a proportion, +// deliberately: a table column tight enough to be dropped on a 150-column terminal is +// the wrong home for a fifth figure. So this pane is the only place the number itself +// appears per event, and filterForDetail dropping it would leave it nowhere. +func TestFilterForDetail_ResponseKeepsReasoningTokens(t *testing.T) { + ext := &pipeline.InferenceExtension{ + Model: "claude-opus-5", OutputTokens: 1593, CompletionTokens: 1593, + ReasoningTokens: 948, PromptTokens: 56, TotalTokens: 1649, + } + raw, err := json.Marshal(map[string]any{"inference": ext}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := string(filterForDetail(raw, pipeline.SessionResponse)) + + if !strings.Contains(got, "reasoningTokens") { + t.Errorf("response detail dropped reasoningTokens, so the figure appears nowhere:\n%s", got) + } + if !strings.Contains(got, "948") { + t.Errorf("response detail dropped the reasoning count:\n%s", got) + } + // It must sit beside the figure it is a subset of, not replace it. + if !strings.Contains(got, "completionTokens") { + t.Errorf("response detail lost completionTokens:\n%s", got) + } +} + +// A REQUEST row has no reasoning figure to show — reasoning is reported with the +// response — so the key must not leak onto the request side, where it would read as +// a request-time budget rather than a measurement. +func TestFilterForDetail_RequestDropsReasoningTokens(t *testing.T) { + ext := &pipeline.InferenceExtension{Model: "claude-opus-5", ReasoningTokens: 948} + raw, err := json.Marshal(map[string]any{"inference": ext}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := string(filterForDetail(raw, pipeline.SessionRequest)); strings.Contains(got, "reasoningTokens") { + t.Errorf("request detail carries reasoningTokens:\n%s", got) + } +} + +// Unreported reasoning must not render as a zero. The field is omitempty, so an +// absent split leaves the key out entirely rather than claiming the model did none. +func TestFilterForDetail_UnreportedReasoningIsAbsentNotZero(t *testing.T) { + ext := &pipeline.InferenceExtension{ + Model: "gpt-oss-120b", OutputTokens: 244, CompletionTokens: 244, + } + raw, err := json.Marshal(map[string]any{"inference": ext}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := string(filterForDetail(raw, pipeline.SessionResponse)); strings.Contains(got, "reasoningTokens") { + t.Errorf("a provider reporting no split still shows reasoningTokens:\n%s", got) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index c2a4f16d9..d7db3be48 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -56,11 +56,19 @@ const ( // full height, and layout() reserving fewer is not a cosmetic slip — the view comes out // taller than the terminal and the footer goes off the bottom, which is the failure // spendStripReservesRow's own doc describes for one row. - // Now: one HEADER row, the taller of the two columns, and the hint line. Both columns - // are four rows — numTierRows on the left, spendDrawerSeries ranked series plus the - // "(other)" band on the right — so the arithmetic is numTierRows + 2 and the two - // columns are the same height by construction rather than by coincidence. - spendDrawerLines = numTierRows + 2 + // Now: one HEADER row, the taller of the two columns, and the hint line. The left + // column is tierPanelLines — numTierRows plus the optional reasoning child that + // hangs under output — and the right is spendDrawerSeries ranked series plus the + // "(other)" band, so the reservation is the taller of the two plus the two fixed + // rows. + // + // RESERVED UNCONDITIONALLY, even though the child row only renders when a provider + // reports a reasoning split. Reserving the maximum costs one row of body height on + // traffic that has no split; reserving the actual height would make the drawer's + // size depend on the data, so pressing `$` on a session that happens to report + // thinking would push the footer off the bottom — exactly the failure this comment + // already records. + spendDrawerLines = max(tierPanelLines, spendDrawerSeries+1) + 2 ) // spendDrawerAxes are the breakdown axes `g` cycles through. @@ -713,10 +721,18 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window out := make([]string, 0, spendDrawerLines) out = append(out, drawerHeaders(axis, twoCol, width)) - for i := 0; i < numTierRows; i++ { - // NO BRANCH GLYPHS. "├" and "└" implied a parent row that does not exist — there is no - // node above these — and the column header now names the grouping the glyphs were - // gesturing at. + // tierPanelLines, NOT numTierRows: the left column is the four rate tiers PLUS the + // reasoning row that hangs under output. Bounded by numTierRows this loop dropped + // the last tier to make room for the child — cheapest tier first, so `input` simply + // vanished from a panel that still claimed to break down the whole bill. + // + // The right column is shorter and its slots are filled by the `i < len(rows)` guard + // below, so the two columns stay the same height by construction. + for i := 0; i < tierPanelLines; i++ { + // NO BRANCH GLYPHS BETWEEN THE COLUMNS' OWN ROWS. "├" and "└" once prefixed every + // row here and implied a parent none of them had; the column headers name the + // grouping instead. The one "└" now in the panel is the reasoning row's, which does + // have a parent directly above it — that is the distinction, not the glyph. series := "" if i < len(rows) { series = fitStripFigures(" ", drawerFigures(rows[i]), seriesWidth) diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index 1592e55be..14a70433b 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -1299,12 +1299,30 @@ func TestRenderSpendDrawer_DoesNotRestateTheBandsFigures(t *testing.T) { } } -// The tree glyphs are gone: they implied a parent row that does not exist. +// No ORPHAN tree glyph. The original rule was "no glyphs at all", because every row +// they prefixed was top-level and the glyph implied a parent none of them had. The +// reasoning row is the first row in this panel that genuinely has one — output, +// directly above it — so the glyph is now allowed exactly where it tells the truth. +// The invariant is unchanged: a glyph must have its parent on the preceding line. +// +// "├" stays banned outright. It means "more siblings follow", and reasoning is the +// only child this panel has. func TestRenderSpendDrawer_HasNoOrphanTreeGlyph(t *testing.T) { - joined := strings.Join(renderSpendDrawer(tierSnap(), nil, usage.GroupModel, "1h", 100), "\n") - for _, glyph := range []string{"└", "├"} { - if strings.Contains(joined, glyph) { - t.Errorf("the panel still draws %q, which implies a parent row:\n%s", glyph, joined) + lines := renderSpendDrawer(tierSnap(), nil, usage.GroupModel, "1h", 100) + joined := strings.Join(lines, "\n") + if strings.Contains(joined, "├") { + t.Errorf("the panel draws \"├\", which claims a sibling follows:\n%s", joined) + } + for i, l := range lines { + if !strings.Contains(l, "└") { + continue + } + if i == 0 { + t.Errorf("row 0 carries \"└\" with nothing above it to be a child of:\n%s", joined) + continue + } + if !strings.Contains(lines[i-1], "output") { + t.Errorf("row %d carries \"└\" but the line above it is not output:\n%s", i, joined) } } } diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index c98cd1c2f..217f1317d 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -20,18 +20,24 @@ import ( // constant and every state returns exactly this many rows — enforced by the return type // rather than by a guard, see renderTierRows. // -// Four and not five: reasoning is a subset of output, not a sibling tier, so a row for it -// would double-count the same money at the most expensive rate there is. It stays in -// `abctl cost`'s token line. +// Four and not five: reasoning is a subset of output, not a sibling tier, so counting it +// here would double-count the same money at the most expensive rate there is. It IS shown +// — as an indented child of output, see childTierLabel — but it is not a tier, which is +// why this constant stays pinned to the rate count and tierPanelLines carries the +// rendered height. const numTierRows = pricing.NumTiers // tierBarWidth is the widest a bar may be. Bars are decoration over a figure that is // already printed, so they yield their space before the figures do. const tierBarWidth = 12 -// tierLabelWidth is the label column. Sized to "cache-write", the longest of the four, so -// the bars start at one column whatever the mix. -const tierLabelWidth = 11 +// tierLabelWidth is the label column, sized to the longest label so the bars start at +// one column whatever the mix. +// +// 12, not 11: the longest label is no longer "cache-write" (11) but the reasoning +// child's " └ reasoning" (12), whose two leading columns are the indent that says it +// is part of the row above. +const tierLabelWidth = 12 // tierPctWidth is the share column: "100%" at its widest, right-aligned. // @@ -64,6 +70,26 @@ var tierLabels = map[pricing.Tier]string{ pricing.TierOutput: "output", } +// childTierLabel is the reasoning row's label, EXACTLY tierLabelWidth runes so the +// bars still start at one column whatever the mix. +// +// "reasoning", not "thinking", because that is the word every other surface in this +// repo uses for it — usage.Counts.ReasoningTokens, parsercommon KindReasoning, and +// `abctl cost`'s own "reasoning (of output)" line. Anthropic's wire field is +// thinking_tokens, and that name stays where it belongs: on the JSON tag that reads +// it. +// +// Indented and hung off a box-drawing stem rather than flush left, because the label +// has to carry a fact the money column cannot: this row's dollars are already inside +// the row above it. Flush left it reads as a fifth tier and the column stops adding +// up to the bill. +const childTierLabel = " └ reasoning" + +// tierPanelLines is the panel's MAXIMUM height: the four tiers plus the optional +// reasoning child. Separate from numTierRows, which stays pinned to +// pricing.NumTiers because that is a count of RATES and reasoning is not one. +const tierPanelLines = numTierRows + 1 + // tierOrder is the declaration order, which is deliberately NOT the display order. var tierOrder = [numTierRows]pricing.Tier{ pricing.TierInput, pricing.TierCacheWrite, pricing.TierCacheRead, pricing.TierOutput, @@ -150,7 +176,107 @@ func renderTierRows(c usage.Counts, width int) []string { } out[i] = clipRow(row, width) } - return out[:] + // The child row is appended into a SLICE rather than written into the array, + // because it is optional: the array's length is the guarantee that every tier got + // a row, and a fifth slot in it would be an empty string on the common path. + // ALWAYS APPENDED, never conditional. The panel's height must not follow its data — + // layout() reserves it from a constant it cannot consult, and a renderer whose + // height varied is what TestRenderTierRows_HeightIsConstant records as having + // overflowed this pane by five rows and under-filled it by six. When no reasoning + // split was reported the child renders the not-known cell, which is exactly what an + // absent TIER does two branches above. + return insertAfterOutput(out[:], order, + reasoningChildRow(c, tiers, ok, shares, peak, budget, width)) +} + +// reasoningChildRow renders the reasoning row that hangs under output. +// +// NOT A FIFTH TIER, and the whole shape of this function follows from that. Reasoning +// has no rate of its own: it is a subset of output, billed at the output rate, which +// pricing.Usage and usage.Counts both state in their own words and which is why +// ApportionTiers returns exactly pricing.NumTiers figures that sum to the bill. So +// this row is derived here rather than apportioned there, it is excluded from +// tierShares, and it is indented so a reader does not add it to the column. +// +// THE MONEY IS APPORTIONED FROM OUTPUT'S DISPLAYED FIGURE, not from +// c.OutputCostMicros. The displayed figure is already scaled to the gateway's +// authoritative total, so deriving from the raw mix would put a child on screen that +// does not divide into the parent printed directly above it. +// +// The share is denominated in the TOTAL, like every other row, even though the share +// of OUTPUT (56% here, against 15% of the bill) is the more interesting number. Two +// denominators in one column is the defect renderTierRows already refuses — "a row +// cannot state a percentage of one total beside a figure from another" — and the +// containment reads from the indent anyway: 15% under 27% is visibly a part of it. +func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, + shares [pricing.NumTiers]int, peak int64, budget, width int) string { + notKnown := clipRow(fmt.Sprintf("%-*s %s", tierLabelWidth, childTierLabel, emptyCell), width) + + // The present bit decides, as everywhere else: a clear bit with a zero value means + // nothing reported a split, which is not the same as a split of zero. A provider + // that exposes no reasoning counter gets the not-known cell, never $0.00 — the same + // refusal renderTierRows makes for a tier absent from the mix. + if !ok || c.PresentKinds&usage.KindReasoning == 0 && c.ReasoningTokens == 0 { + return notKnown + } + // No denominator, no defensible figure. Reasoning cannot be a share of an output + // that was never counted. + if c.OutputTokens <= 0 || tiers[pricing.TierOutput] <= 0 { + return notKnown + } + // Float ratio bounded by the parent, the form ApportionTiers uses and for its + // reason: the integer product of two window-sized sums overflows int64. + micros := int64(float64(tiers[pricing.TierOutput]) * + (float64(c.ReasoningTokens) / float64(c.OutputTokens))) + // CLAMPED TO THE PARENT. Reasoning should never exceed output on the wire, but a + // gateway that reports them inconsistently would otherwise draw a child longer than + // the bar above it — a lie that looks authoritative. Clamp rather than refuse: the + // figure is still the best available, and the parent bounds it. + if micros > tiers[pricing.TierOutput] { + micros = tiers[pricing.TierOutput] + } + // Floored against the same total the tier rows use, so the child is comparable down + // the column. Deliberately NOT tierShares, which must keep summing to 100 across + // exactly the four tiers. + pct := 0 + if c.CostMicros > 0 { + pct = int(micros * 100 / c.CostMicros) + } + // The child can never out-rank its parent's share once clamped, but floor division + // can tie them; the indent still distinguishes the rows. + if pct > shares[pricing.TierOutput] { + pct = shares[pricing.TierOutput] + } + label := childTierLabel + var row string + switch { + case budget > 0: + row = fmt.Sprintf("%-*s %s %-*s %s", tierLabelWidth, label, + tierShareCell(pct, micros), budget, tierBar(micros, peak, budget), + tierMoneyCell(micros)) + default: + row = fmt.Sprintf("%-*s %s %s", tierLabelWidth, label, + tierShareCell(pct, micros), tierMoneyCell(micros)) + } + return clipRow(row, width) +} + +// insertAfterOutput places the child directly beneath output, wherever the cost +// ranking put it. Adjacency is what carries "part of the row above" to a reader who +// has not learned the indent convention, so it has to follow the rank rather than +// sit at a fixed line. +func insertAfterOutput(rows []string, order [numTierRows]pricing.Tier, child string) []string { + at := len(rows) // fall back to last, so a missing output row cannot drop the child + for i, tier := range order { + if tier == pricing.TierOutput { + at = i + 1 + break + } + } + out := make([]string, 0, len(rows)+1) + out = append(out, rows[:at]...) + out = append(out, child) + return append(out, rows[at:]...) } // tierShareCell is one tier's share of the window, right-aligned. diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go new file mode 100644 index 000000000..70d58a971 --- /dev/null +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -0,0 +1,178 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/rossoctl/cortex/authbridge/authlib/usage" +) + +// reasoningCounts is tierCounts plus a reported reasoning split: 948 of 1,593 +// generated tokens were reasoning, captured from a live claude-opus-5 turn at +// effort "max". +func reasoningCounts() usage.Counts { + c := tierCounts() + c.OutputTokens = 1593 + c.ReasoningTokens = 948 + c.PresentKinds = uint8(usage.KindOutput | usage.KindReasoning) + return c +} + +// indentedRows returns the child rows — the ones this panel uses to say "part of +// the row above" rather than "peer of it". +func indentedRows(lines []string) []string { + var out []string + for _, l := range lines { + if strings.HasPrefix(l, " ") && strings.Contains(l, "reasoning") { + out = append(out, l) + } + } + return out +} + +// The child row exists, names itself, and sits DIRECTLY under output wherever +// output ranked — the adjacency is what carries "subset" to a reader who does not +// know the indent convention. +func TestRenderTierRows_ReasoningIsAChildOfOutput(t *testing.T) { + lines := renderTierRows(reasoningCounts(), tierColumnWidth) + + outputAt, reasoningAt := -1, -1 + for i, l := range lines { + switch { + case strings.HasPrefix(strings.TrimSpace(l), "output"): + outputAt = i + case strings.Contains(l, "reasoning"): + reasoningAt = i + } + } + if outputAt < 0 { + t.Fatal("no output row") + } + if reasoningAt < 0 { + t.Fatal("no reasoning row; a reported reasoning split must be shown") + } + if reasoningAt != outputAt+1 { + t.Errorf("reasoning is at %d and output at %d; the child must directly follow its parent", + reasoningAt, outputAt) + } + if !strings.HasPrefix(lines[reasoningAt], " ") { + t.Errorf("reasoning row %q is not indented; flush with the tiers it reads as a peer", + lines[reasoningAt]) + } +} + +// THE INVARIANT THIS PANEL EXISTS ON: the rows that sum to the bill still sum to +// the bill. The child is excluded from that sum because its money is already inside +// output's — counting both double-counts every reasoning token at the output rate, +// which is the error usage.Counts warns about in the field's own doc comment. +func TestRenderTierRows_ChildIsExcludedFromTheHundredPercent(t *testing.T) { + lines := renderTierRows(reasoningCounts(), tierColumnWidth) + + total, counted := 0, 0 + for _, l := range lines { + if strings.HasPrefix(l, " ") { // the child + continue + } + pct, ok := sharePercent(l) + if !ok { + continue + } + counted++ + total += pct + } + if counted != numTierRows { + t.Errorf("counted %d tier rows, want %d", counted, numTierRows) + } + if total != 100 { + t.Errorf("the four tier shares sum to %d%%, want 100%% — the child must not be in the sum", total) + } +} + +// Containment, checked as arithmetic rather than left to the label: a child that +// renders a bigger figure than its parent is the one way this layout can lie, and +// it would look authoritative doing it. +func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { + lines := renderTierRows(reasoningCounts(), tierColumnWidth) + + var outputPct, reasoningPct int + for _, l := range lines { + pct, ok := sharePercent(l) + if !ok { + continue + } + switch { + case strings.Contains(l, "reasoning"): + reasoningPct = pct + case strings.HasPrefix(strings.TrimSpace(l), "output"): + outputPct = pct + } + } + if reasoningPct > outputPct { + t.Errorf("reasoning is %d%% of the bill but output is only %d%%; a subset cannot exceed its set", + reasoningPct, outputPct) + } +} + +// An unreported split renders the NOT-KNOWN cell, not $0.00 and not a vanished row. +// +// The row must still be there: the panel's height is reserved from a constant that +// layout() cannot consult, and a height that followed the data is the defect +// TestRenderTierRows_HeightIsConstant exists for. And it must not read $0.00, which +// would assert the model did no reasoning when the truth is that nothing reported +// either way — the same refusal renderTierRows makes for an absent tier. +func TestRenderTierRows_UnreportedSplitIsNotKnownNotZero(t *testing.T) { + lines := renderTierRows(tierCounts(), tierColumnWidth) + child := indentedRows(lines) + if len(child) != 1 { + t.Fatalf("want exactly one child row even when unreported, got %d", len(child)) + } + if !strings.Contains(child[0], emptyCell) { + t.Errorf("child row = %q, want the not-known cell", child[0]) + } + if strings.Contains(child[0], "$0.00") { + t.Errorf("child row = %q, want no $0.00 — that asserts the model did no reasoning", child[0]) + } +} + +// Reasoning reported but nothing generated: no denominator, so no defensible figure. +// The row stays (height is constant) and says it does not know. +func TestRenderTierRows_NoFigureWithoutOutputTokens(t *testing.T) { + c := reasoningCounts() + c.OutputTokens = 0 + child := indentedRows(renderTierRows(c, tierColumnWidth)) + if len(child) != 1 { + t.Fatalf("want one child row, got %d", len(child)) + } + if !strings.Contains(child[0], emptyCell) { + t.Errorf("child row = %q, want the not-known cell with no output to apportion by", child[0]) + } +} + +// The panel's height is CONSTANT whether or not a split was reported. This is the +// invariant the drawer's fixed reservation depends on. +func TestRenderTierRows_HeightConstantAcrossReasoningStates(t *testing.T) { + withSplit := renderTierRows(reasoningCounts(), tierColumnWidth) + without := renderTierRows(tierCounts(), tierColumnWidth) + if len(withSplit) != len(without) { + t.Errorf("panel is %d lines with a split and %d without; height must not follow the data", + len(withSplit), len(without)) + } + if len(withSplit) != tierPanelLines { + t.Errorf("panel is %d lines, want tierPanelLines = %d", len(withSplit), tierPanelLines) + } +} + +// The drawer reserves its height from a constant, so the extra line has to be in +// it — otherwise the child row pushes the footer off the terminal, the defect +// keys.go records for spendDrawerLines. +func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { + if want := len(renderTierRows(reasoningCounts(), tierColumnWidth)); tierPanelLines < want { + t.Errorf("tierPanelLines = %d but the panel renders %d lines", tierPanelLines, want) + } + if tierPanelLines != numTierRows+1 { + t.Errorf("tierPanelLines = %d, want numTierRows+1 = %d", tierPanelLines, numTierRows+1) + } + if spendDrawerLines < tierPanelLines { + t.Errorf("spendDrawerLines = %d cannot hold a %d-line tier panel", spendDrawerLines, tierPanelLines) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_tiers_test.go b/authbridge/cmd/abctl/tui/spend_tiers_test.go index ae0636a51..1f3e7c229 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_test.go @@ -7,6 +7,7 @@ import ( "github.com/charmbracelet/lipgloss" + "github.com/rossoctl/cortex/authbridge/authlib/pricing" "github.com/rossoctl/cortex/authbridge/authlib/usage" ) @@ -23,6 +24,24 @@ func tierCounts() usage.Counts { } } +// tierRowsOnly drops the reasoning child row, leaving the four rate-tier rows. +// +// The child is always rendered — the panel's height must not follow its data — but it +// is NOT a tier: it carries no rate, it is excluded from the shares that sum to 100, +// and its money is already inside output's. Every assertion below about "each tier +// row" therefore has to be made against the tiers, and a test that iterated raw +// lines would be asserting tier properties of something that is not one. +func tierRowsOnly(lines []string) []string { + var out []string + for _, l := range lines { + if strings.HasPrefix(l, " ") { + continue + } + out = append(out, l) + } + return out +} + // THE MONEY COLUMN IS RIGHT-ALIGNED, so the decimal points line up down the panel. // // It was left-flushed directly after the bar, which put "$56.51", "$25.34", "$15.42" and "$2.11" @@ -45,7 +64,7 @@ func TestRenderTierRows_MoneyIsRightAligned(t *testing.T) { if !ok { t.Fatal("the fixture apportions to nothing, so this test asserts nothing") } - lines := renderTierRows(tierCounts(), tierColumnWidth) + lines := tierRowsOnly(renderTierRows(tierCounts(), tierColumnWidth)) type end struct { fig string at int @@ -107,7 +126,7 @@ func TestRenderTierRows_MoneyIsRightAligned(t *testing.T) { // proportionally smallest and cannot flip a rank — so a panel that reordered its bars to make the // arithmetic work would be caught by TestRenderTierRows_RanksByCostNotByDeclarationOrder. func TestRenderTierRows_SharesSumTo100(t *testing.T) { - lines := renderTierRows(tierCounts(), tierColumnWidth) + lines := tierRowsOnly(renderTierRows(tierCounts(), tierColumnWidth)) total, found := 0, 0 for _, line := range lines { pct, ok := sharePercent(line) @@ -204,7 +223,7 @@ func TestRenderTierRows_ASubPercentTierIsNotZero(t *testing.T) { func TestRenderTierRows_AnAbsentTierStatesNoShare(t *testing.T) { c := tierCounts() c.CacheWriteCostMicros = 0 // never wrote cache - lines := renderTierRows(c, tierColumnWidth) + lines := tierRowsOnly(renderTierRows(c, tierColumnWidth)) var cacheWrite string for _, line := range lines { @@ -236,7 +255,7 @@ func TestRenderTierRows_AnAbsentTierStatesNoShare(t *testing.T) { // share column has to inherit: a bar is decoration over a number that is printed anyway, while the // share IS a number. So a terminal too narrow for both keeps the share and drops the bar. func TestRenderTierRows_TheBarYieldsBeforeTheShare(t *testing.T) { - narrow := renderTierRows(tierCounts(), tierLabelWidth+1+tierPctWidth+1+tierMoneyWidth) + narrow := tierRowsOnly(renderTierRows(tierCounts(), tierLabelWidth+1+tierPctWidth+1+tierMoneyWidth)) for _, line := range narrow { if strings.ContainsAny(line, "█▉▊▋▌▍▎▏") { t.Errorf("row %q drew a bar at a width that only fits the figures", line) @@ -250,7 +269,7 @@ func TestRenderTierRows_TheBarYieldsBeforeTheShare(t *testing.T) { // Ranked by MONEY, descending — not by pricing.Tier's declaration order, which starts with // input, and not by token count. func TestRenderTierRows_RanksByCostNotByDeclarationOrder(t *testing.T) { - lines := renderTierRows(tierCounts(), 60) + lines := tierRowsOnly(renderTierRows(tierCounts(), 60)) if len(lines) != numTierRows { t.Fatalf("lines = %d, want exactly %d: %q", len(lines), numTierRows, lines) } @@ -307,7 +326,7 @@ func TestRenderTierRows_FiguresReadInCents(t *testing.T) { // No mix means the "not known here" cell, never $0.00 and never a guess. func TestRenderTierRows_NoMixRendersTheUnknownCell(t *testing.T) { - lines := renderTierRows(usage.Counts{Requests: 35, CostMicros: 4_546_200}, 60) + lines := tierRowsOnly(renderTierRows(usage.Counts{Requests: 35, CostMicros: 4_546_200}, 60)) if len(lines) != numTierRows { t.Fatalf("lines = %d, want %d even with no mix", len(lines), numTierRows) } @@ -320,23 +339,49 @@ func TestRenderTierRows_NoMixRendersTheUnknownCell(t *testing.T) { } } -// Reasoning is never a bar and never a figure here. +// Reasoning is never a TIER here, which is not the same as never being shown. // -// It is a SUBSET of output — tokenSplit labels it "reasoning (of output)" — so a fifth row -// would double-count the same money. The fixture reports reasoning tokens precisely so a -// renderer enumerating token KINDS instead of rate TIERS fails: there are five kinds on -// Counts and four tiers, and that difference is the point. +// This test used to assert reasoning was absent entirely. It is now displayed, as an +// indented child of output, because the panel was the only cost surface that could +// not answer "what is my effort setting costing me". What has NOT changed is the +// reason the original assertion existed: reasoning is a SUBSET of output — tokenSplit +// labels it "reasoning (of output)" — so it must never be counted as a peer. There +// are five token kinds on Counts and four rate tiers, and that difference is still +// the point; it is now carried by the indent and by exclusion from tierShares rather +// than by the row's absence. +// +// The fixture reports reasoning tokens precisely so a renderer that enumerated KINDS +// as TIERS fails here. func TestRenderTierRows_ReasoningIsNotATier(t *testing.T) { c := tierCounts() c.ReasoningTokens = 12_000 c.OutputTokens = 42_000 + c.PresentKinds = uint8(usage.KindOutput | usage.KindReasoning) lines := renderTierRows(c, 60) - joined := strings.Join(lines, "\n") - if strings.Contains(joined, "reasoning") { - t.Errorf("reasoning appears as a tier row, double-counting output:\n%s", joined) + + // numTierRows counts RATES and must not have grown. + if numTierRows != pricing.NumTiers { + t.Errorf("numTierRows = %d but there are %d rate tiers; reasoning became a tier", + numTierRows, pricing.NumTiers) + } + // The reasoning row must be indented — flush left it reads as a fifth tier. + for _, l := range lines { + if strings.Contains(l, "reasoning") && !strings.HasPrefix(l, " ") { + t.Errorf("reasoning row is flush with the tiers, so it reads as a peer: %q", l) + } } - if len(lines) != numTierRows { - t.Errorf("lines = %d, want %d — reasoning added a row", len(lines), numTierRows) + // And it must not be in the sum the tier rows own. + total := 0 + for _, l := range lines { + if strings.HasPrefix(l, " ") { + continue + } + if pct, ok := sharePercent(l); ok { + total += pct + } + } + if total != 100 { + t.Errorf("tier shares sum to %d%%, want 100%% — reasoning is being double-counted", total) } } @@ -355,8 +400,11 @@ func TestRenderTierRows_HeightIsConstant(t *testing.T) { } { for _, w := range []int{10, 20, 34, 46, 60, 100, 200} { got := renderTierRows(c, w) - if len(got) != numTierRows { - t.Errorf("%s at width %d: %d lines, want %d", name, w, len(got), numTierRows) + // tierPanelLines: four tiers plus the reasoning child, which renders the + // not-known cell rather than vanishing when no split was reported. Constant + // is the invariant; the constant itself grew by one. + if len(got) != tierPanelLines { + t.Errorf("%s at width %d: %d lines, want %d", name, w, len(got), tierPanelLines) } for i, line := range got { if n := len([]rune(line)); n > w { @@ -392,7 +440,7 @@ func TestRenderTierRows_ATierAbsentFromTheMixIsUnknownNotFree(t *testing.T) { Requests: 35, CostMicros: 4_546_200, InputCostMicros: 3000, OutputCostMicros: 45000, // no cache tiers in the mix } - lines := renderTierRows(c, 60) + lines := tierRowsOnly(renderTierRows(c, 60)) joined := strings.Join(lines, "\n") // Both spellings, because the figures read in cents now and "$0.00" is the one this panel From 3c8fd1f07f2542cacbd4d32598e8fea2438f43c9 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 17:34:54 -0400 Subject: [PATCH 03/24] chore: Regenerate the README demo asset for the reasoning row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The committed SVG is staleness-checked in CI and the tier panel gained a line, shifting every row below it down 18px. go -C authbridge/scripts/readme-demo run . It renders as the not-known cell because the demo storyboard reports no reasoning split. Correct for a provider exposing no counter, but it does mean the headline asset carries a row with no figure in it — worth revisiting the storyboard separately rather than smuggling fixture changes into an asset regeneration. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- docs/assets/cortex-demo.svg | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/assets/cortex-demo.svg b/docs/assets/cortex-demo.svg index 8b47ba23f..ec910f38f 100644 --- a/docs/assets/cortex-demo.svg +++ b/docs/assets/cortex-demo.svg @@ -239,19 +239,19 @@ clipPath rect{width:100000px} abctl · http://127.0.0.1:9094 LAST 1H $0.65 · TODAY $0.65 · 7 DAYS $0.79 · THIS MONTH $0.79 - WHERE IT WENT BY MODEL - cache-read 43% ████████████ $0.27 claude-opus-5 $0.63 4 req 319k tokens - output 31% █████████ $0.20 claude-sonnet-5 $0.02 1 req 16k tokens - cache-write 21% ██████ $0.14 - input 5% █▍ $0.03 - [a] [model] · endpoint · agent [w] LAST 1H esc closes -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── -SESSION TITLE UPDATED EVENTS TOKENS COST SAVED~CONTEXT(1M) - - infra-55de debug the helm chart 42s ago 2 16.2k $0.02 — ▕▏ ▏ - web-2a91 add dark mode toggle 42s ago 4 76.0k $0.20 — ▕▍ ▏ - api-7f3c fix the retry handler 42s ago 8 300.7k $0.57 — ▕█▎ ▏ - + WHERE IT WENT BY MODEL + cache-read 43% ████████████ $0.27 claude-opus-5 $0.63 4 req 319k tokens + output 31% █████████ $0.20 claude-sonnet-5 $0.02 1 req 16k tokens + └ reasoning — + cache-write 21% ██████ $0.14 + input 5% █▍ $0.03 + [a] [model] · endpoint · agent [w] LAST 1H esc closes +──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── +SESSION TITLE UPDATED EVENTS TOKENS COST SAVED~CONTEXT(1M) + + infra-55de debug the helm chart 42s ago 2 16.2k $0.02 — ▕▏ ▏ + web-2a91 add dark mode toggle 42s ago 4 76.0k $0.20 — ▕▍ ▏ + api-7f3c fix the retry handler 42s ago 8 300.7k $0.57 — ▕█▎ ▏ From 299666501e3aad15b217416bc866155dd04969f3 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 18:21:55 -0400 Subject: [PATCH 04/24] =?UTF-8?q?fix:=20Address=20review=20=E2=80=94=20cou?= =?UTF-8?q?ple=20reasoning's=20value=20to=20its=20presence=20bit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six review findings. Each fix is mutation-tested, because four of them were about guards and assertions that could not fail. 1. PRESENCE AND VALUE TRAVELLED ON DIFFERENT PATHS (anthropic.go). mergeAnthropicPromptMaxSeen unioned Present for every kind from both message_start and message_delta, but reasoning's VALUE was merged in the message_delta branch alone. A gateway relaying output_tokens_details on message_start would set KindReasoning with a value of 0, and `abctl cost` would print "reasoning (of output) 0" — the exact claim ThinkingTokensAbsent exists to forbid. The merge moves into the helper, renamed mergeAnthropicUsageMaxSeen since it no longer merges only the prompt side, and the union now sits beside the value merges so nothing can set a bit this function does not also fill. Mutation: removing the merge fails both streaming tests (0, want 119). 2. UNGUARDED tiers[i] (spend_drawer.go). The loop ran to tierPanelLines while the series column beside it guarded with i < len(rows), so a renderTierRows returning fewer rows was an out-of-range panic mid-render — a crashed TUI, from a contract held only by a test in another package. The bound is now read from the slice when there is one. Mutation: shortening renderTierRows panics with "index out of range [4] with length 4" under the old bound, and fails cleanly under the new one. 3. CLAMPS THAT NEVER EXECUTED (spend_tiers_reasoning_test.go). Every fixture was well-formed (948 of 1,593), so neither the money clamp nor the share clamp could fire, and ReasoningNeverExceedsOutput asserted an invariant its fixture made unreachable. It now runs three cases — well-formed, reasoning reported ABOVE output, and reasoning equal to output — and checks the drawn bar as well as the share, since clamping the percentage alone would still draw a child longer than its parent. Mutation: removing the clamps now fails with "reasoning is 132% of the bill but output is only 54%". Only the inverted case catches it, which is the point. 4. THE INNER NIL CHECK WAS UNTESTED (anthropic_test.go). No fixture carried output_tokens_details without a count inside it, so dropping the inner check broke nothing any test could see. Three shapes added: empty object, explicit null, and an unrelated sub-field. Mutation: dropping the check nil-derefs and panics the parser on a well-formed HTTP response. 5. tierRowsOnly MATCHED "ANY LEADING SPACE" rather than the child's label, so a tier row gaining an indent would silently shrink what the assertions iterate and several tests would weaken without failing. Now matched on childTierLabel, here and in the reasoning test's own helper — which had the same flaw and was not flagged. 6. AN UNRESOLVABLE CITATION (anthropic.go) is now the full URL. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic.go | 43 ++++--- .../plugins/inferenceparser/anthropic_test.go | 86 ++++++++++++++ .../inferenceparser/splittokens_test.go | 2 +- .../authlib/pricing/incompletereason_test.go | 2 +- authbridge/authlib/pricing/inference.go | 2 +- authbridge/cmd/abctl/tui/spend_drawer.go | 13 ++- .../abctl/tui/spend_tiers_reasoning_test.go | 109 +++++++++++++----- authbridge/cmd/abctl/tui/spend_tiers_test.go | 18 +-- 8 files changed, 220 insertions(+), 55 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic.go b/authbridge/authlib/plugins/inferenceparser/anthropic.go index b956377b4..e6b1d4fef 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic.go @@ -154,8 +154,9 @@ type anthropicUsage struct { // Reasoning comes from output_tokens_details.thinking_tokens. This parser used to // carry a comment asserting Anthropic does not expose reasoning; that was true // once and is not now, and the stale comment is why the field stayed unread long -// after the wire carried it. Verified against a live claude-opus-5 turn and -// documented under build-with-claude/thinking-steering-and-cost. +// after the wire carried it. 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, @@ -354,7 +355,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": @@ -398,30 +399,38 @@ 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 } - // Max-seen, for the reason the prompt side is: thinking_tokens rides - // only on message_delta, and a later frame that omits it (message_stop - // carries a details-free usage block) must not clear a real count. - // mergeAnthropicPromptMaxSeen already unioned the Present bit. - if neutral.Reasoning > state.usage.Reasoning { - state.usage.Reasoning = neutral.Reasoning - } state.hasUsage = true } } } -// 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, which is what the name change records. It merged only the +// prompt side while Present was unioned here for ALL kinds, so a value and its +// presence bit travelled on different paths: reasoning's bit was set here from +// either event, but its value was merged in the message_delta branch alone. A +// gateway putting output_tokens_details on message_start would therefore set +// KindReasoning with a value of 0, and `abctl cost` would print +// "reasoning (of output) 0" — the exact claim +// TestInferenceParser_AnthropicMessages_ThinkingTokensAbsent exists to forbid. +// +// 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 nothing can set a bit this function does not also fill. 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 } diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go index 77747e43f..07cf1d9de 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go @@ -644,3 +644,89 @@ func TestInferenceParser_AnthropicMessages_ThinkingTokensAbsent(t *testing.T) { 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) + } + // The bit must never be set with a zero value: that is the reported-zero lie. + if ext.PresentKinds&uint8(parsercommon.KindReasoning) != 0 && ext.ReasoningTokens == 0 { + t.Error("KindReasoning is set with a value of 0; presence and value diverged") + } +} diff --git a/authbridge/authlib/plugins/inferenceparser/splittokens_test.go b/authbridge/authlib/plugins/inferenceparser/splittokens_test.go index adae98489..968ee1713 100644 --- a/authbridge/authlib/plugins/inferenceparser/splittokens_test.go +++ b/authbridge/authlib/plugins/inferenceparser/splittokens_test.go @@ -71,7 +71,7 @@ func TestSplitTokens_AnthropicSSE_NonBeta(t *testing.T) { } // ?beta=true SSE: message_start carries only input_tokens; cache counts -// arrive on message_delta. Exercises mergeAnthropicPromptMaxSeen. +// arrive on message_delta. Exercises mergeAnthropicUsageMaxSeen. func TestSplitTokens_AnthropicSSE_Beta(t *testing.T) { ext := &pipeline.InferenceExtension{Model: "claude-opus-4-8"} body := []byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":9,\"output_tokens\":0}}}\n" + diff --git a/authbridge/authlib/pricing/incompletereason_test.go b/authbridge/authlib/pricing/incompletereason_test.go index b708cca7e..e1c164119 100644 --- a/authbridge/authlib/pricing/incompletereason_test.go +++ b/authbridge/authlib/pricing/incompletereason_test.go @@ -35,7 +35,7 @@ func TestIncompleteReason(t *testing.T) { // PresentKinds here is what a real truncated Anthropic stream carries — // Input|CacheRead|Output, with the Output BIT set and its TALLY zero, because // toNeutral asserts Input|Output unconditionally and - // mergeAnthropicPromptMaxSeen ORs the mask without ever assigning Output. The + // mergeAnthropicUsageMaxSeen ORs the mask without ever assigning Output. The // fixture keeps that bit set on purpose: it is what makes this case // indistinguishable from a reported zero by mask alone, and any future // "simplification" to the bitmask has to fail here. diff --git a/authbridge/authlib/pricing/inference.go b/authbridge/authlib/pricing/inference.go index b7ad750f5..cd81aa346 100644 --- a/authbridge/authlib/pricing/inference.go +++ b/authbridge/authlib/pricing/inference.go @@ -237,7 +237,7 @@ func outputUncounted(inf *pipeline.InferenceExtension) bool { // conclusion that it generalizes. It does not, and Anthropic is where the bug lives. // // The mechanism, on a truncated Anthropic stream: message_start's usage goes through - // mergeAnthropicPromptMaxSeen, which ORs Present and merges Input, CacheRead and + // mergeAnthropicUsageMaxSeen, which ORs Present and merges Input, CacheRead and // CacheWrite — but never assigns Output. Output is assigned only in the // message_delta arm. So the Output BIT arrives on the first frame while the Output // TALLY only ever arrives on the last, nothing records that they came from different diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index d7db3be48..f52a63439 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -728,7 +728,18 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // // The right column is shorter and its slots are filled by the `i < len(rows)` guard // below, so the two columns stay the same height by construction. - for i := 0; i < tierPanelLines; i++ { + // + // READ FROM THE SLICE when there is one, rather than trusting the constant to agree + // with it. tiers[i] below is indexed bare, and renderTierRows returning fewer rows + // than tierPanelLines would be an out-of-range panic in the middle of a render — + // a crashed TUI, from a contract held only by a test in another package. Deriving + // the bound makes the two impossible to disagree; the constant still governs the + // one-column path, where tiers is nil and the loop never indexes it. + bound := tierPanelLines + if twoCol { + bound = len(tiers) + } + for i := 0; i < bound; i++ { // NO BRANCH GLYPHS BETWEEN THE COLUMNS' OWN ROWS. "├" and "└" once prefixed every // row here and implied a parent none of them had; the column headers name the // grouping instead. The one "└" now in the panel is the reasoning row's, which does diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 70d58a971..31efb2c36 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -18,12 +18,15 @@ func reasoningCounts() usage.Counts { return c } -// indentedRows returns the child rows — the ones this panel uses to say "part of -// the row above" rather than "peer of it". -func indentedRows(lines []string) []string { +// childRows returns the reasoning child rows. +// +// Matched on childTierLabel for the reason tierRowsOnly is: "indented and mentions +// reasoning" is a description of today's output, while the label is the thing +// actually meant. Renamed from indentedRows to stop the predicate drifting back. +func childRows(lines []string) []string { var out []string for _, l := range lines { - if strings.HasPrefix(l, " ") && strings.Contains(l, "reasoning") { + if strings.HasPrefix(l, childTierLabel) { out = append(out, l) } } @@ -41,7 +44,7 @@ func TestRenderTierRows_ReasoningIsAChildOfOutput(t *testing.T) { switch { case strings.HasPrefix(strings.TrimSpace(l), "output"): outputAt = i - case strings.Contains(l, "reasoning"): + case strings.HasPrefix(l, childTierLabel): reasoningAt = i } } @@ -55,9 +58,12 @@ func TestRenderTierRows_ReasoningIsAChildOfOutput(t *testing.T) { t.Errorf("reasoning is at %d and output at %d; the child must directly follow its parent", reasoningAt, outputAt) } - if !strings.HasPrefix(lines[reasoningAt], " ") { - t.Errorf("reasoning row %q is not indented; flush with the tiers it reads as a peer", - lines[reasoningAt]) + // The label itself carries the indent, so matching it above is what proves the + // row is a child rather than a peer; this pins the indent has not been flattened + // out of childTierLabel while the tests kept passing. + if !strings.HasPrefix(childTierLabel, " ") { + t.Errorf("childTierLabel %q lost its indent; flush with the tiers it reads as a peer", + childTierLabel) } } @@ -70,7 +76,7 @@ func TestRenderTierRows_ChildIsExcludedFromTheHundredPercent(t *testing.T) { total, counted := 0, 0 for _, l := range lines { - if strings.HasPrefix(l, " ") { // the child + if strings.HasPrefix(l, childTierLabel) { // the child, not a tier continue } pct, ok := sharePercent(l) @@ -91,26 +97,75 @@ func TestRenderTierRows_ChildIsExcludedFromTheHundredPercent(t *testing.T) { // Containment, checked as arithmetic rather than left to the label: a child that // renders a bigger figure than its parent is the one way this layout can lie, and // it would look authoritative doing it. +// +// THE MALFORMED FIXTURE IS THE POINT. A well-formed one (948 of 1,593) cannot +// violate containment whatever the renderer does, so asserting it proves only that +// the arithmetic is not wildly broken — the clamps that actually protect the +// invariant never execute. Reasoning exceeding output should be impossible on the +// wire, which is exactly why a gateway that reports it that way would go unnoticed +// until the panel drew a child longer than its parent. func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { - lines := renderTierRows(reasoningCounts(), tierColumnWidth) + sane := reasoningCounts() - var outputPct, reasoningPct int - for _, l := range lines { - pct, ok := sharePercent(l) - if !ok { - continue - } - switch { - case strings.Contains(l, "reasoning"): - reasoningPct = pct - case strings.HasPrefix(strings.TrimSpace(l), "output"): - outputPct = pct - } + // Reasoning reported ABOVE output: the shape the clamps exist for. + inverted := reasoningCounts() + inverted.ReasoningTokens = 4_000 // > OutputTokens (1,593) + + // Reasoning equal to output: the boundary, where clamping must not overshoot + // into making the child smaller than it is. + equal := reasoningCounts() + equal.ReasoningTokens = equal.OutputTokens + + for _, tc := range []struct { + name string + c usage.Counts + }{ + {"well-formed", sane}, + {"reasoning reported above output", inverted}, + {"reasoning equal to output", equal}, + } { + t.Run(tc.name, func(t *testing.T) { + var outputPct, reasoningPct int + var outputRow, reasoningRow string + for _, l := range renderTierRows(tc.c, tierColumnWidth) { + pct, ok := sharePercent(l) + if !ok { + continue + } + switch { + case strings.HasPrefix(l, childTierLabel): + reasoningPct, reasoningRow = pct, l + case strings.HasPrefix(strings.TrimSpace(l), "output"): + outputPct, outputRow = pct, l + } + } + if reasoningPct > outputPct { + t.Errorf("reasoning is %d%% of the bill but output is only %d%%; a subset cannot "+ + "exceed its set\n %s\n %s", reasoningPct, outputPct, outputRow, reasoningRow) + } + // The money column must be clamped too, not just the share. + if reasoningRow == "" { + t.Fatal("no reasoning row rendered") + } + if drawnBarGlyphs(reasoningRow) > drawnBarGlyphs(outputRow) { + t.Errorf("the child's bar is longer than its parent's:\n %s\n %s", + outputRow, reasoningRow) + } + }) } - if reasoningPct > outputPct { - t.Errorf("reasoning is %d%% of the bill but output is only %d%%; a subset cannot exceed its set", - reasoningPct, outputPct) +} + +// drawnBarGlyphs counts the block glyphs in a rendered row, which is the bar's drawn +// length. Counted rather than measured off an index because the bar sits between +// two variable-width cells. +func drawnBarGlyphs(row string) int { + n := 0 + for _, r := range row { + if r >= '▏' && r <= '█' { + n++ + } } + return n } // An unreported split renders the NOT-KNOWN cell, not $0.00 and not a vanished row. @@ -122,7 +177,7 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { // either way — the same refusal renderTierRows makes for an absent tier. func TestRenderTierRows_UnreportedSplitIsNotKnownNotZero(t *testing.T) { lines := renderTierRows(tierCounts(), tierColumnWidth) - child := indentedRows(lines) + child := childRows(lines) if len(child) != 1 { t.Fatalf("want exactly one child row even when unreported, got %d", len(child)) } @@ -139,7 +194,7 @@ func TestRenderTierRows_UnreportedSplitIsNotKnownNotZero(t *testing.T) { func TestRenderTierRows_NoFigureWithoutOutputTokens(t *testing.T) { c := reasoningCounts() c.OutputTokens = 0 - child := indentedRows(renderTierRows(c, tierColumnWidth)) + child := childRows(renderTierRows(c, tierColumnWidth)) if len(child) != 1 { t.Fatalf("want one child row, got %d", len(child)) } diff --git a/authbridge/cmd/abctl/tui/spend_tiers_test.go b/authbridge/cmd/abctl/tui/spend_tiers_test.go index 1f3e7c229..651df3112 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_test.go @@ -31,10 +31,15 @@ func tierCounts() usage.Counts { // and its money is already inside output's. Every assertion below about "each tier // row" therefore has to be made against the tiers, and a test that iterated raw // lines would be asserting tier properties of something that is not one. +// +// MATCHED ON childTierLabel, not on "any leading space". A leading-space test says +// "indented" when the thing meant is "is the child", and the two come apart the +// moment a tier row gains an indent: the helper would silently drop real tiers and +// several assertions below would weaken without any of them failing. func tierRowsOnly(lines []string) []string { var out []string for _, l := range lines { - if strings.HasPrefix(l, " ") { + if strings.HasPrefix(l, childTierLabel) { continue } out = append(out, l) @@ -364,18 +369,17 @@ func TestRenderTierRows_ReasoningIsNotATier(t *testing.T) { t.Errorf("numTierRows = %d but there are %d rate tiers; reasoning became a tier", numTierRows, pricing.NumTiers) } - // The reasoning row must be indented — flush left it reads as a fifth tier. + // The reasoning row must use the INDENTED label — flush left it reads as a fifth + // tier. Asserted as "carries childTierLabel" rather than "starts with a space", + // so the check names the thing meant instead of a property of today's spelling. for _, l := range lines { - if strings.Contains(l, "reasoning") && !strings.HasPrefix(l, " ") { + if strings.Contains(l, "reasoning") && !strings.HasPrefix(l, childTierLabel) { t.Errorf("reasoning row is flush with the tiers, so it reads as a peer: %q", l) } } // And it must not be in the sum the tier rows own. total := 0 - for _, l := range lines { - if strings.HasPrefix(l, " ") { - continue - } + for _, l := range tierRowsOnly(lines) { if pct, ok := sharePercent(l); ok { total += pct } From 74dea027ec6cbaa891865aeec80f8b2658bcb152 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 19:04:34 -0400 Subject: [PATCH 05/24] =?UTF-8?q?fix:=20Review=20round=202=20=E2=80=94=20a?= =?UTF-8?q?=20dead=20assertion,=20a=20lost=20ceiling,=20a=20dead=20clamp?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MUST-FIX: drawnBarGlyphs counted nothing. `r >= '▏' && r <= '█'` reads as "thinnest through fullest" and is unsatisfiable: the block glyphs run BACKWARDS against visual width, '█' being U+2588 and '▏' U+258F. The counter returned 0 for every row, so `drawnBarGlyphs(child) > drawnBarGlyphs(parent)` was 0 > 0 and could not fail — while the previous commit message claimed it "checks the drawn bar". Verified: none of the eight glyphs satisfies the range. Now a set membership test, which cannot be ordered wrongly, plus a guard that fails the test outright if the counter counts nothing. A dead assertion is worse than an absent one because it reads as coverage, so that class of mistake now fails loudly. The live mutant this left: pct derives from the ALREADY-clamped micros and was then clamped a second time, so deleting only the MONEY clamp kept the share assertion green while the figure and bar rendered ~2.5x output. The previous round's mutation test removed both clamps together and so never saw it. A money-cell assertion closes it — and it, not the bar, is what catches it: with the clamp gone both bars saturate at full width and compare equal. Mutation, money clamp alone: "the child's figure $6.0100 exceeds its parent's $2.3900". Previously survived. THE SHARE CLAMP WAS DEAD CODE, found by mutating it alone — that mutant survived too. It is unreachable by construction: floor(micros*100/total) <= floor(tiers[output]*100/total) <= shares[output] the right step holding because tierShares only ever ADDS its rounding remainder to the largest share. Removed rather than given a fixture that cannot exist: unreachable code with an untestable branch implies a hazard that is not there and invites protecting the wrong invariant. The now-unused shares parameter goes with it. DERIVING THE LOOP BOUND HAD REMOVED THE CEILING (spend_drawer.go). `bound = len(tiers)` fixed the panic but left nothing capping the loop in two-column mode, so a renderTierRows returning MORE rows would emit more body rows than spendDrawerLines reserves and push the footer off the terminal — the failure the comment above it documents. min(tierPanelLines, len(tiers)) holds both ends. THE NEW INVARIANT SENTENCE WAS FALSE FOR OUTPUT (anthropic.go). "nothing can set a bit this function does not also fill" is refuted two paragraphs later by "Output is deliberately NOT here": toNeutral asserts KindOutput unconditionally. The exception is now named, because an absolute claim its own next paragraph contradicts is the shape of comment that finding #1's history shows is expensive here. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic.go | 11 +++- authbridge/cmd/abctl/tui/spend_drawer.go | 18 +++--- authbridge/cmd/abctl/tui/spend_tiers.go | 22 +++++--- .../abctl/tui/spend_tiers_reasoning_test.go | 55 +++++++++++++++++-- 4 files changed, 87 insertions(+), 19 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic.go b/authbridge/authlib/plugins/inferenceparser/anthropic.go index e6b1d4fef..992ed3c35 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic.go @@ -426,7 +426,16 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline 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. Kept beside the - // value merges below so nothing can set a bit this function does not also fill. + // 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 diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index f52a63439..085711c93 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -729,15 +729,19 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // The right column is shorter and its slots are filled by the `i < len(rows)` guard // below, so the two columns stay the same height by construction. // - // READ FROM THE SLICE when there is one, rather than trusting the constant to agree - // with it. tiers[i] below is indexed bare, and renderTierRows returning fewer rows - // than tierPanelLines would be an out-of-range panic in the middle of a render — - // a crashed TUI, from a contract held only by a test in another package. Deriving - // the bound makes the two impossible to disagree; the constant still governs the - // one-column path, where tiers is nil and the loop never indexes it. + // THE LOWER OF THE TWO, because the constant and the slice each guard a different + // failure and neither alone guards both. + // + // tiers[i] below is indexed bare, so renderTierRows returning FEWER rows than + // tierPanelLines is an out-of-range panic mid-render — a crashed TUI, from a + // contract held only by a test in another package. Reading the length alone fixes + // that but removes the ceiling: renderTierRows returning MORE rows (a second child) + // would emit more body rows than spendDrawerLines reserves and push the footer off + // the terminal, which is the failure the block above documents. min keeps both, and + // the one-column path takes tierPanelLines because tiers is nil and never indexed. bound := tierPanelLines if twoCol { - bound = len(tiers) + bound = min(tierPanelLines, len(tiers)) } for i := 0; i < bound; i++ { // NO BRANCH GLYPHS BETWEEN THE COLUMNS' OWN ROWS. "├" and "└" once prefixed every diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index 217f1317d..836a6d973 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -186,7 +186,7 @@ func renderTierRows(c usage.Counts, width int) []string { // split was reported the child renders the not-known cell, which is exactly what an // absent TIER does two branches above. return insertAfterOutput(out[:], order, - reasoningChildRow(c, tiers, ok, shares, peak, budget, width)) + reasoningChildRow(c, tiers, ok, peak, budget, width)) } // reasoningChildRow renders the reasoning row that hangs under output. @@ -209,7 +209,7 @@ func renderTierRows(c usage.Counts, width int) []string { // cannot state a percentage of one total beside a figure from another" — and the // containment reads from the indent anyway: 15% under 27% is visibly a part of it. func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, - shares [pricing.NumTiers]int, peak int64, budget, width int) string { + peak int64, budget, width int) string { notKnown := clipRow(fmt.Sprintf("%-*s %s", tierLabelWidth, childTierLabel, emptyCell), width) // The present bit decides, as everywhere else: a clear bit with a zero value means @@ -238,15 +238,23 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, // Floored against the same total the tier rows use, so the child is comparable down // the column. Deliberately NOT tierShares, which must keep summing to 100 across // exactly the four tiers. + // + // NO SECOND CLAMP HERE, and the omission is deliberate rather than an oversight. + // This share is derived from micros AFTER the clamp above, so it is already bounded + // by the parent's: + // + // floor(micros*100/total) <= floor(tiers[output]*100/total) <= shares[output] + // + // the right-hand step holding because tierShares only ever ADDS its rounding + // remainder to the largest share, never subtracts. A `pct > shares[output]` guard + // was written here first and was unreachable — no fixture could enter it, and + // mutation-testing confirmed removing it changed no output. Unreachable code with + // an untestable branch is worse than none: it implies a hazard that does not exist + // and invites a reader to protect the wrong invariant. pct := 0 if c.CostMicros > 0 { pct = int(micros * 100 / c.CostMicros) } - // The child can never out-rank its parent's share once clamped, but floor division - // can tie them; the indent still distinguishes the rows. - if pct > shares[pricing.TierOutput] { - pct = shares[pricing.TierOutput] - } label := childTierLabel var row string switch { diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 31efb2c36..5bf31e478 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -1,6 +1,7 @@ package tui import ( + "strconv" "strings" "testing" @@ -139,13 +140,33 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { outputPct, outputRow = pct, l } } + if reasoningRow == "" { + t.Fatal("no reasoning row rendered") + } if reasoningPct > outputPct { t.Errorf("reasoning is %d%% of the bill but output is only %d%%; a subset cannot "+ "exceed its set\n %s\n %s", reasoningPct, outputPct, outputRow, reasoningRow) } - // The money column must be clamped too, not just the share. - if reasoningRow == "" { - t.Fatal("no reasoning row rendered") + // THE MONEY CELL NEEDS ITS OWN ASSERTION, and it is the one that catches a + // money clamp deleted on its own. pct is derived from the ALREADY-clamped + // micros and then clamped a SECOND time against the output tier's share, so + // the share check above stays green when only the money clamp is removed — + // while the dollar figure and the bar both render several times output. + rMoney, rOK := rowMoney(reasoningRow) + oMoney, oOK := rowMoney(outputRow) + if rOK && oOK && rMoney > oMoney { + t.Errorf("the child's figure $%.4f exceeds its parent's $%.4f:\n %s\n %s", + rMoney, oMoney, outputRow, reasoningRow) + } + // THE COUNTER MUST COUNT, asserted before it is trusted. The first version + // of drawnBarGlyphs used an inverted rune range and returned 0 for every + // row, so the comparison below was 0 > 0 and could not fail while the + // commit message claimed it checked the bar. A dead assertion is worse than + // an absent one: it reads as coverage. This guard makes that class of + // mistake fail loudly instead of silently passing. + if drawnBarGlyphs(outputRow) == 0 { + t.Fatalf("drawnBarGlyphs counted no glyphs in %q; the bar assertion below "+ + "cannot fail", outputRow) } if drawnBarGlyphs(reasoningRow) > drawnBarGlyphs(outputRow) { t.Errorf("the child's bar is longer than its parent's:\n %s\n %s", @@ -155,19 +176,45 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { } } +// barGlyphs are the eight block glyphs tierBar draws with, U+2588 through U+258F. +// +// A SET, NOT A RANGE, and the reason is worth keeping: the glyphs run BACKWARDS +// against visual width. '█' (full) is U+2588, the LOWEST code point, and '▏' (one +// eighth) is U+258F, the highest. Written as `r >= '▏' && r <= '█'` — which reads +// correctly as "from thinnest to fullest" — it compiles, vets clean, and is +// unsatisfiable: the counter returned 0 for every row, so the assertion using it +// could not fail. A set cannot be ordered wrongly. +const barGlyphs = "█▉▊▋▌▍▎▏" + // drawnBarGlyphs counts the block glyphs in a rendered row, which is the bar's drawn // length. Counted rather than measured off an index because the bar sits between // two variable-width cells. func drawnBarGlyphs(row string) int { n := 0 for _, r := range row { - if r >= '▏' && r <= '█' { + if strings.ContainsRune(barGlyphs, r) { n++ } } return n } +// rowMoney reads the dollar figure a rendered row ends with. +// +// Returns false for the not-known cell, which carries no figure — a row without one +// is not a row whose figure is zero, which is the distinction this panel is built on. +func rowMoney(row string) (float64, bool) { + i := strings.LastIndex(row, "$") + if i < 0 { + return 0, false + } + v, err := strconv.ParseFloat(strings.TrimSpace(row[i+1:]), 64) + if err != nil { + return 0, false + } + return v, true +} + // An unreported split renders the NOT-KNOWN cell, not $0.00 and not a vanished row. // // The row must still be there: the panel's height is reserved from a constant that From d55075e29ff9f8835ab0c1afc5275e97d42329f2 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 19:48:54 -0400 Subject: [PATCH 06/24] =?UTF-8?q?fix:=20Review=20round=203=20=E2=80=94=20$?= =?UTF-8?q?0.00=20for=20a=20reported=20split,=20and=20the=20untested=20bug?= =?UTF-8?q?=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. A REPORTED SPLIT COULD RENDER $0.00 (spend_tiers.go). The apportionment multiply truncates, so a real reasoning count whose share of the window falls under one micro produced micros == 0 and printed "$0.00" — asserting the reasoning was FREE, the one claim renderTierRows refuses for a tier. Tiers escape that through `tiers[tier] == 0 -> emptyCell`; the child had no equivalent. Reproduced before fixing: 1 reasoning token of 900 output against 300 apportioned output micros gives 0.333, and the panel rendered " └ reasoning 0% $0.00" while every other row on the same panel showed "<$0.01". Reachable on any small window. Now returns the not-known cell. Deliberately not "<$0.01": that form means "too small to state", while what is true is that the apportionment resolved no figure at all. 2. THE HEADLINE BUG FIX HAD NO REGRESSION TEST (spend_drawer_test.go). The assembly loop being bounded by numTierRows — which DISPLACED the cheapest tier instead of adding the child, so `input` vanished from a panel still claiming to break down the whole bill — was found by rendering the panel and reading it, and fixed without pinning it. Only the line COUNT was asserted, and the count was right either way: five left rows. Three tests added: every tier label plus the child is emitted (both reported and unreported), the child directly follows output IN THE DRAWER rather than only in renderTierRows, and with a split reported the child carries a figure. No drawer fixture set ReasoningTokens/PresentKinds before, so the drawer had only ever been rendered with the not-known child; reasoningSnap fixes that. Mutation, restoring the numTierRows bound: 'the panel omits the "input" tier'. Previously green. 3. README SAMPLE WAS STALE (cmd/abctl/README.md). Hand-written and not staleness-checked, unlike the SVG: four tier rows where the child makes it always five, and no % column — the latter already stale before this PR. The sample is now copied from a real render. The stated two-column threshold was wrong before this PR (84, written as 72) and this PR moves it to 85, since tierLabelWidth grew for " └ reasoning". Corrected, and the child's semantics are described. 4. THE PROPOSAL RECORDED THE DECISION THIS PR REVERSES (docs/proposals/cost-tier-breakdown.md). §3.5 said reasoning stays "out of the bars" and criterion 5 said it "never appears as a bar and never joins the sum". The sum half holds and is still enforced; the bar half is superseded. Both are now marked, with what changed and what did not — a proposal is a decision record, so superseding one belongs in it rather than only in a PR body. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/README.md | 23 +++-- authbridge/cmd/abctl/tui/spend_drawer_test.go | 90 +++++++++++++++++++ authbridge/cmd/abctl/tui/spend_tiers.go | 13 +++ .../abctl/tui/spend_tiers_reasoning_test.go | 33 +++++++ docs/proposals/cost-tier-breakdown.md | 22 ++++- 5 files changed, 173 insertions(+), 8 deletions(-) diff --git a/authbridge/cmd/abctl/README.md b/authbridge/cmd/abctl/README.md index 892acb96f..d442010b7 100644 --- a/authbridge/cmd/abctl/README.md +++ b/authbridge/cmd/abctl/README.md @@ -501,12 +501,13 @@ abctl is for, and the other three are surfaces you visit and leave. and who spent it, by model, endpoint or agent: ``` - WHERE IT WENT BY MODEL - output ██████ $2.70 claude-opus-5 $4.55 35 req 5.6M tokens - cache-read ███▌ $1.62 - input ▍ $0.22 - cache-write — - [a] [model] · endpoint · agent [w] 1h esc closes + WHERE IT WENT BY MODEL + output 54% ████████████ $5.85 claude-opus-5 $11.12 17 req + └ reasoning 31% ███████▏ $3.48 claude-sonnet-5 <$0.01 2 req + cache-read 35% ███████▉ $3.90 claude-haiku-4-5 <$0.01 120 req + cache-write 8% █▉ $0.98 (other) <$0.01+ 9 req + input 3% ▊ $0.39 + [a] [model] · endpoint · agent [w] 1h esc closes ``` The tier figures are **modelled, not measured**: the split comes from the rate @@ -514,9 +515,17 @@ abctl is for, and the other three are surfaces you visit and leave. because a gateway reports one number per call and never breaks it down. They are apportioned so the column sums to the window total exactly, and a tier the rate table says nothing about shows `—` rather than `$0.00`, which would claim - the tier was free. Below 72 columns the tier column drops and the panel + the tier was free. Below 85 columns the tier column drops and the panel degrades to the by-model breakdown alone. + `└ reasoning` is a **child of output, not a fifth tier**. Reasoning has no rate + of its own — it is the share of the generated tokens the model spent thinking, + billed at the output rate — so its figure is already inside output's, and only + the four unindented rows sum to the window total. Its share is denominated in + that same total, which is what makes `31% ⊂ 54%` read as containment. The row is + always present and shows `—` when the provider reports no split, as every + non-Anthropic endpoint does. + These rows carry **no** `~`, unlike the sessions table's `SAVED~`. The caveat is real but this panel has no money heading to hang it on — `WHERE IT WENT` names the column, not the figures — so the choice was a tilde on every row or the diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index 14a70433b..954ff9527 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -1825,3 +1825,93 @@ func TestPaneView_DrawsTheDrawersStoredError(t *testing.T) { "no reader is the defect renderSpendDrawer's error path exists to end:\n%s", out) } } + +// reasoningSnap is tierSnap with a reported reasoning split, so the drawer is +// exercised with a real child FIGURE rather than only the not-known cell. No other +// drawer fixture sets one, which is why the populated child was never rendered here. +func reasoningSnap() *usage.Snapshot { + s := tierSnap() + s.Totals.OutputTokens = 1593 + s.Totals.ReasoningTokens = 948 + s.Totals.PresentKinds = uint8(usage.KindOutput | usage.KindReasoning) + return s +} + +// TestRenderSpendDrawer_EmitsEveryTierPlusTheChild is the regression test for the +// bug this feature shipped and nothing caught: the assembly loop was bounded by +// numTierRows while the tier column had grown to tierPanelLines, so inserting the +// child DISPLACED a row instead of adding one. The ranking is by cost descending, so +// what fell off was the cheapest tier — `input` simply vanished from a panel still +// claiming to break down the whole bill. +// +// It was found by rendering the panel and reading it, not by a test. Only the line +// COUNT was pinned, and the count was still right: five left rows either way. Pinning +// the labels is what makes the next such regression fail here. +func TestRenderSpendDrawer_EmitsEveryTierPlusTheChild(t *testing.T) { + for _, tc := range []struct { + name string + snap *usage.Snapshot + }{ + {"reasoning reported", reasoningSnap()}, + {"reasoning unreported", tierSnap()}, + } { + t.Run(tc.name, func(t *testing.T) { + joined := strings.Join(renderSpendDrawer(tc.snap, nil, usage.GroupModel, "1h", 100), "\n") + // Every rate tier, by name. "input" last in the cost ranking is the one the + // old bound dropped. + for _, label := range []string{"input", "cache-write", "cache-read", "output"} { + if !strings.Contains(joined, label) { + t.Errorf("the panel omits the %q tier:\n%s", label, joined) + } + } + // And the child, whatever it renders. + if !strings.Contains(joined, "reasoning") { + t.Errorf("the panel omits the reasoning child:\n%s", joined) + } + }) + } +} + +// The child sits directly under output IN THE DRAWER, not just in renderTierRows. +// A regression that emitted five left rows but inserted the child at the wrong index +// would pass both the line count and the label check above. +func TestRenderSpendDrawer_ChildFollowsOutput(t *testing.T) { + lines := renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", 100) + outputAt, childAt := -1, -1 + for i, l := range lines { + switch { + case strings.Contains(l, "reasoning"): + childAt = i + case strings.Contains(l, "output"): + outputAt = i + } + } + if outputAt < 0 || childAt < 0 { + t.Fatalf("output at %d, child at %d; both must render:\n%s", + outputAt, childAt, strings.Join(lines, "\n")) + } + if childAt != outputAt+1 { + t.Errorf("child is at line %d and output at %d; the child must directly follow "+ + "its parent:\n%s", childAt, outputAt, strings.Join(lines, "\n")) + } +} + +// With a split reported, the drawer must show the child's FIGURE — the populated +// path, which no other drawer fixture reaches. +func TestRenderSpendDrawer_ChildCarriesItsFigure(t *testing.T) { + var child string + for _, l := range renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", 100) { + if strings.Contains(l, "reasoning") { + child = l + } + } + if child == "" { + t.Fatal("no reasoning row") + } + if strings.Contains(child, emptyCell) { + t.Errorf("child row = %q shows the not-known cell despite a reported split", child) + } + if !strings.Contains(child, "$") { + t.Errorf("child row = %q carries no figure", child) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index 836a6d973..160ee0ed3 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -235,6 +235,19 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, if micros > tiers[pricing.TierOutput] { micros = tiers[pricing.TierOutput] } + // APPORTIONED TO NOTHING IS NOT APPORTIONED TO ZERO, and this is the child's + // version of the `tiers[tier] == 0` escape the tier rows take. The multiply above + // truncates, so a real reasoning count whose share of the window falls below one + // micro lands here — reachable on a small window, around a hundred output tokens at + // opus-5 rates. Rendering it would print "$0.00", which asserts the reasoning was + // FREE: the one claim renderTierRows refuses for a tier, arriving through the child. + // + // The not-known cell instead. The tier rows cannot say "<$0.01" here either — that + // form means "too small to state", and what is true is that the apportionment could + // not resolve a figure at all. + if micros == 0 { + return notKnown + } // Floored against the same total the tier rows use, so the child is comparable down // the column. Deliberately NOT tierShares, which must keep summing to 100 across // exactly the four tiers. diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 5bf31e478..93ffe0b0e 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -236,6 +236,39 @@ func TestRenderTierRows_UnreportedSplitIsNotKnownNotZero(t *testing.T) { } } +// A REPORTED SPLIT TOO SMALL TO APPORTION MUST NOT RENDER $0.00. +// +// The apportionment multiply truncates, so a real reasoning count whose share of the +// window falls below one micro yields micros == 0 — reachable on a small window, +// around a hundred output tokens at opus-5 rates. Printing that as "$0.00" asserts +// the reasoning was FREE, which is the claim renderTierRows refuses for a tier +// (tiers[tier] == 0 takes the not-known cell); the child needs the same escape. +// +// Not "<$0.01" either: that form means "too small to state", while what is true here +// is that the apportionment resolved no figure at all. +func TestRenderTierRows_TinyShareIsNotKnownNotFree(t *testing.T) { + // 1 reasoning token of 900 output, against 300 apportioned output micros: + // 300 * 1/900 = 0.333, which truncates to zero. + c := usage.Counts{ + Requests: 3, CostMicros: 4_000, + InputCostMicros: 900, CacheReadCostMicros: 2_800, OutputCostMicros: 300, + OutputTokens: 900, ReasoningTokens: 1, + PresentKinds: uint8(usage.KindInput | usage.KindCacheRead | usage.KindOutput | usage.KindReasoning), + } + child := childRows(renderTierRows(c, tierColumnWidth)) + if len(child) != 1 { + t.Fatalf("want one child row, got %d", len(child)) + } + if strings.Contains(child[0], "$0.00") { + t.Errorf("child row = %q renders $0.00 for a REPORTED split; that asserts the "+ + "reasoning was free", child[0]) + } + if !strings.Contains(child[0], emptyCell) { + t.Errorf("child row = %q, want the not-known cell when the share apportions to "+ + "nothing", child[0]) + } +} + // Reasoning reported but nothing generated: no denominator, so no defensible figure. // The row stays (height is constant) and says it does not know. func TestRenderTierRows_NoFigureWithoutOutputTokens(t *testing.T) { diff --git a/docs/proposals/cost-tier-breakdown.md b/docs/proposals/cost-tier-breakdown.md index 90aeb414a..09dfcce57 100644 --- a/docs/proposals/cost-tier-breakdown.md +++ b/docs/proposals/cost-tier-breakdown.md @@ -105,6 +105,22 @@ evidence available, `~` says it is inexact, and a reader who wants coverage has already labels it `reasoning (of output)` — so including it as a fifth bar would double-count. It stays in `abctl cost`'s token line and out of the bars. +> **Superseded in part.** `reasoning` is now drawn in this panel, as an *indented +> child of `output`* carrying its own bar and figure. +> +> The reason behind 3.5 is unchanged and still enforced: it is not a tier, it is +> excluded from the shares that sum to 100, `numTierRows` stays pinned to +> `pricing.NumTiers`, and `ApportionTiers` still returns exactly four figures summing +> to `CostMicros`. What changed is the inference that "not a tier" required "not +> drawn". This panel was the only cost surface that could not answer what an effort +> setting costs, and the containment is carried by the indent and by exclusion from +> the sum rather than by absence. +> +> The child's money is apportioned from `output`'s *displayed* figure and clamped to +> it, so it always divides into the row directly above. It renders the not-known cell +> when no split was reported, and is always present so the panel's height does not +> follow its data (6 below). See `childTierLabel` and `reasoningChildRow`. + **3.6 No residual twins for the new fields.** `CostMicros` has `UngroupedCostMicros` and `SeriesOvershootMicros` because the authoritative total must reconcile across grouping. A modelled mix is an apportionment key; an @@ -269,7 +285,11 @@ implementation: 2. A window priced entirely from gateway headers, with `Σ modelledTier == 0`, renders `emptyCell` and does not divide by zero (3.4). 3. A mix covering a small fraction of the priced spend still apportions, and wears `~` — the positive control for having removed the threshold, since a reintroduced floor would blank this case. 4. Display order is by amount, not by `pricing.Tier` declaration order (3.7). The fixture must order the two differently, or the test passes under either implementation. -5. `reasoning` never appears as a bar and never joins the sum (3.5). +5. `reasoning` never joins the sum (3.5). ~~never appears as a bar~~ — superseded: it + is drawn as an indented child of `output`, so the criterion is now that the four + unindented rows still sum to 100, that the child's figure and bar never exceed + `output`'s, and that a reported split too small to apportion renders the not-known + cell rather than `$0.00`. 6. The panel's line count is identical across every coverage state, which is what keeps the reservation honest. 7. At a width too narrow for two columns, the output equals today's drawer. 8. Saturation: a tier at `MaxInt64` sets `Saturated` and does not wrap — the failure already found once in `rankSeriesByCost`, whose raw `+=` ranked an overflowing series below a ten-micro one. From 06160013c1693b0b45594b90e193741e44884abb Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 20:12:05 -0400 Subject: [PATCH 07/24] =?UTF-8?q?fix:=20Review=20round=204=20=E2=80=94=20t?= =?UTF-8?q?hree=20more=20assertions=20that=20could=20not=20fail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eight suggestions. Five are my own dead or misdescribed assertions, which is the third round running that this class has come back, so each replacement is mutation-checked rather than reasoned about. 5. THREE TAUTOLOGIES REMOVED. `numTierRows != pricing.NumTiers` and `tierPanelLines != numTierRows+1` each restate a const's own definition, and `spendDrawerLines < tierPanelLines` cannot fail because spendDrawerLines is max(tierPanelLines, ...)+2. All three compared a constant to itself. Replaced with properties of the RENDER that can fail: the summing rows still number exactly pricing.NumTiers, and the drawer's emitted line count does not exceed its reservation. 6. THE MONEY ASSERTION COULD SKIP ITSELF. `if rOK && oOK && ...` filtered on the figures being parseable, so the moment the child rendered not-known the comparison silently vanished. The bar had an explicit liveness guard and the money did not; now both fail loudly instead. 7. THE "equal to output" SUBTEST NAMED A DIRECTION IT COULD NOT TEST. Its comment claimed clamping "must not overshoot into making the child smaller", while every comparison in the loop is `>`. That direction now has its own assertion, in the only case that can witness it: all output was reasoning, so the child must render its parent's figure exactly. 8. A STALE RATIONALE, the same defect the parser comment was rewritten to fix, two files away. The money assertion was justified by a share "clamped a SECOND time" — a clamp removed as unreachable in the previous round. The real reason is floor division collapsing a range of micros onto one percentage. 9. THE PRESENT BIT WAS NOT ACTUALLY PINNED. tokenSplit gates on `bit == 0 && v == 0`, and the existing test left both zero — so a renderer gated only on the value passed identically. Two cases where the halves disagree: a REPORTED zero (bit set, value 0) must render, and a legacy producer (bit clear, value non-zero) must too. Mutation, gating on the value alone: 'tokenSplit = "output 1.6k", want a reasoning line for a REPORTED zero'. Previously green. 10. `!ok || bit == 0 && value == 0` now parenthesised, and the comment no longer opens "the present bit decides" when the value decides with it. 11. THE SUBSET INVARIANT IS DISPLAY-ONLY, and that is now written down rather than left as an accident. A provider reporting reasoning > output is stored as reported: `abctl cost` and the detail pane print the contradiction, which is the only way a reader notices the provider's bug, and clamping at ingest would make every surface agree on a number nobody measured. The drawer clamps because a bar longer than its parent is a containment claim the LAYOUT makes rather than relays. Numbers stay faithful; geometry does not lie. Recorded at the clamp and on usage.Counts.ReasoningTokens. 12. THE DEMO STORYBOARD NOW REPORTS A SPLIT, so the flagship asset shows "└ reasoning 17% ████▉ $0.11" instead of a permanent "—". The previous deferral blamed "editing fixtures inside an asset regeneration"; the real obstacle was that the loader had no reasoning field, which is three lines — a yaml field, one assignment, and reasoning deliberately NOT added to TotalTokens. The stated reason was weaker than the actual one, so the work was worth doing rather than tracking. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/usage/usage.go | 12 +++++ authbridge/cmd/abctl/cost_token_split_test.go | 31 +++++++++++ authbridge/cmd/abctl/tui/spend_tiers.go | 27 ++++++++-- .../abctl/tui/spend_tiers_reasoning_test.go | 53 ++++++++++++++----- authbridge/cmd/abctl/tui/spend_tiers_test.go | 11 ++-- authbridge/scripts/readme-demo/demo.yaml | 12 ++--- authbridge/scripts/readme-demo/tuicapture.go | 24 +++++---- docs/assets/cortex-demo.svg | 24 ++++----- 8 files changed, 146 insertions(+), 48 deletions(-) diff --git a/authbridge/authlib/usage/usage.go b/authbridge/authlib/usage/usage.go index 41e074687..d48b1a77e 100644 --- a/authbridge/authlib/usage/usage.go +++ b/authbridge/authlib/usage/usage.go @@ -84,6 +84,18 @@ type Counts struct { // provider reports how much of what it generated was reasoning. Adding the two // double-counts every reasoning token at the output rate, which is the most // expensive tier there is. + // + // THE SUBSET RELATION IS NOT ENFORCED HERE, which is a decision rather than an + // omission. plausibleTokenReport screens for negatives and an implausible ceiling + // but not for ReasoningTokens > OutputTokens, so a provider reporting a + // contradictory pair is stored as it reported it — and `abctl cost`'s token line + // and the detail pane both print it, which is the only way a reader notices the + // provider bug. Clamping at ingest would make every surface agree on a number + // nobody measured. + // + // The spend drawer DOES clamp it, because a bar drawn longer than its parent's is + // a containment claim the layout makes rather than one it relays — see + // reasoningChildRow. Numbers stay faithful; geometry is not allowed to lie. ReasoningTokens int64 `json:"reasoningTokens,omitempty"` // RefusedTokenRequests counts the requests whose token report was REJECTED as // implausible and contributed nothing to any figure above. See plausibleTokenReport for diff --git a/authbridge/cmd/abctl/cost_token_split_test.go b/authbridge/cmd/abctl/cost_token_split_test.go index b88d7a547..024bc48bf 100644 --- a/authbridge/cmd/abctl/cost_token_split_test.go +++ b/authbridge/cmd/abctl/cost_token_split_test.go @@ -47,3 +47,34 @@ func TestTokenSplit_OmitsReasoningWhenUnreported(t *testing.T) { t.Errorf("tokenSplit = %q, want the output line", got) } } + +// TestTokenSplit_BitAndValueAreBothConsulted pins which of the two decides, because +// the case above cannot: it leaves the bit clear AND the value zero, so a renderer +// gated only on the value behaves identically and the rule it claims to pin is not +// pinned. `add` gates on `bit == 0 && v == 0`, so each half needs a case where the +// two disagree. +func TestTokenSplit_BitAndValueAreBothConsulted(t *testing.T) { + // REPORTED ZERO: bit set, value 0. Must render — the provider measured the split + // and it was nothing, which is the observation that says effort reached the model + // and bought no reasoning. A renderer gated on the VALUE alone would drop this. + reportedZero := tokenSplit(usage.Counts{ + OutputTokens: 1593, + ReasoningTokens: 0, + PresentKinds: uint8(usage.KindOutput | usage.KindReasoning), + }) + if !strings.Contains(reportedZero, "reasoning") { + t.Errorf("tokenSplit = %q, want a reasoning line for a REPORTED zero", reportedZero) + } + + // LEGACY PRODUCER: bit clear, value non-zero. Must also render — the value is the + // only evidence available on an event predating PresentKinds, and dropping it would + // hide a real number. A renderer gated on the BIT alone would drop this. + legacy := tokenSplit(usage.Counts{ + OutputTokens: 1593, + ReasoningTokens: 948, + PresentKinds: uint8(usage.KindOutput), + }) + if !strings.Contains(legacy, "948") { + t.Errorf("tokenSplit = %q, want the 948 from a producer predating PresentKinds", legacy) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index 160ee0ed3..bfa9cd735 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -212,11 +212,16 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, peak int64, budget, width int) string { notKnown := clipRow(fmt.Sprintf("%-*s %s", tierLabelWidth, childTierLabel, emptyCell), width) - // The present bit decides, as everywhere else: a clear bit with a zero value means - // nothing reported a split, which is not the same as a split of zero. A provider - // that exposes no reasoning counter gets the not-known cell, never $0.00 — the same + // THE BIT AND THE VALUE TOGETHER, not the bit alone — the parenthesisation says so + // rather than leaving it to Go's precedence. A clear bit with a zero value means + // nothing reported a split, which is not the same as a split of zero: a provider + // exposing no reasoning counter gets the not-known cell, never $0.00, the same // refusal renderTierRows makes for a tier absent from the mix. - if !ok || c.PresentKinds&usage.KindReasoning == 0 && c.ReasoningTokens == 0 { + // + // A non-zero value with a CLEAR bit still renders, which is why the value is in the + // condition at all: that is an event from a producer predating PresentKinds, where + // the value is the only evidence there is. Same rule as tokenSplit's `add`. + if !ok || (c.PresentKinds&usage.KindReasoning == 0 && c.ReasoningTokens == 0) { return notKnown } // No denominator, no defensible figure. Reasoning cannot be a share of an output @@ -232,6 +237,20 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, // gateway that reports them inconsistently would otherwise draw a child longer than // the bar above it — a lie that looks authoritative. Clamp rather than refuse: the // figure is still the best available, and the parent bounds it. + // + // DISPLAY-ONLY, AND DELIBERATELY SO. The subset relation is not enforced at ingest: + // parsercommon leaves the counts as reported, plausibleTokenReport screens only for + // negatives and an implausible ceiling, and `abctl cost`'s token line and the detail + // pane both print reasoning against output exactly as the provider stated them — + // including a contradictory pair. That is on purpose. A count is a measurement + // somebody else made, and silently correcting it here would hide the provider bug + // from the two surfaces where a reader could notice it. + // + // What cannot be left alone is GEOMETRY. A bar's length and a row's indent are + // claims this layout makes itself, not ones it relays: drawing a child longer than + // its parent asserts containment is false, which is a lie the display invented. So + // the numbers stay faithful and the picture stays consistent, and the clamp lives at + // the only layer that draws. if micros > tiers[pricing.TierOutput] { micros = tiers[pricing.TierOutput] } diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 93ffe0b0e..5070cd722 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -112,8 +112,10 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { inverted := reasoningCounts() inverted.ReasoningTokens = 4_000 // > OutputTokens (1,593) - // Reasoning equal to output: the boundary, where clamping must not overshoot - // into making the child smaller than it is. + // Reasoning equal to output: the boundary. Every comparison in the loop below is + // `>`, so "the clamp must not overshoot and make the child SMALLER" needs its own + // check — asserted at the end of the subtest rather than named here and left + // untested. equal := reasoningCounts() equal.ReasoningTokens = equal.OutputTokens @@ -147,14 +149,26 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { t.Errorf("reasoning is %d%% of the bill but output is only %d%%; a subset cannot "+ "exceed its set\n %s\n %s", reasoningPct, outputPct, outputRow, reasoningRow) } - // THE MONEY CELL NEEDS ITS OWN ASSERTION, and it is the one that catches a - // money clamp deleted on its own. pct is derived from the ALREADY-clamped - // micros and then clamped a SECOND time against the output tier's share, so - // the share check above stays green when only the money clamp is removed — - // while the dollar figure and the bar both render several times output. + // THE MONEY CELL NEEDS ITS OWN ASSERTION, because the share cannot stand in + // for it. pct is DERIVED from micros, so a share that looks sane does not + // witness a sane figure: floor(micros*100/total) collapses a range of micros + // onto the same percentage, and on this fixture an unclamped child rendered + // ~2.5x output while the share check stayed green. + // + // (An earlier version of this comment blamed a second clamp on the share. + // That clamp was removed as unreachable — see reasoningChildRow — so the + // reason is the floor division, not a second guard.) + // + // rOK/oOK are asserted rather than used as a filter: a `&&` over them would + // let this assertion skip itself the moment the child renders not-known, + // which is exactly how a guard goes quiet without failing. rMoney, rOK := rowMoney(reasoningRow) oMoney, oOK := rowMoney(outputRow) - if rOK && oOK && rMoney > oMoney { + if !rOK || !oOK { + t.Fatalf("no figure to compare (child ok=%v, parent ok=%v); this assertion "+ + "cannot fail:\n %s\n %s", rOK, oOK, outputRow, reasoningRow) + } + if rMoney > oMoney { t.Errorf("the child's figure $%.4f exceeds its parent's $%.4f:\n %s\n %s", rMoney, oMoney, outputRow, reasoningRow) } @@ -172,6 +186,14 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { t.Errorf("the child's bar is longer than its parent's:\n %s\n %s", outputRow, reasoningRow) } + // THE OTHER DIRECTION, which only the equal case can witness: all of the + // output was reasoning, so the child must render its parent's figure and not + // a clamped-down one. Without this the clamp could subtract and every `>` + // above would still pass. + if tc.name == "reasoning equal to output" && rMoney != oMoney { + t.Errorf("all output was reasoning, so the child should equal its parent, "+ + "got $%.4f against $%.4f:\n %s\n %s", rMoney, oMoney, outputRow, reasoningRow) + } }) } } @@ -301,13 +323,18 @@ func TestRenderTierRows_HeightConstantAcrossReasoningStates(t *testing.T) { // it — otherwise the child row pushes the footer off the terminal, the defect // keys.go records for spendDrawerLines. func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { + // THE ONLY LIVE COMPARISON HERE: a constant against what the renderer actually + // produces. `tierPanelLines != numTierRows+1` and `spendDrawerLines < tierPanelLines` + // were also asserted and both were tautologies — the first restates the const + // definition, and spendDrawerLines is max(tierPanelLines, ...)+2 so the second cannot + // fail. Comparing a constant to its own definition reads as coverage and is none. if want := len(renderTierRows(reasoningCounts(), tierColumnWidth)); tierPanelLines < want { t.Errorf("tierPanelLines = %d but the panel renders %d lines", tierPanelLines, want) } - if tierPanelLines != numTierRows+1 { - t.Errorf("tierPanelLines = %d, want numTierRows+1 = %d", tierPanelLines, numTierRows+1) - } - if spendDrawerLines < tierPanelLines { - t.Errorf("spendDrawerLines = %d cannot hold a %d-line tier panel", spendDrawerLines, tierPanelLines) + // The drawer must actually emit them, which is a property of renderSpendDrawer + // rather than of the constants. Asserted against a real render. + if got := len(renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", 100)); got > spendDrawerLines { + t.Errorf("the drawer emitted %d lines but reserves %d; the footer will be pushed off", + got, spendDrawerLines) } } diff --git a/authbridge/cmd/abctl/tui/spend_tiers_test.go b/authbridge/cmd/abctl/tui/spend_tiers_test.go index 651df3112..50e0f2398 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_test.go @@ -364,10 +364,13 @@ func TestRenderTierRows_ReasoningIsNotATier(t *testing.T) { c.PresentKinds = uint8(usage.KindOutput | usage.KindReasoning) lines := renderTierRows(c, 60) - // numTierRows counts RATES and must not have grown. - if numTierRows != pricing.NumTiers { - t.Errorf("numTierRows = %d but there are %d rate tiers; reasoning became a tier", - numTierRows, pricing.NumTiers) + // numTierRows counts RATES, and `numTierRows != pricing.NumTiers` was asserted here + // — a tautology, since that is the const's definition. What is worth pinning is that + // the SUMMING rows are still exactly the rate tiers, which is a property of the + // render and can fail. + if got := len(tierRowsOnly(lines)); got != pricing.NumTiers { + t.Errorf("%d summing rows against %d rate tiers; reasoning became a tier", + got, pricing.NumTiers) } // The reasoning row must use the INDENTED label — flush left it reads as a fifth // tier. Asserted as "carries childTierLabel" rather than "starts with a space", diff --git a/authbridge/scripts/readme-demo/demo.yaml b/authbridge/scripts/readme-demo/demo.yaml index 4adbb764b..d56546bdc 100644 --- a/authbridge/scripts/readme-demo/demo.yaml +++ b/authbridge/scripts/readme-demo/demo.yaml @@ -116,17 +116,17 @@ acts: title: "fix the retry handler" model: claude-opus-5 turns: - - {messages: 4, tools: 15, input: 1800, cache_read: 46000, cache_write: 9000, output: 620, prompt_usd: 0.098, output_usd: 0.047, tool_call: "tools/call", tool_host: "github-tool-mcp"} - - {messages: 10, tools: 15, input: 900, cache_read: 93000, cache_write: 2400, output: 810, prompt_usd: 0.121, output_usd: 0.061} - - {messages: 16, tools: 15, input: 1100, cache_read: 141000, cache_write: 3100, output: 940, prompt_usd: 0.174, output_usd: 0.071} + - {messages: 4, tools: 15, input: 1800, cache_read: 46000, cache_write: 9000, output: 620, reasoning: 341, prompt_usd: 0.098, output_usd: 0.047, tool_call: "tools/call", tool_host: "github-tool-mcp"} + - {messages: 10, tools: 15, input: 900, cache_read: 93000, cache_write: 2400, output: 810, reasoning: 446, prompt_usd: 0.121, output_usd: 0.061} + - {messages: 16, tools: 15, input: 1100, cache_read: 141000, cache_write: 3100, output: 940, reasoning: 517, prompt_usd: 0.174, output_usd: 0.071} - id: web-2a91 title: "add dark mode toggle" model: claude-opus-5 turns: - - {messages: 4, tools: 15, input: 1200, cache_read: 21000, cache_write: 6400, output: 380, prompt_usd: 0.061, output_usd: 0.029} - - {messages: 8, tools: 15, input: 700, cache_read: 44000, cache_write: 1800, output: 520, prompt_usd: 0.074, output_usd: 0.039} + - {messages: 4, tools: 15, input: 1200, cache_read: 21000, cache_write: 6400, output: 380, reasoning: 209, prompt_usd: 0.061, output_usd: 0.029} + - {messages: 8, tools: 15, input: 700, cache_read: 44000, cache_write: 1800, output: 520, reasoning: 286, prompt_usd: 0.074, output_usd: 0.039} - id: infra-55de title: "debug the helm chart" model: claude-sonnet-5 turns: - - {messages: 4, tools: 15, input: 800, cache_read: 12000, cache_write: 3200, output: 240, prompt_usd: 0.012, output_usd: 0.004} + - {messages: 4, tools: 15, input: 800, cache_read: 12000, cache_write: 3200, output: 240, reasoning: 132, prompt_usd: 0.012, output_usd: 0.004} diff --git a/authbridge/scripts/readme-demo/tuicapture.go b/authbridge/scripts/readme-demo/tuicapture.go index 5a4134436..0ba423d53 100644 --- a/authbridge/scripts/readme-demo/tuicapture.go +++ b/authbridge/scripts/readme-demo/tuicapture.go @@ -40,14 +40,18 @@ import ( // Turn is one request/response exchange with a model. type Turn struct { - Messages int `yaml:"messages"` - Tools int `yaml:"tools"` - Input int `yaml:"input"` - CacheRead int `yaml:"cache_read"` - CacheWrite int `yaml:"cache_write"` - Output int `yaml:"output"` - PromptUSD float64 `yaml:"prompt_usd"` - OutputUSD float64 `yaml:"output_usd"` + Messages int `yaml:"messages"` + Tools int `yaml:"tools"` + Input int `yaml:"input"` + CacheRead int `yaml:"cache_read"` + CacheWrite int `yaml:"cache_write"` + Output int `yaml:"output"` + // Reasoning is the share of Output the model spent thinking, so the `$` + // breakdown's reasoning child renders a real figure rather than the + // not-known cell. A SUBSET of Output, never added to it. + Reasoning int `yaml:"reasoning"` + PromptUSD float64 `yaml:"prompt_usd"` + OutputUSD float64 `yaml:"output_usd"` // ToolCall, when set, adds an outbound MCP tool call after the model // response, so the events timeline shows an agent doing something and not // only talking to a model. @@ -280,7 +284,9 @@ func (c *Capturer) build(f Fixture) []pendingEvent { CacheReadTokens: t.CacheRead, CacheWriteTokens: t.CacheWrite, OutputTokens: t.Output, - TotalTokens: t.Input + t.CacheRead + t.CacheWrite + t.Output, + ReasoningTokens: t.Reasoning, + // Reasoning is NOT added: it is already inside Output. + TotalTokens: t.Input + t.CacheRead + t.CacheWrite + t.Output, }, Plugins: costPlugins(t), Invocations: &pipeline.Invocations{Outbound: []pipeline.Invocation{ diff --git a/docs/assets/cortex-demo.svg b/docs/assets/cortex-demo.svg index ec910f38f..adb6e96d0 100644 --- a/docs/assets/cortex-demo.svg +++ b/docs/assets/cortex-demo.svg @@ -242,7 +242,7 @@ clipPath rect{width:100000px} WHERE IT WENT BY MODEL cache-read 43% ████████████ $0.27 claude-opus-5 $0.63 4 req 319k tokens output 31% █████████ $0.20 claude-sonnet-5 $0.02 1 req 16k tokens - └ reasoning — + └ reasoning 17% ████▉ $0.11 cache-write 21% ██████ $0.14 input 5% █▍ $0.03 [a] [model] · endpoint · agent [w] LAST 1H esc closes @@ -280,19 +280,19 @@ clipPath rect{width:100000px} USAGE — all sessions — 10m0s @ 1m0s — tokens — ungrouped tok - 209k ████ - ████ - 167k ████ - ████ - 125k ████ ████ - ████ ████ - 83k ████ ████ - ████ ████ - 41k ████ ████ - ████ ████ + 209k ████ + ████ + 167k ████ + ████ + 125k ████ ████ + ████ ████ + 83k ████ ████ + ████ ████ + 41k ████ ████ + ████ ████ 0 ┼────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴───── 42:24 :24 :24 :24 :24 - 0 0 126k 0 0 0 0 0 0 209k + 0 126k 0 0 0 0 0 0 209k 0 REQUESTS 5 ERRORS 0 (0.0%) TOKENS 335k LATENCY 2.90s COST $0.65 [unlabelled] From a5598f60df0575bf05bdb3739fe79cd528c11082 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 20:41:47 -0400 Subject: [PATCH 08/24] =?UTF-8?q?fix:=20Review=20round=205=20=E2=80=94=20a?= =?UTF-8?q?=20narrow-terminal=20regression,=20and=20one=20place=20for=20th?= =?UTF-8?q?e=20rule?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2. THE GLYPH TEST PASSED VACUOUSLY. HasNoOrphanTreeGlyph skips any row without a "└", so flattening childTierLabel to no glyph made every iteration skip and the test go green — the drawnBarGlyphs shape again, in a test I did not think to apply that lesson to. Counts the glyph rows and fails if none. 3. THE ONE-COLUMN DRAWER HAD GROWN A ROW. spendDrawerLines went 6 to 7 when the tier column gained the child, and the reservation is unconditional — so a narrow terminal, which has no tier column at all, permanently lost a body row to a child that cannot render there. The narrow path's own doc comment ("degrades to exactly the per-model drawer that shipped before") and the proposal's criterion 7 were both false by one line, and nothing checked the count. Reservation is now width-aware, which is defensible where varying by the DATA is not: width is known wherever layout asks. The one-column bound is the series count rather than tierPanelLines, so it no longer walks an always-empty fifth slot. Pinned at both widths, and the error-path test now compares against the reservation for its width rather than the constant — same invariant, emitted equals reserved, parameterised by the one input that is known. 4. THE APPORTIONMENT RULE LIVED ONLY IN package tui. ApportionTiers documents itself as the one place that arithmetic lives, and costJSON.Tiers refuses a local reimplementation in as many words — then reasoningChildRow derived a reasoning figure (ratio, clamp, sub-micro refusal) inside the drawer, where `abctl cost --json` could not reach it. A consumer wanting the number the TUI draws had to reimplement the unpublished rule. Now usage.ApportionReasoning, beside ApportionTiers, called by both the drawer and a new `tiers.reasoningOfOutput` — a POINTER and inside output, so absent still means "no defensible figure" rather than "free", and the four tiers keep summing to CostMicros without it. Eight tests on the four refusal paths, the clamp, and the legacy-producer case. 5. COMMENT ARCHAEOLOGY, against my own trim rule. spend_tiers.go carried sentences narrating this PR's review rounds — a guard that "was written here first and was unreachable", and in the test file a comment correcting a previous version of itself. The invariants stay; the was-written/was-removed history is commit-message material and is already here. spend_tiers.go is net shorter. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/usage/apportion.go | 48 ++++++++++++ authbridge/authlib/usage/apportion_test.go | 78 +++++++++++++++++++ authbridge/cmd/abctl/cmd_cost.go | 16 +++- authbridge/cmd/abctl/cost_token_split_test.go | 53 +++++++++++++ authbridge/cmd/abctl/tui/app.go | 2 +- authbridge/cmd/abctl/tui/keys.go | 2 +- authbridge/cmd/abctl/tui/spend_drawer.go | 29 ++++++- authbridge/cmd/abctl/tui/spend_drawer_test.go | 48 ++++++++++++ .../cmd/abctl/tui/spend_sanitize_test.go | 9 ++- authbridge/cmd/abctl/tui/spend_tiers.go | 72 +++-------------- .../abctl/tui/spend_tiers_reasoning_test.go | 7 +- 11 files changed, 291 insertions(+), 73 deletions(-) diff --git a/authbridge/authlib/usage/apportion.go b/authbridge/authlib/usage/apportion.go index b8ddf92e5..e2cd87c53 100644 --- a/authbridge/authlib/usage/apportion.go +++ b/authbridge/authlib/usage/apportion.go @@ -69,3 +69,51 @@ func (c Counts) ApportionTiers() (tiers [pricing.NumTiers]int64, ok bool) { } return tiers, true } + +// ApportionReasoning is the reasoning share of an already-apportioned output figure, +// in micros. +// +// HERE RATHER THAN IN A RENDERER, for the reason ApportionTiers gives for itself: it +// is the one place this arithmetic lives, so the surfaces cannot disagree about a +// figure derived more than once. It was written inside abctl's spend drawer first, +// which left `abctl cost --json` unable to publish the number the TUI drew — a +// consumer could only get it by reimplementing this, which is what costJSON.Tiers +// refuses for the tier split. +// +// outputMicros is the DISPLAYED output figure, not c.OutputCostMicros: the displayed +// one is already scaled to the gateway's authoritative total, so deriving from the raw +// mix would produce a child that does not divide into the parent beside it. +// +// ok is false when there is no defensible figure, and the caller renders "not known +// here" — never $0.00, which would assert the reasoning was free. Four ways to get +// there: +// +// - nothing reported a split (the present bit clear AND the value zero; a non-zero +// value with a clear bit still counts, being an event from a producer predating +// PresentKinds) +// - no output tokens, so there is no denominator +// - no output money to take a share of +// - a share that truncates below one micro, which is reachable on a small window +// +// The result is clamped to outputMicros. Reasoning cannot exceed output on the wire, +// but a provider reporting otherwise must not produce a child figure above its parent; +// the counts themselves are left as reported — see Counts.ReasoningTokens. +func (c Counts) ApportionReasoning(outputMicros int64) (micros int64, ok bool) { + if c.PresentKinds&KindReasoning == 0 && c.ReasoningTokens == 0 { + return 0, false + } + if c.OutputTokens <= 0 || outputMicros <= 0 { + return 0, false + } + // Float ratio bounded by the parent, the form ApportionTiers uses and for its + // reason: the integer product of two window-sized sums overflows int64. + micros = int64(float64(outputMicros) * + (float64(c.ReasoningTokens) / float64(c.OutputTokens))) + if micros > outputMicros { + micros = outputMicros + } + if micros == 0 { + return 0, false + } + return micros, true +} diff --git a/authbridge/authlib/usage/apportion_test.go b/authbridge/authlib/usage/apportion_test.go index 48b027c28..cb8224a9c 100644 --- a/authbridge/authlib/usage/apportion_test.go +++ b/authbridge/authlib/usage/apportion_test.go @@ -105,3 +105,81 @@ func TestApportionTiers_ATierWithNoMixGetsNothing(t *testing.T) { t.Errorf("a tier absent from the mix was given money: %v", tiers) } } + +// ApportionReasoning's four not-known paths, each of which must refuse rather than +// return a zero a caller would render as "$0.00" — the claim that the reasoning was +// free. +func TestApportionReasoning_RefusesRatherThanReturningZero(t *testing.T) { + base := Counts{ + OutputTokens: 1593, ReasoningTokens: 948, + PresentKinds: uint8(KindOutput | KindReasoning), + } + for _, tc := range []struct { + name string + mutate func(*Counts) + outputMicros int64 + }{ + {"nothing reported a split", func(c *Counts) { + c.ReasoningTokens, c.PresentKinds = 0, uint8(KindOutput) + }, 1_000_000}, + {"no output tokens, so no denominator", func(c *Counts) { c.OutputTokens = 0 }, 1_000_000}, + {"no output money to take a share of", func(c *Counts) {}, 0}, + // 100 * 1/1000 = 0.1, which truncates away. + {"share truncates below one micro", func(c *Counts) { + c.ReasoningTokens, c.OutputTokens = 1, 1000 + }, 100}, + } { + t.Run(tc.name, func(t *testing.T) { + c := base + tc.mutate(&c) + got, ok := c.ApportionReasoning(tc.outputMicros) + if ok { + t.Errorf("ok = true with %d micros; the caller would render a figure it cannot defend", got) + } + if got != 0 { + t.Errorf("micros = %d, want 0 alongside ok=false", got) + } + }) + } +} + +// A REPORTED ZERO is still a measurement, but it apportions to nothing, so it refuses +// too — the figure is what cannot be stated, not the observation. +func TestApportionReasoning_ReportedZeroApportionsToNothing(t *testing.T) { + c := Counts{ + OutputTokens: 1593, ReasoningTokens: 0, + PresentKinds: uint8(KindOutput | KindReasoning), + } + if got, ok := c.ApportionReasoning(1_000_000); ok { + t.Errorf("ok = true with %d micros for a reported zero", got) + } +} + +// A non-zero value with the bit CLEAR still apportions: that is an event from a +// producer predating PresentKinds, where the value is the only evidence there is. +func TestApportionReasoning_LegacyProducerStillApportions(t *testing.T) { + c := Counts{OutputTokens: 1593, ReasoningTokens: 948} // no PresentKinds + got, ok := c.ApportionReasoning(1_000_000) + if !ok { + t.Fatal("refused a producer predating PresentKinds, dropping the only evidence available") + } + if want := int64(594_000); got < want-2_000 || got > want+2_000 { + t.Errorf("micros = %d, want about %d (948/1593 of the parent)", got, want) + } +} + +// CLAMPED TO THE PARENT. A provider reporting reasoning above output must not yield a +// figure above the one it is a share of; the counts themselves are left as reported. +func TestApportionReasoning_ClampsToTheParent(t *testing.T) { + c := Counts{ + OutputTokens: 1593, ReasoningTokens: 4_000, // impossible on the wire + PresentKinds: uint8(KindOutput | KindReasoning), + } + got, ok := c.ApportionReasoning(1_000_000) + if !ok { + t.Fatal("refused a clampable figure") + } + if got != 1_000_000 { + t.Errorf("micros = %d, want it clamped to the parent's 1000000", got) + } +} diff --git a/authbridge/cmd/abctl/cmd_cost.go b/authbridge/cmd/abctl/cmd_cost.go index 0535a36d4..1c8dce422 100644 --- a/authbridge/cmd/abctl/cmd_cost.go +++ b/authbridge/cmd/abctl/cmd_cost.go @@ -344,6 +344,16 @@ type costTiersJSON struct { CacheWrite int64 `json:"cacheWrite"` CacheRead int64 `json:"cacheRead"` Output int64 `json:"output"` + // Reasoning is the share of Output spent on internal reasoning, apportioned by + // usage.ApportionReasoning — the same call the TUI's drawer draws from, so a + // consumer never has to reimplement the rule. + // + // A POINTER, and INSIDE Output rather than beside it. Absent means no defensible + // figure — nothing reported a split, or the share truncated below one micro — which + // is not the same as zero, and summing it with the four tiers above double-counts + // every reasoning token at the most expensive rate there is. The four fields still + // add up to CostMicros without it. + Reasoning *int64 `json:"reasoningOfOutput,omitempty"` } // tiersJSONOf apportions the totals, or returns nil when there is no mix to apportion by. @@ -352,12 +362,16 @@ func tiersJSONOf(t usage.Counts) *costTiersJSON { if !ok { return nil } - return &costTiersJSON{ + out := &costTiersJSON{ Input: tiers[pricing.TierInput], CacheWrite: tiers[pricing.TierCacheWrite], CacheRead: tiers[pricing.TierCacheRead], Output: tiers[pricing.TierOutput], } + if micros, has := t.ApportionReasoning(tiers[pricing.TierOutput]); has { + out.Reasoning = µs + } + return out } func writeCostJSON(snap *usage.Snapshot, stdout, stderr io.Writer) int { diff --git a/authbridge/cmd/abctl/cost_token_split_test.go b/authbridge/cmd/abctl/cost_token_split_test.go index 024bc48bf..f309986e6 100644 --- a/authbridge/cmd/abctl/cost_token_split_test.go +++ b/authbridge/cmd/abctl/cost_token_split_test.go @@ -78,3 +78,56 @@ func TestTokenSplit_BitAndValueAreBothConsulted(t *testing.T) { t.Errorf("tokenSplit = %q, want the 948 from a producer predating PresentKinds", legacy) } } + +// `abctl cost --json` must publish the reasoning figure the TUI's drawer draws. +// Without it a scripted consumer can only get that number by reimplementing +// usage.ApportionReasoning, which is the drift costJSON.Tiers exists to prevent. +func TestTiersJSON_PublishesReasoningAndKeepsTheFourTiersSumming(t *testing.T) { + c := usage.Counts{ + CostMicros: 4_546_200, + InputCostMicros: 3000, CacheWriteCostMicros: 7500, + CacheReadCostMicros: 30000, OutputCostMicros: 45000, + OutputTokens: 1593, ReasoningTokens: 948, + PresentKinds: uint8(usage.KindOutput | usage.KindReasoning), + } + got := tiersJSONOf(c) + if got == nil { + t.Fatal("no tiers published for a Counts with a mix") + } + if got.Reasoning == nil { + t.Fatal("reasoningOfOutput is absent despite a reported split; a consumer would have " + + "to reimplement the apportionment") + } + // INSIDE output, not beside it. + if *got.Reasoning > got.Output { + t.Errorf("reasoning %d exceeds output %d", *got.Reasoning, got.Output) + } + // And the four tiers still reconcile to the total without it. + if sum := got.Input + got.CacheWrite + got.CacheRead + got.Output; sum != c.CostMicros { + t.Errorf("the four tiers sum to %d, want %d — reasoning must not be in the sum", + sum, c.CostMicros) + } + // It is the SAME figure the drawer derives, by construction: one call. + want, ok := c.ApportionReasoning(got.Output) + if !ok || want != *got.Reasoning { + t.Errorf("published %d but ApportionReasoning gives %d (ok=%v)", *got.Reasoning, want, ok) + } +} + +// Absent, not zero, when nothing reported a split — so a consumer can tell "no figure" +// from "free". +func TestTiersJSON_OmitsReasoningWhenThereIsNoFigure(t *testing.T) { + c := usage.Counts{ + CostMicros: 4_546_200, + InputCostMicros: 3000, OutputCostMicros: 45000, + OutputTokens: 1593, + PresentKinds: uint8(usage.KindOutput), + } + got := tiersJSONOf(c) + if got == nil { + t.Fatal("no tiers published") + } + if got.Reasoning != nil { + t.Errorf("reasoningOfOutput = %d for a provider reporting no split; want absent", *got.Reasoning) + } +} diff --git a/authbridge/cmd/abctl/tui/app.go b/authbridge/cmd/abctl/tui/app.go index 8a5e39779..f0ce8efc3 100644 --- a/authbridge/cmd/abctl/tui/app.go +++ b/authbridge/cmd/abctl/tui/app.go @@ -1912,7 +1912,7 @@ func (m *model) paneView() string { axis, window := m.drawerLabels() lines = renderSpendDrawer(m.spend.drawer.snap, m.spend.drawer.err, axis, window, m.width) } - for len(lines) < spendDrawerLines { + for len(lines) < spendDrawerLinesFor(m.width) { lines = append(lines, "") } for _, line := range lines { diff --git a/authbridge/cmd/abctl/tui/keys.go b/authbridge/cmd/abctl/tui/keys.go index e5f28b47f..2e9d27584 100644 --- a/authbridge/cmd/abctl/tui/keys.go +++ b/authbridge/cmd/abctl/tui/keys.go @@ -1217,7 +1217,7 @@ func (m *model) layout() { // off the bottom on every pane — the failure the strip's own reservation exists to prevent, // five rows at a time instead of one. See spendDrawerReservesRows. if m.spendDrawerReservesRows() { - bodyH -= spendDrawerLines + bodyH -= spendDrawerLinesFor(m.width) } // And one more while the filter is open: View() prepends filterInput above the body, so // the line exists on screen whether or not the budget admits it. Unreserved, the view came diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index 085711c93..532cb5164 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -71,6 +71,26 @@ const ( spendDrawerLines = max(tierPanelLines, spendDrawerSeries+1) + 2 ) +// spendDrawerLinesFor is the reservation at a given WIDTH, and the width is why it is +// a function where spendDrawerLines is a constant. +// +// The tier column only exists in two-column mode, so only there does the panel need +// room for tierPanelLines. Reserving the two-column height unconditionally cost a +// narrow terminal a body row to a child row that cannot render at that width — the +// reasoning child took the panel from 4 left rows to 5, and the one-column drawer, +// which shows only the series, grew with it for nothing. +// +// Width is known wherever this is called, unlike the DATA, which is why the same +// argument does not apply to varying the height by whether a split was reported: see +// renderTierRows. +func spendDrawerLinesFor(width int) int { + left := spendDrawerSeries + 1 // one column: the ranked series plus "(other)" + if width >= spendDrawerTwoColumnMin { + left = max(tierPanelLines, spendDrawerSeries+1) + } + return left + 2 // the header and the hint line +} + // spendDrawerAxes are the breakdown axes `g` cycles through. // // NO GroupNone in the cycle, unlike the Usage pane's grouping. This drawer's only content @@ -697,7 +717,7 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window out := make([]string, 0, spendDrawerLines) out = append(out, clipRow(" breakdown unavailable for "+windowLabel+": "+ sanitizeLabel(err.Error()), width)) - for len(out) < spendDrawerLines-1 { + for len(out) < spendDrawerLinesFor(width)-1 { out = append(out, "") } return append(out, fitStripFigures(" ", plainFigures( @@ -739,7 +759,10 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // would emit more body rows than spendDrawerLines reserves and push the footer off // the terminal, which is the failure the block above documents. min keeps both, and // the one-column path takes tierPanelLines because tiers is nil and never indexed. - bound := tierPanelLines + // ONE COLUMN HAS NO TIER ROWS, so its bound is the series count. Bounded by + // tierPanelLines it walked a fifth slot that is always empty at that width and + // emitted a blank body row. + bound := spendDrawerSeries + 1 if twoCol { bound = min(tierPanelLines, len(tiers)) } @@ -791,7 +814,7 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // Blank lines rather than a taller body, because the body is already sized: the drawer occupies // the space that was set aside for it, so the table's position does not jump when a second // model appears. - for len(out) < spendDrawerLines { + for len(out) < spendDrawerLinesFor(width) { out = append(out, "") } return out diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index 954ff9527..e80155dd2 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -1313,6 +1313,19 @@ func TestRenderSpendDrawer_HasNoOrphanTreeGlyph(t *testing.T) { if strings.Contains(joined, "├") { t.Errorf("the panel draws \"├\", which claims a sibling follows:\n%s", joined) } + // THE GLYPH MUST BE PRESENT BEFORE ITS PARENT IS CHECKED. The loop below skips any + // row without a "└", so flattening childTierLabel to no glyph would make every + // iteration skip and this test go green — the same dead-assertion shape as + // drawnBarGlyphs' inverted rune range. Count first, then check. + glyphRows := 0 + for _, l := range lines { + if strings.Contains(l, "└") { + glyphRows++ + } + } + if glyphRows == 0 { + t.Fatalf("no row carries \"└\", so the parent check below cannot fail:\n%s", joined) + } for i, l := range lines { if !strings.Contains(l, "└") { continue @@ -1915,3 +1928,38 @@ func TestRenderSpendDrawer_ChildCarriesItsFigure(t *testing.T) { t.Errorf("child row = %q carries no figure", child) } } + +// TestRenderSpendDrawer_NarrowHeightIsUnchangedByTheChildRow pins the claim the +// narrow path's own doc comment makes — "degrades to exactly the per-model drawer +// that shipped before" — as a LINE COUNT, which nothing checked. +// +// The reasoning child took the tier column from 4 rows to 5. Reserved +// unconditionally, that grew the one-column drawer too, which has no tier column at +// all: a narrow terminal permanently lost a body row to a child that cannot render +// there. The reservation is width-aware for exactly this reason. +func TestRenderSpendDrawer_NarrowHeightIsUnchangedByTheChildRow(t *testing.T) { + narrow := spendDrawerTwoColumnMin - 1 + // The series column plus "(other)", the header, and the hint line — what shipped + // before the tier column existed, and what must still ship at this width. + want := spendDrawerSeries + 1 + 2 + for _, snap := range []*usage.Snapshot{reasoningSnap(), tierSnap()} { + got := renderSpendDrawer(snap, nil, usage.GroupModel, "1h", narrow) + if len(got) != want { + t.Errorf("one-column drawer is %d lines, want %d:\n%s", + len(got), want, strings.Join(got, "\n")) + } + if len(got) != spendDrawerLinesFor(narrow) { + t.Errorf("drawer emitted %d lines but the reservation for width %d is %d", + len(got), narrow, spendDrawerLinesFor(narrow)) + } + // And no tier or child content leaked into the one-column form. + if joined := strings.Join(got, "\n"); strings.Contains(joined, "reasoning") { + t.Errorf("the one-column drawer draws the reasoning child:\n%s", joined) + } + } + // Two columns still get the taller reservation, or the fix traded one bug for another. + if spendDrawerLinesFor(spendDrawerTwoColumnMin) <= want { + t.Errorf("two-column reservation %d is not taller than the one-column %d", + spendDrawerLinesFor(spendDrawerTwoColumnMin), want) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_sanitize_test.go b/authbridge/cmd/abctl/tui/spend_sanitize_test.go index 20e664f53..0b630f2ce 100644 --- a/authbridge/cmd/abctl/tui/spend_sanitize_test.go +++ b/authbridge/cmd/abctl/tui/spend_sanitize_test.go @@ -154,9 +154,14 @@ func TestRenderSpendDrawer_AWideErrorMessageStaysInsideTheReservation(t *testing wide := errors.New("unexpected status 500: " + strings.Repeat("過", 40)) for _, width := range []int{20, 40, 72, 120} { lines := renderSpendDrawer(nil, wide, usage.GroupModel, "MONTH", width) - if len(lines) != spendDrawerLines { + // Against the reservation FOR THIS WIDTH rather than the constant. The reservation + // became width-aware when the tier column grew a fifth row: that row cannot render + // in one column, so reserving it there cost a narrow terminal a body row. The + // invariant is unchanged — emitted must equal reserved — and is now parameterised + // by the one input layout() already knows. + if want := spendDrawerLinesFor(width); len(lines) != want { t.Errorf("width %d: %d lines, want %d — the reservation is the height, so an extra "+ - "line pushes the footer off the bottom", width, len(lines), spendDrawerLines) + "line pushes the footer off the bottom", width, len(lines), want) } for i, line := range lines { if n := lipgloss.Width(line); n > width { diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index bfa9cd735..81d6730a4 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -212,77 +212,29 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, peak int64, budget, width int) string { notKnown := clipRow(fmt.Sprintf("%-*s %s", tierLabelWidth, childTierLabel, emptyCell), width) - // THE BIT AND THE VALUE TOGETHER, not the bit alone — the parenthesisation says so - // rather than leaving it to Go's precedence. A clear bit with a zero value means - // nothing reported a split, which is not the same as a split of zero: a provider - // exposing no reasoning counter gets the not-known cell, never $0.00, the same - // refusal renderTierRows makes for a tier absent from the mix. + // THE ARITHMETIC IS usage.ApportionReasoning'S, not this file's. It lives beside + // ApportionTiers for the reason that function states about itself — one place, so the + // drawer, `abctl cost` and the JSON cannot disagree about a figure derived three + // times. It was written here first, which left --json unable to publish the number + // this panel draws. // - // A non-zero value with a CLEAR bit still renders, which is why the value is in the - // condition at all: that is an event from a producer predating PresentKinds, where - // the value is the only evidence there is. Same rule as tokenSplit's `add`. - if !ok || (c.PresentKinds&usage.KindReasoning == 0 && c.ReasoningTokens == 0) { - return notKnown - } - // No denominator, no defensible figure. Reasoning cannot be a share of an output - // that was never counted. - if c.OutputTokens <= 0 || tiers[pricing.TierOutput] <= 0 { - return notKnown - } - // Float ratio bounded by the parent, the form ApportionTiers uses and for its - // reason: the integer product of two window-sized sums overflows int64. - micros := int64(float64(tiers[pricing.TierOutput]) * - (float64(c.ReasoningTokens) / float64(c.OutputTokens))) - // CLAMPED TO THE PARENT. Reasoning should never exceed output on the wire, but a - // gateway that reports them inconsistently would otherwise draw a child longer than - // the bar above it — a lie that looks authoritative. Clamp rather than refuse: the - // figure is still the best available, and the parent bounds it. - // - // DISPLAY-ONLY, AND DELIBERATELY SO. The subset relation is not enforced at ingest: - // parsercommon leaves the counts as reported, plausibleTokenReport screens only for - // negatives and an implausible ceiling, and `abctl cost`'s token line and the detail - // pane both print reasoning against output exactly as the provider stated them — - // including a contradictory pair. That is on purpose. A count is a measurement - // somebody else made, and silently correcting it here would hide the provider bug - // from the two surfaces where a reader could notice it. - // - // What cannot be left alone is GEOMETRY. A bar's length and a row's indent are - // claims this layout makes itself, not ones it relays: drawing a child longer than - // its parent asserts containment is false, which is a lie the display invented. So - // the numbers stay faithful and the picture stays consistent, and the clamp lives at - // the only layer that draws. - if micros > tiers[pricing.TierOutput] { - micros = tiers[pricing.TierOutput] - } - // APPORTIONED TO NOTHING IS NOT APPORTIONED TO ZERO, and this is the child's - // version of the `tiers[tier] == 0` escape the tier rows take. The multiply above - // truncates, so a real reasoning count whose share of the window falls below one - // micro lands here — reachable on a small window, around a hundred output tokens at - // opus-5 rates. Rendering it would print "$0.00", which asserts the reasoning was - // FREE: the one claim renderTierRows refuses for a tier, arriving through the child. - // - // The not-known cell instead. The tier rows cannot say "<$0.01" here either — that - // form means "too small to state", and what is true is that the apportionment could - // not resolve a figure at all. - if micros == 0 { + // ok from ApportionTiers gates first: with no mix to apportion by there is no output + // figure to take a share of. + micros, hasFigure := c.ApportionReasoning(tiers[pricing.TierOutput]) + if !ok || !hasFigure { return notKnown } // Floored against the same total the tier rows use, so the child is comparable down // the column. Deliberately NOT tierShares, which must keep summing to 100 across // exactly the four tiers. // - // NO SECOND CLAMP HERE, and the omission is deliberate rather than an oversight. - // This share is derived from micros AFTER the clamp above, so it is already bounded - // by the parent's: + // NO CLAMP ON THE SHARE, because it is already bounded by the parent's: it derives + // from micros AFTER ApportionReasoning's clamp, and // // floor(micros*100/total) <= floor(tiers[output]*100/total) <= shares[output] // // the right-hand step holding because tierShares only ever ADDS its rounding - // remainder to the largest share, never subtracts. A `pct > shares[output]` guard - // was written here first and was unreachable — no fixture could enter it, and - // mutation-testing confirmed removing it changed no output. Unreachable code with - // an untestable branch is worse than none: it implies a hazard that does not exist - // and invites a reader to protect the wrong invariant. + // remainder to the largest share, never subtracts. pct := 0 if c.CostMicros > 0 { pct = int(micros * 100 / c.CostMicros) diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 5070cd722..8a522d52a 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -323,11 +323,8 @@ func TestRenderTierRows_HeightConstantAcrossReasoningStates(t *testing.T) { // it — otherwise the child row pushes the footer off the terminal, the defect // keys.go records for spendDrawerLines. func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { - // THE ONLY LIVE COMPARISON HERE: a constant against what the renderer actually - // produces. `tierPanelLines != numTierRows+1` and `spendDrawerLines < tierPanelLines` - // were also asserted and both were tautologies — the first restates the const - // definition, and spendDrawerLines is max(tierPanelLines, ...)+2 so the second cannot - // fail. Comparing a constant to its own definition reads as coverage and is none. + // A CONSTANT AGAINST WHAT THE RENDERER PRODUCES, which is the only form of this + // assertion that can fail: comparing tierPanelLines to its own definition cannot. if want := len(renderTierRows(reasoningCounts(), tierColumnWidth)); tierPanelLines < want { t.Errorf("tierPanelLines = %d but the panel renders %d lines", tierPanelLines, want) } From 98b8ac35955a6962e21fd5714e6a81866cb934cf Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 21:27:38 -0400 Subject: [PATCH 09/24] =?UTF-8?q?fix:=20Review=20round=206=20=E2=80=94=20n?= =?UTF-8?q?egative=20money=20escaped=20ApportionReasoning=20with=20ok=3Dtr?= =?UTF-8?q?ue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. NEGATIVE MONEY, ok=true. With ReasoningTokens negative and the present bit set, both guards pass, the ratio goes negative, the upper clamp does not fire and the `micros == 0` escape does not either: (-595103, true), reproduced. --json would publish a negative reasoningOfOutput and the drawer would hand it to tierBar. plausibleTokenReport does screen negatives at ingest, which is exactly the defence addSat refuses for itself in this same package — "unreachable today" is how the wrap arrived, and a half-guarded figure invites a reader to conclude the other half was ruled out. ApportionReasoning is exported and was clamped on the upper side only. `ReasoningTokens <= 0` now refuses, which also subsumes the reported-zero case that used to reach the truncation escape. 2. THE README STATED A FACT THIS PACKAGE CONTRADICTS. It said the row shows "—" when the provider reports no split, "as every non-Anthropic endpoint does" — but the OpenAI-format parser has always read completion_tokens_details.reasoning_tokens and set KindReasoning, so OpenAI-compatible endpoints behind litellm do report one. This PR adds the Anthropic path, not the first one. Shipping a new stale claim in a PR whose subject is a stale claim; rewritten to name both wire fields. 3. THE WIDTH CHANGE TURNED A LIVE ASSERTION DEAD. The error path pads to spendDrawerLinesFor(width)-1 and appends one hint line, so asserting against spendDrawerLinesFor compared the renderer with its own padding rule. The constant it replaced was an independent witness. Now a literal table — {20,6},{40,6},{72,6},{120,7} — which also documents that only 120 clears spendDrawerTwoColumnMin, so two columns are exercised at one width. 4. THE JSON FIGURE WAS PINNED TO ITS OWN DERIVATION. `want, ok := c.ApportionReasoning(got.Output)` is the same receiver and argument tiersJSONOf passes, so the surface could publish any wrong figure silently; the adjacent `> got.Output` check was fixture-guaranteed too. Replaced with a hand-derived literal, and the derivation is written out beside it. 5. A GUARD CLAUSE THAT COULD NOT FIRE. `Contains(l, "reasoning") && !HasPrefix(l, childTierLabel)` — the row is formatted FROM childTierLabel, so the second half is always false and flattening the label kept it green. The indent is now asserted on the LABEL, which is the thing that can change, plus a found-the-row guard. 6. THE RESERVATION AND THE LOOP DISAGREED ABOUT WHICH COLUMN CAN BE TALLER. spendDrawerLinesFor reserves max(tierPanelLines, spendDrawerSeries+1); the bound was min(tierPanelLines, len(tiers)), ignoring the series count. They agreed only because spendDrawerSeries+1 is 4 against tierPanelLines' 5 — an unstated inequality, not construction, and raising spendDrawerSeries would have truncated the series column while the reservation still held room. Both now encode "the taller column governs", and the comment claiming otherwise is gone. 7. THE MISSING CASES. Three negatives on ApportionReasoning (count, output count, output money) asserting the result is neither non-zero nor negative; a reported zero and a negative through renderTierRows, the surface carrying the "$0.00 is a lie" rule; and a wire fixture with "thinking_tokens": 0, which the partially-absent shapes cannot express — the count is PRESENT and zero, so KindReasoning must be SET. 8. NOT FIXED, deliberately: the two parsers diverge on a details object with no count. Anthropic uses *int so an empty object leaves the bit clear; the OpenAI path uses a plain int and sets the bit on object presence, asserting a reported zero. The stricter invariant is on one side only. Changing the OpenAI path would alter behaviour for every OpenAI-format endpoint, which is not this PR's subject — recorded here rather than silently left. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic_test.go | 31 +++++++++++++++ authbridge/authlib/usage/apportion.go | 16 ++++++++ authbridge/authlib/usage/apportion_test.go | 12 ++++++ authbridge/cmd/abctl/README.md | 7 +++- authbridge/cmd/abctl/cost_token_split_test.go | 25 ++++++++---- authbridge/cmd/abctl/tui/spend_drawer.go | 19 +++++++--- .../cmd/abctl/tui/spend_sanitize_test.go | 26 +++++++++---- .../abctl/tui/spend_tiers_reasoning_test.go | 38 +++++++++++++++++++ authbridge/cmd/abctl/tui/spend_tiers_test.go | 22 +++++++++-- 9 files changed, 169 insertions(+), 27 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go index 07cf1d9de..26a0d9a51 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go @@ -730,3 +730,34 @@ func TestInferenceParser_AnthropicMessages_ThinkingTokensOnMessageStart(t *testi t.Error("KindReasoning is set with a value of 0; presence and value diverged") } } + +// 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) + } +} diff --git a/authbridge/authlib/usage/apportion.go b/authbridge/authlib/usage/apportion.go index e2cd87c53..0d76afe58 100644 --- a/authbridge/authlib/usage/apportion.go +++ b/authbridge/authlib/usage/apportion.go @@ -102,6 +102,22 @@ func (c Counts) ApportionReasoning(outputMicros int64) (micros int64, ok bool) { if c.PresentKinds&KindReasoning == 0 && c.ReasoningTokens == 0 { return 0, false } + // NEGATIVE IS REFUSED, not merely zero, and refused HERE rather than left to + // plausibleTokenReport. A negative count makes the ratio negative, the upper clamp + // below does not fire and the `micros == 0` escape does not either — so a caller got + // (-595103, true) and would publish negative money or hand it to tierBar. + // + // The ingest screen does catch negatives today. That is precisely the defence addSat + // refuses for itself in this package, in words that transfer: "unreachable today" is + // how the wrap arrived in the first place, and a half-guarded figure invites a reader + // to conclude the other half was considered and ruled out. This function is exported + // and was clamped on the upper side only. + // + // `<= 0` also subsumes the reported-zero case, which used to reach the truncation + // escape instead: a split measured as nothing has no figure to apportion either. + if c.ReasoningTokens <= 0 { + return 0, false + } if c.OutputTokens <= 0 || outputMicros <= 0 { return 0, false } diff --git a/authbridge/authlib/usage/apportion_test.go b/authbridge/authlib/usage/apportion_test.go index cb8224a9c..8c7b95c98 100644 --- a/authbridge/authlib/usage/apportion_test.go +++ b/authbridge/authlib/usage/apportion_test.go @@ -128,6 +128,14 @@ func TestApportionReasoning_RefusesRatherThanReturningZero(t *testing.T) { {"share truncates below one micro", func(c *Counts) { c.ReasoningTokens, c.OutputTokens = 1, 1000 }, 100}, + // NEGATIVE, which returned (-595103, true) before the guard: the ratio goes + // negative, the upper clamp does not fire, and the truncation escape does not + // either. plausibleTokenReport screens negatives at ingest, but this is exported + // and addSat's argument in this package applies — "unreachable today" is how the + // wrap arrived. + {"negative reasoning count", func(c *Counts) { c.ReasoningTokens = -948 }, 1_000_000}, + {"negative output count", func(c *Counts) { c.OutputTokens = -1593 }, 1_000_000}, + {"negative output money", func(c *Counts) {}, -1_000_000}, } { t.Run(tc.name, func(t *testing.T) { c := base @@ -139,6 +147,10 @@ func TestApportionReasoning_RefusesRatherThanReturningZero(t *testing.T) { if got != 0 { t.Errorf("micros = %d, want 0 alongside ok=false", got) } + if got < 0 { + t.Errorf("micros = %d is NEGATIVE; a caller would publish negative money "+ + "or hand it to tierBar", got) + } }) } } diff --git a/authbridge/cmd/abctl/README.md b/authbridge/cmd/abctl/README.md index d442010b7..cd267b71c 100644 --- a/authbridge/cmd/abctl/README.md +++ b/authbridge/cmd/abctl/README.md @@ -523,8 +523,11 @@ abctl is for, and the other three are surfaces you visit and leave. billed at the output rate — so its figure is already inside output's, and only the four unindented rows sum to the window total. Its share is denominated in that same total, which is what makes `31% ⊂ 54%` read as containment. The row is - always present and shows `—` when the provider reports no split, as every - non-Anthropic endpoint does. + always present and shows `—` when no reasoning split was reported — either because + the endpoint does not report one, or because the apportioned share fell below a + micro. Anthropic reports it as `output_tokens_details.thinking_tokens` and + OpenAI-format endpoints as `completion_tokens_details.reasoning_tokens`; both are + read. These rows carry **no** `~`, unlike the sessions table's `SAVED~`. The caveat is real but this panel has no money heading to hang it on — `WHERE IT WENT` names diff --git a/authbridge/cmd/abctl/cost_token_split_test.go b/authbridge/cmd/abctl/cost_token_split_test.go index f309986e6..c0e535390 100644 --- a/authbridge/cmd/abctl/cost_token_split_test.go +++ b/authbridge/cmd/abctl/cost_token_split_test.go @@ -98,19 +98,30 @@ func TestTiersJSON_PublishesReasoningAndKeepsTheFourTiersSumming(t *testing.T) { t.Fatal("reasoningOfOutput is absent despite a reported split; a consumer would have " + "to reimplement the apportionment") } - // INSIDE output, not beside it. - if *got.Reasoning > got.Output { - t.Errorf("reasoning %d exceeds output %d", *got.Reasoning, got.Output) + // A HAND-DERIVED LITERAL, because deriving the expectation from the same call the + // code under test makes would publish any wrong figure silently — and the + // `> got.Output` check alone is fixture-guaranteed (948/1593 = 0.595, false even + // with the clamp deleted). + // + // mixTotal = 3000 + 7500 + 30000 + 45000 = 85500 + // tiers[output] = floor(4546200 * 45000/85500) + remainder = 2392739 + // reasoningOfOutput = floor(2392739 * 948/1593) = 1423927 + const wantReasoning = 1_423_927 + if *got.Reasoning != wantReasoning { + t.Errorf("reasoningOfOutput = %d, want %d", *got.Reasoning, wantReasoning) + } + if got.Output != 2_392_739 { + t.Errorf("tiers.output = %d, want 2392739; the literal above is derived from it", + got.Output) } // And the four tiers still reconcile to the total without it. if sum := got.Input + got.CacheWrite + got.CacheRead + got.Output; sum != c.CostMicros { t.Errorf("the four tiers sum to %d, want %d — reasoning must not be in the sum", sum, c.CostMicros) } - // It is the SAME figure the drawer derives, by construction: one call. - want, ok := c.ApportionReasoning(got.Output) - if !ok || want != *got.Reasoning { - t.Errorf("published %d but ApportionReasoning gives %d (ok=%v)", *got.Reasoning, want, ok) + // And it is the figure the drawer draws, which is the point of publishing it. + if drawn, ok := c.ApportionReasoning(got.Output); !ok || drawn != wantReasoning { + t.Errorf("the drawer derives %d (ok=%v) but the JSON publishes %d", drawn, ok, *got.Reasoning) } } diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index 532cb5164..e81e2e0bc 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -746,8 +746,8 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // the last tier to make room for the child — cheapest tier first, so `input` simply // vanished from a panel that still claimed to break down the whole bill. // - // The right column is shorter and its slots are filled by the `i < len(rows)` guard - // below, so the two columns stay the same height by construction. + // The right column's slots are filled by the `i < len(rows)` guard below, so a + // column shorter than the bound pads itself rather than ending the loop early. // // THE LOWER OF THE TWO, because the constant and the slice each guard a different // failure and neither alone guards both. @@ -759,12 +759,19 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // would emit more body rows than spendDrawerLines reserves and push the footer off // the terminal, which is the failure the block above documents. min keeps both, and // the one-column path takes tierPanelLines because tiers is nil and never indexed. - // ONE COLUMN HAS NO TIER ROWS, so its bound is the series count. Bounded by - // tierPanelLines it walked a fifth slot that is always empty at that width and - // emitted a blank body row. + // THE TALLER COLUMN GOVERNS, which is the same rule spendDrawerLinesFor reserves by. + // It was min(tierPanelLines, len(tiers)) — the tier column alone — while the + // reservation took max(tierPanelLines, spendDrawerSeries+1); the two agreed only + // because spendDrawerSeries+1 is 4 and tierPanelLines is 5 today. That unstated + // inequality is not "by construction", and raising spendDrawerSeries would have + // truncated the series column here while the reservation still held room for it. + // + // One column has no tier rows at all, so there the series count is the whole height. + // Bounded by tierPanelLines it walked a fifth slot that is always empty at that + // width and emitted a blank body row. bound := spendDrawerSeries + 1 if twoCol { - bound = min(tierPanelLines, len(tiers)) + bound = max(min(tierPanelLines, len(tiers)), spendDrawerSeries+1) } for i := 0; i < bound; i++ { // NO BRANCH GLYPHS BETWEEN THE COLUMNS' OWN ROWS. "├" and "└" once prefixed every diff --git a/authbridge/cmd/abctl/tui/spend_sanitize_test.go b/authbridge/cmd/abctl/tui/spend_sanitize_test.go index 0b630f2ce..31c79d85d 100644 --- a/authbridge/cmd/abctl/tui/spend_sanitize_test.go +++ b/authbridge/cmd/abctl/tui/spend_sanitize_test.go @@ -152,16 +152,26 @@ func assertNoControlChars(t *testing.T, where, s string) { // other end of the connection, not just from a wide locale. func TestRenderSpendDrawer_AWideErrorMessageStaysInsideTheReservation(t *testing.T) { wide := errors.New("unexpected status 500: " + strings.Repeat("過", 40)) - for _, width := range []int{20, 40, 72, 120} { + // LITERALS, NOT spendDrawerLinesFor(width). The error path pads to + // spendDrawerLinesFor(width)-1 and appends one hint line, so comparing against that + // same function compares the renderer with its own padding rule and cannot fail — + // the constant this used to name was an independent witness and this restores one. + // + // 6 below spendDrawerTwoColumnMin (85) and 7 at or above it: the tier column, and + // with it the reasoning child's row, only exists in two columns. 120 is the only + // width here that reaches it. + for _, tc := range []struct { + width, wantLines int + }{{20, 6}, {40, 6}, {72, 6}, {120, 7}} { + width := tc.width lines := renderSpendDrawer(nil, wide, usage.GroupModel, "MONTH", width) - // Against the reservation FOR THIS WIDTH rather than the constant. The reservation - // became width-aware when the tier column grew a fifth row: that row cannot render - // in one column, so reserving it there cost a narrow terminal a body row. The - // invariant is unchanged — emitted must equal reserved — and is now parameterised - // by the one input layout() already knows. - if want := spendDrawerLinesFor(width); len(lines) != want { + if len(lines) != tc.wantLines { t.Errorf("width %d: %d lines, want %d — the reservation is the height, so an extra "+ - "line pushes the footer off the bottom", width, len(lines), want) + "line pushes the footer off the bottom", width, len(lines), tc.wantLines) + } + // And the reservation layout() holds back must agree with what was emitted. + if got := spendDrawerLinesFor(width); got != tc.wantLines { + t.Errorf("width %d: reservation is %d but the render is %d lines", width, got, tc.wantLines) } for i, line := range lines { if n := lipgloss.Width(line); n > width { diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 8a522d52a..bc2bda966 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -335,3 +335,41 @@ func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { got, spendDrawerLines) } } + +// A REPORTED ZERO through the whole renderer, which is the surface carrying the +// "$0.00 is a lie" rule. ApportionReasoning refuses the figure, and what matters here +// is what the panel does with that refusal: the not-known cell, never $0.00, and the +// row still present so the height does not follow the data. +func TestRenderTierRows_ReportedZeroIsNotKnownNotFree(t *testing.T) { + c := reasoningCounts() + c.ReasoningTokens = 0 // measured, and measured as nothing: the bit stays set + child := childRows(renderTierRows(c, tierColumnWidth)) + if len(child) != 1 { + t.Fatalf("want one child row for a reported zero, got %d", len(child)) + } + if strings.Contains(child[0], "$0.00") { + t.Errorf("child row = %q asserts the reasoning was free", child[0]) + } + if !strings.Contains(child[0], emptyCell) { + t.Errorf("child row = %q, want the not-known cell", child[0]) + } +} + +// A NEGATIVE count must not reach the bar or the share cell. Unreachable through the +// live parser, which screens negatives at ingest — but renderTierRows takes a +// usage.Counts from the ledger and from any other producer, and a negative here would +// draw a bar from a negative length. +func TestRenderTierRows_NegativeReasoningIsRefused(t *testing.T) { + c := reasoningCounts() + c.ReasoningTokens = -948 + child := childRows(renderTierRows(c, tierColumnWidth)) + if len(child) != 1 { + t.Fatalf("want one child row, got %d", len(child)) + } + if !strings.Contains(child[0], emptyCell) { + t.Errorf("child row = %q, want the not-known cell for a negative count", child[0]) + } + if strings.Contains(child[0], "-") { + t.Errorf("child row = %q carries a negative figure", child[0]) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_tiers_test.go b/authbridge/cmd/abctl/tui/spend_tiers_test.go index 50e0f2398..90406388b 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_test.go @@ -372,14 +372,28 @@ func TestRenderTierRows_ReasoningIsNotATier(t *testing.T) { t.Errorf("%d summing rows against %d rate tiers; reasoning became a tier", got, pricing.NumTiers) } - // The reasoning row must use the INDENTED label — flush left it reads as a fifth - // tier. Asserted as "carries childTierLabel" rather than "starts with a space", - // so the check names the thing meant instead of a property of today's spelling. + // The reasoning row must be INDENTED — flush left it reads as a fifth tier. + // + // ANCHORED ON THE LABEL FIRST. The row is formatted from childTierLabel, so + // `HasPrefix(row, childTierLabel)` is always true and flattening the label to plain + // "reasoning" would keep such a check green. The indent has to be asserted on the + // label itself, which is the thing that can change. + if !strings.HasPrefix(childTierLabel, " ") { + t.Errorf("childTierLabel %q is flush left, so the row reads as a fifth tier", childTierLabel) + } + found := false for _, l := range lines { - if strings.Contains(l, "reasoning") && !strings.HasPrefix(l, childTierLabel) { + if !strings.Contains(l, "reasoning") { + continue + } + found = true + if !strings.HasPrefix(l, " ") { t.Errorf("reasoning row is flush with the tiers, so it reads as a peer: %q", l) } } + if !found { + t.Fatal("no reasoning row rendered, so the indent check above cannot fail") + } // And it must not be in the sum the tier rows own. total := 0 for _, l := range tierRowsOnly(lines) { From 7835205b4d4b366882fed5b0a007808a392ddef9 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 22:03:27 -0400 Subject: [PATCH 10/24] =?UTF-8?q?fix:=20Review=20round=207=20=E2=80=94=20t?= =?UTF-8?q?he=20max=20wrapper=20put=20back=20the=20panic=20the=20min=20pre?= =?UTF-8?q?vented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. A GUARD THAT UNDID ITS OWN GUARD. Round 5 added max(..., spendDrawerSeries+1) to the loop bound so the reservation and the loop encoded the same height rule. That max restores 4 for any len(tiers) < 4 — reinstating the exact out-of-range read on tiers[i] the min beside it was added to prevent, under a seven-line comment explaining why that panic matters. Both concerns were real and neither belonged in the bound: height is a layout fact, the index is a slice fact. The bound is now derived from spendDrawerLinesFor(width)-2, so the loop cannot disagree with the reservation at all, and the index is guarded where it is read. Mutation, renderTierRows returning 2 rows: zero panics, tests fail cleanly. With the max bound that input was an index-out-of-range mid-render. 2. A BRANCH ROUND 6 MADE DEAD. `bit == 0 && value == 0` is entirely subsumed by the `ReasoningTokens <= 0` check added below it — every input reaching one fails the other, and the doc's "four ways to get ok=false" were three. Removed, and the doc now says the present bit is not consulted here and why: the bit separates "nothing reported" from "reported zero", which matters to a renderer choosing between the not-known cell and "$0.00", but neither has a figure to apportion. 3. THE TWO ASSERTIONS MEANT TO PIN (1) WERE ONE-SIDED. `tierPanelLines < want` passed when the panel returned FEWER rows — the case (1) panics on — and `got > spendDrawerLines` passed on under-emission, which is the floating-footer bug this file documents. Both are equalities now, the second against spendDrawerLinesFor(width) rather than the two-column constant. Mutation, renderTierRows returning 4: "the panel renders 4 lines but tierPanelLines is 5". Previously green. 4. THE CHILD ROW HAD NO ALIGNMENT COVERAGE. MoneyIsRightAligned wraps its input in tierRowsOnly and then locks the exclusion in with len(ends) != numTierRows, so the one row this feature adds sits outside the alignment every other row is asserted to obey — and it is the likeliest to break it, its label being what forced tierLabelWidth from 11 to 12. Its money column is now measured against a tier's, and childTierLabel's length against tierLabelWidth, which spend_tiers.go claimed in a comment and nothing checked. 5. A PUBLISHED KEY NAME THAT NOTHING MARSHALLED. Every assertion on costTiersJSON read struct fields, where the json tag is invisible: a typo'd tag, or omitempty dropped so absence serialises as null, shipped silently on a contract scripts consume. Asserted through json.Marshal now, both the key with its figure and its total absence when there is none. Mutation, renaming the tag to reasoning_of_output: caught. 7. Still not fixed, still deliberate: the OpenAI path sets KindReasoning on details-object presence where the Anthropic path requires the count. Changing it moves behaviour for every OpenAI-format endpoint and is not this PR's subject. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/usage/apportion.go | 28 +++---- authbridge/cmd/abctl/cost_token_split_test.go | 44 +++++++++++ authbridge/cmd/abctl/tui/spend_drawer.go | 34 +++++---- .../abctl/tui/spend_tiers_reasoning_test.go | 73 ++++++++++++++++--- 4 files changed, 142 insertions(+), 37 deletions(-) diff --git a/authbridge/authlib/usage/apportion.go b/authbridge/authlib/usage/apportion.go index 0d76afe58..a2e7b74ec 100644 --- a/authbridge/authlib/usage/apportion.go +++ b/authbridge/authlib/usage/apportion.go @@ -85,24 +85,22 @@ func (c Counts) ApportionTiers() (tiers [pricing.NumTiers]int64, ok bool) { // mix would produce a child that does not divide into the parent beside it. // // ok is false when there is no defensible figure, and the caller renders "not known -// here" — never $0.00, which would assert the reasoning was free. Four ways to get +// here" — never $0.00, which would assert the reasoning was free. Three ways to get // there: // -// - nothing reported a split (the present bit clear AND the value zero; a non-zero -// value with a clear bit still counts, being an event from a producer predating -// PresentKinds) -// - no output tokens, so there is no denominator -// - no output money to take a share of -// - a share that truncates below one micro, which is reachable on a small window +// - no reasoning to apportion: the count is zero (nothing reported it, or it was +// reported as nothing) or negative +// - no denominator, or no output money to take a share of +// - a share that truncates below one micro, reachable on a small window +// +// A positive count with the present bit CLEAR does apportion: that is an event from a +// producer predating PresentKinds, where the value is the only evidence there is. // // The result is clamped to outputMicros. Reasoning cannot exceed output on the wire, // but a provider reporting otherwise must not produce a child figure above its parent; // the counts themselves are left as reported — see Counts.ReasoningTokens. func (c Counts) ApportionReasoning(outputMicros int64) (micros int64, ok bool) { - if c.PresentKinds&KindReasoning == 0 && c.ReasoningTokens == 0 { - return 0, false - } - // NEGATIVE IS REFUSED, not merely zero, and refused HERE rather than left to + // NEGATIVE OR ZERO IS REFUSED, not merely zero, and refused HERE rather than left to // plausibleTokenReport. A negative count makes the ratio negative, the upper clamp // below does not fire and the `micros == 0` escape does not either — so a caller got // (-595103, true) and would publish negative money or hand it to tierBar. @@ -113,8 +111,12 @@ func (c Counts) ApportionReasoning(outputMicros int64) (micros int64, ok bool) { // to conclude the other half was considered and ruled out. This function is exported // and was clamped on the upper side only. // - // `<= 0` also subsumes the reported-zero case, which used to reach the truncation - // escape instead: a split measured as nothing has no figure to apportion either. + // THE PRESENT BIT IS NOT CONSULTED, because this test subsumes it. A `bit == 0 && + // value == 0` branch stood above and became dead the moment `<= 0` was added: every + // input reaching one fails the other. The bit distinguishes "nothing reported" from + // "reported zero", which matters to a RENDERER deciding between the not-known cell + // and "$0.00" — but not here, because neither has a figure to apportion. Callers that + // need the distinction read PresentKinds themselves. if c.ReasoningTokens <= 0 { return 0, false } diff --git a/authbridge/cmd/abctl/cost_token_split_test.go b/authbridge/cmd/abctl/cost_token_split_test.go index c0e535390..91e29879a 100644 --- a/authbridge/cmd/abctl/cost_token_split_test.go +++ b/authbridge/cmd/abctl/cost_token_split_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "strings" "testing" @@ -142,3 +143,46 @@ func TestTiersJSON_OmitsReasoningWhenThereIsNoFigure(t *testing.T) { t.Errorf("reasoningOfOutput = %d for a provider reporting no split; want absent", *got.Reasoning) } } + +// THE KEY NAME IS A PUBLISHED CONTRACT, so it is asserted through the MARSHALLER. +// Every other test here reads the struct field, where the json tag is invisible: a +// typo'd tag, or omitempty dropped so absence serialises as null, ships silently and +// breaks every scripted consumer. +func TestTiersJSON_MarshalsTheReasoningKey(t *testing.T) { + priced := usage.Counts{ + CostMicros: 4_546_200, + InputCostMicros: 3000, CacheWriteCostMicros: 7500, + CacheReadCostMicros: 30000, OutputCostMicros: 45000, + OutputTokens: 1593, ReasoningTokens: 948, + PresentKinds: uint8(usage.KindOutput | usage.KindReasoning), + } + raw, err := json.Marshal(tiersJSONOf(priced)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + got := string(raw) + // The exact key, with its exact figure. + if !strings.Contains(got, `"reasoningOfOutput":1423927`) { + t.Errorf("marshalled %s\nwant a reasoningOfOutput of 1423927", got) + } + // The four tier keys must keep their names too — this is the same contract. + for _, key := range []string{`"input"`, `"cacheWrite"`, `"cacheRead"`, `"output"`} { + if !strings.Contains(got, key) { + t.Errorf("marshalled %s\nis missing %s", got, key) + } + } + + // ABSENT, not null and not zero, when there is no figure. A consumer distinguishes + // "no reasoning reported" from "reasoning cost nothing" by the key's absence, which + // is what omitempty on a pointer buys and what a value type would have lost. + unreported := priced + unreported.ReasoningTokens = 0 + unreported.PresentKinds = uint8(usage.KindOutput) + raw, err = json.Marshal(tiersJSONOf(unreported)) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := string(raw); strings.Contains(got, "reasoningOfOutput") { + t.Errorf("marshalled %s\nwant no reasoningOfOutput key at all", got) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index e81e2e0bc..42c186f98 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -759,20 +759,18 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // would emit more body rows than spendDrawerLines reserves and push the footer off // the terminal, which is the failure the block above documents. min keeps both, and // the one-column path takes tierPanelLines because tiers is nil and never indexed. - // THE TALLER COLUMN GOVERNS, which is the same rule spendDrawerLinesFor reserves by. - // It was min(tierPanelLines, len(tiers)) — the tier column alone — while the - // reservation took max(tierPanelLines, spendDrawerSeries+1); the two agreed only - // because spendDrawerSeries+1 is 4 and tierPanelLines is 5 today. That unstated - // inequality is not "by construction", and raising spendDrawerSeries would have - // truncated the series column here while the reservation still held room for it. + // THE BOUND IS THE RESERVATION, minus the header and the hint line — derived from + // spendDrawerLinesFor rather than restated, so the loop and the reservation cannot + // disagree about the panel's height. They did: the reservation took + // max(tierPanelLines, spendDrawerSeries+1) while this was min(tierPanelLines, + // len(tiers)), and the two agreed only because spendDrawerSeries+1 is 4 against + // tierPanelLines' 5. // - // One column has no tier rows at all, so there the series count is the whole height. - // Bounded by tierPanelLines it walked a fifth slot that is always empty at that - // width and emitted a blank body row. - bound := spendDrawerSeries + 1 - if twoCol { - bound = max(min(tierPanelLines, len(tiers)), spendDrawerSeries+1) - } + // The min was here to stop tiers[i] reading past the end, and a max wrapper added + // later to fix the height put that panic straight back for any len(tiers) < 4. Both + // concerns are real and neither belongs in the bound: the height is a layout fact and + // the index is a slice fact, so the index is guarded where it is read. + bound := spendDrawerLinesFor(width) - 2 for i := 0; i < bound; i++ { // NO BRANCH GLYPHS BETWEEN THE COLUMNS' OWN ROWS. "├" and "└" once prefixed every // row here and implied a parent none of them had; the column headers name the @@ -799,8 +797,16 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // row follows it: the fourth tier row is drawn beside an empty series slot on any window // with fewer than four series, and paneView passes these straight to styleMuted.Render, // so the padding becomes styled trailing whitespace on a line nobody can see the end of. + // GUARDED, not assumed. renderTierRows returns tierPanelLines rows today, but the + // contract is held by a test in another package while this index is what crashes + // the render if it ever slips. A short tier column pads with blanks — a missing row + // is a cosmetic loss, an out-of-range read is a dead TUI. + tier := "" + if i < len(tiers) { + tier = tiers[i] + } out = append(out, strings.TrimRight(fmt.Sprintf(" %-*s%s", - tierColumnWidth+drawerColumnGutter, tiers[i], strings.TrimLeft(series, " ")), " ")) + tierColumnWidth+drawerColumnGutter, tier, strings.TrimLeft(series, " ")), " ")) } // The hint line is LAST and always present: it is the only place the two keys and the // current axis are written down, and a drawer whose controls are undiscoverable is a diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index bc2bda966..304a75876 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -5,6 +5,8 @@ import ( "strings" "testing" + "github.com/charmbracelet/lipgloss" + "github.com/rossoctl/cortex/authbridge/authlib/usage" ) @@ -323,16 +325,21 @@ func TestRenderTierRows_HeightConstantAcrossReasoningStates(t *testing.T) { // it — otherwise the child row pushes the footer off the terminal, the defect // keys.go records for spendDrawerLines. func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { - // A CONSTANT AGAINST WHAT THE RENDERER PRODUCES, which is the only form of this - // assertion that can fail: comparing tierPanelLines to its own definition cannot. - if want := len(renderTierRows(reasoningCounts(), tierColumnWidth)); tierPanelLines < want { - t.Errorf("tierPanelLines = %d but the panel renders %d lines", tierPanelLines, want) - } - // The drawer must actually emit them, which is a property of renderSpendDrawer - // rather than of the constants. Asserted against a real render. - if got := len(renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", 100)); got > spendDrawerLines { - t.Errorf("the drawer emitted %d lines but reserves %d; the footer will be pushed off", - got, spendDrawerLines) + // EQUALITY, NOT AN INEQUALITY, in both directions. `tierPanelLines < want` passed + // when the panel returned FEWER rows than the constant — which is the case the + // drawer's bare tiers[i] read used to panic on — and `got > spendDrawerLines` passed + // on under-emission, which is the floating-footer bug this file documents. Each + // inequality guarded one side of a two-sided invariant. + if want := len(renderTierRows(reasoningCounts(), tierColumnWidth)); want != tierPanelLines { + t.Errorf("the panel renders %d lines but tierPanelLines is %d", want, tierPanelLines) + } + // And the drawer emits exactly its reservation FOR THAT WIDTH — not the constant, + // which is the two-column value and would let a narrow render pass short. + const w = 100 + if got, want := len(renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", w)), + spendDrawerLinesFor(w); got != want { + t.Errorf("the drawer emitted %d lines and reserves %d; either way the footer moves", + got, want) } } @@ -373,3 +380,49 @@ func TestRenderTierRows_NegativeReasoningIsRefused(t *testing.T) { t.Errorf("child row = %q carries a negative figure", child[0]) } } + +// THE CHILD'S MONEY COLUMN ALIGNS WITH THE TIERS', which +// TestRenderTierRows_MoneyIsRightAligned cannot say: it wraps its input in +// tierRowsOnly and then locks the exclusion in with `len(ends) != numTierRows`, so +// the one row this feature adds is outside the alignment it has to obey. +// +// The child is the row most likely to break it — its label is exactly +// tierLabelWidth and was what forced that constant from 11 to 12. +func TestRenderTierRows_ChildMoneyColumnAlignsWithTheTiers(t *testing.T) { + lines := renderTierRows(reasoningCounts(), tierColumnWidth) + + endOf := func(row string) int { + i := strings.LastIndex(row, "$") + if i < 0 { + return -1 + } + return lipgloss.Width(row[:i]) + lipgloss.Width(strings.TrimSpace(row[i:])) + } + var tierEnd, childEnd int + for _, l := range lines { + switch { + case strings.HasPrefix(l, childTierLabel): + childEnd = endOf(l) + case tierEnd == 0: + tierEnd = endOf(l) + } + } + if tierEnd <= 0 || childEnd <= 0 { + t.Fatalf("no figure to measure (tier %d, child %d):\n%s", + tierEnd, childEnd, strings.Join(lines, "\n")) + } + if childEnd != tierEnd { + t.Errorf("the child's figure ends at column %d and a tier's at %d, so the decimal "+ + "points do not line up:\n%s", childEnd, tierEnd, strings.Join(lines, "\n")) + } +} + +// childTierLabel must be EXACTLY tierLabelWidth runes, which spend_tiers.go asserts in +// a comment and nothing checked. Shorter and the bars start at two columns on that row; +// longer and it pushes the whole row right. +func TestChildTierLabel_IsExactlyTheLabelWidth(t *testing.T) { + if n := len([]rune(childTierLabel)); n != tierLabelWidth { + t.Errorf("childTierLabel %q is %d runes, want tierLabelWidth = %d", + childTierLabel, n, tierLabelWidth) + } +} From dafb18ef60a65086a9b9023a54cca203f62a8806 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 22:33:36 -0400 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20Review=20round=208=20=E2=80=94=20m?= =?UTF-8?q?irror=20the=20pointer=20on=20the=20OpenAI=20path,=20and=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7. THE PARITY GAP, now closed rather than filed. plugin.go set KindReasoning on presence of completion_tokens_details with a non-pointer count — the exact "details present, count absent → bit set with value 0" defect the Anthropic path checks both pointers to avoid, and that this PR's own test forbids on that side. Leaving it diverged got harder to justify once this PR made the bit visible in the detail pane and the README claimed both wire fields are read. Mirrored to *int. The existing reported-zero test still passes unchanged — a count present and zero keeps setting the bit, which is a measurement — so only the false zero changes. Three shapes added: empty object, explicit null, unrelated sub-field. PromptTokensDetails.CachedTokens has the identical shape and exposure. Left alone and noted in place: changing cache-read's presence rule moves a figure this change is not about. 8. COMMENT TRIM, measured rather than argued. 70% of added production lines were comments against 57% for these same files at the branch base, so the gap was real and not a ratio complaint — my own rule is to cut by reading. Cut: the round-by-round narration this PR's commits already carry ("it was written here first", "the two agreed only because", "a max wrapper added later put that panic back"), and rationale stated twice — ApportionReasoning's body re-explaining its own doc contract, childTierLabel and numTierRows each arguing that reasoning is not a tier, the anthropicUsage struct and toNeutral both explaining the pointer. Kept: the invariants, the non-obvious constraints (why the bound derives from the reservation, why the subset relation is display-only, why the present bit is not consulted), and the two places recording a decision a later reader would otherwise reverse. 70% to 66%, 42 comment lines removed, no invariant dropped. The remaining gap over baseline is invariant statements this feature genuinely needs — a subset that must not be summed, a height that must not follow its data — and cutting to hit 57% would mean cutting those. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic.go | 34 +++++------- .../authlib/plugins/inferenceparser/plugin.go | 17 +++++- .../inferenceparser/splittokens_test.go | 39 +++++++++++++ authbridge/authlib/usage/apportion.go | 55 +++++++------------ authbridge/authlib/usage/usage.go | 6 +- authbridge/cmd/abctl/cmd_cost.go | 14 ++--- authbridge/cmd/abctl/tui/spend_drawer.go | 49 +++++++---------- authbridge/cmd/abctl/tui/spend_tiers.go | 40 ++++++-------- 8 files changed, 131 insertions(+), 123 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic.go b/authbridge/authlib/plugins/inferenceparser/anthropic.go index 992ed3c35..fe9c1efc9 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic.go @@ -138,9 +138,9 @@ type anthropicUsage struct { CacheCreationInputTokens *int `json:"cache_creation_input_tokens"` CacheReadInputTokens *int `json:"cache_read_input_tokens"` - // OutputTokensDetails splits output_tokens by what generated it. Anthropic - // reports exactly one sub-field, thinking_tokens: the share of output spent on - // internal reasoning. It is a SUBSET of OutputTokens, not a sibling. + // 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"` @@ -151,11 +151,8 @@ type anthropicUsage struct { // details are observed via their pointers so an absent field stays absent in // Present. // -// Reasoning comes from output_tokens_details.thinking_tokens. This parser used to -// carry a comment asserting Anthropic does not expose reasoning; that was true -// once and is not now, and the stale comment is why the field stayed unread long -// after the wire carried it. Verified against a live claude-opus-5 turn, and -// documented at +// 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{ @@ -171,8 +168,8 @@ func (u anthropicUsage) toNeutral() parsercommon.TokenUsage { n.CacheWrite = *u.CacheCreationInputTokens n.Present |= parsercommon.KindCacheWrite } - // Both pointers are checked: a details object present but empty (a gateway that - // forwards the key without the count) reports nothing, and must not set the bit. + // 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 @@ -412,17 +409,14 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline // 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, which is what the name change records. It merged only the -// prompt side while Present was unioned here for ALL kinds, so a value and its -// presence bit travelled on different paths: reasoning's bit was set here from -// either event, but its value was merged in the message_delta branch alone. A -// gateway putting output_tokens_details on message_start would therefore set -// KindReasoning with a value of 0, and `abctl cost` would print -// "reasoning (of output) 0" — the exact claim -// TestInferenceParser_AnthropicMessages_ThinkingTokensAbsent exists to forbid. +// EVERY SUB-FIELD IT OWNS, which is what the name says: 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 gateway putting output_tokens_details on message_start +// set KindReasoning with a value of 0 — and `abctl cost` 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. +// 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. Kept beside the diff --git a/authbridge/authlib/plugins/inferenceparser/plugin.go b/authbridge/authlib/plugins/inferenceparser/plugin.go index bdb5ef776..694f38cf7 100644 --- a/authbridge/authlib/plugins/inferenceparser/plugin.go +++ b/authbridge/authlib/plugins/inferenceparser/plugin.go @@ -739,8 +739,18 @@ type inferenceUsage struct { PromptTokensDetails *struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` + // ReasoningTokens is *int so a details object carrying no count leaves + // KindReasoning CLEAR rather than asserting a reported zero — the same reason + // anthropicUsage checks both of its pointers. A gateway relaying + // completion_tokens_details without the field inside it has reported nothing, and + // a set bit with a zero value makes `abctl cost` print "reasoning (of output) 0", + // claiming the model did no reasoning. + // + // PromptTokensDetails.CachedTokens has the identical shape and the identical + // exposure; it is left alone here because changing cache-read's presence rule + // moves a figure this change is not about. CompletionTokensDetails *struct { - ReasoningTokens int `json:"reasoning_tokens"` + ReasoningTokens *int `json:"reasoning_tokens"` } `json:"completion_tokens_details"` } @@ -780,8 +790,9 @@ func (u inferenceUsage) toNeutral() parsercommon.TokenUsage { usage.CacheRead = cached usage.Present |= parsercommon.KindCacheRead } - if u.CompletionTokensDetails != nil { - usage.Reasoning = u.CompletionTokensDetails.ReasoningTokens + // BOTH POINTERS, so "details present, count absent" reports nothing. + if u.CompletionTokensDetails != nil && u.CompletionTokensDetails.ReasoningTokens != nil { + usage.Reasoning = *u.CompletionTokensDetails.ReasoningTokens usage.Present |= parsercommon.KindReasoning } return usage diff --git a/authbridge/authlib/plugins/inferenceparser/splittokens_test.go b/authbridge/authlib/plugins/inferenceparser/splittokens_test.go index 968ee1713..0661f8b8f 100644 --- a/authbridge/authlib/plugins/inferenceparser/splittokens_test.go +++ b/authbridge/authlib/plugins/inferenceparser/splittokens_test.go @@ -313,3 +313,42 @@ func TestFoldOpenAIFrame_UsageIsCumulative(t *testing.T) { t.Errorf("OutputTokens = %d, want 150 (last chunk wins)", ext.OutputTokens) } } + +// A details object with NO count inside it reports nothing, on the OpenAI path as on +// the Anthropic one. +// +// It used to set KindReasoning with a value of zero, because ReasoningTokens was a +// plain int and presence of the OBJECT was the only test — the false reported-zero +// that ..._ThinkingTokensPartiallyAbsent forbids for Anthropic. The two parsers held +// different invariants for the same wire shape until the pointer was mirrored. +// +// The reported-zero case above (TestPresentKinds_OpenAI_WithDetailsBlocks) still sets +// the bit: a count present and zero is a measurement. +func TestPresentKinds_OpenAI_DetailsWithoutTheCount(t *testing.T) { + for _, tc := range []struct{ name, details string }{ + {"empty details object", `"completion_tokens_details":{}`}, + {"explicit null count", `"completion_tokens_details":{"reasoning_tokens":null}`}, + {"unrelated sub-field only", `"completion_tokens_details":{"accepted_prediction_tokens":4}`}, + } { + t.Run(tc.name, func(t *testing.T) { + ext := &pipeline.InferenceExtension{Model: "gpt-4o"} + parseInferenceJSON([]byte(`{ + "choices":[{"message":{"content":"ok"},"finish_reason":"stop"}], + "usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15, + `+tc.details+`} + }`), ext) + + 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. + if ext.PresentKinds&uint8(parsercommon.KindOutput) == 0 { + t.Errorf("PresentKinds = %b, lost KindOutput", ext.PresentKinds) + } + }) + } +} diff --git a/authbridge/authlib/usage/apportion.go b/authbridge/authlib/usage/apportion.go index a2e7b74ec..3255d4917 100644 --- a/authbridge/authlib/usage/apportion.go +++ b/authbridge/authlib/usage/apportion.go @@ -73,50 +73,35 @@ func (c Counts) ApportionTiers() (tiers [pricing.NumTiers]int64, ok bool) { // ApportionReasoning is the reasoning share of an already-apportioned output figure, // in micros. // -// HERE RATHER THAN IN A RENDERER, for the reason ApportionTiers gives for itself: it -// is the one place this arithmetic lives, so the surfaces cannot disagree about a -// figure derived more than once. It was written inside abctl's spend drawer first, -// which left `abctl cost --json` unable to publish the number the TUI drew — a -// consumer could only get it by reimplementing this, which is what costJSON.Tiers -// refuses for the tier split. +// HERE RATHER THAN IN A RENDERER, for the reason ApportionTiers gives for itself: one +// place, so the surfaces cannot disagree about a figure derived more than once. Kept +// in this package is also what lets `abctl cost --json` publish it, instead of leaving +// a consumer to reimplement the rule — which is what costJSON.Tiers refuses for the +// tier split. // // outputMicros is the DISPLAYED output figure, not c.OutputCostMicros: the displayed // one is already scaled to the gateway's authoritative total, so deriving from the raw // mix would produce a child that does not divide into the parent beside it. // // ok is false when there is no defensible figure, and the caller renders "not known -// here" — never $0.00, which would assert the reasoning was free. Three ways to get -// there: +// here" — never $0.00, which would assert the reasoning was free: a count that is zero +// or negative, a missing denominator, or a share that truncates below one micro. // -// - no reasoning to apportion: the count is zero (nothing reported it, or it was -// reported as nothing) or negative -// - no denominator, or no output money to take a share of -// - a share that truncates below one micro, reachable on a small window +// THE PRESENT BIT IS NOT CONSULTED. It separates "nothing reported" from "reported +// zero", which matters to a renderer choosing between the not-known cell and "$0.00", +// but neither has a figure to apportion. A positive count with the bit CLEAR does +// apportion: that is a producer predating PresentKinds, where the value is the only +// evidence there is. // -// A positive count with the present bit CLEAR does apportion: that is an event from a -// producer predating PresentKinds, where the value is the only evidence there is. -// -// The result is clamped to outputMicros. Reasoning cannot exceed output on the wire, -// but a provider reporting otherwise must not produce a child figure above its parent; -// the counts themselves are left as reported — see Counts.ReasoningTokens. +// The result is clamped to outputMicros: a provider reporting reasoning above output +// must not yield a child figure above its parent, though the counts themselves are left +// as reported — see Counts.ReasoningTokens. func (c Counts) ApportionReasoning(outputMicros int64) (micros int64, ok bool) { - // NEGATIVE OR ZERO IS REFUSED, not merely zero, and refused HERE rather than left to - // plausibleTokenReport. A negative count makes the ratio negative, the upper clamp - // below does not fire and the `micros == 0` escape does not either — so a caller got - // (-595103, true) and would publish negative money or hand it to tierBar. - // - // The ingest screen does catch negatives today. That is precisely the defence addSat - // refuses for itself in this package, in words that transfer: "unreachable today" is - // how the wrap arrived in the first place, and a half-guarded figure invites a reader - // to conclude the other half was considered and ruled out. This function is exported - // and was clamped on the upper side only. - // - // THE PRESENT BIT IS NOT CONSULTED, because this test subsumes it. A `bit == 0 && - // value == 0` branch stood above and became dead the moment `<= 0` was added: every - // input reaching one fails the other. The bit distinguishes "nothing reported" from - // "reported zero", which matters to a RENDERER deciding between the not-known cell - // and "$0.00" — but not here, because neither has a figure to apportion. Callers that - // need the distinction read PresentKinds themselves. + // Guarded HERE and not left to plausibleTokenReport's ingest screen, which is the + // defence addSat refuses for itself in this package: "unreachable today" is how the + // wrap arrived, and a figure guarded on one side invites a reader to conclude the + // other was ruled out. Nothing below catches a negative — the clamp is upper-only and + // the truncation escape tests for zero. if c.ReasoningTokens <= 0 { return 0, false } diff --git a/authbridge/authlib/usage/usage.go b/authbridge/authlib/usage/usage.go index d48b1a77e..a7aecafe4 100644 --- a/authbridge/authlib/usage/usage.go +++ b/authbridge/authlib/usage/usage.go @@ -93,9 +93,9 @@ type Counts struct { // provider bug. Clamping at ingest would make every surface agree on a number // nobody measured. // - // The spend drawer DOES clamp it, because a bar drawn longer than its parent's is - // a containment claim the layout makes rather than one it relays — see - // reasoningChildRow. Numbers stay faithful; geometry is not allowed to lie. + // ApportionReasoning DOES clamp what it derives, because a bar drawn longer than + // its parent's is a containment claim the layout makes rather than one it relays. + // Numbers stay faithful; geometry is not allowed to lie. ReasoningTokens int64 `json:"reasoningTokens,omitempty"` // RefusedTokenRequests counts the requests whose token report was REJECTED as // implausible and contributed nothing to any figure above. See plausibleTokenReport for diff --git a/authbridge/cmd/abctl/cmd_cost.go b/authbridge/cmd/abctl/cmd_cost.go index 1c8dce422..06c760e79 100644 --- a/authbridge/cmd/abctl/cmd_cost.go +++ b/authbridge/cmd/abctl/cmd_cost.go @@ -344,15 +344,13 @@ type costTiersJSON struct { CacheWrite int64 `json:"cacheWrite"` CacheRead int64 `json:"cacheRead"` Output int64 `json:"output"` - // Reasoning is the share of Output spent on internal reasoning, apportioned by - // usage.ApportionReasoning — the same call the TUI's drawer draws from, so a - // consumer never has to reimplement the rule. + // Reasoning is the share of Output spent on internal reasoning, from + // usage.ApportionReasoning — the same call the drawer draws from, so a consumer never + // reimplements the rule. // - // A POINTER, and INSIDE Output rather than beside it. Absent means no defensible - // figure — nothing reported a split, or the share truncated below one micro — which - // is not the same as zero, and summing it with the four tiers above double-counts - // every reasoning token at the most expensive rate there is. The four fields still - // add up to CostMicros without it. + // A POINTER, and INSIDE Output. Absent means no defensible figure, which is not zero; + // and summing it with the four tiers double-counts, which still add to CostMicros + // without it. Reasoning *int64 `json:"reasoningOfOutput,omitempty"` } diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index 42c186f98..f1be718a6 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -62,27 +62,21 @@ const ( // "(other)" band, so the reservation is the taller of the two plus the two fixed // rows. // - // RESERVED UNCONDITIONALLY, even though the child row only renders when a provider - // reports a reasoning split. Reserving the maximum costs one row of body height on - // traffic that has no split; reserving the actual height would make the drawer's - // size depend on the data, so pressing `$` on a session that happens to report - // thinking would push the footer off the bottom — exactly the failure this comment - // already records. + // RESERVED FOR THE CHILD ROW UNCONDITIONALLY, even though it only renders when a + // provider reports a split: a height that followed the data would move the footer + // when one session happens to report reasoning and another does not. spendDrawerLines = max(tierPanelLines, spendDrawerSeries+1) + 2 ) -// spendDrawerLinesFor is the reservation at a given WIDTH, and the width is why it is -// a function where spendDrawerLines is a constant. +// spendDrawerLinesFor is the reservation at a given WIDTH. // -// The tier column only exists in two-column mode, so only there does the panel need -// room for tierPanelLines. Reserving the two-column height unconditionally cost a -// narrow terminal a body row to a child row that cannot render at that width — the -// reasoning child took the panel from 4 left rows to 5, and the one-column drawer, -// which shows only the series, grew with it for nothing. +// The tier column only exists in two columns, so only there does the panel need room +// for tierPanelLines; reserving that height unconditionally costs a narrow terminal a +// body row for a row it cannot draw. // -// Width is known wherever this is called, unlike the DATA, which is why the same -// argument does not apply to varying the height by whether a split was reported: see -// renderTierRows. +// A function rather than a constant because WIDTH is known wherever this is called. +// The same argument does not extend to varying the height by whether a split was +// reported — that is data, and the height must not follow it: see renderTierRows. func spendDrawerLinesFor(width int) int { left := spendDrawerSeries + 1 // one column: the ranked series plus "(other)" if width >= spendDrawerTwoColumnMin { @@ -759,17 +753,12 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // would emit more body rows than spendDrawerLines reserves and push the footer off // the terminal, which is the failure the block above documents. min keeps both, and // the one-column path takes tierPanelLines because tiers is nil and never indexed. - // THE BOUND IS THE RESERVATION, minus the header and the hint line — derived from - // spendDrawerLinesFor rather than restated, so the loop and the reservation cannot - // disagree about the panel's height. They did: the reservation took - // max(tierPanelLines, spendDrawerSeries+1) while this was min(tierPanelLines, - // len(tiers)), and the two agreed only because spendDrawerSeries+1 is 4 against - // tierPanelLines' 5. + // DERIVED FROM THE RESERVATION, minus the header and the hint line, so the loop and + // the reservation cannot disagree about the panel's height. // - // The min was here to stop tiers[i] reading past the end, and a max wrapper added - // later to fix the height put that panic straight back for any len(tiers) < 4. Both - // concerns are real and neither belongs in the bound: the height is a layout fact and - // the index is a slice fact, so the index is guarded where it is read. + // The height is a layout fact and the index is a slice fact: restating the height + // here as a bound over len(tiers) conflates them, and tiers[i] is guarded where it + // is read instead. bound := spendDrawerLinesFor(width) - 2 for i := 0; i < bound; i++ { // NO BRANCH GLYPHS BETWEEN THE COLUMNS' OWN ROWS. "├" and "└" once prefixed every @@ -797,10 +786,10 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // row follows it: the fourth tier row is drawn beside an empty series slot on any window // with fewer than four series, and paneView passes these straight to styleMuted.Render, // so the padding becomes styled trailing whitespace on a line nobody can see the end of. - // GUARDED, not assumed. renderTierRows returns tierPanelLines rows today, but the - // contract is held by a test in another package while this index is what crashes - // the render if it ever slips. A short tier column pads with blanks — a missing row - // is a cosmetic loss, an out-of-range read is a dead TUI. + // GUARDED, not assumed: renderTierRows' row count is a contract held in another + // package, and this index is what crashes the render if it slips. A short tier + // column pads with blanks — a missing row is cosmetic, an out-of-range read is a + // dead TUI. tier := "" if i < len(tiers) { tier = tiers[i] diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index 81d6730a4..8a7b8edfc 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -20,11 +20,10 @@ import ( // constant and every state returns exactly this many rows — enforced by the return type // rather than by a guard, see renderTierRows. // -// Four and not five: reasoning is a subset of output, not a sibling tier, so counting it -// here would double-count the same money at the most expensive rate there is. It IS shown -// — as an indented child of output, see childTierLabel — but it is not a tier, which is -// why this constant stays pinned to the rate count and tierPanelLines carries the -// rendered height. +// Four and not five: reasoning is a subset of output, so counting it here would +// double-count the same money at the most expensive rate there is. It IS shown, as an +// indented child (see childTierLabel), which is why this constant stays pinned to the +// RATE count while tierPanelLines carries the rendered height. const numTierRows = pricing.NumTiers // tierBarWidth is the widest a bar may be. Bars are decoration over a figure that is @@ -70,19 +69,15 @@ var tierLabels = map[pricing.Tier]string{ pricing.TierOutput: "output", } -// childTierLabel is the reasoning row's label, EXACTLY tierLabelWidth runes so the -// bars still start at one column whatever the mix. +// childTierLabel is the reasoning row's label, EXACTLY tierLabelWidth runes so the bars +// still start at one column whatever the mix. // -// "reasoning", not "thinking", because that is the word every other surface in this -// repo uses for it — usage.Counts.ReasoningTokens, parsercommon KindReasoning, and -// `abctl cost`'s own "reasoning (of output)" line. Anthropic's wire field is -// thinking_tokens, and that name stays where it belongs: on the JSON tag that reads -// it. +// Indented off a box-drawing stem rather than flush left, because it carries a fact the +// money column cannot: this row's dollars are already inside the row above. Flush left +// it reads as a fifth tier and the column stops adding up to the bill. // -// Indented and hung off a box-drawing stem rather than flush left, because the label -// has to carry a fact the money column cannot: this row's dollars are already inside -// the row above it. Flush left it reads as a fifth tier and the column stops adding -// up to the bill. +// "reasoning", not "thinking" — the word every other surface here uses. Anthropic's +// wire field is thinking_tokens, and that name stays on the JSON tag that reads it. const childTierLabel = " └ reasoning" // tierPanelLines is the panel's MAXIMUM height: the four tiers plus the optional @@ -212,11 +207,9 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, peak int64, budget, width int) string { notKnown := clipRow(fmt.Sprintf("%-*s %s", tierLabelWidth, childTierLabel, emptyCell), width) - // THE ARITHMETIC IS usage.ApportionReasoning'S, not this file's. It lives beside - // ApportionTiers for the reason that function states about itself — one place, so the - // drawer, `abctl cost` and the JSON cannot disagree about a figure derived three - // times. It was written here first, which left --json unable to publish the number - // this panel draws. + // THE ARITHMETIC IS usage.ApportionReasoning'S, not this file's — it sits beside + // ApportionTiers so the drawer, `abctl cost` and the JSON cannot disagree about a + // figure derived three times. // // ok from ApportionTiers gates first: with no mix to apportion by there is no output // figure to take a share of. @@ -228,13 +221,12 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, // the column. Deliberately NOT tierShares, which must keep summing to 100 across // exactly the four tiers. // - // NO CLAMP ON THE SHARE, because it is already bounded by the parent's: it derives - // from micros AFTER ApportionReasoning's clamp, and + // NO CLAMP ON THE SHARE: it derives from micros AFTER ApportionReasoning's clamp, so // // floor(micros*100/total) <= floor(tiers[output]*100/total) <= shares[output] // // the right-hand step holding because tierShares only ever ADDS its rounding - // remainder to the largest share, never subtracts. + // remainder to the largest share. pct := 0 if c.CostMicros > 0 { pct = int(micros * 100 / c.CostMicros) From e40413aac6feddb526c49728787a7cdd1ae4fb71 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Wed, 23 Sep 2026 23:05:38 -0400 Subject: [PATCH 12/24] =?UTF-8?q?fix:=20Review=20round=209=20=E2=80=94=20t?= =?UTF-8?q?he=20narrow-terminal=20fix=20was=20never=20tested?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. THE ROUND-5 CALL SITE HAD NO COVERAGE. Reverting keys.go to the flat spendDrawerLines constant left the whole tui package green: spendDrawerLinesFor is well tested, its caller was not. Writing the test against fitSizes did not fix that — it still passed reverted, because fitSizes has no entry both narrow enough for one column (< 85) and tall enough to open the drawer (>= spendDrawerMinHeight, 27): its sub-85 widths are 20 and 24 rows, so `$` never expands and every case was vacuous. Measured with a probe rather than assumed. Sizes are now chosen — 80x30, 84x40, 85x40, 120x40 — the drawer is opened through the real key, height is asserted as an EQUALITY (assertFits only tests "not taller", and over-reservation makes the view SHORTER), and the test fatals if no one-column width was exercised so it cannot go vacuous again. Mutation: "80x30 with the drawer open: view is 29 lines, want exactly 30". 2. A COMMENT DESCRIBING A REVERTED IMPLEMENTATION. Round 7 replaced the min with a bound derived from the reservation; my replacement anchored on different text and left the argument for the min in place, immediately contradicted by the paragraph below it. Deleted. 3. THE 0 > 0 HOLE WAS STILL OPEN ON THE CHILD SIDE. The liveness guard covered the PARENT's glyph count, so `child > parent` still passed when the CHILD drew nothing — 0 > 12. A child rendered with no bar at all was green. Both operands are guarded now, and the equal case pins the bars equal, which is the only way to catch a clamp that subtracts. Mutation, child rendered bar-less: previously green, now fatal. 4. The equal-case assertion was gated on `tc.name == "reasoning equal to output"`, so renaming the case would have disabled it silently. A wantEqual field instead. 5. ChildCarriesItsFigure asserted only that the cell contains "$", which passes on any amount — including one apportioned from OutputCostMicros rather than output's displayed figure, the distinction ApportionReasoning exists to make. The drawer was the one surface with no value pinned; it now asserts $3.48. 6. HeightIsConstant swept 7 widths x 5 fixtures with no split among them, so the populated child only ever rendered at tierColumnWidth and reasoningChildRow's bar-less branch never rendered at all. reasoningCounts added to the map. 7. Two guards are unreachable by construction — the tiers[i] bound check and insertAfterOutput's at := len(rows) fallback. Both are kept, on addSat's precedent, but the comments claimed a live hazard where addSat says "unreachable through the aggregator today". They say so now. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/layout_fit_test.go | 54 ++++++++++++++++++ authbridge/cmd/abctl/tui/spend_drawer.go | 20 ++----- authbridge/cmd/abctl/tui/spend_drawer_test.go | 11 +++- authbridge/cmd/abctl/tui/spend_tiers.go | 4 +- .../abctl/tui/spend_tiers_reasoning_test.go | 55 +++++++++++-------- authbridge/cmd/abctl/tui/spend_tiers_test.go | 14 +++-- 6 files changed, 114 insertions(+), 44 deletions(-) diff --git a/authbridge/cmd/abctl/tui/layout_fit_test.go b/authbridge/cmd/abctl/tui/layout_fit_test.go index 48f71acb2..b867390bd 100644 --- a/authbridge/cmd/abctl/tui/layout_fit_test.go +++ b/authbridge/cmd/abctl/tui/layout_fit_test.go @@ -461,3 +461,57 @@ func TestUsageChartHeight_MatchesTheRenderedChrome(t *testing.T) { " body=%d chart=%d", got, usagePaneChromeRows, len(body), len(chart)) } } + +// THE DRAWER'S RESERVATION IS ASSERTED AS AN EQUALITY, at widths either side of +// spendDrawerTwoColumnMin, because that is the only shape catching BOTH ways it can be +// wrong. +// +// assertFits above tests `got > m.height` — taller than the terminal. Over-reservation +// makes the view SHORTER, so holding back a row the drawer cannot draw passes every fit +// test in this file. That is what reserving the two-column height at a one-column width +// did: the reasoning child took the tier column to five rows and the one-column drawer, +// which has no tier column, grew with it. +// +// SIZES CHOSEN HERE, NOT fitSizes, and that is the whole reason this test exists. +// fitSizes has no entry that is both narrow enough for one column (< 85) and tall enough +// to open the drawer (>= spendDrawerMinHeight, 27): its sub-85 widths are 20 and 24 rows +// tall, so `$` does not expand and the case is vacuous. Written against fitSizes first, +// this test passed with the call site reverted — which is how the gap was measured +// rather than argued. +func TestLayout_DrawerReservationMatchesWhatItDraws(t *testing.T) { + forceColor(t) + narrowSeen := false + for _, dim := range [][2]int{ + {80, 30}, // one column: below spendDrawerTwoColumnMin, tall enough to open + {84, 40}, // one column, at the boundary + {85, 40}, // two columns: the first width that reaches them + {120, 40}, // two columns, comfortably + } { + w, h := dim[0], dim[1] + m := fitModel(t, paneEvents, w, h, cursorRowsFixture(60)) + m.spend.drawer.snap = reasoningSnap() + for span := spendSpan(0); span < numSpendSpans; span++ { + m.spend.chains[span].snap = reasoningSnap() + } + // Through the real key, like the filter cases: the budget changes with + // spend.expanded, so the handler has to recompute the layout. A test that set the + // flag itself would pass over a handler that forgot. + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'$'}}) + if !m.spendDrawerVisible() { + t.Fatalf("%dx%d: the drawer did not open, so this case asserts nothing", w, h) + } + if w < spendDrawerTwoColumnMin { + narrowSeen = true + } + if got := lipgloss.Height(m.View()); got != h { + t.Errorf("%dx%d with the drawer open: view is %d lines, want exactly %d — taller "+ + "pushes the footer off, shorter means a row was reserved and never drawn", + w, h, got, h) + } + } + // The narrow case is the one the reservation bug lived in; without it this test is + // the wide case twice and cannot fail on it. + if !narrowSeen { + t.Fatal("no one-column width was exercised; the over-reservation case is unasserted") + } +} diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index f1be718a6..c25b590db 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -743,16 +743,6 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // The right column's slots are filled by the `i < len(rows)` guard below, so a // column shorter than the bound pads itself rather than ending the loop early. // - // THE LOWER OF THE TWO, because the constant and the slice each guard a different - // failure and neither alone guards both. - // - // tiers[i] below is indexed bare, so renderTierRows returning FEWER rows than - // tierPanelLines is an out-of-range panic mid-render — a crashed TUI, from a - // contract held only by a test in another package. Reading the length alone fixes - // that but removes the ceiling: renderTierRows returning MORE rows (a second child) - // would emit more body rows than spendDrawerLines reserves and push the footer off - // the terminal, which is the failure the block above documents. min keeps both, and - // the one-column path takes tierPanelLines because tiers is nil and never indexed. // DERIVED FROM THE RESERVATION, minus the header and the hint line, so the loop and // the reservation cannot disagree about the panel's height. // @@ -786,10 +776,12 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // row follows it: the fourth tier row is drawn beside an empty series slot on any window // with fewer than four series, and paneView passes these straight to styleMuted.Render, // so the padding becomes styled trailing whitespace on a line nobody can see the end of. - // GUARDED, not assumed: renderTierRows' row count is a contract held in another - // package, and this index is what crashes the render if it slips. A short tier - // column pads with blanks — a missing row is cosmetic, an out-of-range read is a - // dead TUI. + // GUARDED, not assumed. UNREACHABLE TODAY — bound is len(tiers) in two columns and + // the one-column path continues above — so no test covers it, and saying so is the + // point: renderTierRows' row count is a contract held in another package, and this + // is the index that would crash the render if it slipped. A short tier column pads + // with blanks; a missing row is cosmetic where an out-of-range read is a dead TUI. + // Same standing as addSat's overflow guard in authlib/usage. tier := "" if i < len(tiers) { tier = tiers[i] diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index e80155dd2..620fde2d0 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -1924,8 +1924,15 @@ func TestRenderSpendDrawer_ChildCarriesItsFigure(t *testing.T) { if strings.Contains(child, emptyCell) { t.Errorf("child row = %q shows the not-known cell despite a reported split", child) } - if !strings.Contains(child, "$") { - t.Errorf("child row = %q carries no figure", child) + // THE FIGURE, not merely "a $". `Contains(child, "$")` passes on any amount — + // including one apportioned from OutputCostMicros instead of output's DISPLAYED + // figure, which is the distinction ApportionReasoning exists to make and the drawer + // was the one surface with no value pinned. + // + // tiers[output] = 5_852_431 at this snapshot's mix and total + // reasoningOfOutput = floor(5852431 * 948/1593) = 3_483_063 -> "$3.48" + if !strings.Contains(child, "$3.48") { + t.Errorf("child row = %q, want the apportioned $3.48", child) } } diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index 8a7b8edfc..60363ef67 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -250,7 +250,9 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, // has not learned the indent convention, so it has to follow the rank rather than // sit at a fixed line. func insertAfterOutput(rows []string, order [numTierRows]pricing.Tier, child string) []string { - at := len(rows) // fall back to last, so a missing output row cannot drop the child + // Fall back to last so a missing output row cannot drop the child. UNREACHABLE + // TODAY: order is a permutation of all four tiers, so TierOutput is always found. + at := len(rows) for i, tier := range order { if tier == pricing.TierOutput { at = i + 1 diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 304a75876..c018af616 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -124,10 +124,14 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { for _, tc := range []struct { name string c usage.Counts + // wantEqual says the child must render its parent's figure EXACTLY, which is the + // only way to catch a clamp that subtracts. A field rather than a string match on + // tc.name, so renaming the case cannot silently disable the assertion. + wantEqual bool }{ - {"well-formed", sane}, - {"reasoning reported above output", inverted}, - {"reasoning equal to output", equal}, + {name: "well-formed", c: sane}, + {name: "reasoning reported above output", c: inverted}, + {name: "reasoning equal to output", c: equal, wantEqual: true}, } { t.Run(tc.name, func(t *testing.T) { var outputPct, reasoningPct int @@ -174,27 +178,34 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { t.Errorf("the child's figure $%.4f exceeds its parent's $%.4f:\n %s\n %s", rMoney, oMoney, outputRow, reasoningRow) } - // THE COUNTER MUST COUNT, asserted before it is trusted. The first version - // of drawnBarGlyphs used an inverted rune range and returned 0 for every - // row, so the comparison below was 0 > 0 and could not fail while the - // commit message claimed it checked the bar. A dead assertion is worse than - // an absent one: it reads as coverage. This guard makes that class of - // mistake fail loudly instead of silently passing. - if drawnBarGlyphs(outputRow) == 0 { - t.Fatalf("drawnBarGlyphs counted no glyphs in %q; the bar assertion below "+ - "cannot fail", outputRow) + // BOTH OPERANDS MUST COUNT, asserted before either is trusted. A `>` + // comparison is blind on both sides: a counter returning 0 for every row makes + // it 0 > 0, and a CHILD that drew no bar makes it 0 > 12 — both pass. The + // first hole was closed by guarding the parent alone, which left the second + // open, and a child rendered with no bar at all still passed. + childBar, parentBar := drawnBarGlyphs(reasoningRow), drawnBarGlyphs(outputRow) + if parentBar == 0 || childBar == 0 { + t.Fatalf("bar glyphs: child %d, parent %d — a zero on either side makes the "+ + "comparison below unfailable:\n %s\n %s", + childBar, parentBar, outputRow, reasoningRow) } - if drawnBarGlyphs(reasoningRow) > drawnBarGlyphs(outputRow) { - t.Errorf("the child's bar is longer than its parent's:\n %s\n %s", - outputRow, reasoningRow) + if childBar > parentBar { + t.Errorf("the child's bar is %d glyphs against its parent's %d:\n %s\n %s", + childBar, parentBar, outputRow, reasoningRow) } - // THE OTHER DIRECTION, which only the equal case can witness: all of the - // output was reasoning, so the child must render its parent's figure and not - // a clamped-down one. Without this the clamp could subtract and every `>` - // above would still pass. - if tc.name == "reasoning equal to output" && rMoney != oMoney { - t.Errorf("all output was reasoning, so the child should equal its parent, "+ - "got $%.4f against $%.4f:\n %s\n %s", rMoney, oMoney, outputRow, reasoningRow) + // THE OTHER DIRECTION, which only the equal case witnesses: all of the output + // was reasoning, so the child must render its parent's figure and its parent's + // bar, not clamped-down ones. Without this a clamp that SUBTRACTS passes every + // `>` above. + if tc.wantEqual { + if rMoney != oMoney { + t.Errorf("all output was reasoning, so the child should equal its parent, "+ + "got $%.4f against $%.4f:\n %s\n %s", rMoney, oMoney, outputRow, reasoningRow) + } + if childBar != parentBar { + t.Errorf("all output was reasoning, so the bars should match, got %d "+ + "against %d:\n %s\n %s", childBar, parentBar, outputRow, reasoningRow) + } } }) } diff --git a/authbridge/cmd/abctl/tui/spend_tiers_test.go b/authbridge/cmd/abctl/tui/spend_tiers_test.go index 90406388b..ad355fe9c 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_test.go @@ -413,11 +413,15 @@ func TestRenderTierRows_ReasoningIsNotATier(t *testing.T) { // this pane by five rows and under-filled it by six. func TestRenderTierRows_HeightIsConstant(t *testing.T) { for name, c := range map[string]usage.Counts{ - "full mix": tierCounts(), - "no mix": {Requests: 35, CostMicros: 4_546_200}, - "empty": {}, - "one tier": {CostMicros: 4_546_200, OutputCostMicros: 45000}, - "negative": {CostMicros: -5, OutputCostMicros: 45000}, + // A REPORTED SPLIT among the fixtures, so the populated child is rendered at every + // width in the sweep — including the narrow ones where tierBarBudget returns 0 and + // reasoningChildRow takes its bar-less branch, which nothing else renders. + "reasoning": reasoningCounts(), + "full mix": tierCounts(), + "no mix": {Requests: 35, CostMicros: 4_546_200}, + "empty": {}, + "one tier": {CostMicros: 4_546_200, OutputCostMicros: 45000}, + "negative": {CostMicros: -5, OutputCostMicros: 45000}, } { for _, w := range []int{10, 20, 34, 46, 60, 100, 200} { got := renderTierRows(c, w) From f0aeacacd5493eb94f6902829ac99164ca326e44 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 06:52:02 -0400 Subject: [PATCH 13/24] =?UTF-8?q?fix:=20Review=20round=2010=20=E2=80=94=20?= =?UTF-8?q?the=20height=20floor=20never=20followed=20the=20drawer's=20grow?= =?UTF-8?q?th?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. spendDrawerMinHeight WAS A LITERAL THAT DID NOT MOVE. The reasoning child took the drawer from six rows to seven; the floor stayed at 27, which was spendStripMinHeight + 6 + a separator. At a 27-row terminal the table body therefore lost a row against main while the flash still told the user the breakdown needs 27. Derived now — spendStripMinHeight + spendDrawerLines + dividerLines, 28 — which is the rule spendDrawerLines' own doc states about itself. spendDrawerLines and not spendDrawerLinesFor: a floor must admit the TALLEST form, or widening the terminal would squeeze the table. A behavioural guard is added and its limits are stated in place: now that the floor is derived, any assertion against its own components is a tautology, so a one-row drift is NOT detectable by test — 14 body rows against 15, with no non-arbitrary threshold between them. What the test catches is a floor that has come loose entirely. The derivation guards the single row. 2. A TOKEN RATIO APPLIED TO A COST FIGURE, undocumented. ApportionReasoning scales output's money by ReasoningTokens/OutputTokens, which is the share of output SPEND only where every model in the window bills output at one rate — an expensive model that did no reasoning beside a cheap one that was nearly all reasoning is off by the spread between those rates, an order of magnitude rather than a rounding. ApportionTiers states its own approximation in capitals; this one said nothing. Now named at the function and in the README, which told the reader the tier rows are "modelled, not measured" without saying the child is modelled twice. 3. A RATIONALE FALSE IN THE DIRECTION IT LED WITH. "EXACTLY tierLabelWidth runes so the bars still start at one column" — fmt's %-*s PADS a short label, so shorter is harmless; only longer is a hazard, because fmt does not truncate. Shortening childTierLabel to " └ reason" leaves the alignment test passing. The assertion is a ceiling now, with the real reason, plus a measured check of the alignment it exists to protect. 4. THE CHILD'S NO-BAR BRANCH WAS UNASSERTED, and this PR exempted it: replacing tierRowsOnly's leading-space predicate with childTierLabel removed the child from TheBarYieldsBeforeTheShare. Deleting reasoningChildRow's `budget > 0` arm left the whole package green. The first version of the new test also passed under that mutation — at a budget of 0 the bar formats to nothing, so both branches produce a bar-less row and a glyph count cannot separate them. The difference is one space, which pushes the row a column over and clipRow truncates "$1.42" to "$1.4" — a figure that still parses. Asserted on the figure against the same data rendered wide. Mutation: "the child's figure is $1.4000 narrow against $1.4200 wide". Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/usage/apportion.go | 12 +++ authbridge/cmd/abctl/README.md | 6 ++ authbridge/cmd/abctl/tui/layout_fit_test.go | 42 +++++++++ authbridge/cmd/abctl/tui/spend_drawer.go | 22 +++-- authbridge/cmd/abctl/tui/spend_tiers.go | 9 +- .../abctl/tui/spend_tiers_reasoning_test.go | 85 +++++++++++++++++-- 6 files changed, 161 insertions(+), 15 deletions(-) diff --git a/authbridge/authlib/usage/apportion.go b/authbridge/authlib/usage/apportion.go index 3255d4917..b2fcf164e 100644 --- a/authbridge/authlib/usage/apportion.go +++ b/authbridge/authlib/usage/apportion.go @@ -83,6 +83,18 @@ func (c Counts) ApportionTiers() (tiers [pricing.NumTiers]int64, ok bool) { // one is already scaled to the gateway's authoritative total, so deriving from the raw // mix would produce a child that does not divide into the parent beside it. // +// A TOKEN RATIO APPLIED TO A COST FIGURE, which is a second approximation on top of +// ApportionTiers' own. Reasoning's share of output COST equals its share of output +// TOKENS only where every model in the window bills output at one rate. Across a mixed +// window it can be off by the spread between those rates — an expensive model that did +// no reasoning beside a cheap one that was nearly all reasoning is the worst case, and +// the error there is an order of magnitude, not a rounding. +// +// Accepted because there is no better source: nothing reports a ReasoningCostMicros, +// and the alternative is showing no figure at all for the component this feature exists +// to expose. Callers must present it as modelled — `abctl cost` and the drawer already +// mark the tier split that way, and the README says so for the child specifically. +// // ok is false when there is no defensible figure, and the caller renders "not known // here" — never $0.00, which would assert the reasoning was free: a count that is zero // or negative, a missing denominator, or a share that truncates below one micro. diff --git a/authbridge/cmd/abctl/README.md b/authbridge/cmd/abctl/README.md index cd267b71c..cb391c3f7 100644 --- a/authbridge/cmd/abctl/README.md +++ b/authbridge/cmd/abctl/README.md @@ -518,6 +518,12 @@ abctl is for, and the other three are surfaces you visit and leave. the tier was free. Below 85 columns the tier column drops and the panel degrades to the by-model breakdown alone. + `└ reasoning` is **modelled twice over**: its share of output comes from the token + counts, not from a cost the provider reported, so it is only the share of output + *spend* where every model in the window bills output at one rate. A mixed window can + be off by the spread between those rates. Nothing reports a reasoning cost, so this + is the best available figure rather than a measured one. + `└ reasoning` is a **child of output, not a fifth tier**. Reasoning has no rate of its own — it is the share of the generated tokens the model spent thinking, billed at the output rate — so its figure is already inside output's, and only diff --git a/authbridge/cmd/abctl/tui/layout_fit_test.go b/authbridge/cmd/abctl/tui/layout_fit_test.go index b867390bd..fd2e709c8 100644 --- a/authbridge/cmd/abctl/tui/layout_fit_test.go +++ b/authbridge/cmd/abctl/tui/layout_fit_test.go @@ -515,3 +515,45 @@ func TestLayout_DrawerReservationMatchesWhatItDraws(t *testing.T) { t.Fatal("no one-column width was exercised; the over-reservation case is unasserted") } } + +// AT THE FLOOR, THE DRAWER OPENS AND THE TABLE IS STILL USABLE — the property +// spendDrawerMinHeight exists for, in its own words: "opening it leaves the table more +// than a couple of rows". +// +// WHAT THIS CANNOT CATCH, and the reason is worth stating rather than discovering later. +// The floor is now derived (spendStripMinHeight + spendDrawerLines + dividerLines), so +// any assertion comparing it to those components is a tautology — the defect class three +// earlier rounds of review found in this package. A one-row drift is therefore not +// detectable here: with the floor at 27 against a seven-row drawer the body is 14 rows +// instead of 15, and no non-arbitrary threshold separates those. +// +// What it does catch is a floor that has come loose altogether — low enough that opening +// the drawer squeezes the table to nothing, which is the failure the constant's doc +// describes and the one that makes the drawer "a pane, badly". The derivation is what +// guards the single row. +func TestLayout_DrawerFloorLeavesAUsableTable(t *testing.T) { + forceColor(t) + const w = 120 + m := fitModel(t, paneEvents, w, spendDrawerMinHeight, cursorRowsFixture(60)) + m.spend.drawer.snap = reasoningSnap() + for span := spendSpan(0); span < numSpendSpans; span++ { + m.spend.chains[span].snap = reasoningSnap() + } + m.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'$'}}) + + // The floor is the height at which it MAY open, so it must. + if !m.spendDrawerVisible() { + t.Fatalf("the drawer did not open at spendDrawerMinHeight (%d), so the constant "+ + "promises a height it does not deliver", spendDrawerMinHeight) + } + // The whole point of the floor: data is still readable beside the breakdown. + if got := m.eventsTbl.Height(); got < spendDrawerLines { + t.Errorf("at the floor the events table is %d rows against a %d-row drawer — the "+ + "breakdown has squeezed out the data it exists to be read beside", + got, spendDrawerLines) + } + if got := lipgloss.Height(m.View()); got != spendDrawerMinHeight { + t.Errorf("at the floor the view is %d lines for a %d-line terminal", + got, spendDrawerMinHeight) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index c25b590db..b6bf55d3c 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -39,14 +39,22 @@ const ( // be a pane wearing a smaller name. spendDrawerSeries = 3 - // spendDrawerMinHeight is the terminal height at which the drawer may open. + // spendDrawerMinHeight is the terminal height at which the drawer may open: the + // strip's own floor, the drawer's rows, and the separator between them. // - // The strip's own floor is spendStripMinHeight (20) and the drawer adds five rows on - // top of it plus a separator, so 26 is the first height where opening it leaves the - // table more than a couple of rows. Below that the answer is "no", not "a table with - // two visible rows": the drawer exists to be read ALONGSIDE the data, and a drawer that - // squeezes the data out has defeated its own reason for not being a pane. - spendDrawerMinHeight = 27 + // Below it the answer is "no", not "a table with two visible rows": the drawer exists + // to be read ALONGSIDE the data, and one that squeezes the data out has defeated its + // reason for not being a pane. + // + // DERIVED, for the reason spendDrawerLines states about itself — written as a literal + // it does not follow the drawer's height. It was 27 against a six-row drawer, and the + // reasoning child made the drawer seven: the floor stayed, so at a 27-row terminal the + // table lost a body row while the flash still said the breakdown needs 27. + // + // spendDrawerLines, not spendDrawerLinesFor: a floor has to admit the TALLEST form, + // and a height rule that varied with width would let the drawer open at a size where + // widening the terminal squeezes the table. + spendDrawerMinHeight = spendStripMinHeight + spendDrawerLines + dividerLines // spendDrawerLines is how many rows the drawer adds to the view, and therefore how many // layout() must hold back for it. diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index 60363ef67..064c47ec9 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -69,8 +69,13 @@ var tierLabels = map[pricing.Tier]string{ pricing.TierOutput: "output", } -// childTierLabel is the reasoning row's label, EXACTLY tierLabelWidth runes so the bars -// still start at one column whatever the mix. +// childTierLabel is the reasoning row's label, and it must not EXCEED tierLabelWidth. +// +// Longer is the hazard: fmt's %-*s pads a short label but never truncates a long one, so +// an over-wide label pushes the share, bar and figure right and breaks the column the +// other rows align to. Shorter is harmless — it pads — which is why the assertion is a +// ceiling rather than an equality. It is written at exactly the width so the stem sits +// flush against the labels above it. // // Indented off a box-drawing stem rather than flush left, because it carries a fact the // money column cannot: this row's dollars are already inside the row above. Flush left diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index c018af616..098b9bd89 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -428,12 +428,85 @@ func TestRenderTierRows_ChildMoneyColumnAlignsWithTheTiers(t *testing.T) { } } -// childTierLabel must be EXACTLY tierLabelWidth runes, which spend_tiers.go asserts in -// a comment and nothing checked. Shorter and the bars start at two columns on that row; -// longer and it pushes the whole row right. -func TestChildTierLabel_IsExactlyTheLabelWidth(t *testing.T) { - if n := len([]rune(childTierLabel)); n != tierLabelWidth { - t.Errorf("childTierLabel %q is %d runes, want tierLabelWidth = %d", +// childTierLabel must not EXCEED tierLabelWidth, which spend_tiers.go asserted in a +// comment and nothing checked. +// +// A CEILING, NOT AN EQUALITY, because only one direction is a hazard: fmt's %-*s pads a +// short label and never truncates a long one. An earlier version of this test asserted +// equality and justified it as "shorter and the bars start at two columns", which is +// false — shortening the label to " └ reason" leaves the alignment test passing and +// fails only the tests matching the literal. +func TestChildTierLabel_FitsTheLabelWidth(t *testing.T) { + if n := len([]rune(childTierLabel)); n > tierLabelWidth { + t.Errorf("childTierLabel %q is %d runes, above tierLabelWidth %d — fmt will not "+ + "truncate it, so it pushes the share, bar and figure right", childTierLabel, n, tierLabelWidth) } + // And the alignment it exists to protect, measured rather than inferred from the width. + for _, l := range renderTierRows(reasoningCounts(), tierColumnWidth) { + if !strings.HasPrefix(l, childTierLabel) { + continue + } + if i := strings.Index(l, "%"); i >= 0 && lipgloss.Width(l[:i]) != tierLabelWidth+1+tierPctWidth-1 { + t.Errorf("the child's share cell starts at column %d, not %d:\n %q", + lipgloss.Width(l[:i]), tierLabelWidth+tierPctWidth, l) + } + } +} + +// THE CHILD'S BAR YIELDS BEFORE ITS FIGURES, the rule every tier row obeys — and the +// child was exempted from the test that enforces it. +// +// TestRenderTierRows_TheBarYieldsBeforeTheShare iterates tierRowsOnly, which filters on +// childTierLabel, so replacing its leading-space predicate with the label silently +// removed the one row this feature added from that assertion. Deleting reasoningChildRow's +// `budget > 0` arm, so the child always formats with a bar, left the whole package green. +// +// ASSERTED ON THE FIGURE, NOT ON THE ABSENCE OF GLYPHS. At a budget of 0 the bar formats +// to nothing, so both branches produce a bar-less row and a glyph count cannot tell them +// apart — the difference is one space, which pushes the row a column over and clipRow +// truncates the money cell to "$1.4". A first version of this test asserted the glyph +// count and the width and passed under exactly that mutation. +func TestRenderTierRows_ChildBarYieldsBeforeItsFigures(t *testing.T) { + narrow := tierLabelWidth + 1 + tierPctWidth + 1 + tierMoneyWidth + if tierBarBudget(narrow) > 0 { + t.Fatalf("width %d still affords a bar (budget %d), so this case asserts nothing", + narrow, tierBarBudget(narrow)) + } + atNarrow := childRows(renderTierRows(reasoningCounts(), narrow)) + atWide := childRows(renderTierRows(reasoningCounts(), tierColumnWidth)) + if len(atNarrow) != 1 || len(atWide) != 1 { + t.Fatalf("want one child row at each width, got %d and %d", len(atNarrow), len(atWide)) + } + + // THE FIGURE IS INTACT, which is what yielding the bar buys. Compared against the + // same data rendered wide: a row that kept its bar overflows by a column and clipRow + // eats the last digit, which still parses as a number. + narrowMoney, ok := rowMoney(atNarrow[0]) + if !ok { + t.Fatalf("child row %q carries no figure", atNarrow[0]) + } + wideMoney, ok := rowMoney(atWide[0]) + if !ok { + t.Fatalf("child row %q carries no figure at full width", atWide[0]) + } + if narrowMoney != wideMoney { + t.Errorf("the child's figure is $%.4f narrow against $%.4f wide — the bar did not "+ + "yield and clipRow truncated it:\n %q", narrowMoney, wideMoney, atNarrow[0]) + } + // And the share survives too. + if _, ok := sharePercent(atNarrow[0]); !ok { + t.Errorf("child row = %q lost its share when the bar yielded", atNarrow[0]) + } + // The child ends where the tiers end, or its column is not the same column. + for _, l := range renderTierRows(reasoningCounts(), narrow) { + if strings.HasPrefix(l, childTierLabel) { + continue + } + if lipgloss.Width(l) != lipgloss.Width(atNarrow[0]) { + t.Errorf("child row is %d columns and a tier row %d:\n %q\n %q", + lipgloss.Width(atNarrow[0]), lipgloss.Width(l), atNarrow[0], l) + } + break + } } From f6f9e84caa273c6cd2e2d7caa63ddc706f4653b0 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 08:26:39 -0400 Subject: [PATCH 14/24] =?UTF-8?q?fix:=20Review=20round=2011=20=E2=80=94=20?= =?UTF-8?q?pin=20the=20rank=20search=20and=20the=20README's=20threshold?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2. insertAfterOutput'S RANK SEARCH WAS UNASSERTED. Replacing it with `at := 1` left the whole tui package green: every adjacency fixture happened to rank output FIRST, so a fixed index was indistinguishable from following the rank. The committed demo SVG does exercise the non-zero case — cache-read outranks output there and the child lands at index 2 — so the behaviour was live with only a picture to prove it. A fixture that ranks output LAST, which is also the shape a cache-heavy agent turn produces, plus a guard that fails if output comes out first so the case cannot quietly stop distinguishing anything. Mutation: "output is at 3 and the child at 1; the child must follow its parent's RANK, not a fixed line". 5. THE README'S WIDTH THRESHOLD IS PINNED TO THE CONSTANT. 85 is a literal derived from seven constants, and it had already drifted once before this PR (72 against an actual 84) before this PR moved it again. A three-line test asserts the README mentions strconv.Itoa(spendDrawerTwoColumnMin) — the same idea as the demo SVG's staleness check, and deliberately weak: it cannot tell prose about the threshold from prose containing those digits, but it fails when the constant moves, which is the drift that happens. 6. MORE ARCHAEOLOGY OUT. The floor's "It was 27 against a six-row drawer … the floor stayed", the loop's "Bounded by numTierRows this loop dropped the last tier", anthropic.go's paragraph naming which bug the signature change fixed, and two comments in the reasoning test narrating their own previous versions. A reader of main needs the invariant, not the path to it — 72% prose to 70%, and the remainder is load-bearing. 4. CachedTokens stays a plain int, and no issue is filed — the maintainer's call. The comment is rewritten to stand alone: it now states the exposure concretely (`prompt_tokens_details: {}` records a cache read of nothing), names the test that pins the reported-zero half, says the fix is the same three lines, and says plainly that it is knowingly left rather than overlooked. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic.go | 9 ++- .../authlib/plugins/inferenceparser/plugin.go | 14 ++++- authbridge/cmd/abctl/tui/spend_drawer.go | 30 ++++------ authbridge/cmd/abctl/tui/spend_drawer_test.go | 27 +++++++++ .../abctl/tui/spend_tiers_reasoning_test.go | 57 +++++++++++++++---- 5 files changed, 99 insertions(+), 38 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic.go b/authbridge/authlib/plugins/inferenceparser/anthropic.go index fe9c1efc9..d9bf001da 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic.go @@ -409,11 +409,10 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline // 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, which is what the name says: 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 gateway putting output_tokens_details on message_start -// set KindReasoning with a value of 0 — and `abctl cost` prints -// "reasoning (of output) 0", the claim ThinkingTokensAbsent forbids. +// 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. diff --git a/authbridge/authlib/plugins/inferenceparser/plugin.go b/authbridge/authlib/plugins/inferenceparser/plugin.go index 694f38cf7..5e2763f67 100644 --- a/authbridge/authlib/plugins/inferenceparser/plugin.go +++ b/authbridge/authlib/plugins/inferenceparser/plugin.go @@ -746,9 +746,17 @@ type inferenceUsage struct { // a set bit with a zero value makes `abctl cost` print "reasoning (of output) 0", // claiming the model did no reasoning. // - // PromptTokensDetails.CachedTokens has the identical shape and the identical - // exposure; it is left alone here because changing cache-read's presence rule - // moves a figure this change is not about. + // PromptTokensDetails.CachedTokens ABOVE IS STILL A PLAIN int, with the identical + // shape and the identical exposure: `prompt_tokens_details: {}` sets KindCacheRead + // with a value of zero, so a gateway forwarding an empty details object is recorded + // as having reported a cache read of nothing. TestPresentKinds_OpenAI_WithDetailsBlocks + // pins the reported-zero behaviour for both fields, so the fix is the same three + // lines this field took. + // + // KNOWINGLY LEFT, not overlooked: cache-read is a priced figure on every + // OpenAI-format endpoint and moving its presence rule is a wider change than the one + // this comment sits in. Stated here because that is the only place a reader who + // touches this struct will see it. CompletionTokensDetails *struct { ReasoningTokens *int `json:"reasoning_tokens"` } `json:"completion_tokens_details"` diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index b6bf55d3c..a7cd03d6c 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -46,14 +46,12 @@ const ( // to be read ALONGSIDE the data, and one that squeezes the data out has defeated its // reason for not being a pane. // - // DERIVED, for the reason spendDrawerLines states about itself — written as a literal - // it does not follow the drawer's height. It was 27 against a six-row drawer, and the - // reasoning child made the drawer seven: the floor stayed, so at a 27-row terminal the - // table lost a body row while the flash still said the breakdown needs 27. + // DERIVED, for the reason spendDrawerLines states about itself: written as a literal it + // does not follow the drawer's height, and a floor that lags costs the table a row at + // the very size it exists to protect. // - // spendDrawerLines, not spendDrawerLinesFor: a floor has to admit the TALLEST form, - // and a height rule that varied with width would let the drawer open at a size where - // widening the terminal squeezes the table. + // spendDrawerLines, not spendDrawerLinesFor: a floor has to admit the TALLEST form, or + // widening the terminal would squeeze the table. spendDrawerMinHeight = spendStripMinHeight + spendDrawerLines + dividerLines // spendDrawerLines is how many rows the drawer adds to the view, and therefore how many @@ -64,15 +62,13 @@ const ( // full height, and layout() reserving fewer is not a cosmetic slip — the view comes out // taller than the terminal and the footer goes off the bottom, which is the failure // spendStripReservesRow's own doc describes for one row. - // Now: one HEADER row, the taller of the two columns, and the hint line. The left - // column is tierPanelLines — numTierRows plus the optional reasoning child that - // hangs under output — and the right is spendDrawerSeries ranked series plus the - // "(other)" band, so the reservation is the taller of the two plus the two fixed - // rows. // - // RESERVED FOR THE CHILD ROW UNCONDITIONALLY, even though it only renders when a - // provider reports a split: a height that followed the data would move the footer - // when one session happens to report reasoning and another does not. + // A HEADER row, the taller of the two columns, and the hint line. The left column is + // tierPanelLines and the right is spendDrawerSeries plus the "(other)" band. + // + // The child row is reserved UNCONDITIONALLY, even though it renders only when a + // provider reports a split: a height that followed the data would move the footer when + // one session reports reasoning and the next does not. spendDrawerLines = max(tierPanelLines, spendDrawerSeries+1) + 2 ) @@ -743,10 +739,6 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window out := make([]string, 0, spendDrawerLines) out = append(out, drawerHeaders(axis, twoCol, width)) - // tierPanelLines, NOT numTierRows: the left column is the four rate tiers PLUS the - // reasoning row that hangs under output. Bounded by numTierRows this loop dropped - // the last tier to make room for the child — cheapest tier first, so `input` simply - // vanished from a panel that still claimed to break down the whole bill. // // The right column's slots are filled by the `i < len(rows)` guard below, so a // column shorter than the bound pads itself rather than ending the loop early. diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index 620fde2d0..0ae2f88a8 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -6,6 +6,9 @@ import ( "math" "net/http" "net/http/httptest" + "os" + "path/filepath" + "strconv" "strings" "testing" "time" @@ -1970,3 +1973,27 @@ func TestRenderSpendDrawer_NarrowHeightIsUnchangedByTheChildRow(t *testing.T) { spendDrawerLinesFor(spendDrawerTwoColumnMin), want) } } + +// THE README'S TWO-COLUMN THRESHOLD IS PINNED TO THE CONSTANT. +// +// It is a hand-written literal derived from seven constants, and it had already drifted +// once before this PR — the prose said 72 against an actual 84 — then this PR moved the +// real value to 85 by widening tierLabelWidth for " └ reasoning". A number nothing +// checks will drift again on the next width change. +// +// The repo staleness-checks the demo SVG for the same reason; this is the same idea three +// lines wide. Asserting the number APPEARS is deliberately weak — it cannot tell prose +// about the threshold from prose that happens to contain the digits — but it fails when +// the constant moves, which is the drift that actually happens. +func TestREADME_StatesTheCurrentTwoColumnThreshold(t *testing.T) { + raw, err := os.ReadFile(filepath.Join("..", "README.md")) + if err != nil { + t.Fatalf("read README: %v", err) + } + want := strconv.Itoa(spendDrawerTwoColumnMin) + if !strings.Contains(string(raw), want) { + t.Errorf("cmd/abctl/README.md does not mention %s, the current "+ + "spendDrawerTwoColumnMin — the drawer's documented width threshold has drifted "+ + "from the code", want) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 098b9bd89..bc779dae7 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -161,10 +161,6 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { // onto the same percentage, and on this fixture an unclamped child rendered // ~2.5x output while the share check stayed green. // - // (An earlier version of this comment blamed a second clamp on the share. - // That clamp was removed as unreachable — see reasoningChildRow — so the - // reason is the floor division, not a second guard.) - // // rOK/oOK are asserted rather than used as a filter: a `&&` over them would // let this assertion skip itself the moment the child renders not-known, // which is exactly how a guard goes quiet without failing. @@ -180,9 +176,7 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { } // BOTH OPERANDS MUST COUNT, asserted before either is trusted. A `>` // comparison is blind on both sides: a counter returning 0 for every row makes - // it 0 > 0, and a CHILD that drew no bar makes it 0 > 12 — both pass. The - // first hole was closed by guarding the parent alone, which left the second - // open, and a child rendered with no bar at all still passed. + // it 0 > 0, and a CHILD that drew no bar makes it 0 > 12 — both pass.. childBar, parentBar := drawnBarGlyphs(reasoningRow), drawnBarGlyphs(outputRow) if parentBar == 0 || childBar == 0 { t.Fatalf("bar glyphs: child %d, parent %d — a zero on either side makes the "+ @@ -432,10 +426,8 @@ func TestRenderTierRows_ChildMoneyColumnAlignsWithTheTiers(t *testing.T) { // comment and nothing checked. // // A CEILING, NOT AN EQUALITY, because only one direction is a hazard: fmt's %-*s pads a -// short label and never truncates a long one. An earlier version of this test asserted -// equality and justified it as "shorter and the bars start at two columns", which is -// false — shortening the label to " └ reason" leaves the alignment test passing and -// fails only the tests matching the literal. +// short label and never truncates a long one, so a shorter label still aligns and only a +// longer one pushes the share, bar and figure right. func TestChildTierLabel_FitsTheLabelWidth(t *testing.T) { if n := len([]rune(childTierLabel)); n > tierLabelWidth { t.Errorf("childTierLabel %q is %d runes, above tierLabelWidth %d — fmt will not "+ @@ -510,3 +502,46 @@ func TestRenderTierRows_ChildBarYieldsBeforeItsFigures(t *testing.T) { break } } + +// THE CHILD FOLLOWS OUTPUT'S RANK, not a fixed line. +// +// insertAfterOutput searches tierOrder for TierOutput, and that search was unasserted: +// replacing it with `at := 1` left the whole package green, because every adjacency +// fixture happened to rank output FIRST. The committed demo SVG does exercise the +// non-zero case — cache-read outranks output there and the child lands at index 2 — so +// the behaviour was live with only a picture to prove it. +// +// The fixture below ranks output LAST (cache-read, then input, then output), which is +// also the shape a cache-heavy agent turn actually produces. +func TestRenderTierRows_ChildFollowsOutputWhereverItRanks(t *testing.T) { + c := usage.Counts{ + Requests: 3, CostMicros: 4_000, + InputCostMicros: 900, CacheReadCostMicros: 2_800, OutputCostMicros: 300, + OutputTokens: 900, ReasoningTokens: 400, + PresentKinds: uint8(usage.KindInput | usage.KindCacheRead | usage.KindOutput | usage.KindReasoning), + } + lines := renderTierRows(c, tierColumnWidth) + + outputAt, childAt := -1, -1 + for i, l := range lines { + switch { + case strings.HasPrefix(l, childTierLabel): + childAt = i + case strings.HasPrefix(strings.TrimSpace(l), "output"): + outputAt = i + } + } + if outputAt < 0 || childAt < 0 { + t.Fatalf("output at %d, child at %d:\n%s", outputAt, childAt, strings.Join(lines, "\n")) + } + // The fixture has to actually put output off the top, or this asserts what the other + // adjacency tests already do. + if outputAt == 0 { + t.Fatalf("output ranked first, so this fixture cannot distinguish a rank search "+ + "from a fixed index:\n%s", strings.Join(lines, "\n")) + } + if childAt != outputAt+1 { + t.Errorf("output is at %d and the child at %d; the child must follow its parent's "+ + "RANK, not a fixed line:\n%s", outputAt, childAt, strings.Join(lines, "\n")) + } +} From 422bf93810b6c1b5cfcc9206f031d79a4d1027d9 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 08:49:39 -0400 Subject: [PATCH 15/24] =?UTF-8?q?fix:=20Review=20round=2012=20=E2=80=94=20?= =?UTF-8?q?the=20README=20pin=20could=20not=20detect=20the=20drift=20it=20?= =?UTF-8?q?was=20for?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. A PIN THAT PINNED NOTHING. The round-11 check was strings.Contains(readme, "85"), and "$5.85" in the ASCII sample four lines above the prose supplies those digits — so the sentence could say any number and the test passed. At a drifted 86 both "186" and "8693" elsewhere in the file would have covered for it too. Worse than useless: its comment conceded the check was weak and then claimed "it fails when the constant moves", which was the false half. It closed the finding without closing the gap. Matched in context and compared now — `Below (\d+) columns` against strconv.Itoa(spendDrawerTwoColumnMin) — with a fatal if the sentence it pins has gone, so a reworded README fails loudly instead of silently unpinning. Mutations: prose at 72 → "README documents a 72-column threshold; spendDrawerTwoColumnMin is 85". Prose at 86 → same. Both were green before. 2. THE TRIM TOOK AN INVARIANT OUT WITH THE ARCHAEOLOGY. Round 11 deleted the paragraph whose first sentence was the answer to the first question a reader of `bound := spendDrawerLinesFor(width) - 2` has — what the left column's height is made of — and left the block opening on a bare `//`. The "input simply vanished" history was the part that belonged in a commit message; "tierPanelLines, not numTierRows: the four rate tiers plus the reasoning row" was not. Restored, and the dangling marker is gone. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/spend_drawer.go | 7 +++-- authbridge/cmd/abctl/tui/spend_drawer_test.go | 28 +++++++++++-------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index a7cd03d6c..fd3965cc8 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -739,9 +739,10 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window out := make([]string, 0, spendDrawerLines) out = append(out, drawerHeaders(axis, twoCol, width)) - // - // The right column's slots are filled by the `i < len(rows)` guard below, so a - // column shorter than the bound pads itself rather than ending the loop early. + // THE LEFT COLUMN IS tierPanelLines, NOT numTierRows: the four rate tiers PLUS the + // reasoning row that hangs under output. The right column's slots are filled by the + // `i < len(rows)` guard below, so whichever column is shorter pads itself rather than + // ending the loop early. // // DERIVED FROM THE RESERVATION, minus the header and the hint line, so the loop and // the reservation cannot disagree about the panel's height. diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index 0ae2f88a8..4975a0ef1 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" "strconv" "strings" "testing" @@ -1978,22 +1979,27 @@ func TestRenderSpendDrawer_NarrowHeightIsUnchangedByTheChildRow(t *testing.T) { // // It is a hand-written literal derived from seven constants, and it had already drifted // once before this PR — the prose said 72 against an actual 84 — then this PR moved the -// real value to 85 by widening tierLabelWidth for " └ reasoning". A number nothing -// checks will drift again on the next width change. +// real value to 85 by widening tierLabelWidth for " └ reasoning". A number nothing checks +// will drift again on the next width change. // -// The repo staleness-checks the demo SVG for the same reason; this is the same idea three -// lines wide. Asserting the number APPEARS is deliberately weak — it cannot tell prose -// about the threshold from prose that happens to contain the digits — but it fails when -// the constant moves, which is the drift that actually happens. +// MATCHED IN CONTEXT AND COMPARED, not searched for as a substring. A +// strings.Contains(readme, "85") version of this test was blind: "$5.85" in the ASCII +// sample four lines above the prose supplies those digits, so the sentence could say +// anything and the test still passed — and at a drifted 86 both "186" and "8693" +// elsewhere in the file would have covered for it. It closed the finding without closing +// the gap, under a comment claiming it would fail when the constant moved. func TestREADME_StatesTheCurrentTwoColumnThreshold(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "README.md")) if err != nil { t.Fatalf("read README: %v", err) } - want := strconv.Itoa(spendDrawerTwoColumnMin) - if !strings.Contains(string(raw), want) { - t.Errorf("cmd/abctl/README.md does not mention %s, the current "+ - "spendDrawerTwoColumnMin — the drawer's documented width threshold has drifted "+ - "from the code", want) + m := regexp.MustCompile(`Below (\d+) columns`).FindSubmatch(raw) + if m == nil { + t.Fatalf("cmd/abctl/README.md no longer says \"Below N columns\"; this test pins that " + + "sentence against spendDrawerTwoColumnMin and cannot find it") + } + if got, want := string(m[1]), strconv.Itoa(spendDrawerTwoColumnMin); got != want { + t.Errorf("README documents a %s-column threshold; spendDrawerTwoColumnMin is %s — "+ + "the drawer's documented width has drifted from the code", got, want) } } From ad100b523b22286bf89312b1b0c7c2225fbd9816 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 11:56:37 -0400 Subject: [PATCH 16/24] =?UTF-8?q?fix:=20Review=20round=2013=20=E2=80=94=20?= =?UTF-8?q?two=20more=20self-comparisons,=20and=20mark=20the=20child=20as?= =?UTF-8?q?=20inexact?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1+2. TWO ASSERTIONS AGAINST THE RENDERER'S OWN PADDING RULE. Both compared len(rendered) with spendDrawerLinesFor(width), which is what the renderer pads to — so neither could fail. The rule they break is stated verbatim in this PR, in spend_sanitize_test.go: "comparing against that same function compares the renderer with its own padding rule and cannot fail". I wrote that sentence and then violated it twice. Literals now (6 and 7), which are independent witnesses; one of the two checks was also redundant beside a literal already there and is gone. 3. AN ALIGNMENT LOOP THAT COULD NOT RUN. `continue` when no row carried the prefix, then `i >= 0 &&` when no "%" was found — a child that stopped rendering skipped the body and the test went green. The same filter-versus-assert defect this file names a few tests below. A found guard and a fatal on the missing share. 4. A HAND-DERIVED FIGURE THAT WAS WRONG, twice. The comment claimed floor(5852431×948/1593) = 3_483_063; the real apportionment is 3_483_542 from tiers[output] = 5_853_675, and the reviewer's correction was also off. All three hid behind Contains(child, "$3.48"), which cannot see an error under ~5,000 micros. Pinned on the micros, read off the code, with the regeneration recipe instead of a derivation chain. 5. FOUR DOCS STILL SAID layout() HOLDS BACK spendDrawerLines. It holds back spendDrawerLinesFor(width). In the file whose comment discipline is those two not drifting. 6. ONE PREDICATE. `width >= spendDrawerTwoColumnMin` was written in spendDrawerLinesFor and again in renderSpendDrawer — a height that could disagree with what was drawn. drawerTwoColumn(width) now. 7. THE CHILD WEARS inexactMarker AND NOTHING ELSE DOES. This panel dropped the glyph from every row because a marker on all of them distinguished nothing. The child re-earns one: it is modelled TWICE — ApportionTiers' mix, then a token ratio applied to a cost figure — so it is genuinely less certain than its siblings, and without the glyph it rendered identically to rows modelled once while the extra approximation lived only in the Go doc, the README and the PR body, none of which a TUI reader sees. Prefixed, per spend_strip.go's stated convention, and tierMoneyWidth grows nine to ten so the decimal points stay aligned. That cascades: tierColumnWidth 41, spendDrawerTwoColumnMin 86, README and demo asset regenerated — and the round-12 README pin caught the threshold shift on the first run, which is the first time a test of mine has caught my own drift before review did. The not-known cell wears no marker: the glyph qualifies a figure, and there is none. 8. A STREAMING REPORTED-ZERO FIXTURE. thinking_tokens: 0 on message_delta is the bit-set/value-nothing shape mergeAnthropicUsageMaxSeen's doc is built around, and only the non-streaming form was covered. Max-seen means a zero never raises the total, so the Present union is the only thing carrying the observation — a fold taking presence from the value would pass every other streaming fixture. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic_test.go | 38 ++++++++ authbridge/cmd/abctl/README.md | 19 ++-- authbridge/cmd/abctl/tui/spend_drawer.go | 27 ++++-- authbridge/cmd/abctl/tui/spend_drawer_test.go | 35 ++++--- authbridge/cmd/abctl/tui/spend_tiers.go | 38 +++++--- .../abctl/tui/spend_tiers_reasoning_test.go | 97 +++++++++++++++++-- docs/assets/cortex-demo.svg | 34 +++---- 7 files changed, 220 insertions(+), 68 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go index 26a0d9a51..315058517 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go @@ -761,3 +761,41 @@ func TestInferenceParser_AnthropicMessages_ThinkingTokensReportedZero(t *testing "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) + } +} diff --git a/authbridge/cmd/abctl/README.md b/authbridge/cmd/abctl/README.md index cb391c3f7..b65585fae 100644 --- a/authbridge/cmd/abctl/README.md +++ b/authbridge/cmd/abctl/README.md @@ -501,12 +501,12 @@ abctl is for, and the other three are surfaces you visit and leave. and who spent it, by model, endpoint or agent: ``` - WHERE IT WENT BY MODEL - output 54% ████████████ $5.85 claude-opus-5 $11.12 17 req - └ reasoning 31% ███████▏ $3.48 claude-sonnet-5 <$0.01 2 req - cache-read 35% ███████▉ $3.90 claude-haiku-4-5 <$0.01 120 req - cache-write 8% █▉ $0.98 (other) <$0.01+ 9 req - input 3% ▊ $0.39 + WHERE IT WENT BY MODEL + output 54% ████████████ $5.85 claude-opus-5 $11.12 17 req + └ reasoning 31% ███████▏ ~$3.48 claude-sonnet-5 <$0.01 2 req + cache-read 35% ███████▉ $3.90 claude-haiku-4-5 <$0.01 120 req + cache-write 8% █▉ $0.98 (other) <$0.01+ 9 req + input 3% ▊ $0.39 [a] [model] · endpoint · agent [w] 1h esc closes ``` @@ -515,11 +515,12 @@ abctl is for, and the other three are surfaces you visit and leave. because a gateway reports one number per call and never breaks it down. They are apportioned so the column sums to the window total exactly, and a tier the rate table says nothing about shows `—` rather than `$0.00`, which would claim - the tier was free. Below 85 columns the tier column drops and the panel + the tier was free. Below 86 columns the tier column drops and the panel degrades to the by-model breakdown alone. - `└ reasoning` is **modelled twice over**: its share of output comes from the token - counts, not from a cost the provider reported, so it is only the share of output + `└ reasoning` wears a `~` that no other row does, because it is **modelled twice + over**: its share of output comes from the token counts, not from a cost the provider + reported, so it is only the share of output *spend* where every model in the window bills output at one rate. A mixed window can be off by the spread between those rates. Nothing reports a reasoning cost, so this is the best available figure rather than a measured one. diff --git a/authbridge/cmd/abctl/tui/spend_drawer.go b/authbridge/cmd/abctl/tui/spend_drawer.go index fd3965cc8..ecf38a973 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -54,8 +54,9 @@ const ( // widening the terminal would squeeze the table. spendDrawerMinHeight = spendStripMinHeight + spendDrawerLines + dividerLines - // spendDrawerLines is how many rows the drawer adds to the view, and therefore how many - // layout() must hold back for it. + // spendDrawerLines is the drawer's MAXIMUM height — the two-column form. What layout() + // actually holds back is spendDrawerLinesFor(width), which is this at a width that + // affords the tier column and one row less below it. // // spendDrawerSeries named rows, plus the "(other)" band, plus the hint line. Derived rather // than written as 5 so the two cannot drift: renderSpendDrawer emits exactly this many at @@ -72,6 +73,15 @@ const ( spendDrawerLines = max(tierPanelLines, spendDrawerSeries+1) + 2 ) +// drawerTwoColumn reports whether the panel has room for the tier column beside the +// series column. +// +// ONE PREDICATE, because it decided the reservation and the render independently: the +// same `width >= spendDrawerTwoColumnMin` test was written in spendDrawerLinesFor and +// again in renderSpendDrawer, and a height that disagreed with what was drawn is the +// defect this file's whole comment discipline is about. +func drawerTwoColumn(width int) bool { return width >= spendDrawerTwoColumnMin } + // spendDrawerLinesFor is the reservation at a given WIDTH. // // The tier column only exists in two columns, so only there does the panel need room @@ -83,7 +93,7 @@ const ( // reported — that is data, and the height must not follow it: see renderTierRows. func spendDrawerLinesFor(width int) int { left := spendDrawerSeries + 1 // one column: the ranked series plus "(other)" - if width >= spendDrawerTwoColumnMin { + if drawerTwoColumn(width) { left = max(tierPanelLines, spendDrawerSeries+1) } return left + 2 // the header and the hint line @@ -268,7 +278,8 @@ func spendDrawerHostPane(pane paneID) (bool, string) { return true, "" } -// spendDrawerReservesRows reports whether layout() must hold spendDrawerLines back. +// spendDrawerReservesRows reports whether layout() must hold spendDrawerLinesFor(width) +// back. // // THE FLAG AND THE HEIGHT, deliberately blind to the pane — the same asymmetry // spendStripReservesRow has, for the same reason: layout() is called from the WindowSizeMsg @@ -278,7 +289,7 @@ func spendDrawerHostPane(pane paneID) (bool, string) { // unreserved body overflows the terminal. // // The cost is larger than the strip's: while the drawer is open, a pane that cannot host it -// renders spendDrawerLines shorter than it could. That is a visible loss where the strip's was +// renders its reservation shorter than it could. That is a visible loss where the strip's was // one invisible row, and it is still the right trade — the alternative is a footer pushed off // the bottom, and the state is transient and user-initiated. func (m *model) spendDrawerReservesRows() bool { @@ -709,7 +720,7 @@ func drawerHeaders(axis usage.Group, twoCol bool, width int) string { // failure, so without this a broken endpoint rendered as headers over blank rows forever. func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, windowLabel string, width int) []string { if err != nil { - // The reservation still has to be filled, so this is spendDrawerLines rows with the + // The reservation still has to be filled, so this is spendDrawerLinesFor(width) rows with the // diagnostic on the first and the hint line last — the hints stay because `w` and `esc` // still work, and a failed span is the moment an operator most wants to try another. out := make([]string, 0, spendDrawerLines) @@ -729,7 +740,7 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window // questions, and with a single model in the window the series column alone restated the // band's own window total and saving verbatim — a breakdown of one thing is not a // breakdown. The tier column says something in that case, which is the common one. - twoCol := width >= spendDrawerTwoColumnMin + twoCol := drawerTwoColumn(width) seriesWidth := width var tiers []string if twoCol { @@ -799,7 +810,7 @@ func renderSpendDrawer(snap *usage.Snapshot, err error, axis usage.Group, window "esc closes", ), width)) - // PADDED OUT TO THE RESERVATION. layout() holds back spendDrawerLines unconditionally, and it + // PADDED OUT TO THE RESERVATION. layout() holds back spendDrawerLinesFor(width), and it // has to: a poll can land between the layout and the render, so sizing the body to the rows // that happen to exist right now is a race against the next snapshot. Emitting fewer lines // than were reserved leaves the footer floating above the bottom of the terminal — three rows diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index 4975a0ef1..c11ce380e 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -17,6 +17,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/rossoctl/cortex/authbridge/authlib/pricing" "github.com/rossoctl/cortex/authbridge/authlib/usage" "github.com/rossoctl/cortex/authbridge/cmd/abctl/apiclient" ) @@ -1928,15 +1929,25 @@ func TestRenderSpendDrawer_ChildCarriesItsFigure(t *testing.T) { if strings.Contains(child, emptyCell) { t.Errorf("child row = %q shows the not-known cell despite a reported split", child) } - // THE FIGURE, not merely "a $". `Contains(child, "$")` passes on any amount — - // including one apportioned from OutputCostMicros instead of output's DISPLAYED - // figure, which is the distinction ApportionReasoning exists to make and the drawer - // was the one surface with no value pinned. + // THE MICROS, not the rendered cents. `Contains(child, "$3.48")` passes on any figure + // within ~5,000 micros of the right one — including one apportioned from + // OutputCostMicros instead of output's DISPLAYED figure, which is the distinction + // ApportionReasoning exists to make. // - // tiers[output] = 5_852_431 at this snapshot's mix and total - // reasoningOfOutput = floor(5852431 * 948/1593) = 3_483_063 -> "$3.48" - if !strings.Contains(child, "$3.48") { - t.Errorf("child row = %q, want the apportioned $3.48", child) + // 3_483_542 is read off the code, not derived here: two hand-derived versions of this + // number were wrong by 479 and 740 micros, both invisible behind the rounded string. + // Regenerate with ApportionReasoning(tiers[TierOutput]) on drawerTotals(reasoningSnap()). + const wantMicros = 3_483_542 + tiers, ok := drawerTotals(reasoningSnap()).ApportionTiers() + if !ok { + t.Fatal("the fixture apportions to nothing") + } + got, has := drawerTotals(reasoningSnap()).ApportionReasoning(tiers[pricing.TierOutput]) + if !has || got != wantMicros { + t.Errorf("ApportionReasoning = %d (has=%v), want %d", got, has, wantMicros) + } + if want := formatUSDTotalMicros(wantMicros); !strings.Contains(child, want) { + t.Errorf("child row = %q, want the apportioned %s", child, want) } } @@ -1959,10 +1970,10 @@ func TestRenderSpendDrawer_NarrowHeightIsUnchangedByTheChildRow(t *testing.T) { t.Errorf("one-column drawer is %d lines, want %d:\n%s", len(got), want, strings.Join(got, "\n")) } - if len(got) != spendDrawerLinesFor(narrow) { - t.Errorf("drawer emitted %d lines but the reservation for width %d is %d", - len(got), narrow, spendDrawerLinesFor(narrow)) - } + // NO SECOND CHECK AGAINST spendDrawerLinesFor(narrow). The renderer pads to + // exactly that, so it compares the renderer with its own padding rule and cannot + // fail — the pattern the sanitize test rejects by name. `want` above is the + // independent witness. // And no tier or child content leaked into the one-column form. if joined := strings.Join(got, "\n"); strings.Contains(joined, "reasoning") { t.Errorf("the one-column drawer draws the reasoning child:\n%s", joined) diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index 064c47ec9..a2e886fa8 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -48,14 +48,15 @@ const tierPctWidth = 4 // tierMoneyWidth is the money column, right-aligned so the decimal points line up. // -// Nine columns: "$16740.85" is a month of this proxy's traffic at the top tier and is the widest -// figure the panel can be asked to draw. These figures wear no disclosure markers (see -// renderTierRows), so nothing widens them beyond their digits. +// Ten columns: nine for "$16740.85" — a month of this proxy's traffic at the top tier, and the +// widest figure the panel can be asked to draw — plus one for the reasoning child's +// inexactMarker. Only that row wears a marker (see renderTierRows); the tier rows pad into the +// extra column so their decimal points stay aligned with it. // // FIXED, NOT FITTED TO THE DATA. A column sized to the widest current figure would move whenever a // total crossed a digit boundary, and this panel is polled — the same flicker the cost-descending // sort breaks ties to avoid, arriving through the layout instead of the order. -const tierMoneyWidth = 9 +const tierMoneyWidth = 10 // tierLabels names the rate tiers for a reader. // @@ -104,11 +105,20 @@ var tierOrder = [numTierRows]pricing.Tier{ // from and a block is both unambiguous and legible. Colour stays decoration — the label // carries the identity, so this survives a monochrome terminal and a screenshot. // -// NO FIGURE WEARS inexactMarker, and it is worth saying what was given up. Every figure here IS -// inexact — the mix is the rate table's while the total may be the gateway's — and each one used -// to carry the glyph saying so. It was dropped deliberately: unlike the sessions table and the -// band, this panel has no money column HEADER to move the caveat onto ("WHERE IT WENT" names the -// column, not the figures), so the choice was a glyph on every row or nothing, and nothing won. +// ONE FIGURE WEARS inexactMarker: the reasoning child, and only it. +// +// Every TIER figure here is inexact — the mix is the rate table's while the total may be the +// gateway's — and each used to carry the glyph saying so. That was dropped deliberately: this +// panel has no money column HEADER to move the caveat onto ("WHERE IT WENT" names the column, +// not the figures), so the choice was a glyph on every row or none, and a glyph on EVERY row +// carries no information — it cannot distinguish rows, which is all a reader needs it for. +// +// The child is the exception because it is modelled TWICE: ApportionTiers' mix, and then a token +// ratio applied to a cost figure, which is the share of output SPEND only where every model in +// the window bills output at one rate. It is less certain than the rows around it, so a marker +// there does distinguish something. Without it the child renders identically to siblings +// modelled once, and the extra approximation lives only in the Go doc, the README and the PR — +// none of which a reader of the TUI sees. // // What is left to carry it is this comment and the README. If a reader needs to know these are // apportioned rather than measured, a header for the money column is the thing to add — not the @@ -210,6 +220,7 @@ func renderTierRows(c usage.Counts, width int) []string { // containment reads from the indent anyway: 15% under 27% is visibly a part of it. func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, peak int64, budget, width int) string { + // No marker on the not-known cell: inexactMarker qualifies a FIGURE, and there is none. notKnown := clipRow(fmt.Sprintf("%-*s %s", tierLabelWidth, childTierLabel, emptyCell), width) // THE ARITHMETIC IS usage.ApportionReasoning'S, not this file's — it sits beside @@ -237,15 +248,18 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, pct = int(micros * 100 / c.CostMicros) } label := childTierLabel + // PREFIXED, the convention spend_strip.go states for this glyph: the marker precedes a + // figure that is not exact. Prefixing also keeps the decimal points aligned with the tier + // rows, which a trailing glyph would push out of line. + money := padLeft(inexactMarker+formatUSDTotalMicros(micros), tierMoneyWidth) var row string switch { case budget > 0: row = fmt.Sprintf("%-*s %s %-*s %s", tierLabelWidth, label, - tierShareCell(pct, micros), budget, tierBar(micros, peak, budget), - tierMoneyCell(micros)) + tierShareCell(pct, micros), budget, tierBar(micros, peak, budget), money) default: row = fmt.Sprintf("%-*s %s %s", tierLabelWidth, label, - tierShareCell(pct, micros), tierMoneyCell(micros)) + tierShareCell(pct, micros), money) } return clipRow(row, width) } diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index bc779dae7..185be500f 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -338,13 +338,14 @@ func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { if want := len(renderTierRows(reasoningCounts(), tierColumnWidth)); want != tierPanelLines { t.Errorf("the panel renders %d lines but tierPanelLines is %d", want, tierPanelLines) } - // And the drawer emits exactly its reservation FOR THAT WIDTH — not the constant, - // which is the two-column value and would let a narrow render pass short. - const w = 100 - if got, want := len(renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", w)), - spendDrawerLinesFor(w); got != want { - t.Errorf("the drawer emitted %d lines and reserves %d; either way the footer moves", - got, want) + // A LITERAL, not spendDrawerLinesFor(w): the renderer pads to exactly what that + // function returns, so comparing the two compares the renderer with its own padding + // rule and cannot fail. 7 is the two-column height — 100 clears + // spendDrawerTwoColumnMin — and it is an independent witness. + const w, wantLines = 100, 7 + if got := len(renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", w)); got != wantLines { + t.Errorf("the drawer emitted %d lines at width %d, want %d; either way the footer moves", + got, w, wantLines) } } @@ -435,15 +436,28 @@ func TestChildTierLabel_FitsTheLabelWidth(t *testing.T) { childTierLabel, n, tierLabelWidth) } // And the alignment it exists to protect, measured rather than inferred from the width. + // + // GUARDED, NOT FILTERED. This had two silent escapes — `continue` when no row carried + // the prefix, then `i >= 0 &&` when no "%" was found — so a child that stopped + // rendering skipped the body entirely and the test went green. That is the shape this + // file rejects by name a few tests down: asserted rather than used as a filter. + found := false for _, l := range renderTierRows(reasoningCounts(), tierColumnWidth) { if !strings.HasPrefix(l, childTierLabel) { continue } - if i := strings.Index(l, "%"); i >= 0 && lipgloss.Width(l[:i]) != tierLabelWidth+1+tierPctWidth-1 { - t.Errorf("the child's share cell starts at column %d, not %d:\n %q", - lipgloss.Width(l[:i]), tierLabelWidth+tierPctWidth, l) + found = true + i := strings.Index(l, "%") + if i < 0 { + t.Fatalf("the child row states no share, so its column cannot be measured:\n %q", l) + } + if got, want := lipgloss.Width(l[:i]), tierLabelWidth+tierPctWidth; got != want { + t.Errorf("the child's share cell ends at column %d, not %d:\n %q", got, want, l) } } + if !found { + t.Fatal("no child row rendered, so the alignment check above asserted nothing") + } } // THE CHILD'S BAR YIELDS BEFORE ITS FIGURES, the rule every tier row obeys — and the @@ -545,3 +559,66 @@ func TestRenderTierRows_ChildFollowsOutputWhereverItRanks(t *testing.T) { "RANK, not a fixed line:\n%s", outputAt, childAt, strings.Join(lines, "\n")) } } + +// THE CHILD WEARS inexactMarker AND THE TIERS DO NOT. +// +// The panel dropped the marker from every row because a glyph on all of them +// distinguished nothing. The child re-earns one: it is modelled twice — ApportionTiers' +// mix, then a token ratio applied to a cost figure — so it is genuinely less certain +// than its siblings, and without the glyph it renders identically to rows modelled once +// while the extra approximation lives only in prose a TUI reader never sees. +func TestRenderTierRows_OnlyTheChildWearsTheInexactMarker(t *testing.T) { + lines := renderTierRows(reasoningCounts(), tierColumnWidth) + + child := childRows(lines) + if len(child) != 1 { + t.Fatalf("want one child row, got %d", len(child)) + } + if !strings.Contains(child[0], inexactMarker) { + t.Errorf("child row = %q carries no %q; its second approximation is invisible", + child[0], inexactMarker) + } + // Prefixed, per spend_strip.go's convention for this glyph. + if !strings.Contains(child[0], inexactMarker+"$") { + t.Errorf("child row = %q does not prefix the figure with %q", child[0], inexactMarker) + } + + // And no tier row wears one, or the glyph distinguishes nothing again. + marked := 0 + for _, l := range tierRowsOnly(lines) { + if strings.Contains(l, inexactMarker) { + marked++ + t.Errorf("tier row %q wears %q; then it cannot single out the child", l, inexactMarker) + } + } + if marked == 0 && len(tierRowsOnly(lines)) == 0 { + t.Fatal("no tier rows to compare against") + } + + // The decimal points still line up, which is why the marker is prefixed and the + // column is one wider rather than the glyph trailing. + endOf := func(row string) int { + i := strings.LastIndex(row, "$") + if i < 0 { + return -1 + } + return lipgloss.Width(row[:i]) + lipgloss.Width(strings.TrimSpace(row[i:])) + } + if got, want := endOf(child[0]), endOf(tierRowsOnly(lines)[0]); got != want { + t.Errorf("the child's figure ends at column %d and a tier's at %d; the marker pushed "+ + "the column out of line:\n%s", got, want, strings.Join(lines, "\n")) + } +} + +// The not-known cell wears NO marker: inexactMarker qualifies a figure, and there is none +// to qualify. A glyph there would claim an inexact number where the claim is that there +// is no number. +func TestRenderTierRows_NotKnownChildWearsNoMarker(t *testing.T) { + child := childRows(renderTierRows(tierCounts(), tierColumnWidth)) + if len(child) != 1 { + t.Fatalf("want one child row, got %d", len(child)) + } + if strings.Contains(child[0], inexactMarker) { + t.Errorf("child row = %q wears %q with no figure to qualify", child[0], inexactMarker) + } +} diff --git a/docs/assets/cortex-demo.svg b/docs/assets/cortex-demo.svg index adb6e96d0..478d6ca28 100644 --- a/docs/assets/cortex-demo.svg +++ b/docs/assets/cortex-demo.svg @@ -239,12 +239,12 @@ clipPath rect{width:100000px} abctl · http://127.0.0.1:9094 LAST 1H $0.65 · TODAY $0.65 · 7 DAYS $0.79 · THIS MONTH $0.79 - WHERE IT WENT BY MODEL - cache-read 43% ████████████ $0.27 claude-opus-5 $0.63 4 req 319k tokens - output 31% █████████ $0.20 claude-sonnet-5 $0.02 1 req 16k tokens - └ reasoning 17% ████▉ $0.11 - cache-write 21% ██████ $0.14 - input 5% █▍ $0.03 + WHERE IT WENT BY MODEL + cache-read 43% ████████████ $0.27 claude-opus-5 $0.63 4 req 319k tokens + output 31% █████████ $0.20 claude-sonnet-5 $0.02 1 req 16k tokens + └ reasoning 17% ████▉ ~$0.11 + cache-write 21% ██████ $0.14 + input 5% █▍ $0.03 [a] [model] · endpoint · agent [w] LAST 1H esc closes ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── SESSION TITLE UPDATED EVENTS TOKENS COST SAVED~CONTEXT(1M) @@ -280,19 +280,19 @@ clipPath rect{width:100000px} USAGE — all sessions — 10m0s @ 1m0s — tokens — ungrouped tok - 209k ████ - ████ - 167k ████ - ████ - 125k ████ ████ - ████ ████ - 83k ████ ████ - ████ ████ - 41k ████ ████ - ████ ████ + 209k ████ + ████ + 167k ████ + ████ + 125k ████ ████ + ████ ████ + 83k ████ ████ + ████ ████ + 41k ████ ████ + ████ ████ 0 ┼────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┴───── 42:24 :24 :24 :24 :24 - 0 126k 0 0 0 0 0 0 209k 0 + 0 0 126k 0 0 0 0 0 0 209k REQUESTS 5 ERRORS 0 (0.0%) TOKENS 335k LATENCY 2.90s COST $0.65 [unlabelled] From 8d1cc5eb34363614e27801d8543acb0af615388a Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 12:28:59 -0400 Subject: [PATCH 17/24] =?UTF-8?q?fix:=20Review=20round=2014=20=E2=80=94=20?= =?UTF-8?q?a=20reported=20zero=20is=20a=20measurement,=20not=20an=20absenc?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. THE OPERATOR DOC NOW RECORDS THE BEHAVIOUR CHANGE. session-budget-plugin.md is what an operator reads to decide whether to set max_reasoning_tokens, and there is no CHANGELOG in this repo, so it is the in-tree home for the note. The field was inert on Anthropic traffic and is not any more; with on_exceed at its 'deny' default those sessions start receiving 403s with no config change of theirs. 2. A REPORTED ZERO RENDERS $0.00, NOT '—'. The drawer took the not-known cell for a measured zero, copying the 'tiers[tier] == 0' escape the tier rows make. Wrong borrowing: a TIER apportioning to zero is absent from the modelled mix, so its figure is unknown, while a reasoning count of zero means the provider measured the split and it was nothing. abctl cost's token line already printed 'reasoning (of output) 0' for the same Counts, so two surfaces told different stories about one measured fact — and ApportionReasoning's own doc framed the present bit as what 'matters to a renderer choosing between the not-known cell and $0.00' while no renderer made that choice. No marker on it either: zero tokens cost zero at any rate, so this is the one child figure with no approximation in it. REVERSES A DECISION THIS PR PINNED. The test asserting the not-known cell is rewritten, and says so. 3. The README's retained 'these rows carry no ~' contradicted the new paragraph three above it. Scoped to the four tier rows. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/authlib/usage/apportion.go | 7 +-- authbridge/cmd/abctl/README.md | 20 +++++---- authbridge/cmd/abctl/tui/spend_tiers.go | 21 +++++++++ .../abctl/tui/spend_tiers_reasoning_test.go | 44 +++++++++++++++---- authbridge/docs/session-budget-plugin.md | 14 +++++- 5 files changed, 85 insertions(+), 21 deletions(-) diff --git a/authbridge/authlib/usage/apportion.go b/authbridge/authlib/usage/apportion.go index b2fcf164e..6a1964ca0 100644 --- a/authbridge/authlib/usage/apportion.go +++ b/authbridge/authlib/usage/apportion.go @@ -99,9 +99,10 @@ func (c Counts) ApportionTiers() (tiers [pricing.NumTiers]int64, ok bool) { // here" — never $0.00, which would assert the reasoning was free: a count that is zero // or negative, a missing denominator, or a share that truncates below one micro. // -// THE PRESENT BIT IS NOT CONSULTED. It separates "nothing reported" from "reported -// zero", which matters to a renderer choosing between the not-known cell and "$0.00", -// but neither has a figure to apportion. A positive count with the bit CLEAR does +// THE PRESENT BIT IS NOT CONSULTED HERE, because neither "nothing reported" nor +// "reported zero" has a figure to apportion. The bit separates them, and a caller that +// needs to — the spend drawer does, to choose between the not-known cell and an exact +// "$0.00" — reads PresentKinds itself. A positive count with the bit CLEAR does // apportion: that is a producer predating PresentKinds, where the value is the only // evidence there is. // diff --git a/authbridge/cmd/abctl/README.md b/authbridge/cmd/abctl/README.md index b65585fae..6da892d92 100644 --- a/authbridge/cmd/abctl/README.md +++ b/authbridge/cmd/abctl/README.md @@ -529,14 +529,18 @@ abctl is for, and the other three are surfaces you visit and leave. of its own — it is the share of the generated tokens the model spent thinking, billed at the output rate — so its figure is already inside output's, and only the four unindented rows sum to the window total. Its share is denominated in - that same total, which is what makes `31% ⊂ 54%` read as containment. The row is - always present and shows `—` when no reasoning split was reported — either because - the endpoint does not report one, or because the apportioned share fell below a - micro. Anthropic reports it as `output_tokens_details.thinking_tokens` and - OpenAI-format endpoints as `completion_tokens_details.reasoning_tokens`; both are - read. - - These rows carry **no** `~`, unlike the sessions table's `SAVED~`. The caveat is + that same total, which is what makes `31% ⊂ 54%` read as containment. + + The row is always present, in one of three states. A reported split shows its figure + with a `~`. A split reported as **zero** shows an exact `$0.00` and no `~` — the model + was asked to think and spent nothing on it, which is a measurement and the reading that + says an effort setting is not reaching the model. `—` means no figure: the endpoint + reports no split, or the apportioned share fell below a micro. Anthropic reports the + count as `output_tokens_details.thinking_tokens` and OpenAI-format endpoints as + `completion_tokens_details.reasoning_tokens`; both are read. + + The four **tier** rows carry **no** `~`, unlike the sessions table's `SAVED~` + (the `└ reasoning` child does, for the reason given above). The caveat is real but this panel has no money heading to hang it on — `WHERE IT WENT` names the column, not the figures — so the choice was a tilde on every row or the sentence above, and the sentence won. diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index a2e886fa8..ead4b0d06 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -229,6 +229,27 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, // // ok from ApportionTiers gates first: with no mix to apportion by there is no output // figure to take a share of. + // A REPORTED ZERO IS A MEASUREMENT, and it renders $0.00 — exactly, with no marker. + // + // Checked BEFORE ApportionReasoning, which refuses a zero count along with every + // other case it has no figure for. This row used to take the not-known cell here, + // copying the `tiers[tier] == 0` escape the tier rows make — the wrong borrowing. A + // TIER apportioning to zero is absent from the modelled mix, so its figure is + // unknown; a reasoning count of zero means the provider measured the split and it + // was nothing. "—" for a value we have discards it, and `abctl cost`'s token line + // prints "reasoning (of output) 0" for the same Counts, so the two surfaces told + // different stories about one measured fact. + // + // NO inexactMarker: zero tokens cost zero whatever the output rate, so this is the + // one child figure that is not modelled at all. The marker qualifies the token-ratio + // approximation and there is no ratio here to qualify. + // + // It is also the observation worth having — effort reached the model and bought no + // reasoning — which is unreadable as "—". + if ok && c.PresentKinds&usage.KindReasoning != 0 && c.ReasoningTokens == 0 { + return clipRow(fmt.Sprintf("%-*s %s %s", tierLabelWidth, childTierLabel, + tierShareCell(0, 0), tierMoneyCell(0)), width) + } micros, hasFigure := c.ApportionReasoning(tiers[pricing.TierOutput]) if !ok || !hasFigure { return notKnown diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 185be500f..7b712272c 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -349,22 +349,48 @@ func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { } } -// A REPORTED ZERO through the whole renderer, which is the surface carrying the -// "$0.00 is a lie" rule. ApportionReasoning refuses the figure, and what matters here -// is what the panel does with that refusal: the not-known cell, never $0.00, and the -// row still present so the height does not follow the data. -func TestRenderTierRows_ReportedZeroIsNotKnownNotFree(t *testing.T) { +// A REPORTED ZERO RENDERS $0.00, EXACTLY, AND WEARS NO MARKER. +// +// REVERSES A DECISION THIS TEST USED TO PIN. It asserted the not-known cell, on the +// grounds that "$0.00 is a lie" — the rule the tier rows follow for `tiers[tier] == 0`. +// That borrowing was wrong: a TIER apportioning to zero is absent from the modelled mix, +// so its figure is unknown, while a reasoning count of zero means the provider MEASURED +// the split and it was nothing. "—" for a value we have discards it. +// +// It also split the surfaces: `abctl cost`'s token line prints "reasoning (of output) 0" +// for the same Counts, so the drawer and the CLI told different stories about one +// measured fact — and ApportionReasoning's own doc frames the present bit as what +// "matters to a renderer choosing between the not-known cell and $0.00", while no +// renderer was making that choice. +// +// No marker either: zero tokens cost zero whatever the output rate, so this is the one +// child figure with no token-ratio approximation in it for a marker to qualify. +func TestRenderTierRows_ReportedZeroIsTheMeasurement(t *testing.T) { c := reasoningCounts() c.ReasoningTokens = 0 // measured, and measured as nothing: the bit stays set child := childRows(renderTierRows(c, tierColumnWidth)) if len(child) != 1 { t.Fatalf("want one child row for a reported zero, got %d", len(child)) } - if strings.Contains(child[0], "$0.00") { - t.Errorf("child row = %q asserts the reasoning was free", child[0]) + if strings.Contains(child[0], emptyCell) { + t.Errorf("child row = %q shows the not-known cell for a MEASURED zero; the CLI's "+ + "token line prints 0 for the same Counts", child[0]) } - if !strings.Contains(child[0], emptyCell) { - t.Errorf("child row = %q, want the not-known cell", child[0]) + if got, ok := rowMoney(child[0]); !ok || got != 0 { + t.Errorf("child row = %q, want a $0.00 figure (parsed %v, ok=%v)", child[0], got, ok) + } + if strings.Contains(child[0], inexactMarker) { + t.Errorf("child row = %q wears %q; a zero costs zero at any rate, so nothing here "+ + "is modelled", child[0], inexactMarker) + } + // An UNREPORTED split is still the not-known cell — that is the distinction the + // present bit exists to carry, and this is the half that must not move. + unreported := reasoningCounts() + unreported.ReasoningTokens = 0 + unreported.PresentKinds = uint8(usage.KindOutput) + if got := childRows(renderTierRows(unreported, tierColumnWidth)); len(got) != 1 || + !strings.Contains(got[0], emptyCell) { + t.Errorf("an unreported split rendered %q, want the not-known cell", got) } } diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 81169c58d..186054e23 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -50,7 +50,7 @@ pipeline: | `max_cache_read_tokens` | 0 | Per-kind ceiling on prompt tokens served from cache. 0 = no limit. | | `max_cache_write_tokens` | 0 | Per-kind ceiling on prompt tokens written to cache. 0 = no limit. | | `max_output_tokens` | 0 | Per-kind ceiling on generated completion tokens. 0 = no limit. | -| `max_reasoning_tokens` | 0 | Per-kind ceiling on reasoning-only output tokens (subset of output). 0 = no limit. | +| `max_reasoning_tokens` | 0 | Per-kind ceiling on reasoning-only output tokens (subset of output). 0 = no limit. See note below — this limit was inert on Anthropic traffic until recently. | | `max_calls` | 0 | LLM/inference call cap (from `inference-parser`); MCP, A2A, and other outbound traffic do not count. 0 = no limit. See note below on enforcement scope. | | `max_duration_seconds` | 0 | Session lifetime cap (0 = no limit) | | `on_exceed` | `deny` | `deny` (403), `observe` (log only), or `pause` (webhook) | @@ -65,6 +65,18 @@ pipeline: At least one of `max_tokens`, `max_input_tokens`, `max_cache_read_tokens`, `max_cache_write_tokens`, `max_output_tokens`, `max_reasoning_tokens`, `max_calls`, `max_duration_seconds` must be > 0. +**`max_reasoning_tokens` was inert on Anthropic traffic, and is not any more.** +`inference-parser` did not read Anthropic's +`usage.output_tokens_details.thinking_tokens`, so `ReasoningTokens` was always 0 on +that path and this limit could never be reached however low it was set. The parser +reads it now, which makes the counter real **without any config change of yours**. + +If you set `max_reasoning_tokens` against Claude traffic and saw no effect, that was +why — and with `on_exceed` at its `deny` default those sessions will now start +receiving 403s once they cross it. Audit any value currently set before upgrading. +OpenAI-format endpoints were never affected: that path has always read +`completion_tokens_details.reasoning_tokens`. + **`max_calls` enforcement scope.** Only inference calls surfaced by `inference-parser` increment the counter, but the limit check runs on every outbound request. Once the LLM counter crosses `max_calls`, the From 31cecea1460a746845f977416225ac2bd11c936f Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 12:32:03 -0400 Subject: [PATCH 18/24] fix: Self-review caught a misaligned reported-zero row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reported-zero branch added a moment ago formatted the row on its own no-bar Sprintf, so its figure landed at column 28 against the tiers' 41 — the bar SLOT is padding the other rows carry whether or not they fill it. Nothing caught it: TestRenderTierRows_ChildMoneyColumnAlignsWithTheTiers renders reasoningCounts(), whose count is non-zero, so the fixture could not reach the branch. That is the same shape as a clamp no fixture crosses. The state now goes through the shared bar/no-bar switch with micros=0, and the alignment test runs both child states that carry a figure. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/spend_tiers.go | 50 +++++++++++-------- .../abctl/tui/spend_tiers_reasoning_test.go | 22 +++++++- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/authbridge/cmd/abctl/tui/spend_tiers.go b/authbridge/cmd/abctl/tui/spend_tiers.go index ead4b0d06..ba0cadad3 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -229,30 +229,32 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, // // ok from ApportionTiers gates first: with no mix to apportion by there is no output // figure to take a share of. - // A REPORTED ZERO IS A MEASUREMENT, and it renders $0.00 — exactly, with no marker. + // A REPORTED ZERO IS A MEASUREMENT, and it renders an exact $0.00. // - // Checked BEFORE ApportionReasoning, which refuses a zero count along with every - // other case it has no figure for. This row used to take the not-known cell here, - // copying the `tiers[tier] == 0` escape the tier rows make — the wrong borrowing. A - // TIER apportioning to zero is absent from the modelled mix, so its figure is - // unknown; a reasoning count of zero means the provider measured the split and it - // was nothing. "—" for a value we have discards it, and `abctl cost`'s token line - // prints "reasoning (of output) 0" for the same Counts, so the two surfaces told - // different stories about one measured fact. - // - // NO inexactMarker: zero tokens cost zero whatever the output rate, so this is the - // one child figure that is not modelled at all. The marker qualifies the token-ratio - // approximation and there is no ratio here to qualify. + // Checked BEFORE ApportionReasoning, which refuses a zero count along with every case + // it has no figure for. This row used to take the not-known cell here, copying the + // `tiers[tier] == 0` escape the tier rows make — the wrong borrowing. A TIER + // apportioning to zero is absent from the modelled mix, so its figure is unknown; a + // reasoning count of zero means the provider measured the split and it was nothing. + // "—" for a value we have discards it, and `abctl cost`'s token line prints + // "reasoning (of output) 0" for the same Counts, so the two surfaces told different + // stories about one measured fact. // // It is also the observation worth having — effort reached the model and bought no // reasoning — which is unreadable as "—". - if ok && c.PresentKinds&usage.KindReasoning != 0 && c.ReasoningTokens == 0 { - return clipRow(fmt.Sprintf("%-*s %s %s", tierLabelWidth, childTierLabel, - tierShareCell(0, 0), tierMoneyCell(0)), width) - } - micros, hasFigure := c.ApportionReasoning(tiers[pricing.TierOutput]) - if !ok || !hasFigure { - return notKnown + // + // FORMATTED THROUGH THE SAME SWITCH as every other state, not on its own path. A + // separate no-bar Sprintf here put the figure at column 28 against the tiers' 41, + // because the bar SLOT is padding the other rows carry whether or not they fill it. + reportedZero := ok && c.PresentKinds&usage.KindReasoning != 0 && c.ReasoningTokens == 0 + + micros := int64(0) + if !reportedZero { + var hasFigure bool + micros, hasFigure = c.ApportionReasoning(tiers[pricing.TierOutput]) + if !ok || !hasFigure { + return notKnown + } } // Floored against the same total the tier rows use, so the child is comparable down // the column. Deliberately NOT tierShares, which must keep summing to 100 across @@ -272,7 +274,13 @@ func reasoningChildRow(c usage.Counts, tiers [pricing.NumTiers]int64, ok bool, // PREFIXED, the convention spend_strip.go states for this glyph: the marker precedes a // figure that is not exact. Prefixing also keeps the decimal points aligned with the tier // rows, which a trailing glyph would push out of line. - money := padLeft(inexactMarker+formatUSDTotalMicros(micros), tierMoneyWidth) + // + // NOT ON A REPORTED ZERO: zero tokens cost zero whatever the output rate, so that is the + // one child figure with no token-ratio approximation in it for a marker to qualify. + money := tierMoneyCell(micros) + if !reportedZero { + money = padLeft(inexactMarker+formatUSDTotalMicros(micros), tierMoneyWidth) + } var row string switch { case budget > 0: diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 7b712272c..b58b5e924 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -421,7 +421,27 @@ func TestRenderTierRows_NegativeReasoningIsRefused(t *testing.T) { // The child is the row most likely to break it — its label is exactly // tierLabelWidth and was what forced that constant from 11 to 12. func TestRenderTierRows_ChildMoneyColumnAlignsWithTheTiers(t *testing.T) { - lines := renderTierRows(reasoningCounts(), tierColumnWidth) + // EVERY CHILD STATE THAT CARRIES A FIGURE, because the fixture decides which branch + // is measured. With only the apportioned case, a reported zero formatted on its own + // no-bar path put its figure at column 28 against the tiers' 41 and nothing failed. + reportedZero := reasoningCounts() + reportedZero.ReasoningTokens = 0 + for _, tc := range []struct { + name string + c usage.Counts + }{ + {"apportioned figure", reasoningCounts()}, + {"reported zero", reportedZero}, + } { + t.Run(tc.name, func(t *testing.T) { + assertChildFigureAligns(t, tc.c) + }) + } +} + +func assertChildFigureAligns(t *testing.T, counts usage.Counts) { + t.Helper() + lines := renderTierRows(counts, tierColumnWidth) endOf := func(row string) int { i := strings.LastIndex(row, "$") From 50782a5238bb6491fcd84f948aa9b68b851d42b4 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 13:57:08 -0400 Subject: [PATCH 19/24] =?UTF-8?q?fix:=20Review=20round=2015=20=E2=80=94=20?= =?UTF-8?q?three=20classes=20swept,=20not=20six=20locations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A strict re-review of the whole diff found 6 must-fixes. All three classes were swept repo-wide by shape before any edit, and the sweeps found no siblings beyond the instances reported, so each class is closed rather than sampled. No production lines, no new files, no new exported symbols. DERIVED-CONSTANT (swept 20 sites, fixed 4) spendDrawerTwoColumnMin is 86 and spendDrawerMinHeight is 28 — measured at HEAD via an in-package probe, not derived by hand. Comments added in rounds 6, 9 and 12 still said 85 and 27. The other 16 hits were accurate history ("was 34", "from 11 to 12") and are left alone. layout_fit_test.go carried a live defect, not just a stale number: its {84,40} and {85,40} cases were BOTH one-column widths, so the two-column half of the sweep rested entirely on {120,40} and the boundary that test's own doc calls its reason to exist was never crossed. Now {85,40}/{86,40}, and a wideSeen guard makes an all-narrow table fail instead of pass quietly. SELF-CONSISTENCY (2 sites) anthropic_test.go asserted "the bit must never be set with a zero value: that is the reported-zero lie" — the exact inverse of the design, which requires the bit SET on a reported zero. Inert only because that fixture reports 119. Removed; the split-brain bug it was aimed at is already caught by the value assertion above it, re-proved below. TestRenderTierRows_MarksNoFigureInexact claimed the panel wears the marker "nowhere on a row", which this PR made false when it gave the reasoning child one. Verified by swapping the fixture: it fails on ~$1.42. Scoped to tierRowsOnly, where the claim is true; the complement is already pinned by OnlyTheChildWearsTheInexactMarker. UNFAILABLE-ASSERTION (1 site) spend_tiers_reasoning_test.go compared reasoningPct > outputPct with neither operand asserted, 23 lines above the comment stating that rule and enforcing it for the bars. A first attempt guarded `outputPct == 0` and was itself dead: tierShareCell floors any tier holding money to "<1%", which sharePercent reads back as 1, so that value is unreachable while the row exists. Measured, then replaced with a guard on the ROW, which is the reachable degenerate case. Mutation table — this round's new assertions | New assertion | Mutation | Verdict | |--------------------------|---------------------------------------|---------| | wideSeen guard | all four widths made one-column | killed | | rescoped tierRowsOnly | every tier money cell wears the marker| killed | | outputRow guard | output tier label "output"->"completion"| killed | The four comment-only fixes add no assertions, so there is nothing to mutate. Backward sweep — 9 prior-round mutations re-run against this tree, all still killed: presence-bit-uncond, maxseen-to-assign, reasoning-merge-gone (these three matter most, since an assertion was deleted from anthropic_test.go), reportedzero-drops-bit, reportedzero-to-notknown, marker-on-reported-zero, twocolumn-off-by-one, bound-numtierrows, rank-search-fixed-index. Deferred, both pre-existing and recorded rather than fixed: the t.Logf vacuity "guard" in TestRenderTierRows_MoneyIsRightAligned (from 71c0f20a), and a stale mergeAnthropicPromptMaxSeen reference in a dated design spec. Stop rule: 8 of 9 findings traced to earlier review rounds rather than to the feature, against a 50% threshold. This is the last fix round on this PR; anything found after it goes to an issue. Signed-off-by: Hai Huang --- .../plugins/inferenceparser/anthropic_test.go | 14 ++++++++--- authbridge/cmd/abctl/tui/layout_fit_test.go | 25 +++++++++++++++---- authbridge/cmd/abctl/tui/spend_drawer_test.go | 14 +++++------ .../cmd/abctl/tui/spend_sanitize_test.go | 2 +- .../abctl/tui/spend_tiers_reasoning_test.go | 13 ++++++++++ authbridge/cmd/abctl/tui/spend_tiers_test.go | 13 +++++++--- 6 files changed, 61 insertions(+), 20 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go index 315058517..80450d8a9 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go @@ -725,10 +725,16 @@ func TestInferenceParser_AnthropicMessages_ThinkingTokensOnMessageStart(t *testi if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 { t.Errorf("PresentKinds = %#b, want KindReasoning set", ext.PresentKinds) } - // The bit must never be set with a zero value: that is the reported-zero lie. - if ext.PresentKinds&uint8(parsercommon.KindReasoning) != 0 && ext.ReasoningTokens == 0 { - t.Error("KindReasoning is set with a value of 0; presence and value diverged") - } + // 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 diff --git a/authbridge/cmd/abctl/tui/layout_fit_test.go b/authbridge/cmd/abctl/tui/layout_fit_test.go index fd2e709c8..70056c634 100644 --- a/authbridge/cmd/abctl/tui/layout_fit_test.go +++ b/authbridge/cmd/abctl/tui/layout_fit_test.go @@ -473,18 +473,23 @@ func TestUsageChartHeight_MatchesTheRenderedChrome(t *testing.T) { // which has no tier column, grew with it. // // SIZES CHOSEN HERE, NOT fitSizes, and that is the whole reason this test exists. -// fitSizes has no entry that is both narrow enough for one column (< 85) and tall enough -// to open the drawer (>= spendDrawerMinHeight, 27): its sub-85 widths are 20 and 24 rows +// fitSizes has no entry that is both narrow enough for one column (< 86) and tall enough +// to open the drawer (>= spendDrawerMinHeight, 28): its sub-86 widths are 20 and 24 rows // tall, so `$` does not expand and the case is vacuous. Written against fitSizes first, // this test passed with the call site reverted — which is how the gap was measured // rather than argued. func TestLayout_DrawerReservationMatchesWhatItDraws(t *testing.T) { forceColor(t) - narrowSeen := false + narrowSeen, wideSeen := false, false + // BOTH SIDES OF THE BOUNDARY, at spendDrawerTwoColumnMin-1 and spendDrawerTwoColumnMin + // themselves. Written as 84 and 85 these were BOTH one-column widths — the constant is + // 86 — so the two-column half of the sweep rested entirely on {120,40} and the boundary + // the comment above calls this test's reason to exist was never crossed. The guards at + // the end are what stop that recurring silently. for _, dim := range [][2]int{ {80, 30}, // one column: below spendDrawerTwoColumnMin, tall enough to open - {84, 40}, // one column, at the boundary - {85, 40}, // two columns: the first width that reaches them + {85, 40}, // one column, at the boundary: spendDrawerTwoColumnMin-1 + {86, 40}, // two columns: the first width that reaches them {120, 40}, // two columns, comfortably } { w, h := dim[0], dim[1] @@ -502,6 +507,8 @@ func TestLayout_DrawerReservationMatchesWhatItDraws(t *testing.T) { } if w < spendDrawerTwoColumnMin { narrowSeen = true + } else { + wideSeen = true } if got := lipgloss.Height(m.View()); got != h { t.Errorf("%dx%d with the drawer open: view is %d lines, want exactly %d — taller "+ @@ -514,6 +521,14 @@ func TestLayout_DrawerReservationMatchesWhatItDraws(t *testing.T) { if !narrowSeen { t.Fatal("no one-column width was exercised; the over-reservation case is unasserted") } + // AND THE WIDE HALF, for the same reason in the other direction. Both halves were + // nominally covered while every width above was under the constant, so the sweep had + // silently become the narrow case four times. Asserting reachability is cheaper than + // rederiving the boundary by hand every time a column width moves. + if !wideSeen { + t.Fatalf("no width reached two columns (spendDrawerTwoColumnMin is %d); the tier "+ + "column's reservation is unasserted", spendDrawerTwoColumnMin) + } } // AT THE FLOOR, THE DRAWER OPENS AND THE TABLE IS STILL USABLE — the property diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index c11ce380e..742cd3610 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -1990,15 +1990,15 @@ func TestRenderSpendDrawer_NarrowHeightIsUnchangedByTheChildRow(t *testing.T) { // // It is a hand-written literal derived from seven constants, and it had already drifted // once before this PR — the prose said 72 against an actual 84 — then this PR moved the -// real value to 85 by widening tierLabelWidth for " └ reasoning". A number nothing checks -// will drift again on the next width change. +// real value to 86 by widening tierLabelWidth AND tierMoneyWidth for " └ reasoning". A +// number nothing checks will drift again on the next width change. // // MATCHED IN CONTEXT AND COMPARED, not searched for as a substring. A -// strings.Contains(readme, "85") version of this test was blind: "$5.85" in the ASCII -// sample four lines above the prose supplies those digits, so the sentence could say -// anything and the test still passed — and at a drifted 86 both "186" and "8693" -// elsewhere in the file would have covered for it. It closed the finding without closing -// the gap, under a comment claiming it would fail when the constant moved. +// strings.Contains(readme, "86") version of this test is blind at the CURRENT value: +// "8693" and "186" appear elsewhere in the file and supply those digits, so the sentence +// could say anything and the test would still pass. A substring check closed this finding +// once without closing the gap, under a comment claiming it would fail when the constant +// moved. func TestREADME_StatesTheCurrentTwoColumnThreshold(t *testing.T) { raw, err := os.ReadFile(filepath.Join("..", "README.md")) if err != nil { diff --git a/authbridge/cmd/abctl/tui/spend_sanitize_test.go b/authbridge/cmd/abctl/tui/spend_sanitize_test.go index 31c79d85d..6d83c350e 100644 --- a/authbridge/cmd/abctl/tui/spend_sanitize_test.go +++ b/authbridge/cmd/abctl/tui/spend_sanitize_test.go @@ -157,7 +157,7 @@ func TestRenderSpendDrawer_AWideErrorMessageStaysInsideTheReservation(t *testing // same function compares the renderer with its own padding rule and cannot fail — // the constant this used to name was an independent witness and this restores one. // - // 6 below spendDrawerTwoColumnMin (85) and 7 at or above it: the tier column, and + // 6 below spendDrawerTwoColumnMin (86) and 7 at or above it: the tier column, and // with it the reasoning child's row, only exists in two columns. 120 is the only // width here that reaches it. for _, tc := range []struct { diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index b58b5e924..40c1b3619 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -151,6 +151,19 @@ func TestRenderTierRows_ReasoningNeverExceedsOutput(t *testing.T) { if reasoningRow == "" { t.Fatal("no reasoning row rendered") } + // THE OPERAND IS ASSERTED, NOT FILTERED — the rule this subtest writes out for the + // bars below and skipped here. sharePercent's ok is spent as a `continue` above, so + // an output row whose share does not parse never lands in outputRow and leaves + // outputPct at 0. `reasoningPct > outputPct` then reads 0 > 0 and passes on all three + // cases, including the fixture built to report reasoning ABOVE output. + // + // ASSERTED ON THE ROW, NOT ON A ZERO SHARE, and the difference was measured: + // tierShareCell floors any tier holding money to "<1%", which sharePercent reads back + // as 1, so `outputPct == 0` is unreachable while the row exists. Guarding the value + // would have been exactly the dead check this comment exists to avoid. + if outputRow == "" { + t.Fatal("no output row rendered; the share comparison has nothing to bound against") + } if reasoningPct > outputPct { t.Errorf("reasoning is %d%% of the bill but output is only %d%%; a subset cannot "+ "exceed its set\n %s\n %s", reasoningPct, outputPct, outputRow, reasoningRow) diff --git a/authbridge/cmd/abctl/tui/spend_tiers_test.go b/authbridge/cmd/abctl/tui/spend_tiers_test.go index ad355fe9c..8f54698dc 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_test.go @@ -290,7 +290,14 @@ func TestRenderTierRows_RanksByCostNotByDeclarationOrder(t *testing.T) { } } -// NO figure wears the inexact marker, which is the reverse of what this test used to assert. +// NO TIER figure wears the inexact marker, which is the reverse of what this test used to assert. +// +// SCOPED TO tierRowsOnly, because "no row in this panel wears the marker" is FALSE: the reasoning +// child wears one, by the rule spend_tiers.go:108 states ("ONE FIGURE WEARS inexactMarker: the +// reasoning child, and only it"). This loop passed over that only because tierCounts() reports no +// split, so the child rendered the not-known cell and never reached the marker branch — the +// fixture was doing the work, not the panel. Swapping in reasoningCounts() failed it outright. +// The complement — that the child DOES wear one — is TestRenderTierRows_OnlyTheChildWearsTheInexactMarker. // // Every figure here still IS modelled — the mix is the rate table's while the total may be the // gateway's — so the disclosure was real and was given up rather than made unnecessary. It was @@ -302,13 +309,13 @@ func TestRenderTierRows_RanksByCostNotByDeclarationOrder(t *testing.T) { // make deliberately: it would put a tilde on every row of the panel again. func TestRenderTierRows_MarksNoFigureInexact(t *testing.T) { rows := 0 - for _, line := range renderTierRows(tierCounts(), 60) { + for _, line := range tierRowsOnly(renderTierRows(tierCounts(), 60)) { if line == "" { continue } rows++ if strings.Contains(line, inexactMarker) { - t.Errorf("row %q carries %q; this panel states the caveat nowhere on a row", line, + t.Errorf("tier row %q carries %q; the tier rows state the caveat nowhere", line, inexactMarker) } } From 8068d482e3fdfb0c89f95f9f62148d314f92d9d7 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 14:17:40 -0400 Subject: [PATCH 20/24] =?UTF-8?q?refactor:=20Trim=20out-of-scope=20changes?= =?UTF-8?q?=20=E2=80=94=20one=20provider,=20one=20claim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR claims two things: read Anthropic's thinking_tokens, and surface reasoning spend in abctl. Four changes served neither and are removed so the diff matches the title. 1. OPENAI PRESENCE-BIT PARITY (plugin.go, splittokens_test.go). A real bug — completion_tokens_details present with no reasoning_tokens inside it set KindReasoning with a value of zero, which renders as "the model did no reasoning" rather than "nothing was reported". But it is a second provider's parser in a PR about Anthropic, and the code fix was 3 lines carrying 18 of comment. NEEDS A FOLLOW-UP ISSUE, which should also carry the note that PromptTokensDetails.CachedTokens has the identical shape and the identical exposure and was knowingly left. 2. README DEMO TOOLING (tuicapture.go, demo.yaml). The feature commit already regenerated the demo asset; changing the generator is separate work. 3. TWO DOCS (docs/proposals/cost-tier-breakdown.md, authbridge/docs/session-budget-plugin.md). Design and operator prose that arrived during review rounds 3 and 14. Neither is required by either claim. No assertion covering an in-scope behaviour is lost: the only test removed is TestPresentKinds_OpenAI_DetailsWithoutTheCount, which covers the reverted OpenAI path. The Anthropic rename in splittokens_test.go is kept. authlib (61 packages) and cmd/abctl (6 packages) are green. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../authlib/plugins/inferenceparser/plugin.go | 25 ++---------- .../inferenceparser/splittokens_test.go | 39 ------------------- authbridge/docs/session-budget-plugin.md | 14 +------ authbridge/scripts/readme-demo/demo.yaml | 12 +++--- authbridge/scripts/readme-demo/tuicapture.go | 24 +++++------- docs/proposals/cost-tier-breakdown.md | 22 +---------- 6 files changed, 20 insertions(+), 116 deletions(-) diff --git a/authbridge/authlib/plugins/inferenceparser/plugin.go b/authbridge/authlib/plugins/inferenceparser/plugin.go index 5e2763f67..bdb5ef776 100644 --- a/authbridge/authlib/plugins/inferenceparser/plugin.go +++ b/authbridge/authlib/plugins/inferenceparser/plugin.go @@ -739,26 +739,8 @@ type inferenceUsage struct { PromptTokensDetails *struct { CachedTokens int `json:"cached_tokens"` } `json:"prompt_tokens_details"` - // ReasoningTokens is *int so a details object carrying no count leaves - // KindReasoning CLEAR rather than asserting a reported zero — the same reason - // anthropicUsage checks both of its pointers. A gateway relaying - // completion_tokens_details without the field inside it has reported nothing, and - // a set bit with a zero value makes `abctl cost` print "reasoning (of output) 0", - // claiming the model did no reasoning. - // - // PromptTokensDetails.CachedTokens ABOVE IS STILL A PLAIN int, with the identical - // shape and the identical exposure: `prompt_tokens_details: {}` sets KindCacheRead - // with a value of zero, so a gateway forwarding an empty details object is recorded - // as having reported a cache read of nothing. TestPresentKinds_OpenAI_WithDetailsBlocks - // pins the reported-zero behaviour for both fields, so the fix is the same three - // lines this field took. - // - // KNOWINGLY LEFT, not overlooked: cache-read is a priced figure on every - // OpenAI-format endpoint and moving its presence rule is a wider change than the one - // this comment sits in. Stated here because that is the only place a reader who - // touches this struct will see it. CompletionTokensDetails *struct { - ReasoningTokens *int `json:"reasoning_tokens"` + ReasoningTokens int `json:"reasoning_tokens"` } `json:"completion_tokens_details"` } @@ -798,9 +780,8 @@ func (u inferenceUsage) toNeutral() parsercommon.TokenUsage { usage.CacheRead = cached usage.Present |= parsercommon.KindCacheRead } - // BOTH POINTERS, so "details present, count absent" reports nothing. - if u.CompletionTokensDetails != nil && u.CompletionTokensDetails.ReasoningTokens != nil { - usage.Reasoning = *u.CompletionTokensDetails.ReasoningTokens + if u.CompletionTokensDetails != nil { + usage.Reasoning = u.CompletionTokensDetails.ReasoningTokens usage.Present |= parsercommon.KindReasoning } return usage diff --git a/authbridge/authlib/plugins/inferenceparser/splittokens_test.go b/authbridge/authlib/plugins/inferenceparser/splittokens_test.go index 0661f8b8f..968ee1713 100644 --- a/authbridge/authlib/plugins/inferenceparser/splittokens_test.go +++ b/authbridge/authlib/plugins/inferenceparser/splittokens_test.go @@ -313,42 +313,3 @@ func TestFoldOpenAIFrame_UsageIsCumulative(t *testing.T) { t.Errorf("OutputTokens = %d, want 150 (last chunk wins)", ext.OutputTokens) } } - -// A details object with NO count inside it reports nothing, on the OpenAI path as on -// the Anthropic one. -// -// It used to set KindReasoning with a value of zero, because ReasoningTokens was a -// plain int and presence of the OBJECT was the only test — the false reported-zero -// that ..._ThinkingTokensPartiallyAbsent forbids for Anthropic. The two parsers held -// different invariants for the same wire shape until the pointer was mirrored. -// -// The reported-zero case above (TestPresentKinds_OpenAI_WithDetailsBlocks) still sets -// the bit: a count present and zero is a measurement. -func TestPresentKinds_OpenAI_DetailsWithoutTheCount(t *testing.T) { - for _, tc := range []struct{ name, details string }{ - {"empty details object", `"completion_tokens_details":{}`}, - {"explicit null count", `"completion_tokens_details":{"reasoning_tokens":null}`}, - {"unrelated sub-field only", `"completion_tokens_details":{"accepted_prediction_tokens":4}`}, - } { - t.Run(tc.name, func(t *testing.T) { - ext := &pipeline.InferenceExtension{Model: "gpt-4o"} - parseInferenceJSON([]byte(`{ - "choices":[{"message":{"content":"ok"},"finish_reason":"stop"}], - "usage":{"prompt_tokens":12,"completion_tokens":3,"total_tokens":15, - `+tc.details+`} - }`), ext) - - 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. - if ext.PresentKinds&uint8(parsercommon.KindOutput) == 0 { - t.Errorf("PresentKinds = %b, lost KindOutput", ext.PresentKinds) - } - }) - } -} diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 186054e23..81169c58d 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -50,7 +50,7 @@ pipeline: | `max_cache_read_tokens` | 0 | Per-kind ceiling on prompt tokens served from cache. 0 = no limit. | | `max_cache_write_tokens` | 0 | Per-kind ceiling on prompt tokens written to cache. 0 = no limit. | | `max_output_tokens` | 0 | Per-kind ceiling on generated completion tokens. 0 = no limit. | -| `max_reasoning_tokens` | 0 | Per-kind ceiling on reasoning-only output tokens (subset of output). 0 = no limit. See note below — this limit was inert on Anthropic traffic until recently. | +| `max_reasoning_tokens` | 0 | Per-kind ceiling on reasoning-only output tokens (subset of output). 0 = no limit. | | `max_calls` | 0 | LLM/inference call cap (from `inference-parser`); MCP, A2A, and other outbound traffic do not count. 0 = no limit. See note below on enforcement scope. | | `max_duration_seconds` | 0 | Session lifetime cap (0 = no limit) | | `on_exceed` | `deny` | `deny` (403), `observe` (log only), or `pause` (webhook) | @@ -65,18 +65,6 @@ pipeline: At least one of `max_tokens`, `max_input_tokens`, `max_cache_read_tokens`, `max_cache_write_tokens`, `max_output_tokens`, `max_reasoning_tokens`, `max_calls`, `max_duration_seconds` must be > 0. -**`max_reasoning_tokens` was inert on Anthropic traffic, and is not any more.** -`inference-parser` did not read Anthropic's -`usage.output_tokens_details.thinking_tokens`, so `ReasoningTokens` was always 0 on -that path and this limit could never be reached however low it was set. The parser -reads it now, which makes the counter real **without any config change of yours**. - -If you set `max_reasoning_tokens` against Claude traffic and saw no effect, that was -why — and with `on_exceed` at its `deny` default those sessions will now start -receiving 403s once they cross it. Audit any value currently set before upgrading. -OpenAI-format endpoints were never affected: that path has always read -`completion_tokens_details.reasoning_tokens`. - **`max_calls` enforcement scope.** Only inference calls surfaced by `inference-parser` increment the counter, but the limit check runs on every outbound request. Once the LLM counter crosses `max_calls`, the diff --git a/authbridge/scripts/readme-demo/demo.yaml b/authbridge/scripts/readme-demo/demo.yaml index d56546bdc..4adbb764b 100644 --- a/authbridge/scripts/readme-demo/demo.yaml +++ b/authbridge/scripts/readme-demo/demo.yaml @@ -116,17 +116,17 @@ acts: title: "fix the retry handler" model: claude-opus-5 turns: - - {messages: 4, tools: 15, input: 1800, cache_read: 46000, cache_write: 9000, output: 620, reasoning: 341, prompt_usd: 0.098, output_usd: 0.047, tool_call: "tools/call", tool_host: "github-tool-mcp"} - - {messages: 10, tools: 15, input: 900, cache_read: 93000, cache_write: 2400, output: 810, reasoning: 446, prompt_usd: 0.121, output_usd: 0.061} - - {messages: 16, tools: 15, input: 1100, cache_read: 141000, cache_write: 3100, output: 940, reasoning: 517, prompt_usd: 0.174, output_usd: 0.071} + - {messages: 4, tools: 15, input: 1800, cache_read: 46000, cache_write: 9000, output: 620, prompt_usd: 0.098, output_usd: 0.047, tool_call: "tools/call", tool_host: "github-tool-mcp"} + - {messages: 10, tools: 15, input: 900, cache_read: 93000, cache_write: 2400, output: 810, prompt_usd: 0.121, output_usd: 0.061} + - {messages: 16, tools: 15, input: 1100, cache_read: 141000, cache_write: 3100, output: 940, prompt_usd: 0.174, output_usd: 0.071} - id: web-2a91 title: "add dark mode toggle" model: claude-opus-5 turns: - - {messages: 4, tools: 15, input: 1200, cache_read: 21000, cache_write: 6400, output: 380, reasoning: 209, prompt_usd: 0.061, output_usd: 0.029} - - {messages: 8, tools: 15, input: 700, cache_read: 44000, cache_write: 1800, output: 520, reasoning: 286, prompt_usd: 0.074, output_usd: 0.039} + - {messages: 4, tools: 15, input: 1200, cache_read: 21000, cache_write: 6400, output: 380, prompt_usd: 0.061, output_usd: 0.029} + - {messages: 8, tools: 15, input: 700, cache_read: 44000, cache_write: 1800, output: 520, prompt_usd: 0.074, output_usd: 0.039} - id: infra-55de title: "debug the helm chart" model: claude-sonnet-5 turns: - - {messages: 4, tools: 15, input: 800, cache_read: 12000, cache_write: 3200, output: 240, reasoning: 132, prompt_usd: 0.012, output_usd: 0.004} + - {messages: 4, tools: 15, input: 800, cache_read: 12000, cache_write: 3200, output: 240, prompt_usd: 0.012, output_usd: 0.004} diff --git a/authbridge/scripts/readme-demo/tuicapture.go b/authbridge/scripts/readme-demo/tuicapture.go index 0ba423d53..5a4134436 100644 --- a/authbridge/scripts/readme-demo/tuicapture.go +++ b/authbridge/scripts/readme-demo/tuicapture.go @@ -40,18 +40,14 @@ import ( // Turn is one request/response exchange with a model. type Turn struct { - Messages int `yaml:"messages"` - Tools int `yaml:"tools"` - Input int `yaml:"input"` - CacheRead int `yaml:"cache_read"` - CacheWrite int `yaml:"cache_write"` - Output int `yaml:"output"` - // Reasoning is the share of Output the model spent thinking, so the `$` - // breakdown's reasoning child renders a real figure rather than the - // not-known cell. A SUBSET of Output, never added to it. - Reasoning int `yaml:"reasoning"` - PromptUSD float64 `yaml:"prompt_usd"` - OutputUSD float64 `yaml:"output_usd"` + Messages int `yaml:"messages"` + Tools int `yaml:"tools"` + Input int `yaml:"input"` + CacheRead int `yaml:"cache_read"` + CacheWrite int `yaml:"cache_write"` + Output int `yaml:"output"` + PromptUSD float64 `yaml:"prompt_usd"` + OutputUSD float64 `yaml:"output_usd"` // ToolCall, when set, adds an outbound MCP tool call after the model // response, so the events timeline shows an agent doing something and not // only talking to a model. @@ -284,9 +280,7 @@ func (c *Capturer) build(f Fixture) []pendingEvent { CacheReadTokens: t.CacheRead, CacheWriteTokens: t.CacheWrite, OutputTokens: t.Output, - ReasoningTokens: t.Reasoning, - // Reasoning is NOT added: it is already inside Output. - TotalTokens: t.Input + t.CacheRead + t.CacheWrite + t.Output, + TotalTokens: t.Input + t.CacheRead + t.CacheWrite + t.Output, }, Plugins: costPlugins(t), Invocations: &pipeline.Invocations{Outbound: []pipeline.Invocation{ diff --git a/docs/proposals/cost-tier-breakdown.md b/docs/proposals/cost-tier-breakdown.md index 09dfcce57..90aeb414a 100644 --- a/docs/proposals/cost-tier-breakdown.md +++ b/docs/proposals/cost-tier-breakdown.md @@ -105,22 +105,6 @@ evidence available, `~` says it is inexact, and a reader who wants coverage has already labels it `reasoning (of output)` — so including it as a fifth bar would double-count. It stays in `abctl cost`'s token line and out of the bars. -> **Superseded in part.** `reasoning` is now drawn in this panel, as an *indented -> child of `output`* carrying its own bar and figure. -> -> The reason behind 3.5 is unchanged and still enforced: it is not a tier, it is -> excluded from the shares that sum to 100, `numTierRows` stays pinned to -> `pricing.NumTiers`, and `ApportionTiers` still returns exactly four figures summing -> to `CostMicros`. What changed is the inference that "not a tier" required "not -> drawn". This panel was the only cost surface that could not answer what an effort -> setting costs, and the containment is carried by the indent and by exclusion from -> the sum rather than by absence. -> -> The child's money is apportioned from `output`'s *displayed* figure and clamped to -> it, so it always divides into the row directly above. It renders the not-known cell -> when no split was reported, and is always present so the panel's height does not -> follow its data (6 below). See `childTierLabel` and `reasoningChildRow`. - **3.6 No residual twins for the new fields.** `CostMicros` has `UngroupedCostMicros` and `SeriesOvershootMicros` because the authoritative total must reconcile across grouping. A modelled mix is an apportionment key; an @@ -285,11 +269,7 @@ implementation: 2. A window priced entirely from gateway headers, with `Σ modelledTier == 0`, renders `emptyCell` and does not divide by zero (3.4). 3. A mix covering a small fraction of the priced spend still apportions, and wears `~` — the positive control for having removed the threshold, since a reintroduced floor would blank this case. 4. Display order is by amount, not by `pricing.Tier` declaration order (3.7). The fixture must order the two differently, or the test passes under either implementation. -5. `reasoning` never joins the sum (3.5). ~~never appears as a bar~~ — superseded: it - is drawn as an indented child of `output`, so the criterion is now that the four - unindented rows still sum to 100, that the child's figure and bar never exceed - `output`'s, and that a reported split too small to apportion renders the not-known - cell rather than `$0.00`. +5. `reasoning` never appears as a bar and never joins the sum (3.5). 6. The panel's line count is identical across every coverage state, which is what keeps the reservation honest. 7. At a width too narrow for two columns, the output equals today's drawer. 8. Saturation: a tier at `MaxInt64` sets `Saturated` and does not wrap — the failure already found once in `rankSeriesByCost`, whose raw `+=` ranked an overflowing series below a ten-micro one. From 5733b7cdc6427e742d3800858bff7881b24f2872 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 14:21:54 -0400 Subject: [PATCH 21/24] test: Fold the drawer's adjacency check into the orphan-glyph test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestRenderSpendDrawer_ChildFollowsOutput was a subset of TestRenderSpendDrawer_HasNoOrphanTreeGlyph: childTierLabel is " └ reasoning", so matching "└" and matching "reasoning" select the same row, and the orphan test already asserted that row's predecessor is output. The only difference was the fixture — tierSnap vs reasoningSnap — so the orphan test now loops over both and the duplicate is gone. VERIFIED BY MUTATION, because a merge that quietly drops coverage is worse than the duplication. Replacing insertAfterOutput's rank search with a fixed `at := 1`: - the merged test PASSES on both fixtures, and so did the test just deleted: both rank output first at width 100, so neither could ever tell a fixed index from a rank search. The duplicate was also a weak one. - TestRenderTierRows_ChildFollowsOutputWhereverItRanks FAILS. It is the only test in the package that catches this, it ranks output LAST on purpose, and it is untouched here. So the class keeps its one real witness and loses a decorative second one. Not cut, having read it: TestREADME_StatesTheCurrentTwoColumnThreshold. It is no longer the strings.Contains(readme, "85") version that could not fail — it regex-extracts the documented number and compares it to spendDrawerTwoColumnMin, and that prose has already drifted once (72 against an actual 84). A working guard on a number with a drift history stays. cmd/abctl/tui green; both subtests observed running. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/spend_drawer_test.go | 92 ++++++++----------- 1 file changed, 39 insertions(+), 53 deletions(-) diff --git a/authbridge/cmd/abctl/tui/spend_drawer_test.go b/authbridge/cmd/abctl/tui/spend_drawer_test.go index 742cd3610..5eeb3678a 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -1312,36 +1312,46 @@ func TestRenderSpendDrawer_DoesNotRestateTheBandsFigures(t *testing.T) { // // "├" stays banned outright. It means "more siblings follow", and reasoning is the // only child this panel has. +// BOTH FIXTURES, which is what lets this subsume the drawer's adjacency check: the glyph +// row's parent must be output whether or not a split was reported, and a regression that +// inserted the child at a fixed index passes on one fixture and fails on the other. func TestRenderSpendDrawer_HasNoOrphanTreeGlyph(t *testing.T) { - lines := renderSpendDrawer(tierSnap(), nil, usage.GroupModel, "1h", 100) - joined := strings.Join(lines, "\n") - if strings.Contains(joined, "├") { - t.Errorf("the panel draws \"├\", which claims a sibling follows:\n%s", joined) - } - // THE GLYPH MUST BE PRESENT BEFORE ITS PARENT IS CHECKED. The loop below skips any - // row without a "└", so flattening childTierLabel to no glyph would make every - // iteration skip and this test go green — the same dead-assertion shape as - // drawnBarGlyphs' inverted rune range. Count first, then check. - glyphRows := 0 - for _, l := range lines { - if strings.Contains(l, "└") { - glyphRows++ - } - } - if glyphRows == 0 { - t.Fatalf("no row carries \"└\", so the parent check below cannot fail:\n%s", joined) - } - for i, l := range lines { - if !strings.Contains(l, "└") { - continue - } - if i == 0 { - t.Errorf("row 0 carries \"└\" with nothing above it to be a child of:\n%s", joined) - continue - } - if !strings.Contains(lines[i-1], "output") { - t.Errorf("row %d carries \"└\" but the line above it is not output:\n%s", i, joined) - } + for _, tc := range []struct { + name string + snap *usage.Snapshot + }{{"unreported split", tierSnap()}, {"reported split", reasoningSnap()}} { + t.Run(tc.name, func(t *testing.T) { + lines := renderSpendDrawer(tc.snap, nil, usage.GroupModel, "1h", 100) + joined := strings.Join(lines, "\n") + if strings.Contains(joined, "├") { + t.Errorf("the panel draws \"├\", which claims a sibling follows:\n%s", joined) + } + // THE GLYPH MUST BE PRESENT BEFORE ITS PARENT IS CHECKED. The loop below skips + // any row without a "└", so flattening childTierLabel to no glyph would make + // every iteration skip and this test go green — the same dead-assertion shape + // as drawnBarGlyphs' inverted rune range. Count first, then check. + glyphRows := 0 + for _, l := range lines { + if strings.Contains(l, "└") { + glyphRows++ + } + } + if glyphRows == 0 { + t.Fatalf("no row carries \"└\", so the parent check below cannot fail:\n%s", joined) + } + for i, l := range lines { + if !strings.Contains(l, "└") { + continue + } + if i == 0 { + t.Errorf("row 0 carries \"└\" with nothing above it to be a child of:\n%s", joined) + continue + } + if !strings.Contains(lines[i-1], "output") { + t.Errorf("row %d carries \"└\" but the line above it is not output:\n%s", i, joined) + } + } + }) } } @@ -1890,30 +1900,6 @@ func TestRenderSpendDrawer_EmitsEveryTierPlusTheChild(t *testing.T) { } } -// The child sits directly under output IN THE DRAWER, not just in renderTierRows. -// A regression that emitted five left rows but inserted the child at the wrong index -// would pass both the line count and the label check above. -func TestRenderSpendDrawer_ChildFollowsOutput(t *testing.T) { - lines := renderSpendDrawer(reasoningSnap(), nil, usage.GroupModel, "1h", 100) - outputAt, childAt := -1, -1 - for i, l := range lines { - switch { - case strings.Contains(l, "reasoning"): - childAt = i - case strings.Contains(l, "output"): - outputAt = i - } - } - if outputAt < 0 || childAt < 0 { - t.Fatalf("output at %d, child at %d; both must render:\n%s", - outputAt, childAt, strings.Join(lines, "\n")) - } - if childAt != outputAt+1 { - t.Errorf("child is at line %d and output at %d; the child must directly follow "+ - "its parent:\n%s", childAt, outputAt, strings.Join(lines, "\n")) - } -} - // With a split reported, the drawer must show the child's FIGURE — the populated // path, which no other drawer fixture reaches. func TestRenderSpendDrawer_ChildCarriesItsFigure(t *testing.T) { From e9ddc89785bd38a94c4f3b6ca0455e0c2d574dad Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 14:26:05 -0400 Subject: [PATCH 22/24] test: Collapse the child cell's six states into one truth table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six test functions were six states of ONE decision — which cell the reasoning child row shows — and split that way, each asserted only the dimension the round that found it cared about. The negative case never checked the marker; the tiny-share case never checked the figure; the reported-zero case was the only one checking all three. TestRenderTierRows_ChildCellTruthTable now asserts cell, figure AND marker on every row, so a state cannot be half-covered. Replaces: UnreportedSplitIsNotKnownNotZero, TinyShareIsNotKnownNotFree, NoFigureWithoutOutputTokens, ReportedZeroIsTheMeasurement, NegativeReasoningIsRefused, NotKnownChildWearsNoMarker. The sub-case embedded in ReportedZeroIsTheMeasurement (value zero, bit clear) is now a row of its own rather than a tail assertion. 145 lines out, 124 in, net -21 — small because every per-case rationale is kept verbatim as the row's `why`, and it prints in the failure message. The saving is not the point; the point is that the presence-vs-value distinction this PR spent four rounds on is now one readable column instead of six scattered functions. MUTATION-CHECKED on both dimensions, since a table that cannot fail is the defect this PR keeps re-finding: - `reportedZero := false` (a measured zero falls back to the not-known cell) -> FAILS reported_zero: `child row = " └ reasoning —" shows the not-known cell for a MEASURED value` - inexactMarker prepended to the notKnown cell -> FAILS unreported_split and reported_zero_with_the_bit_clear: `wears "~" = true, want false` Control green before each, tree restored and green after. authlib + cmd/abctl green; go vet clean; gofmt clean. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- .../abctl/tui/spend_tiers_reasoning_test.go | 263 ++++++++---------- 1 file changed, 121 insertions(+), 142 deletions(-) diff --git a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go index 40c1b3619..4eb0a5ff0 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -257,74 +257,6 @@ func rowMoney(row string) (float64, bool) { return v, true } -// An unreported split renders the NOT-KNOWN cell, not $0.00 and not a vanished row. -// -// The row must still be there: the panel's height is reserved from a constant that -// layout() cannot consult, and a height that followed the data is the defect -// TestRenderTierRows_HeightIsConstant exists for. And it must not read $0.00, which -// would assert the model did no reasoning when the truth is that nothing reported -// either way — the same refusal renderTierRows makes for an absent tier. -func TestRenderTierRows_UnreportedSplitIsNotKnownNotZero(t *testing.T) { - lines := renderTierRows(tierCounts(), tierColumnWidth) - child := childRows(lines) - if len(child) != 1 { - t.Fatalf("want exactly one child row even when unreported, got %d", len(child)) - } - if !strings.Contains(child[0], emptyCell) { - t.Errorf("child row = %q, want the not-known cell", child[0]) - } - if strings.Contains(child[0], "$0.00") { - t.Errorf("child row = %q, want no $0.00 — that asserts the model did no reasoning", child[0]) - } -} - -// A REPORTED SPLIT TOO SMALL TO APPORTION MUST NOT RENDER $0.00. -// -// The apportionment multiply truncates, so a real reasoning count whose share of the -// window falls below one micro yields micros == 0 — reachable on a small window, -// around a hundred output tokens at opus-5 rates. Printing that as "$0.00" asserts -// the reasoning was FREE, which is the claim renderTierRows refuses for a tier -// (tiers[tier] == 0 takes the not-known cell); the child needs the same escape. -// -// Not "<$0.01" either: that form means "too small to state", while what is true here -// is that the apportionment resolved no figure at all. -func TestRenderTierRows_TinyShareIsNotKnownNotFree(t *testing.T) { - // 1 reasoning token of 900 output, against 300 apportioned output micros: - // 300 * 1/900 = 0.333, which truncates to zero. - c := usage.Counts{ - Requests: 3, CostMicros: 4_000, - InputCostMicros: 900, CacheReadCostMicros: 2_800, OutputCostMicros: 300, - OutputTokens: 900, ReasoningTokens: 1, - PresentKinds: uint8(usage.KindInput | usage.KindCacheRead | usage.KindOutput | usage.KindReasoning), - } - child := childRows(renderTierRows(c, tierColumnWidth)) - if len(child) != 1 { - t.Fatalf("want one child row, got %d", len(child)) - } - if strings.Contains(child[0], "$0.00") { - t.Errorf("child row = %q renders $0.00 for a REPORTED split; that asserts the "+ - "reasoning was free", child[0]) - } - if !strings.Contains(child[0], emptyCell) { - t.Errorf("child row = %q, want the not-known cell when the share apportions to "+ - "nothing", child[0]) - } -} - -// Reasoning reported but nothing generated: no denominator, so no defensible figure. -// The row stays (height is constant) and says it does not know. -func TestRenderTierRows_NoFigureWithoutOutputTokens(t *testing.T) { - c := reasoningCounts() - c.OutputTokens = 0 - child := childRows(renderTierRows(c, tierColumnWidth)) - if len(child) != 1 { - t.Fatalf("want one child row, got %d", len(child)) - } - if !strings.Contains(child[0], emptyCell) { - t.Errorf("child row = %q, want the not-known cell with no output to apportion by", child[0]) - } -} - // The panel's height is CONSTANT whether or not a split was reported. This is the // invariant the drawer's fixed reservation depends on. func TestRenderTierRows_HeightConstantAcrossReasoningStates(t *testing.T) { @@ -362,70 +294,6 @@ func TestSpendDrawerLines_AccountsForTheChildRow(t *testing.T) { } } -// A REPORTED ZERO RENDERS $0.00, EXACTLY, AND WEARS NO MARKER. -// -// REVERSES A DECISION THIS TEST USED TO PIN. It asserted the not-known cell, on the -// grounds that "$0.00 is a lie" — the rule the tier rows follow for `tiers[tier] == 0`. -// That borrowing was wrong: a TIER apportioning to zero is absent from the modelled mix, -// so its figure is unknown, while a reasoning count of zero means the provider MEASURED -// the split and it was nothing. "—" for a value we have discards it. -// -// It also split the surfaces: `abctl cost`'s token line prints "reasoning (of output) 0" -// for the same Counts, so the drawer and the CLI told different stories about one -// measured fact — and ApportionReasoning's own doc frames the present bit as what -// "matters to a renderer choosing between the not-known cell and $0.00", while no -// renderer was making that choice. -// -// No marker either: zero tokens cost zero whatever the output rate, so this is the one -// child figure with no token-ratio approximation in it for a marker to qualify. -func TestRenderTierRows_ReportedZeroIsTheMeasurement(t *testing.T) { - c := reasoningCounts() - c.ReasoningTokens = 0 // measured, and measured as nothing: the bit stays set - child := childRows(renderTierRows(c, tierColumnWidth)) - if len(child) != 1 { - t.Fatalf("want one child row for a reported zero, got %d", len(child)) - } - if strings.Contains(child[0], emptyCell) { - t.Errorf("child row = %q shows the not-known cell for a MEASURED zero; the CLI's "+ - "token line prints 0 for the same Counts", child[0]) - } - if got, ok := rowMoney(child[0]); !ok || got != 0 { - t.Errorf("child row = %q, want a $0.00 figure (parsed %v, ok=%v)", child[0], got, ok) - } - if strings.Contains(child[0], inexactMarker) { - t.Errorf("child row = %q wears %q; a zero costs zero at any rate, so nothing here "+ - "is modelled", child[0], inexactMarker) - } - // An UNREPORTED split is still the not-known cell — that is the distinction the - // present bit exists to carry, and this is the half that must not move. - unreported := reasoningCounts() - unreported.ReasoningTokens = 0 - unreported.PresentKinds = uint8(usage.KindOutput) - if got := childRows(renderTierRows(unreported, tierColumnWidth)); len(got) != 1 || - !strings.Contains(got[0], emptyCell) { - t.Errorf("an unreported split rendered %q, want the not-known cell", got) - } -} - -// A NEGATIVE count must not reach the bar or the share cell. Unreachable through the -// live parser, which screens negatives at ingest — but renderTierRows takes a -// usage.Counts from the ledger and from any other producer, and a negative here would -// draw a bar from a negative length. -func TestRenderTierRows_NegativeReasoningIsRefused(t *testing.T) { - c := reasoningCounts() - c.ReasoningTokens = -948 - child := childRows(renderTierRows(c, tierColumnWidth)) - if len(child) != 1 { - t.Fatalf("want one child row, got %d", len(child)) - } - if !strings.Contains(child[0], emptyCell) { - t.Errorf("child row = %q, want the not-known cell for a negative count", child[0]) - } - if strings.Contains(child[0], "-") { - t.Errorf("child row = %q carries a negative figure", child[0]) - } -} - // THE CHILD'S MONEY COLUMN ALIGNS WITH THE TIERS', which // TestRenderTierRows_MoneyIsRightAligned cannot say: it wraps its input in // tierRowsOnly and then locks the exclusion in with `len(ends) != numTierRows`, so @@ -669,15 +537,126 @@ func TestRenderTierRows_OnlyTheChildWearsTheInexactMarker(t *testing.T) { } } -// The not-known cell wears NO marker: inexactMarker qualifies a figure, and there is none -// to qualify. A glyph there would claim an inexact number where the claim is that there -// is no number. -func TestRenderTierRows_NotKnownChildWearsNoMarker(t *testing.T) { - child := childRows(renderTierRows(tierCounts(), tierColumnWidth)) - if len(child) != 1 { - t.Fatalf("want one child row, got %d", len(child)) - } - if strings.Contains(child[0], inexactMarker) { - t.Errorf("child row = %q wears %q with no figure to qualify", child[0], inexactMarker) +// THE CHILD CELL'S TRUTH TABLE — six states of ONE decision, which is why it is one table +// and not six functions. Split across functions, each state asserted only the dimension +// the round that found it cared about: the negative case never checked the marker, the +// tiny-share case never checked the figure. Every row now asserts all three (cell, figure, +// marker), so a state cannot be half-covered. +// +// The distinction the present bit exists to carry runs down the wantMoney column: nil is +// "no defensible figure, show the not-known cell", and a pointer to 0 is "the provider +// MEASURED the split and it was nothing". "-" for a value we have would discard it, and +// "$0.00" for a value we lack asserts the model reasoned for free. +func TestRenderTierRows_ChildCellTruthTable(t *testing.T) { + zero := func(f float64) *float64 { return &f } + + reportedZero := reasoningCounts() + reportedZero.ReasoningTokens = 0 // measured, and measured as nothing: the bit stays set + + zeroBitClear := reasoningCounts() + zeroBitClear.ReasoningTokens = 0 + zeroBitClear.PresentKinds = uint8(usage.KindOutput) // nothing reported at all + + noDenominator := reasoningCounts() + noDenominator.OutputTokens = 0 + + negative := reasoningCounts() + negative.ReasoningTokens = -948 + + for _, tc := range []struct { + name string + c usage.Counts + // nil = the not-known cell; non-nil = that exact figure, in dollars. + wantMoney *float64 + wantMarker bool + why string + }{ + { + name: "unreported split", + c: tierCounts(), + why: "no bit, no value: the row still renders (height is constant) and says it " + + "does not know. A $0.00 here would assert the model did no reasoning.", + }, + { + name: "reported zero", c: reportedZero, wantMoney: zero(0), wantMarker: false, + why: "the provider measured the split and it was nothing. `abctl cost`'s token " + + "line prints 0 for the same Counts, so the not-known cell would split the " + + "two surfaces. No marker: a zero costs zero at any rate, so nothing here is " + + "modelled for a marker to qualify.", + }, + { + name: "reported zero with the bit clear", c: zeroBitClear, + why: "the half of the present bit's job that must not move — same value as the " + + "row above, opposite cell, because nothing was reported.", + }, + { + // 1 reasoning token of 900 output against 300 apportioned output micros: + // 300 * 1/900 = 0.333, which truncates to zero. + name: "share apportions to nothing", + c: usage.Counts{ + Requests: 3, CostMicros: 4_000, + InputCostMicros: 900, CacheReadCostMicros: 2_800, OutputCostMicros: 300, + OutputTokens: 900, ReasoningTokens: 1, + PresentKinds: uint8(usage.KindInput | usage.KindCacheRead | usage.KindOutput | + usage.KindReasoning), + }, + why: "the apportionment multiply truncates, so a REAL count whose share falls " + + "below one micro yields micros == 0 — reachable around a hundred output " + + "tokens at opus-5 rates. Not \"<$0.01\" either: that means \"too small to " + + "state\", and what is true here is that the apportionment resolved nothing.", + }, + { + name: "no output to apportion by", c: noDenominator, + why: "reasoning reported but nothing generated: no denominator, no figure.", + }, + { + name: "negative count", c: negative, + why: "unreachable through the live parser, which screens negatives at ingest — " + + "but renderTierRows takes a usage.Counts from the ledger and from any other " + + "producer, and a negative would draw a bar from a negative length.", + }, + } { + t.Run(tc.name, func(t *testing.T) { + child := childRows(renderTierRows(tc.c, tierColumnWidth)) + if len(child) != 1 { + t.Fatalf("want exactly one child row in every state, got %d -- %s", + len(child), tc.why) + } + row := child[0] + gotMoney, hasMoney := rowMoney(row) + hasNotKnown := strings.Contains(row, emptyCell) + + if tc.wantMoney == nil { + if !hasNotKnown { + t.Errorf("child row = %q, want the not-known cell -- %s", row, tc.why) + } + if hasMoney { + t.Errorf("child row = %q carries the figure $%.4f where there is none to "+ + "state -- %s", row, gotMoney, tc.why) + } + } else { + if hasNotKnown { + t.Errorf("child row = %q shows the not-known cell for a MEASURED value "+ + "-- %s", row, tc.why) + } + if !hasMoney { + t.Errorf("child row = %q carries no figure, want $%.2f -- %s", + row, *tc.wantMoney, tc.why) + } else if gotMoney != *tc.wantMoney { + t.Errorf("child row = %q parsed $%.4f, want $%.2f -- %s", + row, gotMoney, *tc.wantMoney, tc.why) + } + } + + if got := strings.Contains(row, inexactMarker); got != tc.wantMarker { + t.Errorf("child row = %q wears %q = %v, want %v -- %s", + row, inexactMarker, got, tc.wantMarker, tc.why) + } + + // A negative must not reach the row in any form, figure or bar. + if tc.name == "negative count" && strings.Contains(row, "-") { + t.Errorf("child row = %q carries a negative figure", row) + } + }) } } From 94c67397ebec6f3dc672dc0f9c7311c51765decf Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 15:14:06 -0400 Subject: [PATCH 23/24] fix: Restore three files the scope trim wrongly removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review caught 8068d482 cutting too deep. Three of its four removals were not out of scope, and one of them broke CI. 1. THE DEMO GENERATOR IS NOT SEPARATE WORK (tuicapture.go, demo.yaml). Reverting them while KEEPING the regenerated docs/assets/cortex-demo.svg left the committed asset unreproducible from the committed generator. Proven, not reasoned about: `GOWORK=off go test -count=1 ./...` in scripts/readme-demo fails with TestCommittedAssetIsCurrent: cortex-demo.svg is stale (committed 67158 bytes, regenerated 67123 bytes) The job's own comment says it "drives the real abctl TUI and compares its output against the committed docs/assets/cortex-demo.svg", so this would have landed as a red "Go CI (README demo)". The yaml adds `reasoning:` to each fixture and tuicapture.go feeds it to ReasoningTokens — that IS the reasoning row in the asset the feature commit regenerated. 2. THE OPERATOR DOC RECORDS A BEHAVIOUR CHANGE (session-budget-plugin.md). The PR body calls max_reasoning_tokens enforcement "the one to look at": the counter was inert on Anthropic traffic and this PR makes it real, so sessions with on_exceed other than observe start being cut off. There is no CHANGELOG in this repo, so that doc is the only in-tree home for it. Removing it deleted the record of the PR's highest-impact consequence. 3. THE SUPERSEDED PROPOSAL (cost-tier-breakdown.md). The PR body cites it by name; reverting it made that claim false. Only the OpenAI removal stands, because it is a second provider's parser. The PR body still needs its section 2 deleted, and that fix needs its own issue. Trim now nets -95 rather than -150, which is the honest figure. 67 packages green; the README-demo staleness check green. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/docs/session-budget-plugin.md | 14 +++++++++++- authbridge/scripts/readme-demo/demo.yaml | 12 +++++----- authbridge/scripts/readme-demo/tuicapture.go | 24 ++++++++++++-------- docs/proposals/cost-tier-breakdown.md | 22 +++++++++++++++++- 4 files changed, 55 insertions(+), 17 deletions(-) diff --git a/authbridge/docs/session-budget-plugin.md b/authbridge/docs/session-budget-plugin.md index 81169c58d..186054e23 100644 --- a/authbridge/docs/session-budget-plugin.md +++ b/authbridge/docs/session-budget-plugin.md @@ -50,7 +50,7 @@ pipeline: | `max_cache_read_tokens` | 0 | Per-kind ceiling on prompt tokens served from cache. 0 = no limit. | | `max_cache_write_tokens` | 0 | Per-kind ceiling on prompt tokens written to cache. 0 = no limit. | | `max_output_tokens` | 0 | Per-kind ceiling on generated completion tokens. 0 = no limit. | -| `max_reasoning_tokens` | 0 | Per-kind ceiling on reasoning-only output tokens (subset of output). 0 = no limit. | +| `max_reasoning_tokens` | 0 | Per-kind ceiling on reasoning-only output tokens (subset of output). 0 = no limit. See note below — this limit was inert on Anthropic traffic until recently. | | `max_calls` | 0 | LLM/inference call cap (from `inference-parser`); MCP, A2A, and other outbound traffic do not count. 0 = no limit. See note below on enforcement scope. | | `max_duration_seconds` | 0 | Session lifetime cap (0 = no limit) | | `on_exceed` | `deny` | `deny` (403), `observe` (log only), or `pause` (webhook) | @@ -65,6 +65,18 @@ pipeline: At least one of `max_tokens`, `max_input_tokens`, `max_cache_read_tokens`, `max_cache_write_tokens`, `max_output_tokens`, `max_reasoning_tokens`, `max_calls`, `max_duration_seconds` must be > 0. +**`max_reasoning_tokens` was inert on Anthropic traffic, and is not any more.** +`inference-parser` did not read Anthropic's +`usage.output_tokens_details.thinking_tokens`, so `ReasoningTokens` was always 0 on +that path and this limit could never be reached however low it was set. The parser +reads it now, which makes the counter real **without any config change of yours**. + +If you set `max_reasoning_tokens` against Claude traffic and saw no effect, that was +why — and with `on_exceed` at its `deny` default those sessions will now start +receiving 403s once they cross it. Audit any value currently set before upgrading. +OpenAI-format endpoints were never affected: that path has always read +`completion_tokens_details.reasoning_tokens`. + **`max_calls` enforcement scope.** Only inference calls surfaced by `inference-parser` increment the counter, but the limit check runs on every outbound request. Once the LLM counter crosses `max_calls`, the diff --git a/authbridge/scripts/readme-demo/demo.yaml b/authbridge/scripts/readme-demo/demo.yaml index 4adbb764b..d56546bdc 100644 --- a/authbridge/scripts/readme-demo/demo.yaml +++ b/authbridge/scripts/readme-demo/demo.yaml @@ -116,17 +116,17 @@ acts: title: "fix the retry handler" model: claude-opus-5 turns: - - {messages: 4, tools: 15, input: 1800, cache_read: 46000, cache_write: 9000, output: 620, prompt_usd: 0.098, output_usd: 0.047, tool_call: "tools/call", tool_host: "github-tool-mcp"} - - {messages: 10, tools: 15, input: 900, cache_read: 93000, cache_write: 2400, output: 810, prompt_usd: 0.121, output_usd: 0.061} - - {messages: 16, tools: 15, input: 1100, cache_read: 141000, cache_write: 3100, output: 940, prompt_usd: 0.174, output_usd: 0.071} + - {messages: 4, tools: 15, input: 1800, cache_read: 46000, cache_write: 9000, output: 620, reasoning: 341, prompt_usd: 0.098, output_usd: 0.047, tool_call: "tools/call", tool_host: "github-tool-mcp"} + - {messages: 10, tools: 15, input: 900, cache_read: 93000, cache_write: 2400, output: 810, reasoning: 446, prompt_usd: 0.121, output_usd: 0.061} + - {messages: 16, tools: 15, input: 1100, cache_read: 141000, cache_write: 3100, output: 940, reasoning: 517, prompt_usd: 0.174, output_usd: 0.071} - id: web-2a91 title: "add dark mode toggle" model: claude-opus-5 turns: - - {messages: 4, tools: 15, input: 1200, cache_read: 21000, cache_write: 6400, output: 380, prompt_usd: 0.061, output_usd: 0.029} - - {messages: 8, tools: 15, input: 700, cache_read: 44000, cache_write: 1800, output: 520, prompt_usd: 0.074, output_usd: 0.039} + - {messages: 4, tools: 15, input: 1200, cache_read: 21000, cache_write: 6400, output: 380, reasoning: 209, prompt_usd: 0.061, output_usd: 0.029} + - {messages: 8, tools: 15, input: 700, cache_read: 44000, cache_write: 1800, output: 520, reasoning: 286, prompt_usd: 0.074, output_usd: 0.039} - id: infra-55de title: "debug the helm chart" model: claude-sonnet-5 turns: - - {messages: 4, tools: 15, input: 800, cache_read: 12000, cache_write: 3200, output: 240, prompt_usd: 0.012, output_usd: 0.004} + - {messages: 4, tools: 15, input: 800, cache_read: 12000, cache_write: 3200, output: 240, reasoning: 132, prompt_usd: 0.012, output_usd: 0.004} diff --git a/authbridge/scripts/readme-demo/tuicapture.go b/authbridge/scripts/readme-demo/tuicapture.go index 5a4134436..0ba423d53 100644 --- a/authbridge/scripts/readme-demo/tuicapture.go +++ b/authbridge/scripts/readme-demo/tuicapture.go @@ -40,14 +40,18 @@ import ( // Turn is one request/response exchange with a model. type Turn struct { - Messages int `yaml:"messages"` - Tools int `yaml:"tools"` - Input int `yaml:"input"` - CacheRead int `yaml:"cache_read"` - CacheWrite int `yaml:"cache_write"` - Output int `yaml:"output"` - PromptUSD float64 `yaml:"prompt_usd"` - OutputUSD float64 `yaml:"output_usd"` + Messages int `yaml:"messages"` + Tools int `yaml:"tools"` + Input int `yaml:"input"` + CacheRead int `yaml:"cache_read"` + CacheWrite int `yaml:"cache_write"` + Output int `yaml:"output"` + // Reasoning is the share of Output the model spent thinking, so the `$` + // breakdown's reasoning child renders a real figure rather than the + // not-known cell. A SUBSET of Output, never added to it. + Reasoning int `yaml:"reasoning"` + PromptUSD float64 `yaml:"prompt_usd"` + OutputUSD float64 `yaml:"output_usd"` // ToolCall, when set, adds an outbound MCP tool call after the model // response, so the events timeline shows an agent doing something and not // only talking to a model. @@ -280,7 +284,9 @@ func (c *Capturer) build(f Fixture) []pendingEvent { CacheReadTokens: t.CacheRead, CacheWriteTokens: t.CacheWrite, OutputTokens: t.Output, - TotalTokens: t.Input + t.CacheRead + t.CacheWrite + t.Output, + ReasoningTokens: t.Reasoning, + // Reasoning is NOT added: it is already inside Output. + TotalTokens: t.Input + t.CacheRead + t.CacheWrite + t.Output, }, Plugins: costPlugins(t), Invocations: &pipeline.Invocations{Outbound: []pipeline.Invocation{ diff --git a/docs/proposals/cost-tier-breakdown.md b/docs/proposals/cost-tier-breakdown.md index 90aeb414a..09dfcce57 100644 --- a/docs/proposals/cost-tier-breakdown.md +++ b/docs/proposals/cost-tier-breakdown.md @@ -105,6 +105,22 @@ evidence available, `~` says it is inexact, and a reader who wants coverage has already labels it `reasoning (of output)` — so including it as a fifth bar would double-count. It stays in `abctl cost`'s token line and out of the bars. +> **Superseded in part.** `reasoning` is now drawn in this panel, as an *indented +> child of `output`* carrying its own bar and figure. +> +> The reason behind 3.5 is unchanged and still enforced: it is not a tier, it is +> excluded from the shares that sum to 100, `numTierRows` stays pinned to +> `pricing.NumTiers`, and `ApportionTiers` still returns exactly four figures summing +> to `CostMicros`. What changed is the inference that "not a tier" required "not +> drawn". This panel was the only cost surface that could not answer what an effort +> setting costs, and the containment is carried by the indent and by exclusion from +> the sum rather than by absence. +> +> The child's money is apportioned from `output`'s *displayed* figure and clamped to +> it, so it always divides into the row directly above. It renders the not-known cell +> when no split was reported, and is always present so the panel's height does not +> follow its data (6 below). See `childTierLabel` and `reasoningChildRow`. + **3.6 No residual twins for the new fields.** `CostMicros` has `UngroupedCostMicros` and `SeriesOvershootMicros` because the authoritative total must reconcile across grouping. A modelled mix is an apportionment key; an @@ -269,7 +285,11 @@ implementation: 2. A window priced entirely from gateway headers, with `Σ modelledTier == 0`, renders `emptyCell` and does not divide by zero (3.4). 3. A mix covering a small fraction of the priced spend still apportions, and wears `~` — the positive control for having removed the threshold, since a reintroduced floor would blank this case. 4. Display order is by amount, not by `pricing.Tier` declaration order (3.7). The fixture must order the two differently, or the test passes under either implementation. -5. `reasoning` never appears as a bar and never joins the sum (3.5). +5. `reasoning` never joins the sum (3.5). ~~never appears as a bar~~ — superseded: it + is drawn as an indented child of `output`, so the criterion is now that the four + unindented rows still sum to 100, that the child's figure and bar never exceed + `output`'s, and that a reported split too small to apportion renders the not-known + cell rather than `$0.00`. 6. The panel's line count is identical across every coverage state, which is what keeps the reservation honest. 7. At a width too narrow for two columns, the output equals today's drawer. 8. Saturation: a tier at `MaxInt64` sets `Saturated` and does not wrap — the failure already found once in `rankSeriesByCost`, whose raw `+=` ranked an overflowing series below a ten-micro one. From d9a20fab4f02f2cdc7a5a81cbc70bd34ff4e5429 Mon Sep 17 00:00:00 2001 From: Hai Huang Date: Thu, 24 Sep 2026 15:37:03 -0400 Subject: [PATCH 24/24] =?UTF-8?q?fix:=20Review=20round=2016=20=E2=80=94=20?= =?UTF-8?q?pin=20the=20drawer's=20height=20floor,=20and=20show=20a=20measu?= =?UTF-8?q?red=20zero?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two classes, each swept repo-wide rather than fixed where the review pointed. DERIVED-CONSTANT — swept 14 test sites naming a geometry producer (spendDrawerMinHeight, spendDrawerLinesFor, tierPanelLines, numTierRows), fixed 1. spendDrawerMinHeight was the last link in the chain with no independent witness: TestLayout_DrawerFloorLeavesAUsableTable sizes the terminal to the constant and then asserts against the constant, so both sides move together and reverting the floor to the literal 27 it once was left the whole package green. 27 is a genuine off-by-one, not a style choice — the chain probes as 20 + 7 + 1 = 28, and at 27 the table loses the row the floor exists to protect. spendDrawerLines was already pinned to the literal 7 twice, so this adds the missing link rather than a second copy of one. The other 12 sites compare a renderer against a constant it does not derive from, or test the floor's semantics at +/-1; both can fail, and are left alone. PRESENCE-VS-VALUE — swept every PresentKinds and ReasoningTokens site in the tree (253 hits across 7 layers), fixed 1. The detail pane was the one surface that never received this fix, on the class the PR already spent four rounds on. ReasoningTokens is omitempty and filterFields keeps only keys the map actually has, so a provider that measured the split and found none rendered identically to one that never measured — on the surface inferenceRespKeys' own doc calls the home of the exact figure. Repaired at the pane, not the wire format: dropping omitempty would publish a zero for every provider that never reports a split, which is the opposite error. cmd_cost.go, spend_tiers.go and apportion.go were each re-read and are correct; apportion deliberately does not consult the bit and documents why. | New assertion | Mutation | Verdict | |---|---|---| | TestSpendDrawerMinHeight_IsTwentyEight | floor back to the literal 27 (mut-m14) | killed, and the only test in the package that failed | | ReportedZero.../reasoning_reported_as_zero | drop the restoreReportedZeroSplits call (mut-n1) | killed | | ReportedZero.../other_kinds_but_not_reasoning | publish the zero ignoring the presence bit (mut-n2b) | killed | mut-n2b SURVIVED its first run, and that is why the new test is a three-row table rather than the matched pair it started as. With only the two ends, the no-bits fixture returns at the type assertion — PresentKinds is omitempty too, so an all-zero bitfield leaves no key to read — and never reaches the bit test at all. Only a producer that reports other kinds and not reasoning puts that test on the path. A comment claiming the pair covered both directions was written and then removed, because it was false. A first n2 attempt reported EXIT:1 from an unused-import compile error, which is not a kill and was redone. Also corrects TestLayout_DrawerFloorLeavesAUsableTable's own doc, which concluded "the derivation is what guards the single row" while nothing checked the derivation. Full suite: 67 packages ok, plus the readme-demo committed-asset check. Budget: 3 files, all already in the PR's set; prod +36, test +118. Assisted-By: Claude (Anthropic AI) Signed-off-by: Hai Huang --- authbridge/cmd/abctl/tui/detail_pane.go | 36 ++++++++ .../cmd/abctl/tui/detail_reasoning_test.go | 84 +++++++++++++++++++ authbridge/cmd/abctl/tui/layout_fit_test.go | 38 ++++++++- 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/authbridge/cmd/abctl/tui/detail_pane.go b/authbridge/cmd/abctl/tui/detail_pane.go index 0875b3fda..634096d69 100644 --- a/authbridge/cmd/abctl/tui/detail_pane.go +++ b/authbridge/cmd/abctl/tui/detail_pane.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/x/ansi" "github.com/rossoctl/cortex/authbridge/authlib/costevent" "github.com/rossoctl/cortex/authbridge/authlib/pipeline" + "github.com/rossoctl/cortex/authbridge/authlib/usage" ) // showDetail loads the row's event into the detail viewport as colorized @@ -145,6 +146,7 @@ func filterForDetail(data []byte, phase pipeline.SessionPhase) []byte { a2aKeep = a2aRespKeys } if inf, ok := m["inference"].(map[string]any); ok { + restoreReportedZeroSplits(inf) m["inference"] = filterFields(inf, keep) } if mcp, ok := m["mcp"].(map[string]any); ok { @@ -210,6 +212,40 @@ var ( a2aRespKeys = []string{"method", "rpcId", "sessionId", "taskId", "finalStatus", "artifact", "errorMessage"} ) +// restoreReportedZeroSplits puts back a split the provider MEASURED AND FOUND TO BE ZERO. +// +// InferenceExtension.ReasoningTokens is omitempty, so a provider that measured the split and +// got none serialises identically to one that never measured at all — and filterFields keeps +// only keys the map actually has, so both read as "no reasoning" in the pane that +// inferenceRespKeys' own doc calls the place the exact figure lives. A measured zero is a +// figure; absence is the lack of one. PresentKinds is the only thing that separates them, +// and it travels in the same object. +// +// THE PANE, NOT THE WIRE FORMAT. Dropping omitempty upstream would publish a zero for every +// provider that never reports a split — the opposite error, and the one the tier panel's +// JSON tests forbid. So the repair belongs here, at the surface that lost the distinction. +// +// NO PHASE GUARD. Reasoning is response-side, but what keeps it off a request row is +// inferenceReqKeys not listing it — one allow-list deciding visibility, per this file's +// existing rule about a second copy of a predicate. A phase check here would be a guard the +// allow-list already makes unreachable, so nothing could prove it worked. +// +// presentKinds is itself absent from both allow-lists and stays that way: the reader gets +// the figure, not the bitfield. +func restoreReportedZeroSplits(inf map[string]any) { + if _, ok := inf["reasoningTokens"]; ok { + return // a non-zero figure cleared omitempty on its own + } + // json.Unmarshal into `any` numbers everything as float64, including a uint8 bitfield. + bits, ok := inf["presentKinds"].(float64) + if !ok { + return // no presence bits: a producer predating them, where absence is all we know + } + if uint8(bits)&usage.KindReasoning != 0 { + inf["reasoningTokens"] = 0 + } +} + // filterFields returns a new map containing only the keys in `keep` that are // present in obj. Keys not listed are dropped. This is strict filtering — // unlike a partition, fields absent from the allow-list do not pass through. diff --git a/authbridge/cmd/abctl/tui/detail_reasoning_test.go b/authbridge/cmd/abctl/tui/detail_reasoning_test.go index 1a30e3d9e..cb95fac0d 100644 --- a/authbridge/cmd/abctl/tui/detail_reasoning_test.go +++ b/authbridge/cmd/abctl/tui/detail_reasoning_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/rossoctl/cortex/authbridge/authlib/pipeline" + "github.com/rossoctl/cortex/authbridge/authlib/usage" ) // The detail pane is where the EXACT reasoning figure lives. @@ -65,3 +66,86 @@ func TestFilterForDetail_UnreportedReasoningIsAbsentNotZero(t *testing.T) { t.Errorf("a provider reporting no split still shows reasoningTokens:\n%s", got) } } + +// THE OTHER HALF OF THE ONE ABOVE, and the half this pane was missing. A provider that +// measured the split and got zero has told us something — the model did no reasoning on +// this call — and that is not the same statement as a provider which never measured. +// omitempty erases the difference on the wire, so the pane has to read the presence bit +// to put it back; otherwise the surface the PR calls the home of the exact figure is the +// one surface that cannot show a zero. +// +// THREE STATES OVER ONE ABSENT KEY, not two, and the middle one is why this is a table. +// Publishing the zero unconditionally survived every test in this package when the suite +// had only the two ends: the no-bits fixture returns at the type assertion — PresentKinds +// is omitempty too, so an all-zero bitfield leaves no key to read — and so never reaches +// the bit test at all. Only a producer that reports OTHER kinds and not reasoning puts the +// bit test on the path. That mutation is mut-n2b, and this row is what kills it. +func TestFilterForDetail_ReportedZeroReasoningIsShownNotHidden(t *testing.T) { + for _, tc := range []struct { + name string + bits uint8 + wantZeroShown bool + }{ + // No bits at all: a producer predating PresentKinds. The value is the only + // evidence there is, and it says nothing, so the key stays out. + {"no presence bits", 0, false}, + // Reports output but NOT reasoning: it measured, and reasoning was not among + // what it measured. Still absent — and this is the row the bit test needs. + {"other kinds but not reasoning", usage.KindOutput, false}, + // Reports reasoning, and the figure is zero: a measurement of none. + {"reasoning reported as zero", usage.KindOutput | usage.KindReasoning, true}, + } { + t.Run(tc.name, func(t *testing.T) { + ext := &pipeline.InferenceExtension{ + Model: "claude-opus-5", OutputTokens: 244, CompletionTokens: 244, + ReasoningTokens: 0, + PresentKinds: tc.bits, + } + raw, err := json.Marshal(map[string]any{"inference": ext}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Every row has to actually reach the gap: omitempty must really have dropped + // the key, or a row passes on a figure that was never missing. + if strings.Contains(string(raw), "reasoningTokens") { + t.Fatalf("fixture does not reach the defect — omitempty kept the key:\n%s", raw) + } + + got := string(filterForDetail(raw, pipeline.SessionResponse)) + // The VALUE, not just the key: a restored zero that came back as something + // else would satisfy a key-presence check while misreporting the measurement. + // filterForDetail returns compact json.Marshal output; the colorizer spaces it. + shown := strings.Contains(got, `"reasoningTokens":0`) + if shown != tc.wantZeroShown { + t.Errorf("reported-zero shown = %v, want %v — the pane cannot distinguish "+ + "\"the model did no reasoning\" from \"this provider does not say\":\n%s", + shown, tc.wantZeroShown, got) + } + // Whatever the verdict, the key must never carry a non-zero figure it invented. + if strings.Contains(got, "reasoningTokens") && !shown { + t.Errorf("reasoningTokens is present but not the reported zero:\n%s", got) + } + // The bitfield is plumbing, not a figure for the reader. + if strings.Contains(got, "presentKinds") { + t.Errorf("the presence bitfield leaked into the display:\n%s", got) + } + }) + } +} + +// A REPORTED ZERO MUST NOT LEAK ONTO A REQUEST ROW EITHER. restoreReportedZeroSplits runs +// on both phases by design — the allow-list is the one thing that decides visibility — so +// this is what proves that design holds rather than merely being asserted in its comment. +func TestFilterForDetail_RequestDropsAReportedZeroToo(t *testing.T) { + ext := &pipeline.InferenceExtension{ + Model: "claude-opus-5", ReasoningTokens: 0, + PresentKinds: usage.KindOutput | usage.KindReasoning, + } + raw, err := json.Marshal(map[string]any{"inference": ext}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if got := string(filterForDetail(raw, pipeline.SessionRequest)); strings.Contains(got, "reasoningTokens") { + t.Errorf("request detail carries a restored reasoning zero:\n%s", got) + } +} diff --git a/authbridge/cmd/abctl/tui/layout_fit_test.go b/authbridge/cmd/abctl/tui/layout_fit_test.go index 70056c634..486b276ef 100644 --- a/authbridge/cmd/abctl/tui/layout_fit_test.go +++ b/authbridge/cmd/abctl/tui/layout_fit_test.go @@ -531,21 +531,51 @@ func TestLayout_DrawerReservationMatchesWhatItDraws(t *testing.T) { } } +// THE FLOOR'S VALUE, AGAINST A LITERAL — the witness the test below cannot be. +// +// TestLayout_DrawerFloorLeavesAUsableTable sizes the terminal to spendDrawerMinHeight and +// then asserts against spendDrawerMinHeight, so both sides move together and every value +// passes it. Its own doc said as much and concluded "the derivation is what guards the +// single row" — but nothing checked the derivation, so reverting the constant to the +// literal 27 it once was left this whole package green. 27 is not a style choice: against +// a seven-row drawer it costs the table the row the floor exists to protect. +// +// A LITERAL, like spend_sanitize_test.go's line pins and for the same reason: a +// right-hand side spelled with spendStripMinHeight + spendDrawerLines + dividerLines is +// the tautology this replaces. The terms are named in the failure message instead, so a +// deliberate change to any of them reads as one number to update and an accidental one +// names what moved. +// +// spendDrawerLines is already pinned to 7 twice (TestSpendDrawerLines_AccountsForTheChildRow +// and the {120, 7} row in spend_sanitize_test.go), so this is the last unwitnessed link in +// the chain, not a second copy of one. +func TestSpendDrawerMinHeight_IsTwentyEight(t *testing.T) { + const wantFloor = 28 // 20 strip rows + 7 drawer rows + 1 divider + if spendDrawerMinHeight != wantFloor { + t.Errorf("the drawer's height floor is %d, want %d — recompute it from the terms: "+ + "spendStripMinHeight %d + spendDrawerLines %d + dividerLines %d. If one of those "+ + "moved deliberately, update this literal; if none did, the floor has been written "+ + "as a constant again and no longer follows the drawer's height", + spendDrawerMinHeight, wantFloor, + spendStripMinHeight, spendDrawerLines, dividerLines) + } +} + // AT THE FLOOR, THE DRAWER OPENS AND THE TABLE IS STILL USABLE — the property // spendDrawerMinHeight exists for, in its own words: "opening it leaves the table more // than a couple of rows". // // WHAT THIS CANNOT CATCH, and the reason is worth stating rather than discovering later. -// The floor is now derived (spendStripMinHeight + spendDrawerLines + dividerLines), so -// any assertion comparing it to those components is a tautology — the defect class three +// The floor is derived (spendStripMinHeight + spendDrawerLines + dividerLines), so any +// assertion here comparing it to those components is a tautology — the defect class three // earlier rounds of review found in this package. A one-row drift is therefore not // detectable here: with the floor at 27 against a seven-row drawer the body is 14 rows // instead of 15, and no non-arbitrary threshold separates those. // // What it does catch is a floor that has come loose altogether — low enough that opening // the drawer squeezes the table to nothing, which is the failure the constant's doc -// describes and the one that makes the drawer "a pane, badly". The derivation is what -// guards the single row. +// describes and the one that makes the drawer "a pane, badly". The single row is guarded +// by TestSpendDrawerMinHeight_IsTwentyEight above, which pins the value to a literal. func TestLayout_DrawerFloorLeavesAUsableTable(t *testing.T) { forceColor(t) const w = 120