downshift 0.1.0.dev0__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.
- downshift/__init__.py +3 -0
- downshift/audit.py +188 -0
- downshift/cli.py +755 -0
- downshift/config.py +348 -0
- downshift/cost.py +212 -0
- downshift/decide.py +220 -0
- downshift/evalgen.py +220 -0
- downshift/evals.py +395 -0
- downshift/llm.py +171 -0
- downshift/py.typed +0 -0
- downshift/report.py +380 -0
- downshift/resolve.py +439 -0
- downshift/runner.py +312 -0
- downshift/scanner.py +310 -0
- downshift/schema.py +356 -0
- downshift/scorer.py +219 -0
- downshift-0.1.0.dev0.dist-info/METADATA +24 -0
- downshift-0.1.0.dev0.dist-info/RECORD +21 -0
- downshift-0.1.0.dev0.dist-info/WHEEL +4 -0
- downshift-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- downshift-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
downshift/config.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Load and validate downshift.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
CONFIG_FILENAME = "downshift.yaml"
|
|
14
|
+
SUPPORTED_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ConfigError(Exception):
|
|
18
|
+
"""Raised when a config file is missing or invalid. The message lists every problem."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ProviderConfig:
|
|
23
|
+
base_url: str = "http://localhost:11434/v1"
|
|
24
|
+
api_key_env: str | None = None
|
|
25
|
+
|
|
26
|
+
def api_key(self) -> str:
|
|
27
|
+
if self.api_key_env is None:
|
|
28
|
+
return "not-needed"
|
|
29
|
+
value = os.environ.get(self.api_key_env)
|
|
30
|
+
if not value:
|
|
31
|
+
raise ConfigError(
|
|
32
|
+
f"environment variable {self.api_key_env} is not set (provider.api_key_env)"
|
|
33
|
+
)
|
|
34
|
+
return value
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ModelsConfig:
|
|
39
|
+
baseline: str = "qwen2.5:7b"
|
|
40
|
+
candidates: tuple[str, ...] = ("qwen2.5:1.5b", "qwen2.5:0.5b")
|
|
41
|
+
judge: str | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def judge_model(self) -> str:
|
|
45
|
+
return self.judge or self.baseline
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def all_models(self) -> tuple[str, ...]:
|
|
49
|
+
return (self.baseline, *self.candidates)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ModelPrice:
|
|
54
|
+
"""Price in USD per one million tokens."""
|
|
55
|
+
|
|
56
|
+
input_per_mtok: float
|
|
57
|
+
output_per_mtok: float
|
|
58
|
+
tier: str | None = None
|
|
59
|
+
|
|
60
|
+
def cost(self, prompt_tokens: int, completion_tokens: int) -> float:
|
|
61
|
+
return (
|
|
62
|
+
prompt_tokens * self.input_per_mtok + completion_tokens * self.output_per_mtok
|
|
63
|
+
) / 1_000_000
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class VolumeConfig:
|
|
68
|
+
default_per_day: int = 1000
|
|
69
|
+
per_call_site: Mapping[str, int] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
def calls_per_day(self, call_site_id: str) -> int:
|
|
72
|
+
return self.per_call_site.get(call_site_id, self.default_per_day)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class ScanConfig:
|
|
77
|
+
include: tuple[str, ...] = ("*.py",)
|
|
78
|
+
exclude: tuple[str, ...] = ()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Config:
|
|
83
|
+
provider: ProviderConfig = field(default_factory=ProviderConfig)
|
|
84
|
+
models: ModelsConfig = field(default_factory=ModelsConfig)
|
|
85
|
+
quality_threshold: float = 0.95
|
|
86
|
+
min_pass_rate: float = 0.0
|
|
87
|
+
pricing: Mapping[str, ModelPrice] = field(default_factory=dict)
|
|
88
|
+
volume: VolumeConfig = field(default_factory=VolumeConfig)
|
|
89
|
+
scan: ScanConfig = field(default_factory=ScanConfig)
|
|
90
|
+
source: Path | None = None
|
|
91
|
+
|
|
92
|
+
def price_for(self, model: str) -> ModelPrice:
|
|
93
|
+
try:
|
|
94
|
+
return self.pricing[model]
|
|
95
|
+
except KeyError:
|
|
96
|
+
where = f" in {self.source}" if self.source else ""
|
|
97
|
+
raise ConfigError(
|
|
98
|
+
f"no pricing for model {model!r}{where}; add it under 'pricing'"
|
|
99
|
+
) from None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# --- loading ------------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_config(path: Path) -> Config:
|
|
106
|
+
"""Read and validate a config file."""
|
|
107
|
+
try:
|
|
108
|
+
text = path.read_text(encoding="utf-8")
|
|
109
|
+
except FileNotFoundError:
|
|
110
|
+
raise ConfigError(f"config file not found: {path}") from None
|
|
111
|
+
except OSError as exc:
|
|
112
|
+
raise ConfigError(f"cannot read {path}: {exc}") from exc
|
|
113
|
+
try:
|
|
114
|
+
data = yaml.safe_load(text)
|
|
115
|
+
except yaml.YAMLError as exc:
|
|
116
|
+
raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
|
|
117
|
+
return parse_config(data, source=path)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def find_config(start: Path) -> Path | None:
|
|
121
|
+
"""Return downshift.yaml next to start (a directory or a file), if it exists."""
|
|
122
|
+
directory = start if start.is_dir() else start.parent
|
|
123
|
+
candidate = directory / CONFIG_FILENAME
|
|
124
|
+
return candidate if candidate.is_file() else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def resolve_config(explicit: Path | None, search_from: Path) -> Config:
|
|
128
|
+
"""Use the explicit config if given, else a discovered one, else defaults."""
|
|
129
|
+
if explicit is not None:
|
|
130
|
+
return load_config(explicit)
|
|
131
|
+
found = find_config(search_from)
|
|
132
|
+
return load_config(found) if found else Config()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- validation ---------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
_TOP_LEVEL_KEYS = {
|
|
138
|
+
"version",
|
|
139
|
+
"provider",
|
|
140
|
+
"models",
|
|
141
|
+
"quality_threshold",
|
|
142
|
+
"min_pass_rate",
|
|
143
|
+
"pricing",
|
|
144
|
+
"volume",
|
|
145
|
+
"scan",
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _is_number(value: Any) -> bool:
|
|
150
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _is_int(value: Any) -> bool:
|
|
154
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_nonempty_str(value: Any) -> bool:
|
|
158
|
+
return isinstance(value, str) and value.strip() != ""
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _section(data: dict[str, Any], name: str, problems: list[str]) -> dict[str, Any] | None:
|
|
162
|
+
value = data.get(name)
|
|
163
|
+
if value is None:
|
|
164
|
+
return {}
|
|
165
|
+
if not isinstance(value, dict):
|
|
166
|
+
problems.append(f"{name}: must be a mapping")
|
|
167
|
+
return None
|
|
168
|
+
return value
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _check_keys(
|
|
172
|
+
mapping: dict[str, Any], allowed: set[str], prefix: str, problems: list[str]
|
|
173
|
+
) -> None:
|
|
174
|
+
for key in mapping:
|
|
175
|
+
if key not in allowed:
|
|
176
|
+
problems.append(f"{prefix}{key}: unknown key (allowed: {', '.join(sorted(allowed))})")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _str_list(value: Any, path: str, problems: list[str]) -> tuple[str, ...] | None:
|
|
180
|
+
if not isinstance(value, list) or not all(_is_nonempty_str(v) for v in value):
|
|
181
|
+
problems.append(f"{path}: must be a list of non-empty strings")
|
|
182
|
+
return None
|
|
183
|
+
return tuple(value)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _parse_provider(data: dict[str, Any], problems: list[str]) -> ProviderConfig:
|
|
187
|
+
section = _section(data, "provider", problems)
|
|
188
|
+
if section is None:
|
|
189
|
+
return ProviderConfig()
|
|
190
|
+
_check_keys(section, {"base_url", "api_key_env"}, "provider.", problems)
|
|
191
|
+
base_url = section.get("base_url", ProviderConfig.base_url)
|
|
192
|
+
if not (_is_nonempty_str(base_url) and base_url.startswith(("http://", "https://"))):
|
|
193
|
+
problems.append("provider.base_url: must be a URL starting with http:// or https://")
|
|
194
|
+
base_url = ProviderConfig.base_url
|
|
195
|
+
api_key_env = section.get("api_key_env")
|
|
196
|
+
if api_key_env is not None and not _is_nonempty_str(api_key_env):
|
|
197
|
+
problems.append("provider.api_key_env: must be an environment variable name or null")
|
|
198
|
+
api_key_env = None
|
|
199
|
+
return ProviderConfig(base_url=base_url, api_key_env=api_key_env)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse_models(data: dict[str, Any], problems: list[str]) -> ModelsConfig:
|
|
203
|
+
section = _section(data, "models", problems)
|
|
204
|
+
if section is None:
|
|
205
|
+
return ModelsConfig()
|
|
206
|
+
_check_keys(section, {"baseline", "candidates", "judge"}, "models.", problems)
|
|
207
|
+
defaults = ModelsConfig()
|
|
208
|
+
|
|
209
|
+
baseline = section.get("baseline", defaults.baseline)
|
|
210
|
+
if not _is_nonempty_str(baseline):
|
|
211
|
+
problems.append("models.baseline: must be a non-empty model name")
|
|
212
|
+
baseline = defaults.baseline
|
|
213
|
+
|
|
214
|
+
candidates = defaults.candidates
|
|
215
|
+
if "candidates" in section:
|
|
216
|
+
parsed = _str_list(section["candidates"], "models.candidates", problems)
|
|
217
|
+
if parsed is not None:
|
|
218
|
+
if not parsed:
|
|
219
|
+
problems.append("models.candidates: list at least one cheaper model to try")
|
|
220
|
+
if len(set(parsed)) != len(parsed):
|
|
221
|
+
problems.append("models.candidates: contains duplicate model names")
|
|
222
|
+
if baseline in parsed:
|
|
223
|
+
problems.append("models.candidates: must not include the baseline model")
|
|
224
|
+
candidates = parsed
|
|
225
|
+
|
|
226
|
+
judge = section.get("judge")
|
|
227
|
+
if judge is not None and not _is_nonempty_str(judge):
|
|
228
|
+
problems.append("models.judge: must be a model name or null")
|
|
229
|
+
judge = None
|
|
230
|
+
return ModelsConfig(baseline=baseline, candidates=candidates, judge=judge)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_threshold(data: dict[str, Any], problems: list[str]) -> float:
|
|
234
|
+
value = data.get("quality_threshold", 0.95)
|
|
235
|
+
if not _is_number(value) or not 0 < value <= 1:
|
|
236
|
+
problems.append("quality_threshold: must be a number greater than 0 and at most 1")
|
|
237
|
+
return 0.95
|
|
238
|
+
return float(value)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _parse_min_pass_rate(data: dict[str, Any], problems: list[str]) -> float:
|
|
242
|
+
value = data.get("min_pass_rate", 0.0)
|
|
243
|
+
if not _is_number(value) or not 0 <= value <= 1:
|
|
244
|
+
problems.append("min_pass_rate: must be a number from 0 to 1")
|
|
245
|
+
return 0.0
|
|
246
|
+
return float(value)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _parse_pricing(data: dict[str, Any], problems: list[str]) -> dict[str, ModelPrice]:
|
|
250
|
+
section = _section(data, "pricing", problems)
|
|
251
|
+
if section is None:
|
|
252
|
+
return {}
|
|
253
|
+
prices: dict[str, ModelPrice] = {}
|
|
254
|
+
for model, entry in section.items():
|
|
255
|
+
path = f"pricing.{model}"
|
|
256
|
+
if not isinstance(entry, dict):
|
|
257
|
+
problems.append(f"{path}: must be a mapping with input and output prices")
|
|
258
|
+
continue
|
|
259
|
+
_check_keys(entry, {"input", "output", "tier"}, f"{path}.", problems)
|
|
260
|
+
ok = True
|
|
261
|
+
for key in ("input", "output"):
|
|
262
|
+
if key not in entry:
|
|
263
|
+
problems.append(f"{path}.{key}: missing (USD per 1M tokens)")
|
|
264
|
+
ok = False
|
|
265
|
+
elif not _is_number(entry[key]) or entry[key] < 0:
|
|
266
|
+
problems.append(f"{path}.{key}: must be a number >= 0")
|
|
267
|
+
ok = False
|
|
268
|
+
tier = entry.get("tier")
|
|
269
|
+
if tier is not None and not _is_nonempty_str(tier):
|
|
270
|
+
problems.append(f"{path}.tier: must be a string")
|
|
271
|
+
tier = None
|
|
272
|
+
if ok:
|
|
273
|
+
prices[str(model)] = ModelPrice(float(entry["input"]), float(entry["output"]), tier)
|
|
274
|
+
return prices
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _parse_volume(data: dict[str, Any], problems: list[str]) -> VolumeConfig:
|
|
278
|
+
section = _section(data, "volume", problems)
|
|
279
|
+
if section is None:
|
|
280
|
+
return VolumeConfig()
|
|
281
|
+
_check_keys(section, {"default_per_day", "per_call_site"}, "volume.", problems)
|
|
282
|
+
default = section.get("default_per_day", VolumeConfig.default_per_day)
|
|
283
|
+
if not _is_int(default) or default < 0:
|
|
284
|
+
problems.append("volume.default_per_day: must be a whole number >= 0")
|
|
285
|
+
default = VolumeConfig.default_per_day
|
|
286
|
+
|
|
287
|
+
overrides: dict[str, int] = {}
|
|
288
|
+
raw = section.get("per_call_site") or {}
|
|
289
|
+
if not isinstance(raw, dict):
|
|
290
|
+
problems.append("volume.per_call_site: must be a mapping of call site id to calls/day")
|
|
291
|
+
else:
|
|
292
|
+
for site, count in raw.items():
|
|
293
|
+
if not _is_int(count) or count < 0:
|
|
294
|
+
problems.append(f"volume.per_call_site.{site}: must be a whole number >= 0")
|
|
295
|
+
else:
|
|
296
|
+
overrides[str(site)] = count
|
|
297
|
+
return VolumeConfig(default_per_day=default, per_call_site=overrides)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _parse_scan(data: dict[str, Any], problems: list[str]) -> ScanConfig:
|
|
301
|
+
section = _section(data, "scan", problems)
|
|
302
|
+
if section is None:
|
|
303
|
+
return ScanConfig()
|
|
304
|
+
_check_keys(section, {"include", "exclude"}, "scan.", problems)
|
|
305
|
+
defaults = ScanConfig()
|
|
306
|
+
include = defaults.include
|
|
307
|
+
if "include" in section:
|
|
308
|
+
parsed = _str_list(section["include"], "scan.include", problems)
|
|
309
|
+
if parsed is not None:
|
|
310
|
+
if not parsed:
|
|
311
|
+
problems.append("scan.include: must list at least one pattern")
|
|
312
|
+
else:
|
|
313
|
+
include = parsed
|
|
314
|
+
exclude = defaults.exclude
|
|
315
|
+
if "exclude" in section:
|
|
316
|
+
parsed = _str_list(section["exclude"], "scan.exclude", problems)
|
|
317
|
+
if parsed is not None:
|
|
318
|
+
exclude = parsed
|
|
319
|
+
return ScanConfig(include=include, exclude=exclude)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def parse_config(data: Any, source: Path | None = None) -> Config:
|
|
323
|
+
"""Validate parsed YAML and build a Config. Raises ConfigError listing every problem."""
|
|
324
|
+
label = f"invalid config {source}" if source else "invalid config"
|
|
325
|
+
if data is None:
|
|
326
|
+
data = {}
|
|
327
|
+
if not isinstance(data, dict):
|
|
328
|
+
raise ConfigError(f"{label}: the top level must be a mapping")
|
|
329
|
+
|
|
330
|
+
problems: list[str] = []
|
|
331
|
+
_check_keys(data, _TOP_LEVEL_KEYS, "", problems)
|
|
332
|
+
version = data.get("version", SUPPORTED_VERSION)
|
|
333
|
+
if version != SUPPORTED_VERSION:
|
|
334
|
+
problems.append(f"version: unsupported value {version!r} (expected {SUPPORTED_VERSION})")
|
|
335
|
+
|
|
336
|
+
config = Config(
|
|
337
|
+
provider=_parse_provider(data, problems),
|
|
338
|
+
models=_parse_models(data, problems),
|
|
339
|
+
quality_threshold=_parse_threshold(data, problems),
|
|
340
|
+
min_pass_rate=_parse_min_pass_rate(data, problems),
|
|
341
|
+
pricing=_parse_pricing(data, problems),
|
|
342
|
+
volume=_parse_volume(data, problems),
|
|
343
|
+
scan=_parse_scan(data, problems),
|
|
344
|
+
source=source,
|
|
345
|
+
)
|
|
346
|
+
if problems:
|
|
347
|
+
raise ConfigError(label + ":\n" + "\n".join(f" - {p}" for p in problems))
|
|
348
|
+
return config
|
downshift/cost.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Projected monthly cost before vs after the decisions.
|
|
2
|
+
|
|
3
|
+
These are projections, not bills: measured avg tokens per call (from the eval runs,
|
|
4
|
+
per model) x configured prices x configured calls per day x days. Judge calls are
|
|
5
|
+
not part of the app's cost and are never counted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Iterable, Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from downshift.config import Config, ModelPrice, VolumeConfig
|
|
15
|
+
from downshift.decide import Decision, ModelStats
|
|
16
|
+
|
|
17
|
+
DAYS_PER_MONTH = 30
|
|
18
|
+
HOURS_PER_MONTH = 730
|
|
19
|
+
SECONDS_PER_HOUR = 3600
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _per_call(
|
|
23
|
+
stats: Mapping[str, ModelStats], model: str, prices: Mapping[str, ModelPrice]
|
|
24
|
+
) -> float | None:
|
|
25
|
+
s = stats.get(model)
|
|
26
|
+
if s is None:
|
|
27
|
+
return None
|
|
28
|
+
price = prices.get(model)
|
|
29
|
+
if price is None:
|
|
30
|
+
raise ValueError(f"no pricing for model {model!r}")
|
|
31
|
+
return s.cost_per_call(price)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class SiteCost:
|
|
36
|
+
"""Monthly cost of one call site before and after its decision."""
|
|
37
|
+
|
|
38
|
+
site_id: str
|
|
39
|
+
before_model: str
|
|
40
|
+
after_model: str
|
|
41
|
+
calls_per_day: int
|
|
42
|
+
before_per_call: float | None
|
|
43
|
+
after_per_call: float | None
|
|
44
|
+
days: int = DAYS_PER_MONTH
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def calls_per_month(self) -> int:
|
|
48
|
+
return self.calls_per_day * self.days
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def known(self) -> bool:
|
|
52
|
+
return self.before_per_call is not None and self.after_per_call is not None
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def before_monthly(self) -> float | None:
|
|
56
|
+
if self.before_per_call is None:
|
|
57
|
+
return None
|
|
58
|
+
return self.before_per_call * self.calls_per_month
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def after_monthly(self) -> float | None:
|
|
62
|
+
if self.after_per_call is None:
|
|
63
|
+
return None
|
|
64
|
+
return self.after_per_call * self.calls_per_month
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def savings(self) -> float | None:
|
|
68
|
+
before, after = self.before_monthly, self.after_monthly
|
|
69
|
+
if before is None or after is None:
|
|
70
|
+
return None
|
|
71
|
+
return before - after
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def savings_pct(self) -> float | None:
|
|
75
|
+
before, savings = self.before_monthly, self.savings
|
|
76
|
+
if before is None or savings is None or before == 0:
|
|
77
|
+
return None
|
|
78
|
+
return savings / before
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def site_cost(
|
|
82
|
+
decision: Decision,
|
|
83
|
+
prices: Mapping[str, ModelPrice],
|
|
84
|
+
calls_per_day: int,
|
|
85
|
+
days: int = DAYS_PER_MONTH,
|
|
86
|
+
) -> SiteCost:
|
|
87
|
+
"""Cost of one site; each model is priced with its own measured tokens."""
|
|
88
|
+
if calls_per_day < 0:
|
|
89
|
+
raise ValueError(f"calls_per_day must be >= 0, got {calls_per_day}")
|
|
90
|
+
if days <= 0:
|
|
91
|
+
raise ValueError(f"days must be > 0, got {days}")
|
|
92
|
+
before = _per_call(decision.stats, decision.baseline, prices)
|
|
93
|
+
if decision.model == decision.baseline:
|
|
94
|
+
after = before
|
|
95
|
+
else:
|
|
96
|
+
after = _per_call(decision.stats, decision.model, prices)
|
|
97
|
+
return SiteCost(
|
|
98
|
+
site_id=decision.site_id,
|
|
99
|
+
before_model=decision.baseline,
|
|
100
|
+
after_model=decision.model,
|
|
101
|
+
calls_per_day=calls_per_day,
|
|
102
|
+
before_per_call=before,
|
|
103
|
+
after_per_call=after,
|
|
104
|
+
days=days,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class CostSummary:
|
|
110
|
+
"""Totals over the sites whose cost is known (unknown sites are listed, not guessed)."""
|
|
111
|
+
|
|
112
|
+
sites: tuple[SiteCost, ...]
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def unknown(self) -> tuple[str, ...]:
|
|
116
|
+
return tuple(s.site_id for s in self.sites if not s.known)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def before_monthly(self) -> float:
|
|
120
|
+
return sum(s.before_monthly or 0.0 for s in self.sites if s.known)
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def after_monthly(self) -> float:
|
|
124
|
+
return sum(s.after_monthly or 0.0 for s in self.sites if s.known)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def savings(self) -> float:
|
|
128
|
+
return self.before_monthly - self.after_monthly
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def savings_pct(self) -> float | None:
|
|
132
|
+
before = self.before_monthly
|
|
133
|
+
return self.savings / before if before else None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cost_summary(
|
|
137
|
+
decisions: Iterable[Decision],
|
|
138
|
+
prices: Mapping[str, ModelPrice],
|
|
139
|
+
volume: VolumeConfig,
|
|
140
|
+
days: int = DAYS_PER_MONTH,
|
|
141
|
+
) -> CostSummary:
|
|
142
|
+
return CostSummary(
|
|
143
|
+
tuple(site_cost(d, prices, volume.calls_per_day(d.site_id), days) for d in decisions)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cost_summary_for(decisions: Iterable[Decision], config: Config) -> CostSummary:
|
|
148
|
+
"""cost_summary with prices and volumes from the config."""
|
|
149
|
+
return cost_summary(decisions, config.pricing, config.volume)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# --- self-host break-even (a calculation from stated assumptions) -----------------
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass(frozen=True)
|
|
156
|
+
class SelfHost:
|
|
157
|
+
"""Assumptions for serving a model yourself on a rented GPU (e.g. with vLLM)."""
|
|
158
|
+
|
|
159
|
+
gpu_hourly_usd: float
|
|
160
|
+
tokens_per_second: float # sustained throughput of one GPU, prompt + completion
|
|
161
|
+
utilization: float = 0.5 # share of the month the GPU does useful work
|
|
162
|
+
|
|
163
|
+
def __post_init__(self) -> None:
|
|
164
|
+
if self.gpu_hourly_usd <= 0:
|
|
165
|
+
raise ValueError("gpu_hourly_usd must be > 0")
|
|
166
|
+
if self.tokens_per_second <= 0:
|
|
167
|
+
raise ValueError("tokens_per_second must be > 0")
|
|
168
|
+
if not 0 < self.utilization <= 1:
|
|
169
|
+
raise ValueError("utilization must be in (0, 1]")
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def monthly_cost_per_gpu(self) -> float:
|
|
173
|
+
return self.gpu_hourly_usd * HOURS_PER_MONTH
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def tokens_per_month_per_gpu(self) -> float:
|
|
177
|
+
return self.tokens_per_second * self.utilization * HOURS_PER_MONTH * SECONDS_PER_HOUR
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True)
|
|
181
|
+
class BreakEven:
|
|
182
|
+
calls_per_day: float # volume where one GPU costs the same as the API
|
|
183
|
+
capacity_calls_per_day: float # what one GPU can serve at this call size
|
|
184
|
+
monthly_cost_per_gpu: float
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def self_host_can_win(self) -> bool:
|
|
188
|
+
"""False when one GPU saturates before it gets cheaper than the API."""
|
|
189
|
+
return self.calls_per_day <= self.capacity_calls_per_day
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def break_even(
|
|
193
|
+
api_cost_per_call: float,
|
|
194
|
+
tokens_per_call: float,
|
|
195
|
+
host: SelfHost,
|
|
196
|
+
days: int = DAYS_PER_MONTH,
|
|
197
|
+
) -> BreakEven:
|
|
198
|
+
if tokens_per_call <= 0:
|
|
199
|
+
raise ValueError("tokens_per_call must be > 0")
|
|
200
|
+
if days <= 0:
|
|
201
|
+
raise ValueError(f"days must be > 0, got {days}")
|
|
202
|
+
gpu = host.monthly_cost_per_gpu
|
|
203
|
+
calls = math.inf if api_cost_per_call <= 0 else gpu / (days * api_cost_per_call)
|
|
204
|
+
capacity = host.tokens_per_month_per_gpu / (tokens_per_call * days)
|
|
205
|
+
return BreakEven(calls_per_day=calls, capacity_calls_per_day=capacity, monthly_cost_per_gpu=gpu)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def gpus_needed(tokens_per_month: float, host: SelfHost) -> int:
|
|
209
|
+
"""GPUs needed for a monthly token volume (at least 1)."""
|
|
210
|
+
if tokens_per_month < 0:
|
|
211
|
+
raise ValueError("tokens_per_month must be >= 0")
|
|
212
|
+
return max(1, math.ceil(tokens_per_month / host.tokens_per_month_per_gpu))
|