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/runner.py
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Run eval sets against models and store scored results.
|
|
2
|
+
|
|
3
|
+
For each call site and model, the runner renders the site's prompt with every
|
|
4
|
+
eval case, calls the model with the site's own settings, scores the output and
|
|
5
|
+
appends one JSON line per case to results/<slug>/<model>.jsonl.
|
|
6
|
+
|
|
7
|
+
Runs are resumable: cases that already have a scored row are skipped, and rows
|
|
8
|
+
that failed (model or judge error) are retried. The last row for a case wins.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from dataclasses import asdict, dataclass, replace
|
|
18
|
+
from dataclasses import fields as dataclass_fields
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from downshift.evals import EvalCase, EvalSet, graded_fields, render_messages, slug_for
|
|
23
|
+
from downshift.llm import LLMClient, LLMError
|
|
24
|
+
from downshift.schema import CallSite
|
|
25
|
+
from downshift.scorer import Judge, score_case
|
|
26
|
+
|
|
27
|
+
RESULTS_SUFFIX = ".jsonl"
|
|
28
|
+
WARMUP_MESSAGES: list[dict[str, str]] = [{"role": "user", "content": "Reply with the word OK."}]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def model_filename(model: str) -> str:
|
|
32
|
+
"""Filesystem-safe model name: `qwen2.5:7b` -> `qwen2.5-7b`."""
|
|
33
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "-", model).strip("-") or "model"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def results_path(results_dir: Path, site_id: str, model: str) -> Path:
|
|
37
|
+
return results_dir / slug_for(site_id) / f"{model_filename(model)}{RESULTS_SUFFIX}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ResultRow:
|
|
42
|
+
"""One eval case run on one model."""
|
|
43
|
+
|
|
44
|
+
case_id: str
|
|
45
|
+
model: str
|
|
46
|
+
output: str = ""
|
|
47
|
+
prompt_tokens: int = 0
|
|
48
|
+
completion_tokens: int = 0
|
|
49
|
+
latency_s: float = 0.0
|
|
50
|
+
score: float | None = None
|
|
51
|
+
passed: bool | None = None
|
|
52
|
+
detail: str = ""
|
|
53
|
+
judge_score: int | None = None
|
|
54
|
+
error: str | None = None
|
|
55
|
+
judge_model: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def ok(self) -> bool:
|
|
59
|
+
"""Scored without errors."""
|
|
60
|
+
return self.error is None and self.score is not None
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict[str, Any]:
|
|
63
|
+
data = asdict(self)
|
|
64
|
+
data["latency_s"] = round(self.latency_s, 3)
|
|
65
|
+
return data
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_dict(cls, data: Mapping[str, Any]) -> ResultRow:
|
|
69
|
+
known = {f.name for f in dataclass_fields(cls)}
|
|
70
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_results(path: Path) -> dict[str, ResultRow]:
|
|
74
|
+
"""Rows by case id. Unreadable lines are skipped; the last row for a case wins."""
|
|
75
|
+
rows: dict[str, ResultRow] = {}
|
|
76
|
+
if not path.is_file():
|
|
77
|
+
return rows
|
|
78
|
+
for raw in path.read_text(encoding="utf-8").splitlines():
|
|
79
|
+
if not raw.strip():
|
|
80
|
+
continue
|
|
81
|
+
try:
|
|
82
|
+
row = ResultRow.from_dict(json.loads(raw))
|
|
83
|
+
except (json.JSONDecodeError, TypeError, AttributeError):
|
|
84
|
+
continue
|
|
85
|
+
rows[row.case_id] = row
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def append_row(path: Path, row: ResultRow) -> None:
|
|
90
|
+
"""Append one row, starting a new line if a previous write was cut off."""
|
|
91
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
prefix = ""
|
|
93
|
+
if path.is_file() and path.stat().st_size:
|
|
94
|
+
with path.open("rb") as fh:
|
|
95
|
+
fh.seek(-1, os.SEEK_END)
|
|
96
|
+
if fh.read(1) != b"\n":
|
|
97
|
+
prefix = "\n"
|
|
98
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
99
|
+
fh.write(prefix + json.dumps(row.to_dict(), ensure_ascii=False) + "\n")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass(frozen=True)
|
|
103
|
+
class RunSummary:
|
|
104
|
+
"""Totals for one call site on one model, over the cases in scope."""
|
|
105
|
+
|
|
106
|
+
site_id: str
|
|
107
|
+
model: str
|
|
108
|
+
cases: int
|
|
109
|
+
scored: int
|
|
110
|
+
passed: int
|
|
111
|
+
errors: int
|
|
112
|
+
new: int
|
|
113
|
+
mean_score: float | None
|
|
114
|
+
avg_latency_s: float | None
|
|
115
|
+
avg_prompt_tokens: float | None
|
|
116
|
+
avg_completion_tokens: float | None
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def pass_rate(self) -> float | None:
|
|
120
|
+
return self.passed / self.scored if self.scored else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _mean(values: Sequence[float]) -> float | None:
|
|
124
|
+
return sum(values) / len(values) if values else None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def summarize_rows(
|
|
128
|
+
site_id: str,
|
|
129
|
+
model: str,
|
|
130
|
+
case_ids: Sequence[str],
|
|
131
|
+
rows: Mapping[str, ResultRow],
|
|
132
|
+
new: int = 0,
|
|
133
|
+
) -> RunSummary:
|
|
134
|
+
"""Summarize the rows for these case ids (rows for other cases are ignored)."""
|
|
135
|
+
selected = [rows[c] for c in case_ids if c in rows]
|
|
136
|
+
ok = [r for r in selected if r.ok]
|
|
137
|
+
return RunSummary(
|
|
138
|
+
site_id=site_id,
|
|
139
|
+
model=model,
|
|
140
|
+
cases=len(case_ids),
|
|
141
|
+
scored=len(ok),
|
|
142
|
+
passed=sum(1 for r in ok if r.passed),
|
|
143
|
+
errors=sum(1 for r in selected if r.error is not None),
|
|
144
|
+
new=new,
|
|
145
|
+
mean_score=_mean([r.score or 0.0 for r in ok]),
|
|
146
|
+
avg_latency_s=_mean([r.latency_s for r in ok]),
|
|
147
|
+
avg_prompt_tokens=_mean([float(r.prompt_tokens) for r in ok]),
|
|
148
|
+
avg_completion_tokens=_mean([float(r.completion_tokens) for r in ok]),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Runner:
|
|
153
|
+
"""Runs eval sets on models and appends scored rows to results files."""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
client: LLMClient,
|
|
158
|
+
*,
|
|
159
|
+
results_dir: Path,
|
|
160
|
+
judge: Judge | None = None,
|
|
161
|
+
limit: int | None = None,
|
|
162
|
+
warmup: bool = True,
|
|
163
|
+
on_case: Callable[[ResultRow], None] | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
self.client = client
|
|
166
|
+
self.results_dir = results_dir
|
|
167
|
+
self.judge = judge
|
|
168
|
+
self.limit = limit
|
|
169
|
+
self.warmup = warmup
|
|
170
|
+
self.on_case = on_case
|
|
171
|
+
self._warmed: set[str] = set()
|
|
172
|
+
|
|
173
|
+
def warm_up(self, model: str, client: LLMClient | None = None) -> None:
|
|
174
|
+
"""One untimed call so model load time does not count as latency.
|
|
175
|
+
|
|
176
|
+
LLMError propagates: a model that cannot answer this cannot run evals.
|
|
177
|
+
"""
|
|
178
|
+
if not self.warmup or model in self._warmed:
|
|
179
|
+
return
|
|
180
|
+
(client or self.client).complete(model, WARMUP_MESSAGES, temperature=0.0, max_tokens=5)
|
|
181
|
+
self._warmed.add(model)
|
|
182
|
+
|
|
183
|
+
def cases_for(self, eval_set: EvalSet) -> list[EvalCase]:
|
|
184
|
+
return eval_set.cases[: self.limit] if self.limit else list(eval_set.cases)
|
|
185
|
+
|
|
186
|
+
def run_site(self, site: CallSite, eval_set: EvalSet, model: str) -> RunSummary:
|
|
187
|
+
cases = self.cases_for(eval_set)
|
|
188
|
+
path = results_path(self.results_dir, site.id, model)
|
|
189
|
+
rows = load_results(path)
|
|
190
|
+
todo = [c for c in cases if not (c.id in rows and rows[c.id].ok)]
|
|
191
|
+
if todo:
|
|
192
|
+
self.warm_up(model)
|
|
193
|
+
if self.judge is not None and any(c.grading == "judge" for c in todo):
|
|
194
|
+
self.warm_up(self.judge.model, self.judge.client)
|
|
195
|
+
fields = graded_fields(site)
|
|
196
|
+
for case in todo:
|
|
197
|
+
row = self._run_case(site, eval_set, case, model, fields)
|
|
198
|
+
append_row(path, row)
|
|
199
|
+
rows[case.id] = row
|
|
200
|
+
if self.on_case is not None:
|
|
201
|
+
self.on_case(row)
|
|
202
|
+
return summarize_rows(site.id, model, [c.id for c in cases], rows, new=len(todo))
|
|
203
|
+
|
|
204
|
+
def _run_case(
|
|
205
|
+
self,
|
|
206
|
+
site: CallSite,
|
|
207
|
+
eval_set: EvalSet,
|
|
208
|
+
case: EvalCase,
|
|
209
|
+
model: str,
|
|
210
|
+
fields: list[str] | None,
|
|
211
|
+
) -> ResultRow:
|
|
212
|
+
messages = render_messages(site, eval_set.inputs_for(case))
|
|
213
|
+
temperature = float(site.temperature) if site.temperature is not None else 0.0
|
|
214
|
+
try:
|
|
215
|
+
completion = self.client.complete(
|
|
216
|
+
model,
|
|
217
|
+
messages,
|
|
218
|
+
temperature=temperature,
|
|
219
|
+
max_tokens=site.max_tokens,
|
|
220
|
+
json_mode=site.output_format == "json",
|
|
221
|
+
)
|
|
222
|
+
except LLMError as exc:
|
|
223
|
+
return ResultRow(case.id, model, error=str(exc))
|
|
224
|
+
row = ResultRow(
|
|
225
|
+
case.id,
|
|
226
|
+
model,
|
|
227
|
+
output=completion.text,
|
|
228
|
+
prompt_tokens=completion.prompt_tokens,
|
|
229
|
+
completion_tokens=completion.completion_tokens,
|
|
230
|
+
latency_s=completion.latency_s,
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
score = score_case(
|
|
234
|
+
case.grading,
|
|
235
|
+
case.expected,
|
|
236
|
+
completion.text,
|
|
237
|
+
fields=fields,
|
|
238
|
+
prompt_messages=messages,
|
|
239
|
+
judge=self.judge,
|
|
240
|
+
)
|
|
241
|
+
except (LLMError, ValueError) as exc:
|
|
242
|
+
return replace(row, error=f"scoring failed: {exc}")
|
|
243
|
+
return replace(
|
|
244
|
+
row,
|
|
245
|
+
score=score.value,
|
|
246
|
+
passed=score.passed,
|
|
247
|
+
detail=score.detail,
|
|
248
|
+
judge_score=score.judge_score,
|
|
249
|
+
judge_model=self.judge.model if case.grading == "judge" and self.judge else None,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def rescore_site(
|
|
254
|
+
site: CallSite,
|
|
255
|
+
eval_set: EvalSet,
|
|
256
|
+
model: str,
|
|
257
|
+
*,
|
|
258
|
+
results_dir: Path,
|
|
259
|
+
judge: Judge,
|
|
260
|
+
on_case: Callable[[ResultRow], None] | None = None,
|
|
261
|
+
) -> tuple[RunSummary, RunSummary]:
|
|
262
|
+
"""Re-grade saved outputs of a judge-graded site with `judge`. Returns (before, after).
|
|
263
|
+
|
|
264
|
+
Model outputs are never regenerated. Rows already graded by this judge are
|
|
265
|
+
skipped, rows whose model call failed (no output) are left for `run`, and
|
|
266
|
+
judge failures become error rows that are retried on the next rescore.
|
|
267
|
+
"""
|
|
268
|
+
path = results_path(results_dir, site.id, model)
|
|
269
|
+
rows = load_results(path)
|
|
270
|
+
case_ids = [c.id for c in eval_set.cases]
|
|
271
|
+
before = summarize_rows(site.id, model, case_ids, rows)
|
|
272
|
+
new = 0
|
|
273
|
+
for case in eval_set.cases:
|
|
274
|
+
row = rows.get(case.id)
|
|
275
|
+
if row is None or case.grading != "judge":
|
|
276
|
+
continue
|
|
277
|
+
if row.error is not None and not row.error.startswith("scoring failed"):
|
|
278
|
+
continue
|
|
279
|
+
if row.ok and row.judge_model == judge.model:
|
|
280
|
+
continue
|
|
281
|
+
messages = render_messages(site, eval_set.inputs_for(case))
|
|
282
|
+
try:
|
|
283
|
+
score = score_case(
|
|
284
|
+
case.grading, case.expected, row.output, prompt_messages=messages, judge=judge
|
|
285
|
+
)
|
|
286
|
+
except (LLMError, ValueError) as exc:
|
|
287
|
+
updated = replace(
|
|
288
|
+
row,
|
|
289
|
+
score=None,
|
|
290
|
+
passed=None,
|
|
291
|
+
detail="",
|
|
292
|
+
judge_score=None,
|
|
293
|
+
judge_model=judge.model,
|
|
294
|
+
error=f"scoring failed: {exc}",
|
|
295
|
+
)
|
|
296
|
+
else:
|
|
297
|
+
updated = replace(
|
|
298
|
+
row,
|
|
299
|
+
score=score.value,
|
|
300
|
+
passed=score.passed,
|
|
301
|
+
detail=score.detail,
|
|
302
|
+
judge_score=score.judge_score,
|
|
303
|
+
judge_model=judge.model,
|
|
304
|
+
error=None,
|
|
305
|
+
)
|
|
306
|
+
append_row(path, updated)
|
|
307
|
+
rows[case.id] = updated
|
|
308
|
+
new += 1
|
|
309
|
+
if on_case is not None:
|
|
310
|
+
on_case(updated)
|
|
311
|
+
after = summarize_rows(site.id, model, case_ids, rows, new=new)
|
|
312
|
+
return before, after
|
downshift/scanner.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
"""Find LLM call sites in Python source code using the ast module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import fnmatch
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Iterator
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from downshift.config import ScanConfig
|
|
13
|
+
from downshift.resolve import Ctx, FunctionNode, Module, ModuleIndex, Resolver, keyword_arg
|
|
14
|
+
from downshift.schema import CallSite, ModelRef, PromptMessage, ScanResult
|
|
15
|
+
|
|
16
|
+
SKIP_DIRS = frozenset(
|
|
17
|
+
{
|
|
18
|
+
".venv",
|
|
19
|
+
"venv",
|
|
20
|
+
"env",
|
|
21
|
+
".git",
|
|
22
|
+
"node_modules",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"site-packages",
|
|
25
|
+
".tox",
|
|
26
|
+
".nox",
|
|
27
|
+
".mypy_cache",
|
|
28
|
+
".pytest_cache",
|
|
29
|
+
".ruff_cache",
|
|
30
|
+
"build",
|
|
31
|
+
"dist",
|
|
32
|
+
".downshift",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# (attribute suffix, api name, only counts if a model= keyword is present)
|
|
37
|
+
API_PATTERNS: tuple[tuple[tuple[str, ...], str, bool], ...] = (
|
|
38
|
+
(("chat", "completions", "create"), "openai.chat.completions", False),
|
|
39
|
+
(("chat", "completions", "parse"), "openai.chat.completions.parse", False),
|
|
40
|
+
(("responses", "create"), "openai.responses", True),
|
|
41
|
+
(("messages", "create"), "anthropic.messages", True),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
MAX_TOKEN_KWARGS = ("max_tokens", "max_completion_tokens", "max_output_tokens")
|
|
45
|
+
JSON_FORMAT_TYPES = frozenset({"json_object", "json_schema"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --- public API ---------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def scan_path(root: Path, scan_config: ScanConfig | None = None) -> ScanResult:
|
|
52
|
+
"""Scan a directory (or a single .py file) for LLM call sites."""
|
|
53
|
+
scan_config = scan_config or ScanConfig()
|
|
54
|
+
if not root.exists():
|
|
55
|
+
raise FileNotFoundError(f"path does not exist: {root}")
|
|
56
|
+
root = root.resolve()
|
|
57
|
+
|
|
58
|
+
modules: list[Module] = []
|
|
59
|
+
warnings: list[str] = []
|
|
60
|
+
files_scanned = 0
|
|
61
|
+
for path, rel in iter_python_files(root, scan_config):
|
|
62
|
+
files_scanned += 1
|
|
63
|
+
try:
|
|
64
|
+
source = path.read_text(encoding="utf-8")
|
|
65
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
66
|
+
warnings.append(f"{rel}: could not read file, skipped ({exc})")
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
tree = ast.parse(source, filename=rel)
|
|
70
|
+
except SyntaxError as exc:
|
|
71
|
+
warnings.append(f"{rel}:{exc.lineno}: syntax error, skipped ({exc.msg})")
|
|
72
|
+
continue
|
|
73
|
+
modules.append(Module.build(tree, rel))
|
|
74
|
+
|
|
75
|
+
return ScanResult(
|
|
76
|
+
root=root.name,
|
|
77
|
+
files_scanned=files_scanned,
|
|
78
|
+
call_sites=_scan_modules(modules),
|
|
79
|
+
warnings=warnings,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def scan_source(source: str, rel: str = "<string>") -> list[CallSite]:
|
|
84
|
+
"""Scan one module's source text. Handy for tests and editor integrations."""
|
|
85
|
+
return scan_module(ast.parse(source, filename=rel), rel)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def scan_module(tree: ast.Module, rel: str) -> list[CallSite]:
|
|
89
|
+
"""Return the call sites in one parsed module, resolving names within it only."""
|
|
90
|
+
return _scan_modules([Module.build(tree, rel)])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def iter_python_files(root: Path, scan_config: ScanConfig) -> Iterator[tuple[Path, str]]:
|
|
94
|
+
"""Yield (absolute path, posix path relative to root) for files to scan."""
|
|
95
|
+
if root.is_file():
|
|
96
|
+
yield root, root.name
|
|
97
|
+
return
|
|
98
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
99
|
+
dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS and not d.startswith("."))
|
|
100
|
+
for name in sorted(filenames):
|
|
101
|
+
if not name.endswith(".py"):
|
|
102
|
+
continue
|
|
103
|
+
path = Path(dirpath) / name
|
|
104
|
+
rel = path.relative_to(root).as_posix()
|
|
105
|
+
if not any(fnmatch.fnmatch(rel, pattern) for pattern in scan_config.include):
|
|
106
|
+
continue
|
|
107
|
+
if any(fnmatch.fnmatch(rel, pattern) for pattern in scan_config.exclude):
|
|
108
|
+
continue
|
|
109
|
+
yield path, rel
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# --- scanning -----------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _scan_modules(modules: list[Module]) -> list[CallSite]:
|
|
116
|
+
resolver = Resolver(ModuleIndex(modules))
|
|
117
|
+
sites: list[CallSite] = []
|
|
118
|
+
calls: list[tuple[str, str]] = []
|
|
119
|
+
for module in modules:
|
|
120
|
+
finder = _CallFinder()
|
|
121
|
+
finder.visit(module.tree)
|
|
122
|
+
calls.extend((f"{module.rel}::{caller}", callee) for caller, callee in finder.calls)
|
|
123
|
+
sites.extend(_build_sites(module, finder.found, resolver))
|
|
124
|
+
_attach_callers(sites, calls)
|
|
125
|
+
sites.sort(key=lambda site: (site.file, site.line))
|
|
126
|
+
return sites
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _build_sites(module: Module, found: list[_FoundCall], resolver: Resolver) -> list[CallSite]:
|
|
130
|
+
sites: list[CallSite] = []
|
|
131
|
+
seen: dict[str, int] = {}
|
|
132
|
+
for item in found:
|
|
133
|
+
base_id = f"{module.rel}::{item.qualname}"
|
|
134
|
+
seen[base_id] = seen.get(base_id, 0) + 1
|
|
135
|
+
site_id = base_id if seen[base_id] == 1 else f"{base_id}#{seen[base_id]}"
|
|
136
|
+
|
|
137
|
+
ctx = Ctx(module, item.func, item.node.lineno)
|
|
138
|
+
model = resolver.resolve_model(item.node, ctx)
|
|
139
|
+
messages = resolver.resolve_messages(item.node, item.api, ctx)
|
|
140
|
+
sites.append(
|
|
141
|
+
CallSite(
|
|
142
|
+
id=site_id,
|
|
143
|
+
file=module.rel,
|
|
144
|
+
line=item.node.lineno,
|
|
145
|
+
end_line=item.node.end_lineno,
|
|
146
|
+
function=item.qualname,
|
|
147
|
+
api=item.api,
|
|
148
|
+
model=model,
|
|
149
|
+
is_async=item.is_async,
|
|
150
|
+
messages=messages,
|
|
151
|
+
output_format=_output_format(item.node, item.api),
|
|
152
|
+
temperature=_temperature(item.node),
|
|
153
|
+
max_tokens=_max_tokens(item.node),
|
|
154
|
+
notes=_notes(model, messages),
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return sites
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _attach_callers(sites: list[CallSite], calls: list[tuple[str, str]]) -> None:
|
|
161
|
+
"""Name-based: record which functions call the function containing each call site."""
|
|
162
|
+
for site in sites:
|
|
163
|
+
if site.function == "<module>":
|
|
164
|
+
continue
|
|
165
|
+
short = site.function.split(".")[-1]
|
|
166
|
+
own = f"{site.file}::{site.function}"
|
|
167
|
+
site.callers = sorted(
|
|
168
|
+
{caller for caller, callee in calls if callee == short and caller != own}
|
|
169
|
+
)
|
|
170
|
+
if len(site.callers) > 1 and site.messages is None:
|
|
171
|
+
site.notes.append(
|
|
172
|
+
f"shared helper called from {len(site.callers)} places; each caller may be "
|
|
173
|
+
"a separate feature with its own prompt"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclass
|
|
178
|
+
class _FoundCall:
|
|
179
|
+
node: ast.Call
|
|
180
|
+
api: str
|
|
181
|
+
qualname: str
|
|
182
|
+
func: FunctionNode | None
|
|
183
|
+
is_async: bool
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class _CallFinder(ast.NodeVisitor):
|
|
187
|
+
"""Walks a module, recording LLM calls and every function call's caller/callee."""
|
|
188
|
+
|
|
189
|
+
def __init__(self) -> None:
|
|
190
|
+
self.scope: list[str] = []
|
|
191
|
+
self.functions: list[FunctionNode] = []
|
|
192
|
+
self.found: list[_FoundCall] = []
|
|
193
|
+
self.calls: list[tuple[str, str]] = []
|
|
194
|
+
self._awaited: set[int] = set()
|
|
195
|
+
|
|
196
|
+
def visit_ClassDef(self, node: ast.ClassDef) -> None:
|
|
197
|
+
self.scope.append(node.name)
|
|
198
|
+
self.generic_visit(node)
|
|
199
|
+
self.scope.pop()
|
|
200
|
+
|
|
201
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
202
|
+
self._visit_function(node)
|
|
203
|
+
|
|
204
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
|
|
205
|
+
self._visit_function(node)
|
|
206
|
+
|
|
207
|
+
def _visit_function(self, node: FunctionNode) -> None:
|
|
208
|
+
self.scope.append(node.name)
|
|
209
|
+
self.functions.append(node)
|
|
210
|
+
self.generic_visit(node)
|
|
211
|
+
self.functions.pop()
|
|
212
|
+
self.scope.pop()
|
|
213
|
+
|
|
214
|
+
def visit_Await(self, node: ast.Await) -> None:
|
|
215
|
+
if isinstance(node.value, ast.Call):
|
|
216
|
+
self._awaited.add(id(node.value))
|
|
217
|
+
self.generic_visit(node)
|
|
218
|
+
|
|
219
|
+
def visit_Call(self, node: ast.Call) -> None:
|
|
220
|
+
qualname = ".".join(self.scope) or "<module>"
|
|
221
|
+
if isinstance(node.func, ast.Name):
|
|
222
|
+
self.calls.append((qualname, node.func.id))
|
|
223
|
+
elif isinstance(node.func, ast.Attribute):
|
|
224
|
+
self.calls.append((qualname, node.func.attr))
|
|
225
|
+
|
|
226
|
+
api = _match_api(node)
|
|
227
|
+
if api is not None:
|
|
228
|
+
func = self.functions[-1] if self.functions else None
|
|
229
|
+
self.found.append(_FoundCall(node, api, qualname, func, id(node) in self._awaited))
|
|
230
|
+
self.generic_visit(node)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _attribute_chain(node: ast.expr) -> list[str]:
|
|
234
|
+
"""client.chat.completions.create -> ["chat", "completions", "create"]."""
|
|
235
|
+
parts: list[str] = []
|
|
236
|
+
while isinstance(node, ast.Attribute):
|
|
237
|
+
parts.append(node.attr)
|
|
238
|
+
node = node.value
|
|
239
|
+
parts.reverse()
|
|
240
|
+
return parts
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _match_api(call: ast.Call) -> str | None:
|
|
244
|
+
chain = _attribute_chain(call.func)
|
|
245
|
+
for suffix, api, needs_model in API_PATTERNS:
|
|
246
|
+
if len(chain) >= len(suffix) and tuple(chain[-len(suffix) :]) == suffix:
|
|
247
|
+
if needs_model and not any(kw.arg == "model" for kw in call.keywords):
|
|
248
|
+
return None
|
|
249
|
+
return api
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- reading call arguments ---------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _output_format(call: ast.Call, api: str) -> str:
|
|
257
|
+
if api.endswith(".parse"):
|
|
258
|
+
return "json"
|
|
259
|
+
fmt = keyword_arg(call, "response_format")
|
|
260
|
+
if isinstance(fmt, ast.Dict):
|
|
261
|
+
for key, val in zip(fmt.keys, fmt.values, strict=True):
|
|
262
|
+
if (
|
|
263
|
+
isinstance(key, ast.Constant)
|
|
264
|
+
and key.value == "type"
|
|
265
|
+
and isinstance(val, ast.Constant)
|
|
266
|
+
and val.value in JSON_FORMAT_TYPES
|
|
267
|
+
):
|
|
268
|
+
return "json"
|
|
269
|
+
return "text"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _temperature(call: ast.Call) -> float | None:
|
|
273
|
+
value = keyword_arg(call, "temperature")
|
|
274
|
+
if isinstance(value, ast.Constant) and isinstance(value.value, (int, float)):
|
|
275
|
+
if isinstance(value.value, bool):
|
|
276
|
+
return None
|
|
277
|
+
return float(value.value)
|
|
278
|
+
return None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _max_tokens(call: ast.Call) -> int | None:
|
|
282
|
+
value = keyword_arg(call, *MAX_TOKEN_KWARGS)
|
|
283
|
+
if (
|
|
284
|
+
isinstance(value, ast.Constant)
|
|
285
|
+
and isinstance(value.value, int)
|
|
286
|
+
and not isinstance(value.value, bool)
|
|
287
|
+
and value.value >= 1
|
|
288
|
+
):
|
|
289
|
+
return value.value
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _notes(model: ModelRef, messages: list[PromptMessage] | None) -> list[str]:
|
|
294
|
+
notes: list[str] = []
|
|
295
|
+
if model.source == "kwargs":
|
|
296
|
+
notes.append(f"model is passed via {model.expression} and could not be resolved statically")
|
|
297
|
+
elif model.source == "missing":
|
|
298
|
+
notes.append("no model argument found at this call")
|
|
299
|
+
elif model.source == "env_default":
|
|
300
|
+
notes.append(f"model comes from env var {model.env_var}; assuming default {model.value!r}")
|
|
301
|
+
elif model.source == "env":
|
|
302
|
+
notes.append(f"model comes from env var {model.env_var} with no default")
|
|
303
|
+
elif model.source == "dynamic":
|
|
304
|
+
notes.append(f"model is computed at runtime ({model.expression})")
|
|
305
|
+
|
|
306
|
+
if messages is None:
|
|
307
|
+
notes.append("messages are built at runtime; the prompt template could not be recovered")
|
|
308
|
+
elif not all(m.resolved for m in messages):
|
|
309
|
+
notes.append("part of the prompt comes from a runtime value the scanner could not see")
|
|
310
|
+
return notes
|