diff --git a/agent/agent_init.py b/agent/agent_init.py index 62de3f2c540..c51177a9b8b 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1620,6 +1620,12 @@ def init_agent( agent.session_cache_write_tokens = 0 agent.session_reasoning_tokens = 0 agent.session_estimated_cost_usd = 0.0 + # Provider-REPORTED cost only (e.g. OpenRouter usage.cost). None means + # "nothing reported" — distinct from a real $0.00. + agent.session_actual_cost_usd = None + # Per-model session usage rows for /usage: {model: {calls, input, output, + # cache_read, cache_write, cost_usd|None}}. + agent.session_model_usage = {} agent.session_cost_status = "unknown" agent.session_cost_source = "none" diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 2e4b7ed7073..d0231e994ac 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -57,7 +57,11 @@ from agent.process_bootstrap import _install_safe_stdio from agent.prompt_caching import apply_anthropic_cache_control from agent.retry_utils import jittered_backoff from agent.trajectory import has_incomplete_scratchpad -from agent.usage_pricing import estimate_usage_cost, normalize_usage +from agent.usage_pricing import ( + estimate_usage_cost, + extract_provider_cost_usd, + normalize_usage, +) from hermes_constants import PARTIAL_STREAM_STUB_ID from hermes_logging import set_session_context from tools.skill_provenance import set_current_write_origin @@ -1633,6 +1637,37 @@ def run_conversation( agent.session_cost_status = cost_result.status agent.session_cost_source = cost_result.source + # ── Real provider-REPORTED cost (never estimated) ── + # OpenRouter usage accounting returns ``usage.cost`` on the + # response when the request carries usage:{include:true} + # (added on OpenRouter routes). When the provider reports + # nothing, this stays None — absent, NOT zero — so cost + # displays hide instead of showing a fabricated $0.00. + reported_cost_usd = extract_provider_cost_usd(response.usage) + if reported_cost_usd is not None: + _prev_actual = getattr(agent, "session_actual_cost_usd", None) + agent.session_actual_cost_usd = (_prev_actual or 0.0) + reported_cost_usd + agent.session_cost_status = "actual" + agent.session_cost_source = "provider_cost_api" + + # Per-model session breakdown for /usage — counts are always + # real; cost_usd only accumulates provider-reported values + # and stays None when the provider reports nothing. + _model_usage = getattr(agent, "session_model_usage", None) + if _model_usage is None: + _model_usage = agent.session_model_usage = {} + _mrow = _model_usage.setdefault(agent.model, { + "calls": 0, "input": 0, "output": 0, + "cache_read": 0, "cache_write": 0, "cost_usd": None, + }) + _mrow["calls"] += 1 + _mrow["input"] += canonical_usage.input_tokens + _mrow["output"] += canonical_usage.output_tokens + _mrow["cache_read"] += canonical_usage.cache_read_tokens + _mrow["cache_write"] += canonical_usage.cache_write_tokens + if reported_cost_usd is not None: + _mrow["cost_usd"] = (_mrow["cost_usd"] or 0.0) + reported_cost_usd + # Persist token counts to session DB for /insights. # Do this for every platform with a session_id so non-CLI # sessions (gateway, cron, delegated runs) cannot lose @@ -1659,8 +1694,14 @@ def run_conversation( reasoning_tokens=canonical_usage.reasoning_tokens, estimated_cost_usd=float(cost_result.amount_usd) if cost_result.amount_usd is not None else None, - cost_status=cost_result.status, - cost_source=cost_result.source, + # Provider-reported per-call cost delta. NULL + # (not 0) when the provider reported nothing — + # the SQL CASE keeps actual_cost_usd untouched. + actual_cost_usd=reported_cost_usd, + cost_status="actual" + if reported_cost_usd is not None else cost_result.status, + cost_source="provider_cost_api" + if reported_cost_usd is not None else cost_result.source, billing_provider=agent.provider, billing_base_url=agent.base_url, billing_mode="subscription_included" diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 0c17e309a8b..3649f21d3eb 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -388,6 +388,13 @@ class ChatCompletionsTransport(ProviderTransport): if provider_prefs and is_openrouter: extra_body["provider"] = provider_prefs + # OpenRouter usage accounting — response `usage.cost` carries the REAL + # charged cost (credits are 1:1 USD). Parity with the profile path in + # plugins/model-providers/openrouter/__init__.py; this branch only runs + # when the OpenRouter profile isn't loaded. + if is_openrouter: + extra_body["usage"] = {"include": True} + # Pareto Code router plugin — model-gated. Same shape as the # profile path in plugins/model-providers/openrouter/__init__.py; # this branch only runs when the OpenRouter profile isn't loaded. diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index 8d6b85cd0b8..69cc74f3db9 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -849,6 +849,73 @@ def estimate_usage_cost( ) +def _finite_nonneg_number(value: Any) -> Optional[float]: + """Return ``value`` as a float when it is a real, finite, non-negative + number (int/float, not bool); otherwise None.""" + if isinstance(value, bool) or not isinstance(value, (int, float)): + return None + try: + f = float(value) + except (TypeError, ValueError): + return None + if f != f or f in (float("inf"), float("-inf")) or f < 0: + return None + return f + + +def extract_provider_cost_usd(response_usage: Any) -> Optional[float]: + """Provider-REPORTED cost (USD) from a response ``usage`` object, or None. + + Reads the ``usage.cost`` field that OpenRouter's usage accounting returns + (``usage: {"include": true}`` request param; OpenRouter credits are 1:1 + USD). OpenRouter-compatible aggregators use the same field. This NEVER + estimates: when the provider reports nothing, the result is None — callers + must treat None as "no cost data", not zero. A reported ``0`` is a real + zero (e.g. free-tier models) and is returned as ``0.0``. + """ + if response_usage is None: + return None + cost = getattr(response_usage, "cost", None) + if cost is None and isinstance(response_usage, dict): + cost = response_usage.get("cost") + return _finite_nonneg_number(cost) + + +def real_session_cost_usd(agent: Any) -> Optional[float]: + """Session-cumulative provider-REPORTED cost in USD, or None. + + Combines the two real sources Hermes has — no estimation, ever: + - ``agent.session_actual_cost_usd``: per-response ``usage.cost`` + accumulator (OpenRouter usage accounting). + - Nous ``x-nous-credits-*`` header delta via + ``agent.get_credits_spent_micros()`` (account-level spend since the + session first saw a header; clamped at 0 so a mid-session top-up + doesn't render a negative cost). + + Returns None when neither source has reported anything — callers must + hide their cost display in that case rather than showing $0.00. + """ + total: Optional[float] = None + + actual = _finite_nonneg_number(getattr(agent, "session_actual_cost_usd", None)) + if actual is not None: + total = actual + + try: + spent_micros = agent.get_credits_spent_micros() + except Exception: + spent_micros = None + if spent_micros is not None: + try: + spent_usd = max(0, int(spent_micros)) / 1_000_000 + except (TypeError, ValueError): + spent_usd = None + if spent_usd is not None: + total = (total or 0.0) + spent_usd + + return total + + def has_known_pricing( model_name: str, provider: Optional[str] = None, diff --git a/cli.py b/cli.py index 000778b750f..37861e65dc6 100644 --- a/cli.py +++ b/cli.py @@ -10638,14 +10638,13 @@ class HermesCLI: compressions = compressor.compression_count msg_count = len(self.conversation_history) - cost_result = estimate_usage_cost( + # Cost — provider-REPORTED only (OpenRouter usage.cost accumulator + # and/or Nous credits-header delta). No estimation: an unreported + # cost shows as "not reported", never a fabricated dollar figure. + from agent.usage_pricing import real_session_cost_usd, resolve_billing_route + real_cost_usd = real_session_cost_usd(agent) + _billing_route = resolve_billing_route( agent.model, - CanonicalUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - cache_read_tokens=cache_read_tokens, - cache_write_tokens=cache_write_tokens, - ), provider=getattr(agent, "provider", None), base_url=getattr(agent, "base_url", None), ) @@ -10665,21 +10664,16 @@ class HermesCLI: print(f" Total tokens: {total:>10,}") print(f" API calls: {calls:>10,}") print(f" Session duration: {elapsed:>10}") - print(f" Cost status: {cost_result.status:>10}") - print(f" Cost source: {cost_result.source:>10}") - if cost_result.amount_usd is not None: - prefix = "~" if cost_result.status == "estimated" else "" - print(f" Total cost: {prefix}${float(cost_result.amount_usd):>10.4f}") - elif cost_result.status == "included": - print(f" Total cost: {'included':>10}") + if real_cost_usd is not None: + print(f" Cost (provider-reported): ${real_cost_usd:>9.4f}") + elif _billing_route.billing_mode == "subscription_included": + print(f" Cost: {'included':>11}") else: - print(f" Total cost: {'n/a':>10}") + print(f" Cost: {'not reported by provider':>23}") print(f" {'─' * 40}") print(f" Current context: {last_prompt:,} / {ctx_len:,} ({pct:.0f}%)") print(f" Messages: {msg_count}") print(f" Compressions: {compressions}") - if cost_result.status == "unknown": - print(f" Note: Pricing unknown for {agent.model}") # Account limits -- fetched off-thread with a hard timeout so slow # provider APIs don't hang the prompt. diff --git a/gateway/run.py b/gateway/run.py index 3cef89b96fd..ecb24da1d54 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -13277,25 +13277,24 @@ class GatewayRunner(GatewayKanbanWatchersMixin): lines.append(t("gateway.usage.label_total", count=f"{agent.session_total_tokens:,}")) lines.append(t("gateway.usage.label_api_calls", count=agent.session_api_calls)) - # Cost estimation + # Cost — provider-REPORTED only (OpenRouter usage.cost accumulator + # and/or Nous credits-header delta). No estimation: when nothing + # was reported the line is omitted entirely, never shown as $0.00. + # Subscription-included routes (a billing fact, not a price guess) + # still show "included". try: - from agent.usage_pricing import CanonicalUsage, estimate_usage_cost - cost_result = estimate_usage_cost( - agent.model, - CanonicalUsage( - input_tokens=input_tokens, - output_tokens=output_tokens, - cache_read_tokens=cache_read, - cache_write_tokens=cache_write, - ), - provider=getattr(agent, "provider", None), - base_url=getattr(agent, "base_url", None), - ) - if cost_result.amount_usd is not None: - prefix = "~" if cost_result.status == "estimated" else "" - lines.append(t("gateway.usage.label_cost", prefix=prefix, amount=f"{float(cost_result.amount_usd):.4f}")) - elif cost_result.status == "included": - lines.append(t("gateway.usage.label_cost_included")) + from agent.usage_pricing import real_session_cost_usd, resolve_billing_route + real_cost = real_session_cost_usd(agent) + if real_cost is not None: + lines.append(t("gateway.usage.label_cost", prefix="", amount=f"{real_cost:.4f}")) + else: + route = resolve_billing_route( + agent.model, + provider=getattr(agent, "provider", None), + base_url=getattr(agent, "base_url", None), + ) + if route.billing_mode == "subscription_included": + lines.append(t("gateway.usage.label_cost_included")) except Exception: pass diff --git a/plugins/model-providers/openrouter/__init__.py b/plugins/model-providers/openrouter/__init__.py index 1b464b42e82..19ab1545f76 100644 --- a/plugins/model-providers/openrouter/__init__.py +++ b/plugins/model-providers/openrouter/__init__.py @@ -49,6 +49,13 @@ class OpenRouterProfile(ProviderProfile): if prefs: body["provider"] = prefs + # Usage accounting — makes OpenRouter return the REAL cost it charged + # in the response `usage.cost` field (credits are 1:1 USD), instead of + # Hermes having to estimate from a pricing table. Captured by + # agent.usage_pricing.extract_provider_cost_usd in the conversation + # loop. https://openrouter.ai/docs/use-cases/usage-accounting + body["usage"] = {"include": True} + # Pareto Code router — model-gated. The plugins block is only # meaningful for openrouter/pareto-code; sending it on any other # model has no documented effect and would be confusing in logs. diff --git a/run_agent.py b/run_agent.py index c6cc1e21581..f95a91a19a4 100644 --- a/run_agent.py +++ b/run_agent.py @@ -632,6 +632,9 @@ class AIAgent: self.session_reasoning_tokens = 0 self.session_api_calls = 0 self.session_estimated_cost_usd = 0.0 + # Provider-REPORTED cost only — None means "nothing reported". + self.session_actual_cost_usd = None + self.session_model_usage = {} self.session_cost_status = "unknown" self.session_cost_source = "none" diff --git a/tests/agent/test_provider_cost_capture.py b/tests/agent/test_provider_cost_capture.py new file mode 100644 index 00000000000..5399d81a907 --- /dev/null +++ b/tests/agent/test_provider_cost_capture.py @@ -0,0 +1,186 @@ +"""Real provider-reported cost capture — never estimated, absent ≠ zero. + +Covers the three fixture shapes from the cost-tracking fix: + - OpenRouter usage accounting: response ``usage.cost`` present → accumulates. + - Nous: ``x-nous-credits-*`` headers present → header delta accumulates. + - Provider reports nothing → cost stays None/absent (NOT zero-as-real). +""" + +from types import SimpleNamespace + +import pytest + +from agent.usage_pricing import extract_provider_cost_usd, real_session_cost_usd + + +# ── extract_provider_cost_usd — the per-response REAL cost reader ──────────── + + +class TestExtractProviderCost: + def test_openrouter_usage_cost_attr(self): + usage = SimpleNamespace(prompt_tokens=10, completion_tokens=5, cost=0.001234) + assert extract_provider_cost_usd(usage) == pytest.approx(0.001234) + + def test_dict_shaped_usage(self): + assert extract_provider_cost_usd({"cost": 0.5}) == pytest.approx(0.5) + + def test_reported_zero_is_real_zero(self): + # Free-tier models really cost $0 — distinct from "not reported". + usage = SimpleNamespace(cost=0) + assert extract_provider_cost_usd(usage) == 0.0 + + def test_absent_cost_is_none_not_zero(self): + usage = SimpleNamespace(prompt_tokens=10, completion_tokens=5) + assert extract_provider_cost_usd(usage) is None + assert extract_provider_cost_usd({"prompt_tokens": 10}) is None + + def test_none_usage_is_none(self): + assert extract_provider_cost_usd(None) is None + + def test_garbage_cost_values_are_none(self): + for bad in ("0.01", True, float("nan"), float("inf"), -0.5, [], {}): + assert extract_provider_cost_usd(SimpleNamespace(cost=bad)) is None, bad + + +# ── real_session_cost_usd — the session accumulator surface ───────────────── + + +class _FakeAgent: + def __init__(self, actual=None, credits_micros=None): + self.session_actual_cost_usd = actual + self._credits_micros = credits_micros + + def get_credits_spent_micros(self): + return self._credits_micros + + +class TestRealSessionCost: + def test_nothing_reported_is_none(self): + assert real_session_cost_usd(_FakeAgent()) is None + + def test_openrouter_accumulator_only(self): + assert real_session_cost_usd(_FakeAgent(actual=0.42)) == pytest.approx(0.42) + + def test_nous_credits_delta_only(self): + # 123_400 micros = $0.1234 + assert real_session_cost_usd( + _FakeAgent(credits_micros=123_400) + ) == pytest.approx(0.1234) + + def test_both_sources_sum(self): + assert real_session_cost_usd( + _FakeAgent(actual=0.10, credits_micros=200_000) + ) == pytest.approx(0.30) + + def test_negative_credits_delta_clamped(self): + # A mid-session top-up makes the delta negative — never show negative cost. + assert real_session_cost_usd(_FakeAgent(credits_micros=-50_000)) == 0.0 + + def test_agent_without_credits_method(self): + agent = SimpleNamespace(session_actual_cost_usd=None) + assert real_session_cost_usd(agent) is None + + def test_non_numeric_actual_ignored(self): + agent = _FakeAgent() + agent.session_actual_cost_usd = "0.42" # corrupted attr → ignore + assert real_session_cost_usd(agent) is None + + +# ── Nous header fixture → real accumulator (full _capture_credits path) ───── + + +def _nous_headers(remaining_micros: int) -> dict: + return { + "x-nous-credits-version": "1", + "x-nous-credits-remaining-micros": str(remaining_micros), + "x-nous-credits-remaining-usd": f"{remaining_micros / 1_000_000:.2f}", + "x-nous-credits-subscription-micros": str(remaining_micros), + "x-nous-credits-subscription-usd": f"{remaining_micros / 1_000_000:.2f}", + "x-nous-credits-rollover-micros": "0", + "x-nous-credits-purchased-micros": "0", + "x-nous-credits-purchased-usd": "0.00", + "x-nous-credits-denominator-kind": "none", + "x-nous-credits-paid-access": "true", + "x-nous-credits-as-of-ms": "1717000000000", + } + + +def _bare_nous_agent(): + """Minimal AIAgent shell exercising the real _capture_credits path.""" + from run_agent import AIAgent + + agent = object.__new__(AIAgent) + agent.provider = "nous" + agent._credits_state = None + agent._credits_session_start_micros = None + agent.notice_callback = None + agent.notice_clear_callback = None + agent.session_actual_cost_usd = None + return agent + + +class TestNousHeaderAccumulation: + def test_headers_accumulate_into_real_session_cost(self, monkeypatch): + monkeypatch.delenv("HERMES_DEV_CREDITS_FIXTURE", raising=False) + agent = _bare_nous_agent() + + # First response latches the session-start balance ($10.00). + agent._capture_credits(SimpleNamespace(headers=_nous_headers(10_000_000))) + assert real_session_cost_usd(agent) == 0.0 # real zero: headers seen, $0 spent + + # Second response: balance dropped by $0.25 → real reported spend. + agent._capture_credits(SimpleNamespace(headers=_nous_headers(9_750_000))) + assert real_session_cost_usd(agent) == pytest.approx(0.25) + + def test_no_headers_means_no_cost(self, monkeypatch): + monkeypatch.delenv("HERMES_DEV_CREDITS_FIXTURE", raising=False) + agent = _bare_nous_agent() + agent._capture_credits(SimpleNamespace(headers={"content-type": "application/json"})) + assert real_session_cost_usd(agent) is None + + +# ── OpenRouter request param — usage accounting must be requested ──────────── + + +class TestOpenRouterUsageParam: + def test_profile_extra_body_requests_usage_accounting(self): + import importlib.util + from pathlib import Path + + from providers import get_provider_profile + + profile = get_provider_profile("openrouter") + if profile is None: + # Force plugin discovery in minimal test envs. + plugin = Path(__file__).resolve().parents[2] / "plugins" / "model-providers" / "openrouter" / "__init__.py" + spec = importlib.util.spec_from_file_location("_or_plugin", plugin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + profile = mod.openrouter + + body = profile.build_extra_body(session_id="s-1") + assert body["usage"] == {"include": True} + + def test_legacy_transport_path_requests_usage_accounting(self): + from agent.transports.chat_completions import ChatCompletionsTransport + + transport = ChatCompletionsTransport() + kwargs = transport.build_kwargs( + model="anthropic/claude-sonnet-4.6", + messages=[{"role": "user", "content": "hi"}], + tools=None, + is_openrouter=True, + ) + assert kwargs["extra_body"]["usage"] == {"include": True} + + def test_non_openrouter_does_not_send_usage_param(self): + from agent.transports.chat_completions import ChatCompletionsTransport + + transport = ChatCompletionsTransport() + kwargs = transport.build_kwargs( + model="deepseek-chat", + messages=[{"role": "user", "content": "hi"}], + tools=None, + is_openrouter=False, + ) + assert "usage" not in (kwargs.get("extra_body") or {}) diff --git a/tests/cli/test_cli_status_bar.py b/tests/cli/test_cli_status_bar.py index 47a78c57044..5631110d1da 100644 --- a/tests/cli/test_cli_status_bar.py +++ b/tests/cli/test_cli_status_bar.py @@ -523,7 +523,7 @@ class TestCLIStatusBar: class TestCLIUsageReport: - def test_show_usage_includes_estimated_cost(self, capsys): + def test_show_usage_reports_real_provider_cost(self, capsys): cli_obj = _attach_agent( _make_cli(), prompt_tokens=10_230, @@ -535,20 +535,22 @@ class TestCLIUsageReport: compressions=1, ) cli_obj.verbose = False + # Provider-reported cost (e.g. OpenRouter usage accounting accumulator). + cli_obj.agent.session_actual_cost_usd = 0.0640 cli_obj._show_usage() output = capsys.readouterr().out assert "Model:" in output - assert "Cost status:" in output - assert "Cost source:" in output - assert "Total cost:" in output + assert "Cost (provider-reported):" in output assert "$" in output assert "0.064" in output assert "Session duration:" in output assert "Compressions:" in output - def test_show_usage_marks_unknown_pricing(self, capsys): + def test_show_usage_unreported_cost_is_not_a_dollar_figure(self, capsys): + """No estimation: when the provider reports nothing, /usage must NOT + fabricate a dollar amount — not even $0.00.""" cli_obj = _attach_agent( _make_cli(model="local/my-custom-model"), prompt_tokens=1_000, @@ -563,13 +565,15 @@ class TestCLIUsageReport: cli_obj._show_usage() output = capsys.readouterr().out - assert "Total cost:" in output - assert "n/a" in output - assert "Pricing unknown for local/my-custom-model" in output + assert "not reported by provider" in output + assert "Cost (provider-reported):" not in output + assert "$0.00" not in output - def test_zero_priced_provider_models_stay_unknown(self, capsys): + def test_show_usage_never_estimates_even_with_known_pricing(self, capsys): + """A model with a pricing-table entry must still show NO cost when the + provider reported nothing (hard requirement: real cost only).""" cli_obj = _attach_agent( - _make_cli(model="glm-5"), + _make_cli(model="anthropic/claude-sonnet-4-6"), prompt_tokens=1_000, completion_tokens=500, total_tokens=1_500, @@ -582,9 +586,8 @@ class TestCLIUsageReport: cli_obj._show_usage() output = capsys.readouterr().out - assert "Total cost:" in output - assert "n/a" in output - assert "Pricing unknown for glm-5" in output + assert "not reported by provider" in output + assert "Cost (provider-reported):" not in output class TestStatusBarWidthSource: diff --git a/tests/gateway/test_usage_command.py b/tests/gateway/test_usage_command.py index 2775e72f9d7..bc2f09743e0 100644 --- a/tests/gateway/test_usage_command.py +++ b/tests/gateway/test_usage_command.py @@ -21,11 +21,16 @@ def _make_mock_agent(**overrides): "session_output_tokens": 10_000, "session_cache_read_tokens": 5_000, "session_cache_write_tokens": 2_000, + # Real provider-reported cost: None = nothing reported (the default). + "session_actual_cost_usd": None, } defaults.update(overrides) for k, v in defaults.items(): setattr(agent, k, v) + # No Nous credits headers seen unless a test overrides this. + agent.get_credits_spent_micros = MagicMock(return_value=None) + # Rate limit state rl = MagicMock() rl.has_data = True @@ -72,13 +77,11 @@ class TestUsageCachedAgent: @pytest.mark.asyncio async def test_cached_agent_shows_detailed_usage(self): - agent = _make_mock_agent() + agent = _make_mock_agent(session_actual_cost_usd=0.1234) runner = _make_runner(SK, cached_agent=agent) event = MagicMock() - with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ - patch("agent.usage_pricing.estimate_usage_cost") as mock_cost: - mock_cost.return_value = MagicMock(amount_usd=0.1234, status="estimated") + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): result = await runner._handle_usage_command(event) assert "claude-sonnet-4.6" in result @@ -99,9 +102,7 @@ class TestUsageCachedAgent: runner = _make_runner(SK, agent=running, cached_agent=cached) event = MagicMock() - with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ - patch("agent.usage_pricing.estimate_usage_cost") as mock_cost: - mock_cost.return_value = MagicMock(amount_usd=None, status="unknown") + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): result = await runner._handle_usage_command(event) assert "80,000" in result # running agent's total @@ -117,9 +118,7 @@ class TestUsageCachedAgent: runner._running_agents[SK] = _AGENT_PENDING_SENTINEL event = MagicMock() - with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ - patch("agent.usage_pricing.estimate_usage_cost") as mock_cost: - mock_cost.return_value = MagicMock(amount_usd=None, status="unknown") + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): result = await runner._handle_usage_command(event) assert "claude-sonnet-4.6" in result @@ -153,9 +152,7 @@ class TestUsageCachedAgent: runner = _make_runner(SK, cached_agent=agent) event = MagicMock() - with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ - patch("agent.usage_pricing.estimate_usage_cost") as mock_cost: - mock_cost.return_value = MagicMock(amount_usd=None, status="unknown") + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): result = await runner._handle_usage_command(event) assert "Cache read" not in result @@ -168,9 +165,7 @@ class TestUsageCachedAgent: runner = _make_runner(SK, cached_agent=agent) event = MagicMock() - with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ - patch("agent.usage_pricing.estimate_usage_cost") as mock_cost: - mock_cost.return_value = MagicMock(amount_usd=None, status="included") + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): result = await runner._handle_usage_command(event) assert "Cost: included" in result @@ -199,9 +194,7 @@ class TestUsageAccountSection: "Session: 85% remaining (15% used)", ], ) - with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \ - patch("agent.usage_pricing.estimate_usage_cost") as mock_cost: - mock_cost.return_value = MagicMock(amount_usd=None, status="included") + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): result = await runner._handle_usage_command(event) assert "📊 **Session Token Usage**" in result @@ -256,3 +249,42 @@ class TestUsageAccountSection: assert account_call["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex" assert "📊 **Session Info**" in result assert "📈 **Account limits**" in result + + +class TestUsageRealCostOnly: + """Cost lines are provider-REPORTED only — never estimated, never $0.00.""" + + @pytest.mark.asyncio + async def test_unreported_cost_renders_no_cost_line(self): + agent = _make_mock_agent() # openrouter, nothing reported + runner = _make_runner(SK, cached_agent=agent) + event = MagicMock() + + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): + result = await runner._handle_usage_command(event) + + assert "Cost:" not in result + assert "$0.00" not in result + + @pytest.mark.asyncio + async def test_nous_credits_delta_renders_as_cost(self): + agent = _make_mock_agent(provider="nous", model="Hermes-4.1-405B") + agent.get_credits_spent_micros = MagicMock(return_value=123_400) + runner = _make_runner(SK, cached_agent=agent) + event = MagicMock() + + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): + result = await runner._handle_usage_command(event) + + assert "$0.1234" in result + + @pytest.mark.asyncio + async def test_openrouter_reported_cost_renders(self): + agent = _make_mock_agent(session_actual_cost_usd=0.9876) + runner = _make_runner(SK, cached_agent=agent) + event = MagicMock() + + with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"): + result = await runner._handle_usage_command(event) + + assert "$0.9876" in result diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 438eddddf99..e44df7fdcd9 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -110,7 +110,9 @@ class TestOpenRouterProfile: def test_extra_body_no_prefs(self): p = get_provider_profile("openrouter") body = p.build_extra_body() - assert body == {} + # Usage accounting is always requested (real provider-reported cost); + # nothing else should appear without prefs/session. + assert body == {"usage": {"include": True}} def test_pareto_min_coding_score_emitted_for_pareto_model(self): """min_coding_score → plugins block when model is openrouter/pareto-code.""" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 2098f3e20e1..d2acb20e477 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1709,23 +1709,17 @@ def _get_usage(agent) -> dict: usage["context_max"] = ctx_max usage["context_percent"] = max(0, min(100, round(ctx_used / ctx_max * 100))) usage["compressions"] = getattr(comp, "compression_count", 0) or 0 + # Cost: provider-REPORTED only (OpenRouter usage.cost accumulator and/or + # the Nous x-nous-credits-* header delta). Never estimated. When nothing + # was reported `cost_usd` is ABSENT (not $0.00) and the TUI hides its + # cost segment — that's the intended UX. try: - from agent.usage_pricing import CanonicalUsage, estimate_usage_cost + from agent.usage_pricing import real_session_cost_usd - cost = estimate_usage_cost( - usage["model"], - CanonicalUsage( - input_tokens=usage["input"], - output_tokens=usage["output"], - cache_read_tokens=usage["cache_read"], - cache_write_tokens=usage["cache_write"], - ), - provider=getattr(agent, "provider", None), - base_url=getattr(agent, "base_url", None), - ) - usage["cost_status"] = cost.status - if cost.amount_usd is not None: - usage["cost_usd"] = float(cost.amount_usd) + real_cost = real_session_cost_usd(agent) + if real_cost is not None: + usage["cost_usd"] = real_cost + usage["cost_status"] = "actual" except Exception: pass # Dev-only live credits-spent readout (L0 usage-aware-credits). Gated on