Add provider accounting to context window
Show provider-reported price, cache hit, and input/output tokens in the context window popover while omitting unavailable price and cache rows. Drain OpenRouter terminal usage through plugin extensions without changing Responses behavior, and keep prompt-bucket reconciliation accurate.
Alessandro committed
Aug 24, 2026 at 17:05 UTC
4fb3d2555f669be6413b469128ada95d6ab8768a
11 files changed
+504
-34
plugins/_context_window/AGENTS.md
+16
-1
@@ -8,7 +8,8 @@
8
## Ownership
9
10
- `helpers/usage.py` owns per-prompt bucket measurement and reconciliation.
11
-- `extensions/python/` records prompt parts at their source extension points.
11
+- `extensions/python/` records prompt parts at their source extension points,
12
+ preserves terminal streamed usage, and captures optional provider usage.
13
- `api/context_window.py` exposes the active chat's token usage and effective
14
model limit without returning prompt content.
15
- `webui/` and `extensions/webui/` own the Alpine store, indicator, popover,
@@ -26,6 +27,19 @@
27
fragments use a bounded, content-addressed, runtime-only cache.
28
- Bucket totals reconcile to the already-stored prompt token total; the
29
unclaimed remainder belongs to System prompt.
30
+- If the history ledger would consume the whole prompt estimate, recompute only
31
+ the rendered message portion before reconciliation; ordinary prompt builds
32
+ keep the fast ledger path.
33
+- The prompt estimate never guesses provider-specific image token costs or
34
+ counts embedded image bytes as text.
35
+- Provider price, cache hit, and input/output tokens form a flat summary without
36
+ diagnostic detail rows.
37
+- Provider rows are exposed only when the provider or transport reports their
38
+ values; unavailable price and cache data render no row.
39
+- Streamed OpenRouter main turns request LiteLLM's terminal usage event. The
40
+ response callback still runs normally; only an actual Chat Completions result
41
+ restores the accepted response after the accounting tail is drained.
42
+- Responses API turns keep their native result and callback behavior unchanged.
43
- Older chats without a stored breakdown show the explanatory empty state.
44
- `_model_config` supplies the effective model limit and the
45
`model-context-strip-end` WebUI slot; it does not own this feature's state.
@@ -35,6 +49,7 @@
49
## Work Guidance
50
51
- Keep prompt accounting out of rendered-text heuristics.
52
+- Keep provider-reported usage separate from the six estimated context buckets.
53
- Keep the API response limited to counts needed by the UI.
54
- Preserve the upward, right-aligned popover geometry used beside the model and
55
profile selectors.
plugins/_context_window/README.md
+8
@@ -7,3 +7,11 @@ Extras, and Free space.
7
8
Older chats gain the detailed breakdown after their next model turn. Mobile and
9
desktop visibility can be changed under **Settings > Interface**.
10
+
11
+When a model provider reports usage, the popover shows price, cache-hit rate,
12
+and input/output tokens. Unreported price and cache data are omitted. The
13
+context breakdown does not guess model-specific image token costs.
14
+
15
+For streamed OpenRouter chat calls, the plugin requests and drains LiteLLM's
16
+terminal usage event after Agent Zero has accepted the response. Price remains
17
+hidden when LiteLLM does not report a cost or map the selected model.
plugins/_context_window/api/context_window.py
+5
-1
@@ -1,5 +1,8 @@
1
from helpers.api import ApiHandler, Input, Output, Request
2
-from plugins._context_window.helpers.usage import usage_snapshot
2
+from plugins._context_window.helpers.usage import (
3
+ latest_provider_usage,
4
+ usage_snapshot,
5
+)
6
from plugins._model_config.helpers.model_config import get_chat_model_config
7
8
@@ -15,4 +18,5 @@ class ContextWindow(ApiHandler):
18
"tokens": max(int(window.get("tokens") or 0), 0),
19
"context_window": max(int(config.get("ctx_length") or 0), 0),
20
"usage": usage_snapshot(window.get("usage")),
21
+ "provider_usage": latest_provider_usage(agent),
22
}
plugins/_context_window/extensions/python/_functions/agent/Agent/call_chat_model_turn/end/_90_record_provider_usage.py
new
+9
@@ -0,0 +1,9 @@
1
+from helpers.extension import Extension
2
+from plugins._context_window.helpers.usage import capture_provider_usage
3
+
4
+
5
+class RecordProviderUsage(Extension):
6
+ def execute(self, data: dict | None = None, **kwargs):
7
+ payload = data if isinstance(data, dict) else {}
8
+ if not payload.get("exception"):
9
+ capture_provider_usage(self.agent, payload.get("result"))
plugins/_context_window/extensions/python/_functions/models/LiteLLMChatWrapper/unified_turn/end/_90_restore_provider_response.py
new
+24
@@ -0,0 +1,24 @@
1
+from helpers.extension import Extension
2
+from helpers.llm_result import LLMResult
3
+
4
+
5
+RESPONSE_KEY = "_context_window_accepted_response"
6
+
7
+
8
+class RestoreProviderResponse(Extension):
9
+ def execute(self, data: dict, **kwargs):
10
+ response = data.pop(RESPONSE_KEY, None)
11
+ result = data.get("result")
12
+ if (
13
+ response is None
14
+ or data.get("exception")
15
+ or not isinstance(result, LLMResult)
16
+ or result.mode != "chat_completions"
17
+ ):
18
+ return
19
+
20
+ result.response = response
21
+ if not result.output_items:
22
+ result.output_items = LLMResult.from_chat(
23
+ response=response, reasoning=result.reasoning
24
+ ).output_items
plugins/_context_window/extensions/python/_functions/models/LiteLLMChatWrapper/unified_turn/start/_10_drain_provider_usage.py
new
+30
@@ -0,0 +1,30 @@
1
+from helpers.extension import Extension
2
+
3
+
4
+RESPONSE_KEY = "_context_window_accepted_response"
5
+
6
+
7
+class DrainProviderUsage(Extension):
8
+ def execute(self, data: dict, **kwargs):
9
+ model = data["args"][0]
10
+ callback = data["kwargs"].get("response_callback")
11
+ if callback is None:
12
+ return
13
+
14
+ if (
15
+ model.provider == "openrouter"
16
+ and data["kwargs"].get("explicit_caching")
17
+ ):
18
+ data["kwargs"]["stream_options"] = {
19
+ **model.kwargs.get("stream_options", {}),
20
+ **data["kwargs"].get("stream_options", {}),
21
+ "include_usage": True,
22
+ }
23
+
24
+ async def drain_callback(chunk: str, full: str):
25
+ response = await callback(chunk, full)
26
+ if response is not None and RESPONSE_KEY not in data:
27
+ data[RESPONSE_KEY] = response
28
+ return None
29
+
30
+ data["kwargs"]["response_callback"] = drain_callback
plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html
+67
-17
@@ -30,7 +30,10 @@
30
aria-label="Context window usage">
31
<div class="context-window-header">
32
<strong>Context window</strong>
33
- <span x-text="$store.contextWindow.usage.summary"></span>
33
+ <div class="context-window-summary">
34
+ <span class="context-window-summary-tokens" x-text="$store.contextWindow.usage.summaryTokens"></span>
35
+ <span class="context-window-summary-percent" x-text="$store.contextWindow.usage.summaryPercent"></span>
36
+ </div>
37
</div>
38
<div class="context-window-meter" aria-hidden="true">
39
<span :style="$store.contextWindow.usage.meterStyle"></span>
@@ -39,7 +42,6 @@
42
<div class="context-window-rows">
43
<template x-for="row in $store.contextWindow.usage.rows" :key="row.key">
44
<div class="context-window-row">
42
- <span class="context-window-dot" :style="row.dotStyle"></span>
45
<span x-text="row.label"></span>
46
<span class="context-window-value context-window-tokens" x-text="row.tokensLabel"></span>
47
<span class="context-window-value" x-text="row.percentLabel"></span>
@@ -50,6 +52,28 @@
52
<template x-if="$store.contextWindow.usage.missingBreakdown">
53
<div class="context-window-empty">Breakdown available after the next message.</div>
54
</template>
55
+ <template x-if="$store.contextWindow.usage.provider.hasData">
56
+ <div class="context-window-provider">
57
+ <template x-if="$store.contextWindow.usage.provider.price.hasData">
58
+ <div class="context-window-provider-row context-window-provider-price">
59
+ <span>Price</span>
60
+ <span class="context-window-value" x-text="$store.contextWindow.usage.provider.price.label"></span>
61
+ </div>
62
+ </template>
63
+ <template x-if="$store.contextWindow.usage.provider.cache.hasData">
64
+ <div class="context-window-provider-row">
65
+ <span>Cache hit</span>
66
+ <span class="context-window-value" x-text="$store.contextWindow.usage.provider.cache.label"></span>
67
+ </div>
68
+ </template>
69
+ <template x-if="$store.contextWindow.usage.provider.tokens">
70
+ <div class="context-window-provider-row">
71
+ <span>Tokens In/Out</span>
72
+ <span class="context-window-value" x-text="$store.contextWindow.usage.provider.tokens"></span>
73
+ </div>
74
+ </template>
75
+ </div>
76
+ </template>
77
</div>
78
</div>
79
</template>
@@ -99,7 +123,7 @@
123
.context-window-button > span {
124
position: relative;
125
z-index: 1;
102
- font-family: var(--font-family-code);
126
+ font-family: var(--font-family-main);
127
font-size: .42rem;
128
font-weight: 600;
129
letter-spacing: -.03em;
@@ -117,27 +141,59 @@
141
box-shadow: 0 16px 38px rgba(0, 0, 0, .3);
142
}
143
.context-window-header,
120
- .context-window-row {
144
+ .context-window-row,
145
+ .context-window-provider-row {
146
display: grid;
147
align-items: center;
148
}
149
.context-window-header {
125
- grid-template-columns: minmax(0, 1fr) auto;
126
- gap: .75rem;
150
+ gap: .4rem;
151
font-size: .86rem;
152
}
129
- .context-window-header > span,
153
+ .context-window-summary,
154
.context-window-value {
131
- font-family: var(--font-family-code);
155
+ font-family: var(--font-family-main);
156
font-variant-numeric: tabular-nums;
157
}
134
- .context-window-header > span {
158
+ .context-window-summary {
159
+ display: flex;
160
+ align-items: baseline;
161
+ justify-content: space-between;
162
+ gap: .75rem;
163
+ color: color-mix(in srgb, var(--color-text) 72%, transparent);
164
+ font-size: .76rem;
165
+ }
166
+ .context-window-summary-percent {
167
+ color: var(--color-text);
168
+ }
169
+ .context-window-summary-tokens,
170
+ .context-window-tokens {
171
+ font-family: var(--font-family-code);
172
+ }
173
+ .context-window-provider {
174
+ margin-top: .7rem;
175
+ padding-top: .7rem;
176
+ border-top: 1px solid var(--color-border);
177
+ }
178
+ .context-window-provider-row {
179
+ grid-template-columns: minmax(0, 1fr) auto;
180
+ gap: .75rem;
181
+ min-height: 1.35rem;
182
color: color-mix(in srgb, var(--color-text) 72%, transparent);
183
font-size: .76rem;
184
}
185
+ .context-window-provider-price {
186
+ margin-bottom: .25rem;
187
+ color: var(--color-text);
188
+ font-size: .82rem;
189
+ }
190
+ .context-window-provider-price .context-window-value {
191
+ font-size: 1rem;
192
+ font-weight: 600;
193
+ }
194
.context-window-meter {
195
height: .45rem;
140
- margin: .85rem 0 .75rem;
196
+ margin: .4rem 0 .75rem;
197
overflow: hidden;
198
border-radius: 999px;
199
background: color-mix(in srgb, var(--color-text) 9%, transparent);
@@ -159,7 +215,7 @@
215
font-size: .8rem;
216
}
217
.context-window-row {
162
- grid-template-columns: .55rem minmax(0, 1fr) auto 3rem;
218
+ grid-template-columns: minmax(0, 1fr) auto 3rem;
219
gap: .5rem;
220
min-height: 1.5rem;
221
color: color-mix(in srgb, var(--color-text) 78%, transparent);
@@ -172,12 +228,6 @@
228
.context-window-tokens {
229
color: color-mix(in srgb, var(--color-text) 62%, transparent);
230
}
175
- .context-window-dot {
176
- width: .55rem;
177
- height: .55rem;
178
- border-radius: 50%;
179
- background: var(--color-highlight);
180
- }
231
@media (max-width: 25rem) {
232
.context-window-popover {
233
right: 1.25rem;
plugins/_context_window/helpers/usage.py
+110
-1
@@ -1,11 +1,14 @@
1
import hashlib
2
+import math
3
from typing import Any
4
5
from helpers import files, history, skills, tokens
6
+from helpers.llm_result import result_from_metadata
7
8
9
PARTS_KEY = "context_window_usage"
10
CACHE_KEY = "_context_window_usage_cache"
11
+PROVIDER_USAGE_KEY = "context_window_provider_usage"
12
USAGE_KEYS = (
13
"messages",
14
"system_tools",
@@ -43,6 +46,7 @@ def capture_context(agent: Any, loop_data: Any) -> None:
46
return
47
48
output = list(getattr(loop_data, "history_output", None) or [])
49
+ parts["_history_output"] = output
50
skill_output = [message for message in output if skills.skill_instruction_name(message)]
51
skill_tokens = _output_tokens(agent, "history_skills", skill_output)
52
parts["messages"] = max(_history_tokens(agent, output) - skill_tokens, 0)
@@ -78,9 +82,20 @@ def finalize(agent: Any) -> None:
82
if not isinstance(parts, dict) or not isinstance(window, dict):
83
return
84
85
+ history_output = parts.pop("_history_output", None)
86
total = _non_negative_int(window.get("tokens"))
87
usage = {key: _non_negative_int(parts.get(key)) for key in MEASURED_KEYS}
88
measured_total = sum(usage.values())
89
+ if total and measured_total >= total and isinstance(history_output, list):
90
+ message_output = [
91
+ message
92
+ for message in history_output
93
+ if not skills.skill_instruction_name(message)
94
+ ]
95
+ usage["messages"] = _output_tokens(
96
+ agent, "history_messages", message_output
97
+ )
98
+ measured_total = sum(usage.values())
99
if measured_total > total and measured_total:
100
usage = _scale_to_total(usage, total, measured_total)
101
measured_total = total
@@ -98,7 +113,73 @@ def usage_snapshot(value: Any) -> dict[str, int]:
113
return {key: _non_negative_int(value.get(key)) for key in USAGE_KEYS}
114
115
101
-def _parts(agent: Any) -> dict[str, int] | None:
116
+def capture_provider_usage(agent: Any, result: Any) -> None:
117
+ if agent is None or result is None or not hasattr(result, "usage"):
118
+ return
119
+
120
+ snapshot = provider_usage_snapshot(getattr(result, "usage", None))
121
+ agent.set_data(
122
+ PROVIDER_USAGE_KEY,
123
+ snapshot if snapshot else {"available": False},
124
+ )
125
+
126
+
127
+def latest_provider_usage(agent: Any) -> dict[str, int | float]:
128
+ data = getattr(agent, "data", None)
129
+ if isinstance(data, dict) and PROVIDER_USAGE_KEY in data:
130
+ stored = data.get(PROVIDER_USAGE_KEY)
131
+ if isinstance(stored, dict) and stored.get("available") is False:
132
+ return {}
133
+ return provider_usage_snapshot(stored)
134
+
135
+ all_messages = getattr(getattr(agent, "history", None), "all_messages", None)
136
+ if not callable(all_messages):
137
+ return {}
138
+ for message in reversed(all_messages()):
139
+ if not getattr(message, "ai", False):
140
+ continue
141
+ result = result_from_metadata(getattr(message, "metadata", None))
142
+ if result:
143
+ return provider_usage_snapshot(result.usage)
144
+ return {}
145
+
146
+
147
+def provider_usage_snapshot(value: Any) -> dict[str, int | float]:
148
+ if not isinstance(value, dict):
149
+ return {}
150
+
151
+ input_details = {
152
+ **_mapping(value.get("prompt_tokens_details")),
153
+ **_mapping(value.get("input_tokens_details")),
154
+ }
155
+ result: dict[str, int | float] = {}
156
+ fields = {
157
+ "input_tokens": (value.get("input_tokens"), value.get("prompt_tokens")),
158
+ "cached_tokens": (
159
+ input_details.get("cached_tokens"),
160
+ input_details.get("cache_read_tokens"),
161
+ value.get("cache_read_input_tokens"),
162
+ value.get("cached_tokens"),
163
+ ),
164
+ "output_tokens": (
165
+ value.get("output_tokens"),
166
+ value.get("completion_tokens"),
167
+ ),
168
+ }
169
+ for key, values in fields.items():
170
+ number = _optional_non_negative_int(*values)
171
+ if number is not None:
172
+ result[key] = number
173
+
174
+ cost = _optional_non_negative_float(
175
+ value.get("cost"), value.get("response_cost")
176
+ )
177
+ if cost is not None:
178
+ result["cost"] = cost
179
+ return result
180
+
181
+
182
+def _parts(agent: Any) -> dict[str, Any] | None:
183
params = _temporary_params(agent)
184
value = params.get(PARTS_KEY) if params is not None else None
185
return value if isinstance(value, dict) else None
@@ -160,6 +241,34 @@ def _non_negative_int(value: Any) -> int:
241
return 0
242
243
244
+def _mapping(value: Any) -> dict[str, Any]:
245
+ return value if isinstance(value, dict) else {}
246
+
247
+
248
+def _optional_non_negative_int(*values: Any) -> int | None:
249
+ for value in values:
250
+ if value is None:
251
+ continue
252
+ try:
253
+ return max(int(value), 0)
254
+ except (TypeError, ValueError):
255
+ continue
256
+ return None
257
+
258
+
259
+def _optional_non_negative_float(*values: Any) -> float | None:
260
+ for value in values:
261
+ if value is None:
262
+ continue
263
+ try:
264
+ number = float(value)
265
+ except (TypeError, ValueError):
266
+ continue
267
+ if math.isfinite(number):
268
+ return max(number, 0)
269
+ return None
270
+
271
+
272
def _scale_to_total(values: dict[str, int], total: int, current: int) -> dict[str, int]:
273
scaled = {key: value * total // current for key, value in values.items()}
274
remainder = total - sum(scaled.values())
plugins/_context_window/tests/test_context_window.py
+181
-4
@@ -1,17 +1,23 @@
1
+import sys
2
from pathlib import Path
3
from types import SimpleNamespace
4
5
import pytest
6
+from langchain_core.messages import HumanMessage, SystemMessage
7
8
+
9
+ROOT = Path(__file__).resolve().parents[3]
10
+if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+import models
14
from agent import Agent, LoopData
7
-from helpers import extension, history
15
+from helpers import extension, extract_tools, history, litellm_transport
16
+from helpers.llm_result import LLMResult
17
from plugins._context_window.api.context_window import ContextWindow
18
from plugins._context_window.helpers import usage
19
20
12
-ROOT = Path(__file__).resolve().parents[3]
13
-
14
-
21
class _Log:
22
def set_progress(self, _message: str) -> None:
23
pass
@@ -80,6 +86,7 @@ async def test_usage_follows_prompt_sources_and_reconciles_to_total(monkeypatch)
86
assert sum(breakdown.values()) == window["tokens"]
87
assert all(breakdown[key] > 0 for key in usage.USAGE_KEYS)
88
assert usage.PARTS_KEY not in loop_data.params_temporary
89
+ assert "history_messages" not in agent.data[usage.CACHE_KEY]
90
91
92
@pytest.mark.asyncio
@@ -115,6 +122,7 @@ async def test_api_returns_only_counts_and_effective_limit(monkeypatch):
122
"system_prompt": 0,
123
"extras": 0,
124
},
125
+ "provider_usage": {},
126
}
127
assert "text" not in result
128
@@ -147,6 +155,34 @@ def test_webui_and_accounting_are_plugin_owned():
155
assert "right: 1.25rem" in component
156
assert "width: min(17rem, calc(100vw - 3rem))" in component
157
assert 'label: "Free space"' in context_store
158
+ assert "Last model call" not in component
159
+ assert ">Price<" in component
160
+ assert ">Cache hit<" in component
161
+ assert ">Tokens In/Out<" in component
162
+ assert "context-window-cache-meter" not in component
163
+ assert "price: {" in context_store
164
+ assert "hasData: cost !== null" in context_store
165
+ assert 'label: cost === null ? "" : formatCost(cost)' in context_store
166
+ assert "usage.provider.price.hasData" in component
167
+ assert "usage.provider.price.label" in component
168
+ assert 'value < 0.001 ? "<$0.001"' in context_store
169
+ assert "maximumSignificantDigits: 3" in context_store
170
+ assert "border-top: 1px solid var(--color-border)" in component
171
+ assert " → " in context_store
172
+ assert "summaryTokens" in context_store
173
+ assert 'summaryPercent: `${percentLabel} used`' in context_store
174
+ assert "formatTokens(output)} tok" not in context_store
175
+ assert "context-window-summary-tokens" in component
176
+ assert "context-window-summary-percent" in component
177
+ assert "font-family: var(--font-family-main)" in component
178
+ assert "<details" not in component
179
+ assert "Reasoning" not in component
180
+ assert "Images sent" not in component
181
+ assert "provider did not split out their token cost" not in context_store
182
+ assert "cached / input" in context_store
183
+ assert "Math.round(cachePercent)" in context_store
184
+ assert "context-window-dot" not in component
185
+ assert "dotStyle" not in context_store
186
assert "Breakdown available after the next message." in component
187
assert "startswith(" not in helper
188
assert "rpartition(" not in helper
@@ -156,6 +192,9 @@ def test_source_prompt_extensions_are_registered():
192
expected = {
193
"_functions/agent/Agent/prepare_prompt/start": "ResetContextUsage",
194
"_functions/agent/Agent/prepare_prompt/end": "StoreContextUsage",
195
+ "_functions/agent/Agent/call_chat_model_turn/end": "RecordProviderUsage",
196
+ "_functions/models/LiteLLMChatWrapper/unified_turn/start": "DrainProviderUsage",
197
+ "_functions/models/LiteLLMChatWrapper/unified_turn/end": "RestoreProviderResponse",
198
"message_loop_prompts_after": "CaptureContextUsage",
199
}
200
for point, class_name in expected.items():
@@ -178,6 +217,75 @@ def test_source_prompt_extensions_are_registered():
217
assert any(cls.__name__ == recorder for cls in classes)
218
219
220
+@pytest.mark.asyncio
221
+async def test_chat_stream_drains_terminal_provider_usage(monkeypatch):
222
+ response = '{"tool_name":"response","tool_args":{"text":"done"}}'
223
+ chunks = [
224
+ {"choices": [{"delta": {"content": response}, "message": {}}]},
225
+ {"choices": [{"delta": {"content": " ignored"}, "message": {}}]},
226
+ {
227
+ "choices": [],
228
+ "usage": {
229
+ "prompt_tokens": 12_000,
230
+ "prompt_tokens_details": {"cached_tokens": 9_000},
231
+ "completion_tokens": 80,
232
+ },
233
+ "_hidden_params": {"response_cost": 0.0042},
234
+ },
235
+ ]
236
+ consumed = []
237
+
238
+ async def stream():
239
+ for chunk in chunks:
240
+ consumed.append(chunk)
241
+ yield chunk
242
+
243
+ async def fake_acompletion(*args, **kwargs):
244
+ assert kwargs["stream_options"] == {"include_usage": True}
245
+ return stream()
246
+
247
+ async def fake_rate_limiter(*args, **kwargs):
248
+ return None
249
+
250
+ callback_calls = []
251
+
252
+ async def response_callback(chunk: str, full: str):
253
+ callback_calls.append((chunk, full))
254
+ return full if extract_tools.extract_tool_request(full) else None
255
+
256
+ monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
257
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
258
+ wrapper = models.LiteLLMChatWrapper(
259
+ model="test-model",
260
+ provider="openrouter",
261
+ model_config=None,
262
+ api_base="https://openrouter.ai/api/v1",
263
+ )
264
+
265
+ result = await wrapper.unified_turn(
266
+ messages=[
267
+ SystemMessage(content="stable instructions"),
268
+ HumanMessage(content="question"),
269
+ ],
270
+ response_callback=response_callback,
271
+ explicit_caching=True,
272
+ )
273
+
274
+ assert consumed == chunks
275
+ assert callback_calls == [
276
+ (response, response),
277
+ (" ignored", response + " ignored"),
278
+ ]
279
+ assert result.response == response
280
+ assert result.output_items[0].type == "message"
281
+ assert result.usage == {
282
+ "prompt_tokens": 12_000,
283
+ "prompt_tokens_details": {"cached_tokens": 9_000},
284
+ "completion_tokens": 80,
285
+ "cost": 0.0042,
286
+ }
287
+
288
+
289
def test_prompt_fragment_cache_is_bounded_and_content_addressed(monkeypatch):
290
calls = []
291
agent = SimpleNamespace(data={}, loop_data=LoopData())
@@ -248,3 +356,72 @@ def test_history_ledger_changes_without_invalidating_fragment_cache(monkeypatch)
356
assert second["messages"] == 400 - second["skills"]
357
assert calls.count("stable tools") == 1
358
assert len(agent.data[usage.CACHE_KEY]) == 3
359
+
360
+
361
+def test_rendered_history_fallback_preserves_system_prompt_bucket(monkeypatch):
362
+ data = {}
363
+ output = [{"ai": False, "content": "short message"}]
364
+ agent = SimpleNamespace(
365
+ DATA_NAME_CTX_WINDOW="ctx_window",
366
+ data=data,
367
+ loop_data=LoopData(),
368
+ history=SimpleNamespace(get_tokens=lambda: 10_000),
369
+ _build_context_message=lambda *args, **kwargs: [],
370
+ get_data=lambda key: data.get(key),
371
+ set_data=lambda key, value: data.__setitem__(key, value),
372
+ )
373
+ loop_data = SimpleNamespace(
374
+ history_output=output,
375
+ protocol_persistent={},
376
+ protocol_temporary={},
377
+ extras_persistent={},
378
+ extras_temporary={},
379
+ )
380
+ monkeypatch.setattr(
381
+ usage.tokens,
382
+ "approximate_prompt_tokens",
383
+ lambda text: len(text),
384
+ )
385
+
386
+ usage.reset(agent)
387
+ usage.capture_context(agent, loop_data)
388
+ data[agent.DATA_NAME_CTX_WINDOW] = {"tokens": 100}
389
+ usage.finalize(agent)
390
+
391
+ breakdown = data[agent.DATA_NAME_CTX_WINDOW]["usage"]
392
+ assert breakdown["messages"] == len("user: short message")
393
+ assert breakdown["system_prompt"] > 0
394
+ assert sum(breakdown.values()) == 100
395
+
396
+
397
+def test_provider_usage_is_optional():
398
+ data = {}
399
+ agent = SimpleNamespace(
400
+ data=data,
401
+ history=SimpleNamespace(all_messages=lambda: []),
402
+ set_data=lambda key, value: data.__setitem__(key, value),
403
+ )
404
+ result = LLMResult.from_chat(
405
+ response="done",
406
+ usage={
407
+ "prompt_tokens": 12_000,
408
+ "prompt_tokens_details": {"cached_tokens": 9_000},
409
+ "completion_tokens": 80,
410
+ "cost": 0.0123,
411
+ },
412
+ )
413
+
414
+ usage.capture_provider_usage(agent, result)
415
+
416
+ assert usage.latest_provider_usage(agent) == {
417
+ "input_tokens": 12_000,
418
+ "cached_tokens": 9_000,
419
+ "output_tokens": 80,
420
+ "cost": 0.0123,
421
+ }
422
+
423
+ usage.capture_provider_usage(agent, LLMResult.from_chat(response="no usage"))
424
+ assert usage.latest_provider_usage(agent) == {}
425
+ assert usage.provider_usage_snapshot(
426
+ {"input_tokens": 100, "cached_tokens": None, "cost": None}
427
+ ) == {"input_tokens": 100}
plugins/_context_window/webui/context-window-store.js
+52
-9
@@ -5,13 +5,18 @@ import { store as preferencesStore } from "/components/sidebar/bottom/preference
5
6
const API_PATH = "/plugins/_context_window/context_window";
7
const ROWS = [
8
- { key: "messages", label: "Messages", opacity: 1 },
9
- { key: "system_tools", label: "System tools", opacity: 0.88 },
10
- { key: "skills", label: "Skills", opacity: 0.76 },
11
- { key: "mcp_tools", label: "MCP tools", opacity: 0.64 },
12
- { key: "system_prompt", label: "System prompt", opacity: 0.52 },
13
- { key: "extras", label: "Extras", opacity: 0.4 },
8
+ { key: "messages", label: "Messages" },
9
+ { key: "system_tools", label: "System tools" },
10
+ { key: "skills", label: "Skills" },
11
+ { key: "mcp_tools", label: "MCP tools" },
12
+ { key: "system_prompt", label: "System prompt" },
13
+ { key: "extras", label: "Extras" },
14
];
15
+const COST_FORMATTER = new Intl.NumberFormat("en-US", {
16
+ style: "currency",
17
+ currency: "USD",
18
+ maximumSignificantDigits: 3,
19
+});
20
21
preferencesStore.registerUiControlVisibility("contextWindowUsage", {
22
mobile: true,
@@ -31,6 +36,44 @@ function formatPercent(value) {
36
return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}%`;
37
}
38
39
+function optionalNumber(value, key) {
40
+ if (!value || !Object.prototype.hasOwnProperty.call(value, key)) return null;
41
+ const number = Number(value[key]);
42
+ return Number.isFinite(number) && number >= 0 ? number : null;
43
+}
44
+
45
+function formatCost(value) {
46
+ if (value === 0) return "$0";
47
+ return value < 0.001 ? "<$0.001" : COST_FORMATTER.format(value);
48
+}
49
+
50
+function buildProviderUsage(value = {}) {
51
+ const input = optionalNumber(value, "input_tokens");
52
+ const cached = optionalNumber(value, "cached_tokens");
53
+ const output = optionalNumber(value, "output_tokens");
54
+ const cost = optionalNumber(value, "cost");
55
+
56
+ const tokenSummary = input === null && output === null
57
+ ? ""
58
+ : `${input === null ? "–" : formatTokens(input)} → ${output === null ? "–" : formatTokens(output)}`;
59
+
60
+ const cachePercent = input > 0 && cached !== null
61
+ ? Math.min((cached / input) * 100, 100)
62
+ : null;
63
+ return {
64
+ hasData: cost !== null || cachePercent !== null || Boolean(tokenSummary),
65
+ price: {
66
+ hasData: cost !== null,
67
+ label: cost === null ? "" : formatCost(cost),
68
+ },
69
+ cache: {
70
+ hasData: cachePercent !== null,
71
+ label: cachePercent === null ? "" : `${Math.round(cachePercent)}%`,
72
+ },
73
+ tokens: tokenSummary,
74
+ };
75
+}
76
+
77
function buildUsage(data = {}) {
78
const tokens = Math.max(Number(data.tokens) || 0, 0);
79
const contextWindow = Math.max(Number(data.context_window) || 0, 0);
@@ -43,7 +86,6 @@ function buildUsage(data = {}) {
86
...row,
87
tokensLabel: formatTokens(rowTokens),
88
percentLabel: formatPercent(rowPercent),
46
- dotStyle: `opacity:${row.opacity}`,
89
};
90
});
91
const hasBreakdown = rows.some(row => Number(breakdown[row.key]) > 0);
@@ -55,7 +97,6 @@ function buildUsage(data = {}) {
97
label: "Free space",
98
tokensLabel: formatTokens(freeTokens),
99
percentLabel: formatPercent(freePercent),
58
- dotStyle: "opacity:0.24",
100
});
101
}
102
const percentLabel = formatPercent(percent);
@@ -66,8 +107,10 @@ function buildUsage(data = {}) {
107
ariaLabel: `Context window ${percentLabel} used`,
108
ringLabel: contextWindow ? `${Math.round(percent)}%` : "–",
109
ringDasharray: `${Math.min(percent, 100)} 100`,
69
- summary: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : "–"} (${percentLabel})`,
110
+ summaryTokens: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : "–"} tokens`,
111
+ summaryPercent: `${percentLabel} used`,
112
meterStyle: `width:${Math.min(percent, 100)}%`,
113
+ provider: buildProviderUsage(data.provider_usage),
114
};
115
}
116
tests/test_stream_tool_early_stop.py
+2
-1
@@ -449,7 +449,8 @@ async def test_unified_turn_stops_chat_stream_after_text_tool_request(monkeypatc
449
async def response_callback(chunk: str, full: str):
450
return full if extract_tools.extract_tool_request(full) else None
451
452
- result = await wrapper.unified_turn(
452
+ result = await wrapper.unified_turn.__wrapped__(
453
+ wrapper,
454
messages=[],
455
response_callback=response_callback,
456
)