lewtun HF Staff OpenAI Codex commited on
Commit
7d506e1
·
unverified ·
1 Parent(s): 02a90d2

Add app-attributed usage display (#292)

Browse files

* Add app-attributed usage display

Co-authored-by: OpenAI Codex <codex@openai.com>

* Fix usage windows for local timestamps

Co-authored-by: OpenAI Codex <codex@openai.com>

* Add HF billing usage to usage display

Co-authored-by: OpenAI Codex <codex@openai.com>

* Simplify usage popover

Co-authored-by: OpenAI Codex <codex@openai.com>

* Remove token row from usage popover

Co-authored-by: OpenAI Codex <codex@openai.com>

* Move session billing note to usage header

Co-authored-by: OpenAI Codex <codex@openai.com>

* Optimize usage popover rollup loading

Co-authored-by: OpenAI Codex <codex@openai.com>

* Round usage costs to cents

Co-authored-by: OpenAI Codex <codex@openai.com>

---------

Co-authored-by: OpenAI Codex <codex@openai.com>

agent/core/session.py CHANGED
@@ -137,7 +137,7 @@ class Session:
137
 
138
  # Session trajectory logging
139
  self.logged_events: list[dict] = []
140
- self.session_start_time = datetime.now().isoformat()
141
  self.turn_count: int = 0
142
  self.last_auto_save_turn: int = 0
143
  # Stable local save path so heartbeat saves overwrite one file instead
@@ -162,7 +162,7 @@ class Session:
162
  # Log event to trajectory
163
  self.logged_events.append(
164
  {
165
- "timestamp": datetime.now().isoformat(),
166
  "event_type": event.event_type,
167
  "data": event.data,
168
  }
@@ -412,7 +412,7 @@ class Session:
412
  self.context_manager.running_context_usage = 0
413
 
414
  self.session_id = str(uuid.uuid4())
415
- self.session_start_time = datetime.now().isoformat()
416
  self.turn_count = 0
417
  self.last_auto_save_turn = 0
418
  self.logged_events = []
 
137
 
138
  # Session trajectory logging
139
  self.logged_events: list[dict] = []
140
+ self.session_start_time = datetime.now().astimezone().isoformat()
141
  self.turn_count: int = 0
142
  self.last_auto_save_turn: int = 0
143
  # Stable local save path so heartbeat saves overwrite one file instead
 
162
  # Log event to trajectory
163
  self.logged_events.append(
164
  {
165
+ "timestamp": datetime.now().astimezone().isoformat(),
166
  "event_type": event.event_type,
167
  "data": event.data,
168
  }
 
412
  self.context_manager.running_context_usage = 0
413
 
414
  self.session_id = str(uuid.uuid4())
415
+ self.session_start_time = datetime.now().astimezone().isoformat()
416
  self.turn_count = 0
417
  self.last_auto_save_turn = 0
418
  self.logged_events = []
agent/core/session_persistence.py CHANGED
@@ -20,6 +20,7 @@ logger = logging.getLogger(__name__)
20
 
21
  SCHEMA_VERSION = 1
22
  MAX_BSON_BYTES = 15 * 1024 * 1024
 
23
 
24
 
25
  def _now() -> datetime:
@@ -86,6 +87,9 @@ class NoopSessionStore:
86
  async def load_events_after(self, *_: Any, **__: Any) -> list[dict[str, Any]]:
87
  return []
88
 
 
 
 
89
  async def append_trace_message(self, *_: Any, **__: Any) -> int | None:
90
  return None
91
 
@@ -142,6 +146,9 @@ class MongoSessionStore(NoopSessionStore):
142
  await self.db.session_events.create_index(
143
  [("session_id", 1), ("seq", 1)], unique=True
144
  )
 
 
 
145
  await self.db.session_trace_messages.create_index(
146
  [("session_id", 1), ("seq", 1)], unique=True
147
  )
@@ -365,6 +372,42 @@ class MongoSessionStore(NoopSessionStore):
365
  ).sort("seq", 1)
366
  return [row async for row in cursor]
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  async def append_trace_message(
369
  self, session_id: str, message: dict[str, Any], source: str = "message"
370
  ) -> int | None:
 
20
 
21
  SCHEMA_VERSION = 1
22
  MAX_BSON_BYTES = 15 * 1024 * 1024
23
+ USAGE_EVENT_TYPES = ("llm_call", "hf_job_complete")
24
 
25
 
26
  def _now() -> datetime:
 
87
  async def load_events_after(self, *_: Any, **__: Any) -> list[dict[str, Any]]:
88
  return []
89
 
90
+ async def load_usage_events(self, *_: Any, **__: Any) -> list[dict[str, Any]]:
91
+ return []
92
+
93
  async def append_trace_message(self, *_: Any, **__: Any) -> int | None:
94
  return None
95
 
 
146
  await self.db.session_events.create_index(
147
  [("session_id", 1), ("seq", 1)], unique=True
148
  )
149
+ await self.db.session_events.create_index(
150
+ [("session_id", 1), ("created_at", 1), ("event_type", 1)]
151
+ )
152
  await self.db.session_trace_messages.create_index(
153
  [("session_id", 1), ("seq", 1)], unique=True
154
  )
 
372
  ).sort("seq", 1)
373
  return [row async for row in cursor]
374
 
375
+ async def load_usage_events(
376
+ self,
377
+ user_id: str,
378
+ *,
379
+ session_id: str | None = None,
380
+ start: datetime | None = None,
381
+ end: datetime | None = None,
382
+ ) -> list[dict[str, Any]]:
383
+ if not self._ready():
384
+ return []
385
+ session_query: dict[str, Any] = {"visibility": {"$ne": "deleted"}}
386
+ if user_id != "dev":
387
+ session_query["user_id"] = user_id
388
+ if session_id is not None:
389
+ session_query["_id"] = session_id
390
+
391
+ session_cursor = self.db.sessions.find(session_query, {"_id": 1})
392
+ session_ids = [str(row.get("_id")) async for row in session_cursor]
393
+ if not session_ids:
394
+ return []
395
+
396
+ event_query: dict[str, Any] = {
397
+ "session_id": {"$in": session_ids},
398
+ "event_type": {"$in": list(USAGE_EVENT_TYPES)},
399
+ }
400
+ if start is not None or end is not None:
401
+ created_at: dict[str, datetime] = {}
402
+ if start is not None:
403
+ created_at["$gte"] = start
404
+ if end is not None:
405
+ created_at["$lt"] = end
406
+ event_query["created_at"] = created_at
407
+
408
+ event_cursor = self.db.session_events.find(event_query)
409
+ return [row async for row in event_cursor]
410
+
411
  async def append_trace_message(
412
  self, session_id: str, message: dict[str, Any], source: str = "message"
413
  ) -> int | None:
agent/core/telemetry.py CHANGED
@@ -21,6 +21,8 @@ import logging
21
  import time
22
  from typing import Any
23
 
 
 
24
  logger = logging.getLogger(__name__)
25
 
26
 
@@ -190,6 +192,18 @@ async def record_hf_job_complete(
190
 
191
  try:
192
  wall_time_s = int(time.monotonic() - submit_ts)
 
 
 
 
 
 
 
 
 
 
 
 
193
  await session.send_event(
194
  Event(
195
  event_type="hf_job_complete",
@@ -198,6 +212,10 @@ async def record_hf_job_complete(
198
  "flavor": flavor,
199
  "final_status": final_status,
200
  "wall_time_s": wall_time_s,
 
 
 
 
201
  },
202
  )
203
  )
 
21
  import time
22
  from typing import Any
23
 
24
+ from agent.core.cost_estimation import hf_jobs_price_catalog
25
+
26
  logger = logging.getLogger(__name__)
27
 
28
 
 
192
 
193
  try:
194
  wall_time_s = int(time.monotonic() - submit_ts)
195
+ billable_seconds = max(0, wall_time_s)
196
+ price_usd_per_hour = None
197
+ estimated_cost_usd = None
198
+ cost_estimate_source = "unknown_price"
199
+ prices = await hf_jobs_price_catalog()
200
+ if flavor in prices:
201
+ price_usd_per_hour = float(prices[flavor])
202
+ estimated_cost_usd = round(
203
+ price_usd_per_hour * (billable_seconds / 3600),
204
+ 4,
205
+ )
206
+ cost_estimate_source = "runtime_price_catalog"
207
  await session.send_event(
208
  Event(
209
  event_type="hf_job_complete",
 
212
  "flavor": flavor,
213
  "final_status": final_status,
214
  "wall_time_s": wall_time_s,
215
+ "billable_seconds_estimate": billable_seconds,
216
+ "price_usd_per_hour": price_usd_per_hour,
217
+ "estimated_cost_usd": estimated_cost_usd,
218
+ "cost_estimate_source": cost_estimate_source,
219
  },
220
  )
221
  )
backend/models.py CHANGED
@@ -120,6 +120,78 @@ class SessionYoloRequest(BaseModel):
120
  cost_cap_usd: float | None = Field(default=None, ge=0)
121
 
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  class DatasetUploadResponse(BaseModel):
124
  """Response for a dataset file uploaded to the Hub."""
125
 
 
120
  cost_cap_usd: float | None = Field(default=None, ge=0)
121
 
122
 
123
+ class UsageBucket(BaseModel):
124
+ """App-attributed usage totals for a session or time window."""
125
+
126
+ session_id: str | None = None
127
+ window_start: str | None = None
128
+ window_end: str | None = None
129
+ timezone: str | None = None
130
+ total_usd: float = 0.0
131
+ inference_usd: float = 0.0
132
+ hf_jobs_estimated_usd: float = 0.0
133
+ llm_calls: int = 0
134
+ hf_jobs_count: int = 0
135
+ prompt_tokens: int = 0
136
+ completion_tokens: int = 0
137
+ cache_read_tokens: int = 0
138
+ cache_creation_tokens: int = 0
139
+ total_tokens: int = 0
140
+ hf_jobs_billable_seconds_estimate: int = 0
141
+
142
+
143
+ class HfAccountUsageBucket(BaseModel):
144
+ """HF account billing usage for a time window."""
145
+
146
+ window_start: str | None = None
147
+ window_end: str | None = None
148
+ timezone: str | None = None
149
+ total_usd: float = 0.0
150
+ inference_providers_usd: float = 0.0
151
+ hf_jobs_usd: float = 0.0
152
+ inference_provider_requests: int = 0
153
+ hf_jobs_minutes: float = 0.0
154
+
155
+
156
+ class HfInferenceProvidersCredits(BaseModel):
157
+ """Included and configured Inference Providers account credits."""
158
+
159
+ included_usd: float = 0.0
160
+ used_usd: float = 0.0
161
+ remaining_included_usd: float = 0.0
162
+ limit_usd: float = 0.0
163
+ remaining_limit_usd: float = 0.0
164
+ num_requests: int = 0
165
+ period_start: str | None = None
166
+ period_end: str | None = None
167
+
168
+
169
+ class HfAccountUsage(BaseModel):
170
+ """Authoritative HF account billing usage from the signed-in token."""
171
+
172
+ source: Literal["hf_billing_usage_v2"]
173
+ available: bool = False
174
+ error: str | None = None
175
+ current_session: HfAccountUsageBucket | None = None
176
+ today: HfAccountUsageBucket | None = None
177
+ month: HfAccountUsageBucket | None = None
178
+ inference_providers_credits: HfInferenceProvidersCredits | None = None
179
+
180
+
181
+ class UsageResponse(BaseModel):
182
+ """Current-user app-attributed usage response."""
183
+
184
+ source: Literal["app_telemetry"]
185
+ currency: Literal["USD"]
186
+ generated_at: str
187
+ timezone: str
188
+ session: UsageBucket | None = None
189
+ today: UsageBucket
190
+ month: UsageBucket
191
+ hf_account: HfAccountUsage | None = None
192
+ links: dict[str, str] = Field(default_factory=dict)
193
+
194
+
195
  class DatasetUploadResponse(BaseModel):
196
  """Response for a dataset file uploaded to the Hub."""
197
 
backend/routes/agent.py CHANGED
@@ -41,6 +41,7 @@ from models import (
41
  SessionYoloRequest,
42
  SubmitRequest,
43
  TruncateRequest,
 
44
  )
45
  from session_manager import (
46
  MAX_SESSIONS,
@@ -62,6 +63,7 @@ from agent.core.model_ids import (
62
  MINIMAX_M27_MODEL_ID,
63
  strip_huggingface_model_prefix,
64
  )
 
65
 
66
  logger = logging.getLogger(__name__)
67
 
@@ -674,6 +676,36 @@ async def get_jobs_access_info(
674
  }
675
 
676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  @router.get("/sessions", response_model=list[SessionInfo])
678
  async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
679
  """List sessions belonging to the authenticated user."""
 
41
  SessionYoloRequest,
42
  SubmitRequest,
43
  TruncateRequest,
44
+ UsageResponse,
45
  )
46
  from session_manager import (
47
  MAX_SESSIONS,
 
63
  MINIMAX_M27_MODEL_ID,
64
  strip_huggingface_model_prefix,
65
  )
66
+ from usage import build_usage_response
67
 
68
  logger = logging.getLogger(__name__)
69
 
 
676
  }
677
 
678
 
679
+ @router.get("/usage", response_model=UsageResponse)
680
+ async def get_usage(
681
+ request: Request,
682
+ session_id: str | None = None,
683
+ tz: str | None = None,
684
+ include_rollups: bool = True,
685
+ user: dict = Depends(get_current_user),
686
+ ) -> dict:
687
+ """Return app-attributed usage for the current user."""
688
+ if session_id:
689
+ await _check_session_access(
690
+ session_id,
691
+ user,
692
+ request,
693
+ preload_sandbox=False,
694
+ )
695
+ return await build_usage_response(
696
+ session_manager,
697
+ user_id=user["user_id"],
698
+ hf_token=(
699
+ resolve_hf_request_token(request, include_env_fallback=False)
700
+ or _user_hf_token(user)
701
+ or resolve_hf_request_token(request)
702
+ ),
703
+ session_id=session_id,
704
+ timezone_name=tz,
705
+ include_rollups=include_rollups,
706
+ )
707
+
708
+
709
  @router.get("/sessions", response_model=list[SessionInfo])
710
  async def list_sessions(user: dict = Depends(get_current_user)) -> list[SessionInfo]:
711
  """List sessions belonging to the authenticated user."""
backend/usage.py ADDED
@@ -0,0 +1,583 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Usage aggregation for app-attributed ML Intern spend."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
8
+
9
+ import httpx
10
+
11
+ USAGE_EVENT_TYPES = ("llm_call", "hf_job_complete")
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ HF_BILLING_USAGE_V2_URL = "https://huggingface.co/api/settings/billing/usage-v2"
16
+ HF_BILLING_URL = "https://huggingface.co/settings/billing"
17
+ HF_INFERENCE_PROVIDERS_USAGE_URL = "https://huggingface.co/settings/billing/usage"
18
+ HF_INFERENCE_PROVIDERS_PRICING_URL = (
19
+ "https://huggingface.co/docs/inference-providers/en/pricing"
20
+ )
21
+ HF_JOBS_PRICING_URL = "https://huggingface.co/docs/hub/jobs-pricing"
22
+
23
+
24
+ def _utc(dt: datetime) -> datetime:
25
+ if dt.tzinfo is None:
26
+ return dt.replace(tzinfo=UTC)
27
+ return dt.astimezone(UTC)
28
+
29
+
30
+ def _iso(dt: datetime | None) -> str | None:
31
+ if dt is None:
32
+ return None
33
+ return _utc(dt).isoformat().replace("+00:00", "Z")
34
+
35
+
36
+ def _coerce_float(value: Any) -> float:
37
+ if isinstance(value, bool) or value is None:
38
+ return 0.0
39
+ try:
40
+ return float(value)
41
+ except (TypeError, ValueError):
42
+ return 0.0
43
+
44
+
45
+ def _coerce_int(value: Any) -> int:
46
+ if isinstance(value, bool) or value is None:
47
+ return 0
48
+ try:
49
+ return int(value)
50
+ except (TypeError, ValueError):
51
+ return 0
52
+
53
+
54
+ def _nano_usd_to_usd(value: Any) -> float:
55
+ return _coerce_float(value) / 1_000_000_000
56
+
57
+
58
+ def _micro_usd_to_usd(value: Any) -> float:
59
+ return _coerce_float(value) / 1_000_000
60
+
61
+
62
+ def _coerce_timezone(timezone_name: str | None) -> ZoneInfo | None:
63
+ if not timezone_name:
64
+ return None
65
+ try:
66
+ return ZoneInfo(timezone_name)
67
+ except (ZoneInfoNotFoundError, ValueError):
68
+ return None
69
+
70
+
71
+ def _normalize_event_timestamp(
72
+ dt: datetime,
73
+ *,
74
+ timezone_name: str | None = None,
75
+ ) -> datetime:
76
+ if dt.tzinfo is not None:
77
+ return _utc(dt)
78
+ timezone = _coerce_timezone(timezone_name)
79
+ if timezone is not None:
80
+ return dt.replace(tzinfo=timezone).astimezone(UTC)
81
+ return dt.astimezone(UTC)
82
+
83
+
84
+ def _parse_timestamp(
85
+ value: Any, *, timezone_name: str | None = None
86
+ ) -> datetime | None:
87
+ if isinstance(value, datetime):
88
+ return _normalize_event_timestamp(value, timezone_name=timezone_name)
89
+ if not isinstance(value, str) or not value:
90
+ return None
91
+ try:
92
+ return _normalize_event_timestamp(
93
+ datetime.fromisoformat(value.replace("Z", "+00:00")),
94
+ timezone_name=timezone_name,
95
+ )
96
+ except ValueError:
97
+ return None
98
+
99
+
100
+ def event_created_at(
101
+ event: dict[str, Any],
102
+ *,
103
+ timezone_name: str | None = None,
104
+ ) -> datetime | None:
105
+ return _parse_timestamp(
106
+ event.get("created_at") or event.get("timestamp"),
107
+ timezone_name=timezone_name,
108
+ )
109
+
110
+
111
+ def resolve_usage_windows(
112
+ timezone_name: str | None,
113
+ *,
114
+ now: datetime | None = None,
115
+ ) -> dict[str, datetime | str]:
116
+ """Return UTC day/month windows for a browser timezone."""
117
+ try:
118
+ tz = ZoneInfo(timezone_name or "UTC")
119
+ except (ZoneInfoNotFoundError, ValueError):
120
+ tz = ZoneInfo("UTC")
121
+
122
+ now_utc = _utc(now or datetime.now(UTC))
123
+ local_now = now_utc.astimezone(tz)
124
+ today_local = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
125
+ month_local = today_local.replace(day=1)
126
+ return {
127
+ "timezone": tz.key,
128
+ "now_utc": now_utc,
129
+ "today_start_utc": today_local.astimezone(UTC),
130
+ "month_start_utc": month_local.astimezone(UTC),
131
+ }
132
+
133
+
134
+ def _empty_bucket(
135
+ *,
136
+ session_id: str | None = None,
137
+ window_start: datetime | None = None,
138
+ window_end: datetime | None = None,
139
+ timezone: str | None = None,
140
+ ) -> dict[str, Any]:
141
+ return {
142
+ "session_id": session_id,
143
+ "window_start": _iso(window_start),
144
+ "window_end": _iso(window_end),
145
+ "timezone": timezone,
146
+ "total_usd": 0.0,
147
+ "inference_usd": 0.0,
148
+ "hf_jobs_estimated_usd": 0.0,
149
+ "llm_calls": 0,
150
+ "hf_jobs_count": 0,
151
+ "prompt_tokens": 0,
152
+ "completion_tokens": 0,
153
+ "cache_read_tokens": 0,
154
+ "cache_creation_tokens": 0,
155
+ "total_tokens": 0,
156
+ "hf_jobs_billable_seconds_estimate": 0,
157
+ }
158
+
159
+
160
+ def _empty_hf_account_bucket(
161
+ *,
162
+ window_start: datetime | None = None,
163
+ window_end: datetime | None = None,
164
+ timezone: str | None = None,
165
+ ) -> dict[str, Any]:
166
+ return {
167
+ "window_start": _iso(window_start),
168
+ "window_end": _iso(window_end),
169
+ "timezone": timezone,
170
+ "total_usd": 0.0,
171
+ "inference_providers_usd": 0.0,
172
+ "hf_jobs_usd": 0.0,
173
+ "inference_provider_requests": 0,
174
+ "hf_jobs_minutes": 0.0,
175
+ }
176
+
177
+
178
+ def aggregate_usage_events(
179
+ events: list[dict[str, Any]],
180
+ *,
181
+ session_id: str | None = None,
182
+ window_start: datetime | None = None,
183
+ window_end: datetime | None = None,
184
+ timezone: str | None = None,
185
+ ) -> dict[str, Any]:
186
+ bucket = _empty_bucket(
187
+ session_id=session_id,
188
+ window_start=window_start,
189
+ window_end=window_end,
190
+ timezone=timezone,
191
+ )
192
+ for event in events:
193
+ event_type = event.get("event_type")
194
+ data = event.get("data") or {}
195
+ if event_type == "llm_call":
196
+ bucket["llm_calls"] += 1
197
+ bucket["inference_usd"] += _coerce_float(data.get("cost_usd"))
198
+ prompt_tokens = _coerce_int(data.get("prompt_tokens"))
199
+ completion_tokens = _coerce_int(data.get("completion_tokens"))
200
+ cache_read_tokens = _coerce_int(data.get("cache_read_tokens"))
201
+ cache_creation_tokens = _coerce_int(data.get("cache_creation_tokens"))
202
+ total_tokens = _coerce_int(data.get("total_tokens")) or (
203
+ prompt_tokens
204
+ + completion_tokens
205
+ + cache_read_tokens
206
+ + cache_creation_tokens
207
+ )
208
+ bucket["prompt_tokens"] += prompt_tokens
209
+ bucket["completion_tokens"] += completion_tokens
210
+ bucket["cache_read_tokens"] += cache_read_tokens
211
+ bucket["cache_creation_tokens"] += cache_creation_tokens
212
+ bucket["total_tokens"] += total_tokens
213
+ elif event_type == "hf_job_complete":
214
+ bucket["hf_jobs_count"] += 1
215
+ bucket["hf_jobs_estimated_usd"] += _coerce_float(
216
+ data.get("estimated_cost_usd")
217
+ )
218
+ bucket["hf_jobs_billable_seconds_estimate"] += _coerce_int(
219
+ data.get("billable_seconds_estimate") or data.get("wall_time_s")
220
+ )
221
+
222
+ bucket["inference_usd"] = round(bucket["inference_usd"], 6)
223
+ bucket["hf_jobs_estimated_usd"] = round(bucket["hf_jobs_estimated_usd"], 6)
224
+ bucket["total_usd"] = round(
225
+ bucket["inference_usd"] + bucket["hf_jobs_estimated_usd"],
226
+ 6,
227
+ )
228
+ return bucket
229
+
230
+
231
+ def _account_bucket_from_billing_usage(
232
+ payload: dict[str, Any] | None,
233
+ *,
234
+ window_start: datetime,
235
+ window_end: datetime,
236
+ timezone: str,
237
+ ) -> dict[str, Any]:
238
+ bucket = _empty_hf_account_bucket(
239
+ window_start=window_start,
240
+ window_end=window_end,
241
+ timezone=timezone,
242
+ )
243
+ usage = payload.get("usage") if isinstance(payload, dict) else {}
244
+ if not isinstance(usage, dict):
245
+ return bucket
246
+
247
+ inference = usage.get("inferenceProviders")
248
+ if not isinstance(inference, dict):
249
+ inference = {}
250
+ jobs = usage.get("jobs")
251
+ if not isinstance(jobs, dict):
252
+ jobs = {}
253
+
254
+ bucket["inference_providers_usd"] = round(
255
+ _nano_usd_to_usd(inference.get("usedNanoUsd")),
256
+ 6,
257
+ )
258
+ bucket["hf_jobs_usd"] = round(_micro_usd_to_usd(jobs.get("usedMicroUsd")), 6)
259
+ bucket["inference_provider_requests"] = _coerce_int(inference.get("numRequests"))
260
+ bucket["hf_jobs_minutes"] = round(_coerce_float(jobs.get("totalMinutes")), 3)
261
+ bucket["total_usd"] = round(
262
+ bucket["inference_providers_usd"] + bucket["hf_jobs_usd"],
263
+ 6,
264
+ )
265
+ return bucket
266
+
267
+
268
+ def _inference_credits_from_billing_usage(
269
+ payload: dict[str, Any] | None,
270
+ ) -> dict[str, Any] | None:
271
+ usage = payload.get("usage") if isinstance(payload, dict) else {}
272
+ if not isinstance(usage, dict):
273
+ return None
274
+ inference = usage.get("inferenceProviders")
275
+ if not isinstance(inference, dict):
276
+ return None
277
+
278
+ included_usd = _nano_usd_to_usd(inference.get("includedNanoUsd"))
279
+ used_usd = _nano_usd_to_usd(inference.get("usedNanoUsd"))
280
+ limit_usd = _nano_usd_to_usd(inference.get("limitNanoUsd"))
281
+ return {
282
+ "included_usd": round(included_usd, 6),
283
+ "used_usd": round(used_usd, 6),
284
+ "remaining_included_usd": round(max(0.0, included_usd - used_usd), 6),
285
+ "limit_usd": round(limit_usd, 6),
286
+ "remaining_limit_usd": round(max(0.0, limit_usd - used_usd), 6),
287
+ "num_requests": _coerce_int(inference.get("numRequests")),
288
+ "period_start": inference.get("periodStart"),
289
+ "period_end": inference.get("periodEnd"),
290
+ }
291
+
292
+
293
+ async def _fetch_hf_billing_usage_v2(
294
+ hf_token: str,
295
+ *,
296
+ start: datetime,
297
+ end: datetime,
298
+ ) -> dict[str, Any] | None:
299
+ start_ts = max(1, int(_utc(start).timestamp()))
300
+ end_ts = max(start_ts + 1, int(_utc(end).timestamp()))
301
+ try:
302
+ async with httpx.AsyncClient(timeout=10.0) as client:
303
+ response = await client.get(
304
+ HF_BILLING_USAGE_V2_URL,
305
+ params={"startDate": start_ts, "endDate": end_ts},
306
+ headers={"Authorization": f"Bearer {hf_token}"},
307
+ )
308
+ if response.status_code != 200:
309
+ logger.debug(
310
+ "HF billing usage-v2 failed: status=%s body=%s",
311
+ response.status_code,
312
+ response.text[:200],
313
+ )
314
+ return None
315
+ payload = response.json()
316
+ return payload if isinstance(payload, dict) else None
317
+ except (httpx.HTTPError, ValueError) as e:
318
+ logger.debug("HF billing usage-v2 failed: %s", e)
319
+ return None
320
+
321
+
322
+ def _session_started_at(manager: Any, session_id: str | None) -> datetime | None:
323
+ if not session_id:
324
+ return None
325
+ agent_session = getattr(manager, "sessions", {}).get(session_id)
326
+ created_at = getattr(agent_session, "created_at", None)
327
+ if isinstance(created_at, datetime):
328
+ return _utc(created_at)
329
+ return None
330
+
331
+
332
+ async def _load_persisted_session_started_at(
333
+ manager: Any,
334
+ session_id: str | None,
335
+ ) -> datetime | None:
336
+ if not session_id:
337
+ return None
338
+ store = manager._store()
339
+ if not getattr(store, "enabled", False):
340
+ return None
341
+ loaded = await store.load_session(session_id)
342
+ metadata = loaded.get("metadata") if isinstance(loaded, dict) else None
343
+ created_at = metadata.get("created_at") if isinstance(metadata, dict) else None
344
+ if isinstance(created_at, datetime):
345
+ return _utc(created_at)
346
+ parsed = _parse_timestamp(created_at)
347
+ return _utc(parsed) if parsed is not None else None
348
+
349
+
350
+ async def _build_hf_account_usage(
351
+ manager: Any,
352
+ *,
353
+ hf_token: str | None,
354
+ session_id: str | None,
355
+ timezone: str,
356
+ now_utc: datetime,
357
+ today_start: datetime,
358
+ month_start: datetime,
359
+ include_rollups: bool = True,
360
+ ) -> dict[str, Any]:
361
+ account_usage: dict[str, Any] = {
362
+ "source": "hf_billing_usage_v2",
363
+ "available": False,
364
+ "current_session": None,
365
+ "today": None,
366
+ "month": None,
367
+ "inference_providers_credits": None,
368
+ }
369
+ if not hf_token:
370
+ account_usage["error"] = "missing_hf_token"
371
+ return account_usage
372
+
373
+ session_start = _session_started_at(manager, session_id)
374
+ if session_start is None:
375
+ session_start = await _load_persisted_session_started_at(manager, session_id)
376
+
377
+ window_tasks: dict[str, tuple[datetime, asyncio.Task[dict[str, Any] | None]]] = {
378
+ "month": (
379
+ month_start,
380
+ asyncio.create_task(
381
+ _fetch_hf_billing_usage_v2(hf_token, start=month_start, end=now_utc)
382
+ ),
383
+ ),
384
+ }
385
+ if include_rollups:
386
+ window_tasks["today"] = (
387
+ today_start,
388
+ asyncio.create_task(
389
+ _fetch_hf_billing_usage_v2(hf_token, start=today_start, end=now_utc)
390
+ ),
391
+ )
392
+ if session_start is not None:
393
+ window_tasks["current_session"] = (
394
+ session_start,
395
+ asyncio.create_task(
396
+ _fetch_hf_billing_usage_v2(hf_token, start=session_start, end=now_utc)
397
+ ),
398
+ )
399
+
400
+ payloads: dict[str, dict[str, Any] | None] = {}
401
+ for name, (_, task) in window_tasks.items():
402
+ payloads[name] = await task
403
+
404
+ any_payload = any(isinstance(payload, dict) for payload in payloads.values())
405
+ account_usage["available"] = any_payload
406
+ if not any_payload:
407
+ account_usage["error"] = "billing_usage_unavailable"
408
+ return account_usage
409
+
410
+ for name, (start, _) in window_tasks.items():
411
+ payload = payloads.get(name)
412
+ if payload is None:
413
+ continue
414
+ account_usage[name] = _account_bucket_from_billing_usage(
415
+ payload,
416
+ window_start=start,
417
+ window_end=now_utc,
418
+ timezone=timezone,
419
+ )
420
+
421
+ account_usage["inference_providers_credits"] = (
422
+ _inference_credits_from_billing_usage(payloads.get("month"))
423
+ )
424
+ return account_usage
425
+
426
+
427
+ def _event_in_window(
428
+ event: dict[str, Any],
429
+ *,
430
+ start: datetime | None = None,
431
+ end: datetime | None = None,
432
+ timezone_name: str | None = None,
433
+ ) -> bool:
434
+ if start is None and end is None:
435
+ return True
436
+ created_at = event_created_at(event, timezone_name=timezone_name)
437
+ if created_at is None:
438
+ return False
439
+ if start is not None and created_at < _utc(start):
440
+ return False
441
+ if end is not None and created_at >= _utc(end):
442
+ return False
443
+ return True
444
+
445
+
446
+ def _events_from_runtime_session(agent_session: Any) -> list[dict[str, Any]]:
447
+ events: list[dict[str, Any]] = []
448
+ for raw in getattr(agent_session.session, "logged_events", []) or []:
449
+ if raw.get("event_type") not in USAGE_EVENT_TYPES:
450
+ continue
451
+ events.append(
452
+ {
453
+ "session_id": agent_session.session_id,
454
+ "event_type": raw.get("event_type"),
455
+ "data": raw.get("data") or {},
456
+ "timestamp": raw.get("timestamp"),
457
+ }
458
+ )
459
+ return events
460
+
461
+
462
+ def _runtime_sessions_for_user(manager: Any, user_id: str) -> list[Any]:
463
+ sessions = list(getattr(manager, "sessions", {}).values())
464
+ if user_id == "dev":
465
+ return sessions
466
+ return [session for session in sessions if session.user_id == user_id]
467
+
468
+
469
+ async def _load_usage_events(
470
+ manager: Any,
471
+ *,
472
+ user_id: str,
473
+ session_id: str | None = None,
474
+ start: datetime | None = None,
475
+ end: datetime | None = None,
476
+ timezone_name: str | None = None,
477
+ ) -> list[dict[str, Any]]:
478
+ store = manager._store()
479
+ if getattr(store, "enabled", False):
480
+ return await store.load_usage_events(
481
+ user_id,
482
+ session_id=session_id,
483
+ start=start,
484
+ end=end,
485
+ )
486
+
487
+ events: list[dict[str, Any]] = []
488
+ for agent_session in _runtime_sessions_for_user(manager, user_id):
489
+ if session_id is not None and agent_session.session_id != session_id:
490
+ continue
491
+ for event in _events_from_runtime_session(agent_session):
492
+ if _event_in_window(
493
+ event,
494
+ start=start,
495
+ end=end,
496
+ timezone_name=timezone_name,
497
+ ):
498
+ events.append(event)
499
+ return events
500
+
501
+
502
+ async def build_usage_response(
503
+ manager: Any,
504
+ *,
505
+ user_id: str,
506
+ hf_token: str | None = None,
507
+ session_id: str | None = None,
508
+ timezone_name: str | None = None,
509
+ now: datetime | None = None,
510
+ include_rollups: bool = True,
511
+ ) -> dict[str, Any]:
512
+ windows = resolve_usage_windows(timezone_name, now=now)
513
+ timezone = str(windows["timezone"])
514
+ now_utc = windows["now_utc"]
515
+ today_start = windows["today_start_utc"]
516
+ month_start = windows["month_start_utc"]
517
+
518
+ session_events: list[dict[str, Any]] = []
519
+ if session_id:
520
+ session_events = await _load_usage_events(
521
+ manager,
522
+ user_id=user_id,
523
+ session_id=session_id,
524
+ )
525
+
526
+ today_events: list[dict[str, Any]] = []
527
+ month_events: list[dict[str, Any]] = []
528
+ if include_rollups:
529
+ today_events = await _load_usage_events(
530
+ manager,
531
+ user_id=user_id,
532
+ start=today_start,
533
+ end=now_utc,
534
+ timezone_name=timezone,
535
+ )
536
+ month_events = await _load_usage_events(
537
+ manager,
538
+ user_id=user_id,
539
+ start=month_start,
540
+ end=now_utc,
541
+ timezone_name=timezone,
542
+ )
543
+ hf_account = await _build_hf_account_usage(
544
+ manager,
545
+ hf_token=hf_token,
546
+ session_id=session_id,
547
+ timezone=timezone,
548
+ now_utc=now_utc,
549
+ today_start=today_start,
550
+ month_start=month_start,
551
+ include_rollups=include_rollups,
552
+ )
553
+
554
+ return {
555
+ "source": "app_telemetry",
556
+ "currency": "USD",
557
+ "generated_at": _iso(now_utc),
558
+ "timezone": timezone,
559
+ "session": (
560
+ aggregate_usage_events(session_events, session_id=session_id)
561
+ if session_id
562
+ else None
563
+ ),
564
+ "today": aggregate_usage_events(
565
+ today_events,
566
+ window_start=today_start,
567
+ window_end=now_utc,
568
+ timezone=timezone,
569
+ ),
570
+ "month": aggregate_usage_events(
571
+ month_events,
572
+ window_start=month_start,
573
+ window_end=now_utc,
574
+ timezone=timezone,
575
+ ),
576
+ "hf_account": hf_account,
577
+ "links": {
578
+ "hf_billing": HF_BILLING_URL,
579
+ "inference_providers_usage": HF_INFERENCE_PROVIDERS_USAGE_URL,
580
+ "inference_providers_pricing": HF_INFERENCE_PROVIDERS_PRICING_URL,
581
+ "jobs_pricing": HF_JOBS_PRICING_URL,
582
+ },
583
+ }
frontend/src/components/Layout/AppLayout.tsx CHANGED
@@ -26,6 +26,7 @@ import SessionChat from '@/components/SessionChat';
26
  import CodePanel from '@/components/CodePanel/CodePanel';
27
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
28
  import YoloControl from '@/components/YoloControl';
 
29
  import { apiFetch } from '@/utils/api';
30
  import { inferenceCreditCta, isInferenceCreditError } from '@/utils/inferenceBilling';
31
 
@@ -300,6 +301,7 @@ export default function AppLayout() {
300
  </Box>
301
 
302
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
 
303
  <YoloControl />
304
  <IconButton
305
  onClick={toggleTheme}
 
26
  import CodePanel from '@/components/CodePanel/CodePanel';
27
  import WelcomeScreen from '@/components/WelcomeScreen/WelcomeScreen';
28
  import YoloControl from '@/components/YoloControl';
29
+ import UsageMeter from '@/components/UsageMeter';
30
  import { apiFetch } from '@/utils/api';
31
  import { inferenceCreditCta, isInferenceCreditError } from '@/utils/inferenceBilling';
32
 
 
301
  </Box>
302
 
303
  <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
304
+ <UsageMeter />
305
  <YoloControl />
306
  <IconButton
307
  onClick={toggleTheme}
frontend/src/components/UsageMeter.tsx ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { type ReactNode, useEffect, useMemo, useState } from 'react';
2
+ import {
3
+ Box,
4
+ Button,
5
+ CircularProgress,
6
+ Divider,
7
+ Link,
8
+ Popover,
9
+ Tooltip,
10
+ Typography,
11
+ } from '@mui/material';
12
+ import PaidOutlinedIcon from '@mui/icons-material/PaidOutlined';
13
+ import OpenInNewIcon from '@mui/icons-material/OpenInNew';
14
+ import { useSessionStore } from '@/store/sessionStore';
15
+ import {
16
+ type HfAccountUsageBucket,
17
+ type HfInferenceProvidersCredits,
18
+ type UsageBucket,
19
+ useUsageStore,
20
+ } from '@/store/usageStore';
21
+
22
+ function formatUsd(value: number | undefined): string {
23
+ const amount = value ?? 0;
24
+ if (amount > 0 && amount < 0.01) return '<$0.01';
25
+ return new Intl.NumberFormat('en-US', {
26
+ style: 'currency',
27
+ currency: 'USD',
28
+ minimumFractionDigits: 2,
29
+ maximumFractionDigits: 2,
30
+ }).format(amount);
31
+ }
32
+
33
+ function UsageRow({
34
+ label,
35
+ value,
36
+ strong = false,
37
+ }: {
38
+ label: string;
39
+ value: string;
40
+ strong?: boolean;
41
+ }) {
42
+ return (
43
+ <>
44
+ <Typography variant="body2" color="text.secondary">
45
+ {label}
46
+ </Typography>
47
+ <Typography
48
+ variant="body2"
49
+ sx={{ fontWeight: strong ? 700 : 400, fontVariantNumeric: 'tabular-nums' }}
50
+ >
51
+ {value}
52
+ </Typography>
53
+ </>
54
+ );
55
+ }
56
+
57
+ function UsageGrid({ children }: { children: ReactNode }) {
58
+ return (
59
+ <Box
60
+ sx={{
61
+ display: 'grid',
62
+ gridTemplateColumns: '1fr auto',
63
+ columnGap: 2,
64
+ rowGap: 0.5,
65
+ mt: 0.75,
66
+ }}
67
+ >
68
+ {children}
69
+ </Box>
70
+ );
71
+ }
72
+
73
+ function AccountUsageSection({
74
+ title,
75
+ account,
76
+ telemetry,
77
+ }: {
78
+ title: string;
79
+ account: HfAccountUsageBucket | null | undefined;
80
+ telemetry: UsageBucket | null | undefined;
81
+ }) {
82
+ return (
83
+ <Box sx={{ py: 1 }}>
84
+ <Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
85
+ {title}
86
+ </Typography>
87
+ <UsageGrid>
88
+ <UsageRow
89
+ label="Inference Providers"
90
+ value={formatUsd(account?.inference_providers_usd ?? telemetry?.inference_usd)}
91
+ strong
92
+ />
93
+ <UsageRow
94
+ label={account ? 'HF Jobs' : 'HF Jobs estimated'}
95
+ value={formatUsd(account?.hf_jobs_usd ?? telemetry?.hf_jobs_estimated_usd)}
96
+ />
97
+ </UsageGrid>
98
+ </Box>
99
+ );
100
+ }
101
+
102
+ function CreditsSection({ credits }: { credits: HfInferenceProvidersCredits | null | undefined }) {
103
+ if (!credits) return null;
104
+ return (
105
+ <>
106
+ <Divider />
107
+ <Box sx={{ py: 1 }}>
108
+ <Typography variant="caption" sx={{ color: 'text.secondary', fontWeight: 700 }}>
109
+ Inference credits
110
+ </Typography>
111
+ <UsageGrid>
112
+ <UsageRow
113
+ label="Included remaining"
114
+ value={formatUsd(credits.remaining_included_usd)}
115
+ strong
116
+ />
117
+ <UsageRow
118
+ label="Used / included"
119
+ value={`${formatUsd(credits.used_usd)} / ${formatUsd(credits.included_usd)}`}
120
+ />
121
+ {credits.limit_usd > 0 && (
122
+ <UsageRow
123
+ label="Spend limit remaining"
124
+ value={formatUsd(credits.remaining_limit_usd)}
125
+ />
126
+ )}
127
+ </UsageGrid>
128
+ </Box>
129
+ </>
130
+ );
131
+ }
132
+
133
+ export default function UsageMeter() {
134
+ const activeSessionId = useSessionStore((state) => state.activeSessionId);
135
+ const { usage, isLoading, error, fetchUsage } = useUsageStore();
136
+ const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
137
+
138
+ useEffect(() => {
139
+ void fetchUsage(activeSessionId);
140
+ }, [activeSessionId, fetchUsage]);
141
+
142
+ const sessionTotal =
143
+ usage?.hf_account?.current_session?.total_usd ?? usage?.session?.total_usd ?? 0;
144
+ const links = useMemo(() => usage?.links ?? {}, [usage?.links]);
145
+ const open = Boolean(anchorEl);
146
+
147
+ return (
148
+ <>
149
+ <Tooltip title="Usage">
150
+ <Button
151
+ size="small"
152
+ variant="outlined"
153
+ startIcon={isLoading ? <CircularProgress size={14} /> : <PaidOutlinedIcon fontSize="small" />}
154
+ onClick={(event) => setAnchorEl(event.currentTarget)}
155
+ sx={{
156
+ minWidth: { xs: 58, sm: 84 },
157
+ height: 32,
158
+ px: { xs: 0.75, sm: 1 },
159
+ borderColor: 'divider',
160
+ color: 'text.secondary',
161
+ fontVariantNumeric: 'tabular-nums',
162
+ '& .MuiButton-startIcon': { mr: { xs: 0.25, sm: 0.5 } },
163
+ '&:hover': { borderColor: 'primary.main', color: 'primary.main' },
164
+ }}
165
+ >
166
+ {formatUsd(sessionTotal)}
167
+ </Button>
168
+ </Tooltip>
169
+ <Popover
170
+ open={open}
171
+ anchorEl={anchorEl}
172
+ onClose={() => setAnchorEl(null)}
173
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
174
+ transformOrigin={{ vertical: 'top', horizontal: 'right' }}
175
+ slotProps={{
176
+ paper: {
177
+ sx: {
178
+ width: 320,
179
+ maxWidth: 'calc(100vw - 24px)',
180
+ maxHeight: 'calc(100vh - 24px)',
181
+ overflowY: 'auto',
182
+ p: 2,
183
+ border: '1px solid',
184
+ borderColor: 'divider',
185
+ },
186
+ },
187
+ }}
188
+ >
189
+ <Typography variant="subtitle2" sx={{ fontWeight: 800 }}>
190
+ Usage
191
+ </Typography>
192
+ <Typography variant="caption" color="text.secondary">
193
+ Session billing is inferred from HF account usage since session start.
194
+ </Typography>
195
+
196
+ {error ? (
197
+ <Typography variant="body2" color="error" sx={{ mt: 1.5 }}>
198
+ {error}
199
+ </Typography>
200
+ ) : (
201
+ <>
202
+ <AccountUsageSection
203
+ title="Current session"
204
+ account={usage?.hf_account?.current_session ?? null}
205
+ telemetry={usage?.session ?? null}
206
+ />
207
+ <CreditsSection credits={usage?.hf_account?.inference_providers_credits} />
208
+ </>
209
+ )}
210
+
211
+ <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1, pt: 1 }}>
212
+ {links.hf_billing && (
213
+ <Link href={links.hf_billing} target="_blank" rel="noopener noreferrer" underline="hover" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25, fontSize: '0.75rem' }}>
214
+ HF billing <OpenInNewIcon sx={{ fontSize: 12 }} />
215
+ </Link>
216
+ )}
217
+ {links.inference_providers_usage && (
218
+ <Link href={links.inference_providers_usage} target="_blank" rel="noopener noreferrer" underline="hover" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25, fontSize: '0.75rem' }}>
219
+ Inference usage <OpenInNewIcon sx={{ fontSize: 12 }} />
220
+ </Link>
221
+ )}
222
+ {links.jobs_pricing && (
223
+ <Link href={links.jobs_pricing} target="_blank" rel="noopener noreferrer" underline="hover" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.25, fontSize: '0.75rem' }}>
224
+ Jobs pricing <OpenInNewIcon sx={{ fontSize: 12 }} />
225
+ </Link>
226
+ )}
227
+ </Box>
228
+ </Popover>
229
+ </>
230
+ );
231
+ }
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -18,6 +18,7 @@ import { llmMessagesToUIMessages } from '@/lib/convert-llm-messages';
18
  import { apiFetch } from '@/utils/api';
19
  import { useAgentStore } from '@/store/agentStore';
20
  import { useSessionStore } from '@/store/sessionStore';
 
21
  import { useLayoutStore } from '@/store/layoutStore';
22
  import { logger } from '@/utils/logger';
23
 
@@ -64,6 +65,11 @@ export function useAgentChat({ sessionId, isActive, isProcessing = false, onRead
64
  [sessionId, updateSession],
65
  );
66
 
 
 
 
 
 
67
  // -- Build side-channel callbacks (stable ref) --------------------------
68
  const sideChannel = useMemo<SideChannelCallbacks>(
69
  () => ({
@@ -96,6 +102,7 @@ export function useAgentChat({ sessionId, isActive, isProcessing = false, onRead
96
  },
97
  onProcessingDone: () => {
98
  setProcessingState(false);
 
99
  },
100
  onUndoComplete: () => {
101
  setProcessingState(false);
@@ -230,6 +237,7 @@ export function useAgentChat({ sessionId, isActive, isProcessing = false, onRead
230
  // doesn't stay "processing" until the next /sessions merge —
231
  // activityStatus still surfaces the waiting-approval state.
232
  setProcessingState(false, { activityStatus: { type: 'waiting-approval' } });
 
233
 
234
  // Build panel data for this session's pending approval
235
  const firstTool = tools[0];
@@ -336,6 +344,9 @@ export function useAgentChat({ sessionId, isActive, isProcessing = false, onRead
336
  }
337
  updateSession(sessionId, updates);
338
  },
 
 
 
339
  onInterrupted: () => { /* no-op — handled by stop() caller */ },
340
  }),
341
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -593,6 +604,8 @@ export function useAgentChat({ sessionId, isActive, isProcessing = false, onRead
593
  const state = event.data?.state as string;
594
  const toolName = event.data?.tool as string;
595
  if (state === 'running' && toolName) sideChannel.onToolRunning(toolName);
 
 
596
  } else if (et === 'turn_complete' || et === 'error' || et === 'interrupted') {
597
  sideChannel.onProcessingDone();
598
  stopReconnect();
 
18
  import { apiFetch } from '@/utils/api';
19
  import { useAgentStore } from '@/store/agentStore';
20
  import { useSessionStore } from '@/store/sessionStore';
21
+ import { useUsageStore } from '@/store/usageStore';
22
  import { useLayoutStore } from '@/store/layoutStore';
23
  import { logger } from '@/utils/logger';
24
 
 
65
  [sessionId, updateSession],
66
  );
67
 
68
+ const refreshUsage = useCallback(() => {
69
+ const activeSessionId = useSessionStore.getState().activeSessionId;
70
+ void useUsageStore.getState().fetchUsage(activeSessionId);
71
+ }, []);
72
+
73
  // -- Build side-channel callbacks (stable ref) --------------------------
74
  const sideChannel = useMemo<SideChannelCallbacks>(
75
  () => ({
 
102
  },
103
  onProcessingDone: () => {
104
  setProcessingState(false);
105
+ refreshUsage();
106
  },
107
  onUndoComplete: () => {
108
  setProcessingState(false);
 
237
  // doesn't stay "processing" until the next /sessions merge —
238
  // activityStatus still surfaces the waiting-approval state.
239
  setProcessingState(false, { activityStatus: { type: 'waiting-approval' } });
240
+ refreshUsage();
241
 
242
  // Build panel data for this session's pending approval
243
  const firstTool = tools[0];
 
344
  }
345
  updateSession(sessionId, updates);
346
  },
347
+ onUsageEvent: (eventType, data) => {
348
+ useUsageStore.getState().applyUsageEvent(sessionId, eventType, data);
349
+ },
350
  onInterrupted: () => { /* no-op — handled by stop() caller */ },
351
  }),
352
  // eslint-disable-next-line react-hooks/exhaustive-deps
 
604
  const state = event.data?.state as string;
605
  const toolName = event.data?.tool as string;
606
  if (state === 'running' && toolName) sideChannel.onToolRunning(toolName);
607
+ } else if (et === 'llm_call' || et === 'hf_job_complete') {
608
+ sideChannel.onUsageEvent(et, (event.data || {}) as Record<string, unknown>);
609
  } else if (et === 'turn_complete' || et === 'error' || et === 'interrupted') {
610
  sideChannel.onProcessingDone();
611
  stopReconnect();
frontend/src/lib/sse-chat-transport.ts CHANGED
@@ -39,6 +39,7 @@ export interface SideChannelCallbacks {
39
  onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
40
  onStreaming: () => void;
41
  onToolRunning: (toolName: string, description?: string) => void;
 
42
  onInterrupted: () => void;
43
  }
44
 
@@ -316,6 +317,11 @@ function createEventToChunkStream(sideChannel: SideChannelCallbacks): TransformS
316
  break;
317
  }
318
 
 
 
 
 
 
319
  case 'turn_complete':
320
  endTextPart(controller);
321
  controller.enqueue({ type: 'finish-step' });
 
39
  onToolOutputPanel: (tool: string, toolCallId: string, output: string, success: boolean) => void;
40
  onStreaming: () => void;
41
  onToolRunning: (toolName: string, description?: string) => void;
42
+ onUsageEvent: (eventType: 'llm_call' | 'hf_job_complete', data: Record<string, unknown>) => void;
43
  onInterrupted: () => void;
44
  }
45
 
 
317
  break;
318
  }
319
 
320
+ case 'llm_call':
321
+ case 'hf_job_complete':
322
+ sideChannel.onUsageEvent(event.event_type, event.data || {});
323
+ break;
324
+
325
  case 'turn_complete':
326
  endTextPart(controller);
327
  controller.enqueue({ type: 'finish-step' });
frontend/src/store/usageStore.ts ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { create } from 'zustand';
2
+ import { apiFetch } from '@/utils/api';
3
+
4
+ export interface UsageBucket {
5
+ session_id?: string | null;
6
+ window_start?: string | null;
7
+ window_end?: string | null;
8
+ timezone?: string | null;
9
+ total_usd: number;
10
+ inference_usd: number;
11
+ hf_jobs_estimated_usd: number;
12
+ llm_calls: number;
13
+ hf_jobs_count: number;
14
+ prompt_tokens: number;
15
+ completion_tokens: number;
16
+ cache_read_tokens: number;
17
+ cache_creation_tokens: number;
18
+ total_tokens: number;
19
+ hf_jobs_billable_seconds_estimate: number;
20
+ }
21
+
22
+ export interface HfAccountUsageBucket {
23
+ window_start?: string | null;
24
+ window_end?: string | null;
25
+ timezone?: string | null;
26
+ total_usd: number;
27
+ inference_providers_usd: number;
28
+ hf_jobs_usd: number;
29
+ inference_provider_requests: number;
30
+ hf_jobs_minutes: number;
31
+ }
32
+
33
+ export interface HfInferenceProvidersCredits {
34
+ included_usd: number;
35
+ used_usd: number;
36
+ remaining_included_usd: number;
37
+ limit_usd: number;
38
+ remaining_limit_usd: number;
39
+ num_requests: number;
40
+ period_start?: string | null;
41
+ period_end?: string | null;
42
+ }
43
+
44
+ export interface HfAccountUsage {
45
+ source: 'hf_billing_usage_v2';
46
+ available: boolean;
47
+ error?: string | null;
48
+ current_session: HfAccountUsageBucket | null;
49
+ today: HfAccountUsageBucket | null;
50
+ month: HfAccountUsageBucket | null;
51
+ inference_providers_credits: HfInferenceProvidersCredits | null;
52
+ }
53
+
54
+ export interface UsageResponse {
55
+ source: 'app_telemetry';
56
+ currency: 'USD';
57
+ generated_at: string;
58
+ timezone: string;
59
+ session: UsageBucket | null;
60
+ today: UsageBucket;
61
+ month: UsageBucket;
62
+ hf_account?: HfAccountUsage | null;
63
+ links: Record<string, string>;
64
+ }
65
+
66
+ type UsageEventType = 'llm_call' | 'hf_job_complete';
67
+
68
+ interface UsageStore {
69
+ usage: UsageResponse | null;
70
+ isLoading: boolean;
71
+ error: string | null;
72
+ fetchUsage: (sessionId?: string | null) => Promise<void>;
73
+ applyUsageEvent: (
74
+ sessionId: string,
75
+ eventType: UsageEventType,
76
+ data: Record<string, unknown>,
77
+ ) => void;
78
+ }
79
+
80
+ function numberValue(value: unknown): number {
81
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0;
82
+ }
83
+
84
+ function intValue(value: unknown): number {
85
+ return Math.trunc(numberValue(value));
86
+ }
87
+
88
+ function roundUsd(value: number): number {
89
+ return Math.round(value * 1_000_000) / 1_000_000;
90
+ }
91
+
92
+ function usageUrl(sessionId?: string | null): string {
93
+ const params = new URLSearchParams();
94
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
95
+ params.set('tz', timezone);
96
+ params.set('include_rollups', 'false');
97
+ if (sessionId) params.set('session_id', sessionId);
98
+ return `/api/usage?${params.toString()}`;
99
+ }
100
+
101
+ function applyEventToBucket(
102
+ bucket: UsageBucket | null,
103
+ eventType: UsageEventType,
104
+ data: Record<string, unknown>,
105
+ ): UsageBucket | null {
106
+ if (!bucket) return null;
107
+ const next = { ...bucket };
108
+
109
+ if (eventType === 'llm_call') {
110
+ const prompt = intValue(data.prompt_tokens);
111
+ const completion = intValue(data.completion_tokens);
112
+ const cacheRead = intValue(data.cache_read_tokens);
113
+ const cacheCreation = intValue(data.cache_creation_tokens);
114
+ const total =
115
+ intValue(data.total_tokens) || prompt + completion + cacheRead + cacheCreation;
116
+ next.llm_calls += 1;
117
+ next.inference_usd = roundUsd(next.inference_usd + numberValue(data.cost_usd));
118
+ next.prompt_tokens += prompt;
119
+ next.completion_tokens += completion;
120
+ next.cache_read_tokens += cacheRead;
121
+ next.cache_creation_tokens += cacheCreation;
122
+ next.total_tokens += total;
123
+ } else if (eventType === 'hf_job_complete') {
124
+ next.hf_jobs_count += 1;
125
+ next.hf_jobs_estimated_usd = roundUsd(
126
+ next.hf_jobs_estimated_usd + numberValue(data.estimated_cost_usd),
127
+ );
128
+ next.hf_jobs_billable_seconds_estimate +=
129
+ intValue(data.billable_seconds_estimate) || intValue(data.wall_time_s);
130
+ }
131
+
132
+ next.total_usd = roundUsd(next.inference_usd + next.hf_jobs_estimated_usd);
133
+ return next;
134
+ }
135
+
136
+ export const useUsageStore = create<UsageStore>()((set, get) => ({
137
+ usage: null,
138
+ isLoading: false,
139
+ error: null,
140
+
141
+ fetchUsage: async (sessionId?: string | null) => {
142
+ set({ isLoading: true, error: null });
143
+ try {
144
+ let response = await apiFetch(usageUrl(sessionId));
145
+ if (response.status === 404 && sessionId) {
146
+ response = await apiFetch(usageUrl());
147
+ }
148
+ if (!response.ok) {
149
+ throw new Error(response.statusText || 'Failed to load usage');
150
+ }
151
+ const usage = (await response.json()) as UsageResponse;
152
+ set({ usage, isLoading: false, error: null });
153
+ } catch (error) {
154
+ set({
155
+ isLoading: false,
156
+ error: error instanceof Error ? error.message : 'Failed to load usage',
157
+ });
158
+ }
159
+ },
160
+
161
+ applyUsageEvent: (sessionId, eventType, data) => {
162
+ const current = get().usage;
163
+ if (!current) return;
164
+ set({
165
+ usage: {
166
+ ...current,
167
+ session:
168
+ current.session?.session_id === sessionId
169
+ ? applyEventToBucket(current.session, eventType, data)
170
+ : current.session,
171
+ today: applyEventToBucket(current.today, eventType, data) ?? current.today,
172
+ month: applyEventToBucket(current.month, eventType, data) ?? current.month,
173
+ },
174
+ });
175
+ },
176
+ }));
frontend/src/types/events.ts CHANGED
@@ -13,6 +13,8 @@ export type EventType =
13
  | 'tool_log'
14
  | 'approval_required'
15
  | 'tool_state_change'
 
 
16
  | 'turn_complete'
17
  | 'compacted'
18
  | 'error'
 
13
  | 'tool_log'
14
  | 'approval_required'
15
  | 'tool_state_change'
16
+ | 'llm_call'
17
+ | 'hf_job_complete'
18
  | 'turn_complete'
19
  | 'compacted'
20
  | 'error'
tests/unit/test_session_persistence.py CHANGED
@@ -1,10 +1,13 @@
1
  """Unit tests for the optional durable session store abstraction."""
2
 
 
 
3
  import pytest
4
 
5
  from agent.core.session_persistence import (
6
  MongoSessionStore,
7
  NoopSessionStore,
 
8
  _safe_message_doc,
9
  )
10
 
@@ -126,3 +129,111 @@ async def test_noop_store_mark_pro_seen_returns_none():
126
  store = NoopSessionStore()
127
  assert await store.mark_pro_seen("u1", is_pro=True) is None
128
  assert await store.mark_pro_seen("u1", is_pro=False) is None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  """Unit tests for the optional durable session store abstraction."""
2
 
3
+ from datetime import datetime
4
+
5
  import pytest
6
 
7
  from agent.core.session_persistence import (
8
  MongoSessionStore,
9
  NoopSessionStore,
10
+ USAGE_EVENT_TYPES,
11
  _safe_message_doc,
12
  )
13
 
 
129
  store = NoopSessionStore()
130
  assert await store.mark_pro_seen("u1", is_pro=True) is None
131
  assert await store.mark_pro_seen("u1", is_pro=False) is None
132
+
133
+
134
+ # ── load_usage_events ────────────────────────────────────────────────────
135
+
136
+
137
+ class _FakeAsyncCursor:
138
+ def __init__(self, docs):
139
+ self.docs = docs
140
+ self.sort_calls = []
141
+
142
+ def sort(self, *args, **kwargs):
143
+ self.sort_calls.append((args, kwargs))
144
+ return self
145
+
146
+ def __aiter__(self):
147
+ self._iter = iter(self.docs)
148
+ return self
149
+
150
+ async def __anext__(self):
151
+ try:
152
+ return next(self._iter)
153
+ except StopIteration:
154
+ raise StopAsyncIteration from None
155
+
156
+
157
+ class _FakeFindCollection:
158
+ def __init__(self, docs):
159
+ self.docs = docs
160
+ self.find_calls = []
161
+ self.cursors = []
162
+
163
+ def find(self, query, projection=None):
164
+ self.find_calls.append((query, projection))
165
+ cursor = _FakeAsyncCursor(self.docs)
166
+ self.cursors.append(cursor)
167
+ return cursor
168
+
169
+
170
+ class _FakeUsageDB:
171
+ def __init__(self, *, sessions, events) -> None:
172
+ self.sessions = _FakeFindCollection(sessions)
173
+ self.session_events = _FakeFindCollection(events)
174
+
175
+
176
+ def _store_with_fake_usage_db(*, sessions, events) -> MongoSessionStore:
177
+ s = MongoSessionStore.__new__(MongoSessionStore)
178
+ s.enabled = True
179
+ s.db = _FakeUsageDB(sessions=sessions, events=events)
180
+ return s
181
+
182
+
183
+ @pytest.mark.asyncio
184
+ async def test_load_usage_events_scopes_mongo_queries_to_current_user_and_window():
185
+ start = datetime(2026, 6, 1, 0, 0)
186
+ end = datetime(2026, 7, 1, 0, 0)
187
+ store = _store_with_fake_usage_db(
188
+ sessions=[{"_id": "s1"}, {"_id": "s2"}],
189
+ events=[
190
+ {"session_id": "s1", "event_type": "llm_call", "data": {"cost_usd": 1.0}}
191
+ ],
192
+ )
193
+
194
+ events = await store.load_usage_events("owner", start=start, end=end)
195
+
196
+ assert events == [
197
+ {"session_id": "s1", "event_type": "llm_call", "data": {"cost_usd": 1.0}}
198
+ ]
199
+ assert store.db.sessions.find_calls == [
200
+ (
201
+ {"visibility": {"$ne": "deleted"}, "user_id": "owner"},
202
+ {"_id": 1},
203
+ )
204
+ ]
205
+ assert store.db.session_events.find_calls == [
206
+ (
207
+ {
208
+ "session_id": {"$in": ["s1", "s2"]},
209
+ "event_type": {"$in": list(USAGE_EVENT_TYPES)},
210
+ "created_at": {"$gte": start, "$lt": end},
211
+ },
212
+ None,
213
+ )
214
+ ]
215
+ assert store.db.session_events.cursors[0].sort_calls == []
216
+
217
+
218
+ @pytest.mark.asyncio
219
+ async def test_load_usage_events_dev_mode_uses_requested_session_without_user_filter():
220
+ store = _store_with_fake_usage_db(
221
+ sessions=[{"_id": "s3"}],
222
+ events=[{"session_id": "s3", "event_type": "hf_job_complete", "data": {}}],
223
+ )
224
+
225
+ events = await store.load_usage_events("dev", session_id="s3")
226
+
227
+ assert events == [{"session_id": "s3", "event_type": "hf_job_complete", "data": {}}]
228
+ assert store.db.sessions.find_calls == [
229
+ ({"visibility": {"$ne": "deleted"}, "_id": "s3"}, {"_id": 1})
230
+ ]
231
+ assert store.db.session_events.find_calls == [
232
+ (
233
+ {
234
+ "session_id": {"$in": ["s3"]},
235
+ "event_type": {"$in": list(USAGE_EVENT_TYPES)},
236
+ },
237
+ None,
238
+ )
239
+ ]
tests/unit/test_telemetry_usage.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from types import SimpleNamespace
2
+
3
+ import pytest
4
+
5
+ from agent.core import telemetry
6
+
7
+
8
+ class FakeSession:
9
+ def __init__(self):
10
+ self.events = []
11
+
12
+ async def send_event(self, event):
13
+ self.events.append(event)
14
+
15
+
16
+ @pytest.mark.asyncio
17
+ async def test_record_hf_job_complete_emits_runtime_cost(monkeypatch):
18
+ async def fake_catalog():
19
+ return {"a100-large": 4.0}
20
+
21
+ monkeypatch.setattr(telemetry, "hf_jobs_price_catalog", fake_catalog)
22
+ monkeypatch.setattr(telemetry.time, "monotonic", lambda: 130.0)
23
+ session = FakeSession()
24
+
25
+ await telemetry.record_hf_job_complete(
26
+ session,
27
+ SimpleNamespace(id="job-1"),
28
+ flavor="a100-large",
29
+ final_status="COMPLETED",
30
+ submit_ts=100.0,
31
+ )
32
+
33
+ event = session.events[0]
34
+ assert event.event_type == "hf_job_complete"
35
+ assert event.data["wall_time_s"] == 30
36
+ assert event.data["billable_seconds_estimate"] == 30
37
+ assert event.data["price_usd_per_hour"] == 4.0
38
+ assert event.data["estimated_cost_usd"] == round(4.0 * 30 / 3600, 4)
39
+ assert event.data["cost_estimate_source"] == "runtime_price_catalog"
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_record_hf_job_complete_keeps_unknown_hardware_countable(monkeypatch):
44
+ async def fake_catalog():
45
+ return {}
46
+
47
+ monkeypatch.setattr(telemetry, "hf_jobs_price_catalog", fake_catalog)
48
+ monkeypatch.setattr(telemetry.time, "monotonic", lambda: 160.0)
49
+ session = FakeSession()
50
+
51
+ await telemetry.record_hf_job_complete(
52
+ session,
53
+ SimpleNamespace(id="job-2"),
54
+ flavor="future-gpu",
55
+ final_status="FAILED",
56
+ submit_ts=100.0,
57
+ )
58
+
59
+ data = session.events[0].data
60
+ assert data["wall_time_s"] == 60
61
+ assert data["billable_seconds_estimate"] == 60
62
+ assert data["price_usd_per_hour"] is None
63
+ assert data["estimated_cost_usd"] is None
64
+ assert data["cost_estimate_source"] == "unknown_price"
tests/unit/test_usage.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
+ import sys
3
+ from pathlib import Path
4
+ from types import SimpleNamespace
5
+
6
+ import pytest
7
+
8
+ _BACKEND_DIR = Path(__file__).resolve().parent.parent.parent / "backend"
9
+ if str(_BACKEND_DIR) not in sys.path:
10
+ sys.path.insert(0, str(_BACKEND_DIR))
11
+
12
+ from usage import ( # noqa: E402
13
+ _account_bucket_from_billing_usage,
14
+ aggregate_usage_events,
15
+ build_usage_response,
16
+ resolve_usage_windows,
17
+ )
18
+
19
+
20
+ def _event(event_type, data=None, created_at="2026-06-01T12:00:00+00:00"):
21
+ return {
22
+ "event_type": event_type,
23
+ "data": data or {},
24
+ "timestamp": created_at,
25
+ }
26
+
27
+
28
+ def test_aggregate_usage_events_sums_inference_and_jobs():
29
+ events = [
30
+ _event(
31
+ "llm_call",
32
+ {
33
+ "cost_usd": 0.125,
34
+ "prompt_tokens": 100,
35
+ "completion_tokens": 50,
36
+ "cache_read_tokens": 25,
37
+ "cache_creation_tokens": 5,
38
+ "total_tokens": 180,
39
+ },
40
+ ),
41
+ _event("llm_call", {"cost_usd": 0.25, "prompt_tokens": 10}),
42
+ _event(
43
+ "hf_job_complete",
44
+ {
45
+ "estimated_cost_usd": 1.5,
46
+ "billable_seconds_estimate": 1800,
47
+ },
48
+ ),
49
+ ]
50
+
51
+ usage = aggregate_usage_events(events, session_id="s1")
52
+
53
+ assert usage["session_id"] == "s1"
54
+ assert usage["llm_calls"] == 2
55
+ assert usage["hf_jobs_count"] == 1
56
+ assert usage["prompt_tokens"] == 110
57
+ assert usage["completion_tokens"] == 50
58
+ assert usage["cache_read_tokens"] == 25
59
+ assert usage["cache_creation_tokens"] == 5
60
+ assert usage["total_tokens"] == 190
61
+ assert usage["hf_jobs_billable_seconds_estimate"] == 1800
62
+ assert usage["inference_usd"] == 0.375
63
+ assert usage["hf_jobs_estimated_usd"] == 1.5
64
+ assert usage["total_usd"] == 1.875
65
+
66
+
67
+ def test_aggregate_usage_events_treats_missing_costs_as_zero():
68
+ usage = aggregate_usage_events(
69
+ [
70
+ _event("llm_call", {"prompt_tokens": 7}),
71
+ _event("hf_job_complete", {"wall_time_s": 60}),
72
+ ]
73
+ )
74
+
75
+ assert usage["llm_calls"] == 1
76
+ assert usage["hf_jobs_count"] == 1
77
+ assert usage["prompt_tokens"] == 7
78
+ assert usage["hf_jobs_billable_seconds_estimate"] == 60
79
+ assert usage["total_usd"] == 0.0
80
+
81
+
82
+ def test_account_bucket_from_hf_billing_usage_v2():
83
+ usage = _account_bucket_from_billing_usage(
84
+ {
85
+ "usage": {
86
+ "inferenceProviders": {
87
+ "usedNanoUsd": 1_500_000_000,
88
+ "numRequests": 12,
89
+ },
90
+ "jobs": {
91
+ "usedMicroUsd": 250_000,
92
+ "totalMinutes": 3.5,
93
+ },
94
+ }
95
+ },
96
+ window_start=datetime(2026, 6, 1, 0, 0, tzinfo=UTC),
97
+ window_end=datetime(2026, 6, 1, 1, 0, tzinfo=UTC),
98
+ timezone="UTC",
99
+ )
100
+
101
+ assert usage["inference_providers_usd"] == 1.5
102
+ assert usage["hf_jobs_usd"] == 0.25
103
+ assert usage["total_usd"] == 1.75
104
+ assert usage["inference_provider_requests"] == 12
105
+ assert usage["hf_jobs_minutes"] == 3.5
106
+
107
+
108
+ def test_usage_windows_respect_browser_timezone():
109
+ windows = resolve_usage_windows(
110
+ "America/Los_Angeles",
111
+ now=datetime(2026, 6, 1, 7, 30, tzinfo=UTC),
112
+ )
113
+
114
+ assert windows["timezone"] == "America/Los_Angeles"
115
+ assert windows["today_start_utc"] == datetime(2026, 6, 1, 7, 0, tzinfo=UTC)
116
+ assert windows["month_start_utc"] == datetime(2026, 6, 1, 7, 0, tzinfo=UTC)
117
+
118
+
119
+ class _NoopStore:
120
+ enabled = False
121
+
122
+
123
+ class _RecordingStore:
124
+ enabled = True
125
+
126
+ def __init__(self):
127
+ self.calls = []
128
+
129
+ async def load_usage_events(self, user_id, **kwargs):
130
+ self.calls.append((user_id, kwargs))
131
+ return []
132
+
133
+
134
+ class _Manager:
135
+ def __init__(self, sessions, store=None):
136
+ self.sessions = sessions
137
+ self.store = store or _NoopStore()
138
+
139
+ def _store(self):
140
+ return self.store
141
+
142
+
143
+ def _agent_session(session_id, user_id, events):
144
+ return SimpleNamespace(
145
+ session_id=session_id,
146
+ user_id=user_id,
147
+ session=SimpleNamespace(logged_events=events),
148
+ )
149
+
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_runtime_usage_excludes_other_users():
153
+ manager = _Manager(
154
+ {
155
+ "owner-session": _agent_session(
156
+ "owner-session",
157
+ "owner",
158
+ [_event("llm_call", {"cost_usd": 0.5})],
159
+ ),
160
+ "other-session": _agent_session(
161
+ "other-session",
162
+ "other",
163
+ [_event("llm_call", {"cost_usd": 99.0})],
164
+ ),
165
+ }
166
+ )
167
+
168
+ usage = await build_usage_response(
169
+ manager,
170
+ user_id="owner",
171
+ session_id=None,
172
+ timezone_name="UTC",
173
+ now=datetime(2026, 6, 1, 13, 0, tzinfo=UTC),
174
+ )
175
+
176
+ assert usage["today"]["llm_calls"] == 1
177
+ assert usage["today"]["inference_usd"] == 0.5
178
+ assert usage["month"]["inference_usd"] == 0.5
179
+
180
+
181
+ @pytest.mark.asyncio
182
+ async def test_runtime_usage_includes_requested_session_total():
183
+ manager = _Manager(
184
+ {
185
+ "s1": _agent_session(
186
+ "s1",
187
+ "owner",
188
+ [
189
+ _event(
190
+ "llm_call",
191
+ {"cost_usd": 0.25},
192
+ created_at="2026-05-01T12:00:00+00:00",
193
+ )
194
+ ],
195
+ )
196
+ }
197
+ )
198
+
199
+ usage = await build_usage_response(
200
+ manager,
201
+ user_id="owner",
202
+ session_id="s1",
203
+ timezone_name="UTC",
204
+ now=datetime(2026, 6, 1, 13, 0, tzinfo=UTC),
205
+ )
206
+
207
+ assert usage["session"]["session_id"] == "s1"
208
+ assert usage["session"]["inference_usd"] == 0.25
209
+ assert usage["today"]["inference_usd"] == 0.0
210
+
211
+
212
+ @pytest.mark.asyncio
213
+ async def test_runtime_usage_interprets_naive_timestamps_in_browser_timezone():
214
+ manager = _Manager(
215
+ {
216
+ "s1": _agent_session(
217
+ "s1",
218
+ "owner",
219
+ [
220
+ _event(
221
+ "llm_call",
222
+ {"cost_usd": 0.25, "total_tokens": 42},
223
+ created_at="2026-06-05T15:00:00",
224
+ )
225
+ ],
226
+ )
227
+ }
228
+ )
229
+
230
+ usage = await build_usage_response(
231
+ manager,
232
+ user_id="owner",
233
+ session_id="s1",
234
+ timezone_name="Europe/Zurich",
235
+ now=datetime(2026, 6, 5, 13, 30, tzinfo=UTC),
236
+ )
237
+
238
+ assert usage["session"]["llm_calls"] == 1
239
+ assert usage["today"]["llm_calls"] == 1
240
+ assert usage["month"]["llm_calls"] == 1
241
+ assert usage["today"]["total_tokens"] == 42
242
+
243
+
244
+ @pytest.mark.asyncio
245
+ async def test_hf_account_usage_uses_session_start_for_current_delta(monkeypatch):
246
+ session_created_at = datetime(2026, 6, 5, 12, 0, tzinfo=UTC)
247
+ manager = _Manager(
248
+ {
249
+ "s1": SimpleNamespace(
250
+ session_id="s1",
251
+ user_id="owner",
252
+ created_at=session_created_at,
253
+ session=SimpleNamespace(logged_events=[]),
254
+ )
255
+ }
256
+ )
257
+ calls = []
258
+
259
+ async def fake_fetch(_token, *, start, end):
260
+ calls.append((start, end))
261
+ if start == session_created_at:
262
+ used_nano = 500_000_000
263
+ elif start == datetime(2026, 6, 5, 0, 0, tzinfo=UTC):
264
+ used_nano = 1_000_000_000
265
+ else:
266
+ used_nano = 2_000_000_000
267
+ return {
268
+ "usage": {
269
+ "inferenceProviders": {
270
+ "usedNanoUsd": used_nano,
271
+ "includedNanoUsd": 2_000_000_000,
272
+ "limitNanoUsd": 5_000_000_000,
273
+ "numRequests": 4,
274
+ },
275
+ "jobs": {"usedMicroUsd": 0, "totalMinutes": 0},
276
+ }
277
+ }
278
+
279
+ monkeypatch.setattr("usage._fetch_hf_billing_usage_v2", fake_fetch)
280
+
281
+ usage = await build_usage_response(
282
+ manager,
283
+ user_id="owner",
284
+ hf_token="hf_fake",
285
+ session_id="s1",
286
+ timezone_name="UTC",
287
+ now=datetime(2026, 6, 5, 13, 0, tzinfo=UTC),
288
+ )
289
+
290
+ assert usage["hf_account"]["available"] is True
291
+ assert usage["hf_account"]["current_session"]["inference_providers_usd"] == 0.5
292
+ assert usage["hf_account"]["today"]["inference_providers_usd"] == 1.0
293
+ assert usage["hf_account"]["month"]["inference_providers_usd"] == 2.0
294
+ assert usage["hf_account"]["inference_providers_credits"] == {
295
+ "included_usd": 2.0,
296
+ "used_usd": 2.0,
297
+ "remaining_included_usd": 0.0,
298
+ "limit_usd": 5.0,
299
+ "remaining_limit_usd": 3.0,
300
+ "num_requests": 4,
301
+ "period_start": None,
302
+ "period_end": None,
303
+ }
304
+ assert {start for start, _ in calls} == {
305
+ datetime(2026, 6, 5, 0, 0, tzinfo=UTC),
306
+ datetime(2026, 6, 1, 0, 0, tzinfo=UTC),
307
+ session_created_at,
308
+ }
309
+
310
+
311
+ @pytest.mark.asyncio
312
+ async def test_compact_usage_skips_unused_rollup_loads(monkeypatch):
313
+ session_created_at = datetime(2026, 6, 5, 12, 0, tzinfo=UTC)
314
+ store = _RecordingStore()
315
+ manager = _Manager(
316
+ {
317
+ "s1": SimpleNamespace(
318
+ session_id="s1",
319
+ user_id="owner",
320
+ created_at=session_created_at,
321
+ session=SimpleNamespace(logged_events=[]),
322
+ )
323
+ },
324
+ store=store,
325
+ )
326
+ billing_starts = []
327
+
328
+ async def fake_fetch(_token, *, start, end):
329
+ billing_starts.append(start)
330
+ return {
331
+ "usage": {
332
+ "inferenceProviders": {
333
+ "usedNanoUsd": 0,
334
+ "includedNanoUsd": 2_000_000_000,
335
+ "limitNanoUsd": 0,
336
+ },
337
+ "jobs": {"usedMicroUsd": 0, "totalMinutes": 0},
338
+ }
339
+ }
340
+
341
+ monkeypatch.setattr("usage._fetch_hf_billing_usage_v2", fake_fetch)
342
+
343
+ usage = await build_usage_response(
344
+ manager,
345
+ user_id="owner",
346
+ hf_token="hf_fake",
347
+ session_id="s1",
348
+ timezone_name="UTC",
349
+ now=datetime(2026, 6, 5, 13, 0, tzinfo=UTC),
350
+ include_rollups=False,
351
+ )
352
+
353
+ assert store.calls == [("owner", {"session_id": "s1", "start": None, "end": None})]
354
+ assert set(billing_starts) == {
355
+ datetime(2026, 6, 1, 0, 0, tzinfo=UTC),
356
+ session_created_at,
357
+ }
358
+ assert datetime(2026, 6, 5, 0, 0, tzinfo=UTC) not in billing_starts
359
+ assert usage["today"]["llm_calls"] == 0
360
+ assert usage["month"]["llm_calls"] == 0
361
+ assert usage["hf_account"]["today"] is None
362
+ assert usage["hf_account"]["month"]["inference_providers_usd"] == 0.0