lewtun HF Staff OpenAI Codex commited on
Commit
4ecc3bd
·
unverified ·
1 Parent(s): d08ea78

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 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. Uploads are detached subprocesses this is fast.
 
54
  try:
55
- for sid, agent_session in list(session_manager.sessions.items()):
56
- sess = agent_session.session
57
- if sess.config.save_sessions:
58
- try:
59
- sess.save_and_upload_detached(sess.config.session_dataset_repo)
60
- logger.info("Flushed session %s on shutdown", sid)
61
- except Exception as e:
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
- _background_teardown_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,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
- _background_teardown_tasks.add(task)
763
- task.add_done_callback(_background_teardown_tasks.discard)
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
- agent_session.session.save_and_upload_detached(
914
- agent_session.session.config.session_dataset_repo
 
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
- agent_session.session.save_and_upload_detached(
1158
- agent_session.session.config.session_dataset_repo
 
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.cost_estimation import SPACE_PRICE_USD_PER_HOUR
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
- ordered_events = [
293
- event
294
- for _, event in sorted(
295
- lifecycle_events,
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={useJobEstimate ? 'HF Jobs estimated' : 'HF Jobs'}
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(