mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-10-19 15:33:54 +00:00
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:
parent
429946fba4
commit
935fb5cdcc
9 changed files with 360 additions and 38 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
},
|
||||
}
|
||||
|
|
|
|||
5
plugins/_context_window/default_config.yaml
Normal file
5
plugins/_context_window/default_config.yaml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
show_breakdown: true
|
||||
show_price: true
|
||||
show_cache_hit: true
|
||||
show_tokens: true
|
||||
price_currency: USD
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
39
plugins/_context_window/helpers/currency.py
Normal file
39
plugins/_context_window/helpers/currency.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
53
plugins/_context_window/webui/config.html
Normal file
53
plugins/_context_window/webui/config.html
Normal 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>
|
||||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue