""" Token Tracker — intercepts ALL litellm calls via CustomLogger to track token usage. Captures every LLM call made by OpenSpace internals: - Agent main loop (grounding_agent) → source="agent" - Skill selection (skill_registry) → source="skill_select" - Post-execution analysis (execution_analyzer) → source="analyzer" - Skill evolution (skill_evolver) → source="evolver" Each call is tagged with a ``source`` label via ``set_call_source()``. The TokenStats class tracks agent-only and overhead tokens separately. Usage (serial mode — one task at a time): tracker = TokenTracker() tracker.start() # ... run OpenSpace task ... stats = tracker.stop() print(f"Agent tokens: {stats.agent_prompt_tokens}") Usage (concurrent mode — multiple tasks in parallel): tracker = TokenTracker() tracker.install() # install callback once async def worker(task_id): ctx = tracker.begin_task(task_id) try: await cs.execute(...) finally: stats = tracker.end_task(task_id, ctx) tracker.uninstall() # remove callback when done Source tagging (call from any component): from openspace.telemetry.call_source import reset_call_source, set_call_source token = set_call_source("analyzer") try: await llm_client.call_model(...) finally: reset_call_source(token) """ from __future__ import annotations import contextvars import time import threading from dataclasses import dataclass, field from typing import Any, Dict, List, Optional import litellm from litellm.integrations.custom_logger import CustomLogger from openspace.telemetry import call_source as _call_source # ── ContextVar for concurrent per-task routing ── _current_task_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar( "gdpval_current_task_id", default=None ) @dataclass class TokenStats: """Accumulated token usage statistics for one tracking window. Tracks both *total* usage (all calls) and *agent-only* usage (source == "agent") so benchmarks can compare apples-to-apples. """ prompt_tokens: int = 0 completion_tokens: int = 0 total_tokens: int = 0 llm_calls: int = 0 cost_usd: float = 0.0 wall_time_sec: float = 0.0 # Agent-only counters (source == "agent") agent_prompt_tokens: int = 0 agent_completion_tokens: int = 0 agent_total_tokens: int = 0 agent_llm_calls: int = 0 # Per-call breakdown (for detailed analysis) call_details: List[Dict[str, Any]] = field(default_factory=list) def to_dict(self, include_details: bool = False) -> Dict[str, Any]: d = { "prompt_tokens": self.prompt_tokens, "completion_tokens": self.completion_tokens, "total_tokens": self.total_tokens, "llm_calls": self.llm_calls, "cost_usd": round(self.cost_usd, 6), "wall_time_sec": round(self.wall_time_sec, 2), "agent_prompt_tokens": self.agent_prompt_tokens, "agent_completion_tokens": self.agent_completion_tokens, "agent_total_tokens": self.agent_total_tokens, "agent_llm_calls": self.agent_llm_calls, } if include_details: d["call_details"] = self.call_details return d def reset(self) -> None: self.prompt_tokens = 0 self.completion_tokens = 0 self.total_tokens = 0 self.llm_calls = 0 self.cost_usd = 0.0 self.wall_time_sec = 0.0 self.agent_prompt_tokens = 0 self.agent_completion_tokens = 0 self.agent_total_tokens = 0 self.agent_llm_calls = 0 self.call_details.clear() def _accumulate(stats: TokenStats, prompt_tok: int, completion_tok: int, total_tok: int, cost: float, detail: Optional[Dict], source: str = "agent") -> None: """Add one LLM call's usage to a TokenStats object (caller holds lock).""" stats.prompt_tokens += prompt_tok stats.completion_tokens += completion_tok stats.total_tokens += total_tok stats.llm_calls += 1 stats.cost_usd += cost if source == _call_source.AGENT_SOURCE: stats.agent_prompt_tokens += prompt_tok stats.agent_completion_tokens += completion_tok stats.agent_total_tokens += total_tok stats.agent_llm_calls += 1 if detail is not None: stats.call_details.append(detail) class _TokenLoggerCallback(CustomLogger): """litellm CustomLogger that routes token usage to the TokenTracker.""" def __init__(self, tracker: "TokenTracker"): super().__init__() self._tracker = tracker def log_success_event(self, kwargs: dict, response_obj: Any, start_time: Any, end_time: Any) -> None: self._tracker._on_success(kwargs, response_obj, start_time, end_time) async def async_log_success_event(self, kwargs: dict, response_obj: Any, start_time: Any, end_time: Any) -> None: self._tracker._on_success(kwargs, response_obj, start_time, end_time) class TokenTracker: """Global token tracker using litellm CustomLogger callback. Thread-safe. Supports two modes: - **Serial**: start() → execute one task → stop() - **Concurrent**: install() → begin_task()/end_task() per task → uninstall() In concurrent mode, a ``contextvars.ContextVar`` is used to route each litellm callback to the correct per-task stats bucket. This works because asyncio executes callbacks in the context of the coroutine that triggered the LLM call. """ def __init__(self, record_details: bool = True): self._serial_stats = TokenStats() # serial mode accumulator self._per_task: Dict[str, TokenStats] = {} # concurrent mode buckets self._per_task_start: Dict[str, float] = {} self._lock = threading.Lock() self._start_time: Optional[float] = None self._record_details = record_details self._active = False self._installed = False self._callback = _TokenLoggerCallback(self) # ─── Callback management ─────────────────────────────────────── def _install_callback(self) -> None: if not self._installed: # Use litellm.callbacks (CustomLogger instances) if not hasattr(litellm, 'callbacks') or litellm.callbacks is None: litellm.callbacks = [] litellm.callbacks.append(self._callback) self._installed = True def _uninstall_callback(self) -> None: if self._installed: try: litellm.callbacks.remove(self._callback) except (ValueError, AttributeError): pass self._installed = False # ─── Serial mode (backward compat) ───────────────────────────── def start(self) -> None: """Start tracking (serial). Resets counters and installs callback.""" with self._lock: self._serial_stats.reset() self._start_time = time.monotonic() self._active = True self._install_callback() def stop(self) -> TokenStats: """Stop tracking (serial) and return final stats.""" with self._lock: self._active = False if self._start_time is not None: self._serial_stats.wall_time_sec = time.monotonic() - self._start_time snapshot = self._copy_stats(self._serial_stats) self._uninstall_callback() return snapshot def snapshot(self) -> TokenStats: """Get current serial stats without stopping.""" with self._lock: s = self._copy_stats(self._serial_stats) if self._start_time is not None: s.wall_time_sec = time.monotonic() - self._start_time return s # ─── Concurrent mode ─────────────────────────────────────────── def install(self) -> None: """Install persistent callback for concurrent tracking.""" self._active = True self._install_callback() def uninstall(self) -> None: """Remove persistent callback after all concurrent tasks finish.""" self._active = False self._uninstall_callback() def begin_task(self, task_id: str) -> contextvars.Token: """Start per-task tracking. Returns a ContextVar token for reset. Must be called *inside* the asyncio Task that will run the LLM calls. """ with self._lock: self._per_task[task_id] = TokenStats() self._per_task_start[task_id] = time.monotonic() return _current_task_id.set(task_id) def end_task(self, task_id: str, ctx_token: contextvars.Token) -> TokenStats: """Stop per-task tracking, return its stats, reset ContextVar.""" _current_task_id.reset(ctx_token) with self._lock: stats = self._per_task.pop(task_id, TokenStats()) t0 = self._per_task_start.pop(task_id, None) if t0 is not None: stats.wall_time_sec = time.monotonic() - t0 return self._copy_stats(stats) # ─── Internals ───────────────────────────────────────────────── @staticmethod def _copy_stats(src: TokenStats) -> TokenStats: return TokenStats( prompt_tokens=src.prompt_tokens, completion_tokens=src.completion_tokens, total_tokens=src.total_tokens, llm_calls=src.llm_calls, cost_usd=src.cost_usd, wall_time_sec=src.wall_time_sec, agent_prompt_tokens=src.agent_prompt_tokens, agent_completion_tokens=src.agent_completion_tokens, agent_total_tokens=src.agent_total_tokens, agent_llm_calls=src.agent_llm_calls, call_details=list(src.call_details), ) def _on_success(self, kwargs: dict, completion_response: Any, start_time: Any, end_time: Any) -> None: """Called by _TokenLoggerCallback after every successful LLM call.""" if not self._active: return # ── Parse usage ── usage = getattr(completion_response, "usage", None) prompt_tok = completion_tok = total_tok = 0 if usage: prompt_tok = getattr(usage, "prompt_tokens", 0) or 0 completion_tok = getattr(usage, "completion_tokens", 0) or 0 total_tok = getattr(usage, "total_tokens", 0) or 0 if total_tok == 0: total_tok = prompt_tok + completion_tok cost = 0.0 try: cost = litellm.completion_cost(completion_response=completion_response) or 0.0 except Exception: pass model = kwargs.get("model", "unknown") source = _call_source.get_call_source() # Capture wall-clock timestamp for retroactive analysis ts: Optional[float] = None if end_time is not None: try: if hasattr(end_time, 'timestamp'): ts = end_time.timestamp() else: ts = float(end_time) except (TypeError, ValueError): pass detail: Optional[Dict] = None if self._record_details: detail = { "model": model, "source": source, "prompt_tokens": prompt_tok, "completion_tokens": completion_tok, "total_tokens": total_tok, "cost_usd": round(cost, 6), } if ts is not None: detail["timestamp"] = round(ts, 3) # ── Route to correct bucket ── with self._lock: task_id = _current_task_id.get(None) if task_id is not None and task_id in self._per_task: _accumulate(self._per_task[task_id], prompt_tok, completion_tok, total_tok, cost, detail, source) else: _accumulate(self._serial_stats, prompt_tok, completion_tok, total_tok, cost, detail, source)