0ef5fcb1c5
Security / Dependency audit (pip-audit) (push) Has been cancelled
Security / CodeQL (javascript-typescript) (push) Has been cancelled
Security / CodeQL (python) (push) Has been cancelled
Security / Secret scan (gitleaks) (push) Has been cancelled
rust / test (ubuntu) (push) Has been cancelled
rust / simulator e2e (macos-latest) (push) Has been cancelled
rust / simulator e2e (ubuntu-latest) (push) Has been cancelled
rust / simulator e2e (windows-latest) (push) Has been cancelled
rust / wheels (aarch64-apple-darwin) (push) Has been cancelled
rust / wheels (x86_64-unknown-linux-gnu) (push) Has been cancelled
rust / wheels (x86_64-apple-darwin) (push) Has been cancelled
rust / audit (push) Has been cancelled
rust / parity (nightly, allowed to fail during Phase 0) (push) Has been cancelled
CI / commitlint (push) Has been skipped
Dev Containers / validate (.devcontainer/devcontainer.json, default) (push) Failing after 0s
Dev Containers / validate (.devcontainer/memory-stack/devcontainer.json, memory-stack) (push) Failing after 0s
Dev Containers / validate-worktree (push) Failing after 0s
CI / changes (push) Failing after 4s
Deploy Documentation / validate (push) Has been skipped
Deploy Documentation / deploy (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, claude) (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, codex) (push) Failing after 1s
Install Native E2E / install-native (ubuntu-latest) (push) Failing after 1s
OpenCode Plugin / typecheck + build + test (push) Failing after 1s
Init Native E2E / init-native (ubuntu-latest, copilot) (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
Wrap E2E / docker-wrap-e2e (push) Failing after 1s
Wrap Native E2E / wrap-native (ubuntu-latest) (push) Failing after 1s
Init E2E / docker-init-e2e (push) Failing after 4s
Merge Conflicts / merge-conflicts (push) Failing after 4s
CI / lint (push) Has been cancelled
CI / build-wheel (push) Has been cancelled
CI / build-wheel-windows (push) Has been cancelled
CI / prefetch-model (push) Has been cancelled
CI / test-dashboard-ui (push) Has been cancelled
CI / test (1) (push) Has been cancelled
CI / test (2) (push) Has been cancelled
CI / test (3) (push) Has been cancelled
CI / test (4) (push) Has been cancelled
CI / test-extras (push) Has been cancelled
CI / test-agno (push) Has been cancelled
CI / build (push) Has been cancelled
CI / workflow-validation (push) Has been cancelled
CI / docker-native-e2e (push) Has been cancelled
CI / windows-native-wrapper (push) Has been cancelled
CI / macos-native-wrapper (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-manifest (map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-nonroot name:code-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim name:code-slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-code-slim-nonroot name:code-slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-nonroot name:nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim name:slim]) (push) Has been cancelled
Docker / docker-build (map[name:amd64 platform:linux/amd64 runs_on:ubuntu-24.04], map[bake_target:runtime-slim-nonroot name:slim-nonroot]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime name:]) (push) Has been cancelled
Docker / docker-build (map[name:arm64 platform:linux/arm64 runs_on:ubuntu-24.04-arm], map[bake_target:runtime-code name:code]) (push) Has been cancelled
Docker / promote-latest (push) Has been cancelled
Init Native E2E / init-native (macos-latest, claude) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, codex) (push) Has been cancelled
Init Native E2E / init-native (macos-latest, copilot) (push) Has been cancelled
Install Native E2E / install-native (macos-latest) (push) Has been cancelled
Wrap Native E2E / wrap-native (macos-latest) (push) Has been cancelled
996 lines
41 KiB
Python
996 lines
41 KiB
Python
"""Background subscription window tracker for Anthropic OAuth accounts.
|
||
|
||
Polls GET https://api.anthropic.com/api/oauth/usage on a configurable interval
|
||
while there has been at least one active OAuth session within the last minute.
|
||
Falls back to a stored token from ~/.claude/.credentials.json when no live
|
||
request has come through the proxy recently.
|
||
|
||
Architecture:
|
||
- Single asyncio.Task polling loop (started in start(), stopped via asyncio.Event)
|
||
- Thread-safe state updates via threading.Lock (consistent with headroom patterns)
|
||
- Atomic JSON persistence via tempfile + os.replace()
|
||
- Module-level singleton via get_subscription_tracker() / configure_subscription_tracker()
|
||
|
||
Also reads Claude transcript JSONL files (via session_tracking module) to provide
|
||
token breakdowns per window that enable:
|
||
- Headroom efficiency metrics (tokens saved = raw - what proxy sent)
|
||
- Surge pricing detection (API utilization vs expected from weighted tokens)
|
||
- Cache miss detection (low cache_reads despite high input tokens)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import tempfile
|
||
import threading
|
||
import time
|
||
from datetime import timedelta
|
||
from pathlib import Path
|
||
from typing import Any, cast
|
||
|
||
from headroom import paths as _paths
|
||
from headroom.subscription.base import QuotaTracker
|
||
from headroom.subscription.client import SubscriptionClient
|
||
from headroom.subscription.models import (
|
||
HeadroomContribution,
|
||
RateLimitWindow,
|
||
SubscriptionSnapshot,
|
||
SubscriptionState,
|
||
WindowDiscrepancy,
|
||
WindowTokens,
|
||
_utc_now,
|
||
synthesize_window_render,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_DEFAULT_POLL_INTERVAL_S = 300
|
||
_DEFAULT_ACTIVE_WINDOW_S = 60
|
||
_PERSIST_FILE_ENV = _paths.HEADROOM_SUBSCRIPTION_STATE_PATH_ENV
|
||
_DEFAULT_PERSIST_DIR = ".headroom"
|
||
_DEFAULT_PERSIST_FILE = "subscription_state.json"
|
||
|
||
# PR-G2 (Realignment) — RTK savings wiring.
|
||
#
|
||
# Operators can disable the RTK polling from inside ``update_contribution``
|
||
# without uninstalling the binary or unsetting ``HEADROOM_CONTEXT_TOOL``.
|
||
# Used for diagnostics and for environments where RTK is intentionally
|
||
# excluded from headroom accounting (e.g. shadow tests).
|
||
#
|
||
# Loud / configurable / no silent fallback: unknown values raise loudly via
|
||
# the parser below — they do not silently default to ``enabled``.
|
||
_RTK_WIRING_ENV = "HEADROOM_RTK_WIRING"
|
||
_RTK_WIRING_DEFAULT = "enabled"
|
||
_RTK_WIRING_ALLOWED = ("enabled", "disabled")
|
||
|
||
# PR-G2 remediation (C3) — multi-worker poll ownership.
|
||
#
|
||
# Each uvicorn worker independently runs ``configure_subscription_tracker``,
|
||
# so each worker would poll RTK and add the same delta to its own
|
||
# ``c.tokens_saved_rtk``. The persisted state is shared by atomic
|
||
# os.replace, but the in-memory counters diverge per worker — and any
|
||
# dashboard hitting a non-owner worker would see drifted values.
|
||
#
|
||
# The owner-election strategy mirrors the beacon's file-lock pattern in
|
||
# ``headroom/proxy/server.py``: a non-blocking ``fcntl.flock`` on
|
||
# ``HEADROOM_RTK_POLL_LOCK`` (default under the workspace dir). Only the
|
||
# lock holder polls; non-owners return 0 from ``_poll_rtk_delta`` and
|
||
# delegate to whatever the owner writes to the shared state file.
|
||
#
|
||
# Loud / no silent fallback: when ``fcntl`` is unavailable (Windows), every
|
||
# worker polls — but the explicit ``WindowsNoLockMode`` log line surfaces
|
||
# the choice so operators see it in startup logs.
|
||
_RTK_POLL_LOCK_ENV = "HEADROOM_RTK_POLL_LOCK"
|
||
|
||
|
||
def _rtk_wiring_mode() -> str:
|
||
"""Return ``enabled`` or ``disabled``. Raises on unknown values.
|
||
|
||
Read at call-time so operators can flip the env var without a restart.
|
||
"""
|
||
raw = os.environ.get(_RTK_WIRING_ENV, "").strip().lower()
|
||
if not raw:
|
||
return _RTK_WIRING_DEFAULT
|
||
if raw in _RTK_WIRING_ALLOWED:
|
||
return raw
|
||
raise ValueError(f"Invalid {_RTK_WIRING_ENV}={raw!r}; expected one of {_RTK_WIRING_ALLOWED}")
|
||
|
||
|
||
def _validate_rtk_env_at_startup() -> None:
|
||
"""Validate RTK env vars eagerly at proxy startup.
|
||
|
||
Raises ``ValueError`` loudly if ``HEADROOM_RTK_WIRING`` is set to an
|
||
invalid value. PR-G2 remediation (H1): previously a typo at startup
|
||
would silently default to enabled but get swallowed at every
|
||
``update_contribution`` call — fail loudly here instead.
|
||
"""
|
||
_rtk_wiring_mode()
|
||
|
||
|
||
# Singleton on-demand poll floor (seconds): the dashboard may request a fresh
|
||
# poll if the cached snapshot is stale, but we cap how often we will actually
|
||
# hit Anthropic to avoid 429s / OAuth-token flagging. Bounded across users.
|
||
_DEFAULT_ON_DEMAND_POLL_FLOOR_S = 60.0
|
||
|
||
# Hard timeout for the on-demand poll so a slow upstream never blocks the
|
||
# dashboard request handler.
|
||
_ON_DEMAND_POLL_TIMEOUT_S = 2.0
|
||
|
||
# Rolling-window lengths. Single-value constants because Anthropic's API
|
||
# defines exactly one 5-hour and one 7-day window.
|
||
_FIVE_HOUR_WINDOW = timedelta(hours=5)
|
||
_SEVEN_DAY_WINDOW = timedelta(days=7)
|
||
|
||
# A genuine 5-hour-window rollover advances ``five_hour.resets_at`` by ~5 hours.
|
||
# The usage API, however, reports ``resets_at`` with second-level jitter — it has
|
||
# been observed flapping between e.g. ``01:59:59Z`` and ``02:00:00Z`` on
|
||
# consecutive polls within the *same* window. A bare ``!=`` comparison therefore
|
||
# misfires on essentially every poll, zeroing the contribution counters every
|
||
# poll interval instead of once per window. Only treat a *forward* jump larger
|
||
# than this threshold as a real rollover (jitter is sub-second; a rollover is hours).
|
||
_ROLLOVER_MIN_ADVANCE = timedelta(minutes=1)
|
||
|
||
# Surge pricing threshold: if actual utilization is >N% higher than expected,
|
||
# flag it as a potential surge pricing event.
|
||
_SURGE_THRESHOLD_PCT = 15.0
|
||
|
||
# Cache miss threshold: if cache_reads < N% of total input when we expect
|
||
# heavy caching (>50k input tokens in window), flag it.
|
||
_CACHE_MISS_RATIO_THRESHOLD = 0.10
|
||
|
||
|
||
def _get_persist_path() -> Path:
|
||
return _paths.subscription_state_path()
|
||
|
||
|
||
class SubscriptionTracker(QuotaTracker):
|
||
"""Background tracker for Anthropic Claude Code subscription windows.
|
||
|
||
Implements :class:`~headroom.subscription.base.QuotaTracker` so it can
|
||
be registered with :func:`~headroom.subscription.base.get_quota_registry`
|
||
alongside the Codex and Copilot trackers.
|
||
|
||
Args:
|
||
poll_interval_s: Seconds between polls while active (1–3600, default 300).
|
||
active_window_s: Seconds since last notify_active call that keeps
|
||
polling alive (default 60 = 1 minute).
|
||
enabled: Set to ``False`` to disable tracking (mirrors
|
||
``ProxyConfig.subscription_tracking_enabled``).
|
||
persist_path: Where to persist state across restarts.
|
||
client: Injected client (for testing); defaults to SubscriptionClient().
|
||
"""
|
||
|
||
# QuotaTracker identity
|
||
key = "subscription_window"
|
||
label = "Anthropic Claude Code"
|
||
|
||
def __init__(
|
||
self,
|
||
poll_interval_s: int = _DEFAULT_POLL_INTERVAL_S,
|
||
active_window_s: float = _DEFAULT_ACTIVE_WINDOW_S,
|
||
enabled: bool = True,
|
||
persist_path: Path | None = None,
|
||
client: SubscriptionClient | None = None,
|
||
) -> None:
|
||
self._enabled = enabled
|
||
self._poll_interval_s = max(1, min(poll_interval_s, 3600))
|
||
self._active_window_s = max(5.0, active_window_s)
|
||
self._persist_path = persist_path or _get_persist_path()
|
||
self._client = client or SubscriptionClient()
|
||
|
||
self._lock = threading.Lock()
|
||
self._state = SubscriptionState()
|
||
self._current_token: str | None = None
|
||
self._full_tokens: dict[str, int] = {} # token_prefix -> count of requests
|
||
|
||
# PR-G2 (Realignment) — most recent session-incremental ``tokens_saved``
|
||
# observed in ``_get_rtk_stats()['session']['tokens_saved']``. This
|
||
# field is de-baselined by the helper (see
|
||
# :func:`~headroom.proxy.helpers._get_context_tool_stats`) so it
|
||
# already represents savings accumulated since the proxy session
|
||
# baseline was pinned at startup.
|
||
#
|
||
# PR-G2 remediation (C1): previously we read
|
||
# ``lifetime_tokens_saved`` (the raw monotonic counter from
|
||
# ``rtk gain --project``), which on the very first poll emitted the
|
||
# entire pre-Headroom RTK history as one fake delta. Switching to
|
||
# the session-incremental field dissolves both the first-poll
|
||
# phantom and the post-restart phantom (the helper rebaselines
|
||
# session counters at every proxy startup, so a fresh process sees
|
||
# ``session.tokens_saved == 0`` until new RTK invocations land).
|
||
#
|
||
# Monotonic non-decreasing within a proxy session: only advances on
|
||
# positive delta and on explicit counter-reset detection (session
|
||
# value drops below the last seen value).
|
||
self._last_rtk_tokens_saved: int = 0
|
||
|
||
# PR-G2 remediation (C3) — owner-election state for multi-worker
|
||
# poll deduplication. None means we haven't tried to elect yet; True
|
||
# means this worker holds the lock and polls; False means another
|
||
# worker owns the lock and we skip polling.
|
||
self._rtk_poll_owner: bool | None = None
|
||
self._rtk_poll_lock_fd: Any = None
|
||
|
||
self._stop_event: asyncio.Event | None = None
|
||
self._poll_task: asyncio.Task[None] | None = None
|
||
|
||
# Unix-ts of the last on-demand poll triggered from the dashboard.
|
||
# Floor-gated by ``_DEFAULT_ON_DEMAND_POLL_FLOOR_S``.
|
||
self._last_on_demand_poll: float = 0.0
|
||
self._on_demand_poll_floor_s: float = _DEFAULT_ON_DEMAND_POLL_FLOOR_S
|
||
|
||
self._load_persisted_state()
|
||
|
||
# ------------------------------------------------------------------
|
||
# QuotaTracker interface
|
||
# ------------------------------------------------------------------
|
||
|
||
def is_available(self) -> bool:
|
||
"""Returns ``True`` when subscription tracking is enabled in config."""
|
||
return self._enabled
|
||
|
||
def get_stats(self) -> dict[str, Any] | None:
|
||
"""Return current tracker state dict for ``/stats``."""
|
||
return self.state
|
||
|
||
# ------------------------------------------------------------------
|
||
# Lifecycle
|
||
# ------------------------------------------------------------------
|
||
|
||
async def start(self) -> None:
|
||
"""Start the background polling loop."""
|
||
if self._poll_task and not self._poll_task.done():
|
||
return
|
||
self._stop_event = asyncio.Event()
|
||
self._poll_task = asyncio.create_task(self._poll_loop(), name="subscription-tracker")
|
||
logger.info("Subscription tracker started (poll_interval=%ds)", self._poll_interval_s)
|
||
|
||
async def stop(self) -> None:
|
||
"""Stop the background polling loop and persist current state."""
|
||
if self._stop_event:
|
||
self._stop_event.set()
|
||
if self._poll_task:
|
||
try:
|
||
await asyncio.wait_for(self._poll_task, timeout=5.0)
|
||
except (asyncio.TimeoutError, asyncio.CancelledError):
|
||
self._poll_task.cancel()
|
||
self._persist_state()
|
||
# PR-G2 remediation (C3): release the poll lock so a subsequent
|
||
# process / worker restart can re-elect the owner.
|
||
self._release_rtk_poll_lock()
|
||
logger.info("Subscription tracker stopped")
|
||
|
||
# ------------------------------------------------------------------
|
||
# Proxy integration hooks
|
||
# ------------------------------------------------------------------
|
||
|
||
def notify_active(self, token: str) -> None:
|
||
"""Called by the proxy handler when an OAuth request comes through.
|
||
|
||
Stores the token for polling and marks the tracker as recently active.
|
||
Only processes Bearer tokens that look like OAuth (not API keys).
|
||
"""
|
||
if not token or not token.startswith("Bearer "):
|
||
return
|
||
raw = token[len("Bearer ") :]
|
||
# Skip raw API keys (not OAuth tokens)
|
||
if raw.startswith("sk-ant-api"):
|
||
return
|
||
with self._lock:
|
||
self._current_token = raw
|
||
self._state.last_active_at = _utc_now()
|
||
prefix = raw[:8]
|
||
self._full_tokens[prefix] = self._full_tokens.get(prefix, 0) + 1
|
||
|
||
def update_contribution(
|
||
self,
|
||
*,
|
||
tokens_submitted: int = 0,
|
||
tokens_saved_compression: int = 0,
|
||
tokens_saved_cli_filtering: int | None = None,
|
||
tokens_saved_rtk: int | None = None,
|
||
tokens_saved_cache_reads: int = 0,
|
||
compression_savings_usd: float = 0.0,
|
||
cache_savings_usd: float = 0.0,
|
||
) -> None:
|
||
"""Update headroom contribution counters for the current session window.
|
||
|
||
Called after each proxy request completes with the actual token deltas.
|
||
|
||
PR-G2 (Realignment) — ``tokens_saved_rtk`` is now sourced from RTK's
|
||
own stats endpoint (``rtk gain --format json`` via
|
||
:func:`headroom.proxy.helpers._get_rtk_stats`) when the caller does
|
||
not pass an explicit value. The tracker computes the delta against
|
||
the last per-session ``tokens_saved`` it observed and feeds only the
|
||
delta into the contribution counter.
|
||
|
||
PR-G2 remediation (C1): the RTK source is the SESSION-incremental
|
||
``session.tokens_saved`` field of the helper payload, NOT the raw
|
||
``lifetime_tokens_saved`` counter. The helper de-baselines per
|
||
proxy session, so the first poll after process startup correctly
|
||
reads 0 instead of the entire pre-Headroom RTK history.
|
||
|
||
Args:
|
||
tokens_saved_cli_filtering: Explicit per-call CLI-filtering
|
||
contribution. PR-G2 remediation (M5): ``None`` means "caller
|
||
omitted, default 0" (mirrors the ``tokens_saved_rtk is
|
||
None`` semantic). An explicit ``0`` is honored verbatim.
|
||
tokens_saved_rtk: Explicit override for tokens saved by RTK on
|
||
this call. If ``None`` (the default), the tracker polls RTK
|
||
stats itself and writes the per-call delta. If passed
|
||
(including ``0``), the override is used verbatim.
|
||
"""
|
||
# Polled outside the lock so the subprocess call can't deadlock the
|
||
# event loop or contend with concurrent ``notify_active`` callers.
|
||
if tokens_saved_rtk is None:
|
||
tokens_saved_rtk = self._poll_rtk_delta()
|
||
|
||
with self._lock:
|
||
c = self._state.contribution
|
||
# PR-G2 remediation (M5): explicit None-guard so callers can
|
||
# pass ``0`` and have it honored without colliding with the
|
||
# default "caller omitted" semantic.
|
||
if tokens_saved_cli_filtering is None:
|
||
cli_filtering = 0
|
||
else:
|
||
cli_filtering = tokens_saved_cli_filtering
|
||
c.tokens_submitted += max(tokens_submitted, 0)
|
||
c.tokens_saved_compression += max(tokens_saved_compression, 0)
|
||
c.tokens_saved_cli_filtering += max(cli_filtering, 0)
|
||
c.tokens_saved_rtk += max(tokens_saved_rtk, 0)
|
||
c.tokens_saved_cache_reads += max(tokens_saved_cache_reads, 0)
|
||
c.compression_savings_usd += max(compression_savings_usd, 0.0)
|
||
c.cache_savings_usd += max(cache_savings_usd, 0.0)
|
||
|
||
def _poll_rtk_delta(self) -> int:
|
||
"""Return the delta of the session-scoped RTK ``tokens_saved`` since last poll.
|
||
|
||
Implementation of PR-G2 data-plane wiring. Calls
|
||
:func:`headroom.proxy.helpers._get_rtk_stats` and reads the
|
||
session-incremental ``session.tokens_saved`` field (de-baselined per
|
||
proxy session by the helper), then diffs against
|
||
``self._last_rtk_tokens_saved``.
|
||
|
||
Returns ``0`` (never negative) when:
|
||
- ``HEADROOM_RTK_WIRING=disabled`` — operator opt-out.
|
||
- ``_get_rtk_stats()`` returns ``None`` — RTK not selected, or the
|
||
stat read failed this poll ("no data"); explicit zero contribution
|
||
is the right answer and the high-water mark is preserved.
|
||
- ``_get_rtk_stats()`` raises — transient error, logged loudly.
|
||
- The session counter regressed (RTK reset / new project) — that
|
||
path also re-baselines ``_last_rtk_tokens_saved`` to the new
|
||
(smaller) value so subsequent polls return correct deltas.
|
||
- PR-G2 remediation (C3): another worker holds the RTK poll lock —
|
||
this worker skips polling so we don't double-count.
|
||
|
||
Otherwise advances ``self._last_rtk_tokens_saved`` to the new
|
||
session total and returns the positive delta.
|
||
|
||
PR-G2 remediation (H1): ``HEADROOM_RTK_WIRING`` is now validated at
|
||
startup via :func:`_validate_rtk_env_at_startup`; an invalid value
|
||
raises at proxy boot rather than being silently swallowed here.
|
||
We still catch + structured-log per-call as a defence-in-depth so
|
||
that an env var flipped to garbage after startup is at least loud
|
||
in the logs.
|
||
"""
|
||
try:
|
||
wiring_mode = _rtk_wiring_mode()
|
||
except ValueError as exc:
|
||
# PR-G2 remediation (H1): elevate to ERROR — this is config
|
||
# corruption, not a transient runtime hiccup. The bad value
|
||
# should have been caught at startup but a rotation could flip
|
||
# it mid-run; either way the operator must see this.
|
||
logger.error(
|
||
"event=subscription_rtk_invalid_env error=%s",
|
||
exc,
|
||
)
|
||
return 0
|
||
if wiring_mode == "disabled":
|
||
return 0
|
||
|
||
# PR-G2 remediation (C3): only the lock-holder worker polls. We try
|
||
# once per tracker instance and cache the verdict; the lock is
|
||
# released on tracker stop.
|
||
if not self._try_acquire_rtk_poll_lock():
|
||
return 0
|
||
|
||
try:
|
||
# Local import keeps the tracker module decoupled from the proxy
|
||
# helper at import time (helpers.py imports many heavy deps).
|
||
from headroom.proxy.helpers import _get_rtk_stats
|
||
except Exception as exc: # pragma: no cover — defensive
|
||
logger.warning(
|
||
"event=subscription_rtk_helper_import_failed error=%s",
|
||
exc,
|
||
)
|
||
return 0
|
||
|
||
try:
|
||
stats = _get_rtk_stats()
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"event=subscription_rtk_stats_fetch_failed error=%s",
|
||
exc,
|
||
)
|
||
return 0
|
||
|
||
if stats is None:
|
||
logger.info(
|
||
"event=subscription_rtk_stats_unavailable wiring=%s",
|
||
wiring_mode,
|
||
)
|
||
return 0
|
||
|
||
# PR-G2 remediation (C1): read the SESSION-incremental field, not
|
||
# the raw lifetime counter. The helper de-baselines per proxy
|
||
# session at startup, so ``session.tokens_saved`` already excludes
|
||
# the pre-Headroom RTK history. Falls back to the top-level
|
||
# ``tokens_saved`` (which is also session-scoped in the canonical
|
||
# payload; see ``_get_context_tool_stats``) and finally to 0 for
|
||
# not-installed zero payloads (failed reads arrive as ``None`` and
|
||
# returned above).
|
||
session_payload = stats.get("session")
|
||
if isinstance(session_payload, dict) and "tokens_saved" in session_payload:
|
||
current_total_raw = session_payload.get("tokens_saved", 0)
|
||
else:
|
||
current_total_raw = stats.get("tokens_saved", 0)
|
||
try:
|
||
current_total = int(current_total_raw or 0)
|
||
except (TypeError, ValueError) as exc:
|
||
logger.warning(
|
||
"event=subscription_rtk_stats_coerce_failed value=%r error=%s",
|
||
current_total_raw,
|
||
exc,
|
||
)
|
||
return 0
|
||
|
||
with self._lock:
|
||
last = self._last_rtk_tokens_saved
|
||
if current_total < last:
|
||
# Counter regressed: helper rebaselined (session reset) or
|
||
# RTK rebuilt its DB. Re-baseline silently — losing one
|
||
# delta is preferable to reporting a giant negative number.
|
||
logger.info(
|
||
"event=subscription_rtk_counter_regressed previous=%d current=%d",
|
||
last,
|
||
current_total,
|
||
)
|
||
self._last_rtk_tokens_saved = current_total
|
||
return 0
|
||
delta = current_total - last
|
||
if delta > 0:
|
||
self._last_rtk_tokens_saved = current_total
|
||
return delta
|
||
|
||
# ------------------------------------------------------------------
|
||
# Multi-worker poll ownership (PR-G2 remediation C3)
|
||
# ------------------------------------------------------------------
|
||
|
||
def _try_acquire_rtk_poll_lock(self) -> bool:
|
||
"""Try to acquire the RTK poll file lock (non-blocking).
|
||
|
||
Returns ``True`` if this worker owns the lock and should poll RTK.
|
||
Caches the verdict so we don't pay the syscall on every call. The
|
||
lock is released in :meth:`_release_rtk_poll_lock`, called from
|
||
:meth:`stop`.
|
||
|
||
Mirrors the beacon's ``_try_acquire_beacon_lock`` pattern in
|
||
``headroom/proxy/server.py`` (fcntl.flock, LOCK_EX | LOCK_NB).
|
||
"""
|
||
if self._rtk_poll_owner is not None:
|
||
return self._rtk_poll_owner
|
||
|
||
try:
|
||
import fcntl
|
||
except ImportError:
|
||
# Platform without fcntl (Windows). Every worker polls; log
|
||
# loudly so the operator knows the multi-worker invariant is
|
||
# weaker on this platform.
|
||
logger.warning("event=subscription_rtk_poll_lock_unavailable platform=no-fcntl")
|
||
self._rtk_poll_owner = True
|
||
return True
|
||
|
||
lock_path = self._rtk_poll_lock_path()
|
||
fd = None
|
||
try:
|
||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||
fd = open(lock_path, "w") # noqa: SIM115
|
||
fcntl_any = cast(Any, fcntl)
|
||
fcntl_any.flock(fd, fcntl_any.LOCK_EX | fcntl_any.LOCK_NB)
|
||
fd.write(str(os.getpid()))
|
||
fd.flush()
|
||
self._rtk_poll_lock_fd = fd
|
||
self._rtk_poll_owner = True
|
||
logger.info(
|
||
"event=subscription_rtk_poll_lock_acquired pid=%d path=%s",
|
||
os.getpid(),
|
||
lock_path,
|
||
)
|
||
return True
|
||
except OSError:
|
||
if fd is not None:
|
||
fd.close()
|
||
self._rtk_poll_owner = False
|
||
logger.info(
|
||
"event=subscription_rtk_poll_lock_skipped pid=%d path=%s",
|
||
os.getpid(),
|
||
lock_path,
|
||
)
|
||
return False
|
||
|
||
def _release_rtk_poll_lock(self) -> None:
|
||
"""Release the RTK poll file lock; safe to call repeatedly."""
|
||
fd = self._rtk_poll_lock_fd
|
||
if fd is None:
|
||
return
|
||
try:
|
||
import fcntl
|
||
|
||
fcntl_any = cast(Any, fcntl)
|
||
fcntl_any.flock(fd, fcntl_any.LOCK_UN)
|
||
except Exception:
|
||
pass
|
||
try:
|
||
fd.close()
|
||
except Exception:
|
||
pass
|
||
self._rtk_poll_lock_fd = None
|
||
try:
|
||
self._rtk_poll_lock_path().unlink(missing_ok=True)
|
||
except Exception:
|
||
pass
|
||
|
||
def _rtk_poll_lock_path(self) -> Path:
|
||
"""Return the path to the RTK poll lock file."""
|
||
override = os.environ.get(_RTK_POLL_LOCK_ENV, "").strip()
|
||
if override:
|
||
return Path(override)
|
||
return self._persist_path.parent / ".rtk_poll_lock"
|
||
|
||
# ------------------------------------------------------------------
|
||
# State access
|
||
# ------------------------------------------------------------------
|
||
|
||
@property
|
||
def state(self) -> dict[str, Any]:
|
||
"""Return current tracker state as a serialisable dict."""
|
||
with self._lock:
|
||
return self._state.to_dict()
|
||
|
||
@property
|
||
def latest_snapshot(self) -> SubscriptionSnapshot | None:
|
||
with self._lock:
|
||
return self._state.latest
|
||
|
||
def is_active(self) -> bool:
|
||
with self._lock:
|
||
return self._state.is_active(active_window_s=self._active_window_s)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Display-time rendering (issue #281)
|
||
# ------------------------------------------------------------------
|
||
|
||
def render_state(self) -> dict[str, Any]:
|
||
"""Return the dashboard-facing state dict, synthesizing post-reset.
|
||
|
||
Background poll cadence is capped at 5 minutes by Anthropic-tolerance
|
||
constraints; if the user's 5-hour window rolls over between two polls
|
||
the cached ``utilization_pct`` is stale and the dashboard would render
|
||
the OLD window's percentage even though Claude Code itself shows 0%
|
||
(issue #281). To avoid lying to the user without hammering the API,
|
||
we synthesize the post-reset windows here using locally-tracked
|
||
transcript token counts.
|
||
|
||
The returned dict preserves every key in :meth:`SubscriptionState
|
||
.to_dict` for backward compatibility; per-window dicts inside
|
||
``latest`` gain ``synthesized: bool`` and ``resets_at_estimated:
|
||
bool`` keys plus an optional ``render_warning`` string when synthesis
|
||
falls back.
|
||
"""
|
||
with self._lock:
|
||
base = self._state.to_dict()
|
||
snapshot = self._state.latest
|
||
|
||
if snapshot is None or base.get("latest") is None:
|
||
return base
|
||
|
||
latest_dict = base["latest"]
|
||
latest_dict["five_hour"] = self._render_window(
|
||
snapshot.five_hour,
|
||
window_duration=_FIVE_HOUR_WINDOW,
|
||
window_name="five_hour",
|
||
)
|
||
latest_dict["seven_day"] = self._render_window(
|
||
snapshot.seven_day,
|
||
window_duration=_SEVEN_DAY_WINDOW,
|
||
window_name="seven_day",
|
||
)
|
||
if snapshot.seven_day_opus is not None:
|
||
latest_dict["seven_day_opus"] = self._render_window(
|
||
snapshot.seven_day_opus,
|
||
window_duration=_SEVEN_DAY_WINDOW,
|
||
window_name="seven_day_opus",
|
||
)
|
||
if snapshot.seven_day_sonnet is not None:
|
||
latest_dict["seven_day_sonnet"] = self._render_window(
|
||
snapshot.seven_day_sonnet,
|
||
window_duration=_SEVEN_DAY_WINDOW,
|
||
window_name="seven_day_sonnet",
|
||
)
|
||
return base
|
||
|
||
def _render_window(
|
||
self,
|
||
window: RateLimitWindow | None,
|
||
*,
|
||
window_duration: timedelta,
|
||
window_name: str,
|
||
) -> dict[str, Any]:
|
||
used_since_reset = self._compute_used_since_reset(window)
|
||
return synthesize_window_render(
|
||
window,
|
||
used_since_reset=used_since_reset,
|
||
window_duration=window_duration,
|
||
window_name=window_name,
|
||
)
|
||
|
||
def _compute_used_since_reset(self, window: RateLimitWindow | None) -> int | None:
|
||
"""Read transcripts to count tokens spent strictly after ``window.resets_at``.
|
||
|
||
Returns ``None`` if the window has no observed reset time or if the
|
||
transcript scan fails (caller treats ``None`` as 0 for arithmetic
|
||
but propagates a render_warning when synthesis is otherwise
|
||
attempted).
|
||
"""
|
||
if window is None or window.resets_at is None:
|
||
return None
|
||
now = _utc_now()
|
||
if now < window.resets_at:
|
||
return None
|
||
try:
|
||
from headroom.subscription import session_tracking
|
||
|
||
tokens = session_tracking.compute_window_tokens(
|
||
window.resets_at.timestamp(), now.timestamp()
|
||
)
|
||
return int(tokens.weighted_token_equivalent or tokens.total_raw())
|
||
except Exception as exc:
|
||
logger.warning("event=subscription_render_used_since_reset_failed error=%s", exc)
|
||
return None
|
||
|
||
async def maybe_poll_on_demand(self) -> None:
|
||
"""Trigger at most one bounded poll per ``_DEFAULT_ON_DEMAND_POLL_FLOOR_S``.
|
||
|
||
Called from the ``/subscription-window`` endpoint so a dashboard
|
||
refresh can pull a fresh snapshot when the cache is stale, while
|
||
keeping fleet-wide poll rate within Anthropic tolerance. All
|
||
exceptions are swallowed and logged — never propagated.
|
||
"""
|
||
now_ts = time.time()
|
||
with self._lock:
|
||
elapsed = now_ts - self._last_on_demand_poll
|
||
if elapsed < self._on_demand_poll_floor_s:
|
||
logger.info(
|
||
"event=subscription_on_demand_poll_skipped_floor elapsed_s=%.1f floor_s=%.1f",
|
||
elapsed,
|
||
self._on_demand_poll_floor_s,
|
||
)
|
||
return
|
||
self._last_on_demand_poll = now_ts
|
||
|
||
logger.info(
|
||
"event=subscription_on_demand_poll_triggered floor_s=%.1f",
|
||
self._on_demand_poll_floor_s,
|
||
)
|
||
try:
|
||
await asyncio.wait_for(self._maybe_poll(), timeout=_ON_DEMAND_POLL_TIMEOUT_S)
|
||
except asyncio.TimeoutError:
|
||
logger.warning(
|
||
"event=subscription_on_demand_poll_timeout timeout_s=%.1f",
|
||
_ON_DEMAND_POLL_TIMEOUT_S,
|
||
)
|
||
except Exception as exc:
|
||
logger.warning("event=subscription_on_demand_poll_failed error=%s", exc)
|
||
|
||
# ------------------------------------------------------------------
|
||
# Poll loop
|
||
# ------------------------------------------------------------------
|
||
|
||
async def _poll_loop(self) -> None:
|
||
assert self._stop_event is not None
|
||
while not self._stop_event.is_set():
|
||
try:
|
||
await self._maybe_poll()
|
||
except Exception as exc:
|
||
logger.warning("Subscription tracker poll error: %s", exc)
|
||
try:
|
||
# NOTE: do NOT wrap in asyncio.shield() — shield prevents the
|
||
# inner Event.wait() from being cancelled when wait_for times
|
||
# out, leaking one Task per poll interval. Over hours the
|
||
# accumulated idle waiters bog down the event loop scheduler
|
||
# (observed as the "aged proxy degradation" in 2026-04-17).
|
||
await asyncio.wait_for(
|
||
self._stop_event.wait(),
|
||
timeout=self._poll_interval_s,
|
||
)
|
||
break # stop event was set
|
||
except asyncio.TimeoutError:
|
||
pass # normal: poll interval elapsed
|
||
|
||
async def _maybe_poll(self) -> None:
|
||
with self._lock:
|
||
is_active = self._state.is_active(active_window_s=self._active_window_s)
|
||
token = self._current_token
|
||
|
||
if not is_active:
|
||
# Try background poll using credentials file token
|
||
from headroom.subscription.client import read_cached_oauth_token
|
||
|
||
bg_token = read_cached_oauth_token()
|
||
if not bg_token:
|
||
return
|
||
token = token or bg_token
|
||
|
||
snapshot = await self._client.fetch(token)
|
||
if snapshot is None:
|
||
with self._lock:
|
||
self._state.mark_error("fetch returned None")
|
||
return
|
||
|
||
# Offload off the event loop: this scans every ~/.claude/projects/**/*.jsonl
|
||
# transcript and json.loads each line, which can take seconds and block /health.
|
||
window_tokens = await asyncio.to_thread(_compute_window_tokens_for_snapshot, snapshot)
|
||
|
||
# Detect anomalies
|
||
discrepancies = _detect_discrepancies(snapshot, window_tokens)
|
||
|
||
with self._lock:
|
||
self._state.add_snapshot(snapshot)
|
||
self._state.window_tokens = window_tokens
|
||
for d in discrepancies:
|
||
self._state.add_discrepancy(d)
|
||
self._state.last_error = None
|
||
# Reset contribution when 5h window rolls over
|
||
self._maybe_reset_contribution(snapshot)
|
||
|
||
self._persist_state()
|
||
logger.debug(
|
||
"Subscription poll: 5h=%.1f%% 7d=%.1f%%",
|
||
snapshot.five_hour.utilization_pct,
|
||
snapshot.seven_day.utilization_pct,
|
||
)
|
||
|
||
# Update OTEL metrics if configured
|
||
try:
|
||
from headroom.observability.metrics import get_otel_metrics
|
||
|
||
get_otel_metrics().record_subscription_window(self._state.to_dict())
|
||
except Exception:
|
||
pass
|
||
|
||
def _maybe_reset_contribution(self, snapshot: SubscriptionSnapshot) -> None:
|
||
"""Reset contribution counters when the 5h window rolls over."""
|
||
prev = self._state.history[-2] if len(self._state.history) >= 2 else None
|
||
if prev is None:
|
||
return
|
||
prev_resets_at = prev.five_hour.resets_at
|
||
curr_resets_at = snapshot.five_hour.resets_at
|
||
if (
|
||
prev_resets_at is not None
|
||
and curr_resets_at is not None
|
||
and curr_resets_at - prev_resets_at > _ROLLOVER_MIN_ADVANCE
|
||
):
|
||
logger.info("5h window rolled over; resetting headroom contribution counters")
|
||
self._state.contribution = HeadroomContribution()
|
||
|
||
# ------------------------------------------------------------------
|
||
# Persistence
|
||
# ------------------------------------------------------------------
|
||
|
||
def _persist_state(self) -> None:
|
||
try:
|
||
self._persist_path.parent.mkdir(parents=True, exist_ok=True)
|
||
with self._lock:
|
||
data = self._state.to_persist_dict()
|
||
with tempfile.NamedTemporaryFile(
|
||
mode="w",
|
||
dir=self._persist_path.parent,
|
||
delete=False,
|
||
suffix=".tmp",
|
||
encoding="utf-8",
|
||
) as fh:
|
||
json.dump(data, fh, indent=2)
|
||
tmp_path = fh.name
|
||
os.replace(tmp_path, self._persist_path)
|
||
except Exception as exc:
|
||
logger.debug("Failed to persist subscription state: %s", exc)
|
||
|
||
def _load_persisted_state(self) -> None:
|
||
try:
|
||
with open(self._persist_path, encoding="utf-8") as fh:
|
||
raw = json.load(fh)
|
||
# Restore only the contribution counters and poll counts for now;
|
||
# snapshot data is re-fetched on first active poll.
|
||
contrib = raw.get("contribution", {})
|
||
c = self._state.contribution
|
||
c.tokens_submitted = int(contrib.get("tokens_submitted", 0))
|
||
saved = contrib.get("tokens_saved", {})
|
||
# Newer state writes dashboard-facing ``compression`` as
|
||
# proxy-compression + CLI filtering. Prefer the raw proxy field when
|
||
# present so loading does not double-count CLI filtering.
|
||
c.tokens_saved_compression = int(
|
||
saved.get("proxy_compression", saved.get("compression", 0))
|
||
)
|
||
# PR-G2 (Realignment) — prefer the raw counters when present
|
||
# (new format). For backward compatibility with state written
|
||
# before PR-G2 we fall back to the dashboard-aliased keys.
|
||
cli_filtering = int(
|
||
saved.get(
|
||
"cli_filtering_raw",
|
||
saved.get("cli_filtering", saved.get("rtk", 0)),
|
||
)
|
||
)
|
||
c.tokens_saved_cli_filtering = cli_filtering
|
||
# PR-G2 remediation (M2): legacy state files written before this
|
||
# PR have no ``rtk_raw`` key — but pre-G2 the ``rtk`` field
|
||
# silently mirrored ``cli_filtering``. Treat the legacy ``rtk``
|
||
# field as authoritative for the rtk_raw counter to avoid
|
||
# zeroing historical accumulation. New format writes ``rtk_raw``
|
||
# explicitly; legacy writes had only ``rtk`` (aliased) and we
|
||
# honor it on read.
|
||
is_legacy_state = "rtk_raw" not in saved
|
||
if is_legacy_state:
|
||
# Pre-G2 semantic: ``rtk`` == ``cli_filtering``. Carry the
|
||
# accumulated value forward instead of silently zeroing it.
|
||
legacy_rtk_value = int(saved.get("rtk", cli_filtering))
|
||
c.tokens_saved_rtk = legacy_rtk_value
|
||
logger.info(
|
||
"event=subscription_state_legacy_load "
|
||
"migrated_rtk_raw_from_cli_filtering=%d path=%s",
|
||
legacy_rtk_value,
|
||
self._persist_path,
|
||
)
|
||
else:
|
||
c.tokens_saved_rtk = int(saved.get("rtk_raw", 0))
|
||
c.tokens_saved_cache_reads = int(saved.get("cache_reads", 0))
|
||
savings_usd = contrib.get("savings_usd", {})
|
||
c.compression_savings_usd = float(savings_usd.get("compression", 0.0))
|
||
c.cache_savings_usd = float(savings_usd.get("cache", 0.0))
|
||
self._state.poll_count = int(raw.get("poll_count", 0))
|
||
logger.debug("Loaded persisted subscription state from %s", self._persist_path)
|
||
except FileNotFoundError:
|
||
pass
|
||
except Exception as exc:
|
||
logger.debug("Could not load persisted subscription state: %s", exc)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Transcript-based window token computation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _compute_window_tokens_for_snapshot(snapshot: SubscriptionSnapshot) -> WindowTokens:
|
||
"""Read Claude transcript files and sum tokens for the current 5h window."""
|
||
try:
|
||
from headroom.subscription import session_tracking
|
||
|
||
resets_at = snapshot.five_hour.resets_at
|
||
if resets_at is None:
|
||
return WindowTokens()
|
||
window_duration_s = 5 * 3600 # 5-hour window
|
||
start_ts = resets_at.timestamp() - window_duration_s
|
||
end_ts = resets_at.timestamp()
|
||
return session_tracking.compute_window_tokens(start_ts, end_ts)
|
||
except Exception as exc:
|
||
logger.debug("Could not compute window tokens from transcripts: %s", exc)
|
||
return WindowTokens()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Anomaly detection
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _detect_discrepancies(
|
||
snapshot: SubscriptionSnapshot,
|
||
window_tokens: WindowTokens,
|
||
) -> list[WindowDiscrepancy]:
|
||
"""Detect surge pricing or cache miss anomalies in the snapshot."""
|
||
discrepancies: list[WindowDiscrepancy] = []
|
||
|
||
if snapshot.five_hour.limit > 0 and window_tokens.weighted_token_equivalent > 0:
|
||
expected_pct = window_tokens.weighted_token_equivalent / snapshot.five_hour.limit * 100.0
|
||
actual_pct = snapshot.five_hour.utilization_pct
|
||
delta = actual_pct - expected_pct
|
||
|
||
if delta > _SURGE_THRESHOLD_PCT:
|
||
discrepancies.append(
|
||
WindowDiscrepancy(
|
||
kind="surge_pricing",
|
||
description=(
|
||
f"API 5h utilization ({actual_pct:.1f}%) is "
|
||
f"{delta:.1f}% higher than transcript-implied "
|
||
f"({expected_pct:.1f}%); possible surge weighting."
|
||
),
|
||
severity="warning" if delta < 30 else "alert",
|
||
expected_utilization_pct=round(expected_pct, 2),
|
||
actual_utilization_pct=round(actual_pct, 2),
|
||
delta_pct=round(delta, 2),
|
||
)
|
||
)
|
||
|
||
total_input = window_tokens.input
|
||
total_cache_reads = window_tokens.cache_reads
|
||
if total_input > 50_000 and total_cache_reads < total_input * _CACHE_MISS_RATIO_THRESHOLD:
|
||
cache_ratio = total_cache_reads / total_input if total_input else 0
|
||
discrepancies.append(
|
||
WindowDiscrepancy(
|
||
kind="cache_miss",
|
||
description=(
|
||
f"Cache-read ratio is {cache_ratio:.1%} (threshold "
|
||
f"{_CACHE_MISS_RATIO_THRESHOLD:.0%}); system may not be "
|
||
"using prefix cache effectively."
|
||
),
|
||
severity="warning",
|
||
expected_utilization_pct=None,
|
||
actual_utilization_pct=None,
|
||
delta_pct=None,
|
||
)
|
||
)
|
||
|
||
return discrepancies
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Module-level singleton
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_tracker_lock = threading.Lock()
|
||
_tracker_instance: SubscriptionTracker | None = None
|
||
|
||
|
||
def get_subscription_tracker() -> SubscriptionTracker | None:
|
||
"""Return the global singleton tracker, or None if not configured."""
|
||
return _tracker_instance
|
||
|
||
|
||
def configure_subscription_tracker(
|
||
poll_interval_s: int = _DEFAULT_POLL_INTERVAL_S,
|
||
active_window_s: float = _DEFAULT_ACTIVE_WINDOW_S,
|
||
enabled: bool = True,
|
||
persist_path: Path | None = None,
|
||
client: SubscriptionClient | None = None,
|
||
) -> SubscriptionTracker:
|
||
"""Create (or return existing) global tracker singleton.
|
||
|
||
PR-G2 remediation (H1): validates RTK-related env vars eagerly here so
|
||
a typo (``HEADROOM_RTK_WIRING=enabld``) crashes the proxy at startup
|
||
instead of being silently swallowed at every ``update_contribution``
|
||
call.
|
||
"""
|
||
_validate_rtk_env_at_startup()
|
||
global _tracker_instance
|
||
with _tracker_lock:
|
||
if _tracker_instance is None:
|
||
_tracker_instance = SubscriptionTracker(
|
||
poll_interval_s=poll_interval_s,
|
||
active_window_s=active_window_s,
|
||
enabled=enabled,
|
||
persist_path=persist_path,
|
||
client=client,
|
||
)
|
||
return _tracker_instance
|
||
|
||
|
||
async def shutdown_subscription_tracker() -> None:
|
||
"""Stop and clean up the global tracker."""
|
||
global _tracker_instance
|
||
with _tracker_lock:
|
||
tracker = _tracker_instance
|
||
_tracker_instance = None
|
||
if tracker:
|
||
await tracker.stop()
|