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)