Add context window display controls and price currency selection

Add plugin settings to toggle the full context breakdown and each provider
usage row. Match price typography to the cache and token summary rows.

Keep USD amounts unchanged without exchange-rate requests. Convert non-USD
prices using validated Frankfurter rates cached for 24 hours, with dated
tooltips and an explicit USD fallback when a rate is unavailable. Preserve
stored provider usage and back off failed lookups for five minutes.

Validation: 20 context-window plugin tests pass. Verified settings persistence,
EUR price rendering, USD fallback, and exchange-rate lookup against the live
Docker runtime at localhost:32081.
This commit is contained in:
Alessandro 2026-10-07 23:31:54 +02:00
parent 429946fba4
commit 935fb5cdcc
9 changed files with 360 additions and 38 deletions

View file

@ -8,12 +8,14 @@
## Ownership
- `helpers/usage.py` owns per-prompt bucket measurement and reconciliation.
- `helpers/currency.py` owns optional USD exchange-rate lookup and validation.
- `extensions/python/` records prompt parts at their source extension points,
preserves terminal streamed usage, and captures optional provider usage.
- `api/context_window.py` exposes the active chat's token usage and effective
model limit without returning prompt content.
model limit plus display flags and price-currency metadata without returning
prompt content or modifying stored provider usage.
- `webui/` and `extensions/webui/` own the Alpine store, indicator, popover,
model-override refresh, and Interface visibility row.
model-override refresh, plugin display settings, and Interface visibility row.
## Local Contracts
@ -33,9 +35,19 @@
- The prompt estimate never guesses provider-specific image token costs or
counts embedded image bytes as text.
- Provider price, cache hit, and input/output tokens form a flat summary without
diagnostic detail rows.
diagnostic detail rows and share the same regular-weight typography.
- Provider rows are exposed only when the provider or transport reports their
values; unavailable price and cache data render no row.
- Global `show_breakdown`, `show_price`, `show_cache_hit`, and `show_tokens`
settings default to true and apply on each usage refresh or popover opening.
Hiding the breakdown also hides its empty state; hiding all provider rows
removes their container and divider. Accounting and the summary remain intact.
- `price_currency` defaults to USD; USD never invokes the rate helper or makes
an external request. Fetch Frankfurter's daily reference rate only for a
visible, nonzero provider cost in a selected non-USD currency. Cache valid
rates for 24 hours and failed lookups for five minutes. Only currency codes
leave the server. Validate pair, positive finite rate, and date; on failure,
show the original USD amount with an explicit USD label and explanation.
- Streaming main turns request LiteLLM's terminal usage event for every
provider through the transport's `stream_options.include_usage` injection,
so provider input/output and cache tokens reach the summary. The response
@ -56,7 +68,8 @@
- Keep prompt accounting out of rendered-text heuristics.
- Keep provider-reported usage separate from the six estimated context buckets.
- Keep the API response limited to counts needed by the UI.
- Keep the API response limited to usage counts, whitelisted display flags,
and effective price-currency metadata.
- Preserve the upward, right-aligned popover beside the model/profile selectors. Its minimum strip footprint is bounded by the available width; shared `x-overflow` positions the same popover when the indicator enters the overflow menu. `data-overflow-label` names the entry; `data-overflow-icon` retains its live percentage ring.
## Verification

View file

@ -8,6 +8,17 @@ Extras, and Free space.
Older chats gain the detailed breakdown after their next model turn. Mobile and
desktop visibility can be changed under **Settings > Interface**.
Under **Plugins > Context Window > Settings**, show or hide the whole breakdown
block, Price, Cache hit, and Tokens In/Out independently. All four are visible
by default. These global settings apply when the popover next refreshes or opens.
**Price currency** defaults to USD and uses the provider's reported amount
directly, with no exchange-rate request. Other currencies convert the visible
price using [Frankfurter's daily reference rates](https://frankfurter.dev/).
Rates are cached for 24 hours; no chat content or prices are sent to the rate
service. Hover over a converted amount to see the rate date. If a rate is
unavailable, the price is explicitly shown in USD and retries wait five minutes.
When a model provider reports usage, the popover shows price, cache-hit rate,
and input/output tokens. Unreported price and cache data are omitted. The
context breakdown does not guess model-specific image token costs.

View file

@ -1,4 +1,8 @@
import re
from helpers.api import ApiHandler, Input, Output, Request
from helpers import plugins
from plugins._context_window.helpers.currency import get_exchange_rate
from plugins._context_window.helpers.usage import (
latest_provider_usage,
usage_snapshot,
@ -13,10 +17,28 @@ class ContextWindow(ApiHandler):
window = agent.get_data(agent.DATA_NAME_CTX_WINDOW)
window = window if isinstance(window, dict) else {}
config = get_chat_model_config(agent)
display = plugins.get_plugin_config("_context_window") or {}
provider_usage = latest_provider_usage(agent)
currency = str(display.get("price_currency") or "USD").upper()
if not re.fullmatch(r"[A-Z]{3}", currency):
currency = "USD"
price_currency = {"code": "USD", "rate": 1, "date": ""}
if currency != "USD" and display.get("show_price") is not False and "cost" in provider_usage:
if provider_usage["cost"] == 0:
price_currency = {"code": currency, "rate": None, "date": ""}
else:
price_currency = await get_exchange_rate(currency) or {
"code": "USD", "rate": 1, "date": "", "requested": currency,
}
return {
"tokens": max(int(window.get("tokens") or 0), 0),
"context_window": max(int(config.get("ctx_length") or 0), 0),
"usage": usage_snapshot(window.get("usage")),
"provider_usage": latest_provider_usage(agent),
"provider_usage": provider_usage,
"price_currency": price_currency,
"display": {
key: display.get(key, True) is not False
for key in ("show_breakdown", "show_price", "show_cache_hit", "show_tokens")
},
}

View file

@ -0,0 +1,5 @@
show_breakdown: true
show_price: true
show_cache_hit: true
show_tokens: true
price_currency: USD

View file

@ -57,9 +57,10 @@
<template x-if="$store.contextWindow.usage.provider.hasData">
<div class="context-window-provider">
<template x-if="$store.contextWindow.usage.provider.price.hasData">
<div class="context-window-provider-row context-window-provider-price">
<div class="context-window-provider-row">
<span>Price</span>
<span class="context-window-value" x-text="$store.contextWindow.usage.provider.price.label"></span>
<span class="context-window-value" :title="$store.contextWindow.usage.provider.price.title"
x-text="$store.contextWindow.usage.provider.price.label"></span>
</div>
</template>
<template x-if="$store.contextWindow.usage.provider.cache.hasData">
@ -187,15 +188,6 @@
color: color-mix(in srgb, var(--color-text) 72%, transparent);
font-size: .76rem;
}
.context-window-provider-price {
margin-bottom: .25rem;
color: var(--color-text);
font-size: .82rem;
}
.context-window-provider-price .context-window-value {
font-size: 1rem;
font-weight: 600;
}
.context-window-meter {
height: .45rem;
margin: .4rem 0 .75rem;

View file

@ -0,0 +1,39 @@
import math
import re
import time
from datetime import date
import httpx
from helpers import cache
CACHE_AREA = "context_window_currency(plugins)"
async def get_exchange_rate(currency: str) -> dict | None:
if currency == "USD" or not re.fullmatch(r"[A-Z]{3}", currency):
return None
cached = cache.get(CACHE_AREA, currency)
if cached and time.monotonic() < cached[0]:
return cached[1]
result = None
try:
async with httpx.AsyncClient(timeout=3) as client:
response = await client.get(f"https://api.frankfurter.dev/v2/rate/USD/{currency}")
response.raise_for_status()
data = response.json()
rate = float(data["rate"])
rate_date = date.fromisoformat(data["date"]).isoformat()
if (
data.get("base") != "USD" or data.get("quote") != currency
or isinstance(data["rate"], bool) or not math.isfinite(rate) or rate <= 0
):
raise ValueError("Invalid exchange rate")
result = {"code": currency, "rate": rate, "date": rate_date}
except (httpx.HTTPError, KeyError, TypeError, ValueError):
pass
cache.add(CACHE_AREA, currency, (time.monotonic() + (86400 if result else 300), result))
return result

View file

@ -15,10 +15,10 @@ if str(ROOT) not in sys.path:
import models
from agent import Agent, LoopData
from helpers import extension, extract_tools, history, litellm_transport
from helpers import cache, extension, extract_tools, history, litellm_transport
from helpers.llm_result import LLMResult
from plugins._context_window.api.context_window import ContextWindow
from plugins._context_window.helpers import usage
from plugins._context_window.helpers import currency, usage
class _Log:
@ -93,7 +93,12 @@ async def test_usage_follows_prompt_sources_and_reconciles_to_total(monkeypatch)
@pytest.mark.asyncio
async def test_api_returns_only_counts_and_effective_limit(monkeypatch):
@pytest.mark.parametrize("display", [
{},
{"show_breakdown": False, "show_cache_hit": False, "private": "not exposed"},
{"price_currency": "EUR"},
])
async def test_api_returns_only_counts_and_display_flags(monkeypatch, display):
agent = SimpleNamespace(
DATA_NAME_CTX_WINDOW="ctx_window",
get_data=lambda _key: {
@ -111,6 +116,15 @@ async def test_api_returns_only_counts_and_effective_limit(monkeypatch):
"plugins._context_window.api.context_window.get_chat_model_config",
lambda _agent: {"ctx_length": 128_000},
)
monkeypatch.setattr(
"plugins._context_window.api.context_window.plugins.get_plugin_config",
lambda plugin_name: display if plugin_name == "_context_window" else None,
)
async def unexpected_lookup(_currency):
raise AssertionError("Missing prices must not fetch exchange rates")
monkeypatch.setattr(
"plugins._context_window.api.context_window.get_exchange_rate", unexpected_lookup
)
result = await handler.process({"context": "ctx-1"}, SimpleNamespace())
@ -126,10 +140,176 @@ async def test_api_returns_only_counts_and_effective_limit(monkeypatch):
"extras": 0,
},
"provider_usage": {},
"price_currency": {"code": "USD", "rate": 1, "date": ""},
"display": {
"show_breakdown": display.get("show_breakdown", True),
"show_price": True,
"show_cache_hit": display.get("show_cache_hit", True),
"show_tokens": True,
},
}
assert "text" not in result
@pytest.mark.asyncio
@pytest.mark.parametrize("config, cost, expected_code, lookup", [
({}, 0.05, "USD", False),
({"price_currency": "USD"}, 0.05, "USD", False),
({"price_currency": "EUR", "show_price": False}, 0.05, "USD", False),
({"price_currency": "EUR"}, 0, "EUR", False),
({"price_currency": "EUR"}, 0.05, "EUR", True),
({"price_currency": "../../EUR"}, 0.05, "USD", False),
])
async def test_currency_lookup_only_for_visible_nonzero_non_usd_prices(
monkeypatch, config, cost, expected_code, lookup
):
handler = object.__new__(ContextWindow)
agent = SimpleNamespace(DATA_NAME_CTX_WINDOW="ctx_window", get_data=lambda _key: {})
handler.use_context = lambda _id: SimpleNamespace(streaming_agent=None, agent0=agent)
module = "plugins._context_window.api.context_window"
monkeypatch.setattr(f"{module}.plugins.get_plugin_config", lambda _name: config)
monkeypatch.setattr(f"{module}.get_chat_model_config", lambda _agent: {})
monkeypatch.setattr(f"{module}.latest_provider_usage", lambda _agent: {"cost": cost})
calls = []
async def get_rate(code):
calls.append(code)
return {"code": code, "rate": 0.9, "date": "2026-10-07"}
monkeypatch.setattr(f"{module}.get_exchange_rate", get_rate)
result = await handler.process({"context": "chat-1"}, SimpleNamespace())
assert calls == (["EUR"] if lookup else [])
assert result["price_currency"]["code"] == expected_code
assert result["provider_usage"] == {"cost": cost}
if lookup:
async def failed_rate(_code):
return None
monkeypatch.setattr(f"{module}.get_exchange_rate", failed_rate)
result = await handler.process({"context": "chat-1"}, SimpleNamespace())
assert result["price_currency"] == {
"code": "USD", "rate": 1, "date": "", "requested": "EUR",
}
@pytest.mark.asyncio
async def test_exchange_rate_cache_validation_and_failure_backoff(monkeypatch):
import httpx
monkeypatch.setattr(currency, "CACHE_AREA", "context_currency_test")
cache.clear(currency.CACHE_AREA)
data = {"base": "USD", "quote": "EUR", "rate": 0.9, "date": "2026-10-07"}
calls = []
def respond(request):
calls.append(str(request.url))
if isinstance(data, Exception):
raise data
return httpx.Response(200, json=data)
client = httpx.AsyncClient
monkeypatch.setattr(currency.httpx, "AsyncClient", lambda **kwargs: client(
transport=httpx.MockTransport(respond), **kwargs
))
assert await currency.get_exchange_rate("USD") is None
assert await currency.get_exchange_rate("../../EUR") is None
assert calls == []
expected = {"code": "EUR", "rate": 0.9, "date": "2026-10-07"}
assert await currency.get_exchange_rate("EUR") == expected
assert await currency.get_exchange_rate("EUR") == expected
assert len(calls) == 1
for invalid in (
{"rate": 0}, {"rate": -1}, {"rate": "NaN"}, {"rate": True},
{"base": "GBP"}, {"quote": "JPY"}, {"date": "invalid"},
httpx.ConnectError("offline"),
):
data = invalid if isinstance(invalid, Exception) else {
"base": "USD", "quote": "EUR", "rate": 0.9, "date": "2026-10-07", **invalid,
}
cache.add(currency.CACHE_AREA, "EUR", (0, expected))
assert await currency.get_exchange_rate("EUR") is None
count = len(calls)
assert await currency.get_exchange_rate("EUR") is None
assert len(calls) == count
assert all(url == "https://api.frankfurter.dev/v2/rate/USD/EUR" for url in calls)
cache.clear(currency.CACHE_AREA)
@pytest.mark.skipif(not shutil.which("node"), reason="Node.js is required")
def test_webui_display_toggles_are_independent():
source = (ROOT / "plugins/_context_window/webui/context-window-store.js").read_text(
encoding="utf-8"
)
source = "\n".join(line for line in source.splitlines() if not line.startswith("import "))
source = """
const createStore = (_name, model) => model;
const callJsonApi = async () => globalThis.payload;
const chatsStore = {};
const preferencesStore = { registerUiControlVisibility() {} };
""" + source
module_url = "data:text/javascript;base64," + base64.b64encode(
source.encode("utf-8")
).decode("ascii")
script = f"""
import assert from "node:assert/strict";
const {{ store }} = await import({module_url!r});
globalThis.payload = {{
tokens: 120, context_window: 1000, usage: {{ messages: 42 }},
provider_usage: {{ input_tokens: 100, cached_tokens: 98, output_tokens: 10, cost: 0.0275 }},
}};
const keys = ["show_breakdown", "show_price", "show_cache_hit", "show_tokens"];
for (let mask = 0; mask < 16; mask++) {{
payload.display = Object.fromEntries(keys.map((key, i) => [key, Boolean(mask & (1 << i))]));
const value = await store.refresh("chat-1");
assert.equal(value.hasBreakdown, Boolean(mask & 1));
assert.equal(value.rows.length, mask & 1 ? 7 : 0);
assert.equal(value.missingBreakdown, false);
assert.equal(value.provider.price.hasData, Boolean(mask & 2));
assert.equal(value.provider.cache.hasData, Boolean(mask & 4));
assert.equal(Boolean(value.provider.tokens), Boolean(mask & 8));
assert.equal(value.provider.hasData, Boolean(mask & 14));
assert.equal(value.summaryTokens, "120/1K tokens");
assert.equal(value.ringLabel, "12%");
}}
delete payload.display;
let value = await store.refresh("chat-1");
assert.equal(value.rows.length, 7);
assert.equal(value.provider.price.label, "$0.0275");
assert.equal(value.provider.cache.label, "98%");
assert.equal(value.provider.tokens, "100 → 10");
payload.price_currency = {{ code: "EUR", rate: 0.9, date: "2026-10-07" }};
value = await store.refresh("chat-1");
assert.equal(value.provider.price.label, "€0.0248");
assert.ok(value.provider.price.title.includes("2026-10-07"));
payload.provider_usage.cost = 0.0001;
value = await store.refresh("chat-1");
assert.equal(value.provider.price.label, "<€0.001");
payload.provider_usage.cost = 0;
value = await store.refresh("chat-1");
assert.equal(value.provider.price.label, "€0");
payload.provider_usage.cost = 0.0275;
payload.price_currency = {{ code: "USD", rate: 1, date: "", requested: "EUR" }};
value = await store.refresh("chat-1");
assert.equal(value.provider.price.label, "$0.0275 (USD)");
assert.ok(value.provider.price.title.includes("unavailable"));
delete payload.price_currency;
payload.provider_usage.cost = 0;
value = await store.refresh("chat-1");
assert.equal(value.provider.price.label, "$0");
payload.provider_usage.cost = 0.0001;
value = await store.refresh("chat-1");
assert.equal(value.provider.price.label, "<$0.001");
payload.usage = {{}};
payload.provider_usage = {{}};
value = await store.refresh("chat-1");
assert.equal(value.missingBreakdown, true);
assert.equal(value.provider.hasData, false);
payload.display = {{ show_breakdown: false }};
value = await store.refresh("chat-1");
assert.equal(value.missingBreakdown, false);
"""
subprocess.run(["node", "--input-type=module", "-e", script], check=True)
def test_webui_and_accounting_are_plugin_owned():
model_switcher = (
ROOT
@ -171,10 +351,10 @@ def test_webui_and_accounting_are_plugin_owned():
assert "context-window-cache-meter" not in component
assert "price: {" in context_store
assert "hasData: cost !== null" in context_store
assert 'label: cost === null ? "" : formatCost(cost)' in context_store
assert 'label: cost === null ? "" : formatCost(cost *' in context_store
assert "usage.provider.price.hasData" in component
assert "usage.provider.price.label" in component
assert 'value < 0.001 ? "<$0.001"' in context_store
assert 'value > 0 && value < 0.001' in context_store
assert "maximumSignificantDigits: 3" in context_store
assert "border-top: 1px solid var(--color-border)" in component
assert " → " in context_store

View file

@ -0,0 +1,53 @@
<html>
<head><title>Context Window</title></head>
<body>
<div x-data>
<template x-if="config">
<div>
<div class="section-title">Context Window</div>
<div class="section-description">
Choose what appears in the context window popover. Provider rows appear only when their data is available.
</div>
<template x-for="field in [
{ key: 'show_breakdown', label: 'Breakdown', description: 'Messages, System tools, Skills, MCP tools, System prompt, Extras, and Free space.' },
{ key: 'show_price', label: 'Price', description: 'The reported cost of the latest model call.' },
{ key: 'show_cache_hit', label: 'Cache hit', description: 'The percentage of input tokens served from cache.' },
{ key: 'show_tokens', label: 'Tokens In/Out', description: 'The input and output token counts of the latest model call.' }
]" :key="field.key">
<div class="field">
<div class="field-label">
<div class="field-title" x-text="field.label"></div>
<div class="field-description" x-text="field.description"></div>
</div>
<div class="field-control">
<label class="toggle">
<input type="checkbox" x-model="config[field.key]" :aria-label="field.label"
x-init="if (config[field.key] == null) config[field.key] = true">
<span class="toggler"></span>
</label>
</div>
</div>
</template>
<div class="field">
<div class="field-label">
<div class="field-title">Price currency</div>
<div class="field-description">
USD uses the reported amount directly. Other currencies use daily exchange rates; if unavailable, the price stays in USD.
</div>
</div>
<div class="field-control">
<select x-model="config.price_currency" aria-label="Price currency"
x-init="if (!config.price_currency) config.price_currency = 'USD'">
<template x-for="currency in ['USD', ...Intl.supportedValuesOf('currency').filter(code => code !== 'USD')]" :key="currency">
<option :value="currency" :selected="currency === config.price_currency" x-text="currency"></option>
</template>
</select>
</div>
</div>
</div>
</template>
</div>
</body>
</html>

View file

@ -12,11 +12,6 @@ const ROWS = [
{ key: "system_prompt", label: "System prompt" },
{ key: "extras", label: "Extras" },
];
const COST_FORMATTER = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumSignificantDigits: 3,
});
preferencesStore.registerUiControlVisibility("contextWindowUsage", {
mobile: true,
@ -42,18 +37,22 @@ function optionalNumber(value, key) {
return Number.isFinite(number) && number >= 0 ? number : null;
}
function formatCost(value) {
if (value === 0) return "$0";
return value < 0.001 ? "<$0.001" : COST_FORMATTER.format(value);
function formatCost(value, currency = "USD") {
const formatter = new Intl.NumberFormat("en-US", {
style: "currency",
currency,
maximumSignificantDigits: 3,
});
return value > 0 && value < 0.001 ? `<${formatter.format(0.001)}` : formatter.format(value);
}
function buildProviderUsage(value = {}) {
function buildProviderUsage(value = {}, display = {}, priceCurrency = {}) {
const input = optionalNumber(value, "input_tokens");
const cached = optionalNumber(value, "cached_tokens");
const output = optionalNumber(value, "output_tokens");
const cost = optionalNumber(value, "cost");
const tokenSummary = input === null && output === null
const tokenSummary = display.show_tokens === false || (input === null && output === null)
? ""
: `${input === null ? "–" : formatTokens(input)} → ${output === null ? "–" : formatTokens(output)}`;
@ -61,13 +60,19 @@ function buildProviderUsage(value = {}) {
? Math.min((cached / input) * 100, 100)
: null;
return {
hasData: cost !== null || cachePercent !== null || Boolean(tokenSummary),
get hasData() {
return this.price.hasData || this.cache.hasData || Boolean(this.tokens);
},
price: {
hasData: cost !== null,
label: cost === null ? "" : formatCost(cost),
hasData: cost !== null && display.show_price !== false,
label: cost === null ? "" : formatCost(cost * (priceCurrency.rate ?? 1), priceCurrency.code || "USD")
+ (priceCurrency.requested ? " (USD)" : ""),
title: priceCurrency.requested
? `${priceCurrency.requested} exchange rate unavailable; shown in USD.`
: priceCurrency.date ? `Converted from USD at the ${priceCurrency.date} reference rate.` : "",
},
cache: {
hasData: cachePercent !== null,
hasData: cachePercent !== null && display.show_cache_hit !== false,
label: cachePercent === null ? "" : `${Math.round(cachePercent)}%`,
},
tokens: tokenSummary,
@ -75,6 +80,7 @@ function buildProviderUsage(value = {}) {
}
function buildUsage(data = {}) {
const display = data.display || {};
const tokens = Math.max(Number(data.tokens) || 0, 0);
const contextWindow = Math.max(Number(data.context_window) || 0, 0);
const breakdown = data.usage && typeof data.usage === "object" ? data.usage : {};
@ -88,7 +94,8 @@ function buildUsage(data = {}) {
percentLabel: formatPercent(rowPercent),
};
});
const hasBreakdown = rows.some(row => Number(breakdown[row.key]) > 0);
const hasBreakdown = display.show_breakdown !== false
&& rows.some(row => Number(breakdown[row.key]) > 0);
if (hasBreakdown) {
const freeTokens = Math.max(contextWindow - tokens, 0);
const freePercent = contextWindow > 0 ? (freeTokens / contextWindow) * 100 : 0;
@ -103,14 +110,14 @@ function buildUsage(data = {}) {
return {
rows: hasBreakdown ? rows : [],
hasBreakdown,
missingBreakdown: !hasBreakdown,
missingBreakdown: display.show_breakdown !== false && !hasBreakdown,
ariaLabel: `Context window ${percentLabel} used`,
ringLabel: contextWindow ? `${Math.round(percent)}%` : "–",
ringDasharray: `${Math.min(percent, 100)} 100`,
summaryTokens: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : "–"} tokens`,
summaryPercent: `${percentLabel} used`,
meterStyle: `width:${Math.min(percent, 100)}%`,
provider: buildProviderUsage(data.provider_usage),
provider: buildProviderUsage(data.provider_usage, display, data.price_currency),
};
}