Files
2026-07-13 13:12:33 +08:00

455 lines
18 KiB
Python

"""Unit tests for SessionUsage / UsageTracker cache token accumulation."""
import pytest
from opensquilla.engine.usage import ModelUsage, SessionUsage, UsageTracker
def test_session_usage_accumulates_cache_tokens() -> None:
usage = SessionUsage(model_id="claude-opus-4-7")
usage.add(1000, 50, "claude-opus-4-7", cache_read_tokens=500, cache_write_tokens=100)
usage.add(2000, 80, "claude-opus-4-7", cache_read_tokens=300, cache_write_tokens=40)
assert usage.input_tokens == 3000
assert usage.output_tokens == 130
assert usage.cache_read_tokens == 800
assert usage.cache_write_tokens == 140
def test_session_usage_per_model_breakdown_isolates_cache_tokens() -> None:
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", cache_read_tokens=500, cache_write_tokens=100)
usage.add(2000, 80, "deepseek-v4-pro", cache_read_tokens=300, cache_write_tokens=40)
assert usage._per_model is not None
opus = usage._per_model["claude-opus-4-7"]
deepseek = usage._per_model["deepseek-v4-pro"]
assert opus.cache_read_tokens == 500
assert opus.cache_write_tokens == 100
assert deepseek.cache_read_tokens == 300
assert deepseek.cache_write_tokens == 40
def test_usage_tracker_session_total_equals_sum_across_providers() -> None:
# R9c guard: add() is synchronous, so a session's totals are the exact
# arithmetic sum of every accumulation, including turns billed to
# different providers/models (no interleaving / lost-update race).
tracker = UsageTracker()
sk = "agent:main:s1"
tracker.add(sk, 1000, 100, model_id="anthropic/m1", provider="anthropic")
tracker.add(sk, 2000, 200, model_id="deepseek/m2", provider="deepseek")
tracker.add(sk, 500, 50, model_id="anthropic/m1", provider="anthropic")
usage = tracker.get(sk)
assert usage is not None
assert usage.input_tokens == 3500
assert usage.output_tokens == 350
assert usage._per_model is not None
assert usage._per_model["anthropic/m1"].input_tokens == 1500
assert usage._per_model["deepseek/m2"].input_tokens == 2000
def test_session_usage_add_default_cache_zero() -> None:
"""Existing positional callers (no cache kwargs) still work; cache fields stay at 0."""
usage = SessionUsage(model_id="claude-opus-4-7")
usage.add(1000, 50, "claude-opus-4-7")
assert usage.input_tokens == 1000
assert usage.cache_read_tokens == 0
assert usage.cache_write_tokens == 0
def test_usage_tracker_add_passes_cache_to_session() -> None:
tracker = UsageTracker()
tracker.add(
"session-a",
input_tokens=1000,
output_tokens=50,
model_id="claude-opus-4-7",
cache_read_tokens=200,
cache_write_tokens=80,
)
tracker.add(
"session-a",
input_tokens=500,
output_tokens=20,
model_id="claude-opus-4-7",
cache_read_tokens=100,
cache_write_tokens=40,
)
usage = tracker.get("session-a")
assert usage is not None
assert usage.cache_read_tokens == 300
assert usage.cache_write_tokens == 120
def test_usage_tracker_isolates_sessions() -> None:
tracker = UsageTracker()
tracker.add(
"session-a",
input_tokens=100,
output_tokens=10,
model_id="m",
cache_read_tokens=50,
cache_write_tokens=5,
)
tracker.add(
"session-b",
input_tokens=200,
output_tokens=20,
model_id="m",
cache_read_tokens=70,
cache_write_tokens=15,
)
a = tracker.get("session-a")
b = tracker.get("session-b")
assert a is not None and b is not None
assert a.cache_read_tokens == 50
assert a.cache_write_tokens == 5
assert b.cache_read_tokens == 70
assert b.cache_write_tokens == 15
# ---------------------------------------------------------------------------
# Positional dataclass construction safety.
# ---------------------------------------------------------------------------
def test_session_usage_positional_construction_does_not_shift_fields() -> None:
"""Regression: SessionUsage(1, 2, "claude-opus-4-7") must keep the third
positional arg in model_id, not in a cache field. Asserts the new cache
counters were appended at the *end* of the dataclass."""
usage = SessionUsage(1000, 50, "claude-opus-4-7")
assert usage.input_tokens == 1000
assert usage.output_tokens == 50
assert usage.model_id == "claude-opus-4-7"
assert usage.cache_read_tokens == 0
assert usage.cache_write_tokens == 0
def test_model_usage_positional_construction_keeps_model_id_first() -> None:
"""ModelUsage(model_id, in, out) — sanity check for positional callers."""
mu = ModelUsage("claude-opus-4-7", 1000, 50)
assert mu.model_id == "claude-opus-4-7"
assert mu.input_tokens == 1000
assert mu.output_tokens == 50
assert mu.cache_read_tokens == 0
assert mu.cache_write_tokens == 0
# ---------------------------------------------------------------------------
# model_breakdown must surface cache fields.
# ---------------------------------------------------------------------------
def test_model_breakdown_serializes_cache_fields_for_per_model_path() -> None:
"""Multi-model session: each breakdown entry must carry cache R/W counters
so the UI's modelBreakdown column can show per-model cache split."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", cache_read_tokens=500, cache_write_tokens=100)
usage.add(2000, 80, "deepseek-v4-pro", cache_read_tokens=300, cache_write_tokens=40)
breakdown = usage.model_breakdown
assert len(breakdown) == 2
by_model = {row["model"]: row for row in breakdown}
assert by_model["claude-opus-4-7"]["cacheReadTokens"] == 500
assert by_model["claude-opus-4-7"]["cacheWriteTokens"] == 100
assert by_model["deepseek-v4-pro"]["cacheReadTokens"] == 300
assert by_model["deepseek-v4-pro"]["cacheWriteTokens"] == 40
def test_model_breakdown_serializes_cache_fields_for_single_model_path() -> None:
"""Single-model session (no per-model dict yet): the synthesized one-row
breakdown must also carry cache R/W counters."""
usage = SessionUsage(model_id="claude-opus-4-7")
# Direct field mutation — exercises the no-_per_model branch in model_breakdown.
usage.input_tokens = 1000
usage.output_tokens = 50
usage.cache_read_tokens = 200
usage.cache_write_tokens = 80
[row] = usage.model_breakdown
assert row["model"] == "claude-opus-4-7"
assert row["cacheReadTokens"] == 200
assert row["cacheWriteTokens"] == 80
# ---------------------------------------------------------------------------
# Per-model billed_cost: real provider-billed amounts flow through ModelUsage
# so the breakdown can show actual numbers instead of relying on the rpc_usage
# pro-rate workaround.
# ---------------------------------------------------------------------------
def test_model_usage_accumulates_billed_cost() -> None:
"""ModelUsage.billed_cost accumulates across SessionUsage.add(...) calls
for the same model. Mirrors the agent.py raw_ev loop where one model can
receive multiple provider calls within a turn."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.0500)
usage.add(2000, 80, "claude-opus-4-7", billed_cost=0.0125)
assert usage._per_model is not None
opus = usage._per_model["claude-opus-4-7"]
assert opus.billed_cost == pytest.approx(0.0625)
def test_session_usage_per_model_billed_isolates_by_model() -> None:
"""Multi-model session: each model's billed_cost stays independent."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.1254)
usage.add(9000, 100, "z-ai/glm-5.1", billed_cost=0.0111)
assert usage._per_model is not None
assert usage._per_model["claude-opus-4-7"].billed_cost == pytest.approx(0.1254)
assert usage._per_model["z-ai/glm-5.1"].billed_cost == pytest.approx(0.0111)
def test_model_breakdown_uses_billed_when_present() -> None:
"""When provider returned real billed costs, model_breakdown must surface
those (not the cache-blind pricing-table estimate). Source must flip to
'provider_billed' so the UI renders the solid 'Actual' chip without
triggering the pro-rate disclosure notice."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.1254)
usage.add(9000, 100, "z-ai/glm-5.1", billed_cost=0.0111)
breakdown = usage.model_breakdown
by_model = {row["model"]: row for row in breakdown}
assert by_model["claude-opus-4-7"]["costUsd"] == pytest.approx(0.1254)
assert by_model["claude-opus-4-7"]["billedCostUsd"] == pytest.approx(0.1254)
assert by_model["claude-opus-4-7"]["costSource"] == "provider_billed"
assert by_model["z-ai/glm-5.1"]["costUsd"] == pytest.approx(0.0111)
assert by_model["z-ai/glm-5.1"]["costSource"] == "provider_billed"
# Sum of per-model billed equals total session billed (the property the
# WebUI relies on to render the "Actual" badge without pro-rating).
total = sum(row["costUsd"] for row in breakdown)
assert total == pytest.approx(0.1365)
def test_model_breakdown_falls_back_to_estimate_when_no_billed() -> None:
"""When billed_cost is 0 (provider unavailable / estimate-only path),
model_breakdown reverts to the local pricing-table estimate and tags the
source as 'opensquilla_estimate'."""
usage = SessionUsage()
# Use a model that has a stable price entry.
usage.add(1000, 50, "claude-opus-4-7") # no billed_cost kwarg → defaults to 0.0
[row] = usage.model_breakdown
assert row["billedCostUsd"] == 0.0
assert row["costSource"] == "opensquilla_estimate"
# costUsd here is the pricing-table estimate (non-zero for known models).
assert row["costUsd"] > 0
def test_model_breakdown_mixed_billed_and_estimate() -> None:
"""If only one of two models has a billed cost, each row's source reflects
its own situation — one provider_billed, one opensquilla_estimate. Total
is mixed but the rpc_usage layer handles that aggregation separately."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.05)
usage.add(2000, 80, "deepseek-v4-pro") # no billed
by_model = {row["model"]: row for row in usage.model_breakdown}
assert by_model["claude-opus-4-7"]["costSource"] == "provider_billed"
assert by_model["deepseek-v4-pro"]["costSource"] == "opensquilla_estimate"
def test_usage_tracker_add_forwards_billed_cost() -> None:
"""UsageTracker.add must thread billed_cost through to SessionUsage —
this is the surface agent.py uses, so a regression here breaks the whole
provider-billed cost path."""
tracker = UsageTracker()
tracker.add(
"session-a",
input_tokens=1000,
output_tokens=50,
model_id="claude-opus-4-7",
billed_cost=0.05,
)
tracker.add(
"session-a",
input_tokens=500,
output_tokens=20,
model_id="claude-opus-4-7",
billed_cost=0.025,
)
usage = tracker.get("session-a")
assert usage is not None
assert usage._per_model is not None
assert usage._per_model["claude-opus-4-7"].billed_cost == pytest.approx(0.075)
def test_session_billed_cost_aggregates_across_models() -> None:
"""SessionUsage.billed_cost sums per-model real billed totals.
Used by rpc_usage._tracker_rows to decide the row-level cost_source."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.1254)
usage.add(9000, 100, "z-ai/glm-5.1", billed_cost=0.0111)
assert usage.billed_cost == pytest.approx(0.1365)
def test_session_cost_source_provider_billed_when_all_models_billed() -> None:
"""Every model has real billed → row source = provider_billed."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.05)
usage.add(2000, 80, "deepseek-v4-pro", billed_cost=0.01)
assert usage.cost_source == "provider_billed"
def test_session_cost_source_mixed_when_only_some_models_billed() -> None:
"""Some models billed, some estimate-only → source = mixed.
Prevents the row from claiming 'Actual' when half its breakdown is
estimated."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.05)
usage.add(2000, 80, "deepseek-v4-pro") # no billed
assert usage.cost_source == "mixed"
def test_session_cost_source_estimate_when_no_models_billed() -> None:
"""No billed data anywhere → source = opensquilla_estimate (the
estimate-only path; preserved for sessions where provider returned no cost
on any call)."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7")
usage.add(2000, 80, "deepseek-v4-pro")
assert usage.cost_source == "opensquilla_estimate"
assert usage.billed_cost == 0.0
def test_session_total_cost_uses_billed_when_available() -> None:
"""All-billed session: total_cost equals billed_cost (no double-count)."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.05)
usage.add(2000, 80, "deepseek-v4-pro", billed_cost=0.01)
assert usage.total_cost == pytest.approx(0.06)
assert usage.billed_cost == pytest.approx(0.06)
def test_session_total_cost_mixes_billed_and_estimate() -> None:
"""Mixed session: total_cost = billed (for billed models) +
estimate (for unbilled). This is the key property that prevents
the row from under-reporting the unbilled portion of cost.
Without this, _tracker_rows would set row.cost_usd to billed_cost
only ($0.05) while the breakdown's unbilled item still contributes
its estimate ($0.03) — the row would show $0.05 but the breakdown
sum would be $0.08, a visible inconsistency."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.05)
# deepseek-v4-pro: no billed; pricing-table estimate kicks in
usage.add(2000, 80, "deepseek-v4-pro")
# billed_cost only counts billed models
billed = usage.billed_cost
assert billed == pytest.approx(0.05)
# cost (estimate) sums both models' pricing-table estimates
estimate = usage.cost
assert estimate > 0 # both models have pricing entries
# total_cost: billed for first + estimate for second
deepseek_estimate = usage._per_model["deepseek-v4-pro"].cost
expected_total = 0.05 + deepseek_estimate
assert usage.total_cost == pytest.approx(expected_total)
# And total_cost equals the breakdown sum (key invariant). The breakdown
# serializer rounds to 6 decimals so we allow a 1e-6 tolerance against
# the un-rounded total_cost; user-facing display rounds to <=4 decimals.
breakdown_sum = sum(item["costUsd"] for item in usage.model_breakdown)
assert breakdown_sum == pytest.approx(usage.total_cost, abs=1e-6)
def test_session_total_cost_same_model_mixed_calls_adds_estimate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""One model, one billed call plus one unbilled call: total_cost must be
billed + estimate-of-unbilled, not the old billed-only collapse. The
breakdown row flips to 'mixed' and still sums to total_cost."""
monkeypatch.setenv("OPENSQUILLA_OPENROUTER_LIVE_PRICING", "0")
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7", billed_cost=0.05)
usage.add(2000, 80, "claude-opus-4-7") # provider returned no cost
# $15/M in + $75/M out (static table): (2000 * 15 + 80 * 75) / 1e6 = 0.036
assert usage.total_cost == pytest.approx(0.05 + 0.036)
[row] = usage.model_breakdown
assert row["costSource"] == "mixed"
assert row["billedCostUsd"] == pytest.approx(0.05)
assert row["costUsd"] == pytest.approx(usage.total_cost, abs=1e-6)
def test_session_total_cost_falls_back_to_estimate_when_no_billed() -> None:
"""No billed anywhere → total_cost == cost (pure estimate)."""
usage = SessionUsage()
usage.add(1000, 50, "claude-opus-4-7")
usage.add(2000, 80, "deepseek-v4-pro")
assert usage.total_cost == pytest.approx(usage.cost)
def test_session_total_cost_empty_session_is_zero() -> None:
"""Defensive: empty session returns 0 (falls through to .cost which is 0)."""
usage = SessionUsage()
assert usage.total_cost == 0.0
def test_session_billed_cost_zero_when_no_per_model() -> None:
"""Defensive: empty session (no add() calls) returns 0 billed and
estimate cost source. Guards against AttributeError when _per_model
is None."""
usage = SessionUsage()
assert usage.billed_cost == 0.0
assert usage.cost_source == "opensquilla_estimate"
def test_model_usage_positional_construction_keeps_billed_cost_default() -> None:
"""Regression: the new billed_cost field MUST be appended at the end of
ModelUsage so positional callers (ModelUsage(model_id, in, out, ...))
keep aligning. Existing test (around line 112) already covers
cache_read/write at the tail; this extends to billed_cost."""
mu = ModelUsage("claude-opus-4-7", 1000, 50, 200, 80)
assert mu.model_id == "claude-opus-4-7"
assert mu.input_tokens == 1000
assert mu.output_tokens == 50
assert mu.cache_read_tokens == 200
assert mu.cache_write_tokens == 80
assert mu.billed_cost == 0.0 # default at the tail
def test_local_provider_estimates_as_free() -> None:
usage = SessionUsage()
usage.add(10_000, 500, "qwen3:4b", provider="ollama")
assert usage.cost == 0.0
assert usage._per_model is not None
assert usage._per_model["qwen3:4b"].cost == 0.0
breakdown = usage.model_breakdown[0]
assert breakdown["costUsd"] == 0.0
assert breakdown["billedCostUsd"] == 0.0
def test_cloud_provider_cost_unchanged() -> None:
usage = SessionUsage()
usage.add(1_000_000, 0, "claude-sonnet-4", provider="anthropic")
assert usage.cost == pytest.approx(3.0)
def test_usage_tracker_threads_provider_to_per_model() -> None:
tracker = UsageTracker()
tracker.add("s", 8000, 300, "gemma3:4b", provider="ollama")
sess = tracker._sessions["s"]
assert sess.cost == 0.0
assert sess._per_model is not None
assert sess._per_model["gemma3:4b"].provider == "ollama"