throttle-pro 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
throttle/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Throttle: safety-first measurement for OpenAI-compatible endpoints."""
2
+
3
+ __version__ = "0.3.0"
throttle/advisor.py ADDED
@@ -0,0 +1,283 @@
1
+ """
2
+ throttle/advisor.py
3
+ ===================
4
+ vLLM metric-to-dollar translation layer. Tier 1 only.
5
+
6
+ Reads vLLM /metrics (Prometheus text format), computes cost figures,
7
+ and streams one self-contained JSON snapshot per scrape interval.
8
+
9
+ Design constraints (from spec):
10
+ - Never estimates cost for unobserved configs
11
+ - Refuses to print $/Mtok when gen_throughput is unavailable
12
+ - Stops at Tier 1 — no recommendations, no window logic yet
13
+ - Small and readable: metrics arithmetic only
14
+
15
+ Consumed by a live view (not built here). Caller iterates stream_metrics()
16
+ and renders each snapshot however it wants.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import time
23
+ import urllib.request
24
+ import urllib.error
25
+ from dataclasses import dataclass, field, asdict
26
+ from typing import Iterator, Optional
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Prometheus scraper — no dependencies
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def _scrape(url: str, timeout: float = 5.0) -> dict[str, float]:
34
+ """
35
+ Scrape Prometheus text format from url.
36
+ Returns {metric_name: value} for gauge and counter lines.
37
+ Ignores histograms and summaries (not needed for Tier 1).
38
+ Raises on connection failure — caller decides how to handle.
39
+ """
40
+ try:
41
+ with urllib.request.urlopen(url, timeout=timeout) as resp:
42
+ body = resp.read().decode("utf-8")
43
+ except urllib.error.URLError as e:
44
+ raise ConnectionError(f"Cannot reach {url}: {e}") from e
45
+
46
+ metrics: dict[str, float] = {}
47
+ for line in body.splitlines():
48
+ line = line.strip()
49
+ if not line or line.startswith("#"):
50
+ continue
51
+ try:
52
+ # metric_name{labels} value [timestamp]
53
+ # or metric_name value [timestamp]
54
+ parts = line.rsplit(" ", 2)
55
+ value = float(parts[-2] if len(parts) == 3 else parts[-1])
56
+ name_part = parts[0]
57
+ # strip labels
58
+ name = name_part.split("{")[0]
59
+ # keep last value if duplicated (e.g. per-model labels)
60
+ metrics[name] = value
61
+ except (ValueError, IndexError):
62
+ continue
63
+ return metrics
64
+
65
+
66
+ # ---------------------------------------------------------------------------
67
+ # Tier 1 cost computation
68
+ # ---------------------------------------------------------------------------
69
+
70
+ @dataclass
71
+ class CostSnapshot:
72
+ # Metadata
73
+ timestamp: float
74
+ scrape_url: str
75
+ gpu_hourly_rate: float
76
+ observation_seconds: float # how long this tool has been running
77
+
78
+ # Raw metrics (None = not exposed by endpoint)
79
+ generation_throughput_toks_per_sec: Optional[float]
80
+ prompt_throughput_toks_per_sec: Optional[float]
81
+ num_requests_running: Optional[float]
82
+ num_requests_waiting: Optional[float]
83
+ gpu_cache_usage_perc: Optional[float]
84
+ num_preemptions_total: Optional[float]
85
+
86
+ # Derived cost (None = refused — see basis field)
87
+ cost_per_hour: Optional[float]
88
+ cost_per_million_tokens: Optional[float]
89
+ idle_fraction: Optional[float] # None until window logic added
90
+
91
+ # Batch fill (None if max_num_seqs unknown)
92
+ batch_fill: Optional[float]
93
+ max_num_seqs: Optional[int]
94
+
95
+ # Data quality
96
+ metrics_present: list[str] = field(default_factory=list)
97
+ metrics_unavailable: list[str] = field(default_factory=list)
98
+ refusals: list[dict] = field(default_factory=list)
99
+
100
+ def to_dict(self) -> dict:
101
+ return asdict(self)
102
+
103
+ def to_json(self) -> str:
104
+ return json.dumps(self.to_dict(), indent=2)
105
+
106
+
107
+ _TIER1_METRICS = [
108
+ "vllm:avg_generation_throughput_toks_per_s",
109
+ "vllm:avg_prompt_throughput_toks_per_s",
110
+ "vllm:num_requests_running",
111
+ "vllm:num_requests_waiting",
112
+ "vllm:gpu_cache_usage_perc",
113
+ "vllm:num_preemptions_total",
114
+ ]
115
+
116
+
117
+ def _build_snapshot(
118
+ raw: dict[str, float],
119
+ gpu_hourly_rate: float,
120
+ scrape_url: str,
121
+ observation_seconds: float,
122
+ max_num_seqs: Optional[int],
123
+ ) -> CostSnapshot:
124
+ """
125
+ Translate raw Prometheus metrics into a CostSnapshot.
126
+ Refuses cost computation when gen_throughput is unavailable.
127
+ """
128
+ present = []
129
+ unavailable = []
130
+ refusals = []
131
+
132
+ def get(name: str) -> Optional[float]:
133
+ if name in raw:
134
+ present.append(name)
135
+ return raw[name]
136
+ else:
137
+ unavailable.append(name)
138
+ return None
139
+
140
+ gen_tput = get("vllm:avg_generation_throughput_toks_per_s")
141
+ prompt_tput = get("vllm:avg_prompt_throughput_toks_per_s")
142
+ running = get("vllm:num_requests_running")
143
+ waiting = get("vllm:num_requests_waiting")
144
+ kv_usage = get("vllm:gpu_cache_usage_perc")
145
+ preemptions = get("vllm:num_preemptions_total")
146
+
147
+ # Cost computation
148
+ gpu_rate_per_sec = gpu_hourly_rate / 3600.0
149
+
150
+ if gen_tput is None or gen_tput <= 0:
151
+ cost_per_hour = None
152
+ cost_per_mtok = None
153
+ if gen_tput is None:
154
+ refusals.append({
155
+ "figure": "cost_per_million_tokens",
156
+ "reason": (
157
+ "vllm:avg_generation_throughput_toks_per_s not exposed. "
158
+ "Cannot compute cost without throughput denominator."
159
+ ),
160
+ })
161
+ else:
162
+ refusals.append({
163
+ "figure": "cost_per_million_tokens",
164
+ "reason": (
165
+ "Generation throughput is zero. "
166
+ "Is the backend serving requests?"
167
+ ),
168
+ })
169
+ else:
170
+ cost_per_hour = gpu_hourly_rate
171
+ cost_per_mtok = round((gpu_rate_per_sec / gen_tput) * 1_000_000, 4)
172
+
173
+ # Batch fill
174
+ batch_fill = None
175
+ if running is not None and max_num_seqs is not None and max_num_seqs > 0:
176
+ batch_fill = round(running / max_num_seqs, 3)
177
+
178
+ return CostSnapshot(
179
+ timestamp=time.time(),
180
+ scrape_url=scrape_url,
181
+ gpu_hourly_rate=gpu_hourly_rate,
182
+ observation_seconds=round(observation_seconds, 1),
183
+ generation_throughput_toks_per_sec=gen_tput,
184
+ prompt_throughput_toks_per_sec=prompt_tput,
185
+ num_requests_running=running,
186
+ num_requests_waiting=waiting,
187
+ gpu_cache_usage_perc=kv_usage,
188
+ num_preemptions_total=preemptions,
189
+ cost_per_hour=cost_per_hour,
190
+ cost_per_million_tokens=cost_per_mtok,
191
+ idle_fraction=None, # Tier 2 — window logic not yet built
192
+ batch_fill=batch_fill,
193
+ max_num_seqs=max_num_seqs,
194
+ metrics_present=present,
195
+ metrics_unavailable=unavailable,
196
+ refusals=refusals,
197
+ )
198
+
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # Public streaming interface
202
+ # ---------------------------------------------------------------------------
203
+
204
+ def stream_metrics(
205
+ metrics_url: str,
206
+ gpu_rate_per_hour: float,
207
+ interval_seconds: float = 15.0,
208
+ max_num_seqs: Optional[int] = None,
209
+ ) -> Iterator[CostSnapshot]:
210
+ """
211
+ Yields one CostSnapshot per scrape interval. Never returns.
212
+ Each snapshot is self-contained — caller renders or stores it.
213
+
214
+ Args:
215
+ metrics_url: vLLM /metrics endpoint, e.g. http://localhost:8000/metrics
216
+ gpu_rate_per_hour: GPU cost in $/hr. Required — no default.
217
+ interval_seconds: scrape interval (default 15s)
218
+ max_num_seqs: vLLM max_num_seqs for batch fill computation.
219
+ If None, batch_fill is omitted from snapshots.
220
+ """
221
+ start = time.time()
222
+
223
+ while True:
224
+ t0 = time.perf_counter()
225
+ observation_seconds = time.time() - start
226
+
227
+ try:
228
+ raw = _scrape(metrics_url)
229
+ except ConnectionError as e:
230
+ # Yield an error snapshot rather than crashing the stream
231
+ yield CostSnapshot(
232
+ timestamp=time.time(),
233
+ scrape_url=metrics_url,
234
+ gpu_hourly_rate=gpu_rate_per_hour,
235
+ observation_seconds=round(observation_seconds, 1),
236
+ generation_throughput_toks_per_sec=None,
237
+ prompt_throughput_toks_per_sec=None,
238
+ num_requests_running=None,
239
+ num_requests_waiting=None,
240
+ gpu_cache_usage_perc=None,
241
+ num_preemptions_total=None,
242
+ cost_per_hour=None,
243
+ cost_per_million_tokens=None,
244
+ idle_fraction=None,
245
+ batch_fill=None,
246
+ max_num_seqs=max_num_seqs,
247
+ metrics_present=[],
248
+ metrics_unavailable=_TIER1_METRICS,
249
+ refusals=[{"figure": "all", "reason": str(e)}],
250
+ )
251
+ else:
252
+ yield _build_snapshot(
253
+ raw=raw,
254
+ gpu_hourly_rate=gpu_rate_per_hour,
255
+ scrape_url=metrics_url,
256
+ observation_seconds=observation_seconds,
257
+ max_num_seqs=max_num_seqs,
258
+ )
259
+
260
+ # Sleep for remainder of interval
261
+ elapsed = time.perf_counter() - t0
262
+ sleep = max(0.0, interval_seconds - elapsed)
263
+ time.sleep(sleep)
264
+
265
+
266
+ # ---------------------------------------------------------------------------
267
+ # One-shot snapshot (for --try and testing)
268
+ # ---------------------------------------------------------------------------
269
+
270
+ def snapshot_once(
271
+ metrics_url: str,
272
+ gpu_rate_per_hour: float,
273
+ max_num_seqs: Optional[int] = None,
274
+ ) -> CostSnapshot:
275
+ """Single scrape — used by --try and tests. Does not loop."""
276
+ raw = _scrape(metrics_url)
277
+ return _build_snapshot(
278
+ raw=raw,
279
+ gpu_hourly_rate=gpu_rate_per_hour,
280
+ scrape_url=metrics_url,
281
+ observation_seconds=0.0,
282
+ max_num_seqs=max_num_seqs,
283
+ )