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/evals.py
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Eval sets: one JSONL file of test cases per call site.
|
|
2
|
+
|
|
3
|
+
File layout (``<slug>.jsonl``, slug from `slug_for`)::
|
|
4
|
+
|
|
5
|
+
{"shared": {"policy": {"file": "../data/refund_policy.md"}}} <- optional first line
|
|
6
|
+
{"id": "c01", "inputs": {...}, "expected": ..., "grading": "exact", "notes": "..."}
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
`shared` inputs are merged under every case's own inputs, so long fixed
|
|
10
|
+
values (a policy document) are written once. A shared value is a JSON value
|
|
11
|
+
or ``{"file": "relative/path"}``, resolved against the eval file's folder.
|
|
12
|
+
|
|
13
|
+
`validate_eval_set` checks a file against its call site: the input keys must
|
|
14
|
+
match the prompt placeholders, and `expected` must fit the grading type.
|
|
15
|
+
The CLI command `check-evals` is a thin wrapper around `check_eval_dir`.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import ast
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from downshift.schema import GRADINGS, CallSite
|
|
28
|
+
|
|
29
|
+
EVAL_SUFFIX = ".jsonl"
|
|
30
|
+
CASE_FIELDS = frozenset({"id", "inputs", "expected", "grading", "notes"})
|
|
31
|
+
MIN_CASES = 20
|
|
32
|
+
|
|
33
|
+
_BRACED = re.compile(r"\{([^{}\n]+)\}")
|
|
34
|
+
_GRADED_FIELDS = re.compile(r"Graded fields?:\s*([^\n]+)", re.IGNORECASE)
|
|
35
|
+
_VALUE_SET = re.compile(r"from the set \{([^{}]+)\}", re.IGNORECASE)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EvalError(ValueError):
|
|
39
|
+
"""Raised when an eval file cannot be parsed at all."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# --- call site helpers --------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def slug_for(site_id: str) -> str:
|
|
46
|
+
"""`supportdesk/triage.py::classify_category` -> `supportdesk.triage__classify_category`."""
|
|
47
|
+
path, _, function = site_id.partition("::")
|
|
48
|
+
module = path[:-3] if path.endswith(".py") else path
|
|
49
|
+
module = module.replace("\\", "/").strip("/").replace("/", ".")
|
|
50
|
+
return f"{module}__{function}" if function else module
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def placeholders(template: str) -> list[str]:
|
|
54
|
+
"""Placeholders in a prompt as the scanner renders it, in order, without duplicates.
|
|
55
|
+
|
|
56
|
+
`{name}` and `{ticket['body']}` both count. Braces around text that is not
|
|
57
|
+
a Python expression (literal JSON such as `{"a": 1}`) are ignored.
|
|
58
|
+
"""
|
|
59
|
+
found: list[str] = []
|
|
60
|
+
for match in _BRACED.finditer(template):
|
|
61
|
+
inner = match.group(1).strip()
|
|
62
|
+
if not inner or not _is_expression(inner):
|
|
63
|
+
continue
|
|
64
|
+
if inner not in found:
|
|
65
|
+
found.append(inner)
|
|
66
|
+
return found
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def site_placeholders(site: CallSite) -> list[str]:
|
|
70
|
+
found: list[str] = []
|
|
71
|
+
for message in site.messages or []:
|
|
72
|
+
for name in placeholders(message.content):
|
|
73
|
+
if name not in found:
|
|
74
|
+
found.append(name)
|
|
75
|
+
return found
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def expression_placeholders(site: CallSite) -> list[str]:
|
|
79
|
+
"""Placeholders that are not plain names; eval inputs cannot fill them."""
|
|
80
|
+
return [name for name in site_placeholders(site) if not name.isidentifier()]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def graded_fields(site: CallSite) -> list[str] | None:
|
|
84
|
+
"""Field names after 'Graded fields:' in the output contract, or None if absent."""
|
|
85
|
+
if not site.output_contract:
|
|
86
|
+
return None
|
|
87
|
+
match = _GRADED_FIELDS.search(site.output_contract)
|
|
88
|
+
if not match:
|
|
89
|
+
return None
|
|
90
|
+
names: list[str] = []
|
|
91
|
+
for part in match.group(1).split(","):
|
|
92
|
+
token = part.strip().rstrip(".;").strip("`'\"")
|
|
93
|
+
if not token.isidentifier():
|
|
94
|
+
break
|
|
95
|
+
names.append(token)
|
|
96
|
+
return names or None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def allowed_values(site: CallSite) -> list[str] | None:
|
|
100
|
+
"""Labels from 'from the set {a, b, c}' in the output contract, or None if absent."""
|
|
101
|
+
if not site.output_contract:
|
|
102
|
+
return None
|
|
103
|
+
match = _VALUE_SET.search(site.output_contract)
|
|
104
|
+
if not match:
|
|
105
|
+
return None
|
|
106
|
+
values = [v.strip().strip("`'\"") for v in match.group(1).split(",")]
|
|
107
|
+
return [v for v in values if v] or None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def render_messages(site: CallSite, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
|
111
|
+
"""Fill a call site's prompt with one case's inputs (for Phase 5 runs)."""
|
|
112
|
+
rendered = []
|
|
113
|
+
for message in site.messages or []:
|
|
114
|
+
content = message.content
|
|
115
|
+
for name in placeholders(content):
|
|
116
|
+
if name in inputs:
|
|
117
|
+
content = content.replace("{" + name + "}", _as_text(inputs[name]))
|
|
118
|
+
rendered.append({"role": message.role, "content": content})
|
|
119
|
+
return rendered
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# --- loading ------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class EvalCase:
|
|
127
|
+
id: str
|
|
128
|
+
inputs: dict[str, Any]
|
|
129
|
+
expected: Any
|
|
130
|
+
grading: str
|
|
131
|
+
notes: str | None = None
|
|
132
|
+
line: int = 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass
|
|
136
|
+
class EvalSet:
|
|
137
|
+
path: Path
|
|
138
|
+
cases: list[EvalCase]
|
|
139
|
+
shared: dict[str, Any] = field(default_factory=dict)
|
|
140
|
+
problems: list[str] = field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def slug(self) -> str:
|
|
144
|
+
return self.path.name[: -len(EVAL_SUFFIX)]
|
|
145
|
+
|
|
146
|
+
def inputs_for(self, case: EvalCase) -> dict[str, Any]:
|
|
147
|
+
return {**self.shared, **case.inputs}
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def load_eval_set(path: Path) -> EvalSet:
|
|
151
|
+
"""Parse a JSONL eval file. Bad lines become `problems`; unreadable files raise."""
|
|
152
|
+
try:
|
|
153
|
+
text = path.read_text(encoding="utf-8")
|
|
154
|
+
except FileNotFoundError:
|
|
155
|
+
raise EvalError(f"eval file not found: {path}") from None
|
|
156
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
157
|
+
raise EvalError(f"{path}: could not read ({exc})") from exc
|
|
158
|
+
|
|
159
|
+
eval_set = EvalSet(path=path, cases=[])
|
|
160
|
+
first_record = True
|
|
161
|
+
for number, raw in enumerate(text.splitlines(), start=1):
|
|
162
|
+
if not raw.strip():
|
|
163
|
+
continue
|
|
164
|
+
where = f"line {number}"
|
|
165
|
+
try:
|
|
166
|
+
record = json.loads(raw)
|
|
167
|
+
except json.JSONDecodeError as exc:
|
|
168
|
+
eval_set.problems.append(f"{where}: invalid JSON ({exc.msg})")
|
|
169
|
+
first_record = False
|
|
170
|
+
continue
|
|
171
|
+
if not isinstance(record, dict):
|
|
172
|
+
eval_set.problems.append(f"{where}: expected a JSON object")
|
|
173
|
+
first_record = False
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
if "shared" in record:
|
|
177
|
+
if not first_record:
|
|
178
|
+
eval_set.problems.append(f"{where}: 'shared' is only allowed on the first line")
|
|
179
|
+
else:
|
|
180
|
+
eval_set.shared = _load_shared(record, path, where, eval_set.problems)
|
|
181
|
+
first_record = False
|
|
182
|
+
continue
|
|
183
|
+
first_record = False
|
|
184
|
+
|
|
185
|
+
case = _case_from_record(record, number, eval_set.problems)
|
|
186
|
+
if case is not None:
|
|
187
|
+
eval_set.cases.append(case)
|
|
188
|
+
return eval_set
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _load_shared(record: dict[str, Any], path: Path, where: str, problems: list[str]) -> dict:
|
|
192
|
+
extra = sorted(set(record) - {"shared"})
|
|
193
|
+
if extra:
|
|
194
|
+
problems.append(f"{where}: unknown field(s) next to 'shared': {', '.join(extra)}")
|
|
195
|
+
shared = record["shared"]
|
|
196
|
+
if not isinstance(shared, dict):
|
|
197
|
+
problems.append(f"{where}: 'shared' must be an object")
|
|
198
|
+
return {}
|
|
199
|
+
values: dict[str, Any] = {}
|
|
200
|
+
for key, value in shared.items():
|
|
201
|
+
if isinstance(value, dict) and set(value) == {"file"}:
|
|
202
|
+
target = path.parent / str(value["file"])
|
|
203
|
+
try:
|
|
204
|
+
values[key] = target.read_text(encoding="utf-8")
|
|
205
|
+
except OSError:
|
|
206
|
+
problems.append(f"{where}: shared input {key!r}: file not found: {value['file']}")
|
|
207
|
+
else:
|
|
208
|
+
values[key] = value
|
|
209
|
+
return values
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _case_from_record(record: dict[str, Any], number: int, problems: list[str]) -> EvalCase | None:
|
|
213
|
+
where = f"line {number}"
|
|
214
|
+
unknown = sorted(set(record) - CASE_FIELDS)
|
|
215
|
+
if unknown:
|
|
216
|
+
problems.append(f"{where}: unknown field(s) {', '.join(unknown)}")
|
|
217
|
+
missing = [name for name in ("id", "inputs", "expected", "grading") if name not in record]
|
|
218
|
+
if missing:
|
|
219
|
+
problems.append(f"{where}: missing {', '.join(missing)}")
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
case_id, inputs, grading = record["id"], record["inputs"], record["grading"]
|
|
223
|
+
notes = record.get("notes")
|
|
224
|
+
ok = True
|
|
225
|
+
if not isinstance(case_id, str) or not case_id.strip():
|
|
226
|
+
problems.append(f"{where}: id must be a non-empty string")
|
|
227
|
+
ok = False
|
|
228
|
+
if not isinstance(inputs, dict):
|
|
229
|
+
problems.append(f"{where}: inputs must be an object")
|
|
230
|
+
ok = False
|
|
231
|
+
if grading not in GRADINGS:
|
|
232
|
+
problems.append(f"{where}: grading must be one of {', '.join(sorted(GRADINGS))}")
|
|
233
|
+
ok = False
|
|
234
|
+
if notes is not None and not isinstance(notes, str):
|
|
235
|
+
problems.append(f"{where}: notes must be a string")
|
|
236
|
+
ok = False
|
|
237
|
+
if not ok:
|
|
238
|
+
return None
|
|
239
|
+
return EvalCase(
|
|
240
|
+
id=case_id,
|
|
241
|
+
inputs=inputs,
|
|
242
|
+
expected=record["expected"],
|
|
243
|
+
grading=grading,
|
|
244
|
+
notes=notes,
|
|
245
|
+
line=number,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# --- validation ---------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass
|
|
253
|
+
class EvalReport:
|
|
254
|
+
path: Path
|
|
255
|
+
site_id: str | None = None
|
|
256
|
+
cases: int = 0
|
|
257
|
+
grading: str | None = None
|
|
258
|
+
errors: list[str] = field(default_factory=list)
|
|
259
|
+
warnings: list[str] = field(default_factory=list)
|
|
260
|
+
|
|
261
|
+
@property
|
|
262
|
+
def ok(self) -> bool:
|
|
263
|
+
return not self.errors
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def validate_eval_set(eval_set: EvalSet, site: CallSite) -> EvalReport:
|
|
267
|
+
report = EvalReport(
|
|
268
|
+
path=eval_set.path, site_id=site.id, cases=len(eval_set.cases), grading=site.grading
|
|
269
|
+
)
|
|
270
|
+
report.errors.extend(eval_set.problems)
|
|
271
|
+
|
|
272
|
+
names = site_placeholders(site)
|
|
273
|
+
expressions = [name for name in names if not name.isidentifier()]
|
|
274
|
+
if site.messages is None or not site.prompt_resolved:
|
|
275
|
+
report.errors.append("call site prompt is not resolved; re-run the Bob auditor")
|
|
276
|
+
if expressions:
|
|
277
|
+
report.errors.append(
|
|
278
|
+
"prompt has expression placeholders "
|
|
279
|
+
+ ", ".join("{" + e + "}" for e in expressions)
|
|
280
|
+
+ "; rename them to plain names in the audit"
|
|
281
|
+
)
|
|
282
|
+
wanted = set(names) - set(expressions)
|
|
283
|
+
|
|
284
|
+
fields = graded_fields(site)
|
|
285
|
+
labels = allowed_values(site)
|
|
286
|
+
if site.grading == "json_fields" and fields is None:
|
|
287
|
+
report.warnings.append("output_contract has no 'Graded fields:' list")
|
|
288
|
+
|
|
289
|
+
seen: set[str] = set()
|
|
290
|
+
for case in eval_set.cases:
|
|
291
|
+
where = f"line {case.line} ({case.id})"
|
|
292
|
+
if case.id in seen:
|
|
293
|
+
report.errors.append(f"{where}: duplicate id")
|
|
294
|
+
seen.add(case.id)
|
|
295
|
+
|
|
296
|
+
if site.grading is not None and case.grading != site.grading:
|
|
297
|
+
report.errors.append(
|
|
298
|
+
f"{where}: grading {case.grading} does not match call site grading {site.grading}"
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
given = set(eval_set.inputs_for(case))
|
|
302
|
+
if not expressions:
|
|
303
|
+
missing = sorted(wanted - given)
|
|
304
|
+
extra = sorted(given - wanted)
|
|
305
|
+
if missing:
|
|
306
|
+
report.errors.append(f"{where}: inputs missing {', '.join(missing)}")
|
|
307
|
+
if extra:
|
|
308
|
+
report.errors.append(f"{where}: inputs not in the prompt: {', '.join(extra)}")
|
|
309
|
+
|
|
310
|
+
report.errors.extend(f"{where}: {p}" for p in _check_expected(case, fields, labels))
|
|
311
|
+
|
|
312
|
+
if len(eval_set.cases) < MIN_CASES:
|
|
313
|
+
report.warnings.append(f"only {len(eval_set.cases)} cases (aim for {MIN_CASES} or more)")
|
|
314
|
+
return report
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _check_expected(case: EvalCase, fields: list[str] | None, labels: list[str] | None) -> list:
|
|
318
|
+
expected = case.expected
|
|
319
|
+
if case.grading == "exact":
|
|
320
|
+
if not isinstance(expected, str) or not expected.strip():
|
|
321
|
+
return ["exact grading needs expected to be a non-empty string"]
|
|
322
|
+
if labels is not None and expected not in labels:
|
|
323
|
+
return [f"expected {expected!r} is not one of {', '.join(labels)}"]
|
|
324
|
+
return []
|
|
325
|
+
if case.grading == "json_fields":
|
|
326
|
+
if not isinstance(expected, dict) or not expected:
|
|
327
|
+
return ["json_fields grading needs expected to be a non-empty object"]
|
|
328
|
+
if fields is None:
|
|
329
|
+
return []
|
|
330
|
+
problems = []
|
|
331
|
+
missing = [name for name in fields if name not in expected]
|
|
332
|
+
extra = sorted(set(expected) - set(fields))
|
|
333
|
+
if missing:
|
|
334
|
+
problems.append(f"expected missing graded field(s) {', '.join(missing)}")
|
|
335
|
+
if extra:
|
|
336
|
+
problems.append(f"expected has fields that are not graded: {', '.join(extra)}")
|
|
337
|
+
return problems
|
|
338
|
+
if not isinstance(expected, str) or not expected.strip():
|
|
339
|
+
return ["judge grading needs expected to be a reference answer or rubric string"]
|
|
340
|
+
return []
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def check_eval_dir(directory: Path, sites: list[CallSite]) -> list[EvalReport]:
|
|
344
|
+
"""Validate every `*.jsonl` in a folder against the call sites it is named after.
|
|
345
|
+
|
|
346
|
+
Returns one report per eval file, plus one per call site that has no file.
|
|
347
|
+
"""
|
|
348
|
+
by_slug = {slug_for(site.id): site for site in sites}
|
|
349
|
+
reports: list[EvalReport] = []
|
|
350
|
+
covered: set[str] = set()
|
|
351
|
+
|
|
352
|
+
for path in sorted(directory.glob(f"*{EVAL_SUFFIX}")):
|
|
353
|
+
slug = path.name[: -len(EVAL_SUFFIX)]
|
|
354
|
+
site = by_slug.get(slug)
|
|
355
|
+
if site is None:
|
|
356
|
+
reports.append(
|
|
357
|
+
EvalReport(path=path, errors=[f"no call site with slug {slug!r} in the audit"])
|
|
358
|
+
)
|
|
359
|
+
continue
|
|
360
|
+
covered.add(slug)
|
|
361
|
+
try:
|
|
362
|
+
eval_set = load_eval_set(path)
|
|
363
|
+
except EvalError as exc:
|
|
364
|
+
reports.append(EvalReport(path=path, site_id=site.id, errors=[str(exc)]))
|
|
365
|
+
continue
|
|
366
|
+
reports.append(validate_eval_set(eval_set, site))
|
|
367
|
+
|
|
368
|
+
for slug, site in by_slug.items():
|
|
369
|
+
if slug not in covered:
|
|
370
|
+
reports.append(
|
|
371
|
+
EvalReport(
|
|
372
|
+
path=directory / f"{slug}{EVAL_SUFFIX}",
|
|
373
|
+
site_id=site.id,
|
|
374
|
+
grading=site.grading,
|
|
375
|
+
warnings=["no eval file"],
|
|
376
|
+
)
|
|
377
|
+
)
|
|
378
|
+
return reports
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# --- helpers ------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_expression(text: str) -> bool:
|
|
385
|
+
if text[0] in "\"'":
|
|
386
|
+
return False
|
|
387
|
+
try:
|
|
388
|
+
node = ast.parse(text, mode="eval").body
|
|
389
|
+
except SyntaxError:
|
|
390
|
+
return False
|
|
391
|
+
return not isinstance(node, ast.Constant)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _as_text(value: Any) -> str:
|
|
395
|
+
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
downshift/llm.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""LLM client interface used by every Downshift command.
|
|
2
|
+
|
|
3
|
+
All model calls go through LLMClient, so tests can swap in FakeLLMClient
|
|
4
|
+
and run with no network and no models.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Protocol, runtime_checkable
|
|
13
|
+
|
|
14
|
+
ChatMessage = Mapping[str, str]
|
|
15
|
+
Responder = Callable[[str, list[dict[str, str]]], str]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class LLMError(Exception):
|
|
19
|
+
"""Raised when a model call fails."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Completion:
|
|
24
|
+
"""One model response plus the numbers Downshift needs for cost math."""
|
|
25
|
+
|
|
26
|
+
text: str
|
|
27
|
+
model: str
|
|
28
|
+
prompt_tokens: int
|
|
29
|
+
completion_tokens: int
|
|
30
|
+
latency_s: float
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def total_tokens(self) -> int:
|
|
34
|
+
return self.prompt_tokens + self.completion_tokens
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@runtime_checkable
|
|
38
|
+
class LLMClient(Protocol):
|
|
39
|
+
"""Anything that can run a chat completion."""
|
|
40
|
+
|
|
41
|
+
def complete(
|
|
42
|
+
self,
|
|
43
|
+
model: str,
|
|
44
|
+
messages: Sequence[ChatMessage],
|
|
45
|
+
*,
|
|
46
|
+
temperature: float = 0.0,
|
|
47
|
+
max_tokens: int | None = None,
|
|
48
|
+
json_mode: bool = False,
|
|
49
|
+
) -> Completion: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class OpenAICompatClient:
|
|
53
|
+
"""Client for any OpenAI-compatible endpoint (Ollama, vLLM, OpenAI, Groq, ...)."""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
base_url: str,
|
|
58
|
+
api_key: str = "not-needed",
|
|
59
|
+
*,
|
|
60
|
+
timeout: float = 120.0,
|
|
61
|
+
client: Any = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
if client is None:
|
|
64
|
+
from openai import OpenAI
|
|
65
|
+
|
|
66
|
+
client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout)
|
|
67
|
+
self._client = client
|
|
68
|
+
|
|
69
|
+
def complete(
|
|
70
|
+
self,
|
|
71
|
+
model: str,
|
|
72
|
+
messages: Sequence[ChatMessage],
|
|
73
|
+
*,
|
|
74
|
+
temperature: float = 0.0,
|
|
75
|
+
max_tokens: int | None = None,
|
|
76
|
+
json_mode: bool = False,
|
|
77
|
+
) -> Completion:
|
|
78
|
+
kwargs: dict[str, Any] = {
|
|
79
|
+
"model": model,
|
|
80
|
+
"messages": [dict(m) for m in messages],
|
|
81
|
+
"temperature": temperature,
|
|
82
|
+
}
|
|
83
|
+
if max_tokens is not None:
|
|
84
|
+
kwargs["max_tokens"] = max_tokens
|
|
85
|
+
if json_mode:
|
|
86
|
+
kwargs["response_format"] = {"type": "json_object"}
|
|
87
|
+
|
|
88
|
+
start = time.perf_counter()
|
|
89
|
+
try:
|
|
90
|
+
response = self._client.chat.completions.create(**kwargs)
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
raise LLMError(f"call to {model!r} failed: {exc}") from exc
|
|
93
|
+
latency = time.perf_counter() - start
|
|
94
|
+
|
|
95
|
+
if not response.choices:
|
|
96
|
+
raise LLMError(f"call to {model!r} returned no choices")
|
|
97
|
+
text = response.choices[0].message.content or ""
|
|
98
|
+
usage = getattr(response, "usage", None)
|
|
99
|
+
return Completion(
|
|
100
|
+
text=text,
|
|
101
|
+
model=model,
|
|
102
|
+
prompt_tokens=int(getattr(usage, "prompt_tokens", 0) or 0),
|
|
103
|
+
completion_tokens=int(getattr(usage, "completion_tokens", 0) or 0),
|
|
104
|
+
latency_s=latency,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class FakeCall:
|
|
110
|
+
"""A call recorded by FakeLLMClient, for assertions in tests."""
|
|
111
|
+
|
|
112
|
+
model: str
|
|
113
|
+
messages: list[dict[str, str]]
|
|
114
|
+
temperature: float
|
|
115
|
+
max_tokens: int | None
|
|
116
|
+
json_mode: bool
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def estimate_tokens(text: str) -> int:
|
|
120
|
+
"""Deterministic stand-in for a tokenizer: one token per whitespace-separated word."""
|
|
121
|
+
return len(text.split())
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class FakeLLMClient:
|
|
125
|
+
"""In-memory client for tests. No network, no models.
|
|
126
|
+
|
|
127
|
+
responses can be:
|
|
128
|
+
- a string: every call returns it
|
|
129
|
+
- a mapping of model name to string: unknown models raise LLMError
|
|
130
|
+
- a function (model, messages) -> string
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def __init__(
|
|
134
|
+
self,
|
|
135
|
+
responses: str | Mapping[str, str] | Responder = "",
|
|
136
|
+
*,
|
|
137
|
+
latency_s: float = 0.0,
|
|
138
|
+
) -> None:
|
|
139
|
+
self._responses = responses
|
|
140
|
+
self._latency_s = latency_s
|
|
141
|
+
self.calls: list[FakeCall] = []
|
|
142
|
+
|
|
143
|
+
def complete(
|
|
144
|
+
self,
|
|
145
|
+
model: str,
|
|
146
|
+
messages: Sequence[ChatMessage],
|
|
147
|
+
*,
|
|
148
|
+
temperature: float = 0.0,
|
|
149
|
+
max_tokens: int | None = None,
|
|
150
|
+
json_mode: bool = False,
|
|
151
|
+
) -> Completion:
|
|
152
|
+
msgs = [dict(m) for m in messages]
|
|
153
|
+
self.calls.append(FakeCall(model, msgs, temperature, max_tokens, json_mode))
|
|
154
|
+
text = self._respond(model, msgs)
|
|
155
|
+
prompt_text = " ".join(m.get("content", "") for m in msgs)
|
|
156
|
+
return Completion(
|
|
157
|
+
text=text,
|
|
158
|
+
model=model,
|
|
159
|
+
prompt_tokens=estimate_tokens(prompt_text),
|
|
160
|
+
completion_tokens=estimate_tokens(text),
|
|
161
|
+
latency_s=self._latency_s,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
def _respond(self, model: str, msgs: list[dict[str, str]]) -> str:
|
|
165
|
+
if isinstance(self._responses, str):
|
|
166
|
+
return self._responses
|
|
167
|
+
if isinstance(self._responses, Mapping):
|
|
168
|
+
if model not in self._responses:
|
|
169
|
+
raise LLMError(f"FakeLLMClient has no response for model {model!r}")
|
|
170
|
+
return self._responses[model]
|
|
171
|
+
return self._responses(model, msgs)
|
downshift/py.typed
ADDED
|
File without changes
|