github-actions[bot] commited on
Commit
a4ffed2
·
1 Parent(s): 188dc2b

Deploy ecc2a52

Browse files

The cost cap can finally see the GPU

Source: https://github.com/WINTER4000/turingDNA/commit/ecc2a52ff08d074502a69e19f73ef4227ca6ea61

dee/core/compute_budget.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """What a run spends on rented GPUs — and, until now, what the cap could not see.
2
+
3
+ THE HOLE
4
+ --------
5
+ `AGENT_MAX_COST_USD` stops a run that talks to the language model too much. It
6
+ has never seen a single second of GPU time. A run that calls the DNA scorer
7
+ forty times bought forty A10G jobs and reported spending only the tokens it
8
+ took to ask for them, because the only thing `run.cost_usd` ever accumulated
9
+ was `_llm.call`'s return value.
10
+
11
+ That is the wrong shape of failure for a budget. A cap that silently excludes
12
+ the expensive half is worse than no cap, because it reads as protection.
13
+
14
+ WHY THERE IS NO PRICE IN THIS FILE
15
+ ----------------------------------
16
+ Rates change, and this repo already decided (modal/evo2_scoring.py, modal/
17
+ sieve.py) that a hardcoded per-second figure is a number that goes stale
18
+ without anyone noticing and then gets quoted as fact. So the rate comes from
19
+ the environment or not at all:
20
+
21
+ MODAL_USD_PER_GPU_SECOND applies to every tier
22
+ MODAL_USD_PER_GPU_SECOND_T4 per-tier override
23
+ MODAL_USD_PER_GPU_SECOND_A10G
24
+
25
+ With nothing configured, this module still counts GPU SECONDS — which are
26
+ measured, not looked up — and says plainly that the dollar cap covers model
27
+ time only. That is the honest failure: report the quantity you actually have,
28
+ and name the one you don't. Reporting $0.00 of compute would be a claim, and
29
+ a false one.
30
+
31
+ SECONDS ARE AN UPPER BOUND, ON PURPOSE
32
+ --------------------------------------
33
+ What is measured is wall time around the whole submit-and-poll cycle, which
34
+ includes queueing and container cold start — time the GPU may not have been
35
+ billed for. For a budget that is the correct direction to be wrong in: it
36
+ spends the cap early rather than late. Anywhere the number is shown it is
37
+ labelled an estimate, because it is one.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import os
43
+ import threading
44
+ from dataclasses import dataclass
45
+ from typing import Any, Dict, List, Optional
46
+
47
+ __all__ = ["Charge", "GPU_TIER_BY_PIPELINE", "rate_for", "record", "drain",
48
+ "summarise", "reset"]
49
+
50
+ # Which GPU each Modal pipeline runs on. MIRRORED from modal/ rather than
51
+ # imported: dee/ and modal/ are deployed separately and neither imports the
52
+ # other's package at runtime (the same reason dna_scoring.TIER_CONTEXT
53
+ # duplicates MODEL_PRESETS). Sources: modal/sieve.py `gpu="T4"`,
54
+ # modal/evo2_scoring.py `gpu="A10G"`, modal/ensemble.py `gpu="A10G"`.
55
+ #
56
+ # A pipeline missing from this table is still METERED — its seconds are
57
+ # counted under an "unknown" tier — because dropping the measurement would
58
+ # reintroduce exactly the blindness this module exists to fix.
59
+ GPU_TIER_BY_PIPELINE = {"protein": "T4", "dna": "A10G"}
60
+ UNKNOWN_TIER = "unknown"
61
+
62
+ _ENV_ANY = "MODAL_USD_PER_GPU_SECOND"
63
+ _ENV_TIER = "MODAL_USD_PER_GPU_SECOND_{tier}"
64
+
65
+ # Thread-local, not a ContextVar: the orchestrator drives each run on its own
66
+ # thread and executes that run's tool calls in it, so per-thread is exactly
67
+ # the scope wanted. A process-global would bill one run for another's GPU.
68
+ _local = threading.local()
69
+
70
+
71
+ @dataclass
72
+ class Charge:
73
+ label: str # what bought it — the pipeline or tool name
74
+ tier: str # GPU tier, or "unknown"
75
+ seconds: float # wall time, an UPPER BOUND (see module docstring)
76
+ usd: Optional[float] # None when no rate is configured for this tier
77
+
78
+
79
+ def rate_for(tier: str) -> Optional[float]:
80
+ """USD per GPU-second for a tier, from the environment only.
81
+
82
+ Returns None when unset — which the caller must treat as "unknown", never
83
+ as zero. Those are different claims and only one of them is true.
84
+ """
85
+ for key in (_ENV_TIER.format(tier=str(tier or "").upper().replace("-", "_")),
86
+ _ENV_ANY):
87
+ raw = os.environ.get(key)
88
+ if raw is None or str(raw).strip() == "":
89
+ continue
90
+ try:
91
+ v = float(raw)
92
+ except (TypeError, ValueError):
93
+ continue
94
+ # A negative or non-finite rate would credit the run for spending.
95
+ if v >= 0 and v == v and v not in (float("inf"), float("-inf")):
96
+ return v
97
+ return None
98
+
99
+
100
+ def record(label: str, seconds: float, *, pipeline: str = "",
101
+ tier: str = "") -> Optional[Charge]:
102
+ """Meter one GPU call against the current thread's run."""
103
+ try:
104
+ s = float(seconds)
105
+ except (TypeError, ValueError):
106
+ return None
107
+ if not (s > 0) or s != s or s in (float("inf"), float("-inf")):
108
+ return None
109
+ t = tier or GPU_TIER_BY_PIPELINE.get(pipeline, UNKNOWN_TIER)
110
+ rate = rate_for(t)
111
+ charge = Charge(label=str(label or pipeline or "gpu")[:64], tier=t,
112
+ seconds=round(s, 3),
113
+ usd=(None if rate is None else round(rate * s, 6)))
114
+ if not hasattr(_local, "charges"):
115
+ _local.charges = []
116
+ _local.charges.append(charge)
117
+ return charge
118
+
119
+
120
+ def drain() -> List[Charge]:
121
+ """Take everything metered on this thread since the last drain."""
122
+ out = getattr(_local, "charges", [])
123
+ _local.charges = []
124
+ return out
125
+
126
+
127
+ def reset() -> None:
128
+ """Discard anything metered on this thread. For tests and for the start of
129
+ a run, so a charge can never leak across a thread's reuse."""
130
+ _local.charges = []
131
+
132
+
133
+ def summarise(charges: List[Charge]) -> Dict[str, Any]:
134
+ """Roll charges up, and be explicit about the part that has no price."""
135
+ charges = charges or []
136
+ seconds = round(sum(c.seconds for c in charges), 3)
137
+ priced = [c for c in charges if c.usd is not None]
138
+ unpriced = [c for c in charges if c.usd is None]
139
+ usd = round(sum(c.usd or 0.0 for c in priced), 6)
140
+ tiers = sorted({c.tier for c in unpriced})
141
+ return {
142
+ "calls": len(charges),
143
+ "gpu_seconds": seconds,
144
+ "usd": usd,
145
+ # False whenever ANY charge could not be priced. A partially priced
146
+ # total is the failure this module exists to avoid: it looks complete.
147
+ "fully_priced": not unpriced,
148
+ "unpriced_calls": len(unpriced),
149
+ "unpriced_tiers": tiers,
150
+ "note": (
151
+ "" if not charges else
152
+ (f"{seconds:g}s of GPU time, estimated from wall clock "
153
+ f"(includes queue and cold start, so it reads high)."
154
+ + ("" if not unpriced else
155
+ f" No rate configured for {', '.join(tiers) or 'these tiers'}, "
156
+ f"so the dollar cap covers model time only — set "
157
+ f"{_ENV_ANY} to include compute."))),
158
+ }
dee/core/modal_client.py CHANGED
@@ -41,6 +41,8 @@ import os
41
  import time
42
  from typing import Any, Dict, Optional, Tuple
43
 
 
 
44
  logger = logging.getLogger("dee.modal_client")
45
 
46
  SUBMIT_URL_ENV = "MODAL_SUBMIT_URL"
@@ -242,9 +244,23 @@ def run(payload: Dict[str, Any], *, pipeline: str = "protein",
242
  total_timeout = DNA_TIMEOUT if pipeline == "dna" else DEFAULT_TIMEOUT
243
 
244
  job_id = submit(payload, pipeline=pipeline)
245
- deadline = time.monotonic() + total_timeout
 
246
  last_state = None
247
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  while True:
249
  state = status(job_id, pipeline=pipeline)
250
  # The orchestrator calls this field "stage" (orchestrate_logic.
@@ -276,11 +292,14 @@ def run(payload: Dict[str, Any], *, pipeline: str = "protein",
276
  logger.debug("on_progress raised; continuing", exc_info=True)
277
 
278
  if phase == "done":
 
279
  return state
280
  if phase == "error":
 
281
  raise ModalError(str(state.get("error") or "GPU run failed")[:400])
282
 
283
  if time.monotonic() >= deadline:
 
284
  # Say what was actually seen. "Timed out" alone leaves the caller
285
  # unable to tell a slow run from a stuck one.
286
  raise ModalError(
 
41
  import time
42
  from typing import Any, Dict, Optional, Tuple
43
 
44
+ from dee.core import compute_budget as _budget
45
+
46
  logger = logging.getLogger("dee.modal_client")
47
 
48
  SUBMIT_URL_ENV = "MODAL_SUBMIT_URL"
 
244
  total_timeout = DNA_TIMEOUT if pipeline == "dna" else DEFAULT_TIMEOUT
245
 
246
  job_id = submit(payload, pipeline=pipeline)
247
+ started = time.monotonic()
248
+ deadline = started + total_timeout
249
  last_state = None
250
 
251
+ def _meter() -> None:
252
+ """Bill the run for the GPU it just rented.
253
+
254
+ Metered on EVERY exit — done, error and timeout alike. A failed job
255
+ still occupied a container, and a budget that only counts successes
256
+ can be walked past by a caller whose calls keep failing.
257
+ """
258
+ try:
259
+ _budget.record(pipeline or "gpu", time.monotonic() - started,
260
+ pipeline=pipeline)
261
+ except Exception: # noqa: BLE001 — accounting must never fail the work
262
+ logger.debug("gpu metering failed; continuing", exc_info=True)
263
+
264
  while True:
265
  state = status(job_id, pipeline=pipeline)
266
  # The orchestrator calls this field "stage" (orchestrate_logic.
 
292
  logger.debug("on_progress raised; continuing", exc_info=True)
293
 
294
  if phase == "done":
295
+ _meter()
296
  return state
297
  if phase == "error":
298
+ _meter()
299
  raise ModalError(str(state.get("error") or "GPU run failed")[:400])
300
 
301
  if time.monotonic() >= deadline:
302
+ _meter()
303
  # Say what was actually seen. "Timed out" alone leaves the caller
304
  # unable to tell a slow run from a stuck one.
305
  raise ModalError(
dee/core/orchestrator.py CHANGED
@@ -53,6 +53,7 @@ import uuid
53
  from dataclasses import dataclass, field
54
  from typing import Any, Dict, List, Optional, Tuple
55
 
 
56
  from dee.core import llm as _llm
57
  from dee.core import provenance as _prov
58
  from dee.core import agent_tools as _tools
@@ -578,6 +579,17 @@ class Run:
578
  seq: int = 0
579
  steps: int = 0
580
  cost_usd: float = 0.0
 
 
 
 
 
 
 
 
 
 
 
581
  context_tokens: int = 0
582
  # Prompt-cache accounting, summed over every step of the run. Kept
583
  # separate from context_tokens (which is last-step-wins, because it drives
@@ -760,7 +772,15 @@ def _persist(run: Run) -> None:
760
  "cost_usd": run.cost_usd,
761
  "events": [_slim_for_storage(e) for e in run.events],
762
  "history": list(run.history),
763
- "meta": {"workspace": dict(run.workspace)},
 
 
 
 
 
 
 
 
764
  }
765
  owner = run.owner
766
 
@@ -800,7 +820,17 @@ def rehydrate(run_id: str, user_id: str) -> Optional[Run]:
800
  run.title = row.get("title") or ""
801
  run.steps = int(row.get("steps") or 0)
802
  run.cost_usd = float(row.get("cost_usd") or 0.0)
803
- run.workspace = dict((row.get("meta") or {}).get("workspace") or {})
 
 
 
 
 
 
 
 
 
 
804
  # seq must continue past the highest persisted event or the client's
805
  # `after=` cursor would skip everything it has already seen on the next
806
  # turn — restored events keep their original numbering.
@@ -850,6 +880,15 @@ def public_state(run: Run) -> Dict[str, Any]:
850
  "seq": run.seq,
851
  "steps": run.steps,
852
  "cost_usd": round(run.cost_usd, 6),
 
 
 
 
 
 
 
 
 
853
  # Published so a client can SHOW the allowance instead of the user
854
  # meeting it for the first time as a wall (reviewer, 2026-07-30:
855
  # "not clear what happened here / was this a user limit?").
@@ -1103,6 +1142,10 @@ def _drive(run: Run, config: _llm.OpenRouterConfig) -> None:
1103
  a terminal event (done/error/ask) so the client's poll always converges
1104
  instead of spinning on a run that quietly died.
1105
  """
 
 
 
 
1106
  try:
1107
  while True:
1108
  # ── Out of steps? Buy more, if this is still work. ────────────
@@ -1460,6 +1503,10 @@ def _run_one_tool(run: Run, name: str, call_id: str, args: Dict[str, Any],
1460
  # duration by execute_tool so no tool inherits
1461
  # the model of whatever ran before it.
1462
  model=getattr(run, "model", "small"))
 
 
 
 
1463
  model_result = _strip_ui(result)
1464
  # Bind BEFORE the event is emitted, so the very next system prompt — the
1465
  # one the model plans with — already knows what this run is about.
@@ -1537,11 +1584,50 @@ def _clean_plan(raw: Any) -> List[Dict[str, str]]:
1537
  # everything the run had already done. All three are fixed below.
1538
 
1539
  def _cost_fraction(run: Run, config: _llm.OpenRouterConfig) -> float:
1540
- """How much of this run's allowance is gone, 0-1. Zero when uncapped."""
 
 
 
 
 
 
 
 
 
 
1541
  limit = float(getattr(config, "max_cost_usd", 0.0) or 0.0)
1542
  if limit <= 0:
1543
  return 0.0
1544
- return run.cost_usd / limit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1545
 
1546
 
1547
  def _warn_on_spend(run: Run, config: _llm.OpenRouterConfig) -> None:
@@ -1552,12 +1638,15 @@ def _warn_on_spend(run: Run, config: _llm.OpenRouterConfig) -> None:
1552
  limit = float(config.max_cost_usd)
1553
  _emit(run, "budget", {
1554
  "spent_usd": round(run.cost_usd, 4),
 
 
 
1555
  "limit_usd": round(limit, 4),
1556
  "fraction": round(_cost_fraction(run, config), 3),
1557
  })
1558
  _emit(run, "text", {"text": (
1559
  f"Heads up — I've used about {round(_cost_fraction(run, config) * 100)}% "
1560
- f"of this run's compute allowance (${limit:.2f} of model time per run; "
1561
  f"it's a cap on the run, not a limit on your account). I'll pause and "
1562
  f"check in with you before it runs out.")})
1563
 
@@ -1567,8 +1656,8 @@ def _budget_spent(run: Run, config: _llm.OpenRouterConfig) -> None:
1567
  limit = float(config.max_cost_usd)
1568
  run.budget_spent = True
1569
  _hand_over(run, reason="budget_spent", why=(
1570
- f"That's this run's full compute allowance spent (${limit:.2f} of "
1571
- f"model time per run — it's a cap on the run, not a limit on your "
1572
  f"account, and nothing you did is lost)."))
1573
 
1574
 
 
53
  from dataclasses import dataclass, field
54
  from typing import Any, Dict, List, Optional, Tuple
55
 
56
+ from dee.core import compute_budget as _budget
57
  from dee.core import llm as _llm
58
  from dee.core import provenance as _prov
59
  from dee.core import agent_tools as _tools
 
579
  seq: int = 0
580
  steps: int = 0
581
  cost_usd: float = 0.0
582
+ # GPU time the run bought, metered by dee.core.compute_budget. Kept apart
583
+ # from cost_usd because they are known to different precisions: cost_usd
584
+ # is what the provider billed, compute_usd is an ESTIMATE from wall clock
585
+ # and may be 0.0 simply because no rate is configured. Summing them into
586
+ # one field would erase which half is measured and which is inferred.
587
+ gpu_seconds: float = 0.0
588
+ compute_usd: float = 0.0
589
+ compute_calls: int = 0
590
+ # False once ANY GPU call could not be priced. The cap still counts what
591
+ # it can; this is how the run says the total is a floor, not a total.
592
+ compute_fully_priced: bool = True
593
  context_tokens: int = 0
594
  # Prompt-cache accounting, summed over every step of the run. Kept
595
  # separate from context_tokens (which is last-step-wins, because it drives
 
772
  "cost_usd": run.cost_usd,
773
  "events": [_slim_for_storage(e) for e in run.events],
774
  "history": list(run.history),
775
+ # Compute rides in `meta` rather than its own column: it needs
776
+ # no migration, and a resumed run that forgot the GPU it already
777
+ # bought would hand its successor a fresh allowance for spend
778
+ # that already happened.
779
+ "meta": {"workspace": dict(run.workspace),
780
+ "compute": {"gpu_seconds": run.gpu_seconds,
781
+ "usd": run.compute_usd,
782
+ "calls": run.compute_calls,
783
+ "fully_priced": run.compute_fully_priced}},
784
  }
785
  owner = run.owner
786
 
 
820
  run.title = row.get("title") or ""
821
  run.steps = int(row.get("steps") or 0)
822
  run.cost_usd = float(row.get("cost_usd") or 0.0)
823
+ _meta = row.get("meta") or {}
824
+ run.workspace = dict(_meta.get("workspace") or {})
825
+ _compute = _meta.get("compute") or {}
826
+ run.gpu_seconds = float(_compute.get("gpu_seconds") or 0.0)
827
+ run.compute_usd = float(_compute.get("usd") or 0.0)
828
+ run.compute_calls = int(_compute.get("calls") or 0)
829
+ # Absent on runs saved before compute was metered. True is the right
830
+ # default there: those runs have no unpriced charges because they have no
831
+ # recorded charges at all, and defaulting to False would make every old
832
+ # run claim a caveat it never earned.
833
+ run.compute_fully_priced = bool(_compute.get("fully_priced", True))
834
  # seq must continue past the highest persisted event or the client's
835
  # `after=` cursor would skip everything it has already seen on the next
836
  # turn — restored events keep their original numbering.
 
880
  "seq": run.seq,
881
  "steps": run.steps,
882
  "cost_usd": round(run.cost_usd, 6),
883
+ # The GPU half, published separately and never folded into
884
+ # cost_usd: one is what the provider billed, the other an
885
+ # estimate from wall clock that may read 0.00 only because no
886
+ # rate is configured. `compute_priced` is how a client knows
887
+ # which of those two 0.00s it is looking at.
888
+ "compute_usd": round(run.compute_usd, 6),
889
+ "gpu_seconds": round(run.gpu_seconds, 1),
890
+ "compute_calls": run.compute_calls,
891
+ "compute_priced": run.compute_fully_priced,
892
  # Published so a client can SHOW the allowance instead of the user
893
  # meeting it for the first time as a wall (reviewer, 2026-07-30:
894
  # "not clear what happened here / was this a user limit?").
 
1142
  a terminal event (done/error/ask) so the client's poll always converges
1143
  instead of spinning on a run that quietly died.
1144
  """
1145
+ # Clear anything the GPU meter is holding on this thread before the first
1146
+ # tool call. Threads can be reused, and an inherited charge would bill
1147
+ # this run for another's compute.
1148
+ _budget.reset()
1149
  try:
1150
  while True:
1151
  # ── Out of steps? Buy more, if this is still work. ────────────
 
1503
  # duration by execute_tool so no tool inherits
1504
  # the model of whatever ran before it.
1505
  model=getattr(run, "model", "small"))
1506
+ # Bill the run for any GPU that tool just rented, BEFORE the next model
1507
+ # call: a tool that spends the rest of the allowance must stop the run at
1508
+ # the tool, not one LLM round-trip later.
1509
+ _meter_compute(run)
1510
  model_result = _strip_ui(result)
1511
  # Bind BEFORE the event is emitted, so the very next system prompt — the
1512
  # one the model plans with — already knows what this run is about.
 
1584
  # everything the run had already done. All three are fixed below.
1585
 
1586
  def _cost_fraction(run: Run, config: _llm.OpenRouterConfig) -> float:
1587
+ """How much of this run's allowance is gone, 0-1. Zero when uncapped.
1588
+
1589
+ Counts GPU time as well as model time. Until this line did, a run could
1590
+ rent forty A10G jobs and report spending only the tokens it took to ask
1591
+ for them — a cap that silently excluded the expensive half, which is
1592
+ worse than no cap because it reads as protection.
1593
+
1594
+ When no per-second rate is configured, compute_usd is 0.0 and the cap
1595
+ covers model time alone. That is a real limitation, and it is stated
1596
+ wherever the number is shown rather than papered over with a guess.
1597
+ """
1598
  limit = float(getattr(config, "max_cost_usd", 0.0) or 0.0)
1599
  if limit <= 0:
1600
  return 0.0
1601
+ return (run.cost_usd + run.compute_usd) / limit
1602
+
1603
+
1604
+ def _spend_line(run: Run) -> str:
1605
+ """How to describe the allowance, given what is actually known about it."""
1606
+ if not run.compute_calls:
1607
+ return "of model time per run"
1608
+ if run.compute_fully_priced:
1609
+ return "of model and GPU time per run"
1610
+ # The honest middle case: GPU time happened, and we can say how much of it
1611
+ # in seconds, but not what it cost.
1612
+ return (f"of model time per run — plus {run.gpu_seconds:.0f}s of GPU time "
1613
+ f"this run bought, which has no configured rate and so is not "
1614
+ f"counted against the cap")
1615
+
1616
+
1617
+ def _meter_compute(run: Run) -> None:
1618
+ """Fold whatever GPU the last tool call rented into this run's spend."""
1619
+ try:
1620
+ summary = _budget.summarise(_budget.drain())
1621
+ except Exception: # noqa: BLE001 — accounting must never fail the work
1622
+ logger.debug("compute metering failed; continuing", exc_info=True)
1623
+ return
1624
+ if not summary.get("calls"):
1625
+ return
1626
+ run.compute_calls += int(summary["calls"])
1627
+ run.gpu_seconds = round(run.gpu_seconds + float(summary["gpu_seconds"]), 3)
1628
+ run.compute_usd = round(run.compute_usd + float(summary["usd"]), 6)
1629
+ if not summary.get("fully_priced"):
1630
+ run.compute_fully_priced = False
1631
 
1632
 
1633
  def _warn_on_spend(run: Run, config: _llm.OpenRouterConfig) -> None:
 
1638
  limit = float(config.max_cost_usd)
1639
  _emit(run, "budget", {
1640
  "spent_usd": round(run.cost_usd, 4),
1641
+ "compute_usd": round(run.compute_usd, 4),
1642
+ "gpu_seconds": round(run.gpu_seconds, 1),
1643
+ "compute_fully_priced": run.compute_fully_priced,
1644
  "limit_usd": round(limit, 4),
1645
  "fraction": round(_cost_fraction(run, config), 3),
1646
  })
1647
  _emit(run, "text", {"text": (
1648
  f"Heads up — I've used about {round(_cost_fraction(run, config) * 100)}% "
1649
+ f"of this run's compute allowance (${limit:.2f} {_spend_line(run)}; "
1650
  f"it's a cap on the run, not a limit on your account). I'll pause and "
1651
  f"check in with you before it runs out.")})
1652
 
 
1656
  limit = float(config.max_cost_usd)
1657
  run.budget_spent = True
1658
  _hand_over(run, reason="budget_spent", why=(
1659
+ f"That's this run's full compute allowance spent (${limit:.2f} "
1660
+ f"{_spend_line(run)} — it's a cap on the run, not a limit on your "
1661
  f"account, and nothing you did is lost)."))
1662
 
1663
 
dee/static/cockpit.js CHANGED
@@ -1795,6 +1795,13 @@
1795
  applyEvent(ev);
1796
  });
1797
  if (typeof d.cost_usd === "number") state.cost = d.cost_usd;
 
 
 
 
 
 
 
1798
  if (typeof d.context_tokens_used === "number") state.ctxUsed = d.context_tokens_used;
1799
  // null (not 0) until the first billed step — see
1800
  // orchestrator.public_state. Guard on null explicitly so "no
@@ -2309,6 +2316,7 @@
2309
  _planNode = null; // a new run gets a fresh checklist, not the old one
2310
  state.digest = {}; state.transcript = [];
2311
  state.cost = 0; state.ctxUsed = 0; state.ctxLimit = 0;
 
2312
  _noted = false; _phase = 0; _seq = "";
2313
  // The trace is per-run. Carrying the previous run's steps into a new
2314
  // one would be the same mistake the plan node used to make — a stale
@@ -2519,6 +2527,15 @@
2519
  }).length + " tool calls",
2520
  ];
2521
  if (state.cost) lines.push("**Model cost:** $" + state.cost.toFixed(4));
 
 
 
 
 
 
 
 
 
2522
  lines.push("", "---", "", "## Transcript", "");
2523
 
2524
  state.transcript.forEach(function (ev) {
@@ -2625,6 +2642,11 @@
2625
  // real figure at the precision it actually has.
2626
  if (state.cost) bits.push("$" + (state.cost < 0.01
2627
  ? state.cost.toFixed(4) : state.cost.toFixed(2)) + " model cost");
 
 
 
 
 
2628
  // Cache hit rate. ~14.5k tokens of tool specs + system prompt are
2629
  // resent on every step and ~99% of that is byte-identical, so this
2630
  // number is what says whether that repetition is billed at full price
 
1795
  applyEvent(ev);
1796
  });
1797
  if (typeof d.cost_usd === "number") state.cost = d.cost_usd;
1798
+ // GPU time this run rented. Held separately from state.cost
1799
+ // for the same reason the server publishes it separately: one
1800
+ // is what the provider billed, the other an estimate from
1801
+ // wall clock that reads 0.00 when no rate is configured.
1802
+ if (typeof d.gpu_seconds === "number") state.gpuSeconds = d.gpu_seconds;
1803
+ if (typeof d.compute_usd === "number") state.computeUsd = d.compute_usd;
1804
+ if (typeof d.compute_priced === "boolean") state.computePriced = d.compute_priced;
1805
  if (typeof d.context_tokens_used === "number") state.ctxUsed = d.context_tokens_used;
1806
  // null (not 0) until the first billed step — see
1807
  // orchestrator.public_state. Guard on null explicitly so "no
 
2316
  _planNode = null; // a new run gets a fresh checklist, not the old one
2317
  state.digest = {}; state.transcript = [];
2318
  state.cost = 0; state.ctxUsed = 0; state.ctxLimit = 0;
2319
+ state.gpuSeconds = 0; state.computeUsd = 0; state.computePriced = true;
2320
  _noted = false; _phase = 0; _seq = "";
2321
  // The trace is per-run. Carrying the previous run's steps into a new
2322
  // one would be the same mistake the plan node used to make — a stale
 
2527
  }).length + " tool calls",
2528
  ];
2529
  if (state.cost) lines.push("**Model cost:** $" + state.cost.toFixed(4));
2530
+ // In a methods record the GPU line matters more than the model line:
2531
+ // it is the compute that produced the numbers, and its price is an
2532
+ // estimate. Say which is which rather than printing one total.
2533
+ if (state.gpuSeconds) {
2534
+ lines.push("**GPU time:** " + state.gpuSeconds.toFixed(1) + "s" +
2535
+ (state.computePriced && state.computeUsd
2536
+ ? " (~$" + state.computeUsd.toFixed(4) + ", estimated from wall clock)"
2537
+ : " (no rate configured — seconds only)"));
2538
+ }
2539
  lines.push("", "---", "", "## Transcript", "");
2540
 
2541
  state.transcript.forEach(function (ev) {
 
2642
  // real figure at the precision it actually has.
2643
  if (state.cost) bits.push("$" + (state.cost < 0.01
2644
  ? state.cost.toFixed(4) : state.cost.toFixed(2)) + " model cost");
2645
+ // GPU seconds, never folded into the dollar figure beside them. A
2646
+ // combined total would hide that one half is billed and the other
2647
+ // estimated — and, when no rate is set, that the dollars exclude the
2648
+ // expensive half entirely.
2649
+ if (state.gpuSeconds) bits.push(Math.round(state.gpuSeconds) + "s GPU");
2650
  // Cache hit rate. ~14.5k tokens of tool specs + system prompt are
2651
  // resent on every step and ~99% of that is byte-identical, so this
2652
  // number is what says whether that repetition is billed at full price
dee/static/index.html CHANGED
@@ -112,7 +112,7 @@
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update —
114
  without them, users keep getting the stale file for up to a week. -->
115
- <link rel="stylesheet" href="/static/app.css?v=20260819-uplift1" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
@@ -2995,9 +2995,9 @@
2995
  <!-- Cloning reference data must load before app.js so the Designer
2996
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2997
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2998
- <script src="/static/context.js?v=20260819-uplift1" defer></script>
2999
- <script src="/static/lineage.js?v=20260819-uplift1" defer></script>
3000
- <script src="/static/app.js?v=20260819-uplift1" defer></script>
3001
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
3002
  on the very first event, and both are `defer`, so document order is
3003
  load order. Loading it after would drop the opening events of a
@@ -3006,7 +3006,7 @@
3006
  <!-- THE COCKPIT — the persistent orchestrator rail. Loads after app.js so
3007
  TDBench/TDStructure exist when a tool result asks the workspace to
3008
  render something. This is the only conversation surface in the app. -->
3009
- <script src="/static/cockpit.js?v=20260811-buildC" defer></script>
3010
  <!-- structcard before catalog: the catalog calls TDStructCard.observe as
3011
  soon as it paints. Both are defer, so document order is load order. -->
3012
  <script src="/static/structcard.js?v=20260811-buildC" defer></script>
 
112
  <!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
113
  app.js change. Bump these numbers whenever you ship a frontend update —
114
  without them, users keep getting the stale file for up to a week. -->
115
+ <link rel="stylesheet" href="/static/app.css?v=20260819-budget1" />
116
  <!-- The work catalog + the draggable rail. Kept out of app.css so two new
117
  self-contained surfaces stay reviewable; every colour is an app.css
118
  token, so both themes work with nothing added. -->
 
2995
  <!-- Cloning reference data must load before app.js so the Designer
2996
  can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
2997
  <script src="/static/cloning_db.js?v=20260530-ui-polish" defer></script>
2998
+ <script src="/static/context.js?v=20260819-budget1" defer></script>
2999
+ <script src="/static/lineage.js?v=20260819-budget1" defer></script>
3000
+ <script src="/static/app.js?v=20260819-budget1" defer></script>
3001
  <!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
3002
  on the very first event, and both are `defer`, so document order is
3003
  load order. Loading it after would drop the opening events of a
 
3006
  <!-- THE COCKPIT — the persistent orchestrator rail. Loads after app.js so
3007
  TDBench/TDStructure exist when a tool result asks the workspace to
3008
  render something. This is the only conversation surface in the app. -->
3009
+ <script src="/static/cockpit.js?v=20260819-budget1" defer></script>
3010
  <!-- structcard before catalog: the catalog calls TDStructCard.observe as
3011
  soon as it paints. Both are defer, so document order is load order. -->
3012
  <script src="/static/structcard.js?v=20260811-buildC" defer></script>
tests/test_compute_budget.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GPU time the agent buys, and the cap that could not see it.
2
+
3
+ `AGENT_MAX_COST_USD` stopped a run that talked to the model too much and
4
+ never saw a second of GPU. A run could rent forty A10G jobs and report
5
+ spending only the tokens it took to ask for them. A cap that silently
6
+ excludes the expensive half is worse than no cap: it reads as protection.
7
+
8
+ These tests are as much about what the module REFUSES to claim — a price it
9
+ was never told, a $0.00 that means "unknown" — as about the arithmetic.
10
+ """
11
+ import os
12
+
13
+ import pytest
14
+
15
+ from dee.core import compute_budget as cb
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def _clean(monkeypatch):
20
+ for k in list(os.environ):
21
+ if k.startswith("MODAL_USD_PER_GPU_SECOND"):
22
+ monkeypatch.delenv(k, raising=False)
23
+ cb.reset()
24
+ yield
25
+ cb.reset()
26
+
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # The price this module refuses to invent
30
+ # --------------------------------------------------------------------------- #
31
+ def test_with_no_rate_configured_there_is_no_price_only_seconds():
32
+ """Rates change. A hardcoded per-second figure goes stale without anyone
33
+ noticing and is then quoted as fact — the repo already decided this in
34
+ modal/sieve.py and modal/evo2_scoring.py."""
35
+ assert cb.rate_for("A10G") is None
36
+ c = cb.record("dna", 12.0, pipeline="dna")
37
+ assert c.seconds == 12.0 and c.usd is None
38
+
39
+
40
+ def test_an_unpriced_total_is_never_reported_as_fully_priced():
41
+ """$0.00 of compute and 'we do not know what compute cost' are opposite
42
+ claims that render identically as a number."""
43
+ cb.record("dna", 12.0, pipeline="dna")
44
+ s = cb.summarise(cb.drain())
45
+ assert s["usd"] == 0.0 and s["fully_priced"] is False
46
+ assert "no configured rate" in s["note"].lower() or "No rate configured" in s["note"]
47
+
48
+
49
+ def test_one_unpriced_call_poisons_the_whole_total(monkeypatch):
50
+ """A partially priced total is the worst outcome: it looks complete."""
51
+ monkeypatch.setenv("MODAL_USD_PER_GPU_SECOND_T4", "0.0002")
52
+ cb.record("protein", 10.0, pipeline="protein") # priced
53
+ cb.record("dna", 10.0, pipeline="dna") # A10G, no rate
54
+ s = cb.summarise(cb.drain())
55
+ assert s["usd"] == pytest.approx(0.002)
56
+ assert s["fully_priced"] is False and s["unpriced_tiers"] == ["A10G"]
57
+
58
+
59
+ def test_a_per_tier_rate_beats_the_blanket_one(monkeypatch):
60
+ monkeypatch.setenv("MODAL_USD_PER_GPU_SECOND", "1.0")
61
+ monkeypatch.setenv("MODAL_USD_PER_GPU_SECOND_T4", "0.5")
62
+ assert cb.rate_for("T4") == 0.5
63
+ assert cb.rate_for("A10G") == 1.0
64
+
65
+
66
+ @pytest.mark.parametrize("bad", ["", " ", "abc", "-0.5", "nan", "inf"])
67
+ def test_an_unusable_rate_is_treated_as_no_rate_not_as_zero(monkeypatch, bad):
68
+ """A negative rate would credit the run for spending; a garbage one would
69
+ silently zero the compute half of the cap."""
70
+ monkeypatch.setenv("MODAL_USD_PER_GPU_SECOND", bad)
71
+ assert cb.rate_for("A10G") is None
72
+
73
+
74
+ # --------------------------------------------------------------------------- #
75
+ # Metering
76
+ # --------------------------------------------------------------------------- #
77
+ def test_an_unknown_pipeline_is_still_metered():
78
+ """Dropping the measurement would reintroduce exactly the blindness this
79
+ module exists to fix."""
80
+ c = cb.record("mystery", 5.0, pipeline="quantum")
81
+ assert c.tier == cb.UNKNOWN_TIER and c.seconds == 5.0
82
+
83
+
84
+ @pytest.mark.parametrize("bad", [0, -1, None, "x", float("nan"), float("inf")])
85
+ def test_a_nonsense_duration_is_not_recorded(bad):
86
+ assert cb.record("dna", bad, pipeline="dna") is None
87
+ assert cb.summarise(cb.drain())["calls"] == 0
88
+
89
+
90
+ def test_draining_empties_the_meter_so_a_charge_is_never_billed_twice():
91
+ cb.record("dna", 3.0, pipeline="dna")
92
+ assert len(cb.drain()) == 1
93
+ assert cb.drain() == []
94
+
95
+
96
+ def test_the_meter_is_per_thread_so_one_run_never_bills_another():
97
+ import threading
98
+ seen = {}
99
+
100
+ def other():
101
+ cb.record("dna", 99.0, pipeline="dna")
102
+ seen["other"] = cb.summarise(cb.drain())["gpu_seconds"]
103
+
104
+ cb.record("dna", 1.0, pipeline="dna")
105
+ t = threading.Thread(target=other)
106
+ t.start(); t.join()
107
+ assert seen["other"] == 99.0
108
+ assert cb.summarise(cb.drain())["gpu_seconds"] == 1.0
109
+
110
+
111
+ def test_nothing_metered_produces_an_empty_note_not_a_reassurance():
112
+ s = cb.summarise([])
113
+ assert s["calls"] == 0 and s["note"] == "" and s["fully_priced"] is True
114
+
115
+
116
+ def test_the_note_calls_the_seconds_an_estimate():
117
+ """Wall time includes queue and cold start, so it reads high. That is the
118
+ right direction for a budget to be wrong in, and it has to be said."""
119
+ cb.record("dna", 8.0, pipeline="dna")
120
+ assert "estimated" in cb.summarise(cb.drain())["note"]
121
+
122
+
123
+ # --------------------------------------------------------------------------- #
124
+ # The cap actually seeing it
125
+ # --------------------------------------------------------------------------- #
126
+ from dee.core import llm as _llm
127
+ from dee.core import orchestrator as orch
128
+
129
+
130
+ def _run_and_config(limit=1.0):
131
+ run = orch.Run(run_id="r1", owner="u1", anonymous=False)
132
+ config = _llm.OpenRouterConfig(api_key="k", model="m", max_steps=8,
133
+ max_cost_usd=limit)
134
+ return run, config
135
+
136
+
137
+ def test_gpu_spend_counts_against_the_cap():
138
+ run, config = _run_and_config(limit=1.0)
139
+ run.cost_usd = 0.4
140
+ run.compute_usd = 0.4
141
+ assert orch._cost_fraction(run, config) == pytest.approx(0.8)
142
+
143
+
144
+ def test_a_run_that_only_spent_gpu_can_still_hit_the_cap():
145
+ """The exact hole: model time near zero, compute enormous."""
146
+ run, config = _run_and_config(limit=1.0)
147
+ run.cost_usd = 0.01
148
+ run.compute_usd = 1.2
149
+ assert orch._cost_fraction(run, config) >= 1.0
150
+
151
+
152
+ def test_an_uncapped_run_is_still_uncapped():
153
+ run, config = _run_and_config(limit=0.0)
154
+ run.compute_usd = 99.0
155
+ assert orch._cost_fraction(run, config) == 0.0
156
+
157
+
158
+ def test_metering_folds_the_meter_into_the_run():
159
+ run, _ = _run_and_config()
160
+ cb.record("dna", 6.0, pipeline="dna")
161
+ orch._meter_compute(run)
162
+ assert run.compute_calls == 1 and run.gpu_seconds == 6.0
163
+ assert run.compute_fully_priced is False # no rate configured
164
+
165
+
166
+ def test_the_spend_line_says_what_the_cap_actually_covers():
167
+ run, _ = _run_and_config()
168
+ assert orch._spend_line(run) == "of model time per run"
169
+
170
+ run.compute_calls, run.gpu_seconds = 1, 30.0
171
+ run.compute_fully_priced = False
172
+ line = orch._spend_line(run)
173
+ assert "not counted against the cap" in line and "30s" in line
174
+
175
+ run.compute_fully_priced = True
176
+ assert orch._spend_line(run) == "of model and GPU time per run"
177
+
178
+
179
+ def test_a_resumed_run_does_not_forget_the_gpu_it_already_bought():
180
+ """Otherwise every resume hands the successor a fresh allowance for spend
181
+ that already happened."""
182
+ run, _ = _run_and_config()
183
+ cb.record("dna", 20.0, pipeline="dna")
184
+ orch._meter_compute(run)
185
+ restored = orch.Run(run_id="r1", owner="u1", anonymous=False)
186
+ meta = {"workspace": {}, "compute": {"gpu_seconds": run.gpu_seconds,
187
+ "usd": run.compute_usd,
188
+ "calls": run.compute_calls,
189
+ "fully_priced": run.compute_fully_priced}}
190
+ _compute = meta["compute"]
191
+ restored.gpu_seconds = float(_compute["gpu_seconds"])
192
+ restored.compute_usd = float(_compute["usd"])
193
+ restored.compute_calls = int(_compute["calls"])
194
+ restored.compute_fully_priced = bool(_compute["fully_priced"])
195
+ assert restored.gpu_seconds == 20.0 and restored.compute_calls == 1
196
+
197
+
198
+ def test_an_old_run_with_no_compute_block_claims_no_caveat():
199
+ """Runs saved before compute was metered have no unpriced charges because
200
+ they have no recorded charges — defaulting to False would make every one
201
+ of them wear a warning it never earned."""
202
+ meta = {"workspace": {}}
203
+ compute = meta.get("compute") or {}
204
+ assert bool(compute.get("fully_priced", True)) is True