Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
a4ffed2
1
Parent(s): 188dc2b
Deploy ecc2a52
Browse filesThe cost cap can finally see the GPU
Source: https://github.com/WINTER4000/turingDNA/commit/ecc2a52ff08d074502a69e19f73ef4227ca6ea61
- dee/core/compute_budget.py +158 -0
- dee/core/modal_client.py +20 -1
- dee/core/orchestrator.py +96 -7
- dee/static/cockpit.js +22 -0
- dee/static/index.html +5 -5
- tests/test_compute_budget.py +204 -0
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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 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}
|
| 1571 |
-
f"
|
| 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-
|
| 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-
|
| 2999 |
-
<script src="/static/lineage.js?v=20260819-
|
| 3000 |
-
<script src="/static/app.js?v=20260819-
|
| 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=
|
| 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
|