downshift 0.1.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.
- downshift/__init__.py +3 -0
- downshift/audit.py +188 -0
- downshift/cli.py +930 -0
- downshift/config.py +348 -0
- downshift/cost.py +212 -0
- downshift/decide.py +220 -0
- downshift/diff.py +387 -0
- downshift/evalgen.py +220 -0
- downshift/evals.py +395 -0
- downshift/export.py +412 -0
- downshift/gitref.py +65 -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 +334 -0
- downshift/schema.py +356 -0
- downshift/scorer.py +219 -0
- downshift-0.1.0.dist-info/METADATA +218 -0
- downshift-0.1.0.dist-info/RECORD +24 -0
- downshift-0.1.0.dist-info/WHEEL +4 -0
- downshift-0.1.0.dist-info/entry_points.txt +2 -0
- downshift-0.1.0.dist-info/licenses/LICENSE +21 -0
downshift/decide.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Downgrade decisions: the cheapest model that keeps enough of the baseline's quality.
|
|
2
|
+
|
|
3
|
+
Decisions use pass rate (share of cases fully right), never mean score. A model can get
|
|
4
|
+
most JSON fields right and still fail most cases.
|
|
5
|
+
|
|
6
|
+
A candidate qualifies when all of these hold:
|
|
7
|
+
- its results are complete (every case scored, no error rows),
|
|
8
|
+
- pass rate / baseline pass rate >= threshold,
|
|
9
|
+
- pass rate >= min_pass_rate (absolute floor, 0 = off),
|
|
10
|
+
- it costs less per call than the baseline (priced with its own measured tokens).
|
|
11
|
+
The cheapest qualifying candidate wins; ties go to the higher pass rate, then list order.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from downshift.config import ModelPrice
|
|
21
|
+
from downshift.evals import EvalSet
|
|
22
|
+
from downshift.runner import ResultRow, RunSummary, load_results, results_path, summarize_rows
|
|
23
|
+
|
|
24
|
+
KEEP = "keep"
|
|
25
|
+
DOWNGRADE = "downgrade"
|
|
26
|
+
EPSILON = 1e-9 # 18/22 vs 20/22 must count as exactly 0.90
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def call_cost(price: ModelPrice, prompt_tokens: float, completion_tokens: float) -> float:
|
|
30
|
+
"""USD for one call with these (average) token counts."""
|
|
31
|
+
total = prompt_tokens * price.input_per_mtok + completion_tokens * price.output_per_mtok
|
|
32
|
+
return total / 1_000_000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class ModelStats:
|
|
37
|
+
"""Results of one model on one call site."""
|
|
38
|
+
|
|
39
|
+
summary: RunSummary
|
|
40
|
+
avg_judge_score: float | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def model(self) -> str:
|
|
44
|
+
return self.summary.model
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def pass_rate(self) -> float | None:
|
|
48
|
+
return self.summary.pass_rate
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def complete(self) -> bool:
|
|
52
|
+
s = self.summary
|
|
53
|
+
return s.cases > 0 and s.errors == 0 and s.scored == s.cases
|
|
54
|
+
|
|
55
|
+
def cost_per_call(self, price: ModelPrice) -> float | None:
|
|
56
|
+
s = self.summary
|
|
57
|
+
if s.avg_prompt_tokens is None or s.avg_completion_tokens is None:
|
|
58
|
+
return None
|
|
59
|
+
return call_cost(price, s.avg_prompt_tokens, s.avg_completion_tokens)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def stats_for(
|
|
63
|
+
site_id: str, model: str, case_ids: Sequence[str], rows: Mapping[str, ResultRow]
|
|
64
|
+
) -> ModelStats:
|
|
65
|
+
"""Stats for these case ids. Avg judge score counts only scored rows that have one."""
|
|
66
|
+
summary = summarize_rows(site_id, model, case_ids, rows)
|
|
67
|
+
judged = [
|
|
68
|
+
float(rows[c].judge_score) # type: ignore[arg-type]
|
|
69
|
+
for c in case_ids
|
|
70
|
+
if c in rows and rows[c].ok and rows[c].judge_score is not None
|
|
71
|
+
]
|
|
72
|
+
avg = sum(judged) / len(judged) if judged else None
|
|
73
|
+
return ModelStats(summary=summary, avg_judge_score=avg)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_site_stats(
|
|
77
|
+
site_id: str, eval_set: EvalSet, models: Iterable[str], results_dir: Path
|
|
78
|
+
) -> dict[str, ModelStats]:
|
|
79
|
+
"""Stats per model for one site, from results files (last row per case wins)."""
|
|
80
|
+
case_ids = [case.id for case in eval_set.cases]
|
|
81
|
+
out: dict[str, ModelStats] = {}
|
|
82
|
+
for model in models:
|
|
83
|
+
rows = load_results(results_path(results_dir, site_id, model))
|
|
84
|
+
out[model] = stats_for(site_id, model, case_ids, rows)
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class CandidateCheck:
|
|
90
|
+
"""Why one candidate did or did not qualify."""
|
|
91
|
+
|
|
92
|
+
model: str
|
|
93
|
+
pass_rate: float | None
|
|
94
|
+
ratio: float | None
|
|
95
|
+
cost_per_call: float | None
|
|
96
|
+
passed: bool
|
|
97
|
+
reason: str
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class Decision:
|
|
102
|
+
site_id: str
|
|
103
|
+
action: str # KEEP or DOWNGRADE
|
|
104
|
+
model: str # the model to use after the decision
|
|
105
|
+
baseline: str
|
|
106
|
+
reason: str
|
|
107
|
+
baseline_pass_rate: float | None
|
|
108
|
+
baseline_below_floor: bool
|
|
109
|
+
checks: tuple[CandidateCheck, ...]
|
|
110
|
+
stats: Mapping[str, ModelStats]
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def downgraded(self) -> bool:
|
|
114
|
+
return self.action == DOWNGRADE
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _price(prices: Mapping[str, ModelPrice], model: str) -> ModelPrice:
|
|
118
|
+
try:
|
|
119
|
+
return prices[model]
|
|
120
|
+
except KeyError:
|
|
121
|
+
raise ValueError(f"no pricing for model {model!r}") from None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _incomplete(s: RunSummary) -> str:
|
|
125
|
+
return f"incomplete results ({s.errors} errors, {s.scored}/{s.cases} scored)"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _check(
|
|
129
|
+
model: str,
|
|
130
|
+
s: ModelStats | None,
|
|
131
|
+
*,
|
|
132
|
+
base_rate: float,
|
|
133
|
+
base_cost: float | None,
|
|
134
|
+
prices: Mapping[str, ModelPrice],
|
|
135
|
+
threshold: float,
|
|
136
|
+
min_pass_rate: float,
|
|
137
|
+
) -> CandidateCheck:
|
|
138
|
+
if s is None or s.summary.scored == 0:
|
|
139
|
+
return CandidateCheck(model, None, None, None, False, "no results")
|
|
140
|
+
rate = s.pass_rate or 0.0
|
|
141
|
+
ratio = rate / base_rate
|
|
142
|
+
cost = s.cost_per_call(_price(prices, model))
|
|
143
|
+
|
|
144
|
+
def fail(reason: str) -> CandidateCheck:
|
|
145
|
+
return CandidateCheck(model, rate, ratio, cost, False, reason)
|
|
146
|
+
|
|
147
|
+
if not s.complete:
|
|
148
|
+
return fail(_incomplete(s.summary))
|
|
149
|
+
if ratio < threshold - EPSILON:
|
|
150
|
+
return fail(f"keeps {ratio:.0%} of baseline quality, needs {threshold:.0%}")
|
|
151
|
+
if rate < min_pass_rate - EPSILON:
|
|
152
|
+
return fail(f"pass rate {rate:.0%} is below the floor {min_pass_rate:.0%}")
|
|
153
|
+
if cost is None or base_cost is None or cost >= base_cost:
|
|
154
|
+
return fail("not cheaper than the baseline")
|
|
155
|
+
return CandidateCheck(
|
|
156
|
+
model, rate, ratio, cost, True, f"keeps {ratio:.0%} of baseline quality and costs less"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def decide_site(
|
|
161
|
+
site_id: str,
|
|
162
|
+
stats: Mapping[str, ModelStats],
|
|
163
|
+
*,
|
|
164
|
+
baseline: str,
|
|
165
|
+
candidates: Sequence[str],
|
|
166
|
+
prices: Mapping[str, ModelPrice],
|
|
167
|
+
threshold: float,
|
|
168
|
+
min_pass_rate: float = 0.0,
|
|
169
|
+
) -> Decision:
|
|
170
|
+
"""Keep the baseline or downgrade to the cheapest qualifying candidate."""
|
|
171
|
+
if not 0.0 < threshold <= 1.0:
|
|
172
|
+
raise ValueError(f"threshold must be in (0, 1], got {threshold}")
|
|
173
|
+
if not 0.0 <= min_pass_rate <= 1.0:
|
|
174
|
+
raise ValueError(f"min_pass_rate must be in [0, 1], got {min_pass_rate}")
|
|
175
|
+
|
|
176
|
+
def keep(
|
|
177
|
+
reason: str,
|
|
178
|
+
base_rate: float | None = None,
|
|
179
|
+
below: bool = False,
|
|
180
|
+
checks: tuple[CandidateCheck, ...] = (),
|
|
181
|
+
) -> Decision:
|
|
182
|
+
return Decision(site_id, KEEP, baseline, baseline, reason, base_rate, below, checks, stats)
|
|
183
|
+
|
|
184
|
+
base = stats.get(baseline)
|
|
185
|
+
if base is None or base.summary.scored == 0:
|
|
186
|
+
return keep("no baseline results")
|
|
187
|
+
if not base.complete:
|
|
188
|
+
return keep(f"baseline has {_incomplete(base.summary)}", base.pass_rate)
|
|
189
|
+
base_rate = base.pass_rate or 0.0
|
|
190
|
+
below = base_rate < min_pass_rate - EPSILON
|
|
191
|
+
if base_rate == 0.0:
|
|
192
|
+
return keep("baseline passes no cases; nothing to compare against", 0.0, below)
|
|
193
|
+
|
|
194
|
+
base_cost = base.cost_per_call(_price(prices, baseline))
|
|
195
|
+
checks = tuple(
|
|
196
|
+
_check(
|
|
197
|
+
model,
|
|
198
|
+
stats.get(model),
|
|
199
|
+
base_rate=base_rate,
|
|
200
|
+
base_cost=base_cost,
|
|
201
|
+
prices=prices,
|
|
202
|
+
threshold=threshold,
|
|
203
|
+
min_pass_rate=min_pass_rate,
|
|
204
|
+
)
|
|
205
|
+
for model in candidates
|
|
206
|
+
if model != baseline
|
|
207
|
+
)
|
|
208
|
+
order = {c.model: i for i, c in enumerate(checks)}
|
|
209
|
+
passing = [c for c in checks if c.passed]
|
|
210
|
+
if not passing:
|
|
211
|
+
reason = "no candidate models" if not checks else "no cheaper model passed the checks"
|
|
212
|
+
return keep(reason, base_rate, below, checks)
|
|
213
|
+
|
|
214
|
+
best = min(
|
|
215
|
+
passing,
|
|
216
|
+
key=lambda c: (c.cost_per_call or 0.0, -(c.pass_rate or 0.0), order[c.model]),
|
|
217
|
+
)
|
|
218
|
+
return Decision(
|
|
219
|
+
site_id, DOWNGRADE, best.model, baseline, best.reason, base_rate, below, checks, stats
|
|
220
|
+
)
|
downshift/diff.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Projected LLM cost change between two scans (Phase 8).
|
|
2
|
+
|
|
3
|
+
Static estimate, no model calls: prompt tokens are words in the resolved prompt text,
|
|
4
|
+
completion tokens are max_tokens (or a default), prices and volume come from config.
|
|
5
|
+
A model that static analysis cannot resolve is priced as the baseline and marked assumed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from downshift.config import Config, ModelPrice
|
|
14
|
+
from downshift.llm import estimate_tokens
|
|
15
|
+
from downshift.schema import CallSite
|
|
16
|
+
|
|
17
|
+
DAYS_PER_MONTH = 30
|
|
18
|
+
DEFAULT_COMPLETION_TOKENS = 256
|
|
19
|
+
|
|
20
|
+
ADDED = "added"
|
|
21
|
+
REMOVED = "removed"
|
|
22
|
+
CHANGED = "changed"
|
|
23
|
+
UNCHANGED = "unchanged"
|
|
24
|
+
STATUS_SYMBOL = {ADDED: "+", CHANGED: "~", REMOVED: "-", UNCHANGED: "="}
|
|
25
|
+
_ORDER = {ADDED: 0, CHANGED: 1, REMOVED: 2, UNCHANGED: 3}
|
|
26
|
+
|
|
27
|
+
MARK_MODEL = "†"
|
|
28
|
+
MARK_OUTPUT = "‡"
|
|
29
|
+
MARK_PROMPT = "§"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class SiteEstimate:
|
|
34
|
+
"""Projected cost of one call site at one ref."""
|
|
35
|
+
|
|
36
|
+
site_id: str
|
|
37
|
+
model: str
|
|
38
|
+
model_assumed: bool
|
|
39
|
+
prompt_tokens: int
|
|
40
|
+
prompt_partial: bool
|
|
41
|
+
completion_tokens: int
|
|
42
|
+
completion_assumed: bool
|
|
43
|
+
calls_per_day: int
|
|
44
|
+
per_call: float | None
|
|
45
|
+
days: int = DAYS_PER_MONTH
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def known(self) -> bool:
|
|
49
|
+
return self.per_call is not None
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def monthly(self) -> float | None:
|
|
53
|
+
if self.per_call is None:
|
|
54
|
+
return None
|
|
55
|
+
return self.per_call * self.calls_per_day * self.days
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def resolved_model(site: CallSite) -> str | None:
|
|
59
|
+
"""The model name static analysis found, or None."""
|
|
60
|
+
ref = site.model
|
|
61
|
+
if ref is None or not ref.resolved or not ref.value:
|
|
62
|
+
return None
|
|
63
|
+
return ref.value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def prompt_estimate(site: CallSite) -> tuple[int, bool]:
|
|
67
|
+
"""(estimated prompt tokens, partial) from the resolved message text."""
|
|
68
|
+
if not site.messages:
|
|
69
|
+
return 0, True
|
|
70
|
+
tokens = 0
|
|
71
|
+
partial = False
|
|
72
|
+
for message in site.messages:
|
|
73
|
+
if message.resolved:
|
|
74
|
+
tokens += estimate_tokens(message.content)
|
|
75
|
+
else:
|
|
76
|
+
partial = True
|
|
77
|
+
return tokens, partial
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def estimate_site(
|
|
81
|
+
site: CallSite,
|
|
82
|
+
*,
|
|
83
|
+
prices: Mapping[str, ModelPrice],
|
|
84
|
+
baseline: str,
|
|
85
|
+
calls_per_day: Callable[[str], int],
|
|
86
|
+
days: int = DAYS_PER_MONTH,
|
|
87
|
+
default_completion: int = DEFAULT_COMPLETION_TOKENS,
|
|
88
|
+
) -> SiteEstimate:
|
|
89
|
+
found = resolved_model(site)
|
|
90
|
+
model = found if found is not None else baseline
|
|
91
|
+
prompt_tokens, partial = prompt_estimate(site)
|
|
92
|
+
if site.max_tokens is not None:
|
|
93
|
+
completion, completion_assumed = site.max_tokens, False
|
|
94
|
+
else:
|
|
95
|
+
completion, completion_assumed = default_completion, True
|
|
96
|
+
price = prices.get(model)
|
|
97
|
+
per_call = price.cost(prompt_tokens, completion) if price is not None else None
|
|
98
|
+
return SiteEstimate(
|
|
99
|
+
site_id=site.id,
|
|
100
|
+
model=model,
|
|
101
|
+
model_assumed=found is None,
|
|
102
|
+
prompt_tokens=prompt_tokens,
|
|
103
|
+
prompt_partial=partial,
|
|
104
|
+
completion_tokens=completion,
|
|
105
|
+
completion_assumed=completion_assumed,
|
|
106
|
+
calls_per_day=calls_per_day(site.id),
|
|
107
|
+
per_call=per_call,
|
|
108
|
+
days=days,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _monthly(estimate: SiteEstimate | None) -> float:
|
|
113
|
+
if estimate is None:
|
|
114
|
+
return 0.0
|
|
115
|
+
return estimate.monthly or 0.0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _model_label(estimate: SiteEstimate) -> str:
|
|
119
|
+
return estimate.model + (MARK_MODEL if estimate.model_assumed else "")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _reasons(before: SiteEstimate, after: SiteEstimate) -> tuple[str, ...]:
|
|
123
|
+
reasons: list[str] = []
|
|
124
|
+
if before.model != after.model or before.model_assumed != after.model_assumed:
|
|
125
|
+
reasons.append(f"model {_model_label(before)} -> {_model_label(after)}")
|
|
126
|
+
if before.prompt_tokens != after.prompt_tokens:
|
|
127
|
+
reasons.append(f"prompt ~{before.prompt_tokens} -> ~{after.prompt_tokens} tokens")
|
|
128
|
+
if before.completion_tokens != after.completion_tokens:
|
|
129
|
+
reasons.append(f"max output {before.completion_tokens} -> {after.completion_tokens} tokens")
|
|
130
|
+
if before.calls_per_day != after.calls_per_day:
|
|
131
|
+
reasons.append(f"calls/day {before.calls_per_day:,} -> {after.calls_per_day:,}")
|
|
132
|
+
return tuple(reasons)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(frozen=True)
|
|
136
|
+
class SiteDiff:
|
|
137
|
+
site_id: str
|
|
138
|
+
status: str
|
|
139
|
+
before: SiteEstimate | None
|
|
140
|
+
after: SiteEstimate | None
|
|
141
|
+
reasons: tuple[str, ...] = ()
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def known(self) -> bool:
|
|
145
|
+
sides = [e for e in (self.before, self.after) if e is not None]
|
|
146
|
+
return all(e.known for e in sides)
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def delta(self) -> float | None:
|
|
150
|
+
if not self.known:
|
|
151
|
+
return None
|
|
152
|
+
return _monthly(self.after) - _monthly(self.before)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def model_assumed(self) -> bool:
|
|
156
|
+
return any(e is not None and e.model_assumed for e in (self.before, self.after))
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def completion_assumed(self) -> bool:
|
|
160
|
+
return any(e is not None and e.completion_assumed for e in (self.before, self.after))
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def prompt_partial(self) -> bool:
|
|
164
|
+
return any(e is not None and e.prompt_partial for e in (self.before, self.after))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@dataclass(frozen=True)
|
|
168
|
+
class CostDiff:
|
|
169
|
+
sites: tuple[SiteDiff, ...]
|
|
170
|
+
baseline: str
|
|
171
|
+
days: int = DAYS_PER_MONTH
|
|
172
|
+
default_completion: int = DEFAULT_COMPLETION_TOKENS
|
|
173
|
+
|
|
174
|
+
def _with(self, status: str) -> list[SiteDiff]:
|
|
175
|
+
return [s for s in self.sites if s.status == status]
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def added(self) -> list[SiteDiff]:
|
|
179
|
+
return self._with(ADDED)
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def removed(self) -> list[SiteDiff]:
|
|
183
|
+
return self._with(REMOVED)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def changed(self) -> list[SiteDiff]:
|
|
187
|
+
return self._with(CHANGED)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def unchanged(self) -> list[SiteDiff]:
|
|
191
|
+
return self._with(UNCHANGED)
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def has_changes(self) -> bool:
|
|
195
|
+
return any(s.status != UNCHANGED for s in self.sites)
|
|
196
|
+
|
|
197
|
+
@property
|
|
198
|
+
def unknown(self) -> list[SiteDiff]:
|
|
199
|
+
return [s for s in self.sites if not s.known]
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def before_monthly(self) -> float:
|
|
203
|
+
return sum(_monthly(s.before) for s in self.sites if s.known)
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def after_monthly(self) -> float:
|
|
207
|
+
return sum(_monthly(s.after) for s in self.sites if s.known)
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def delta(self) -> float:
|
|
211
|
+
return self.after_monthly - self.before_monthly
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def delta_pct(self) -> float | None:
|
|
215
|
+
if self.before_monthly <= 0:
|
|
216
|
+
return None
|
|
217
|
+
return self.delta / self.before_monthly * 100
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def diff_sites(
|
|
221
|
+
base: Sequence[CallSite],
|
|
222
|
+
head: Sequence[CallSite],
|
|
223
|
+
*,
|
|
224
|
+
prices: Mapping[str, ModelPrice],
|
|
225
|
+
baseline: str,
|
|
226
|
+
calls_per_day: Callable[[str], int],
|
|
227
|
+
days: int = DAYS_PER_MONTH,
|
|
228
|
+
default_completion: int = DEFAULT_COMPLETION_TOKENS,
|
|
229
|
+
) -> CostDiff:
|
|
230
|
+
"""Compare two sets of call sites by id and price both sides."""
|
|
231
|
+
|
|
232
|
+
def est(site: CallSite) -> SiteEstimate:
|
|
233
|
+
return estimate_site(
|
|
234
|
+
site,
|
|
235
|
+
prices=prices,
|
|
236
|
+
baseline=baseline,
|
|
237
|
+
calls_per_day=calls_per_day,
|
|
238
|
+
days=days,
|
|
239
|
+
default_completion=default_completion,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
before = {s.id: est(s) for s in base}
|
|
243
|
+
after = {s.id: est(s) for s in head}
|
|
244
|
+
diffs: list[SiteDiff] = []
|
|
245
|
+
for site_id in sorted(set(before) | set(after)):
|
|
246
|
+
b = before.get(site_id)
|
|
247
|
+
a = after.get(site_id)
|
|
248
|
+
if b is None:
|
|
249
|
+
diffs.append(SiteDiff(site_id, ADDED, None, a))
|
|
250
|
+
elif a is None:
|
|
251
|
+
diffs.append(SiteDiff(site_id, REMOVED, b, None))
|
|
252
|
+
else:
|
|
253
|
+
reasons = _reasons(b, a)
|
|
254
|
+
status = CHANGED if reasons else UNCHANGED
|
|
255
|
+
diffs.append(SiteDiff(site_id, status, b, a, reasons))
|
|
256
|
+
diffs.sort(key=lambda d: (_ORDER[d.status], d.site_id))
|
|
257
|
+
return CostDiff(
|
|
258
|
+
sites=tuple(diffs), baseline=baseline, days=days, default_completion=default_completion
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def diff_for_config(
|
|
263
|
+
base: Sequence[CallSite],
|
|
264
|
+
head: Sequence[CallSite],
|
|
265
|
+
config: Config,
|
|
266
|
+
*,
|
|
267
|
+
days: int = DAYS_PER_MONTH,
|
|
268
|
+
default_completion: int = DEFAULT_COMPLETION_TOKENS,
|
|
269
|
+
) -> CostDiff:
|
|
270
|
+
return diff_sites(
|
|
271
|
+
base,
|
|
272
|
+
head,
|
|
273
|
+
prices=config.pricing,
|
|
274
|
+
baseline=config.models.baseline,
|
|
275
|
+
calls_per_day=config.volume.calls_per_day,
|
|
276
|
+
days=days,
|
|
277
|
+
default_completion=default_completion,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _money(value: float | None) -> str:
|
|
282
|
+
if value is None:
|
|
283
|
+
return "unknown"
|
|
284
|
+
return f"${value:,.2f}"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _signed(value: float | None) -> str:
|
|
288
|
+
if value is None:
|
|
289
|
+
return "unknown"
|
|
290
|
+
if abs(value) < 0.005:
|
|
291
|
+
return "$0.00"
|
|
292
|
+
sign = "+" if value > 0 else "-"
|
|
293
|
+
return f"{sign}${abs(value):,.2f}"
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _tokens(estimate: SiteEstimate) -> str:
|
|
297
|
+
prompt = f"{estimate.prompt_tokens}{MARK_PROMPT if estimate.prompt_partial else ''}"
|
|
298
|
+
out = f"{estimate.completion_tokens}{MARK_OUTPUT if estimate.completion_assumed else ''}"
|
|
299
|
+
return f"{prompt} + {out}"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _pair(before: str | None, after: str | None) -> str:
|
|
303
|
+
if before is None:
|
|
304
|
+
return after or ""
|
|
305
|
+
if after is None:
|
|
306
|
+
return before
|
|
307
|
+
return before if before == after else f"{before} -> {after}"
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _row(site: SiteDiff) -> str:
|
|
311
|
+
b, a = site.before, site.after
|
|
312
|
+
model = _pair(_model_label(b) if b else None, _model_label(a) if a else None)
|
|
313
|
+
tokens = _pair(_tokens(b) if b else None, _tokens(a) if a else None)
|
|
314
|
+
calls = _pair(f"{b.calls_per_day:,}" if b else None, f"{a.calls_per_day:,}" if a else None)
|
|
315
|
+
if b is None:
|
|
316
|
+
monthly = _money(a.monthly if a else None)
|
|
317
|
+
elif a is None:
|
|
318
|
+
monthly = "$0.00"
|
|
319
|
+
else:
|
|
320
|
+
monthly = f"{_money(b.monthly)} -> {_money(a.monthly)}"
|
|
321
|
+
cells = [
|
|
322
|
+
STATUS_SYMBOL[site.status],
|
|
323
|
+
f"`{site.site_id}`",
|
|
324
|
+
model,
|
|
325
|
+
tokens,
|
|
326
|
+
calls,
|
|
327
|
+
monthly,
|
|
328
|
+
_signed(site.delta),
|
|
329
|
+
]
|
|
330
|
+
return "| " + " | ".join(cells) + " |"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def render_markdown(diff: CostDiff, *, base: str = "main", head: str = "HEAD") -> str:
|
|
334
|
+
"""Deterministic markdown for a PR comment."""
|
|
335
|
+
counts = (
|
|
336
|
+
f"{len(diff.added)} added, {len(diff.changed)} changed, "
|
|
337
|
+
f"{len(diff.removed)} removed, {len(diff.unchanged)} unchanged"
|
|
338
|
+
)
|
|
339
|
+
lines = ["## Downshift cost diff", ""]
|
|
340
|
+
if not diff.has_changes:
|
|
341
|
+
lines.append(
|
|
342
|
+
f"No LLM call site cost changes in `{head}` vs `{base}` ({counts}). "
|
|
343
|
+
f"Projected monthly cost stays at **{_money(diff.after_monthly)}**."
|
|
344
|
+
)
|
|
345
|
+
lines.append("")
|
|
346
|
+
else:
|
|
347
|
+
pct = diff.delta_pct
|
|
348
|
+
pct_text = "" if pct is None else f", {'+' if pct > 0 else ''}{pct:.1f}%"
|
|
349
|
+
lines.append(
|
|
350
|
+
f"Projected monthly LLM cost, `{head}` vs `{base}`: "
|
|
351
|
+
f"**{_money(diff.before_monthly)} -> {_money(diff.after_monthly)} "
|
|
352
|
+
f"({_signed(diff.delta)}{pct_text})**"
|
|
353
|
+
)
|
|
354
|
+
lines.append("")
|
|
355
|
+
lines.append(f"Call sites: {counts}. Key: `+` added, `~` changed, `-` removed.")
|
|
356
|
+
lines.append("")
|
|
357
|
+
lines.append("| | Call site | Model | In + out | Calls/day | Monthly | Delta |")
|
|
358
|
+
lines.append("|---|---|---|---|---|---|---|")
|
|
359
|
+
lines.extend(_row(s) for s in diff.sites if s.status != UNCHANGED)
|
|
360
|
+
lines.append("")
|
|
361
|
+
|
|
362
|
+
shown = [s for s in diff.sites if s.status != UNCHANGED]
|
|
363
|
+
notes: list[str] = []
|
|
364
|
+
if any(s.model_assumed for s in shown):
|
|
365
|
+
notes.append(
|
|
366
|
+
f"{MARK_MODEL} Model not resolved by static analysis; priced as the baseline "
|
|
367
|
+
f"`{diff.baseline}`. Run the Bob auditor for exact models."
|
|
368
|
+
)
|
|
369
|
+
if any(s.completion_assumed for s in shown):
|
|
370
|
+
notes.append(f"{MARK_OUTPUT} No max_tokens set; assumed {diff.default_completion}.")
|
|
371
|
+
if any(s.prompt_partial for s in shown):
|
|
372
|
+
notes.append(f"{MARK_PROMPT} Prompt only partly resolved; unresolved text not counted.")
|
|
373
|
+
if diff.unknown:
|
|
374
|
+
notes.append(
|
|
375
|
+
f"{len(diff.unknown)} call site(s) use a model with no configured price "
|
|
376
|
+
"and are left out of the totals."
|
|
377
|
+
)
|
|
378
|
+
for note in notes:
|
|
379
|
+
lines.append(f"- {note}")
|
|
380
|
+
if notes:
|
|
381
|
+
lines.append("")
|
|
382
|
+
lines.append(
|
|
383
|
+
"_Projection, not a bill: tokens estimated from source (words in resolved prompt "
|
|
384
|
+
f"text; output = max_tokens, or {diff.default_completion} if unset) x illustrative "
|
|
385
|
+
f"prices x configured calls/day x {diff.days} days._"
|
|
386
|
+
)
|
|
387
|
+
return "\n".join(lines) + "\n"
|