diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic.go b/authbridge/authlib/plugins/inferenceparser/anthropic.go index d5a1ba152..d9bf001da 100644 --- a/authbridge/authlib/plugins/inferenceparser/anthropic.go +++ b/authbridge/authlib/plugins/inferenceparser/anthropic.go @@ -129,18 +129,31 @@ func parseAnthropicRequest(body []byte) *pipeline.InferenceExtension { // // Cache fields are *int so an omitted field on the wire (e.g. message_start // on the ?beta=true path) stays absent in Present rather than being asserted -// as a reported zero. +// as a reported zero. OutputTokensDetails is a pointer for the same reason, and +// it needs it more: it is absent on message_start and on message_stop, and +// present only on message_delta. type anthropicUsage struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` CacheCreationInputTokens *int `json:"cache_creation_input_tokens"` CacheReadInputTokens *int `json:"cache_read_input_tokens"` + + // OutputTokensDetails splits output_tokens by what generated it. Anthropic reports + // one sub-field, thinking_tokens: the share spent on internal reasoning, a SUBSET of + // OutputTokens rather than a sibling. + OutputTokensDetails *struct { + ThinkingTokens *int `json:"thinking_tokens"` + } `json:"output_tokens_details"` } // toNeutral maps Anthropic's usage onto TokenUsage. Input and Output are -// always emitted by the Messages API; cache sub-fields are observed via -// their pointers so an absent field stays absent in Present. Reasoning is -// not exposed by Anthropic. +// always emitted by the Messages API; cache sub-fields and the output-token +// details are observed via their pointers so an absent field stays absent in +// Present. +// +// Reasoning comes from output_tokens_details.thinking_tokens, verified against a live +// claude-opus-5 turn and documented at +// https://platform.claude.com/docs/en/build-with-claude/thinking-steering-and-cost func (u anthropicUsage) toNeutral() parsercommon.TokenUsage { n := parsercommon.TokenUsage{ Input: u.InputTokens, @@ -155,6 +168,12 @@ func (u anthropicUsage) toNeutral() parsercommon.TokenUsage { n.CacheWrite = *u.CacheCreationInputTokens n.Present |= parsercommon.KindCacheWrite } + // BOTH POINTERS: a details object forwarded without the count inside it reports + // nothing, and must not set the bit. + if u.OutputTokensDetails != nil && u.OutputTokensDetails.ThinkingTokens != nil { + n.Reasoning = *u.OutputTokensDetails.ThinkingTokens + n.Present |= parsercommon.KindReasoning + } return n } @@ -333,7 +352,7 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline switch ev.Type { case "message_start": if ev.Message != nil { - mergeAnthropicPromptMaxSeen(state, ev.Message.Usage.toNeutral()) + mergeAnthropicUsageMaxSeen(state, ev.Message.Usage.toNeutral()) state.hasUsage = true } case "content_block_start": @@ -377,7 +396,7 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline // message_delta; non-beta path carries no input counts here. // Max-seen per sub-field handles both without clobbering. neutral := ev.Usage.toNeutral() - mergeAnthropicPromptMaxSeen(state, neutral) + mergeAnthropicUsageMaxSeen(state, neutral) if neutral.Output > 0 { state.usage.Output = neutral.Output // cumulative } @@ -386,14 +405,34 @@ func foldAnthropicFrame(frame []byte, state *inferenceStreamState, ext *pipeline } } -// mergeAnthropicPromptMaxSeen updates prompt-side sub-fields with -// max-seen semantics so a later event carrying zero cannot clobber an -// earlier real count. See foldAnthropicFrame for why both events need -// this. -func mergeAnthropicPromptMaxSeen(state *inferenceStreamState, incoming parsercommon.TokenUsage) { +// mergeAnthropicUsageMaxSeen updates every sub-field this function owns with +// max-seen semantics so a later event carrying zero cannot clobber an earlier +// real count. See foldAnthropicFrame for why both events need this. +// +// EVERY SUB-FIELD IT OWNS: Present is unioned here for ALL kinds, so any kind whose bit +// this sets must have its value merged here too. Split across two places, a bit arrives +// set with a value of nothing — and `abctl cost` then prints "reasoning (of output) 0", +// the claim ThinkingTokensAbsent forbids. +// +// Output is deliberately NOT here: it is cumulative on the wire rather than max-seen, +// and foldAnthropicFrame assigns it directly. +func mergeAnthropicUsageMaxSeen(state *inferenceStreamState, incoming parsercommon.TokenUsage) { // Presence is a union across events: once a sub-field is observed on - // the wire, later events that omit it must not clear the bit. + // the wire, later events that omit it must not clear the bit. Kept beside the + // value merges below so that every kind merged HERE has its bit and its value set + // in one place. + // + // KindOutput is the one exception, and naming it is the point: toNeutral asserts + // that bit unconditionally, so this union sets it while Output is filled in + // foldAnthropicFrame instead — it is cumulative on the wire, not max-seen. That + // split predates this function and pricing.outputUncounted depends on it. The + // exception is written down because the reasoning bug this signature was changed + // to fix was a bit set here and a value filled elsewhere; an unqualified claim + // that it cannot happen would hide the one place it still does. state.usage.Present |= incoming.Present + if incoming.Reasoning > state.usage.Reasoning { + state.usage.Reasoning = incoming.Reasoning + } if incoming.Input > state.usage.Input { state.usage.Input = incoming.Input } diff --git a/authbridge/authlib/plugins/inferenceparser/anthropic_test.go b/authbridge/authlib/plugins/inferenceparser/anthropic_test.go index 680040597..80450d8a9 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,275 @@ func TestInferenceParser_AnthropicMessages_QueryStringPath(t *testing.T) { t.Errorf("FinishReason = %q, want end_turn", ext.FinishReason) } } + +// TestInferenceParser_AnthropicMessages_NonStreamingThinkingTokens covers +// usage.output_tokens_details.thinking_tokens, which reports how much of +// output_tokens the model spent on internal reasoning. The usage block below is +// captured verbatim from a real claude-opus-5 turn at output_config.effort "max": +// 948 of 1593 generated tokens were thinking. Reasoning is a SUBSET of output, +// never a sibling — adding them double-counts every thinking token. +func TestInferenceParser_AnthropicMessages_NonStreamingThinkingTokens(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{Path: "/v1/messages"} + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true} + + body := []byte(`{ + "id": "msg_bdrk_1", "type": "message", "role": "assistant", "model": "claude-opus-5", + "content": [{"type": "thinking", "thinking": ""}, {"type": "text", "text": "582, 663, 744, 825, 906."}], + "stop_reason": "end_turn", + "usage": { + "input_tokens": 56, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + "output_tokens": 1593, + "output_tokens_details": {"thinking_tokens": 948} + } + }`) + p.OnResponseFrame(context.Background(), pctx, body, true) + + ext := pctx.Extensions.Inference + if ext.ReasoningTokens != 948 { + t.Errorf("ReasoningTokens = %d, want 948", ext.ReasoningTokens) + } + // Reasoning must not inflate the totals it is a subset of. + if ext.OutputTokens != 1593 || ext.TotalTokens != 1649 { + t.Errorf("output %d / total %d, want 1593/1649 (reasoning must not be added)", + ext.OutputTokens, ext.TotalTokens) + } + if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 { + t.Errorf("PresentKinds = %#b, want KindReasoning set", ext.PresentKinds) + } +} + +// TestInferenceParser_AnthropicMessages_StreamThinkingTokensOnMessageDelta pins +// where the field actually arrives when streaming — which is what every Claude +// Code turn does. The three usage blocks below are captured verbatim from a real +// streamed claude-opus-5 turn: message_start carries NO output_tokens_details, +// message_delta carries it, and the trailing message_stop carries a usage block +// with output_tokens but no details at all. +// +// That last frame is the trap: reading details from message_start finds nothing, +// and any last-wins fold over message_stop would zero a real count back out. This +// is the same failure shape as the ?beta=true prompt-cache counts. +func TestInferenceParser_AnthropicMessages_StreamThinkingTokensOnMessageDelta(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{Path: "/v1/messages"} + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", Stream: true, IsAction: true} + + frames := [][]byte{ + []byte(`{"type":"message_start","message":{"id":"msg_bdrk_2","type":"message","role":"assistant","usage":{"input_tokens":22,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":6}}}`), + []byte(`{"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`), + []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"1729 is a taxicab number."}}`), + []byte(`{"type":"content_block_stop","index":0}`), + []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":22,"output_tokens":235,"output_tokens_details":{"thinking_tokens":119}}}`), + []byte(`{"type":"message_stop","usage":{"input_tokens":22,"output_tokens":235}}`), + } + for _, f := range frames { + if action := p.OnResponseFrame(context.Background(), pctx, f, false); action.Type != pipeline.Continue { + t.Fatalf("frame action = %v, want Continue", action.Type) + } + } + p.OnResponseFrame(context.Background(), pctx, nil, true) + + ext := pctx.Extensions.Inference + if ext.ReasoningTokens != 119 { + t.Errorf("ReasoningTokens = %d, want 119 (from message_delta, not cleared by message_stop)", + ext.ReasoningTokens) + } + if ext.OutputTokens != 235 { + t.Errorf("OutputTokens = %d, want 235", ext.OutputTokens) + } + if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 { + t.Errorf("PresentKinds = %#b, want KindReasoning set", ext.PresentKinds) + } +} + +// TestInferenceParser_AnthropicMessages_ThinkingTokensAbsent guards the +// distinction PresentKinds exists to carry: a provider that never reports a +// reasoning split must leave the bit CLEAR, not assert a reported zero. Claude +// with thinking off, and every pre-details gateway, land here — and a set bit +// would make abctl print "reasoning (of output) 0", which claims the model did no +// reasoning when the truth is that nothing said. +func TestInferenceParser_AnthropicMessages_ThinkingTokensAbsent(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{Path: "/v1/messages"} + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true} + + body := []byte(`{ + "id": "msg_bdrk_3", "type": "message", "role": "assistant", "model": "claude-opus-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 4} + }`) + p.OnResponseFrame(context.Background(), pctx, body, true) + + ext := pctx.Extensions.Inference + if ext.ReasoningTokens != 0 { + t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens) + } + if ext.PresentKinds&uint8(parsercommon.KindReasoning) != 0 { + t.Errorf("PresentKinds = %#b, want KindReasoning CLEAR when the wire omits details", + ext.PresentKinds) + } +} + +// TestInferenceParser_AnthropicMessages_ThinkingTokensPartiallyAbsent pins the +// INNER nil check, which the outer one does not cover. +// +// A gateway can forward the output_tokens_details key without the count inside it +// — an empty object, or an explicit null. Both leave the struct pointer non-nil +// and the int pointer nil, so a guard that tested only the outer pointer would +// dereference nil and panic the parser on a well-formed HTTP response. Neither +// shape appeared in any fixture, so removing the inner check broke nothing that +// any test could see. +// +// The correct outcome is the same as fully absent: KindReasoning stays CLEAR, +// because a key with no number in it reported nothing. +func TestInferenceParser_AnthropicMessages_ThinkingTokensPartiallyAbsent(t *testing.T) { + for _, tc := range []struct{ name, details string }{ + {"empty details object", `"output_tokens_details": {}`}, + {"explicit null count", `"output_tokens_details": {"thinking_tokens": null}`}, + {"unrelated sub-field only", `"output_tokens_details": {"something_else": 7}`}, + } { + t.Run(tc.name, func(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{Path: "/v1/messages"} + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true} + + body := []byte(`{ + "id": "msg_bdrk_4", "type": "message", "role": "assistant", "model": "claude-opus-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 4, ` + tc.details + `} + }`) + // Must not panic, and must not claim a measurement. + p.OnResponseFrame(context.Background(), pctx, body, true) + + ext := pctx.Extensions.Inference + if ext.ReasoningTokens != 0 { + t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens) + } + if ext.PresentKinds&uint8(parsercommon.KindReasoning) != 0 { + t.Errorf("PresentKinds = %#b, want KindReasoning CLEAR for a count-free details object", + ext.PresentKinds) + } + // The kinds that WERE reported must survive the partial details object. + if ext.OutputTokens != 4 { + t.Errorf("OutputTokens = %d, want 4", ext.OutputTokens) + } + }) + } +} + +// TestInferenceParser_AnthropicMessages_ThinkingTokensOnMessageStart pins that a +// value and its presence bit travel together. +// +// Anthropic puts output_tokens_details on message_delta today, which is why the +// streaming fixture above does. Nothing guarantees that: a gateway may relay it on +// message_start instead. When the value merge lived in the message_delta branch +// while Present was unioned for every event, this input set KindReasoning with a +// value of 0 — and `abctl cost` would print "reasoning (of output) 0", the exact +// claim ThinkingTokensAbsent forbids. +func TestInferenceParser_AnthropicMessages_ThinkingTokensOnMessageStart(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{Path: "/v1/messages"} + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", Stream: true, IsAction: true} + + frames := [][]byte{ + []byte(`{"type":"message_start","message":{"id":"msg_bdrk_5","type":"message","role":"assistant","usage":{"input_tokens":22,"output_tokens":6,"output_tokens_details":{"thinking_tokens":119}}}}`), + []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`), + // No details here, so only the max-seen merge can carry the earlier value. + []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":22,"output_tokens":235}}`), + } + for _, f := range frames { + p.OnResponseFrame(context.Background(), pctx, f, false) + } + p.OnResponseFrame(context.Background(), pctx, nil, true) + + ext := pctx.Extensions.Inference + if ext.ReasoningTokens != 119 { + t.Errorf("ReasoningTokens = %d, want 119 from message_start", ext.ReasoningTokens) + } + if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 { + t.Errorf("PresentKinds = %#b, want KindReasoning set", ext.PresentKinds) + } + // NO "the bit must never be set with a zero value" CHECK HERE, and deliberately so: + // that shape is LEGAL. TestInferenceParser_AnthropicMessages_ThinkingTokensReportedZero + // REQUIRES it — a present-and-zero count is a measurement, not an absence, which is the + // rule apportion.go and the drawer's reportedZero branch are both built on. An assertion + // forbidding it here stated the opposite of the design and was inert only because this + // fixture reports 119. + // + // The split-brain bug this test exists for is already caught above: a bit unioned in one + // place with the value merged in another leaves ReasoningTokens at 0 against a set bit, + // and the 119 assertion fails on exactly that. +} + +// TestInferenceParser_AnthropicMessages_ThinkingTokensReportedZero pins the wire shape +// the partially-absent cases above cannot express: the count is PRESENT and it is zero. +// +// That is a measurement — the model was asked to think and spent nothing on it, which +// is the observation that says an effort setting is not reaching the model — so +// KindReasoning must be SET, unlike every absent shape. A parser that treated zero as +// absence would pass every other fixture here. +func TestInferenceParser_AnthropicMessages_ThinkingTokensReportedZero(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{Path: "/v1/messages"} + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", IsAction: true} + + body := []byte(`{ + "id": "msg_bdrk_6", "type": "message", "role": "assistant", "model": "claude-opus-5", + "content": [{"type": "text", "text": "ok"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 400, + "output_tokens_details": {"thinking_tokens": 0}} + }`) + p.OnResponseFrame(context.Background(), pctx, body, true) + + ext := pctx.Extensions.Inference + if ext.ReasoningTokens != 0 { + t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens) + } + if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 { + t.Errorf("PresentKinds = %#b, want KindReasoning SET — a reported zero is a "+ + "measurement, not an absence", ext.PresentKinds) + } +} + +// TestInferenceParser_AnthropicMessages_ThinkingTokensStreamedZero is the STREAMING +// reported-zero shape, which the non-streaming case above cannot stand in for. +// +// It is the shape mergeAnthropicUsageMaxSeen's own doc is built around: a bit set while +// the value is nothing. Max-seen means a zero never raises the running total, so the +// only thing carrying the observation across frames is the Present union — and a fold +// that took presence from the value would drop it here while passing every other +// streaming fixture, all of which report a non-zero count. +func TestInferenceParser_AnthropicMessages_ThinkingTokensStreamedZero(t *testing.T) { + p := NewInferenceParser() + pctx := &pipeline.Context{Path: "/v1/messages"} + pctx.Extensions.Inference = &pipeline.InferenceExtension{Model: "claude-opus-5", Stream: true, IsAction: true} + + frames := [][]byte{ + []byte(`{"type":"message_start","message":{"id":"msg_bdrk_7","type":"message","role":"assistant","usage":{"input_tokens":22,"output_tokens":6}}}`), + []byte(`{"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"ok"}}`), + []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"input_tokens":22,"output_tokens":400,"output_tokens_details":{"thinking_tokens":0}}}`), + // A trailing details-free block, which must not clear what was reported. + []byte(`{"type":"message_stop","usage":{"input_tokens":22,"output_tokens":400}}`), + } + for _, f := range frames { + p.OnResponseFrame(context.Background(), pctx, f, false) + } + p.OnResponseFrame(context.Background(), pctx, nil, true) + + ext := pctx.Extensions.Inference + if ext.ReasoningTokens != 0 { + t.Errorf("ReasoningTokens = %d, want 0", ext.ReasoningTokens) + } + if ext.PresentKinds&uint8(parsercommon.KindReasoning) == 0 { + t.Errorf("PresentKinds = %#b, want KindReasoning SET — the count was on the wire and "+ + "it was zero, which is a measurement", ext.PresentKinds) + } + if ext.OutputTokens != 400 { + t.Errorf("OutputTokens = %d, want 400", ext.OutputTokens) + } +} 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/authlib/usage/apportion.go b/authbridge/authlib/usage/apportion.go index b8ddf92e5..6a1964ca0 100644 --- a/authbridge/authlib/usage/apportion.go +++ b/authbridge/authlib/usage/apportion.go @@ -69,3 +69,67 @@ 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: 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. +// +// 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. +// +// 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. +// +// 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) { + // 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 + } + 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..8c7b95c98 100644 --- a/authbridge/authlib/usage/apportion_test.go +++ b/authbridge/authlib/usage/apportion_test.go @@ -105,3 +105,93 @@ 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}, + // 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 + 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) + } + if got < 0 { + t.Errorf("micros = %d is NEGATIVE; a caller would publish negative money "+ + "or hand it to tierBar", 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/authlib/usage/usage.go b/authbridge/authlib/usage/usage.go index 41e074687..a7aecafe4 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. + // + // 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/README.md b/authbridge/cmd/abctl/README.md index 892acb96f..6da892d92 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,10 +515,32 @@ 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 86 columns the tier column drops and the panel degrades to the by-model breakdown alone. - These rows carry **no** `~`, unlike the sessions table's `SAVED~`. The caveat is + `└ 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. + + `└ 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, 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/cmd_cost.go b/authbridge/cmd/abctl/cmd_cost.go index 0535a36d4..06c760e79 100644 --- a/authbridge/cmd/abctl/cmd_cost.go +++ b/authbridge/cmd/abctl/cmd_cost.go @@ -344,6 +344,14 @@ 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, from + // usage.ApportionReasoning — the same call the drawer draws from, so a consumer never + // reimplements the rule. + // + // 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"` } // tiersJSONOf apportions the totals, or returns nil when there is no mix to apportion by. @@ -352,12 +360,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 new file mode 100644 index 000000000..91e29879a --- /dev/null +++ b/authbridge/cmd/abctl/cost_token_split_test.go @@ -0,0 +1,188 @@ +package main + +import ( + "encoding/json" + "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) + } +} + +// 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) + } +} + +// `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") + } + // 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) + } + // 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) + } +} + +// 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) + } +} + +// 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/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/detail_pane.go b/authbridge/cmd/abctl/tui/detail_pane.go index 6bc1e776f..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 { @@ -186,9 +188,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"} @@ -202,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 new file mode 100644 index 000000000..cb95fac0d --- /dev/null +++ b/authbridge/cmd/abctl/tui/detail_reasoning_test.go @@ -0,0 +1,151 @@ +package tui + +import ( + "encoding/json" + "strings" + "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. +// +// 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) + } +} + +// 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/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/layout_fit_test.go b/authbridge/cmd/abctl/tui/layout_fit_test.go index 48f71acb2..486b276ef 100644 --- a/authbridge/cmd/abctl/tui/layout_fit_test.go +++ b/authbridge/cmd/abctl/tui/layout_fit_test.go @@ -461,3 +461,144 @@ 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 (< 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, 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 + {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] + 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 + } 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 "+ + "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") + } + // 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) + } +} + +// 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 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 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 + 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 c2a4f16d9..ecf38a973 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer.go +++ b/authbridge/cmd/abctl/tui/spend_drawer.go @@ -39,30 +39,66 @@ 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 - - // spendDrawerLines is how many rows the drawer adds to the view, and therefore how many - // layout() must hold back for it. + // 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, 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, or + // widening the terminal would squeeze the table. + spendDrawerMinHeight = spendStripMinHeight + spendDrawerLines + dividerLines + + // 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 // 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 + // + // 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 ) +// 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 +// for tierPanelLines; reserving that height unconditionally costs a narrow terminal a +// body row for a row it cannot draw. +// +// 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 drawerTwoColumn(width) { + 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 @@ -242,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 @@ -252,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 { @@ -683,13 +720,13 @@ 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) 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( @@ -703,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 { @@ -713,10 +750,23 @@ 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. + // 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. + // + // 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 + // 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) @@ -738,8 +788,18 @@ 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. 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] + } 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 @@ -750,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 @@ -760,7 +820,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 1592e55be..5eeb3678a 100644 --- a/authbridge/cmd/abctl/tui/spend_drawer_test.go +++ b/authbridge/cmd/abctl/tui/spend_drawer_test.go @@ -6,6 +6,10 @@ import ( "math" "net/http" "net/http/httptest" + "os" + "path/filepath" + "regexp" + "strconv" "strings" "testing" "time" @@ -13,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" ) @@ -1299,13 +1304,54 @@ 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. +// 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) { - 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) - } + 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) + } + } + }) } } @@ -1807,3 +1853,150 @@ 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) + } + }) + } +} + +// 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) + } + // 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. + // + // 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) + } +} + +// 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")) + } + // 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) + } + } + // 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) + } +} + +// 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 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, "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 { + t.Fatalf("read README: %v", err) + } + 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) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_sanitize_test.go b/authbridge/cmd/abctl/tui/spend_sanitize_test.go index 20e664f53..6d83c350e 100644 --- a/authbridge/cmd/abctl/tui/spend_sanitize_test.go +++ b/authbridge/cmd/abctl/tui/spend_sanitize_test.go @@ -152,11 +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 (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 { + width, wantLines int + }{{20, 6}, {40, 6}, {72, 6}, {120, 7}} { + width := tc.width lines := renderSpendDrawer(nil, wide, usage.GroupModel, "MONTH", width) - if len(lines) != spendDrawerLines { + 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), spendDrawerLines) + "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.go b/authbridge/cmd/abctl/tui/spend_tiers.go index c98cd1c2f..ba0cadad3 100644 --- a/authbridge/cmd/abctl/tui/spend_tiers.go +++ b/authbridge/cmd/abctl/tui/spend_tiers.go @@ -20,18 +20,23 @@ 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, 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 // 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. // @@ -43,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. // @@ -64,6 +70,27 @@ var tierLabels = map[pricing.Tier]string{ pricing.TierOutput: "output", } +// 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 +// 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 +// 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, @@ -78,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 @@ -150,7 +186,131 @@ 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, 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, + 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 + // 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. + // A REPORTED ZERO IS A MEASUREMENT, and it renders an exact $0.00. + // + // 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 "—". + // + // 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 + // exactly the four tiers. + // + // 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. + pct := 0 + if c.CostMicros > 0 { + 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. + // + // 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: + row = fmt.Sprintf("%-*s %s %-*s %s", tierLabelWidth, label, + tierShareCell(pct, micros), budget, tierBar(micros, peak, budget), money) + default: + row = fmt.Sprintf("%-*s %s %s", tierLabelWidth, label, + tierShareCell(pct, micros), money) + } + 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 { + // 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 + 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..4eb0a5ff0 --- /dev/null +++ b/authbridge/cmd/abctl/tui/spend_tiers_reasoning_test.go @@ -0,0 +1,662 @@ +package tui + +import ( + "strconv" + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + + "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 +} + +// 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, childTierLabel) { + 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.HasPrefix(l, childTierLabel): + 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) + } + // 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) + } +} + +// 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, childTierLabel) { // the child, not a tier + 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. +// +// 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) { + sane := reasoningCounts() + + // 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. 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 + + 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 + }{ + {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 + 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 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) + } + // 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. + // + // 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 { + 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) + } + // 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.. + 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 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 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) + } + } + }) + } +} + +// 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 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 +} + +// 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) { + // 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) + } + // 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) + } +} + +// 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) { + // 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, "$") + 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 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, 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 "+ + "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. + // + // 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 + } + 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 +// 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 + } +} + +// 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")) + } +} + +// 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 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) + } + }) + } +} diff --git a/authbridge/cmd/abctl/tui/spend_tiers_test.go b/authbridge/cmd/abctl/tui/spend_tiers_test.go index ae0636a51..8f54698dc 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,29 @@ 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. +// +// 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, childTierLabel) { + 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 +69,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 +131,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 +228,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 +260,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 +274,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) } @@ -266,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 @@ -278,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) } } @@ -307,7 +338,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 +351,65 @@ 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 `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 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") { + 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 len(lines) != numTierRows { - t.Errorf("lines = %d, want %d — reasoning added a row", len(lines), numTierRows) + 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) { + 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) } } @@ -347,16 +420,23 @@ 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) - 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 +472,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 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/assets/cortex-demo.svg b/docs/assets/cortex-demo.svg index 8b47ba23f..478d6ca28 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 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) + + 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 — ▕█▎ ▏ 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.