210 lines
9.5 KiB
Python
210 lines
9.5 KiB
Python
"""Rate-limit / credits header capture and low-credit notices for ``AIAgent``.
|
|
|
|
Parses provider response headers into ``_rate_limit_state`` / ``_credits_state`` and emits sticky notices.
|
|
Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged.
|
|
"""
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
from utils import is_truthy_value
|
|
|
|
# Same logger name as the origin module so log records / caplog filters are unchanged.
|
|
logger = logging.getLogger("run_agent")
|
|
|
|
|
|
class RateLimitCreditsMixin:
|
|
"""Rate-limit + credits header capture and notices (see module docstring)."""
|
|
|
|
def _capture_rate_limits(self, http_response: Any) -> None:
|
|
"""Parse x-ratelimit-* headers from an HTTP response and cache the state.
|
|
|
|
Called after each streaming call; the httpx Response is available as ``stream.response``.
|
|
"""
|
|
if http_response is None:
|
|
return
|
|
headers = getattr(http_response, "headers", None)
|
|
if not headers:
|
|
return
|
|
try:
|
|
from agent.rate_limit_tracker import parse_rate_limit_headers
|
|
state = parse_rate_limit_headers(headers, provider=self.provider)
|
|
if state is not None:
|
|
self._rate_limit_state = state
|
|
except Exception:
|
|
pass # Never let header parsing break the agent loop
|
|
|
|
def get_rate_limit_state(self):
|
|
"""Return the last captured RateLimitState, or None."""
|
|
return self._rate_limit_state
|
|
|
|
def _capture_anthropic_response_headers(self, http_response: Any) -> None:
|
|
"""Capture out-of-band state from Anthropic Messages response headers.
|
|
|
|
The SDK's aggregated ``Message`` drops headers, where Portal puts rate-limit and credits state. Fail-
|
|
open.
|
|
"""
|
|
self._capture_rate_limits(http_response)
|
|
self._capture_credits(http_response)
|
|
|
|
def _capture_credits(self, http_response: Any) -> None:
|
|
"""Parse x-nous-credits-* headers, cache CreditsState, fire threshold notices.
|
|
|
|
The PARSE is swallowed (miss → keep last-known); the notice EVALUATION is a separate block that WARNS
|
|
on failure so a depletion-notice bug cannot vanish silently.
|
|
"""
|
|
# Dev test fixture (HERMES_DEV_CREDITS_FIXTURE): inject a chosen notice state
|
|
# each turn for repeatable testing, bypassing real headers. Throwaway scaffolding.
|
|
try:
|
|
from agent.credits_tracker import dev_fixture_credits_state
|
|
_fixture = dev_fixture_credits_state()
|
|
except Exception:
|
|
_fixture = None
|
|
if _fixture is not None:
|
|
self._credits_state = _fixture
|
|
if self._credits_session_start_micros is None:
|
|
self._credits_session_start_micros = _fixture.remaining_micros
|
|
_latch = getattr(self, "_credits_latch", None)
|
|
if isinstance(_latch, dict):
|
|
# Only seen_below_90 — priming seen_grant_unspent would fire grant_spent on first observation.
|
|
_latch["seen_below_90"] = True # let warn90 fire without a real crossing
|
|
_used = _fixture.used_fraction
|
|
logger.info(
|
|
"credits ▸ [FIXTURE] remaining=%d (%s) · paid=%s · denom=%s · used=%s "
|
|
"(real headers bypassed — `echo clear` / unset HERMES_DEV_CREDITS_FIXTURE to restore)",
|
|
_fixture.remaining_micros,
|
|
_fixture.remaining_usd or "?",
|
|
_fixture.paid_access,
|
|
_fixture.denominator_kind,
|
|
("%.0f%%" % (_used * 100)) if _used is not None else "n/a",
|
|
)
|
|
self._emit_credits_notices()
|
|
return
|
|
if http_response is None:
|
|
return
|
|
headers = getattr(http_response, "headers", None)
|
|
if not headers:
|
|
return
|
|
_dev = is_truthy_value(os.environ.get("HERMES_DEV_CREDITS"))
|
|
|
|
# ── Parse (fail-open → miss; never overwrite good state with None) ──
|
|
try:
|
|
from agent.credits_tracker import parse_credits_headers
|
|
state = parse_credits_headers(headers, provider=self.provider)
|
|
except Exception:
|
|
return # parse error → treat as a miss, keep last-known
|
|
if state is None:
|
|
if _dev:
|
|
logger.info(
|
|
"credits ▸ response had no valid x-nous-credits-* headers "
|
|
"(miss — producer off / non-Nous path / >TTL stale)"
|
|
)
|
|
return
|
|
|
|
# retain-last-known: only overwrite on a fresh valid parse
|
|
self._credits_state = state
|
|
# Latch session-start remaining the first time we ever see a header
|
|
if self._credits_session_start_micros is None:
|
|
self._credits_session_start_micros = state.remaining_micros
|
|
if _dev:
|
|
# HERMES_DEV_CREDITS: stream each capture to agent.log — watch live with
|
|
# `hermes logs -f` (grep 'credits ▸'). Dev-only; silent for normal users.
|
|
spent = self.get_credits_spent_micros()
|
|
used = state.used_fraction
|
|
logger.info(
|
|
"credits ▸ remaining=%d (%s) · paid=%s · denom=%s · used=%s "
|
|
"· Δspent=%s · age=%s%s",
|
|
state.remaining_micros,
|
|
state.remaining_usd or "?",
|
|
state.paid_access,
|
|
state.denominator_kind,
|
|
("%.0f%%" % (used * 100)) if used is not None else "n/a",
|
|
("%.1f¢" % (spent / 10000)) if spent is not None else "n/a",
|
|
("%.0fs" % state.age_seconds) if state.age_seconds != float("inf") else "n/a",
|
|
(" · disabled=%s" % state.disabled_reason) if state.disabled_reason else "",
|
|
)
|
|
|
|
# Threshold notices — shared with the cold-start seed (see _emit_credits_notices).
|
|
self._emit_credits_notices()
|
|
|
|
def _emit_credits_notices(self) -> None:
|
|
"""Run the threshold policy on the current credits state and emit notices.
|
|
|
|
Shared by the warm path and the cold-start seed so an already-depleted session warns immediately. Runs
|
|
only when a notice consumer is bound. WARNS on failure. Emits clears FIRST so depleted lands last
|
|
(latest- wins slot).
|
|
"""
|
|
if getattr(self, "notice_callback", None) is None and getattr(self, "notice_clear_callback", None) is None:
|
|
return
|
|
if not self._credits_notices_enabled():
|
|
return
|
|
state = getattr(self, "_credits_state", None)
|
|
if state is None:
|
|
return
|
|
try:
|
|
from agent.credits_tracker import evaluate_credits_notices, is_free_tier_model, new_credits_latch
|
|
latch = getattr(self, "_credits_latch", None)
|
|
if latch is None:
|
|
latch = self._credits_latch = new_credits_latch()
|
|
# Free-model gate: a depleted account can still inference on a free model. Local data only.
|
|
model_is_free = is_free_tier_model(
|
|
getattr(self, "model", "") or "",
|
|
getattr(self, "base_url", "") or "",
|
|
)
|
|
to_show, to_clear = evaluate_credits_notices(state, latch, model_is_free=model_is_free)
|
|
for key in to_clear: # clears FIRST …
|
|
self._emit_notice_clear(key)
|
|
for notice in to_show: # … then shows (depleted lands last in a latest-wins slot)
|
|
self._emit_notice(notice)
|
|
except Exception:
|
|
logger.warning("credits notice evaluation/emit failed", exc_info=True)
|
|
|
|
def _credits_notices_enabled(self) -> bool:
|
|
"""Whether credits notices are enabled (``display.credits_notices``).
|
|
|
|
Read once per agent and cached (governs UI noise, not correctness); fail-open True.
|
|
"""
|
|
cached = getattr(self, "_credits_notices_enabled_cache", None)
|
|
if cached is not None:
|
|
return cached
|
|
enabled = True
|
|
try:
|
|
from hermes_cli.config import load_config as _load_config
|
|
_cfg = _load_config() or {}
|
|
_display = _cfg.get("display") if isinstance(_cfg, dict) else None
|
|
if isinstance(_display, dict) and "credits_notices" in _display:
|
|
enabled = bool(_display.get("credits_notices"))
|
|
except Exception:
|
|
enabled = True
|
|
self._credits_notices_enabled_cache = enabled
|
|
return enabled
|
|
|
|
def get_credits_state(self):
|
|
"""Return the last captured CreditsState, or None."""
|
|
return self._credits_state
|
|
|
|
def get_credits_spent_micros(self):
|
|
"""Session-cumulative micros spent = first_seen_remaining - current_remaining. None if no data."""
|
|
if self._credits_session_start_micros is None or self._credits_state is None:
|
|
return None
|
|
return self._credits_session_start_micros - self._credits_state.remaining_micros
|
|
|
|
def _check_openrouter_cache_status(self, http_response: Any) -> None:
|
|
"""Read X-OpenRouter-Cache-Status from response headers and log it; HITs count in ``_or_cache_hits``."""
|
|
if http_response is None:
|
|
return
|
|
headers = getattr(http_response, "headers", None)
|
|
if not headers:
|
|
return
|
|
try:
|
|
status = headers.get("x-openrouter-cache-status")
|
|
if not status:
|
|
return
|
|
if status.upper() == "HIT":
|
|
self._or_cache_hits += 1
|
|
logger.info("OpenRouter response cache HIT (total: %d)", self._or_cache_hits)
|
|
else:
|
|
logger.debug("OpenRouter response cache %s", status.upper())
|
|
except Exception:
|
|
pass # Never let header parsing break the agent loop
|