pepijn223 HF Staff commited on
Commit
46a15a1
·
verified ·
1 Parent(s): 0ce755e

Report measured usage costs and link completed runs to the annotation viewer

Browse files
Files changed (10) hide show
  1. README.md +10 -0
  2. deploy.py +1 -1
  3. frontend/src/main.tsx +119 -8
  4. frontend/src/style.css +68 -0
  5. provider.py +41 -5
  6. reporting.py +48 -0
  7. server.py +8 -1
  8. studio.py +14 -4
  9. test_studio.py +68 -0
  10. worker.py +43 -6
README.md CHANGED
@@ -123,3 +123,13 @@ out. A refreshable dashboard lists each user's jobs, progress and cancellation.
123
 
124
  Hugging Face login is available on the deployed Space. Local mode is preview
125
  only: it cannot launch jobs using a developer's cached credentials.
 
 
 
 
 
 
 
 
 
 
 
123
 
124
  Hugging Face login is available on the deployed Space. Local mode is preview
125
  only: it cannot launch jobs using a developer's cached credentials.
126
+
127
+ The Jobs dashboard refreshes every 15 seconds and shows usage-derived cost:
128
+ provider-reported input/output/cache tokens at the pinned rates, plus HF-reported
129
+ CPU runtime rounded to minutes. Unknown request charges remain separately
130
+ reserved; this is not an invoice. Worker usage checkpoints are saved every ten
131
+ seconds. The billing link opens the user's final HF charges.
132
+
133
+ Finished annotated copies offer **Show annotations**, opening the first
134
+ materialized episode with `?tab=annotations` in the visualizer. Private results
135
+ require signing in there. JSONL-only exports retain their Results download link.
deploy.py CHANGED
@@ -57,7 +57,7 @@ def main():
57
  "frontend/dist/**",
58
  ],
59
  delete_patterns=["app.py"],
60
- commit_message="Discover dataset camera views before estimating annotation jobs",
61
  )
62
  print(f"https://huggingface.co/spaces/{repo}\nRevision: {result.oid}")
63
 
 
57
  "frontend/dist/**",
58
  ],
59
  delete_patterns=["app.py"],
60
+ commit_message="Report measured usage costs and link completed runs to the annotation viewer",
61
  )
62
  print(f"https://huggingface.co/spaces/{repo}\nRevision: {result.oid}")
63
 
frontend/src/main.tsx CHANGED
@@ -117,7 +117,24 @@ type Progress = {
117
  expected: number;
118
  completed: number;
119
  failed: number;
120
- usage: { measured_inference_usd?: number };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  };
122
  const EXPORT = "Annotation files only",
123
  COPY = "Annotated dataset copy";
@@ -348,7 +365,12 @@ function App() {
348
  } catch {}
349
  }, [dark]);
350
  useEffect(() => {
351
- if (page === "jobs" && session?.user) void loadJobs();
 
 
 
 
 
352
  }, [page, session?.user?.name]);
353
  useEffect(() => {
354
  if (review) modalRef.current?.showModal();
@@ -471,20 +493,22 @@ function App() {
471
  setJobsLoading(true);
472
  setError("");
473
  try {
474
- setJobs(await api<Job[]>("/api/jobs"));
 
 
475
  } catch (e) {
476
  setError((e as Error).message);
477
  } finally {
478
  setJobsLoading(false);
479
  }
480
  }
481
- async function getProgress(job: Job) {
482
  try {
483
  setProgress((old) => ({ ...old }));
484
  const p = await api<Progress>(`/api/jobs/${job.id}/progress`);
485
  setProgress((old) => ({ ...old, [job.id]: p }));
486
  } catch (e) {
487
- setNotice((e as Error).message);
488
  }
489
  }
490
  async function submit() {
@@ -1440,7 +1464,92 @@ function App() {
1440
  </small>
1441
  </div>
1442
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1443
  <div className="job-actions">
 
 
 
 
 
 
 
 
 
 
 
 
1444
  <button
1445
  className="button secondary"
1446
  onClick={() => getProgress(j)}
@@ -1480,9 +1589,11 @@ function App() {
1480
  </div>
1481
  )}
1482
  <p className="section-footnote">
1483
- Only jobs launched from Annotation Studio appear here. A
1484
- completed job can contain partial results; check the saved run
1485
- report.
 
 
1486
  </p>
1487
  </>
1488
  )}
 
117
  expected: number;
118
  completed: number;
119
  failed: number;
120
+ usage: {
121
+ measured_inference_usd?: number;
122
+ successful_calls?: number;
123
+ input_tokens?: number;
124
+ output_tokens?: number;
125
+ rate_limited_requests?: number;
126
+ };
127
+ cost?: {
128
+ inference_usd: number | null;
129
+ cpu_usd: number | null;
130
+ total_usd: number | null;
131
+ uncertain_inference_usd: number;
132
+ running_seconds: number | null;
133
+ basis: string;
134
+ billing_url: string;
135
+ };
136
+ annotation_url?: string | null;
137
+ output_mode?: string;
138
  };
139
  const EXPORT = "Annotation files only",
140
  COPY = "Annotated dataset copy";
 
365
  } catch {}
366
  }, [dark]);
367
  useEffect(() => {
368
+ if (page !== "jobs" || !session?.user) return;
369
+ void loadJobs();
370
+ const timer = window.setInterval(() => {
371
+ if (document.visibilityState === "visible") void loadJobs();
372
+ }, 15000);
373
+ return () => window.clearInterval(timer);
374
  }, [page, session?.user?.name]);
375
  useEffect(() => {
376
  if (review) modalRef.current?.showModal();
 
493
  setJobsLoading(true);
494
  setError("");
495
  try {
496
+ const rows = await api<Job[]>("/api/jobs");
497
+ setJobs(rows);
498
+ await Promise.allSettled(rows.map((job) => getProgress(job, true)));
499
  } catch (e) {
500
  setError((e as Error).message);
501
  } finally {
502
  setJobsLoading(false);
503
  }
504
  }
505
+ async function getProgress(job: Job, quiet = false) {
506
  try {
507
  setProgress((old) => ({ ...old }));
508
  const p = await api<Progress>(`/api/jobs/${job.id}/progress`);
509
  setProgress((old) => ({ ...old, [job.id]: p }));
510
  } catch (e) {
511
+ if (!quiet) setNotice((e as Error).message);
512
  }
513
  }
514
  async function submit() {
 
1464
  </small>
1465
  </div>
1466
  )}
1467
+ {progress[j.id]?.cost && (
1468
+ <div className="job-cost">
1469
+ <div className="job-cost-total">
1470
+ <div>
1471
+ <span>Usage cost</span>
1472
+ <strong>
1473
+ {progress[j.id].cost!.total_usd == null
1474
+ ? "Pending"
1475
+ : new Intl.NumberFormat("en-US", {
1476
+ style: "currency",
1477
+ currency: "USD",
1478
+ minimumFractionDigits: 4,
1479
+ }).format(progress[j.id].cost!.total_usd!)}
1480
+ </strong>
1481
+ </div>
1482
+ <span className="quiet-badge">
1483
+ {terminal.has(j.stage)
1484
+ ? "Run ended"
1485
+ : "Updates every 15 s"}
1486
+ </span>
1487
+ </div>
1488
+ <div className="job-cost-breakdown">
1489
+ <span>
1490
+ Model inference{" "}
1491
+ <b>
1492
+ {progress[j.id].cost!.inference_usd == null
1493
+ ? "Pending"
1494
+ : "$" +
1495
+ progress[j.id].cost!.inference_usd!.toFixed(
1496
+ 4,
1497
+ )}
1498
+ </b>
1499
+ </span>
1500
+ <span>
1501
+ CPU runtime{" "}
1502
+ <b>
1503
+ {progress[j.id].cost!.cpu_usd == null
1504
+ ? "Pending"
1505
+ : "$" +
1506
+ progress[j.id].cost!.cpu_usd!.toFixed(4)}
1507
+ </b>
1508
+ </span>
1509
+ <span>
1510
+ Model calls{" "}
1511
+ <b>
1512
+ {num(
1513
+ progress[j.id].usage.successful_calls || 0,
1514
+ )}
1515
+ </b>
1516
+ </span>
1517
+ </div>
1518
+ {progress[j.id].cost!.uncertain_inference_usd >
1519
+ 0.000001 && (
1520
+ <p className="cost-uncertain">
1521
+ Additional uncertain inference charges: up to $
1522
+ {progress[
1523
+ j.id
1524
+ ].cost!.uncertain_inference_usd.toFixed(4)}
1525
+ . These are reserved separately.
1526
+ </p>
1527
+ )}
1528
+ <p>
1529
+ {progress[j.id].cost!.basis}{" "}
1530
+ <a
1531
+ href={progress[j.id].cost!.billing_url}
1532
+ target="_blank"
1533
+ rel="noreferrer"
1534
+ >
1535
+ View HF billing <ArrowUpRight size={10} />
1536
+ </a>
1537
+ </p>
1538
+ </div>
1539
+ )}
1540
  <div className="job-actions">
1541
+ {terminal.has(j.stage) &&
1542
+ progress[j.id]?.annotation_url && (
1543
+ <a
1544
+ className="button primary"
1545
+ href={progress[j.id].annotation_url!}
1546
+ target="_blank"
1547
+ rel="noreferrer"
1548
+ >
1549
+ <Layers3 size={14} /> Show annotations{" "}
1550
+ <ArrowUpRight size={13} />
1551
+ </a>
1552
+ )}
1553
  <button
1554
  className="button secondary"
1555
  onClick={() => getProgress(j)}
 
1589
  </div>
1590
  )}
1591
  <p className="section-footnote">
1592
+ Show annotations opens the visualizer’s Annotations tab for an
1593
+ annotated dataset copy. Sign in there to access private results.
1594
+ Annotation-file exports can be downloaded from Results. Only
1595
+ jobs launched from Annotation Studio appear here. A completed
1596
+ job can contain partial results; check the saved run report.
1597
  </p>
1598
  </>
1599
  )}
frontend/src/style.css CHANGED
@@ -2491,3 +2491,71 @@ footer > span {
2491
  line-height: 1.6;
2492
  margin-top: 6px;
2493
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2491
  line-height: 1.6;
2492
  margin-top: 6px;
2493
  }
2494
+
2495
+ .job-cost {
2496
+ margin: 16px 0;
2497
+ padding: 18px;
2498
+ background: var(--surface-1);
2499
+ border: 1px solid var(--line);
2500
+ border-radius: 8px;
2501
+ }
2502
+ .job-cost-total {
2503
+ display: flex;
2504
+ align-items: center;
2505
+ justify-content: space-between;
2506
+ gap: 12px;
2507
+ }
2508
+ .job-cost-total > div {
2509
+ display: flex;
2510
+ align-items: baseline;
2511
+ gap: 12px;
2512
+ }
2513
+ .job-cost-total strong {
2514
+ font-size: 23px;
2515
+ font-variant-numeric: tabular-nums;
2516
+ letter-spacing: -0.5px;
2517
+ }
2518
+ .job-cost-total span {
2519
+ font-size: 12px;
2520
+ color: var(--fg-muted);
2521
+ }
2522
+ .job-cost-breakdown {
2523
+ display: flex;
2524
+ flex-wrap: wrap;
2525
+ gap: 12px 28px;
2526
+ margin-top: 16px;
2527
+ font-size: 12px;
2528
+ color: var(--fg-muted);
2529
+ }
2530
+ .job-cost-breakdown b {
2531
+ font-weight: 550;
2532
+ color: var(--fg-soft);
2533
+ margin-left: 8px;
2534
+ font-variant-numeric: tabular-nums;
2535
+ }
2536
+ .job-cost p {
2537
+ font-size: 11px;
2538
+ color: var(--fg-faint);
2539
+ line-height: 1.6;
2540
+ margin-top: 14px;
2541
+ }
2542
+ .job-cost p a {
2543
+ text-decoration: underline;
2544
+ white-space: nowrap;
2545
+ }
2546
+ .job-cost .cost-uncertain {
2547
+ color: var(--fg-soft);
2548
+ }
2549
+ @media (max-width: 520px) {
2550
+ .job-cost-total {
2551
+ align-items: flex-start;
2552
+ }
2553
+ .job-cost-total > div {
2554
+ flex-direction: column;
2555
+ gap: 5px;
2556
+ }
2557
+ .job-cost-breakdown {
2558
+ flex-direction: column;
2559
+ gap: 8px;
2560
+ }
2561
+ }
provider.py CHANGED
@@ -17,10 +17,16 @@ class BudgetExceededError(RuntimeError):
17
  pass
18
 
19
 
 
 
 
 
20
  class Budget:
21
- def __init__(self, limit, input_rate, output_rate):
22
  self.limit, self.input_rate, self.output_rate = limit, input_rate, output_rate
23
  self.reserved, self.measured, self.calls = 0.0, 0.0, 0
 
 
24
  self.lock = threading.Lock()
25
 
26
  def reserve(self, max_tokens):
@@ -30,6 +36,7 @@ class Budget:
30
  if self.reserved + amount > self.limit:
31
  raise BudgetExceededError("Spending target reached; completed results are saved.")
32
  self.reserved += amount
 
33
  return amount
34
 
35
  def reconcile(self, reservation, usage):
@@ -38,11 +45,22 @@ class Budget:
38
  values = [usage.get("prompt_tokens"), usage.get("completion_tokens")]
39
  if any(type(v) is not int or v < 0 for v in values):
40
  return
41
- cost = (values[0] * self.input_rate + values[1] * self.output_rate) / 1e6
 
 
 
 
 
 
 
 
42
  with self.lock:
43
  self.reserved += cost - reservation
44
  self.measured += cost
45
  self.calls += 1
 
 
 
46
 
47
  def summary(self):
48
  with self.lock:
@@ -51,6 +69,12 @@ class Budget:
51
  "reserved_including_uncertain_usd": self.reserved,
52
  "successful_calls": self.calls,
53
  "inference_target_usd": self.limit,
 
 
 
 
 
 
54
  }
55
 
56
 
@@ -82,17 +106,25 @@ def compact_images(messages, max_bytes=2_800_000):
82
 
83
 
84
  class Provider:
85
- def __init__(self, token, model, budget):
86
  self.token, self.model, self.budget = token, model, budget
87
  self.semaphore = threading.BoundedSemaphore(2)
 
88
  self.client = _GenericTextClient(self.generate, VlmConfig(max_new_tokens=4096, temperature=0.2))
89
 
90
  def generate_json(self, *args, **kwargs):
91
  return self.client.generate_json(*args, **kwargs)
92
 
 
 
 
 
 
 
93
  def generate(self, batch, max_tokens, temperature):
94
  output = []
95
  for messages in batch:
 
96
  content, _ = _to_openai_messages(messages)
97
  body = {
98
  "model": self.model,
@@ -104,22 +136,26 @@ class Provider:
104
  }
105
  with self.semaphore:
106
  for attempt in range(8):
 
107
  reservation = self.budget.reserve(max_tokens)
108
  try:
109
  response = requests.post(
110
  "https://router.huggingface.co/v1/chat/completions",
111
  headers={"Authorization": "Bearer " + self.token},
112
  json=body,
113
- timeout=(20, 300),
114
  )
115
  except requests.RequestException:
 
116
  # The provider may have processed the request. Never release its reservation.
117
  raise RuntimeError(
118
  "Inference connection failed; charges are reserved conservatively."
119
  ) from None
120
  if response.status_code == 429:
121
  # Rejections may be ambiguous; retain the reservation before retrying.
122
- time.sleep(min(2**attempt, 30))
 
 
123
  continue
124
  if response.status_code >= 400:
125
  raise RuntimeError(f"Inference provider returned HTTP {response.status_code}.")
 
17
  pass
18
 
19
 
20
+ class DeadlineExceededError(BudgetExceededError):
21
+ pass
22
+
23
+
24
  class Budget:
25
+ def __init__(self, limit, input_rate, output_rate, cached_input_rate=None):
26
  self.limit, self.input_rate, self.output_rate = limit, input_rate, output_rate
27
  self.reserved, self.measured, self.calls = 0.0, 0.0, 0
28
+ self.cached_input_rate = input_rate if cached_input_rate is None else cached_input_rate
29
+ self.input_tokens = self.output_tokens = self.cached_tokens = self.attempts = self.rate_limits = 0
30
  self.lock = threading.Lock()
31
 
32
  def reserve(self, max_tokens):
 
36
  if self.reserved + amount > self.limit:
37
  raise BudgetExceededError("Spending target reached; completed results are saved.")
38
  self.reserved += amount
39
+ self.attempts += 1
40
  return amount
41
 
42
  def reconcile(self, reservation, usage):
 
45
  values = [usage.get("prompt_tokens"), usage.get("completion_tokens")]
46
  if any(type(v) is not int or v < 0 for v in values):
47
  return
48
+ cached = usage.get(
49
+ "cached_tokens", (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0)
50
+ )
51
+ cached = min(values[0], max(0, cached)) if type(cached) is int else 0
52
+ cost = (
53
+ (values[0] - cached) * self.input_rate
54
+ + cached * self.cached_input_rate
55
+ + values[1] * self.output_rate
56
+ ) / 1e6
57
  with self.lock:
58
  self.reserved += cost - reservation
59
  self.measured += cost
60
  self.calls += 1
61
+ self.input_tokens += values[0]
62
+ self.output_tokens += values[1]
63
+ self.cached_tokens += cached
64
 
65
  def summary(self):
66
  with self.lock:
 
69
  "reserved_including_uncertain_usd": self.reserved,
70
  "successful_calls": self.calls,
71
  "inference_target_usd": self.limit,
72
+ "input_tokens": self.input_tokens,
73
+ "output_tokens": self.output_tokens,
74
+ "cached_input_tokens": self.cached_tokens,
75
+ "attempted_requests": self.attempts,
76
+ "requests_without_usage": self.attempts - self.calls,
77
+ "rate_limited_requests": self.rate_limits,
78
  }
79
 
80
 
 
106
 
107
 
108
  class Provider:
109
+ def __init__(self, token, model, budget, deadline=None):
110
  self.token, self.model, self.budget = token, model, budget
111
  self.semaphore = threading.BoundedSemaphore(2)
112
+ self.deadline = deadline
113
  self.client = _GenericTextClient(self.generate, VlmConfig(max_new_tokens=4096, temperature=0.2))
114
 
115
  def generate_json(self, *args, **kwargs):
116
  return self.client.generate_json(*args, **kwargs)
117
 
118
+ def remaining_time(self):
119
+ left = self.deadline - time.time() if self.deadline else 300
120
+ if left <= 1:
121
+ raise DeadlineExceededError("Runtime limit reached; saving completed episodes.")
122
+ return left
123
+
124
  def generate(self, batch, max_tokens, temperature):
125
  output = []
126
  for messages in batch:
127
+ self.remaining_time()
128
  content, _ = _to_openai_messages(messages)
129
  body = {
130
  "model": self.model,
 
136
  }
137
  with self.semaphore:
138
  for attempt in range(8):
139
+ left = self.remaining_time()
140
  reservation = self.budget.reserve(max_tokens)
141
  try:
142
  response = requests.post(
143
  "https://router.huggingface.co/v1/chat/completions",
144
  headers={"Authorization": "Bearer " + self.token},
145
  json=body,
146
+ timeout=(min(20, left), min(300, left)),
147
  )
148
  except requests.RequestException:
149
+ self.remaining_time()
150
  # The provider may have processed the request. Never release its reservation.
151
  raise RuntimeError(
152
  "Inference connection failed; charges are reserved conservatively."
153
  ) from None
154
  if response.status_code == 429:
155
  # Rejections may be ambiguous; retain the reservation before retrying.
156
+ with self.budget.lock:
157
+ self.budget.rate_limits += 1
158
+ time.sleep(min(2**attempt, 30, self.remaining_time()))
159
  continue
160
  if response.status_code >= 400:
161
  raise RuntimeError(f"Inference provider returned HTTP {response.status_code}.")
reporting.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Usage-derived cost reporting and links to materialized annotations."""
2
+
3
+ import json
4
+ import math
5
+ from datetime import UTC, datetime
6
+
7
+ from studio import COPY
8
+
9
+
10
+ def usage_costs(job, report):
11
+ usage = report.get("usage") or {}
12
+ manifest = report.get("manifest") or {}
13
+ if not manifest:
14
+ try:
15
+ manifest = json.loads((getattr(job, "environment", None) or {}).get("ANNOTATION_MANIFEST", "{}"))
16
+ except (TypeError, ValueError):
17
+ manifest = {}
18
+ rate = manifest.get("costs", {}).get("cpu_rate")
19
+ seconds = getattr(getattr(job, "durations", None), "running_secs", None)
20
+ if seconds is None and getattr(job, "started_at", None):
21
+ end = getattr(job, "finished_at", None) or datetime.now(UTC)
22
+ seconds = max(0, (end - job.started_at).total_seconds())
23
+ cpu = None
24
+ if isinstance(seconds, (int, float)) and math.isfinite(seconds) and rate is not None:
25
+ cpu = math.ceil(max(0, seconds) / 60) * rate
26
+ inference = usage.get("measured_inference_usd")
27
+ return {
28
+ "inference_usd": inference,
29
+ "cpu_usd": cpu,
30
+ "total_usd": inference + cpu if inference is not None and cpu is not None else None,
31
+ "uncertain_inference_usd": max(
32
+ 0, usage.get("reserved_including_uncertain_usd", 0) - (inference or 0)
33
+ ),
34
+ "running_seconds": seconds,
35
+ "basis": "Reported tokens × published rates, plus HF job runtime rounded to minutes. Final HF billing may differ.",
36
+ "billing_url": "https://huggingface.co/settings/billing",
37
+ }
38
+
39
+
40
+ def annotation_url(report):
41
+ manifest = report.get("manifest", {})
42
+ written = report.get("written_episode_ids", [])
43
+ # Old completed copy reports predate the explicit materialization checkpoint.
44
+ if not written and report.get("status") == "completed":
45
+ written = [e["episode_index"] for e in report.get("episodes", []) if e["status"] == "completed"]
46
+ if manifest.get("mode") != COPY or not written:
47
+ return None
48
+ return f"https://lerobot-visualize-dataset.hf.space/{manifest['output']}/{min(written)}?tab=annotations"
server.py CHANGED
@@ -18,6 +18,7 @@ from fastapi.staticfiles import StaticFiles
18
  from huggingface_hub import HfApi, hf_hub_download
19
  from prompt_config import public_catalog, validate_prompts
20
  from pydantic import BaseModel, Field
 
21
  from studio import EXPORT, FEATURES, launch, prepare, read_cameras
22
 
23
  app = FastAPI(title="LeRobot Annotation Studio", docs_url=None, redoc_url=None, openapi_url=None)
@@ -390,11 +391,17 @@ def progress(job_id: str, request: Request):
390
  report = json.loads(Path(path).read_text())
391
  episodes = report.get("episodes", [])
392
  return {
393
- "status": report["status"],
 
 
 
394
  "expected": report["expected_episodes"],
395
  "completed": sum(e["status"] == "completed" for e in episodes),
396
  "failed": sum(e["status"] != "completed" for e in episodes),
397
  "usage": report.get("usage", {}),
 
 
 
398
  }
399
  except Exception as exc:
400
  raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
 
18
  from huggingface_hub import HfApi, hf_hub_download
19
  from prompt_config import public_catalog, validate_prompts
20
  from pydantic import BaseModel, Field
21
+ from reporting import annotation_url, usage_costs
22
  from studio import EXPORT, FEATURES, launch, prepare, read_cameras
23
 
24
  app = FastAPI(title="LeRobot Annotation Studio", docs_url=None, redoc_url=None, openapi_url=None)
 
391
  report = json.loads(Path(path).read_text())
392
  episodes = report.get("episodes", [])
393
  return {
394
+ "status": "interrupted"
395
+ if job.status.stage in {"COMPLETED", "ERROR", "CANCELED", "DELETED"}
396
+ and report["status"] == "running"
397
+ else report["status"],
398
  "expected": report["expected_episodes"],
399
  "completed": sum(e["status"] == "completed" for e in episodes),
400
  "failed": sum(e["status"] != "completed" for e in episodes),
401
  "usage": report.get("usage", {}),
402
+ "cost": usage_costs(job, report),
403
+ "annotation_url": annotation_url(report),
404
+ "output_mode": report.get("manifest", {}).get("mode"),
405
  }
406
  except Exception as exc:
407
  raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
studio.py CHANGED
@@ -19,6 +19,7 @@ from prompt_config import TEMPLATES, validate_prompts
19
  MODEL = "Qwen/Qwen3.8-Flash-Next:featherless-ai"
20
  INPUT_RATE = 0.15
21
  OUTPUT_RATE = 0.50
 
22
  PRICE_DATE = "2026-09-25"
23
  FEATURES = {
24
  "Subtasks": "subtask",
@@ -217,6 +218,7 @@ def estimate(selection, features, vqa_hz, hours, cpu_rate, prompts=None):
217
  "hours": hours,
218
  "input_rate": INPUT_RATE,
219
  "output_rate": OUTPUT_RATE,
 
220
  "price_date": PRICE_DATE,
221
  }
222
 
@@ -255,7 +257,7 @@ def prepare(dataset, start, count, features, camera, vqa_hz, hours, mode, token=
255
  }
256
 
257
 
258
- def job_command(space, revision):
259
  # Only trusted deployment values enter shell syntax. User options travel in JSON env.
260
  validate_repo_id(space)
261
  if not re.fullmatch(r"[a-f0-9]{40}", revision):
@@ -271,6 +273,9 @@ def job_command(space, revision):
271
  "\n".join(
272
  [
273
  "set -euo pipefail",
 
 
 
274
  "mkdir -p /workspace/app /workspace/source",
275
  "cd /workspace",
276
  "pip install --quiet huggingface_hub==1.26.0",
@@ -285,7 +290,11 @@ def job_command(space, revision):
285
  ]
286
 
287
 
288
- def launch(plan, output, budget, acknowledged, token, space=None, api_factory=HfApi):
 
 
 
 
289
  if not token:
290
  raise ValueError("Sign in with Hugging Face before starting a job.")
291
  if not acknowledged:
@@ -343,13 +352,14 @@ def launch(plan, output, budget, acknowledged, token, space=None, api_factory=Hf
343
  "space": space,
344
  "space_revision": revision,
345
  "owner": username,
 
346
  }
347
  )
348
  job = api.run_job(
349
  image="python:3.12-slim",
350
- command=job_command(space, revision),
351
  flavor="cpu-upgrade",
352
- timeout=f"{plan['costs']['hours']}h",
353
  namespace=username,
354
  name=f"annotate-{plan['nonce'][:12]}",
355
  labels={
 
19
  MODEL = "Qwen/Qwen3.8-Flash-Next:featherless-ai"
20
  INPUT_RATE = 0.15
21
  OUTPUT_RATE = 0.50
22
+ CACHED_INPUT_RATE = 0.03
23
  PRICE_DATE = "2026-09-25"
24
  FEATURES = {
25
  "Subtasks": "subtask",
 
218
  "hours": hours,
219
  "input_rate": INPUT_RATE,
220
  "output_rate": OUTPUT_RATE,
221
+ "cached_input_rate": CACHED_INPUT_RATE,
222
  "price_date": PRICE_DATE,
223
  }
224
 
 
257
  }
258
 
259
 
260
+ def job_command(space, revision, timeout_minutes=None):
261
  # Only trusted deployment values enter shell syntax. User options travel in JSON env.
262
  validate_repo_id(space)
263
  if not re.fullmatch(r"[a-f0-9]{40}", revision):
 
273
  "\n".join(
274
  [
275
  "set -euo pipefail",
276
+ f"export ANNOTATION_DEADLINE_EPOCH=$(( $(date +%s) + {timeout_minutes * 60 - 30} ))"
277
+ if timeout_minutes
278
+ else ":",
279
  "mkdir -p /workspace/app /workspace/source",
280
  "cd /workspace",
281
  "pip install --quiet huggingface_hub==1.26.0",
 
290
  ]
291
 
292
 
293
+ def launch(plan, output, budget, acknowledged, token, space=None, api_factory=HfApi, timeout_minutes=None):
294
+ if timeout_minutes is not None and (
295
+ type(timeout_minutes) is not int or not 1 <= timeout_minutes <= plan["costs"]["hours"] * 60
296
+ ):
297
+ raise ValueError("Runtime limit must be within the quoted timeout.")
298
  if not token:
299
  raise ValueError("Sign in with Hugging Face before starting a job.")
300
  if not acknowledged:
 
352
  "space": space,
353
  "space_revision": revision,
354
  "owner": username,
355
+ "runtime_limit_minutes": timeout_minutes,
356
  }
357
  )
358
  job = api.run_job(
359
  image="python:3.12-slim",
360
+ command=job_command(space, revision, timeout_minutes),
361
  flavor="cpu-upgrade",
362
+ timeout=f"{timeout_minutes}m" if timeout_minutes else f"{plan['costs']['hours']}h",
363
  namespace=username,
364
  name=f"annotate-{plan['nonce'][:12]}",
365
  labels={
test_studio.py CHANGED
@@ -465,3 +465,71 @@ def test_camera_discovery_rejects_unsupported_sources():
465
  studio.video_cameras({"codebase_version": "v3.0", "fps": 30, "features": {}})
466
  with pytest.raises(ValueError, match="v3"):
467
  studio.video_cameras({"codebase_version": "v2.1"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  studio.video_cameras({"codebase_version": "v3.0", "fps": 30, "features": {}})
466
  with pytest.raises(ValueError, match="v3"):
467
  studio.video_cameras({"codebase_version": "v2.1"})
468
+
469
+
470
+ def test_usage_costs_use_reported_runtime_and_keep_unknown_charges_separate():
471
+ from reporting import usage_costs
472
+
473
+ job = SimpleNamespace(durations=SimpleNamespace(running_secs=61))
474
+ result = usage_costs(
475
+ job,
476
+ {
477
+ "manifest": {"costs": {"cpu_rate": 0.0005}},
478
+ "usage": {"measured_inference_usd": 0.2, "reserved_including_uncertain_usd": 0.24},
479
+ },
480
+ )
481
+ assert result["cpu_usd"] == 0.001
482
+ assert result["total_usd"] == pytest.approx(0.201)
483
+ assert result["uncertain_inference_usd"] == pytest.approx(0.04)
484
+ assert usage_costs(SimpleNamespace(), {})["total_usd"] is None
485
+
486
+
487
+ def test_visualizer_links_only_target_materialized_copy_annotations():
488
+ from reporting import annotation_url
489
+
490
+ report = {
491
+ "manifest": {"mode": studio.COPY, "output": "alice/out"},
492
+ "status": "partial",
493
+ "written_episode_ids": [12, 5],
494
+ }
495
+ assert annotation_url(report) == "https://lerobot-visualize-dataset.hf.space/alice/out/5?tab=annotations"
496
+ report["manifest"]["mode"] = studio.EXPORT
497
+ assert annotation_url(report) is None
498
+ report["manifest"]["mode"] = studio.COPY
499
+ report["written_episode_ids"] = []
500
+ assert annotation_url(report) is None
501
+
502
+
503
+ def test_cached_usage_and_deadline_stop_before_a_paid_request(monkeypatch):
504
+ import provider
505
+
506
+ budget = Budget(1, 1, 2, cached_input_rate=0.2)
507
+ reserved = budget.reserve(100)
508
+ budget.reconcile(reserved, {"prompt_tokens": 10, "completion_tokens": 20, "cached_tokens": 6})
509
+ assert budget.summary()["measured_inference_usd"] == pytest.approx(0.0000452)
510
+ assert budget.summary()["input_tokens"] == 10
511
+ assert budget.summary()["cached_input_tokens"] == 6
512
+ post = Mock()
513
+ monkeypatch.setattr(provider.requests, "post", post)
514
+ client = provider.Provider("token", studio.MODEL, budget, deadline=time.time() - 1)
515
+ with pytest.raises(provider.DeadlineExceededError):
516
+ client.generate([[{"role": "user", "content": "hello"}]], 100, 0.2)
517
+ post.assert_not_called()
518
+
519
+
520
+ def test_five_minute_run_has_job_and_worker_deadlines():
521
+ api = api_mock()
522
+ studio.launch(
523
+ plan(),
524
+ "alice/output",
525
+ 30,
526
+ True,
527
+ "token",
528
+ space="lerobot/annotation-studio",
529
+ api_factory=lambda **_: api,
530
+ timeout_minutes=5,
531
+ )
532
+ kwargs = api.run_job.call_args.kwargs
533
+ assert kwargs["timeout"] == "5m"
534
+ assert "ANNOTATION_DEADLINE_EPOCH" in str(kwargs["command"])
535
+ assert "270" in str(kwargs["command"])
worker.py CHANGED
@@ -5,6 +5,8 @@ import json
5
  import math
6
  import os
7
  import shutil
 
 
8
  from collections import defaultdict
9
  from concurrent.futures import ThreadPoolExecutor, as_completed
10
  from contextvars import ContextVar
@@ -13,7 +15,7 @@ from pathlib import Path
13
  import av
14
  from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
15
  from prompt_config import validate_prompts
16
- from provider import Budget, BudgetExceededError, Provider
17
  from studio import COPY, FEATURES, read_selection, relative_path
18
 
19
  from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
@@ -302,18 +304,30 @@ def run(manifest, work=Path("/workspace/run")):
302
  allow_patterns=["meta/**"],
303
  )
304
  budget = Budget(
305
- manifest["inference_budget"], manifest["costs"]["input_rate"], manifest["costs"]["output_rate"]
 
 
 
306
  )
307
- provider = Provider(token, manifest["model"], budget)
 
308
  progress = {
309
  "manifest": manifest,
310
  "pipeline": json.loads(Path(__file__).with_name("pipeline-version.json").read_text()),
311
  "status": "running",
312
  "episodes": [],
313
  "expected_episodes": len(ids),
 
 
314
  }
315
 
 
 
316
  def checkpoint(extra=()):
 
 
 
 
317
  progress["usage"] = budget.summary()
318
  operations = [
319
  CommitOperationAdd(
@@ -330,6 +344,17 @@ def run(manifest, work=Path("/workspace/run")):
330
  )
331
 
332
  checkpoint()
 
 
 
 
 
 
 
 
 
 
 
333
  shards = defaultdict(list)
334
  info = selection["info"]
335
  for row in selection["episodes"]:
@@ -383,8 +408,13 @@ def run(manifest, work=Path("/workspace/run")):
383
  try:
384
  result = future.result()
385
  completed.append(record)
386
- except BudgetExceededError:
387
- result = {"episode_index": record.episode_index, "status": "budget_stopped"}
 
 
 
 
 
388
  stop = True
389
  for pending in futures:
390
  pending.cancel()
@@ -418,11 +448,16 @@ def run(manifest, work=Path("/workspace/run")):
418
  for p in [*written, root / "meta/info.json"]
419
  ]
420
  )
 
 
421
  for file in files:
422
  (root / file).unlink(missing_ok=True)
423
  shutil.rmtree(staging, ignore_errors=True)
424
  if stop:
425
  break
 
 
 
426
  completed_count = sum(e["status"] == "completed" for e in progress["episodes"])
427
  progress["status"] = "completed" if completed_count == len(ids) else "partial"
428
  final_commit = checkpoint()
@@ -434,7 +469,9 @@ def run(manifest, work=Path("/workspace/run")):
434
  api.delete_tag(output, tag=version, repo_type="dataset")
435
  api.create_tag(output, tag=version, repo_type="dataset", revision=final_commit.oid)
436
  except Exception as exc:
437
- progress.update(status="partial", error_type=type(exc).__name__)
 
 
438
  checkpoint()
439
  raise RuntimeError("Annotation stopped; check the saved run report for completed episodes.") from None
440
  print(f"Result: {progress['status']} — https://huggingface.co/datasets/{output}", flush=True)
 
5
  import math
6
  import os
7
  import shutil
8
+ import threading
9
+ import time
10
  from collections import defaultdict
11
  from concurrent.futures import ThreadPoolExecutor, as_completed
12
  from contextvars import ContextVar
 
15
  import av
16
  from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
17
  from prompt_config import validate_prompts
18
+ from provider import Budget, BudgetExceededError, DeadlineExceededError, Provider
19
  from studio import COPY, FEATURES, read_selection, relative_path
20
 
21
  from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
 
304
  allow_patterns=["meta/**"],
305
  )
306
  budget = Budget(
307
+ manifest["inference_budget"],
308
+ manifest["costs"]["input_rate"],
309
+ manifest["costs"]["output_rate"],
310
+ manifest["costs"].get("cached_input_rate"),
311
  )
312
+ deadline = float(os.environ.get("ANNOTATION_DEADLINE_EPOCH", "0")) or None
313
+ provider = Provider(token, manifest["model"], budget, deadline=deadline)
314
  progress = {
315
  "manifest": manifest,
316
  "pipeline": json.loads(Path(__file__).with_name("pipeline-version.json").read_text()),
317
  "status": "running",
318
  "episodes": [],
319
  "expected_episodes": len(ids),
320
+ "written_episode_ids": [],
321
+ "worker_started_at": time.time(),
322
  }
323
 
324
+ commit_lock = threading.RLock()
325
+
326
  def checkpoint(extra=()):
327
+ with commit_lock:
328
+ return _checkpoint(extra)
329
+
330
+ def _checkpoint(extra=()):
331
  progress["usage"] = budget.summary()
332
  operations = [
333
  CommitOperationAdd(
 
344
  )
345
 
346
  checkpoint()
347
+ heartbeat_stop = threading.Event()
348
+
349
+ def heartbeat():
350
+ while not heartbeat_stop.wait(10):
351
+ try:
352
+ checkpoint()
353
+ except Exception:
354
+ print("Progress update delayed; will retry.", flush=True)
355
+
356
+ heartbeat_thread = threading.Thread(target=heartbeat, daemon=True)
357
+ heartbeat_thread.start()
358
  shards = defaultdict(list)
359
  info = selection["info"]
360
  for row in selection["episodes"]:
 
408
  try:
409
  result = future.result()
410
  completed.append(record)
411
+ except BudgetExceededError as exc:
412
+ result = {
413
+ "episode_index": record.episode_index,
414
+ "status": "time_stopped"
415
+ if isinstance(exc, DeadlineExceededError)
416
+ else "budget_stopped",
417
+ }
418
  stop = True
419
  for pending in futures:
420
  pending.cancel()
 
448
  for p in [*written, root / "meta/info.json"]
449
  ]
450
  )
451
+ progress["written_episode_ids"].extend(r.episode_index for r in completed)
452
+ checkpoint()
453
  for file in files:
454
  (root / file).unlink(missing_ok=True)
455
  shutil.rmtree(staging, ignore_errors=True)
456
  if stop:
457
  break
458
+ heartbeat_stop.set()
459
+ heartbeat_thread.join(timeout=15)
460
+ progress["worker_finished_at"] = time.time()
461
  completed_count = sum(e["status"] == "completed" for e in progress["episodes"])
462
  progress["status"] = "completed" if completed_count == len(ids) else "partial"
463
  final_commit = checkpoint()
 
469
  api.delete_tag(output, tag=version, repo_type="dataset")
470
  api.create_tag(output, tag=version, repo_type="dataset", revision=final_commit.oid)
471
  except Exception as exc:
472
+ heartbeat_stop.set()
473
+ heartbeat_thread.join(timeout=15)
474
+ progress.update(status="partial", error_type=type(exc).__name__, worker_finished_at=time.time())
475
  checkpoint()
476
  raise RuntimeError("Annotation stopped; check the saved run report for completed episodes.") from None
477
  print(f"Result: {progress['status']} — https://huggingface.co/datasets/{output}", flush=True)