mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Revert "gateway: capture real provider-reported cost (openrouter usage accounting)"
This reverts commit 85546bb9e2.
This commit is contained in:
parent
222126db1d
commit
677680034a
13 changed files with 86 additions and 427 deletions
|
|
@ -1598,12 +1598,6 @@ 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"
|
||||
|
||||
|
|
|
|||
|
|
@ -57,11 +57,7 @@ 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,
|
||||
extract_provider_cost_usd,
|
||||
normalize_usage,
|
||||
)
|
||||
from agent.usage_pricing import estimate_usage_cost, 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
|
||||
|
|
@ -1809,37 +1805,6 @@ 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
|
||||
|
|
@ -1866,14 +1831,8 @@ 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,
|
||||
# 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,
|
||||
cost_status=cost_result.status,
|
||||
cost_source=cost_result.source,
|
||||
billing_provider=agent.provider,
|
||||
billing_base_url=agent.base_url,
|
||||
billing_mode="subscription_included"
|
||||
|
|
|
|||
|
|
@ -388,13 +388,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -852,73 +852,6 @@ 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,
|
||||
|
|
|
|||
28
cli.py
28
cli.py
|
|
@ -8301,13 +8301,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
compressions = compressor.compression_count
|
||||
|
||||
msg_count = len(self.conversation_history)
|
||||
# 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(
|
||||
cost_result = estimate_usage_cost(
|
||||
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),
|
||||
)
|
||||
|
|
@ -8327,16 +8328,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
print(f" Total tokens: {total:>10,}")
|
||||
print(f" API calls: {calls:>10,}")
|
||||
print(f" Session duration: {elapsed:>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}")
|
||||
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}")
|
||||
else:
|
||||
print(f" Cost: {'not reported by provider':>23}")
|
||||
print(f" Total cost: {'n/a':>10}")
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -3215,24 +3215,25 @@ class GatewaySlashCommandsMixin:
|
|||
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 — 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".
|
||||
# Cost estimation
|
||||
try:
|
||||
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"))
|
||||
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"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -82,13 +82,6 @@ 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.
|
||||
|
|
|
|||
|
|
@ -636,9 +636,6 @@ 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"
|
||||
|
||||
|
|
|
|||
|
|
@ -1,186 +0,0 @@
|
|||
"""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 {})
|
||||
|
|
@ -457,7 +457,7 @@ class TestCLIStatusBar:
|
|||
|
||||
|
||||
class TestCLIUsageReport:
|
||||
def test_show_usage_reports_real_provider_cost(self, capsys):
|
||||
def test_show_usage_includes_estimated_cost(self, capsys):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(),
|
||||
prompt_tokens=10_230,
|
||||
|
|
@ -469,22 +469,20 @@ 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 (provider-reported):" in output
|
||||
assert "Cost status:" in output
|
||||
assert "Cost source:" in output
|
||||
assert "Total cost:" in output
|
||||
assert "$" in output
|
||||
assert "0.064" in output
|
||||
assert "Session duration:" in output
|
||||
assert "Compressions:" in output
|
||||
|
||||
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."""
|
||||
def test_show_usage_marks_unknown_pricing(self, capsys):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(model="local/my-custom-model"),
|
||||
prompt_tokens=1_000,
|
||||
|
|
@ -499,15 +497,13 @@ class TestCLIUsageReport:
|
|||
cli_obj._show_usage()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "not reported by provider" in output
|
||||
assert "Cost (provider-reported):" not in output
|
||||
assert "$0.00" not in output
|
||||
assert "Total cost:" in output
|
||||
assert "n/a" in output
|
||||
assert "Pricing unknown for local/my-custom-model" in output
|
||||
|
||||
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)."""
|
||||
def test_zero_priced_provider_models_stay_unknown(self, capsys):
|
||||
cli_obj = _attach_agent(
|
||||
_make_cli(model="anthropic/claude-sonnet-4-6"),
|
||||
_make_cli(model="glm-5"),
|
||||
prompt_tokens=1_000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1_500,
|
||||
|
|
@ -520,8 +516,9 @@ class TestCLIUsageReport:
|
|||
cli_obj._show_usage()
|
||||
output = capsys.readouterr().out
|
||||
|
||||
assert "not reported by provider" in output
|
||||
assert "Cost (provider-reported):" not in output
|
||||
assert "Total cost:" in output
|
||||
assert "n/a" in output
|
||||
assert "Pricing unknown for glm-5" in output
|
||||
|
||||
|
||||
class TestStatusBarWidthSource:
|
||||
|
|
|
|||
|
|
@ -21,16 +21,11 @@ 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
|
||||
|
|
@ -77,11 +72,13 @@ class TestUsageCachedAgent:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cached_agent_shows_detailed_usage(self):
|
||||
agent = _make_mock_agent(session_actual_cost_usd=0.1234)
|
||||
agent = _make_mock_agent()
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"):
|
||||
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")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "claude-sonnet-4.6" in result
|
||||
|
|
@ -102,7 +99,9 @@ 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"):
|
||||
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")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "80,000" in result # running agent's total
|
||||
|
|
@ -118,7 +117,9 @@ 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"):
|
||||
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")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "claude-sonnet-4.6" in result
|
||||
|
|
@ -152,7 +153,9 @@ 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"):
|
||||
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")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Cache read" not in result
|
||||
|
|
@ -165,7 +168,9 @@ 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"):
|
||||
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")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "Cost: included" in result
|
||||
|
|
@ -194,7 +199,9 @@ class TestUsageAccountSection:
|
|||
"Session: 85% remaining (15% used)",
|
||||
],
|
||||
)
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"):
|
||||
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")
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
assert "📊 **Session Token Usage**" in result
|
||||
|
|
@ -249,42 +256,3 @@ 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
|
||||
|
|
|
|||
|
|
@ -110,9 +110,7 @@ class TestOpenRouterProfile:
|
|||
def test_extra_body_no_prefs(self):
|
||||
p = get_provider_profile("openrouter")
|
||||
body = p.build_extra_body()
|
||||
# Usage accounting is always requested (real provider-reported cost);
|
||||
# nothing else should appear without prefs/session.
|
||||
assert body == {"usage": {"include": True}}
|
||||
assert body == {}
|
||||
|
||||
def test_pareto_min_coding_score_emitted_for_pareto_model(self):
|
||||
"""min_coding_score → plugins block when model is openrouter/pareto-code."""
|
||||
|
|
|
|||
|
|
@ -2296,17 +2296,23 @@ 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 real_session_cost_usd
|
||||
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
|
||||
|
||||
real_cost = real_session_cost_usd(agent)
|
||||
if real_cost is not None:
|
||||
usage["cost_usd"] = real_cost
|
||||
usage["cost_status"] = "actual"
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
# Dev-only live credits-spent readout (L0 usage-aware-credits). Gated on
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue