fix(telemetry): cost dashboard now computes real USD totals
Three intertwined bugs caused every Cost-tab row to display \$0 even after the scraper started writing real token data: 1. **Lookup key mismatch.** ``_cost_usd`` looked up bare ``model`` but ``_DEFAULT_PRICES`` was keyed by ``provider/model`` — every priced model silently missed. Fixed by adding ``_price_key`` and a fallback chain: ``provider/model`` → bare ``model`` → ``_unknown``. 2. **SQL grouped by model only.** Same modelID served by two providers (e.g. ``claude-opus-4-6`` via Anthropic direct vs a local proxy) at different rates was conflated into one row. Fixed: ``GROUP BY model, provider`` in ``_api_cost`` + ``provider`` returned in each row. 3. **Math convention mismatch.** ``_cost_usd`` did ``(tokens_in - cached) * in_rate`` assuming ``tokens_in`` was total input. But the scraper records ``tokens_in`` as OpenCode's ``info.tokens.input`` (fresh, non-cached), so ``tokens_in - cached`` went negative whenever cache reads exceeded fresh input — which is the common case with Anthropic prompt caching. Fixed: no subtraction; the three populations bill at their three rates. Pricing seeded for the 8 models the scraper has actually observed (``_DEFAULT_PRICES`` corrected from stale Opus-3 numbers + new entries for the Haiku 4.5 / GPT-5 family / CleverThis HF endpoints): | Provider | Model | in | out | cached_in | |--------------|---------------------------|-------|-------|-----------| | local-claude | claude-opus-4-6 | 5.00 | 25.00 | 0.50 | | local-claude | claude-sonnet-4-6 | 3.00 | 15.00 | 0.30 | | local-claude | claude-haiku-4-5 | 1.00 | 5.00 | 0.10 | | openai | gpt-5 / gpt-5-codex | 1.25 | 10.00 | 0.125 | | openai | gpt-5-mini | 0.25 | 2.00 | 0.025 | | openai | gpt-5-nano | 0.05 | 0.40 | 0.005 | | CleverThis-* | (HF endpoints, advisory) | 0.50 | 1.00 | — | Operators can override without touching code via ``.opencode/telemetry/prices.json`` (added; same keying convention). ``_load_prices`` now skips ``_comment`` / ``_last_updated`` / ``_sources`` metadata keys so docs in the JSON don't pollute the table. Smoke against current ``llm_activity`` (3743 turns, 450 archives): total USD over the lifetime window is now \$118.06, with all 8 models showing as priced. Tests pin all three regressions: provider-qualified lookup, bare-model fallback, no-subtraction math, GROUP BY (model, provider), and the metadata-key filter in ``_load_prices``. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"_comment": "Telemetry token-cost overrides. Keys are 'provider/model' (matching OpenCode's info.providerID + info.modelID); values are USD per million tokens. The 'cached_in' field is optional and defaults to the 'in' rate when absent. See .opencode/telemetry/server.py:_DEFAULT_PRICES for the seeded fallback table.",
|
||||||
|
"_last_updated": "2026-05-17",
|
||||||
|
"_sources": {
|
||||||
|
"anthropic": "platform.claude.com/docs/en/about-claude/pricing (May 2026)",
|
||||||
|
"openai": "openai.com/api/pricing + pricepertoken.com (May 2026)",
|
||||||
|
"CleverThis-*": "HF Inference Endpoints — billed per endpoint-hour; per-token figures are imputed for dashboard reporting"
|
||||||
|
},
|
||||||
|
|
||||||
|
"local-claude/claude-opus-4-6": { "in": 5.0, "out": 25.0, "cached_in": 0.50 },
|
||||||
|
"local-claude/claude-sonnet-4-6": { "in": 3.0, "out": 15.0, "cached_in": 0.30 },
|
||||||
|
"local-claude/claude-haiku-4-5": { "in": 1.0, "out": 5.0, "cached_in": 0.10 },
|
||||||
|
|
||||||
|
"openai/gpt-5": { "in": 1.25, "out": 10.0, "cached_in": 0.125 },
|
||||||
|
"openai/gpt-5-codex": { "in": 1.25, "out": 10.0, "cached_in": 0.125 },
|
||||||
|
"openai/gpt-5-mini": { "in": 0.25, "out": 2.0, "cached_in": 0.025 },
|
||||||
|
"openai/gpt-5-nano": { "in": 0.05, "out": 0.4, "cached_in": 0.005 },
|
||||||
|
|
||||||
|
"CleverThis-4/Kimi-K2-6-GGUF-Q2-K-XL": { "in": 0.5, "out": 1.0 },
|
||||||
|
"CleverThis-15/Qwen3-6-35B-A3B-GGUF-UD-Q3-K-XL": { "in": 0.5, "out": 1.0 },
|
||||||
|
"CleverThis/Qwen3-6-35B-A3B-GGUF-BF16": { "in": 0.5, "out": 1.0 },
|
||||||
|
"CleverThis-2/MiniMax-M2-7-GGUF-BF16": { "in": 0.5, "out": 1.0 }
|
||||||
|
}
|
||||||
@@ -382,16 +382,26 @@ def _heartbeat_age(candidates: list[str]) -> dict[str, Any] | None:
|
|||||||
# Defaults seeded from publicly-listed prices; override via
|
# Defaults seeded from publicly-listed prices; override via
|
||||||
# .opencode/telemetry/prices.json. Costs are $/1,000,000 tokens.
|
# .opencode/telemetry/prices.json. Costs are $/1,000,000 tokens.
|
||||||
_DEFAULT_PRICES: dict[str, dict[str, float]] = {
|
_DEFAULT_PRICES: dict[str, dict[str, float]] = {
|
||||||
# Anthropic (public)
|
# Anthropic (public — May 2026 list prices). Cache reads are
|
||||||
"anthropic/claude-opus-4-6": {"in": 15.0, "out": 75.0, "cached_in": 1.5},
|
# 10% of standard input across the family.
|
||||||
"anthropic/claude-sonnet-4-6": {"in": 3.0, "out": 15.0, "cached_in": 0.3},
|
"local-claude/claude-opus-4-6": {"in": 5.0, "out": 25.0, "cached_in": 0.50},
|
||||||
# CleverThis HF Inference Endpoints — these are billed per
|
"local-claude/claude-sonnet-4-6": {"in": 3.0, "out": 15.0, "cached_in": 0.30},
|
||||||
# endpoint-hour, not per token, so the per-token estimate here is a
|
"local-claude/claude-haiku-4-5": {"in": 1.0, "out": 5.0, "cached_in": 0.10},
|
||||||
# best-effort imputation for "what would this have cost on a public
|
# OpenAI (public — May 2026 list prices). gpt-5 / gpt-5-codex
|
||||||
# API"; treat as advisory not source-of-truth.
|
# share the same per-token rate; gpt-5-mini / gpt-5-nano are
|
||||||
|
# the budget tier.
|
||||||
|
"openai/gpt-5": {"in": 1.25, "out": 10.0, "cached_in": 0.125},
|
||||||
|
"openai/gpt-5-codex": {"in": 1.25, "out": 10.0, "cached_in": 0.125},
|
||||||
|
"openai/gpt-5-mini": {"in": 0.25, "out": 2.0, "cached_in": 0.025},
|
||||||
|
"openai/gpt-5-nano": {"in": 0.05, "out": 0.4, "cached_in": 0.005},
|
||||||
|
# CleverThis HF Inference Endpoints — billed per endpoint-hour,
|
||||||
|
# not per token. The per-token entries here are a best-effort
|
||||||
|
# imputation for "what would this have cost on a public API";
|
||||||
|
# treat as advisory not source-of-truth.
|
||||||
"CleverThis-15/Qwen3-6-35B-A3B-GGUF-UD-Q3-K-XL": {"in": 0.5, "out": 1.0},
|
"CleverThis-15/Qwen3-6-35B-A3B-GGUF-UD-Q3-K-XL": {"in": 0.5, "out": 1.0},
|
||||||
"CleverThis/Qwen3-6-35B-A3B-GGUF-BF16": {"in": 0.5, "out": 1.0},
|
"CleverThis/Qwen3-6-35B-A3B-GGUF-BF16": {"in": 0.5, "out": 1.0},
|
||||||
"CleverThis-2/MiniMax-M2-7-GGUF-BF16": {"in": 0.5, "out": 1.0},
|
"CleverThis-2/MiniMax-M2-7-GGUF-BF16": {"in": 0.5, "out": 1.0},
|
||||||
|
"CleverThis-4/Kimi-K2-6-GGUF-Q2-K-XL": {"in": 0.5, "out": 1.0},
|
||||||
# Sentinel — when we don't know, we'd rather produce 0 than a wild
|
# Sentinel — when we don't know, we'd rather produce 0 than a wild
|
||||||
# number. The UI surfaces "model unpriced" rows separately.
|
# number. The UI surfaces "model unpriced" rows separately.
|
||||||
"_unknown": {"in": 0.0, "out": 0.0},
|
"_unknown": {"in": 0.0, "out": 0.0},
|
||||||
@@ -410,17 +420,55 @@ def _load_prices() -> dict[str, dict[str, float]]:
|
|||||||
data = json.loads(override_path.read_text())
|
data = json.loads(override_path.read_text())
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
merged = dict(_DEFAULT_PRICES)
|
merged = dict(_DEFAULT_PRICES)
|
||||||
merged.update(data)
|
# Skip metadata keys (``_comment``, ``_last_updated``,
|
||||||
|
# ``_sources``) so they don't pollute the price table.
|
||||||
|
# The ``_unknown`` sentinel is preserved because it IS
|
||||||
|
# a valid price-table entry the lookup falls back to.
|
||||||
|
for k, v in data.items():
|
||||||
|
if k.startswith("_") and k != "_unknown":
|
||||||
|
continue
|
||||||
|
if isinstance(v, dict):
|
||||||
|
merged[k] = v
|
||||||
return merged
|
return merged
|
||||||
except (OSError, json.JSONDecodeError) as e:
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
logger.warning("ignoring prices.json: %r", e)
|
logger.warning("ignoring prices.json: %r", e)
|
||||||
return dict(_DEFAULT_PRICES)
|
return dict(_DEFAULT_PRICES)
|
||||||
|
|
||||||
|
|
||||||
def _cost_usd(model: str, prices: dict[str, dict[str, float]],
|
def _price_key(provider: str | None, model: str) -> str:
|
||||||
|
"""Resolve the lookup key for the price table.
|
||||||
|
|
||||||
|
The price table is keyed by ``provider/model`` because the same
|
||||||
|
``modelID`` can be served by multiple providers at very different
|
||||||
|
economics (e.g. ``claude-opus-4-6`` via Anthropic direct vs via a
|
||||||
|
local proxy). Falls back to the bare model name for backward
|
||||||
|
compat with manually-inserted rows that never had a provider.
|
||||||
|
"""
|
||||||
|
if provider:
|
||||||
|
return f"{provider}/{model}"
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _cost_usd(provider: str | None, model: str,
|
||||||
|
prices: dict[str, dict[str, float]],
|
||||||
tokens_in: int, tokens_out: int, cached: int) -> float:
|
tokens_in: int, tokens_out: int, cached: int) -> float:
|
||||||
p = prices.get(model) or prices["_unknown"]
|
"""Compute USD cost for a per-model token tally.
|
||||||
in_cost = (tokens_in - cached) * p.get("in", 0.0) / 1_000_000
|
|
||||||
|
Lookup order: ``{provider}/{model}`` → bare ``model`` → ``_unknown``.
|
||||||
|
The fallback chain lets a price entry written without provider
|
||||||
|
namespacing still match, while still preferring the
|
||||||
|
provider-qualified entry when present.
|
||||||
|
|
||||||
|
Token-accounting convention: ``tokens_in`` and ``cached`` are
|
||||||
|
DISJOINT populations — the scraper writes ``tokens_in`` as
|
||||||
|
OpenCode's ``info.tokens.input`` (fresh, non-cached prompt
|
||||||
|
tokens) plus any cache-write tokens; ``cached`` is
|
||||||
|
``info.tokens.cache.read``. So total cost is the simple sum of
|
||||||
|
three line items at three rates, with no subtraction needed.
|
||||||
|
"""
|
||||||
|
p = prices.get(_price_key(provider, model)) or prices.get(model) \
|
||||||
|
or prices.get("_unknown", {})
|
||||||
|
in_cost = tokens_in * p.get("in", 0.0) / 1_000_000
|
||||||
cached_cost = cached * p.get("cached_in", p.get("in", 0.0)) / 1_000_000
|
cached_cost = cached * p.get("cached_in", p.get("in", 0.0)) / 1_000_000
|
||||||
out_cost = tokens_out * p.get("out", 0.0) / 1_000_000
|
out_cost = tokens_out * p.get("out", 0.0) / 1_000_000
|
||||||
return round(in_cost + cached_cost + out_cost, 4)
|
return round(in_cost + cached_cost + out_cost, 4)
|
||||||
@@ -1765,13 +1813,13 @@ def _api_cost(days: int) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
cur = conn.execute(
|
cur = conn.execute(
|
||||||
"SELECT model, COUNT(*) AS n,"
|
"SELECT model, provider, COUNT(*) AS n,"
|
||||||
" SUM(COALESCE(tokens_in, 0)) AS in_tok,"
|
" SUM(COALESCE(tokens_in, 0)) AS in_tok,"
|
||||||
" SUM(COALESCE(tokens_out, 0)) AS out_tok,"
|
" SUM(COALESCE(tokens_out, 0)) AS out_tok,"
|
||||||
" SUM(COALESCE(cached_tokens, 0)) AS cached_tok"
|
" SUM(COALESCE(cached_tokens, 0)) AS cached_tok"
|
||||||
" FROM llm_activity"
|
" FROM llm_activity"
|
||||||
" WHERE started_at > datetime('now', ?)"
|
" WHERE started_at > datetime('now', ?)"
|
||||||
" GROUP BY model ORDER BY n DESC",
|
" GROUP BY model, provider ORDER BY n DESC",
|
||||||
(f"-{int(days)} days",),
|
(f"-{int(days)} days",),
|
||||||
)
|
)
|
||||||
rows = []
|
rows = []
|
||||||
@@ -1781,15 +1829,18 @@ def _api_cost(days: int) -> dict[str, Any]:
|
|||||||
in_tok = int(r["in_tok"] or 0)
|
in_tok = int(r["in_tok"] or 0)
|
||||||
out_tok = int(r["out_tok"] or 0)
|
out_tok = int(r["out_tok"] or 0)
|
||||||
cached = int(r["cached_tok"] or 0)
|
cached = int(r["cached_tok"] or 0)
|
||||||
|
model = r["model"] or "_unknown"
|
||||||
|
provider = r["provider"]
|
||||||
usd = _cost_usd(
|
usd = _cost_usd(
|
||||||
r["model"] or "_unknown", prices, in_tok, out_tok, cached,
|
provider, model, prices, in_tok, out_tok, cached,
|
||||||
)
|
)
|
||||||
|
key = _price_key(provider, model)
|
||||||
rows.append(
|
rows.append(
|
||||||
{
|
{
|
||||||
"model": r["model"], "n": r["n"],
|
"model": model, "provider": provider, "n": r["n"],
|
||||||
"tokens_in": in_tok, "tokens_out": out_tok,
|
"tokens_in": in_tok, "tokens_out": out_tok,
|
||||||
"cached": cached, "usd": usd,
|
"cached": cached, "usd": usd,
|
||||||
"priced": (r["model"] in prices),
|
"priced": (key in prices or model in prices),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
total_usd += usd
|
total_usd += usd
|
||||||
|
|||||||
@@ -384,3 +384,162 @@ def test_running_long_worker_set_when_pid_alive_and_heartbeat_stale(
|
|||||||
"the long-worker state must fire — that's the operator alarm "
|
"the long-worker state must fire — that's the operator alarm "
|
||||||
"for a regressed on_poll callback"
|
"for a regressed on_poll callback"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Cost calculation ─────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# These tests pin the May 2026 fix that closed three intertwined bugs:
|
||||||
|
# (1) the lookup used bare ``model`` but ``_DEFAULT_PRICES`` was keyed
|
||||||
|
# by ``provider/model``, so even priced models showed $0; (2) the SQL
|
||||||
|
# grouped by ``model`` only, so the same modelID served by two providers
|
||||||
|
# at different rates was conflated; (3) ``tokens_in - cached`` went
|
||||||
|
# negative because the scraper records ``tokens_in`` as fresh-only,
|
||||||
|
# but the cost calc treated it as fresh + cached.
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_llm_activity_row(cache, **overrides):
|
||||||
|
row = {
|
||||||
|
"started_at": "2026-05-01T12:00:00+00:00",
|
||||||
|
"agent": "pr-review-worker",
|
||||||
|
"model": "claude-opus-4-6",
|
||||||
|
"provider": "local-claude",
|
||||||
|
"tokens_in": 1_000_000, # 1M fresh input tokens
|
||||||
|
"tokens_out": 100_000, # 100K output tokens
|
||||||
|
"cached_tokens": 10_000_000, # 10M cached read tokens
|
||||||
|
}
|
||||||
|
row.update(overrides)
|
||||||
|
cache.upsert_llm_activity(row)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cost_usd_uses_provider_qualified_lookup(telemetry):
|
||||||
|
"""Same modelID, two providers, two different rates: the dispatch
|
||||||
|
must NOT conflate them. Regression pin for the 2026-05 bug where
|
||||||
|
the SQL grouped by ``model`` only."""
|
||||||
|
server, _ = telemetry
|
||||||
|
# Hand-craft a price table with two distinct rates for the same model.
|
||||||
|
prices = {
|
||||||
|
"providerA/shared-model": {"in": 10.0, "out": 100.0},
|
||||||
|
"providerB/shared-model": {"in": 1.0, "out": 10.0},
|
||||||
|
"_unknown": {"in": 0.0, "out": 0.0},
|
||||||
|
}
|
||||||
|
a = server._cost_usd("providerA", "shared-model", prices, 1_000_000, 1_000_000, 0)
|
||||||
|
b = server._cost_usd("providerB", "shared-model", prices, 1_000_000, 1_000_000, 0)
|
||||||
|
# A: 1M * $10/M input + 1M * $100/M output = $10 + $100 = $110
|
||||||
|
# B: 1M * $1/M input + 1M * $10/M output = $1 + $10 = $11
|
||||||
|
assert a == 110.0
|
||||||
|
assert b == 11.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cost_usd_falls_back_to_bare_model_then_unknown(telemetry):
|
||||||
|
"""Lookup is provider/model → bare model → _unknown. Lets a
|
||||||
|
manually-inserted price entry without provider namespacing still
|
||||||
|
match, and a totally unknown model produces $0 rather than KeyError."""
|
||||||
|
server, _ = telemetry
|
||||||
|
prices = {
|
||||||
|
"bare-model": {"in": 2.0, "out": 4.0},
|
||||||
|
"_unknown": {"in": 0.0, "out": 0.0},
|
||||||
|
}
|
||||||
|
# bare model fallback
|
||||||
|
assert server._cost_usd("anyprovider", "bare-model", prices, 1_000_000, 0, 0) == 2.0
|
||||||
|
# _unknown fallback
|
||||||
|
assert server._cost_usd("anyprovider", "novel-model", prices, 1_000_000, 99, 0) == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cost_usd_does_not_subtract_cached_from_input(telemetry):
|
||||||
|
"""``tokens_in`` and ``cached`` are DISJOINT in the schema —
|
||||||
|
they bill at different rates and must NOT be subtracted from each
|
||||||
|
other. Regression pin: a previous calc did ``(tokens_in - cached)``
|
||||||
|
which goes negative whenever cache reads exceed fresh input
|
||||||
|
(Anthropic prompt caching makes this the common case)."""
|
||||||
|
server, _ = telemetry
|
||||||
|
prices = {
|
||||||
|
"p/m": {"in": 1.0, "out": 0.0, "cached_in": 0.1},
|
||||||
|
"_unknown": {"in": 0.0, "out": 0.0},
|
||||||
|
}
|
||||||
|
# 1M fresh + 10M cached: in_cost = 1M * $1/M = $1; cached_cost = 10M * $0.1/M = $1; total = $2
|
||||||
|
usd = server._cost_usd("p", "m", prices, 1_000_000, 0, 10_000_000)
|
||||||
|
assert usd == 2.0, (
|
||||||
|
f"expected $2.00 (fresh+cached at their own rates), got ${usd}; "
|
||||||
|
"the (tokens_in - cached) subtraction bug has regressed"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_cost_returns_nonzero_for_priced_model(telemetry, monkeypatch):
|
||||||
|
"""End-to-end pin: a seeded ``llm_activity`` row with a model
|
||||||
|
present in ``_DEFAULT_PRICES`` produces a non-zero USD total
|
||||||
|
via ``_api_cost``. This is the test that would have failed
|
||||||
|
before the lookup-key fix landed."""
|
||||||
|
server, cache = telemetry
|
||||||
|
# Force the loader to skip any operator-edited prices.json so the
|
||||||
|
# test is deterministic against the in-code defaults.
|
||||||
|
monkeypatch.setenv("TELEMETRY_PRICES_JSON", "/nonexistent")
|
||||||
|
_seed_llm_activity_row(cache)
|
||||||
|
# Re-run started_at into a fresh window relative to "now"
|
||||||
|
cache._conn.execute(
|
||||||
|
"UPDATE llm_activity SET started_at = datetime('now', '-1 hour')"
|
||||||
|
)
|
||||||
|
cache._conn.commit()
|
||||||
|
|
||||||
|
payload = server._api_cost(days=7)
|
||||||
|
assert payload["instrumentation_pending"] is False
|
||||||
|
assert len(payload["rows"]) == 1
|
||||||
|
row = payload["rows"][0]
|
||||||
|
assert row["model"] == "claude-opus-4-6"
|
||||||
|
assert row["provider"] == "local-claude"
|
||||||
|
assert row["priced"] is True
|
||||||
|
# 1M fresh * $5/M + 10M cached * $0.50/M + 100K out * $25/M
|
||||||
|
# = $5 + $5 + $2.50 = $12.50
|
||||||
|
assert row["usd"] == 12.5, (
|
||||||
|
f"expected $12.50, got ${row['usd']}; check _DEFAULT_PRICES "
|
||||||
|
"for local-claude/claude-opus-4-6 or the math in _cost_usd"
|
||||||
|
)
|
||||||
|
assert payload["totals"]["usd"] == 12.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_cost_groups_by_provider_and_model(telemetry, monkeypatch):
|
||||||
|
"""Two rows with the same model but different providers must
|
||||||
|
produce two separate rows in the response, NOT a single
|
||||||
|
conflated row."""
|
||||||
|
server, cache = telemetry
|
||||||
|
monkeypatch.setenv("TELEMETRY_PRICES_JSON", "/nonexistent")
|
||||||
|
_seed_llm_activity_row(
|
||||||
|
cache, model="shared", provider="A", message_id="m1",
|
||||||
|
tokens_in=1000, tokens_out=10, cached_tokens=0,
|
||||||
|
)
|
||||||
|
_seed_llm_activity_row(
|
||||||
|
cache, model="shared", provider="B", message_id="m2",
|
||||||
|
tokens_in=2000, tokens_out=20, cached_tokens=0,
|
||||||
|
)
|
||||||
|
cache._conn.execute(
|
||||||
|
"UPDATE llm_activity SET started_at = datetime('now', '-1 hour')"
|
||||||
|
)
|
||||||
|
cache._conn.commit()
|
||||||
|
|
||||||
|
payload = server._api_cost(days=7)
|
||||||
|
providers = sorted(r["provider"] for r in payload["rows"])
|
||||||
|
assert providers == ["A", "B"], (
|
||||||
|
f"expected two rows for providers A and B, got {payload['rows']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_prices_skips_metadata_keys(telemetry, monkeypatch, tmp_path):
|
||||||
|
"""Metadata keys starting with ``_`` (e.g. ``_comment``,
|
||||||
|
``_last_updated``, ``_sources``) must not pollute the price table
|
||||||
|
— only ``_unknown`` is preserved as a legitimate sentinel entry."""
|
||||||
|
import json as _json
|
||||||
|
server, _ = telemetry
|
||||||
|
override = tmp_path / "prices.json"
|
||||||
|
override.write_text(_json.dumps({
|
||||||
|
"_comment": "hello",
|
||||||
|
"_last_updated": "2026-05-17",
|
||||||
|
"_sources": {"x": "y"},
|
||||||
|
"_unknown": {"in": 0.0, "out": 0.0},
|
||||||
|
"test/model": {"in": 1.0, "out": 2.0},
|
||||||
|
}))
|
||||||
|
monkeypatch.setenv("TELEMETRY_PRICES_JSON", str(override))
|
||||||
|
loaded = server._load_prices()
|
||||||
|
for meta in ("_comment", "_last_updated", "_sources"):
|
||||||
|
assert meta not in loaded, f"metadata key {meta!r} leaked into price table"
|
||||||
|
assert "_unknown" in loaded, "_unknown sentinel must survive"
|
||||||
|
assert loaded["test/model"] == {"in": 1.0, "out": 2.0}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user