Instrument dataset usage and billing analytics (#316)
Browse files* Instrument session usage analytics
Add durable usage_metrics snapshots, backend HF billing refreshes, and shared dataset row usage columns.
Co-authored-by: OpenAI Codex <codex@openai.com>
* Address usage billing review feedback
Timebox billing refreshes, keep user-facing saves fire-and-forget, and share sandbox lifecycle aggregation semantics.
Co-authored-by: OpenAI Codex <codex@openai.com>
* Simplify HF Jobs usage label
Display HF Jobs without the estimated qualifier while preserving fallback usage logic.
Co-authored-by: OpenAI Codex <codex@openai.com>
---------
Co-authored-by: OpenAI Codex <codex@openai.com>
- agent/core/session.py +17 -0
- agent/core/session_uploader.py +25 -0
- agent/core/usage_metrics.py +448 -0
- backend/main.py +27 -9
- backend/routes/agent.py +41 -7
- backend/session_manager.py +73 -0
- backend/usage.py +68 -77
- frontend/src/components/UsageMeter.tsx +1 -1
- tests/unit/test_session_manager_persistence.py +177 -0
- tests/unit/test_session_uploader.py +98 -0
- tests/unit/test_usage.py +292 -0
agent/core/session.py
CHANGED
|
@@ -162,6 +162,8 @@ class Session:
|
|
| 162 |
self.usage_warning_next_threshold_usd: float = USAGE_WARNING_FIRST_THRESHOLD_USD
|
| 163 |
self.usage_threshold_checker: Any | None = None
|
| 164 |
self.yolo_budget_checker: Any | None = None
|
|
|
|
|
|
|
| 165 |
|
| 166 |
# Session trajectory logging
|
| 167 |
self.logged_events: list[dict] = []
|
|
@@ -467,6 +469,8 @@ class Session:
|
|
| 467 |
self.pending_approval = None
|
| 468 |
self.auto_approval_estimated_spend_usd = 0.0
|
| 469 |
self._yolo_budget_reservations = {}
|
|
|
|
|
|
|
| 470 |
self.reset_cancel()
|
| 471 |
|
| 472 |
# Previous-session metadata is intentionally included for event
|
|
@@ -535,6 +539,18 @@ class Session:
|
|
| 535 |
for e in self.logged_events
|
| 536 |
if e.get("event_type") == "llm_call"
|
| 537 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
return {
|
| 539 |
"session_id": self.session_id,
|
| 540 |
"user_id": self.user_id,
|
|
@@ -543,6 +559,7 @@ class Session:
|
|
| 543 |
"session_end_time": datetime.now().isoformat(),
|
| 544 |
"model_name": self.config.model_name,
|
| 545 |
"total_cost_usd": total_cost_usd,
|
|
|
|
| 546 |
"messages": [msg.model_dump() for msg in self.context_manager.items],
|
| 547 |
"events": self.logged_events,
|
| 548 |
"tools": tools,
|
|
|
|
| 162 |
self.usage_warning_next_threshold_usd: float = USAGE_WARNING_FIRST_THRESHOLD_USD
|
| 163 |
self.usage_threshold_checker: Any | None = None
|
| 164 |
self.yolo_budget_checker: Any | None = None
|
| 165 |
+
self.usage_hf_billing_snapshot: dict[str, Any] | None = None
|
| 166 |
+
self.usage_metrics: dict[str, Any] | None = None
|
| 167 |
|
| 168 |
# Session trajectory logging
|
| 169 |
self.logged_events: list[dict] = []
|
|
|
|
| 469 |
self.pending_approval = None
|
| 470 |
self.auto_approval_estimated_spend_usd = 0.0
|
| 471 |
self._yolo_budget_reservations = {}
|
| 472 |
+
self.usage_hf_billing_snapshot = None
|
| 473 |
+
self.usage_metrics = None
|
| 474 |
self.reset_cancel()
|
| 475 |
|
| 476 |
# Previous-session metadata is intentionally included for event
|
|
|
|
| 539 |
for e in self.logged_events
|
| 540 |
if e.get("event_type") == "llm_call"
|
| 541 |
)
|
| 542 |
+
try:
|
| 543 |
+
from agent.core.usage_metrics import summarize_usage_events
|
| 544 |
+
|
| 545 |
+
usage_metrics = summarize_usage_events(
|
| 546 |
+
self.logged_events,
|
| 547 |
+
session_id=self.session_id,
|
| 548 |
+
hf_billing_snapshot=self.usage_hf_billing_snapshot,
|
| 549 |
+
)
|
| 550 |
+
self.usage_metrics = usage_metrics
|
| 551 |
+
except Exception as e:
|
| 552 |
+
logger.debug("Usage metrics summary failed for %s: %s", self.session_id, e)
|
| 553 |
+
usage_metrics = self.usage_metrics or {}
|
| 554 |
return {
|
| 555 |
"session_id": self.session_id,
|
| 556 |
"user_id": self.user_id,
|
|
|
|
| 559 |
"session_end_time": datetime.now().isoformat(),
|
| 560 |
"model_name": self.config.model_name,
|
| 561 |
"total_cost_usd": total_cost_usd,
|
| 562 |
+
"usage_metrics": usage_metrics,
|
| 563 |
"messages": [msg.model_dump() for msg in self.context_manager.items],
|
| 564 |
"events": self.logged_events,
|
| 565 |
"tools": tools,
|
agent/core/session_uploader.py
CHANGED
|
@@ -26,6 +26,11 @@ from typing import Any
|
|
| 26 |
|
| 27 |
from dotenv import load_dotenv
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
load_dotenv()
|
| 30 |
|
| 31 |
# Token resolution for the org KPI dataset. Fallback chain (least-privilege
|
|
@@ -261,9 +266,27 @@ def _scrub_session_for_upload(data: dict) -> dict:
|
|
| 261 |
return scrubbed
|
| 262 |
|
| 263 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
def _write_row_payload(data: dict, tmp_path: str) -> None:
|
| 265 |
"""Single-row JSONL (existing format) — used by KPI scheduler."""
|
| 266 |
scrubbed = _scrub_session_for_upload(data)
|
|
|
|
| 267 |
session_row = {
|
| 268 |
"session_id": data["session_id"],
|
| 269 |
"user_id": data.get("user_id"),
|
|
@@ -274,7 +297,9 @@ def _write_row_payload(data: dict, tmp_path: str) -> None:
|
|
| 274 |
"messages": json.dumps(scrubbed["messages"]),
|
| 275 |
"events": json.dumps(scrubbed["events"]),
|
| 276 |
"tools": json.dumps(scrubbed["tools"]),
|
|
|
|
| 277 |
}
|
|
|
|
| 278 |
|
| 279 |
with open(tmp_path, "w") as tmp:
|
| 280 |
json.dump(session_row, tmp)
|
|
|
|
| 26 |
|
| 27 |
from dotenv import load_dotenv
|
| 28 |
|
| 29 |
+
from agent.core.usage_metrics import (
|
| 30 |
+
summarize_usage_events,
|
| 31 |
+
usage_metric_scalar_fields,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
load_dotenv()
|
| 35 |
|
| 36 |
# Token resolution for the org KPI dataset. Fallback chain (least-privilege
|
|
|
|
| 266 |
return scrubbed
|
| 267 |
|
| 268 |
|
| 269 |
+
def _usage_metrics_for_row(data: dict) -> dict:
|
| 270 |
+
metrics = data.get("usage_metrics")
|
| 271 |
+
if isinstance(metrics, str):
|
| 272 |
+
try:
|
| 273 |
+
parsed = json.loads(metrics)
|
| 274 |
+
metrics = parsed if isinstance(parsed, dict) else None
|
| 275 |
+
except (json.JSONDecodeError, TypeError):
|
| 276 |
+
metrics = None
|
| 277 |
+
if isinstance(metrics, dict):
|
| 278 |
+
return metrics
|
| 279 |
+
events = data.get("events")
|
| 280 |
+
return summarize_usage_events(
|
| 281 |
+
events if isinstance(events, list) else [],
|
| 282 |
+
session_id=data.get("session_id"),
|
| 283 |
+
)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
def _write_row_payload(data: dict, tmp_path: str) -> None:
|
| 287 |
"""Single-row JSONL (existing format) — used by KPI scheduler."""
|
| 288 |
scrubbed = _scrub_session_for_upload(data)
|
| 289 |
+
usage_metrics = _usage_metrics_for_row(data)
|
| 290 |
session_row = {
|
| 291 |
"session_id": data["session_id"],
|
| 292 |
"user_id": data.get("user_id"),
|
|
|
|
| 297 |
"messages": json.dumps(scrubbed["messages"]),
|
| 298 |
"events": json.dumps(scrubbed["events"]),
|
| 299 |
"tools": json.dumps(scrubbed["tools"]),
|
| 300 |
+
"usage_metrics": json.dumps(_scrub(usage_metrics)),
|
| 301 |
}
|
| 302 |
+
session_row.update(usage_metric_scalar_fields(usage_metrics))
|
| 303 |
|
| 304 |
with open(tmp_path, "w") as tmp:
|
| 305 |
json.dump(session_row, tmp)
|
agent/core/usage_metrics.py
ADDED
|
@@ -0,0 +1,448 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure usage/billing summaries for session trajectory analytics."""
|
| 2 |
+
|
| 3 |
+
from collections import Counter, defaultdict
|
| 4 |
+
from datetime import UTC, datetime, timedelta
|
| 5 |
+
from math import isfinite
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
from agent.core.cost_estimation import SPACE_PRICE_USD_PER_HOUR
|
| 9 |
+
|
| 10 |
+
USAGE_METRICS_VERSION = 1
|
| 11 |
+
BILLING_SCOPE_ACCOUNT_WINDOW_DELTA = "account_window_delta"
|
| 12 |
+
|
| 13 |
+
_USAGE_SCALAR_KEYS = (
|
| 14 |
+
"usage_total_usd",
|
| 15 |
+
"usage_total_usd_source",
|
| 16 |
+
"usage_app_total_usd",
|
| 17 |
+
"usage_hf_billing_total_usd",
|
| 18 |
+
"usage_llm_calls",
|
| 19 |
+
"usage_total_tokens",
|
| 20 |
+
"usage_hf_job_submits",
|
| 21 |
+
"usage_hf_job_status_snapshots",
|
| 22 |
+
"usage_sandbox_creates",
|
| 23 |
+
"usage_sandbox_pairs",
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _coerce_float(value: Any) -> float:
|
| 28 |
+
if isinstance(value, bool) or value is None:
|
| 29 |
+
return 0.0
|
| 30 |
+
try:
|
| 31 |
+
parsed = float(value)
|
| 32 |
+
except (TypeError, ValueError):
|
| 33 |
+
return 0.0
|
| 34 |
+
return parsed if isfinite(parsed) else 0.0
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _coerce_optional_float(value: Any) -> float | None:
|
| 38 |
+
if isinstance(value, bool) or value is None:
|
| 39 |
+
return None
|
| 40 |
+
try:
|
| 41 |
+
parsed = float(value)
|
| 42 |
+
except (TypeError, ValueError):
|
| 43 |
+
return None
|
| 44 |
+
return parsed if isfinite(parsed) else None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _coerce_int(value: Any) -> int:
|
| 48 |
+
if isinstance(value, bool) or value is None:
|
| 49 |
+
return 0
|
| 50 |
+
try:
|
| 51 |
+
return int(value)
|
| 52 |
+
except (TypeError, ValueError):
|
| 53 |
+
return 0
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _round_usd(value: Any) -> float:
|
| 57 |
+
return round(_coerce_float(value), 6)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _parse_timestamp(value: Any) -> datetime | None:
|
| 61 |
+
if isinstance(value, datetime):
|
| 62 |
+
dt = value
|
| 63 |
+
elif isinstance(value, str) and value:
|
| 64 |
+
try:
|
| 65 |
+
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
| 66 |
+
except ValueError:
|
| 67 |
+
return None
|
| 68 |
+
else:
|
| 69 |
+
return None
|
| 70 |
+
if dt.tzinfo is None:
|
| 71 |
+
return dt.replace(tzinfo=UTC)
|
| 72 |
+
return dt.astimezone(UTC)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def event_created_at(event: dict[str, Any]) -> datetime | None:
|
| 76 |
+
return _parse_timestamp(event.get("created_at") or event.get("timestamp"))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _event_data(event: dict[str, Any]) -> dict[str, Any]:
|
| 80 |
+
data = event.get("data") or {}
|
| 81 |
+
return data if isinstance(data, dict) else {}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _has_number(value: Any) -> bool:
|
| 85 |
+
return _coerce_optional_float(value) is not None
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _counter_dict(counter: Counter[str]) -> dict[str, int]:
|
| 89 |
+
return dict(sorted(counter.items()))
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def _empty_app_bucket(session_id: str | None) -> dict[str, Any]:
|
| 93 |
+
return {
|
| 94 |
+
"session_id": session_id,
|
| 95 |
+
"total_usd": 0.0,
|
| 96 |
+
"inference_usd": 0.0,
|
| 97 |
+
"hf_jobs_estimated_usd": 0.0,
|
| 98 |
+
"sandbox_estimated_usd": 0.0,
|
| 99 |
+
"llm_calls": 0,
|
| 100 |
+
"hf_jobs_count": 0,
|
| 101 |
+
"sandbox_count": 0,
|
| 102 |
+
"prompt_tokens": 0,
|
| 103 |
+
"completion_tokens": 0,
|
| 104 |
+
"cache_read_tokens": 0,
|
| 105 |
+
"cache_creation_tokens": 0,
|
| 106 |
+
"total_tokens": 0,
|
| 107 |
+
"hf_jobs_billable_seconds_estimate": 0,
|
| 108 |
+
"sandbox_billable_seconds_estimate": 0,
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _sandbox_id(event: dict[str, Any]) -> str | None:
|
| 113 |
+
sandbox_id = _event_data(event).get("sandbox_id")
|
| 114 |
+
return sandbox_id if isinstance(sandbox_id, str) and sandbox_id else None
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _sandbox_duration_seconds(
|
| 118 |
+
create_event: dict[str, Any],
|
| 119 |
+
destroy_event: dict[str, Any],
|
| 120 |
+
) -> int:
|
| 121 |
+
create_data = _event_data(create_event)
|
| 122 |
+
destroy_data = _event_data(destroy_event)
|
| 123 |
+
lifetime_s = _coerce_int(destroy_data.get("lifetime_s"))
|
| 124 |
+
if lifetime_s > 0:
|
| 125 |
+
return lifetime_s
|
| 126 |
+
|
| 127 |
+
create_at = event_created_at(create_event)
|
| 128 |
+
destroy_at = event_created_at(destroy_event)
|
| 129 |
+
if create_at is None or destroy_at is None:
|
| 130 |
+
return 0
|
| 131 |
+
create_latency_s = max(0, _coerce_int(create_data.get("create_latency_s")))
|
| 132 |
+
interval_start = create_at - timedelta(seconds=create_latency_s)
|
| 133 |
+
if destroy_at <= interval_start:
|
| 134 |
+
return 0
|
| 135 |
+
return int((destroy_at - interval_start).total_seconds())
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def summarize_sandbox_lifecycle(
|
| 139 |
+
lifecycle_events: list[tuple[int, dict[str, Any]]],
|
| 140 |
+
) -> dict[str, Any]:
|
| 141 |
+
"""Pair sandbox lifecycle events and estimate billed usage.
|
| 142 |
+
|
| 143 |
+
Shared by dataset usage metrics and backend usage responses so sandbox
|
| 144 |
+
pricing and create/destroy pairing semantics cannot drift.
|
| 145 |
+
"""
|
| 146 |
+
ordered_events = [
|
| 147 |
+
event
|
| 148 |
+
for _, event in sorted(
|
| 149 |
+
lifecycle_events,
|
| 150 |
+
key=lambda indexed: (
|
| 151 |
+
event_created_at(indexed[1]) is None,
|
| 152 |
+
event_created_at(indexed[1]) or datetime.min.replace(tzinfo=UTC),
|
| 153 |
+
indexed[0],
|
| 154 |
+
),
|
| 155 |
+
)
|
| 156 |
+
]
|
| 157 |
+
active_creates: dict[str, list[dict[str, Any]]] = defaultdict(list)
|
| 158 |
+
matched_pairs = 0
|
| 159 |
+
unpaired_destroys = 0
|
| 160 |
+
estimated_usd = 0.0
|
| 161 |
+
billable_seconds = 0
|
| 162 |
+
|
| 163 |
+
for event in ordered_events:
|
| 164 |
+
event_type = event.get("event_type")
|
| 165 |
+
sandbox_id = _sandbox_id(event)
|
| 166 |
+
if sandbox_id is None:
|
| 167 |
+
continue
|
| 168 |
+
if event_type == "sandbox_create":
|
| 169 |
+
active_creates[sandbox_id].append(event)
|
| 170 |
+
continue
|
| 171 |
+
if event_type != "sandbox_destroy":
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
creates = active_creates.get(sandbox_id)
|
| 175 |
+
if not creates:
|
| 176 |
+
unpaired_destroys += 1
|
| 177 |
+
continue
|
| 178 |
+
|
| 179 |
+
create_event = creates.pop()
|
| 180 |
+
if not creates:
|
| 181 |
+
active_creates.pop(sandbox_id, None)
|
| 182 |
+
|
| 183 |
+
hardware = str(_event_data(create_event).get("hardware") or "cpu-basic")
|
| 184 |
+
seconds = _sandbox_duration_seconds(create_event, event)
|
| 185 |
+
price_usd_per_hour = _coerce_float(SPACE_PRICE_USD_PER_HOUR.get(hardware))
|
| 186 |
+
matched_pairs += 1
|
| 187 |
+
if price_usd_per_hour > 0:
|
| 188 |
+
billable_seconds += seconds
|
| 189 |
+
estimated_usd += price_usd_per_hour * (seconds / 3600)
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
"matched_pairs": matched_pairs,
|
| 193 |
+
"unpaired_creates": sum(len(events) for events in active_creates.values()),
|
| 194 |
+
"unpaired_destroys": unpaired_destroys,
|
| 195 |
+
"estimated_usd": _round_usd(estimated_usd),
|
| 196 |
+
"billable_seconds_estimate": billable_seconds,
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def normalize_hf_billing_snapshot(snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
| 201 |
+
"""Return a dataset-safe HF billing snapshot.
|
| 202 |
+
|
| 203 |
+
Only current-session window rollups are retained. Monthly account totals,
|
| 204 |
+
credit limits, and any caller-provided extra fields are intentionally
|
| 205 |
+
dropped before the snapshot can be serialized into session artifacts.
|
| 206 |
+
"""
|
| 207 |
+
hf_billing = snapshot.get("hf_billing") if isinstance(snapshot, dict) else None
|
| 208 |
+
hf_billing = hf_billing if isinstance(hf_billing, dict) else {}
|
| 209 |
+
current_session = hf_billing.get("current_session")
|
| 210 |
+
current_session = current_session if isinstance(current_session, dict) else None
|
| 211 |
+
|
| 212 |
+
sanitized_current = None
|
| 213 |
+
if current_session is not None:
|
| 214 |
+
sanitized_current = {
|
| 215 |
+
"window_start": current_session.get("window_start"),
|
| 216 |
+
"window_end": current_session.get("window_end"),
|
| 217 |
+
"timezone": current_session.get("timezone"),
|
| 218 |
+
"total_usd": _round_usd(current_session.get("total_usd")),
|
| 219 |
+
"inference_providers_usd": _round_usd(
|
| 220 |
+
current_session.get("inference_providers_usd")
|
| 221 |
+
),
|
| 222 |
+
"hf_jobs_usd": _round_usd(current_session.get("hf_jobs_usd")),
|
| 223 |
+
"inference_provider_requests": _coerce_int(
|
| 224 |
+
current_session.get("inference_provider_requests")
|
| 225 |
+
),
|
| 226 |
+
"hf_jobs_minutes": round(
|
| 227 |
+
_coerce_float(current_session.get("hf_jobs_minutes")), 3
|
| 228 |
+
),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
available = bool(hf_billing.get("available") and sanitized_current is not None)
|
| 232 |
+
return {
|
| 233 |
+
"billing_scope": BILLING_SCOPE_ACCOUNT_WINDOW_DELTA,
|
| 234 |
+
"hf_billing": {
|
| 235 |
+
"source": str(hf_billing.get("source") or "hf_billing_usage_v2"),
|
| 236 |
+
"available": available,
|
| 237 |
+
"error": None if available else hf_billing.get("error"),
|
| 238 |
+
"current_session": sanitized_current if available else None,
|
| 239 |
+
},
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def summarize_usage_events(
|
| 244 |
+
events: list[dict[str, Any]],
|
| 245 |
+
*,
|
| 246 |
+
session_id: str | None = None,
|
| 247 |
+
hf_billing_snapshot: dict[str, Any] | None = None,
|
| 248 |
+
) -> dict[str, Any]:
|
| 249 |
+
app = _empty_app_bucket(session_id)
|
| 250 |
+
llm_by_kind: Counter[str] = Counter()
|
| 251 |
+
llm_by_model: Counter[str] = Counter()
|
| 252 |
+
job_statuses: Counter[str] = Counter()
|
| 253 |
+
job_submit_flavors: Counter[str] = Counter()
|
| 254 |
+
job_status_flavors: Counter[str] = Counter()
|
| 255 |
+
sandbox_hardware: Counter[str] = Counter()
|
| 256 |
+
lifecycle_events: list[tuple[int, dict[str, Any]]] = []
|
| 257 |
+
|
| 258 |
+
event_count = 0
|
| 259 |
+
events_without_timestamp = 0
|
| 260 |
+
llm_calls_with_cost_usd = 0
|
| 261 |
+
llm_calls_with_nonzero_cost_usd = 0
|
| 262 |
+
job_submits = 0
|
| 263 |
+
job_status_snapshots = 0
|
| 264 |
+
job_snapshots_with_estimated_cost = 0
|
| 265 |
+
job_snapshots_with_nonzero_estimated_cost = 0
|
| 266 |
+
sandbox_creates = 0
|
| 267 |
+
sandbox_destroys = 0
|
| 268 |
+
turn_complete_count = 0
|
| 269 |
+
assistant_stream_end_count = 0
|
| 270 |
+
|
| 271 |
+
for index, event in enumerate(events or []):
|
| 272 |
+
if not isinstance(event, dict):
|
| 273 |
+
continue
|
| 274 |
+
event_count += 1
|
| 275 |
+
if event_created_at(event) is None:
|
| 276 |
+
events_without_timestamp += 1
|
| 277 |
+
|
| 278 |
+
event_type = event.get("event_type")
|
| 279 |
+
data = _event_data(event)
|
| 280 |
+
if event_type == "llm_call":
|
| 281 |
+
app["llm_calls"] += 1
|
| 282 |
+
if "cost_usd" in data:
|
| 283 |
+
llm_calls_with_cost_usd += 1
|
| 284 |
+
cost_usd = _coerce_float(data.get("cost_usd"))
|
| 285 |
+
if cost_usd > 0:
|
| 286 |
+
llm_calls_with_nonzero_cost_usd += 1
|
| 287 |
+
app["inference_usd"] += cost_usd
|
| 288 |
+
|
| 289 |
+
prompt_tokens = _coerce_int(data.get("prompt_tokens"))
|
| 290 |
+
completion_tokens = _coerce_int(data.get("completion_tokens"))
|
| 291 |
+
cache_read_tokens = _coerce_int(data.get("cache_read_tokens"))
|
| 292 |
+
cache_creation_tokens = _coerce_int(data.get("cache_creation_tokens"))
|
| 293 |
+
total_tokens = _coerce_int(data.get("total_tokens")) or (
|
| 294 |
+
prompt_tokens
|
| 295 |
+
+ completion_tokens
|
| 296 |
+
+ cache_read_tokens
|
| 297 |
+
+ cache_creation_tokens
|
| 298 |
+
)
|
| 299 |
+
app["prompt_tokens"] += prompt_tokens
|
| 300 |
+
app["completion_tokens"] += completion_tokens
|
| 301 |
+
app["cache_read_tokens"] += cache_read_tokens
|
| 302 |
+
app["cache_creation_tokens"] += cache_creation_tokens
|
| 303 |
+
app["total_tokens"] += total_tokens
|
| 304 |
+
llm_by_kind[str(data.get("kind") or "unknown")] += 1
|
| 305 |
+
llm_by_model[str(data.get("model") or "unknown")] += 1
|
| 306 |
+
elif event_type == "hf_job_submit":
|
| 307 |
+
job_submits += 1
|
| 308 |
+
job_submit_flavors[str(data.get("flavor") or "unknown")] += 1
|
| 309 |
+
elif event_type == "hf_job_complete":
|
| 310 |
+
job_status_snapshots += 1
|
| 311 |
+
app["hf_jobs_count"] += 1
|
| 312 |
+
estimated_cost = _coerce_float(data.get("estimated_cost_usd"))
|
| 313 |
+
app["hf_jobs_estimated_usd"] += estimated_cost
|
| 314 |
+
app["hf_jobs_billable_seconds_estimate"] += _coerce_int(
|
| 315 |
+
data.get("billable_seconds_estimate") or data.get("wall_time_s")
|
| 316 |
+
)
|
| 317 |
+
if _has_number(data.get("estimated_cost_usd")):
|
| 318 |
+
job_snapshots_with_estimated_cost += 1
|
| 319 |
+
if estimated_cost > 0:
|
| 320 |
+
job_snapshots_with_nonzero_estimated_cost += 1
|
| 321 |
+
job_statuses[str(data.get("final_status") or "unknown")] += 1
|
| 322 |
+
job_status_flavors[str(data.get("flavor") or "unknown")] += 1
|
| 323 |
+
elif event_type == "sandbox_create":
|
| 324 |
+
sandbox_creates += 1
|
| 325 |
+
sandbox_hardware[str(data.get("hardware") or "cpu-basic")] += 1
|
| 326 |
+
lifecycle_events.append((index, event))
|
| 327 |
+
elif event_type == "sandbox_destroy":
|
| 328 |
+
sandbox_destroys += 1
|
| 329 |
+
lifecycle_events.append((index, event))
|
| 330 |
+
elif event_type == "turn_complete":
|
| 331 |
+
turn_complete_count += 1
|
| 332 |
+
elif event_type == "assistant_stream_end":
|
| 333 |
+
assistant_stream_end_count += 1
|
| 334 |
+
|
| 335 |
+
sandbox = summarize_sandbox_lifecycle(lifecycle_events)
|
| 336 |
+
app["sandbox_count"] = sandbox["matched_pairs"]
|
| 337 |
+
app["sandbox_estimated_usd"] = sandbox["estimated_usd"]
|
| 338 |
+
app["sandbox_billable_seconds_estimate"] = sandbox["billable_seconds_estimate"]
|
| 339 |
+
app["inference_usd"] = _round_usd(app["inference_usd"])
|
| 340 |
+
app["hf_jobs_estimated_usd"] = _round_usd(app["hf_jobs_estimated_usd"])
|
| 341 |
+
app["total_usd"] = _round_usd(
|
| 342 |
+
app["inference_usd"]
|
| 343 |
+
+ app["hf_jobs_estimated_usd"]
|
| 344 |
+
+ app["sandbox_estimated_usd"]
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
billing = normalize_hf_billing_snapshot(hf_billing_snapshot)
|
| 348 |
+
current_billing = billing["hf_billing"]["current_session"]
|
| 349 |
+
hf_billing_total = None
|
| 350 |
+
if billing["hf_billing"]["available"] and current_billing is not None:
|
| 351 |
+
hf_billing_total = _round_usd(current_billing.get("total_usd"))
|
| 352 |
+
usage_total = _round_usd(hf_billing_total + app["sandbox_estimated_usd"])
|
| 353 |
+
usage_total_source = "hf_billing_plus_sandbox_estimate"
|
| 354 |
+
else:
|
| 355 |
+
usage_total = app["total_usd"]
|
| 356 |
+
usage_total_source = "app_telemetry_fallback"
|
| 357 |
+
|
| 358 |
+
job_flavors = job_submit_flavors + job_status_flavors
|
| 359 |
+
|
| 360 |
+
return {
|
| 361 |
+
"version": USAGE_METRICS_VERSION,
|
| 362 |
+
"session_id": session_id,
|
| 363 |
+
"billing_scope": BILLING_SCOPE_ACCOUNT_WINDOW_DELTA,
|
| 364 |
+
"total_usd": usage_total,
|
| 365 |
+
"total_usd_source": usage_total_source,
|
| 366 |
+
"app_total_usd": app["total_usd"],
|
| 367 |
+
"hf_billing_total_usd": hf_billing_total,
|
| 368 |
+
"app_telemetry": app,
|
| 369 |
+
"hf_billing": billing["hf_billing"],
|
| 370 |
+
"llm": {
|
| 371 |
+
"calls": app["llm_calls"],
|
| 372 |
+
"calls_by_kind": _counter_dict(llm_by_kind),
|
| 373 |
+
"calls_by_model": _counter_dict(llm_by_model),
|
| 374 |
+
"prompt_tokens": app["prompt_tokens"],
|
| 375 |
+
"completion_tokens": app["completion_tokens"],
|
| 376 |
+
"cache_read_tokens": app["cache_read_tokens"],
|
| 377 |
+
"cache_creation_tokens": app["cache_creation_tokens"],
|
| 378 |
+
"total_tokens": app["total_tokens"],
|
| 379 |
+
},
|
| 380 |
+
"turns": {
|
| 381 |
+
"turn_complete_count": turn_complete_count,
|
| 382 |
+
"assistant_stream_end_count": assistant_stream_end_count,
|
| 383 |
+
},
|
| 384 |
+
"hf_jobs": {
|
| 385 |
+
"submits": job_submits,
|
| 386 |
+
"status_snapshots": job_status_snapshots,
|
| 387 |
+
"statuses": _counter_dict(job_statuses),
|
| 388 |
+
"flavors": _counter_dict(job_flavors),
|
| 389 |
+
"submit_flavors": _counter_dict(job_submit_flavors),
|
| 390 |
+
"status_snapshot_flavors": _counter_dict(job_status_flavors),
|
| 391 |
+
"estimated_usd": app["hf_jobs_estimated_usd"],
|
| 392 |
+
"billable_seconds_estimate": app["hf_jobs_billable_seconds_estimate"],
|
| 393 |
+
"snapshots_with_estimated_cost": job_snapshots_with_estimated_cost,
|
| 394 |
+
"snapshots_with_nonzero_estimated_cost": (
|
| 395 |
+
job_snapshots_with_nonzero_estimated_cost
|
| 396 |
+
),
|
| 397 |
+
},
|
| 398 |
+
"sandboxes": {
|
| 399 |
+
"creates": sandbox_creates,
|
| 400 |
+
"destroys": sandbox_destroys,
|
| 401 |
+
"matched_pairs": sandbox["matched_pairs"],
|
| 402 |
+
"unpaired_creates": sandbox["unpaired_creates"],
|
| 403 |
+
"unpaired_destroys": sandbox["unpaired_destroys"],
|
| 404 |
+
"hardware": _counter_dict(sandbox_hardware),
|
| 405 |
+
"estimated_usd": app["sandbox_estimated_usd"],
|
| 406 |
+
"billable_seconds_estimate": app["sandbox_billable_seconds_estimate"],
|
| 407 |
+
},
|
| 408 |
+
"data_quality": {
|
| 409 |
+
"event_count": event_count,
|
| 410 |
+
"events_without_timestamp": events_without_timestamp,
|
| 411 |
+
"llm_calls_with_cost_usd": llm_calls_with_cost_usd,
|
| 412 |
+
"llm_calls_with_nonzero_cost_usd": llm_calls_with_nonzero_cost_usd,
|
| 413 |
+
"job_snapshots_with_estimated_cost": job_snapshots_with_estimated_cost,
|
| 414 |
+
"job_snapshots_missing_estimated_cost": (
|
| 415 |
+
job_status_snapshots - job_snapshots_with_estimated_cost
|
| 416 |
+
),
|
| 417 |
+
},
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def usage_metric_scalar_fields(metrics: dict[str, Any]) -> dict[str, Any]:
|
| 422 |
+
app = metrics.get("app_telemetry") if isinstance(metrics, dict) else {}
|
| 423 |
+
llm = metrics.get("llm") if isinstance(metrics, dict) else {}
|
| 424 |
+
jobs = metrics.get("hf_jobs") if isinstance(metrics, dict) else {}
|
| 425 |
+
sandboxes = metrics.get("sandboxes") if isinstance(metrics, dict) else {}
|
| 426 |
+
values = {
|
| 427 |
+
"usage_total_usd": metrics.get("total_usd"),
|
| 428 |
+
"usage_total_usd_source": metrics.get("total_usd_source"),
|
| 429 |
+
"usage_app_total_usd": metrics.get("app_total_usd"),
|
| 430 |
+
"usage_hf_billing_total_usd": metrics.get("hf_billing_total_usd"),
|
| 431 |
+
"usage_llm_calls": app.get("llm_calls") if isinstance(app, dict) else None,
|
| 432 |
+
"usage_total_tokens": llm.get("total_tokens")
|
| 433 |
+
if isinstance(llm, dict)
|
| 434 |
+
else None,
|
| 435 |
+
"usage_hf_job_submits": (
|
| 436 |
+
jobs.get("submits") if isinstance(jobs, dict) else None
|
| 437 |
+
),
|
| 438 |
+
"usage_hf_job_status_snapshots": (
|
| 439 |
+
jobs.get("status_snapshots") if isinstance(jobs, dict) else None
|
| 440 |
+
),
|
| 441 |
+
"usage_sandbox_creates": (
|
| 442 |
+
sandboxes.get("creates") if isinstance(sandboxes, dict) else None
|
| 443 |
+
),
|
| 444 |
+
"usage_sandbox_pairs": (
|
| 445 |
+
sandboxes.get("matched_pairs") if isinstance(sandboxes, dict) else None
|
| 446 |
+
),
|
| 447 |
+
}
|
| 448 |
+
return {key: values.get(key) for key in _USAGE_SCALAR_KEYS}
|
backend/main.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""FastAPI application for HF Agent web interface."""
|
| 2 |
|
|
|
|
| 3 |
import logging
|
| 4 |
import os
|
| 5 |
from contextlib import asynccontextmanager
|
|
@@ -24,6 +25,23 @@ logging.basicConfig(
|
|
| 24 |
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 25 |
)
|
| 26 |
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
@asynccontextmanager
|
|
@@ -50,16 +68,16 @@ async def lifespan(app: FastAPI):
|
|
| 50 |
logger.warning("KPI scheduler shutdown failed: %s", e)
|
| 51 |
|
| 52 |
# Final-flush: save every still-active session so we don't lose traces on
|
| 53 |
-
# server restart.
|
|
|
|
| 54 |
try:
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
logger.warning("Failed to flush session %s: %s", sid, e)
|
| 63 |
except Exception as e:
|
| 64 |
logger.warning("Lifespan final-flush skipped: %s", e)
|
| 65 |
await session_manager.close()
|
|
|
|
| 1 |
"""FastAPI application for HF Agent web interface."""
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import logging
|
| 5 |
import os
|
| 6 |
from contextlib import asynccontextmanager
|
|
|
|
| 25 |
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
| 26 |
)
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
+
SHUTDOWN_USAGE_REFRESH_CONCURRENCY = 32
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
async def _flush_session_on_shutdown(sid: str, agent_session, semaphore) -> None:
|
| 32 |
+
sess = agent_session.session
|
| 33 |
+
if not sess.config.save_sessions:
|
| 34 |
+
return
|
| 35 |
+
try:
|
| 36 |
+
async with semaphore:
|
| 37 |
+
await session_manager.refresh_session_usage_metrics(
|
| 38 |
+
agent_session,
|
| 39 |
+
error_code="lifespan_billing_snapshot_error",
|
| 40 |
+
)
|
| 41 |
+
sess.save_and_upload_detached(sess.config.session_dataset_repo)
|
| 42 |
+
logger.info("Flushed session %s on shutdown", sid)
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.warning("Failed to flush session %s: %s", sid, e)
|
| 45 |
|
| 46 |
|
| 47 |
@asynccontextmanager
|
|
|
|
| 68 |
logger.warning("KPI scheduler shutdown failed: %s", e)
|
| 69 |
|
| 70 |
# Final-flush: save every still-active session so we don't lose traces on
|
| 71 |
+
# server restart. Billing refreshes are timeboxed and bounded; uploads are
|
| 72 |
+
# detached subprocesses.
|
| 73 |
try:
|
| 74 |
+
semaphore = asyncio.Semaphore(SHUTDOWN_USAGE_REFRESH_CONCURRENCY)
|
| 75 |
+
await asyncio.gather(
|
| 76 |
+
*(
|
| 77 |
+
_flush_session_on_shutdown(sid, agent_session, semaphore)
|
| 78 |
+
for sid, agent_session in list(session_manager.sessions.items())
|
| 79 |
+
)
|
| 80 |
+
)
|
|
|
|
| 81 |
except Exception as e:
|
| 82 |
logger.warning("Lifespan final-flush skipped: %s", e)
|
| 83 |
await session_manager.close()
|
backend/routes/agent.py
CHANGED
|
@@ -70,7 +70,7 @@ from usage import build_usage_response
|
|
| 70 |
logger = logging.getLogger(__name__)
|
| 71 |
|
| 72 |
router = APIRouter(prefix="/api", tags=["agent"])
|
| 73 |
-
|
| 74 |
|
| 75 |
DEFAULT_OPUS_MODEL_ID = CLAUDE_OPUS_48_MODEL_ID
|
| 76 |
DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID
|
|
@@ -85,6 +85,38 @@ async def _reset_usage_window(session_id: str) -> dict[str, Any] | None:
|
|
| 85 |
)
|
| 86 |
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
def _available_models() -> list[dict[str, Any]]:
|
| 89 |
models = [
|
| 90 |
{
|
|
@@ -759,8 +791,8 @@ async def teardown_session_sandbox(
|
|
| 759 |
"""Best-effort sandbox teardown that preserves durable chat history."""
|
| 760 |
await _check_session_access(session_id, user, preload_sandbox=False)
|
| 761 |
task = asyncio.create_task(session_manager.teardown_sandbox(session_id))
|
| 762 |
-
|
| 763 |
-
task.add_done_callback(
|
| 764 |
return {"status": "teardown_requested", "session_id": session_id}
|
| 765 |
|
| 766 |
|
|
@@ -910,8 +942,9 @@ async def record_pro_click(
|
|
| 910 |
target=str(body.get("target") or "pro_pricing"),
|
| 911 |
)
|
| 912 |
if agent_session.session.config.save_sessions:
|
| 913 |
-
|
| 914 |
-
agent_session
|
|
|
|
| 915 |
)
|
| 916 |
return {"status": "ok"}
|
| 917 |
|
|
@@ -1154,7 +1187,8 @@ async def submit_feedback(
|
|
| 1154 |
# Fire-and-forget save so feedback reaches the dataset even if the user
|
| 1155 |
# closes the tab right after clicking.
|
| 1156 |
if agent_session.session.config.save_sessions:
|
| 1157 |
-
|
| 1158 |
-
agent_session
|
|
|
|
| 1159 |
)
|
| 1160 |
return {"status": "ok"}
|
|
|
|
| 70 |
logger = logging.getLogger(__name__)
|
| 71 |
|
| 72 |
router = APIRouter(prefix="/api", tags=["agent"])
|
| 73 |
+
_background_route_tasks: set[asyncio.Task] = set()
|
| 74 |
|
| 75 |
DEFAULT_OPUS_MODEL_ID = CLAUDE_OPUS_48_MODEL_ID
|
| 76 |
DEFAULT_GPT_MODEL_ID = GPT_55_MODEL_ID
|
|
|
|
| 85 |
)
|
| 86 |
|
| 87 |
|
| 88 |
+
async def _refresh_usage_and_upload(
|
| 89 |
+
agent_session: AgentSession,
|
| 90 |
+
*,
|
| 91 |
+
error_code: str,
|
| 92 |
+
) -> None:
|
| 93 |
+
session = agent_session.session
|
| 94 |
+
try:
|
| 95 |
+
await session_manager.refresh_session_usage_metrics(
|
| 96 |
+
agent_session,
|
| 97 |
+
error_code=error_code,
|
| 98 |
+
)
|
| 99 |
+
session.save_and_upload_detached(session.config.session_dataset_repo)
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.warning(
|
| 102 |
+
"Background usage refresh/upload failed for %s: %s",
|
| 103 |
+
agent_session.session_id,
|
| 104 |
+
e,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _schedule_usage_refresh_and_upload(
|
| 109 |
+
agent_session: AgentSession,
|
| 110 |
+
*,
|
| 111 |
+
error_code: str,
|
| 112 |
+
) -> None:
|
| 113 |
+
task = asyncio.create_task(
|
| 114 |
+
_refresh_usage_and_upload(agent_session, error_code=error_code)
|
| 115 |
+
)
|
| 116 |
+
_background_route_tasks.add(task)
|
| 117 |
+
task.add_done_callback(_background_route_tasks.discard)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
def _available_models() -> list[dict[str, Any]]:
|
| 121 |
models = [
|
| 122 |
{
|
|
|
|
| 791 |
"""Best-effort sandbox teardown that preserves durable chat history."""
|
| 792 |
await _check_session_access(session_id, user, preload_sandbox=False)
|
| 793 |
task = asyncio.create_task(session_manager.teardown_sandbox(session_id))
|
| 794 |
+
_background_route_tasks.add(task)
|
| 795 |
+
task.add_done_callback(_background_route_tasks.discard)
|
| 796 |
return {"status": "teardown_requested", "session_id": session_id}
|
| 797 |
|
| 798 |
|
|
|
|
| 942 |
target=str(body.get("target") or "pro_pricing"),
|
| 943 |
)
|
| 944 |
if agent_session.session.config.save_sessions:
|
| 945 |
+
_schedule_usage_refresh_and_upload(
|
| 946 |
+
agent_session,
|
| 947 |
+
error_code="pro_click_billing_snapshot_error",
|
| 948 |
)
|
| 949 |
return {"status": "ok"}
|
| 950 |
|
|
|
|
| 1187 |
# Fire-and-forget save so feedback reaches the dataset even if the user
|
| 1188 |
# closes the tab right after clicking.
|
| 1189 |
if agent_session.session.config.save_sessions:
|
| 1190 |
+
_schedule_usage_refresh_and_upload(
|
| 1191 |
+
agent_session,
|
| 1192 |
+
error_code="feedback_billing_snapshot_error",
|
| 1193 |
)
|
| 1194 |
return {"status": "ok"}
|
backend/session_manager.py
CHANGED
|
@@ -42,6 +42,7 @@ from agent.messaging.gateway import NotificationGateway
|
|
| 42 |
PROJECT_ROOT = Path(__file__).parent.parent
|
| 43 |
DEFAULT_CONFIG_PATH = str(PROJECT_ROOT / "configs" / "frontend_agent_config.json")
|
| 44 |
USAGE_WARNING_SPEND_CACHE_TTL_SECONDS = 30.0
|
|
|
|
| 45 |
|
| 46 |
|
| 47 |
# These dataclasses match agent/main.py structure
|
|
@@ -567,6 +568,72 @@ class SessionManager:
|
|
| 567 |
}
|
| 568 |
return spend, billing_source
|
| 569 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 570 |
@staticmethod
|
| 571 |
def _runtime_session_usage_spend(agent_session: AgentSession) -> float:
|
| 572 |
from usage import aggregate_usage_events, event_created_at
|
|
@@ -1634,6 +1701,8 @@ class SessionManager:
|
|
| 1634 |
# than the idle window would otherwise be reaped the
|
| 1635 |
# instant it completes.
|
| 1636 |
self._touch(agent_session)
|
|
|
|
|
|
|
| 1637 |
await self.persist_session_snapshot(agent_session)
|
| 1638 |
if not should_continue:
|
| 1639 |
break
|
|
@@ -1662,6 +1731,10 @@ class SessionManager:
|
|
| 1662 |
# Idempotent via session_id key; detached subprocess.
|
| 1663 |
if session.config.save_sessions:
|
| 1664 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1665 |
session.save_and_upload_detached(
|
| 1666 |
session.config.session_dataset_repo
|
| 1667 |
)
|
|
|
|
| 42 |
PROJECT_ROOT = Path(__file__).parent.parent
|
| 43 |
DEFAULT_CONFIG_PATH = str(PROJECT_ROOT / "configs" / "frontend_agent_config.json")
|
| 44 |
USAGE_WARNING_SPEND_CACHE_TTL_SECONDS = 30.0
|
| 45 |
+
USAGE_BILLING_REFRESH_TIMEOUT_SECONDS = 2.0
|
| 46 |
|
| 47 |
|
| 48 |
# These dataclasses match agent/main.py structure
|
|
|
|
| 568 |
}
|
| 569 |
return spend, billing_source
|
| 570 |
|
| 571 |
+
@staticmethod
|
| 572 |
+
def _fallback_hf_billing_snapshot(error: str) -> dict[str, Any]:
|
| 573 |
+
return {
|
| 574 |
+
"billing_scope": "account_window_delta",
|
| 575 |
+
"hf_billing": {
|
| 576 |
+
"source": "hf_billing_usage_v2",
|
| 577 |
+
"available": False,
|
| 578 |
+
"error": error,
|
| 579 |
+
"current_session": None,
|
| 580 |
+
},
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
async def refresh_session_usage_metrics(
|
| 584 |
+
self,
|
| 585 |
+
agent_session: AgentSession,
|
| 586 |
+
*,
|
| 587 |
+
error_code: str = "billing_snapshot_error",
|
| 588 |
+
billing_timeout_s: float | None = USAGE_BILLING_REFRESH_TIMEOUT_SECONDS,
|
| 589 |
+
) -> dict[str, Any]:
|
| 590 |
+
"""Refresh the dataset usage snapshot stored on the runtime session."""
|
| 591 |
+
from agent.core.usage_metrics import (
|
| 592 |
+
normalize_hf_billing_snapshot,
|
| 593 |
+
summarize_usage_events,
|
| 594 |
+
)
|
| 595 |
+
from usage import build_hf_billing_snapshot
|
| 596 |
+
|
| 597 |
+
session = agent_session.session
|
| 598 |
+
try:
|
| 599 |
+
billing_snapshot = build_hf_billing_snapshot(
|
| 600 |
+
self,
|
| 601 |
+
hf_token=agent_session.hf_token or getattr(session, "hf_token", None),
|
| 602 |
+
session_id=agent_session.session_id,
|
| 603 |
+
timezone_name="UTC",
|
| 604 |
+
)
|
| 605 |
+
if billing_timeout_s is not None and billing_timeout_s > 0:
|
| 606 |
+
hf_billing_snapshot = await asyncio.wait_for(
|
| 607 |
+
billing_snapshot,
|
| 608 |
+
timeout=billing_timeout_s,
|
| 609 |
+
)
|
| 610 |
+
else:
|
| 611 |
+
hf_billing_snapshot = await billing_snapshot
|
| 612 |
+
except TimeoutError:
|
| 613 |
+
logger.debug(
|
| 614 |
+
"HF billing snapshot refresh timed out for %s after %.2fs",
|
| 615 |
+
agent_session.session_id,
|
| 616 |
+
billing_timeout_s or 0,
|
| 617 |
+
)
|
| 618 |
+
hf_billing_snapshot = self._fallback_hf_billing_snapshot(error_code)
|
| 619 |
+
except Exception as e:
|
| 620 |
+
logger.debug(
|
| 621 |
+
"HF billing snapshot refresh failed for %s: %s",
|
| 622 |
+
agent_session.session_id,
|
| 623 |
+
e,
|
| 624 |
+
)
|
| 625 |
+
hf_billing_snapshot = self._fallback_hf_billing_snapshot(error_code)
|
| 626 |
+
|
| 627 |
+
hf_billing_snapshot = normalize_hf_billing_snapshot(hf_billing_snapshot)
|
| 628 |
+
session.usage_hf_billing_snapshot = hf_billing_snapshot
|
| 629 |
+
metrics = summarize_usage_events(
|
| 630 |
+
getattr(session, "logged_events", []) or [],
|
| 631 |
+
session_id=agent_session.session_id,
|
| 632 |
+
hf_billing_snapshot=hf_billing_snapshot,
|
| 633 |
+
)
|
| 634 |
+
session.usage_metrics = metrics
|
| 635 |
+
return metrics
|
| 636 |
+
|
| 637 |
@staticmethod
|
| 638 |
def _runtime_session_usage_spend(agent_session: AgentSession) -> float:
|
| 639 |
from usage import aggregate_usage_events, event_created_at
|
|
|
|
| 1701 |
# than the idle window would otherwise be reaped the
|
| 1702 |
# instant it completes.
|
| 1703 |
self._touch(agent_session)
|
| 1704 |
+
if session.config.save_sessions:
|
| 1705 |
+
await self.refresh_session_usage_metrics(agent_session)
|
| 1706 |
await self.persist_session_snapshot(agent_session)
|
| 1707 |
if not should_continue:
|
| 1708 |
break
|
|
|
|
| 1731 |
# Idempotent via session_id key; detached subprocess.
|
| 1732 |
if session.config.save_sessions:
|
| 1733 |
try:
|
| 1734 |
+
await self.refresh_session_usage_metrics(
|
| 1735 |
+
agent_session,
|
| 1736 |
+
error_code="final_billing_snapshot_error",
|
| 1737 |
+
)
|
| 1738 |
session.save_and_upload_detached(
|
| 1739 |
session.config.session_dataset_repo
|
| 1740 |
)
|
backend/usage.py
CHANGED
|
@@ -8,7 +8,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
| 8 |
|
| 9 |
import httpx
|
| 10 |
|
| 11 |
-
from agent.core.
|
| 12 |
|
| 13 |
USAGE_EVENT_TYPES = (
|
| 14 |
"llm_call",
|
|
@@ -239,47 +239,6 @@ def aggregate_usage_events(
|
|
| 239 |
return bucket
|
| 240 |
|
| 241 |
|
| 242 |
-
def _event_sort_key(
|
| 243 |
-
indexed_event: tuple[int, dict[str, Any]],
|
| 244 |
-
) -> tuple[bool, datetime, int]:
|
| 245 |
-
index, event = indexed_event
|
| 246 |
-
created_at = event_created_at(event)
|
| 247 |
-
return (
|
| 248 |
-
created_at is None,
|
| 249 |
-
created_at or datetime.min.replace(tzinfo=UTC),
|
| 250 |
-
index,
|
| 251 |
-
)
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
def _sandbox_id(event: dict[str, Any]) -> str | None:
|
| 255 |
-
data = event.get("data") or {}
|
| 256 |
-
sandbox_id = data.get("sandbox_id")
|
| 257 |
-
return sandbox_id if isinstance(sandbox_id, str) and sandbox_id else None
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
def _sandbox_duration_seconds(
|
| 261 |
-
create_event: dict[str, Any],
|
| 262 |
-
destroy_event: dict[str, Any],
|
| 263 |
-
) -> int:
|
| 264 |
-
create_data = create_event.get("data") or {}
|
| 265 |
-
destroy_data = destroy_event.get("data") or {}
|
| 266 |
-
lifetime_s = _coerce_int(destroy_data.get("lifetime_s"))
|
| 267 |
-
|
| 268 |
-
if lifetime_s > 0:
|
| 269 |
-
# Telemetry starts the lifetime clock before create latency elapses.
|
| 270 |
-
return lifetime_s
|
| 271 |
-
|
| 272 |
-
create_at = event_created_at(create_event)
|
| 273 |
-
destroy_at = event_created_at(destroy_event)
|
| 274 |
-
if create_at is None or destroy_at is None:
|
| 275 |
-
return 0
|
| 276 |
-
create_latency_s = max(0, _coerce_int(create_data.get("create_latency_s")))
|
| 277 |
-
interval_start = create_at - timedelta(seconds=create_latency_s)
|
| 278 |
-
if destroy_at <= interval_start:
|
| 279 |
-
return 0
|
| 280 |
-
return int((destroy_at - interval_start).total_seconds())
|
| 281 |
-
|
| 282 |
-
|
| 283 |
def _aggregate_sandbox_usage(
|
| 284 |
events: list[dict[str, Any]],
|
| 285 |
bucket: dict[str, Any],
|
|
@@ -289,41 +248,10 @@ def _aggregate_sandbox_usage(
|
|
| 289 |
for index, event in enumerate(events)
|
| 290 |
if event.get("event_type") in {"sandbox_create", "sandbox_destroy"}
|
| 291 |
]
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
key=_event_sort_key,
|
| 297 |
-
)
|
| 298 |
-
]
|
| 299 |
-
active_creates: dict[str, dict[str, Any]] = {}
|
| 300 |
-
|
| 301 |
-
for event in ordered_events:
|
| 302 |
-
event_type = event.get("event_type")
|
| 303 |
-
sandbox_id = _sandbox_id(event)
|
| 304 |
-
if sandbox_id is None:
|
| 305 |
-
continue
|
| 306 |
-
|
| 307 |
-
if event_type == "sandbox_create":
|
| 308 |
-
active_creates[sandbox_id] = event
|
| 309 |
-
continue
|
| 310 |
-
|
| 311 |
-
if event_type != "sandbox_destroy":
|
| 312 |
-
continue
|
| 313 |
-
|
| 314 |
-
create_event = active_creates.pop(sandbox_id, None)
|
| 315 |
-
if create_event is None:
|
| 316 |
-
continue
|
| 317 |
-
|
| 318 |
-
create_data = create_event.get("data") or {}
|
| 319 |
-
hardware = str(create_data.get("hardware") or "cpu-basic")
|
| 320 |
-
price_usd_per_hour = SPACE_PRICE_USD_PER_HOUR.get(hardware, 0.0)
|
| 321 |
-
seconds = _sandbox_duration_seconds(create_event, event)
|
| 322 |
-
|
| 323 |
-
bucket["sandbox_count"] += 1
|
| 324 |
-
if price_usd_per_hour > 0:
|
| 325 |
-
bucket["sandbox_billable_seconds_estimate"] += seconds
|
| 326 |
-
bucket["sandbox_estimated_usd"] += price_usd_per_hour * (seconds / 3600)
|
| 327 |
|
| 328 |
|
| 329 |
def _account_bucket_from_billing_usage(
|
|
@@ -628,6 +556,69 @@ async def _build_hf_account_usage(
|
|
| 628 |
return account_usage
|
| 629 |
|
| 630 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
def _event_in_window(
|
| 632 |
event: dict[str, Any],
|
| 633 |
*,
|
|
|
|
| 8 |
|
| 9 |
import httpx
|
| 10 |
|
| 11 |
+
from agent.core.usage_metrics import summarize_sandbox_lifecycle
|
| 12 |
|
| 13 |
USAGE_EVENT_TYPES = (
|
| 14 |
"llm_call",
|
|
|
|
| 239 |
return bucket
|
| 240 |
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
def _aggregate_sandbox_usage(
|
| 243 |
events: list[dict[str, Any]],
|
| 244 |
bucket: dict[str, Any],
|
|
|
|
| 248 |
for index, event in enumerate(events)
|
| 249 |
if event.get("event_type") in {"sandbox_create", "sandbox_destroy"}
|
| 250 |
]
|
| 251 |
+
sandbox = summarize_sandbox_lifecycle(lifecycle_events)
|
| 252 |
+
bucket["sandbox_count"] += sandbox["matched_pairs"]
|
| 253 |
+
bucket["sandbox_billable_seconds_estimate"] += sandbox["billable_seconds_estimate"]
|
| 254 |
+
bucket["sandbox_estimated_usd"] += sandbox["estimated_usd"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
|
| 256 |
|
| 257 |
def _account_bucket_from_billing_usage(
|
|
|
|
| 556 |
return account_usage
|
| 557 |
|
| 558 |
|
| 559 |
+
async def build_hf_billing_snapshot(
|
| 560 |
+
manager: Any,
|
| 561 |
+
*,
|
| 562 |
+
hf_token: str | None,
|
| 563 |
+
session_id: str | None,
|
| 564 |
+
timezone_name: str | None = None,
|
| 565 |
+
now: datetime | None = None,
|
| 566 |
+
) -> dict[str, Any]:
|
| 567 |
+
"""Return a dataset-safe HF billing rollup for the session window.
|
| 568 |
+
|
| 569 |
+
This intentionally omits monthly account totals and credit-limit details.
|
| 570 |
+
The snapshot is an account-window delta, not per-call attribution.
|
| 571 |
+
"""
|
| 572 |
+
windows = resolve_usage_windows(timezone_name, now=now)
|
| 573 |
+
timezone = str(windows["timezone"])
|
| 574 |
+
now_utc = windows["now_utc"]
|
| 575 |
+
snapshot: dict[str, Any] = {
|
| 576 |
+
"billing_scope": "account_window_delta",
|
| 577 |
+
"hf_billing": {
|
| 578 |
+
"source": "hf_billing_usage_v2",
|
| 579 |
+
"available": False,
|
| 580 |
+
"error": None,
|
| 581 |
+
"current_session": None,
|
| 582 |
+
},
|
| 583 |
+
}
|
| 584 |
+
hf_billing = snapshot["hf_billing"]
|
| 585 |
+
|
| 586 |
+
if not hf_token:
|
| 587 |
+
hf_billing["error"] = "missing_hf_token"
|
| 588 |
+
return snapshot
|
| 589 |
+
if not session_id:
|
| 590 |
+
hf_billing["error"] = "missing_session_id"
|
| 591 |
+
return snapshot
|
| 592 |
+
|
| 593 |
+
session_start = _session_usage_window_started_at(manager, session_id)
|
| 594 |
+
if session_start is None:
|
| 595 |
+
session_start, _ = await _load_persisted_session_usage_window_metadata(
|
| 596 |
+
manager,
|
| 597 |
+
session_id,
|
| 598 |
+
)
|
| 599 |
+
if session_start is None:
|
| 600 |
+
hf_billing["error"] = "missing_session_window"
|
| 601 |
+
return snapshot
|
| 602 |
+
|
| 603 |
+
payload = await _fetch_hf_billing_usage_v2(
|
| 604 |
+
hf_token,
|
| 605 |
+
start=session_start,
|
| 606 |
+
end=now_utc,
|
| 607 |
+
)
|
| 608 |
+
if not isinstance(payload, dict):
|
| 609 |
+
hf_billing["error"] = "billing_usage_unavailable"
|
| 610 |
+
return snapshot
|
| 611 |
+
|
| 612 |
+
hf_billing["available"] = True
|
| 613 |
+
hf_billing["current_session"] = _account_bucket_from_billing_usage(
|
| 614 |
+
payload,
|
| 615 |
+
window_start=session_start,
|
| 616 |
+
window_end=now_utc,
|
| 617 |
+
timezone=timezone,
|
| 618 |
+
)
|
| 619 |
+
return snapshot
|
| 620 |
+
|
| 621 |
+
|
| 622 |
def _event_in_window(
|
| 623 |
event: dict[str, Any],
|
| 624 |
*,
|
frontend/src/components/UsageMeter.tsx
CHANGED
|
@@ -120,7 +120,7 @@ function AccountUsageSection({
|
|
| 120 |
strong
|
| 121 |
/>
|
| 122 |
<UsageRow
|
| 123 |
-
label=
|
| 124 |
value={formatUsd(
|
| 125 |
useJobEstimate ? telemetry?.hf_jobs_estimated_usd : account?.hf_jobs_usd,
|
| 126 |
)}
|
|
|
|
| 120 |
strong
|
| 121 |
/>
|
| 122 |
<UsageRow
|
| 123 |
+
label="HF Jobs"
|
| 124 |
value={formatUsd(
|
| 125 |
useJobEstimate ? telemetry?.hf_jobs_estimated_usd : account?.hf_jobs_usd,
|
| 126 |
)}
|
tests/unit/test_session_manager_persistence.py
CHANGED
|
@@ -324,6 +324,183 @@ def test_usage_spend_falls_back_when_hf_total_is_unavailable():
|
|
| 324 |
assert source == "app_telemetry_session"
|
| 325 |
|
| 326 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
@pytest.mark.asyncio
|
| 328 |
async def test_usage_threshold_checker_creates_synthetic_pending_approval(monkeypatch):
|
| 329 |
manager = _manager_with_store(NoopSessionStore())
|
|
|
|
| 324 |
assert source == "app_telemetry_session"
|
| 325 |
|
| 326 |
|
| 327 |
+
@pytest.mark.asyncio
|
| 328 |
+
async def test_refresh_usage_metrics_uses_hf_billing_plus_sandbox(monkeypatch):
|
| 329 |
+
manager = _manager_with_store(NoopSessionStore())
|
| 330 |
+
agent_session = _runtime_agent_session("s1", hf_token="owner-token")
|
| 331 |
+
agent_session.session.logged_events = [
|
| 332 |
+
{
|
| 333 |
+
"timestamp": "2026-06-01T12:00:00+00:00",
|
| 334 |
+
"event_type": "llm_call",
|
| 335 |
+
"data": {"cost_usd": 0.5, "total_tokens": 42},
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"timestamp": "2026-06-01T12:05:00+00:00",
|
| 339 |
+
"event_type": "hf_job_complete",
|
| 340 |
+
"data": {"estimated_cost_usd": 1.0},
|
| 341 |
+
},
|
| 342 |
+
{
|
| 343 |
+
"timestamp": "2026-06-01T12:10:00+00:00",
|
| 344 |
+
"event_type": "sandbox_create",
|
| 345 |
+
"data": {"sandbox_id": "owner/sandbox-1", "hardware": "t4-small"},
|
| 346 |
+
},
|
| 347 |
+
{
|
| 348 |
+
"timestamp": "2026-06-01T12:40:00+00:00",
|
| 349 |
+
"event_type": "sandbox_destroy",
|
| 350 |
+
"data": {"sandbox_id": "owner/sandbox-1", "lifetime_s": 1800},
|
| 351 |
+
},
|
| 352 |
+
]
|
| 353 |
+
|
| 354 |
+
async def fake_billing_snapshot(_manager, *, hf_token, session_id, timezone_name):
|
| 355 |
+
assert hf_token == "owner-token"
|
| 356 |
+
assert session_id == "s1"
|
| 357 |
+
assert timezone_name == "UTC"
|
| 358 |
+
return {
|
| 359 |
+
"billing_scope": "account_window_delta",
|
| 360 |
+
"hf_billing": {
|
| 361 |
+
"source": "hf_billing_usage_v2",
|
| 362 |
+
"available": True,
|
| 363 |
+
"current_session": {
|
| 364 |
+
"window_start": "2026-06-01T12:00:00Z",
|
| 365 |
+
"window_end": "2026-06-01T12:40:00Z",
|
| 366 |
+
"timezone": "UTC",
|
| 367 |
+
"total_usd": 4.0,
|
| 368 |
+
"inference_providers_usd": 3.0,
|
| 369 |
+
"hf_jobs_usd": 1.0,
|
| 370 |
+
"inference_provider_requests": 6,
|
| 371 |
+
"hf_jobs_minutes": 2.0,
|
| 372 |
+
"access_token": "must-not-persist",
|
| 373 |
+
},
|
| 374 |
+
},
|
| 375 |
+
"month": {"total_usd": 999},
|
| 376 |
+
"inference_providers_credits": {"limit_usd": 999},
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
monkeypatch.setattr("usage.build_hf_billing_snapshot", fake_billing_snapshot)
|
| 380 |
+
|
| 381 |
+
metrics = await manager.refresh_session_usage_metrics(agent_session)
|
| 382 |
+
|
| 383 |
+
assert metrics["total_usd"] == 4.3
|
| 384 |
+
assert metrics["total_usd_source"] == "hf_billing_plus_sandbox_estimate"
|
| 385 |
+
assert metrics["app_total_usd"] == 1.8
|
| 386 |
+
assert metrics["hf_billing_total_usd"] == 4.0
|
| 387 |
+
assert agent_session.session.usage_metrics == metrics
|
| 388 |
+
assert agent_session.session.usage_hf_billing_snapshot == {
|
| 389 |
+
"billing_scope": "account_window_delta",
|
| 390 |
+
"hf_billing": {
|
| 391 |
+
"source": "hf_billing_usage_v2",
|
| 392 |
+
"available": True,
|
| 393 |
+
"error": None,
|
| 394 |
+
"current_session": {
|
| 395 |
+
"window_start": "2026-06-01T12:00:00Z",
|
| 396 |
+
"window_end": "2026-06-01T12:40:00Z",
|
| 397 |
+
"timezone": "UTC",
|
| 398 |
+
"total_usd": 4.0,
|
| 399 |
+
"inference_providers_usd": 3.0,
|
| 400 |
+
"hf_jobs_usd": 1.0,
|
| 401 |
+
"inference_provider_requests": 6,
|
| 402 |
+
"hf_jobs_minutes": 2.0,
|
| 403 |
+
},
|
| 404 |
+
},
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
@pytest.mark.asyncio
|
| 409 |
+
async def test_refresh_usage_metrics_missing_token_falls_back_to_app_telemetry():
|
| 410 |
+
manager = _manager_with_store(NoopSessionStore())
|
| 411 |
+
agent_session = _runtime_agent_session("s1", hf_token=None)
|
| 412 |
+
agent_session.session.hf_token = None
|
| 413 |
+
agent_session.session.logged_events = [
|
| 414 |
+
{
|
| 415 |
+
"timestamp": "2026-06-01T12:00:00+00:00",
|
| 416 |
+
"event_type": "llm_call",
|
| 417 |
+
"data": {"cost_usd": 2.0, "total_tokens": 10},
|
| 418 |
+
}
|
| 419 |
+
]
|
| 420 |
+
|
| 421 |
+
metrics = await manager.refresh_session_usage_metrics(agent_session)
|
| 422 |
+
|
| 423 |
+
assert metrics["total_usd"] == 2.0
|
| 424 |
+
assert metrics["total_usd_source"] == "app_telemetry_fallback"
|
| 425 |
+
assert metrics["hf_billing"] == {
|
| 426 |
+
"source": "hf_billing_usage_v2",
|
| 427 |
+
"available": False,
|
| 428 |
+
"error": "missing_hf_token",
|
| 429 |
+
"current_session": None,
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
|
| 433 |
+
@pytest.mark.asyncio
|
| 434 |
+
async def test_refresh_usage_metrics_failure_records_error_code(monkeypatch):
|
| 435 |
+
manager = _manager_with_store(NoopSessionStore())
|
| 436 |
+
agent_session = _runtime_agent_session("s1", hf_token="owner-token")
|
| 437 |
+
agent_session.session.logged_events = [
|
| 438 |
+
{
|
| 439 |
+
"timestamp": "2026-06-01T12:00:00+00:00",
|
| 440 |
+
"event_type": "llm_call",
|
| 441 |
+
"data": {"cost_usd": 2.0, "total_tokens": 10},
|
| 442 |
+
}
|
| 443 |
+
]
|
| 444 |
+
|
| 445 |
+
async def fail_billing_snapshot(*_args, **_kwargs):
|
| 446 |
+
raise RuntimeError("boom")
|
| 447 |
+
|
| 448 |
+
monkeypatch.setattr("usage.build_hf_billing_snapshot", fail_billing_snapshot)
|
| 449 |
+
|
| 450 |
+
metrics = await manager.refresh_session_usage_metrics(
|
| 451 |
+
agent_session,
|
| 452 |
+
error_code="unit_billing_error",
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
assert metrics["total_usd"] == 2.0
|
| 456 |
+
assert metrics["total_usd_source"] == "app_telemetry_fallback"
|
| 457 |
+
assert metrics["hf_billing"] == {
|
| 458 |
+
"source": "hf_billing_usage_v2",
|
| 459 |
+
"available": False,
|
| 460 |
+
"error": "unit_billing_error",
|
| 461 |
+
"current_session": None,
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
|
| 465 |
+
@pytest.mark.asyncio
|
| 466 |
+
async def test_refresh_usage_metrics_timeout_records_error_code(monkeypatch):
|
| 467 |
+
manager = _manager_with_store(NoopSessionStore())
|
| 468 |
+
agent_session = _runtime_agent_session("s1", hf_token="owner-token")
|
| 469 |
+
agent_session.session.logged_events = [
|
| 470 |
+
{
|
| 471 |
+
"timestamp": "2026-06-01T12:00:00+00:00",
|
| 472 |
+
"event_type": "llm_call",
|
| 473 |
+
"data": {"cost_usd": 2.0, "total_tokens": 10},
|
| 474 |
+
}
|
| 475 |
+
]
|
| 476 |
+
|
| 477 |
+
async def slow_billing_snapshot(*_args, **_kwargs):
|
| 478 |
+
await asyncio.sleep(0.05)
|
| 479 |
+
return {
|
| 480 |
+
"hf_billing": {
|
| 481 |
+
"available": True,
|
| 482 |
+
"current_session": {"total_usd": 999},
|
| 483 |
+
}
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
monkeypatch.setattr("usage.build_hf_billing_snapshot", slow_billing_snapshot)
|
| 487 |
+
|
| 488 |
+
metrics = await manager.refresh_session_usage_metrics(
|
| 489 |
+
agent_session,
|
| 490 |
+
error_code="unit_billing_timeout",
|
| 491 |
+
billing_timeout_s=0.001,
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
assert metrics["total_usd"] == 2.0
|
| 495 |
+
assert metrics["total_usd_source"] == "app_telemetry_fallback"
|
| 496 |
+
assert metrics["hf_billing"] == {
|
| 497 |
+
"source": "hf_billing_usage_v2",
|
| 498 |
+
"available": False,
|
| 499 |
+
"error": "unit_billing_timeout",
|
| 500 |
+
"current_session": None,
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
|
| 504 |
@pytest.mark.asyncio
|
| 505 |
async def test_usage_threshold_checker_creates_synthetic_pending_approval(monkeypatch):
|
| 506 |
manager = _manager_with_store(NoopSessionStore())
|
tests/unit/test_session_uploader.py
CHANGED
|
@@ -158,6 +158,67 @@ def test_row_payload_scrubs_messages_events_and_tools(tmp_path):
|
|
| 158 |
assert "GITHUB_TOKEN=[REDACTED]" in payload
|
| 159 |
|
| 160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
def test_claude_code_payload_scrubs_messages_before_conversion(tmp_path):
|
| 162 |
tmp_file = tmp_path / "claude_code.jsonl"
|
| 163 |
data = {
|
|
@@ -202,3 +263,40 @@ def test_claude_code_payload_scrubs_messages_before_conversion(tmp_path):
|
|
| 202 |
assert "[REDACTED_HF_TOKEN]" in payload
|
| 203 |
assert "[REDACTED_PROVIDER_API_KEY]" in payload
|
| 204 |
assert "GITHUB_TOKEN=[REDACTED]" in payload
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
assert "GITHUB_TOKEN=[REDACTED]" in payload
|
| 159 |
|
| 160 |
|
| 161 |
+
def test_row_payload_includes_usage_scalars_and_parseable_metrics(tmp_path):
|
| 162 |
+
tmp_file = tmp_path / "row.jsonl"
|
| 163 |
+
data = {
|
| 164 |
+
"session_id": "session-123",
|
| 165 |
+
"user_id": "lewtun",
|
| 166 |
+
"session_start_time": "2026-01-01T00:00:00",
|
| 167 |
+
"session_end_time": "2026-01-01T00:30:00",
|
| 168 |
+
"model_name": "anthropic/claude-opus-4.8:fal-ai",
|
| 169 |
+
"total_cost_usd": 0.01,
|
| 170 |
+
"messages": [{"role": "user", "content": "hello"}],
|
| 171 |
+
"events": [
|
| 172 |
+
{
|
| 173 |
+
"timestamp": "2026-01-01T00:00:01+00:00",
|
| 174 |
+
"event_type": "llm_call",
|
| 175 |
+
"data": {"cost_usd": 0.01, "total_tokens": 42},
|
| 176 |
+
},
|
| 177 |
+
{
|
| 178 |
+
"timestamp": "2026-01-01T00:00:02+00:00",
|
| 179 |
+
"event_type": "hf_job_submit",
|
| 180 |
+
"data": {"flavor": "cpu-basic"},
|
| 181 |
+
},
|
| 182 |
+
{
|
| 183 |
+
"timestamp": "2026-01-01T00:00:03+00:00",
|
| 184 |
+
"event_type": "turn_complete",
|
| 185 |
+
"data": {},
|
| 186 |
+
},
|
| 187 |
+
],
|
| 188 |
+
"tools": [{"name": "bash"}],
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
_write_row_payload(data, str(tmp_file))
|
| 192 |
+
|
| 193 |
+
row = json.loads(tmp_file.read_text())
|
| 194 |
+
assert row["session_id"] == "session-123"
|
| 195 |
+
assert row["user_id"] == "lewtun"
|
| 196 |
+
assert row["session_start_time"] == "2026-01-01T00:00:00"
|
| 197 |
+
assert row["session_end_time"] == "2026-01-01T00:30:00"
|
| 198 |
+
assert row["model_name"] == "anthropic/claude-opus-4.8:fal-ai"
|
| 199 |
+
assert row["total_cost_usd"] == 0.01
|
| 200 |
+
assert json.loads(row["messages"]) == data["messages"]
|
| 201 |
+
assert json.loads(row["events"]) == data["events"]
|
| 202 |
+
assert json.loads(row["tools"]) == data["tools"]
|
| 203 |
+
|
| 204 |
+
metrics = json.loads(row["usage_metrics"])
|
| 205 |
+
assert metrics["version"] == 1
|
| 206 |
+
assert metrics["llm"]["calls"] == 1
|
| 207 |
+
assert metrics["llm"]["total_tokens"] == 42
|
| 208 |
+
assert metrics["hf_jobs"]["submits"] == 1
|
| 209 |
+
assert metrics["turns"]["turn_complete_count"] == 1
|
| 210 |
+
assert row["usage_total_usd"] == 0.01
|
| 211 |
+
assert row["usage_total_usd_source"] == "app_telemetry_fallback"
|
| 212 |
+
assert row["usage_app_total_usd"] == 0.01
|
| 213 |
+
assert row["usage_hf_billing_total_usd"] is None
|
| 214 |
+
assert row["usage_llm_calls"] == 1
|
| 215 |
+
assert row["usage_total_tokens"] == 42
|
| 216 |
+
assert row["usage_hf_job_submits"] == 1
|
| 217 |
+
assert row["usage_hf_job_status_snapshots"] == 0
|
| 218 |
+
assert row["usage_sandbox_creates"] == 0
|
| 219 |
+
assert row["usage_sandbox_pairs"] == 0
|
| 220 |
+
|
| 221 |
+
|
| 222 |
def test_claude_code_payload_scrubs_messages_before_conversion(tmp_path):
|
| 223 |
tmp_file = tmp_path / "claude_code.jsonl"
|
| 224 |
data = {
|
|
|
|
| 263 |
assert "[REDACTED_HF_TOKEN]" in payload
|
| 264 |
assert "[REDACTED_PROVIDER_API_KEY]" in payload
|
| 265 |
assert "GITHUB_TOKEN=[REDACTED]" in payload
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
def test_claude_code_payload_ignores_usage_metrics(tmp_path):
|
| 269 |
+
base = {
|
| 270 |
+
"session_id": "session-123",
|
| 271 |
+
"model_name": "anthropic/claude-opus-4.8:fal-ai",
|
| 272 |
+
"session_start_time": "2026-01-01T00:00:00",
|
| 273 |
+
"messages": [
|
| 274 |
+
{
|
| 275 |
+
"role": "user",
|
| 276 |
+
"content": "hello",
|
| 277 |
+
"timestamp": "2026-01-01T00:00:01",
|
| 278 |
+
},
|
| 279 |
+
{
|
| 280 |
+
"role": "assistant",
|
| 281 |
+
"content": "hi",
|
| 282 |
+
"timestamp": "2026-01-01T00:00:02",
|
| 283 |
+
},
|
| 284 |
+
],
|
| 285 |
+
}
|
| 286 |
+
without_metrics = tmp_path / "without.jsonl"
|
| 287 |
+
with_metrics = tmp_path / "with.jsonl"
|
| 288 |
+
|
| 289 |
+
_write_claude_code_payload(base, str(without_metrics))
|
| 290 |
+
_write_claude_code_payload(
|
| 291 |
+
{
|
| 292 |
+
**base,
|
| 293 |
+
"usage_metrics": {
|
| 294 |
+
"version": 1,
|
| 295 |
+
"total_usd": 123,
|
| 296 |
+
"hf_billing": {"available": True},
|
| 297 |
+
},
|
| 298 |
+
},
|
| 299 |
+
str(with_metrics),
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
assert with_metrics.read_text() == without_metrics.read_text()
|
tests/unit/test_usage.py
CHANGED
|
@@ -14,10 +14,15 @@ from usage import ( # noqa: E402
|
|
| 14 |
_account_bucket_from_billing_usage,
|
| 15 |
_session_bucket_from_inference_session_usage,
|
| 16 |
aggregate_usage_events,
|
|
|
|
| 17 |
build_usage_response,
|
| 18 |
resolve_usage_windows,
|
| 19 |
)
|
| 20 |
from agent.core import session_persistence # noqa: E402
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
BILLING_SESSION_ID = "00000000-0000-4000-8000-000000000001"
|
| 23 |
|
|
@@ -173,6 +178,42 @@ def test_aggregate_usage_events_falls_back_to_sandbox_timestamps():
|
|
| 173 |
assert usage["total_usd"] == 0.3
|
| 174 |
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
def test_usage_event_type_allowlists_include_sandbox_lifecycle():
|
| 177 |
assert set(USAGE_EVENT_TYPES) >= {"sandbox_create", "sandbox_destroy"}
|
| 178 |
assert set(session_persistence.USAGE_EVENT_TYPES) >= {
|
|
@@ -181,6 +222,194 @@ def test_usage_event_type_allowlists_include_sandbox_lifecycle():
|
|
| 181 |
}
|
| 182 |
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
def test_account_bucket_from_hf_billing_usage_v2():
|
| 185 |
usage = _account_bucket_from_billing_usage(
|
| 186 |
{
|
|
@@ -317,6 +546,69 @@ def _agent_session(session_id, user_id, events):
|
|
| 317 |
)
|
| 318 |
|
| 319 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 320 |
@pytest.mark.asyncio
|
| 321 |
async def test_usage_response_omits_app_rollups_without_session():
|
| 322 |
manager = _Manager(
|
|
|
|
| 14 |
_account_bucket_from_billing_usage,
|
| 15 |
_session_bucket_from_inference_session_usage,
|
| 16 |
aggregate_usage_events,
|
| 17 |
+
build_hf_billing_snapshot,
|
| 18 |
build_usage_response,
|
| 19 |
resolve_usage_windows,
|
| 20 |
)
|
| 21 |
from agent.core import session_persistence # noqa: E402
|
| 22 |
+
from agent.core.usage_metrics import ( # noqa: E402
|
| 23 |
+
summarize_usage_events,
|
| 24 |
+
usage_metric_scalar_fields,
|
| 25 |
+
)
|
| 26 |
|
| 27 |
BILLING_SESSION_ID = "00000000-0000-4000-8000-000000000001"
|
| 28 |
|
|
|
|
| 178 |
assert usage["total_usd"] == 0.3
|
| 179 |
|
| 180 |
|
| 181 |
+
def test_sandbox_lifecycle_pairing_is_shared_for_duplicate_creates():
|
| 182 |
+
events = [
|
| 183 |
+
_event(
|
| 184 |
+
"sandbox_create",
|
| 185 |
+
{"sandbox_id": "alice/sandbox-reused", "hardware": "t4-small"},
|
| 186 |
+
created_at="2026-06-01T12:00:00+00:00",
|
| 187 |
+
),
|
| 188 |
+
_event(
|
| 189 |
+
"sandbox_create",
|
| 190 |
+
{"sandbox_id": "alice/sandbox-reused", "hardware": "cpu-basic"},
|
| 191 |
+
created_at="2026-06-01T12:05:00+00:00",
|
| 192 |
+
),
|
| 193 |
+
_event(
|
| 194 |
+
"sandbox_destroy",
|
| 195 |
+
{"sandbox_id": "alice/sandbox-reused", "lifetime_s": 300},
|
| 196 |
+
created_at="2026-06-01T12:10:00+00:00",
|
| 197 |
+
),
|
| 198 |
+
_event(
|
| 199 |
+
"sandbox_destroy",
|
| 200 |
+
{"sandbox_id": "alice/sandbox-reused", "lifetime_s": 1200},
|
| 201 |
+
created_at="2026-06-01T12:20:00+00:00",
|
| 202 |
+
),
|
| 203 |
+
]
|
| 204 |
+
|
| 205 |
+
usage = aggregate_usage_events(events, session_id="s1")
|
| 206 |
+
metrics = summarize_usage_events(events, session_id="s1")
|
| 207 |
+
|
| 208 |
+
assert usage["sandbox_count"] == 2
|
| 209 |
+
assert usage["sandbox_billable_seconds_estimate"] == 1200
|
| 210 |
+
assert usage["sandbox_estimated_usd"] == 0.2
|
| 211 |
+
assert metrics["sandboxes"]["matched_pairs"] == usage["sandbox_count"]
|
| 212 |
+
assert metrics["sandboxes"]["unpaired_creates"] == 0
|
| 213 |
+
assert metrics["sandboxes"]["unpaired_destroys"] == 0
|
| 214 |
+
assert metrics["sandboxes"]["estimated_usd"] == usage["sandbox_estimated_usd"]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
def test_usage_event_type_allowlists_include_sandbox_lifecycle():
|
| 218 |
assert set(USAGE_EVENT_TYPES) >= {"sandbox_create", "sandbox_destroy"}
|
| 219 |
assert set(session_persistence.USAGE_EVENT_TYPES) >= {
|
|
|
|
| 222 |
}
|
| 223 |
|
| 224 |
|
| 225 |
+
def test_summarize_usage_events_aggregates_dataset_analytics():
|
| 226 |
+
events = [
|
| 227 |
+
_event(
|
| 228 |
+
"llm_call",
|
| 229 |
+
{
|
| 230 |
+
"model": "model-a",
|
| 231 |
+
"kind": "main",
|
| 232 |
+
"cost_usd": 0,
|
| 233 |
+
"prompt_tokens": 10,
|
| 234 |
+
"completion_tokens": 5,
|
| 235 |
+
"cache_read_tokens": 2,
|
| 236 |
+
},
|
| 237 |
+
),
|
| 238 |
+
_event(
|
| 239 |
+
"llm_call",
|
| 240 |
+
{
|
| 241 |
+
"model": "model-b",
|
| 242 |
+
"kind": "research",
|
| 243 |
+
"cost_usd": 0.125,
|
| 244 |
+
"prompt_tokens": 20,
|
| 245 |
+
"completion_tokens": 10,
|
| 246 |
+
"cache_creation_tokens": 3,
|
| 247 |
+
"total_tokens": 40,
|
| 248 |
+
},
|
| 249 |
+
),
|
| 250 |
+
_event("hf_job_submit", {"flavor": "a10g-small"}),
|
| 251 |
+
_event(
|
| 252 |
+
"hf_job_complete",
|
| 253 |
+
{
|
| 254 |
+
"flavor": "a10g-small",
|
| 255 |
+
"final_status": "succeeded",
|
| 256 |
+
"estimated_cost_usd": 0.5,
|
| 257 |
+
"billable_seconds_estimate": 600,
|
| 258 |
+
},
|
| 259 |
+
),
|
| 260 |
+
_event(
|
| 261 |
+
"hf_job_complete",
|
| 262 |
+
{
|
| 263 |
+
"flavor": "cpu-basic",
|
| 264 |
+
"final_status": "failed",
|
| 265 |
+
"wall_time_s": 30,
|
| 266 |
+
},
|
| 267 |
+
),
|
| 268 |
+
_event(
|
| 269 |
+
"sandbox_create",
|
| 270 |
+
{"sandbox_id": "alice/sandbox-1", "hardware": "t4-small"},
|
| 271 |
+
created_at="2026-06-01T12:00:00+00:00",
|
| 272 |
+
),
|
| 273 |
+
_event(
|
| 274 |
+
"sandbox_destroy",
|
| 275 |
+
{"sandbox_id": "alice/sandbox-1", "lifetime_s": 1800},
|
| 276 |
+
created_at="2026-06-01T12:30:00+00:00",
|
| 277 |
+
),
|
| 278 |
+
_event(
|
| 279 |
+
"sandbox_create",
|
| 280 |
+
{"sandbox_id": "alice/sandbox-2", "hardware": "a100-large"},
|
| 281 |
+
created_at="2026-06-01T13:00:00+00:00",
|
| 282 |
+
),
|
| 283 |
+
_event(
|
| 284 |
+
"sandbox_destroy",
|
| 285 |
+
{"sandbox_id": "alice/sandbox-missing", "lifetime_s": 60},
|
| 286 |
+
created_at="2026-06-01T13:05:00+00:00",
|
| 287 |
+
),
|
| 288 |
+
_event("turn_complete"),
|
| 289 |
+
_event("assistant_stream_end"),
|
| 290 |
+
{"event_type": "debug", "data": {}},
|
| 291 |
+
]
|
| 292 |
+
|
| 293 |
+
metrics = summarize_usage_events(events, session_id="s1")
|
| 294 |
+
|
| 295 |
+
assert metrics["version"] == 1
|
| 296 |
+
assert metrics["total_usd_source"] == "app_telemetry_fallback"
|
| 297 |
+
assert metrics["total_usd"] == 0.925
|
| 298 |
+
assert metrics["llm"] == {
|
| 299 |
+
"calls": 2,
|
| 300 |
+
"calls_by_kind": {"main": 1, "research": 1},
|
| 301 |
+
"calls_by_model": {"model-a": 1, "model-b": 1},
|
| 302 |
+
"prompt_tokens": 30,
|
| 303 |
+
"completion_tokens": 15,
|
| 304 |
+
"cache_read_tokens": 2,
|
| 305 |
+
"cache_creation_tokens": 3,
|
| 306 |
+
"total_tokens": 57,
|
| 307 |
+
}
|
| 308 |
+
assert metrics["turns"] == {
|
| 309 |
+
"turn_complete_count": 1,
|
| 310 |
+
"assistant_stream_end_count": 1,
|
| 311 |
+
}
|
| 312 |
+
assert metrics["hf_jobs"]["submits"] == 1
|
| 313 |
+
assert metrics["hf_jobs"]["status_snapshots"] == 2
|
| 314 |
+
assert metrics["hf_jobs"]["statuses"] == {"failed": 1, "succeeded": 1}
|
| 315 |
+
assert metrics["hf_jobs"]["flavors"] == {
|
| 316 |
+
"a10g-small": 2,
|
| 317 |
+
"cpu-basic": 1,
|
| 318 |
+
}
|
| 319 |
+
assert metrics["hf_jobs"]["estimated_usd"] == 0.5
|
| 320 |
+
assert metrics["hf_jobs"]["billable_seconds_estimate"] == 630
|
| 321 |
+
assert metrics["sandboxes"]["creates"] == 2
|
| 322 |
+
assert metrics["sandboxes"]["destroys"] == 2
|
| 323 |
+
assert metrics["sandboxes"]["matched_pairs"] == 1
|
| 324 |
+
assert metrics["sandboxes"]["unpaired_creates"] == 1
|
| 325 |
+
assert metrics["sandboxes"]["unpaired_destroys"] == 1
|
| 326 |
+
assert metrics["sandboxes"]["hardware"] == {"a100-large": 1, "t4-small": 1}
|
| 327 |
+
assert metrics["sandboxes"]["estimated_usd"] == 0.3
|
| 328 |
+
assert metrics["sandboxes"]["billable_seconds_estimate"] == 1800
|
| 329 |
+
assert metrics["data_quality"] == {
|
| 330 |
+
"event_count": 12,
|
| 331 |
+
"events_without_timestamp": 1,
|
| 332 |
+
"llm_calls_with_cost_usd": 2,
|
| 333 |
+
"llm_calls_with_nonzero_cost_usd": 1,
|
| 334 |
+
"job_snapshots_with_estimated_cost": 1,
|
| 335 |
+
"job_snapshots_missing_estimated_cost": 1,
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
assert usage_metric_scalar_fields(metrics) == {
|
| 339 |
+
"usage_total_usd": 0.925,
|
| 340 |
+
"usage_total_usd_source": "app_telemetry_fallback",
|
| 341 |
+
"usage_app_total_usd": 0.925,
|
| 342 |
+
"usage_hf_billing_total_usd": None,
|
| 343 |
+
"usage_llm_calls": 2,
|
| 344 |
+
"usage_total_tokens": 57,
|
| 345 |
+
"usage_hf_job_submits": 1,
|
| 346 |
+
"usage_hf_job_status_snapshots": 2,
|
| 347 |
+
"usage_sandbox_creates": 2,
|
| 348 |
+
"usage_sandbox_pairs": 1,
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def test_summarize_usage_events_uses_hf_billing_plus_sandbox_when_available():
|
| 353 |
+
events = [
|
| 354 |
+
_event("llm_call", {"cost_usd": 99.0, "total_tokens": 10}),
|
| 355 |
+
_event("hf_job_complete", {"estimated_cost_usd": 99.0}),
|
| 356 |
+
_event(
|
| 357 |
+
"sandbox_create",
|
| 358 |
+
{"sandbox_id": "alice/sandbox-1", "hardware": "t4-small"},
|
| 359 |
+
created_at="2026-06-01T12:00:00+00:00",
|
| 360 |
+
),
|
| 361 |
+
_event(
|
| 362 |
+
"sandbox_destroy",
|
| 363 |
+
{"sandbox_id": "alice/sandbox-1", "lifetime_s": 1800},
|
| 364 |
+
created_at="2026-06-01T12:30:00+00:00",
|
| 365 |
+
),
|
| 366 |
+
]
|
| 367 |
+
|
| 368 |
+
metrics = summarize_usage_events(
|
| 369 |
+
events,
|
| 370 |
+
session_id="s1",
|
| 371 |
+
hf_billing_snapshot={
|
| 372 |
+
"hf_billing": {
|
| 373 |
+
"source": "hf_billing_usage_v2",
|
| 374 |
+
"available": True,
|
| 375 |
+
"current_session": {
|
| 376 |
+
"window_start": "2026-06-01T12:00:00Z",
|
| 377 |
+
"window_end": "2026-06-01T12:30:00Z",
|
| 378 |
+
"timezone": "UTC",
|
| 379 |
+
"total_usd": 1.25,
|
| 380 |
+
"inference_providers_usd": 1.0,
|
| 381 |
+
"hf_jobs_usd": 0.25,
|
| 382 |
+
"inference_provider_requests": 3,
|
| 383 |
+
"hf_jobs_minutes": 1.5,
|
| 384 |
+
"unexpected": "dropped",
|
| 385 |
+
},
|
| 386 |
+
},
|
| 387 |
+
"month": {"total_usd": 999},
|
| 388 |
+
"inference_providers_credits": {"limit_usd": 999},
|
| 389 |
+
},
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
assert metrics["total_usd"] == 1.55
|
| 393 |
+
assert metrics["total_usd_source"] == "hf_billing_plus_sandbox_estimate"
|
| 394 |
+
assert metrics["app_total_usd"] == 198.3
|
| 395 |
+
assert metrics["hf_billing_total_usd"] == 1.25
|
| 396 |
+
assert metrics["hf_billing"] == {
|
| 397 |
+
"source": "hf_billing_usage_v2",
|
| 398 |
+
"available": True,
|
| 399 |
+
"error": None,
|
| 400 |
+
"current_session": {
|
| 401 |
+
"window_start": "2026-06-01T12:00:00Z",
|
| 402 |
+
"window_end": "2026-06-01T12:30:00Z",
|
| 403 |
+
"timezone": "UTC",
|
| 404 |
+
"total_usd": 1.25,
|
| 405 |
+
"inference_providers_usd": 1.0,
|
| 406 |
+
"hf_jobs_usd": 0.25,
|
| 407 |
+
"inference_provider_requests": 3,
|
| 408 |
+
"hf_jobs_minutes": 1.5,
|
| 409 |
+
},
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
|
| 413 |
def test_account_bucket_from_hf_billing_usage_v2():
|
| 414 |
usage = _account_bucket_from_billing_usage(
|
| 415 |
{
|
|
|
|
| 546 |
)
|
| 547 |
|
| 548 |
|
| 549 |
+
@pytest.mark.asyncio
|
| 550 |
+
async def test_hf_billing_snapshot_uses_session_window_without_account_totals(
|
| 551 |
+
monkeypatch,
|
| 552 |
+
):
|
| 553 |
+
usage_window_started_at = datetime(2026, 6, 5, 12, 30, tzinfo=UTC)
|
| 554 |
+
manager = _Manager(
|
| 555 |
+
{
|
| 556 |
+
"s1": SimpleNamespace(
|
| 557 |
+
session_id="s1",
|
| 558 |
+
user_id="owner",
|
| 559 |
+
created_at=datetime(2026, 6, 5, 12, 0, tzinfo=UTC),
|
| 560 |
+
usage_window_started_at=usage_window_started_at,
|
| 561 |
+
session=SimpleNamespace(logged_events=[]),
|
| 562 |
+
)
|
| 563 |
+
}
|
| 564 |
+
)
|
| 565 |
+
calls = []
|
| 566 |
+
|
| 567 |
+
async def fake_usage_v2(_token, *, start, end):
|
| 568 |
+
calls.append((start, end))
|
| 569 |
+
return {
|
| 570 |
+
"usage": {
|
| 571 |
+
"inferenceProviders": {
|
| 572 |
+
"usedNanoUsd": 1_500_000_000,
|
| 573 |
+
"includedNanoUsd": 2_000_000_000,
|
| 574 |
+
"limitNanoUsd": 5_000_000_000,
|
| 575 |
+
"numRequests": 4,
|
| 576 |
+
},
|
| 577 |
+
"jobs": {"usedMicroUsd": 250_000, "totalMinutes": 3.5},
|
| 578 |
+
}
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
monkeypatch.setattr("usage._fetch_hf_billing_usage_v2", fake_usage_v2)
|
| 582 |
+
|
| 583 |
+
snapshot = await build_hf_billing_snapshot(
|
| 584 |
+
manager,
|
| 585 |
+
hf_token="hf_fake",
|
| 586 |
+
session_id="s1",
|
| 587 |
+
timezone_name="UTC",
|
| 588 |
+
now=datetime(2026, 6, 5, 13, 0, tzinfo=UTC),
|
| 589 |
+
)
|
| 590 |
+
|
| 591 |
+
assert calls == [(usage_window_started_at, datetime(2026, 6, 5, 13, 0, tzinfo=UTC))]
|
| 592 |
+
assert snapshot == {
|
| 593 |
+
"billing_scope": "account_window_delta",
|
| 594 |
+
"hf_billing": {
|
| 595 |
+
"source": "hf_billing_usage_v2",
|
| 596 |
+
"available": True,
|
| 597 |
+
"error": None,
|
| 598 |
+
"current_session": {
|
| 599 |
+
"window_start": "2026-06-05T12:30:00Z",
|
| 600 |
+
"window_end": "2026-06-05T13:00:00Z",
|
| 601 |
+
"timezone": "UTC",
|
| 602 |
+
"total_usd": 1.75,
|
| 603 |
+
"inference_providers_usd": 1.5,
|
| 604 |
+
"hf_jobs_usd": 0.25,
|
| 605 |
+
"inference_provider_requests": 4,
|
| 606 |
+
"hf_jobs_minutes": 3.5,
|
| 607 |
+
},
|
| 608 |
+
},
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
|
| 612 |
@pytest.mark.asyncio
|
| 613 |
async def test_usage_response_omits_app_rollups_without_session():
|
| 614 |
manager = _Manager(
|