diff --git a/cli/internal/tui/context_inspector.go b/cli/internal/tui/context_inspector.go index 88fad4a..11281e2 100644 --- a/cli/internal/tui/context_inspector.go +++ b/cli/internal/tui/context_inspector.go @@ -28,15 +28,17 @@ type ContextSection struct { } type CompactionState struct { - Strategy string `json:"strategy,omitempty"` - Status string `json:"status"` // "not_configured" | "not_needed" | "compacted" | "unknown" - Source string `json:"source,omitempty"` - TriggerFreePercent *float64 `json:"trigger_free_percent,omitempty"` - InputLimitTokens *int `json:"input_limit_tokens,omitempty"` - EstimatedInputTokens *int `json:"estimated_input_tokens,omitempty"` - EstimateMethod string `json:"estimate_method,omitempty"` - BeforeItems *int `json:"before_items,omitempty"` - AfterItems *int `json:"after_items,omitempty"` + Strategy string `json:"strategy,omitempty"` + Status string `json:"status"` // "not_configured" | "not_needed" | "compacted" | "unknown" + Source string `json:"source,omitempty"` + TriggerFreePercent *float64 `json:"trigger_free_percent,omitempty"` + InputLimitTokens *int `json:"input_limit_tokens,omitempty"` + EstimatedInputTokens *int `json:"estimated_input_tokens,omitempty"` + ProviderInputTokens *int `json:"provider_input_tokens,omitempty"` + ProviderCachedInputTokens *int `json:"provider_cached_input_tokens,omitempty"` + EstimateMethod string `json:"estimate_method,omitempty"` + BeforeItems *int `json:"before_items,omitempty"` + AfterItems *int `json:"after_items,omitempty"` } type ContextSnapshot struct { @@ -141,6 +143,7 @@ func validContextSnapshot(s ContextSnapshot) error { if !statuses[c.Status] || jsLength(c.Strategy) > 200 || jsLength(c.Source) > 500 || jsLength(c.EstimateMethod) > 500 || c.TriggerFreePercent != nil && (math.IsNaN(*c.TriggerFreePercent) || math.IsInf(*c.TriggerFreePercent, 0) || *c.TriggerFreePercent < 0 || *c.TriggerFreePercent > 100) || c.InputLimitTokens != nil && *c.InputLimitTokens < 0 || c.EstimatedInputTokens != nil && *c.EstimatedInputTokens < 0 || + c.ProviderInputTokens != nil && *c.ProviderInputTokens < 0 || c.ProviderCachedInputTokens != nil && *c.ProviderCachedInputTokens < 0 || c.BeforeItems != nil && *c.BeforeItems < 0 || c.AfterItems != nil && *c.AfterItems < 0 { return errors.New("daemon returned invalid compaction metadata") } @@ -409,7 +412,13 @@ func (m ContextInspectorModel) View() string { if snap.Compaction.TriggerFreePercent != nil { faint(fmt.Sprintf("trigger: keep %g%% free", *snap.Compaction.TriggerFreePercent)) } - if snap.Compaction.EstimatedInputTokens != nil { + if snap.Compaction.ProviderInputTokens != nil { + measured := "provider input: " + contextCount(*snap.Compaction.ProviderInputTokens, "token") + if snap.Compaction.ProviderCachedInputTokens != nil { + measured += " · " + contextCount(*snap.Compaction.ProviderCachedInputTokens, "cached token") + } + faint(measured) + } else if snap.Compaction.EstimatedInputTokens != nil { method := snap.Compaction.EstimateMethod if method == "" { method = "method not reported" diff --git a/cli/internal/tui/context_inspector_usage_test.go b/cli/internal/tui/context_inspector_usage_test.go new file mode 100644 index 0000000..8ae4087 --- /dev/null +++ b/cli/internal/tui/context_inspector_usage_test.go @@ -0,0 +1,39 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) + +func TestContextInspectorPrefersProviderUsageToEstimate(t *testing.T) { + measured, cached, estimate := 194000, 12000, 217000 + m := NewContextInspectorModel(nil, "fixture") + m.SetSize(100, 25) + m.Loading = false + m.Snapshot = &ContextSnapshot{ + State: "ready", Model: "fixture", Sections: []ContextSection{}, + Compaction: CompactionState{ + Status: "compacted", EstimatedInputTokens: &estimate, + ProviderInputTokens: &measured, ProviderCachedInputTokens: &cached, + }, + } + if err := validContextSnapshot(*m.Snapshot); err != nil { + t.Fatal(err) + } + view := ansi.Strip(m.View()) + if !strings.Contains(view, "provider input: 194,000 tokens · 12,000 cached tokens") || strings.Contains(view, "estimated input:") { + t.Fatalf("provider usage should be primary: %s", view) + } + m.Snapshot.Compaction.ProviderInputTokens = nil + m.Snapshot.Compaction.ProviderCachedInputTokens = nil + if !strings.Contains(ansi.Strip(m.View()), "estimated input: 217,000 tokens") { + t.Fatal("estimate missing when the provider did not report usage") + } + invalid := -1 + m.Snapshot.Compaction.ProviderInputTokens = &invalid + if validContextSnapshot(*m.Snapshot) == nil { + t.Fatal("accepted negative provider usage") + } +} diff --git a/cli/src/ui/context-inspector.test.tsx b/cli/src/ui/context-inspector.test.tsx index 7dee0bf..d14f5f7 100644 --- a/cli/src/ui/context-inspector.test.tsx +++ b/cli/src/ui/context-inspector.test.tsx @@ -35,6 +35,8 @@ const snapshot = { trigger_free_percent: 10, input_limit_tokens: 128_000, estimated_input_tokens: 117_200, + provider_input_tokens: 194_000, + provider_cached_input_tokens: 12_000, estimate_method: "pinned tokenizer estimate", before_items: 42, after_items: 14, @@ -81,7 +83,8 @@ test("context inspector shows ordered sources and paged exact content using only assert(output.includes("128,000 tokens (configured)")) assert(output.includes("rolling · applied")) assert(output.includes("trigger: keep 10% free")) - assert(output.includes("117,200 tokens · pinned tokenizer estimate")) + assert(output.includes("provider input: 194,000 tokens · 12,000 cached tokens")) + assert(!output.includes("estimated input:")) assert(output.includes("900 measured bytes")) assert(output.includes("durable transcript and request-only context are separate")) @@ -144,6 +147,10 @@ test("old daemon capability preflight prevents unsupported context requests", { test("context decoders bound metadata and page content", () => { assert.equal(parseContextSnapshot(snapshot).state, "ready") + assert.throws(() => parseContextSnapshot({ ...snapshot, compaction: { ...snapshot.compaction, provider_input_tokens: -1 } }), /invalid compaction metadata/) + const fallback = parseContextSnapshot({ ...snapshot, compaction: { ...snapshot.compaction, provider_input_tokens: undefined, provider_cached_input_tokens: undefined } }) + assert.equal(fallback.state, "ready") + if (fallback.state === "ready") assert.equal(fallback.compaction.estimated_input_tokens, 117_200) assert.throws(() => parseContextSnapshot({ ...snapshot, sections: Array.from({ length: 1_001 }, () => snapshot.sections[0]) }), /invalid context metadata/) assert.throws(() => parseContextPage({ section: "history", page: 0, pages: 1, content: "x".repeat(65_537) }, "history"), /invalid context page/) assert.throws(() => parseContextPage({ section: "wrong", page: 0, pages: 1, content: "safe" }, "history"), /invalid context page/) diff --git a/cli/src/ui/context-inspector.tsx b/cli/src/ui/context-inspector.tsx index 333294b..18f7b55 100644 --- a/cli/src/ui/context-inspector.tsx +++ b/cli/src/ui/context-inspector.tsx @@ -21,6 +21,8 @@ export type CompactionState = { trigger_free_percent?: number input_limit_tokens?: number estimated_input_tokens?: number + provider_input_tokens?: number + provider_cached_input_tokens?: number estimate_method?: string before_items?: number after_items?: number @@ -112,6 +114,8 @@ export const parseContextSnapshot = (value: unknown): ContextSnapshot => { ...(free === undefined ? {} : { trigger_free_percent: free as number }), ...(optionalInteger("input_limit_tokens") === undefined ? {} : { input_limit_tokens: optionalInteger("input_limit_tokens") }), ...(optionalInteger("estimated_input_tokens") === undefined ? {} : { estimated_input_tokens: optionalInteger("estimated_input_tokens") }), + ...(optionalInteger("provider_input_tokens") === undefined ? {} : { provider_input_tokens: optionalInteger("provider_input_tokens") }), + ...(optionalInteger("provider_cached_input_tokens") === undefined ? {} : { provider_cached_input_tokens: optionalInteger("provider_cached_input_tokens") }), ...(optionalText("estimate_method", 500) === undefined ? {} : { estimate_method: optionalText("estimate_method", 500) }), ...(optionalInteger("before_items") === undefined ? {} : { before_items: optionalInteger("before_items") }), ...(optionalInteger("after_items") === undefined ? {} : { after_items: optionalInteger("after_items") }), @@ -226,7 +230,9 @@ export function ContextInspector({ connection, sessionId, onCancel }: ContextIns context window: {snapshot.context_window_tokens === undefined ? "not reported" : `${snapshot.context_window_tokens.toLocaleString("en-US")} tokens (configured)`} compaction: {compactionLabel(snapshot.compaction)} {snapshot.compaction.trigger_free_percent !== undefined && trigger: keep {snapshot.compaction.trigger_free_percent}% free} - {snapshot.compaction.estimated_input_tokens !== undefined && estimated input: {snapshot.compaction.estimated_input_tokens.toLocaleString("en-US")} tokens · {snapshot.compaction.estimate_method || "method not reported"}} + {snapshot.compaction.provider_input_tokens !== undefined + ? provider input: {snapshot.compaction.provider_input_tokens.toLocaleString("en-US")} tokens{snapshot.compaction.provider_cached_input_tokens === undefined ? "" : ` · ${snapshot.compaction.provider_cached_input_tokens.toLocaleString("en-US")} cached tokens`} + : snapshot.compaction.estimated_input_tokens !== undefined && estimated input: {snapshot.compaction.estimated_input_tokens.toLocaleString("en-US")} tokens · {snapshot.compaction.estimate_method || "method not reported"}} {visibleSections.map((section, row) => { const position = sectionStart + row return diff --git a/docs/compaction.md b/docs/compaction.md index 1e90981..e4026e4 100644 --- a/docs/compaction.md +++ b/docs/compaction.md @@ -61,4 +61,4 @@ Image bytes are never copied into the recap or the summarizer prompt; a recent i ## inspect -`/context` shows the prepared request: its sections in order, their sources, the compaction status, and what the estimate is based on. See [the context inspector](context.md). +`/context` shows the prepared request: its sections in order, their sources, and compaction status. After a provider completes that request, it shows the provider's measured input tokens (and cached tokens when reported); until then, or when usage is omitted, the strategy's local estimate is shown instead. The JSON retains `estimated_input_tokens` separately from `provider_input_tokens` so neither is mistaken for the other. A manual `/compact` sends no ordinary provider request, so its snapshot has only an estimate. See [the context inspector](context.md). diff --git a/docs/context.md b/docs/context.md index 7999056..848a6f6 100644 --- a/docs/context.md +++ b/docs/context.md @@ -13,7 +13,7 @@ Sections appear in provider request order: 3. prepared conversation history 4. enabled tool schemas -Each section names its source and reports exact item and byte counts for its underlying prepared request value. Byte counts measure UTF-8 request content or the exact encoded provider tool schema, not displayed terminal width. Compaction token counts are labeled estimates with their method; they are not provider usage. A configured context-window limit is shown only when it is known. +Each section names its source and reports exact item and byte counts for its underlying prepared request value. Byte counts measure UTF-8 request content or the exact encoded provider tool schema, not displayed terminal width. The compaction observation keeps its local `estimated_input_tokens` and method. Once the provider completes that prepared request, `/context` also reports `provider_input_tokens` and, when available, `provider_cached_input_tokens`; the inspector displays the measured input instead of the estimate. Cached tokens are a subset of the reported input, not an amount to add. A provider that omits usage leaves the estimate as the fallback, and a new request starts without the preceding request's measurement. The configured context-window limit is shown only when it is known. The summary endpoint returns only bounded previews. Full inspectable text is available in bounded 8,000-character pages. Image base64 bodies and opaque provider replay bodies are never copied into inspector pages; the page names each omission while preserving its position and available image metadata. Provider credentials are not part of the recorded request. diff --git a/src/albedo/daemon/context_snapshot.gleam b/src/albedo/daemon/context_snapshot.gleam index 5079021..de5ac8c 100644 --- a/src/albedo/daemon/context_snapshot.gleam +++ b/src/albedo/daemon/context_snapshot.gleam @@ -1,6 +1,7 @@ //// Bounded, read-only observations of an already prepared provider request. //// This module never prepares history, invokes tools, or calls a provider. +import albedo/daemon/usage import albedo/harness/compaction as context_size import albedo/openai_api/request as provider_request import albedo/openai_api/types @@ -54,6 +55,8 @@ pub opaque type Compaction { trigger_free_percent: Option(Int), input_limit_tokens: Option(Int), estimated_input_tokens: Option(Int), + provider_input_tokens: Option(Int), + provider_cached_input_tokens: Option(Int), estimate_method: Option(String), before_items: Option(Int), after_items: Option(Int), @@ -135,12 +138,41 @@ pub fn compaction( clamp_percent(trigger_free_percent), non_negative(input_limit_tokens), non_negative(estimated_input_tokens), + None, + None, estimate_method, non_negative(before_items), non_negative(after_items), ) } +/// Attach the completion's measured input usage to the request that produced it. +/// A newer prepared request starts without a measurement; missing provider usage +/// clears this request's measurement rather than reusing an older completion. +pub fn with_usage(snapshot: Snapshot, metadata: usage.Metadata) -> Snapshot { + case snapshot { + Ready(model: model, compaction: compaction, ..) if model == metadata.model -> { + let #(input, cached) = case metadata.tokens { + Some(usage.Tokens( + prompt_tokens: prompt, + cached_prompt_tokens: cached, + .., + )) -> #(non_negative(Some(prompt)), non_negative(cached)) + None -> #(None, None) + } + Ready( + ..snapshot, + compaction: Compaction( + ..compaction, + provider_input_tokens: input, + provider_cached_input_tokens: cached, + ), + ) + } + _ -> snapshot + } +} + /// The strategy's own token estimate for the prepared request, when it recorded one. pub fn estimate(snapshot: Snapshot) -> Option(Int) { case snapshot { @@ -508,6 +540,12 @@ fn compaction_json(value: Compaction) -> json.Json { |> optional("trigger_free_percent", value.trigger_free_percent, json.int) |> optional("input_limit_tokens", value.input_limit_tokens, json.int) |> optional("estimated_input_tokens", value.estimated_input_tokens, json.int) + |> optional("provider_input_tokens", value.provider_input_tokens, json.int) + |> optional( + "provider_cached_input_tokens", + value.provider_cached_input_tokens, + json.int, + ) |> optional("estimate_method", value.estimate_method, json.string) |> optional("before_items", value.before_items, json.int) |> optional("after_items", value.after_items, json.int) diff --git a/src/albedo/daemon/session.gleam b/src/albedo/daemon/session.gleam index ac03b42..e11bb6e 100644 --- a/src/albedo/daemon/session.gleam +++ b/src/albedo/daemon/session.gleam @@ -854,7 +854,11 @@ fn handle(state: State, message: Message) { case written { Ok(_) -> actor.continue( - session_state.State(..state, latest_usage: Some(metadata)), + session_state.State( + ..state, + latest_usage: Some(metadata), + context: context_snapshot.with_usage(state.context, metadata), + ), ) Error(_) -> actor.continue(state) } diff --git a/test/daemon/context_integration.py b/test/daemon/context_integration.py index 8f87a80..9d19835 100644 --- a/test/daemon/context_integration.py +++ b/test/daemon/context_integration.py @@ -42,6 +42,8 @@ class Provider(http.server.BaseHTTPRequestHandler): "content": [{"type": "output_text", "text": "done", "annotations": []}], }], "usage": {"input_tokens": 20, "output_tokens": 1}, }} + if "without provider usage" in json.dumps(request): + response["response"].pop("usage") body = ("data: " + json.dumps(response) + "\n\n").encode() self.send_response(200) self.send_header("Content-Type", "text/event-stream") @@ -113,6 +115,8 @@ def run(endpoint): "env": served["env"], "models": served["models"]}} assert snapshot["context_window_tokens"] == 200_000, snapshot compaction = snapshot["compaction"] + assert compaction["provider_input_tokens"] == 20, compaction + assert "provider_cached_input_tokens" not in compaction, compaction assert compaction["estimate_method"] == "local byte-based estimate; not provider token usage" assert compaction["status"] == "not_needed", compaction assert compaction["input_limit_tokens"] == 200_000, compaction @@ -135,13 +139,21 @@ def run(endpoint): assert secret not in json.dumps(pages) assert len(Provider.requests) == 1, "summary/page inspection must not contact the provider" + api(f"/sessions/{session}/events", {"content": "without provider usage"}) + deadline = time.monotonic() + 20 + while time.monotonic() < deadline and api(f"/sessions/{session}/status")["running"]: + time.sleep(.025) + unreported = api(route)["compaction"] + assert "provider_input_tokens" not in unreported, unreported + assert "estimated_input_tokens" in unreported, unreported + changed = api(f"/sessions/{session}/commands", {"name": "/model", "args": {"model": "changed-model"}}) assert changed["result"]["model"] == "changed-model" assert api(route) == { "state": "pending", "reason": "runtime session has not prepared a provider request", }, "a snapshot for the old model must not survive model selection" - assert len(Provider.requests) == 1, "model invalidation must not contact the provider" + assert len(Provider.requests) == 2, "model invalidation must not contact the provider" # A model the catalog does not list stays explicitly unknown. api(f"/sessions/{session}/events", {"content": "a model outside the catalog"}) diff --git a/test/daemon/context_snapshot_test.gleam b/test/daemon/context_snapshot_test.gleam index 1cb0be8..db5ba25 100644 --- a/test/daemon/context_snapshot_test.gleam +++ b/test/daemon/context_snapshot_test.gleam @@ -1,4 +1,5 @@ import albedo/daemon/context_snapshot +import albedo/daemon/usage import albedo/harness/compaction import albedo/openai_api/types import gleam/json @@ -71,6 +72,51 @@ pub fn summary_preserves_order_sources_known_limits_and_bounded_previews_test() string.contains(encoded, "image payload bytes") |> should.be_false } +pub fn provider_completion_measurement_belongs_to_its_prepared_request_test() { + let prepared = observed() + let measured = + context_snapshot.with_usage( + prepared, + usage.Metadata( + "fixture-model", + 1235, + Some(usage.Tokens(194_000, 40, Some(12_000))), + ), + ) + let encoded = measured |> context_snapshot.summary |> json.to_string + string.contains(encoded, "\"provider_input_tokens\":194000") |> should.be_true + string.contains(encoded, "\"provider_cached_input_tokens\":12000") + |> should.be_true + string.contains(encoded, "\"estimated_input_tokens\":117200") + |> should.be_true + context_snapshot.estimate(measured) |> should.equal(Some(117_200)) + + let missing = + context_snapshot.with_usage( + measured, + usage.Metadata("fixture-model", 1236, None), + ) + |> context_snapshot.summary + |> json.to_string + string.contains(missing, "provider_input_tokens") |> should.be_false + let mismatched = + context_snapshot.with_usage( + prepared, + usage.Metadata("other-model", 1237, Some(usage.Tokens(9, 1, None))), + ) + mismatched + |> context_snapshot.summary + |> should.equal(context_snapshot.summary(prepared)) + context_snapshot.with_usage( + context_snapshot.pending("not prepared"), + usage.Metadata("fixture-model", 1238, Some(usage.Tokens(9, 1, None))), + ) + |> context_snapshot.summary + |> should.equal( + context_snapshot.summary(context_snapshot.pending("not prepared")), + ) +} + pub fn page_is_bounded_and_names_intentional_omissions_test() { let assert Ok(first) = context_snapshot.page(observed(), "history", 0) let first = json.to_string(first)