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/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))