File size: 7,160 Bytes
5db30ac
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
"""Optional spending limits for one run, shared with the basic-education runtime subprocess.

Nothing here is active unless a caller installs limits with ``spend_limits``.
The CLI never does, so a normal benchmark run behaves exactly as before.

Each paid request appends one line to a JSONL ledger, so the main process and the
runtime subprocess (which inherits the two environment variables below) count
against the same totals. Requests are checked before they are sent, so a limit
can be overshot by the requests already in flight.
"""

from __future__ import annotations

import json
import os
import threading
from contextlib import contextmanager
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterator

LEDGER_ENV = "ELBENCH_SPEND_LEDGER"
LIMITS_ENV = "ELBENCH_SPEND_LIMITS"

ROLE_LABELS = {
    "target": "model under test",
    "judge": "judge",
    "student": "simulated student",
}


class BudgetExceeded(RuntimeError):
    """Raised instead of sending a request once a spending limit is reached.

    ``retryable = False`` keeps ``BenchmarkRunner`` from retrying it, and the message
    says "quota exceeded" so the runner stops the way it does when a key runs out of
    credit: finished items are kept and the run can be resumed with a higher limit.
    """

    retryable = False


@dataclass
class SpendLimits:
    total_usd: float | None = None
    per_role_usd: dict[str, float] = field(default_factory=dict)
    # USD per token, (prompt, completion), used when a response carries no cost.
    prices: dict[str, tuple[float, float]] = field(default_factory=dict)
    roles_by_model_id: dict[str, str] = field(default_factory=dict)

    def to_json(self) -> str:
        return json.dumps(
            {
                "total_usd": self.total_usd,
                "per_role_usd": self.per_role_usd,
                "prices": {role: list(pair) for role, pair in self.prices.items()},
                "roles_by_model_id": self.roles_by_model_id,
            }
        )

    @classmethod
    def from_json(cls, raw: str) -> "SpendLimits":
        data = json.loads(raw)
        return cls(
            total_usd=data.get("total_usd"),
            per_role_usd={k: float(v) for k, v in (data.get("per_role_usd") or {}).items()},
            prices={k: (float(v[0]), float(v[1])) for k, v in (data.get("prices") or {}).items()},
            roles_by_model_id=dict(data.get("roles_by_model_id") or {}),
        )


class SpendLedger:
    def __init__(self, path: Path, limits: SpendLimits) -> None:
        self.path = path
        self.limits = limits
        self._lock = threading.Lock()
        self._offset = 0
        self._spent: dict[str, float] = {}

    def spent(self) -> dict[str, float]:
        with self._lock:
            self._read_new_lines()
            return dict(self._spent)

    def check(self, role: str) -> None:
        spent = self.spent()
        total = sum(spent.values())
        limit = self.limits.total_usd
        if limit is not None and total >= limit:
            raise BudgetExceeded(
                f"Spending limit reached (quota exceeded): ${total:.4f} spent of the ${limit:.2f} total limit."
            )
        role_limit = self.limits.per_role_usd.get(role)
        if role_limit is not None and spent.get(role, 0.0) >= role_limit:
            raise BudgetExceeded(
                f"Spending limit reached (quota exceeded): ${spent.get(role, 0.0):.4f} spent on the "
                f"{ROLE_LABELS.get(role, role)} of its ${role_limit:.2f} limit."
            )

    def record(self, role: str, usage: Any) -> float:
        cost = usage_cost(usage, self.limits.prices.get(role))
        if cost <= 0:
            return 0.0
        line = json.dumps({"role": role, "usd": cost}) + "\n"
        with self._lock:
            with self.path.open("a", encoding="utf-8") as handle:
                handle.write(line)
        return cost

    def _read_new_lines(self) -> None:
        if not self.path.exists():
            return
        with self.path.open("rb") as handle:
            handle.seek(self._offset)
            chunk = handle.read()
        end = chunk.rfind(b"\n")
        if end < 0:
            return
        self._offset += end + 1
        for raw in chunk[: end + 1].splitlines():
            try:
                entry = json.loads(raw)
            except json.JSONDecodeError:
                continue
            role = str(entry.get("role") or "")
            self._spent[role] = self._spent.get(role, 0.0) + float(entry.get("usd") or 0.0)


def usage_cost(usage: Any, price: tuple[float, float] | None) -> float:
    """USD for one response: OpenRouter's reported ``cost`` when present, else tokens x price."""
    if not isinstance(usage, dict):
        return 0.0
    cost = usage.get("cost")
    if isinstance(cost, (int, float)) and not isinstance(cost, bool) and cost >= 0:
        return float(cost)
    if price is None:
        return 0.0
    prompt_tokens = usage.get("prompt_tokens", usage.get("input_tokens")) or 0
    completion_tokens = usage.get("completion_tokens", usage.get("output_tokens")) or 0
    try:
        return float(prompt_tokens) * price[0] + float(completion_tokens) * price[1]
    except (TypeError, ValueError):
        return 0.0


_ACTIVE: SpendLedger | None = None
_ACTIVE_LOCK = threading.Lock()


def active_ledger() -> SpendLedger | None:
    """The installed ledger, or one rebuilt from the environment inside the runtime subprocess."""
    global _ACTIVE
    if _ACTIVE is not None:
        return _ACTIVE
    path = os.environ.get(LEDGER_ENV)
    raw = os.environ.get(LIMITS_ENV)
    if not path or not raw:
        return None
    with _ACTIVE_LOCK:
        if _ACTIVE is None:
            _ACTIVE = SpendLedger(Path(path), SpendLimits.from_json(raw))
    return _ACTIVE


def role_for_model(model_id: str | None) -> str | None:
    ledger = active_ledger()
    if ledger is None or not model_id:
        return None
    return ledger.limits.roles_by_model_id.get(model_id)


def check(role: str | None) -> None:
    ledger = active_ledger()
    if ledger is not None and role:
        ledger.check(role)


def record(role: str | None, usage: Any) -> None:
    ledger = active_ledger()
    if ledger is not None and role:
        ledger.record(role, usage)


@contextmanager
def spend_limits(limits: SpendLimits, ledger_path: Path) -> Iterator[SpendLedger]:
    """Install ``limits`` for this process and any subprocess it starts."""
    global _ACTIVE
    ledger_path.parent.mkdir(parents=True, exist_ok=True)
    ledger_path.touch(exist_ok=True)
    ledger = SpendLedger(ledger_path, limits)
    previous = {name: os.environ.get(name) for name in (LEDGER_ENV, LIMITS_ENV)}
    os.environ[LEDGER_ENV] = str(ledger_path)
    os.environ[LIMITS_ENV] = limits.to_json()
    with _ACTIVE_LOCK:
        _ACTIVE = ledger
    try:
        yield ledger
    finally:
        with _ACTIVE_LOCK:
            _ACTIVE = None
        for name, value in previous.items():
            if value is None:
                os.environ.pop(name, None)
            else:
                os.environ[name] = value