callprobe 0.5.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.
- callprobe/__init__.py +10 -0
- callprobe/bootstrap.py +49 -0
- callprobe/cli.py +346 -0
- callprobe/client.py +196 -0
- callprobe/coerce.py +62 -0
- callprobe/compare.py +95 -0
- callprobe/gates.py +137 -0
- callprobe/init.py +101 -0
- callprobe/loader.py +105 -0
- callprobe/models.py +164 -0
- callprobe/report.py +223 -0
- callprobe/runner.py +188 -0
- callprobe/scoring.py +248 -0
- callprobe/suites/core/distractors.yaml +160 -0
- callprobe/suites/core/suite.yaml +2 -0
- callprobe/suites/core/tasks.yaml +808 -0
- callprobe/suites/core/tools.yaml +223 -0
- callprobe/validate.py +37 -0
- callprobe-0.5.0.dist-info/METADATA +515 -0
- callprobe-0.5.0.dist-info/RECORD +23 -0
- callprobe-0.5.0.dist-info/WHEEL +4 -0
- callprobe-0.5.0.dist-info/entry_points.txt +2 -0
- callprobe-0.5.0.dist-info/licenses/LICENSE +21 -0
callprobe/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""callprobe: test whether a model can actually call your tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("callprobe")
|
|
9
|
+
except PackageNotFoundError: # running from a source checkout, not installed
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
callprobe/bootstrap.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Bootstrap confidence intervals for a success rate.
|
|
2
|
+
|
|
3
|
+
At temperature 0 the repeats of a task mostly vary tool order, not the
|
|
4
|
+
model's reasoning, so results from the same task id are correlated with
|
|
5
|
+
each other rather than independent trials. Resampling individual results
|
|
6
|
+
would understate the true uncertainty. Resampling task ids instead, and
|
|
7
|
+
pulling every result for each sampled id along with it, keeps that
|
|
8
|
+
correlation intact.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import random
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
SEED = 1234
|
|
18
|
+
ITERATIONS = 2000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def bootstrap_ci(
|
|
22
|
+
results: list[Any],
|
|
23
|
+
field: str = "success",
|
|
24
|
+
*,
|
|
25
|
+
confidence: float = 0.95,
|
|
26
|
+
iterations: int = ITERATIONS,
|
|
27
|
+
seed: int = SEED,
|
|
28
|
+
) -> tuple[float, float]:
|
|
29
|
+
by_task: dict[str, list[Any]] = defaultdict(list)
|
|
30
|
+
for r in results:
|
|
31
|
+
by_task[r.task_id].append(r)
|
|
32
|
+
task_ids = list(by_task)
|
|
33
|
+
if not task_ids:
|
|
34
|
+
return (0.0, 0.0)
|
|
35
|
+
|
|
36
|
+
rng = random.Random(seed)
|
|
37
|
+
n = len(task_ids)
|
|
38
|
+
rates = []
|
|
39
|
+
for _ in range(iterations):
|
|
40
|
+
pooled = []
|
|
41
|
+
for _ in range(n):
|
|
42
|
+
pooled.extend(by_task[task_ids[rng.randrange(n)]])
|
|
43
|
+
rates.append(sum(1 for r in pooled if getattr(r, field)) / len(pooled))
|
|
44
|
+
rates.sort()
|
|
45
|
+
|
|
46
|
+
tail = (1 - confidence) / 2
|
|
47
|
+
lo = rates[int(tail * iterations)]
|
|
48
|
+
hi = rates[min(int((1 - tail) * iterations), iterations - 1)]
|
|
49
|
+
return (lo, hi)
|
callprobe/cli.py
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
callprobe run --model qwen3:8b --endpoint http://localhost:11434/v1
|
|
4
|
+
callprobe run --model llama3.1:8b --pad 0,8,16 --repeats 3 --quant q4_K_M
|
|
5
|
+
callprobe leaderboard results/qwen3-8b.json results/llama31-8b.json
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import importlib.resources
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from . import __version__
|
|
21
|
+
from .client import ChatClient, probe_server_version
|
|
22
|
+
from .compare import category_deltas, flipped_tasks, render_compare
|
|
23
|
+
from .gates import evaluate_gate, load_policy, render_gate
|
|
24
|
+
from .init import generate_suite_files
|
|
25
|
+
from .loader import load_suite
|
|
26
|
+
from .models import Run, RunConfig
|
|
27
|
+
from .report import failure_digest, render_markdown, render_text, summarize
|
|
28
|
+
from .runner import prepare_config, run_suite, validate_resume
|
|
29
|
+
from .validate import validate_suite
|
|
30
|
+
|
|
31
|
+
# Sentinel meaning "use the suite packaged inside callprobe itself", resolved
|
|
32
|
+
# lazily so a git checkout and a pip install both find suites/core.
|
|
33
|
+
DEFAULT_SUITE = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _read_run(path: str) -> Run:
|
|
37
|
+
return Run.model_validate_json(Path(path).read_text(encoding="utf-8"))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_run(path: str, run: Run) -> None:
|
|
41
|
+
"""An interrupted write must not destroy a resumable checkpoint."""
|
|
42
|
+
out = Path(path)
|
|
43
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
temporary = None
|
|
45
|
+
try:
|
|
46
|
+
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=out.parent,
|
|
47
|
+
prefix=f".{out.name}.", delete=False) as handle:
|
|
48
|
+
temporary = Path(handle.name)
|
|
49
|
+
handle.write(run.model_dump_json(indent=2))
|
|
50
|
+
temporary.replace(out)
|
|
51
|
+
finally:
|
|
52
|
+
if temporary is not None:
|
|
53
|
+
temporary.unlink(missing_ok=True)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _resolve_suite(suite_arg: str | None):
|
|
57
|
+
if suite_arg is None:
|
|
58
|
+
suite_path = importlib.resources.files("callprobe") / "suites" / "core"
|
|
59
|
+
suite_label = "callprobe/suites/core (packaged)"
|
|
60
|
+
else:
|
|
61
|
+
suite_path = suite_arg
|
|
62
|
+
suite_label = suite_arg
|
|
63
|
+
return load_suite(suite_path), suite_label
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _json_safe(value):
|
|
67
|
+
"""inf shows up as tokens/seconds per success with no successes.
|
|
68
|
+
|
|
69
|
+
json.dumps emits it as a bare Infinity token, which is not valid JSON
|
|
70
|
+
per the spec and trips up strict parsers like jq. Map it to null.
|
|
71
|
+
"""
|
|
72
|
+
if isinstance(value, float) and (value != value or value in (float("inf"), float("-inf"))):
|
|
73
|
+
return None
|
|
74
|
+
if isinstance(value, dict):
|
|
75
|
+
return {k: _json_safe(v) for k, v in value.items()}
|
|
76
|
+
if isinstance(value, (list, tuple)):
|
|
77
|
+
return [_json_safe(v) for v in value]
|
|
78
|
+
return value
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run(args: argparse.Namespace) -> int:
|
|
82
|
+
suite, suite_label = _resolve_suite(args.suite)
|
|
83
|
+
|
|
84
|
+
pads = [int(p) for p in args.pad.split(",") if p.strip()]
|
|
85
|
+
server_name, server_version = probe_server_version(args.endpoint)
|
|
86
|
+
config = RunConfig(
|
|
87
|
+
model=args.model,
|
|
88
|
+
endpoint=args.endpoint,
|
|
89
|
+
suite=suite_label,
|
|
90
|
+
pads=pads,
|
|
91
|
+
repeats=args.repeats,
|
|
92
|
+
temperature=args.temperature,
|
|
93
|
+
max_tokens=args.max_tokens,
|
|
94
|
+
quantization=args.quant,
|
|
95
|
+
notes=args.notes,
|
|
96
|
+
callprobe_version=__version__,
|
|
97
|
+
suite_name=suite.name,
|
|
98
|
+
suite_version=suite.version,
|
|
99
|
+
suite_hash=suite.hash,
|
|
100
|
+
server_name=server_name,
|
|
101
|
+
server_version=server_version,
|
|
102
|
+
)
|
|
103
|
+
api_key = args.api_key or os.getenv("API_KEY") or os.getenv("OPENAI_API_KEY")
|
|
104
|
+
config = prepare_config(suite, config)
|
|
105
|
+
|
|
106
|
+
resume_run = None
|
|
107
|
+
if args.resume:
|
|
108
|
+
resume_run = _read_run(args.resume)
|
|
109
|
+
validate_resume(config, resume_run)
|
|
110
|
+
|
|
111
|
+
client = ChatClient(args.endpoint, api_key=api_key, retries=args.retries)
|
|
112
|
+
|
|
113
|
+
total = len(suite.tasks) * len(pads) * args.repeats
|
|
114
|
+
state = {"done": 0}
|
|
115
|
+
|
|
116
|
+
def progress(result) -> None:
|
|
117
|
+
state["done"] += 1
|
|
118
|
+
if not args.quiet:
|
|
119
|
+
mark = "." if result.success else "x"
|
|
120
|
+
sys.stderr.write(mark)
|
|
121
|
+
if state["done"] % 50 == 0:
|
|
122
|
+
sys.stderr.write(f" {state['done']}/{total}\n")
|
|
123
|
+
sys.stderr.flush()
|
|
124
|
+
|
|
125
|
+
def write_partial(partial_run: Run) -> None:
|
|
126
|
+
if args.out:
|
|
127
|
+
_write_run(args.out, partial_run)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
run = run_suite(
|
|
131
|
+
suite,
|
|
132
|
+
client,
|
|
133
|
+
config,
|
|
134
|
+
on_result=progress,
|
|
135
|
+
on_progress=write_partial if args.out else None,
|
|
136
|
+
concurrency=args.concurrency,
|
|
137
|
+
resume=resume_run,
|
|
138
|
+
)
|
|
139
|
+
finally:
|
|
140
|
+
client.close()
|
|
141
|
+
if not args.quiet:
|
|
142
|
+
sys.stderr.write("\n\n")
|
|
143
|
+
|
|
144
|
+
summary = summarize(run)
|
|
145
|
+
if args.format == "json":
|
|
146
|
+
print(json.dumps(_json_safe(summary), indent=2))
|
|
147
|
+
else:
|
|
148
|
+
print(render_text(run))
|
|
149
|
+
print()
|
|
150
|
+
print(failure_digest(run))
|
|
151
|
+
|
|
152
|
+
if args.out:
|
|
153
|
+
_write_run(args.out, run)
|
|
154
|
+
if args.format != "json":
|
|
155
|
+
print(f"\nwrote {args.out}")
|
|
156
|
+
|
|
157
|
+
if args.fail_under is not None:
|
|
158
|
+
if summary["errors"] or len(run.results) != total or not summary["n"]:
|
|
159
|
+
sys.stderr.write("CI gate failed: run is incomplete or contains request errors\n")
|
|
160
|
+
return 1
|
|
161
|
+
if summary["overall"]["success"] < args.fail_under:
|
|
162
|
+
return 1
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _validate(args: argparse.Namespace) -> int:
|
|
167
|
+
suite, suite_label = _resolve_suite(args.suite)
|
|
168
|
+
problems = validate_suite(suite)
|
|
169
|
+
print(f"suite: {suite_label} ({len(suite.tasks)} tasks)")
|
|
170
|
+
for problem in problems:
|
|
171
|
+
print(f" {problem}")
|
|
172
|
+
if problems:
|
|
173
|
+
print(f"\n{len(problems)} problem(s)")
|
|
174
|
+
return 1
|
|
175
|
+
print("no problems found")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _init(args: argparse.Namespace) -> int:
|
|
180
|
+
try:
|
|
181
|
+
data = json.loads(Path(args.from_file).read_text(encoding="utf-8"))
|
|
182
|
+
except FileNotFoundError:
|
|
183
|
+
sys.stderr.write(f"error: {args.from_file} not found\n")
|
|
184
|
+
return 1
|
|
185
|
+
|
|
186
|
+
out = Path(args.out)
|
|
187
|
+
try:
|
|
188
|
+
files = generate_suite_files(data, out.name)
|
|
189
|
+
except ValueError as exc:
|
|
190
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
191
|
+
return 1
|
|
192
|
+
|
|
193
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
194
|
+
for filename, content in files.items():
|
|
195
|
+
(out / filename).write_text(content, encoding="utf-8")
|
|
196
|
+
|
|
197
|
+
print(f"wrote {len(files)} files to {out}/")
|
|
198
|
+
print("uncomment and fill in the example tasks in tasks.yaml, then run:")
|
|
199
|
+
print(f" callprobe validate --suite {out}")
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _compare(args: argparse.Namespace) -> int:
|
|
204
|
+
a, b = _read_run(args.a), _read_run(args.b)
|
|
205
|
+
gate = None
|
|
206
|
+
if args.fail_on_regression or args.policy:
|
|
207
|
+
policy = load_policy(args.policy)
|
|
208
|
+
if args.fail_on_regression:
|
|
209
|
+
policy.fail_on_regression = True
|
|
210
|
+
gate = evaluate_gate(a, b, policy)
|
|
211
|
+
if a.config.suite_hash != b.config.suite_hash:
|
|
212
|
+
sys.stderr.write(
|
|
213
|
+
f"warning: suite hashes differ (a={a.config.suite_hash}, "
|
|
214
|
+
f"b={b.config.suite_hash}); some of this delta may be the suite "
|
|
215
|
+
"changing, not the model\n"
|
|
216
|
+
)
|
|
217
|
+
if a.config.scoring_version != b.config.scoring_version:
|
|
218
|
+
sys.stderr.write("warning: scoring versions differ; scores are not directly comparable\n")
|
|
219
|
+
if args.format == "json":
|
|
220
|
+
regressions, improvements = flipped_tasks(a, b)
|
|
221
|
+
print(json.dumps({"by_category": category_deltas(a, b),
|
|
222
|
+
"regressed_tasks": regressions, "improved_tasks": improvements,
|
|
223
|
+
"gate": gate}, indent=2))
|
|
224
|
+
else:
|
|
225
|
+
print(render_compare(a, b))
|
|
226
|
+
if gate is not None:
|
|
227
|
+
print("\n" + render_gate(gate))
|
|
228
|
+
return 1 if gate is not None and not gate["passed"] else 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _leaderboard(args: argparse.Namespace) -> int:
|
|
232
|
+
runs = []
|
|
233
|
+
for path in args.results:
|
|
234
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
235
|
+
runs.append(Run(**data))
|
|
236
|
+
runs.sort(key=lambda r: r.config.model)
|
|
237
|
+
|
|
238
|
+
keys = {(r.config.suite_version, r.config.suite_hash, r.config.scoring_version) for r in runs}
|
|
239
|
+
if len(keys) > 1 and not args.allow_mixed:
|
|
240
|
+
sys.stderr.write("these runs come from different suites or scoring versions:\n")
|
|
241
|
+
for r in runs:
|
|
242
|
+
sys.stderr.write(
|
|
243
|
+
f" {r.config.model}: version={r.config.suite_version} "
|
|
244
|
+
f"hash={r.config.suite_hash} scoring={r.config.scoring_version}\n"
|
|
245
|
+
)
|
|
246
|
+
sys.stderr.write("pass --allow-mixed to build the table anyway\n")
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
print(render_markdown(runs))
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def main(argv: list[str] | None = None) -> int:
|
|
254
|
+
parser = argparse.ArgumentParser(prog="callprobe")
|
|
255
|
+
parser.add_argument(
|
|
256
|
+
"--version", action="version", version=f"callprobe {__version__}"
|
|
257
|
+
)
|
|
258
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
259
|
+
|
|
260
|
+
run_cmd = sub.add_parser("run", help="score a model against a suite")
|
|
261
|
+
run_cmd.add_argument("--model", required=True)
|
|
262
|
+
run_cmd.add_argument("--endpoint", default="http://localhost:11434/v1")
|
|
263
|
+
run_cmd.add_argument("--api-key", default=None)
|
|
264
|
+
run_cmd.add_argument(
|
|
265
|
+
"--retries", type=int, default=3, help="retries on 429, 5xx, and connection errors"
|
|
266
|
+
)
|
|
267
|
+
run_cmd.add_argument(
|
|
268
|
+
"--suite", default=DEFAULT_SUITE, help="suite directory, defaults to the packaged core suite"
|
|
269
|
+
)
|
|
270
|
+
run_cmd.add_argument("--pad", default="0,8,16")
|
|
271
|
+
run_cmd.add_argument("--repeats", type=int, default=1)
|
|
272
|
+
run_cmd.add_argument("--temperature", type=float, default=0.0)
|
|
273
|
+
run_cmd.add_argument("--max-tokens", type=int, default=2048)
|
|
274
|
+
run_cmd.add_argument("--quant", default=None, help="label only, e.g. q4_K_M")
|
|
275
|
+
run_cmd.add_argument("--notes", default=None)
|
|
276
|
+
run_cmd.add_argument("--out", default=None, help="write raw results as JSON")
|
|
277
|
+
run_cmd.add_argument("--quiet", action="store_true")
|
|
278
|
+
run_cmd.add_argument("--format", choices=["text", "json"], default="text")
|
|
279
|
+
run_cmd.add_argument(
|
|
280
|
+
"--fail-under",
|
|
281
|
+
type=float,
|
|
282
|
+
default=None,
|
|
283
|
+
help="exit 1 if overall success is below this fraction, e.g. 0.7",
|
|
284
|
+
)
|
|
285
|
+
run_cmd.add_argument(
|
|
286
|
+
"--concurrency", type=int, default=1, help="parallel requests via a thread pool"
|
|
287
|
+
)
|
|
288
|
+
run_cmd.add_argument(
|
|
289
|
+
"--resume",
|
|
290
|
+
default=None,
|
|
291
|
+
help="skip (task, pad, repeat) combinations already in this results file",
|
|
292
|
+
)
|
|
293
|
+
run_cmd.set_defaults(func=_run)
|
|
294
|
+
|
|
295
|
+
init_cmd = sub.add_parser(
|
|
296
|
+
"init", help="scaffold a suite from an OpenAI-format tools.json"
|
|
297
|
+
)
|
|
298
|
+
init_cmd.add_argument(
|
|
299
|
+
"--from", dest="from_file", required=True, help="path to a tools.json"
|
|
300
|
+
)
|
|
301
|
+
init_cmd.add_argument("--out", default="suite", help="directory to write the suite to")
|
|
302
|
+
init_cmd.set_defaults(func=_init)
|
|
303
|
+
|
|
304
|
+
validate_cmd = sub.add_parser(
|
|
305
|
+
"validate", help="check task expectations against tool schemas, no model needed"
|
|
306
|
+
)
|
|
307
|
+
validate_cmd.add_argument(
|
|
308
|
+
"--suite", default=DEFAULT_SUITE, help="suite directory, defaults to the packaged core suite"
|
|
309
|
+
)
|
|
310
|
+
validate_cmd.set_defaults(func=_validate)
|
|
311
|
+
|
|
312
|
+
compare_cmd = sub.add_parser(
|
|
313
|
+
"compare", help="diff two runs: per-category deltas and which tasks flipped"
|
|
314
|
+
)
|
|
315
|
+
compare_cmd.add_argument("a")
|
|
316
|
+
compare_cmd.add_argument("b")
|
|
317
|
+
compare_cmd.add_argument("--fail-on-regression", action="store_true",
|
|
318
|
+
help="fail if any matched passing case regresses; requires complete runs")
|
|
319
|
+
compare_cmd.add_argument("--policy", help="YAML CI policy; enables gating")
|
|
320
|
+
compare_cmd.add_argument("--format", choices=["text", "json"], default="text")
|
|
321
|
+
compare_cmd.set_defaults(func=_compare)
|
|
322
|
+
|
|
323
|
+
board = sub.add_parser("leaderboard", help="build a markdown table from runs")
|
|
324
|
+
board.add_argument("results", nargs="+")
|
|
325
|
+
board.add_argument(
|
|
326
|
+
"--allow-mixed",
|
|
327
|
+
action="store_true",
|
|
328
|
+
help="build the table even if runs come from different suite versions or content",
|
|
329
|
+
)
|
|
330
|
+
board.set_defaults(func=_leaderboard)
|
|
331
|
+
|
|
332
|
+
args = parser.parse_args(argv)
|
|
333
|
+
try:
|
|
334
|
+
if args.command == "run":
|
|
335
|
+
if args.concurrency < 1 or args.max_tokens < 1 or args.retries < 0:
|
|
336
|
+
raise ValueError("concurrency/max-tokens must be positive and retries nonnegative")
|
|
337
|
+
if args.fail_under is not None and not 0 <= args.fail_under <= 1:
|
|
338
|
+
raise ValueError("fail-under must be between 0 and 1")
|
|
339
|
+
return args.func(args)
|
|
340
|
+
except (ValueError, OSError, yaml.YAMLError) as exc:
|
|
341
|
+
sys.stderr.write(f"error: {exc}\n")
|
|
342
|
+
return 2
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
if __name__ == "__main__":
|
|
346
|
+
raise SystemExit(main())
|
callprobe/client.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""A deliberately small OpenAI-compatible client.
|
|
2
|
+
|
|
3
|
+
We avoid the official SDK so that any endpoint that speaks
|
|
4
|
+
/v1/chat/completions works: Ollama, LM Studio, llama.cpp server, vLLM,
|
|
5
|
+
or a hosted provider.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import Any
|
|
15
|
+
from urllib.parse import urlsplit, urlunsplit
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from .models import Call
|
|
20
|
+
|
|
21
|
+
DEFAULT_RETRIES = 3
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def probe_server_version(endpoint: str, timeout: float = 2.0) -> tuple[str | None, str | None]:
|
|
25
|
+
"""Best-effort server identification, for provenance in RunConfig.
|
|
26
|
+
|
|
27
|
+
Only Ollama's /api/version is checked. Any other server, or no
|
|
28
|
+
response, leaves both fields null rather than guessing.
|
|
29
|
+
"""
|
|
30
|
+
parts = urlsplit(endpoint)
|
|
31
|
+
root = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
|
|
32
|
+
try:
|
|
33
|
+
response = httpx.get(f"{root}/api/version", timeout=timeout)
|
|
34
|
+
response.raise_for_status()
|
|
35
|
+
version = response.json().get("version")
|
|
36
|
+
except Exception: # noqa: BLE001 - purely informational
|
|
37
|
+
return None, None
|
|
38
|
+
return ("ollama", version) if version else (None, None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class Completion:
|
|
43
|
+
calls: list[Call] = field(default_factory=list)
|
|
44
|
+
content: str = ""
|
|
45
|
+
prompt_tokens: int = 0
|
|
46
|
+
completion_tokens: int = 0
|
|
47
|
+
latency_ms: float = 0.0
|
|
48
|
+
error: str | None = None
|
|
49
|
+
finish_reason: str = ""
|
|
50
|
+
# Some servers put chain of thought in its own field rather than content.
|
|
51
|
+
reasoning: str = ""
|
|
52
|
+
raw: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_retryable(status_code: int) -> bool:
|
|
56
|
+
return status_code == 429 or status_code >= 500
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ChatClient:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
endpoint: str,
|
|
63
|
+
api_key: str | None = None,
|
|
64
|
+
timeout: float = 120.0,
|
|
65
|
+
retries: int = DEFAULT_RETRIES,
|
|
66
|
+
transport: httpx.BaseTransport | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.endpoint = endpoint.rstrip("/")
|
|
69
|
+
headers = {"Content-Type": "application/json"}
|
|
70
|
+
if api_key:
|
|
71
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
72
|
+
self._client = httpx.Client(timeout=timeout, headers=headers, transport=transport)
|
|
73
|
+
self.retries = retries
|
|
74
|
+
|
|
75
|
+
def close(self) -> None:
|
|
76
|
+
self._client.close()
|
|
77
|
+
|
|
78
|
+
def _backoff(self, attempt: int, retry_after: str | None) -> float:
|
|
79
|
+
"""Exponential backoff with full jitter, honoring Retry-After."""
|
|
80
|
+
if retry_after is not None:
|
|
81
|
+
try:
|
|
82
|
+
return max(0.0, float(retry_after))
|
|
83
|
+
except ValueError:
|
|
84
|
+
pass
|
|
85
|
+
ceiling = min(20.0, 0.5 * (2**attempt))
|
|
86
|
+
return random.uniform(0, ceiling)
|
|
87
|
+
|
|
88
|
+
def complete(
|
|
89
|
+
self,
|
|
90
|
+
model: str,
|
|
91
|
+
messages: list[dict[str, Any]],
|
|
92
|
+
tools: list[dict[str, Any]],
|
|
93
|
+
temperature: float = 0.0,
|
|
94
|
+
max_tokens: int = 512,
|
|
95
|
+
) -> Completion:
|
|
96
|
+
payload = {
|
|
97
|
+
"model": model,
|
|
98
|
+
"messages": messages,
|
|
99
|
+
"temperature": temperature,
|
|
100
|
+
"max_tokens": max_tokens,
|
|
101
|
+
}
|
|
102
|
+
if tools:
|
|
103
|
+
payload["tools"] = tools
|
|
104
|
+
payload["tool_choice"] = "auto"
|
|
105
|
+
|
|
106
|
+
started = time.perf_counter()
|
|
107
|
+
attempts = self.retries + 1
|
|
108
|
+
for attempt in range(attempts):
|
|
109
|
+
last_attempt = attempt + 1 == attempts
|
|
110
|
+
try:
|
|
111
|
+
response = self._client.post(
|
|
112
|
+
f"{self.endpoint}/chat/completions", json=payload
|
|
113
|
+
)
|
|
114
|
+
except httpx.TransportError as exc:
|
|
115
|
+
if last_attempt:
|
|
116
|
+
return Completion(
|
|
117
|
+
latency_ms=(time.perf_counter() - started) * 1000,
|
|
118
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
119
|
+
)
|
|
120
|
+
time.sleep(self._backoff(attempt, None))
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
if _is_retryable(response.status_code) and not last_attempt:
|
|
124
|
+
time.sleep(self._backoff(attempt, response.headers.get("Retry-After")))
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
response.raise_for_status()
|
|
129
|
+
body = response.json()
|
|
130
|
+
except Exception as exc: # noqa: BLE001 - reported, not raised
|
|
131
|
+
return Completion(
|
|
132
|
+
latency_ms=(time.perf_counter() - started) * 1000,
|
|
133
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
return parse_completion(body, (time.perf_counter() - started) * 1000)
|
|
137
|
+
|
|
138
|
+
raise AssertionError("unreachable: loop always returns or retries")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_completion(body: dict[str, Any], latency_ms: float) -> Completion:
|
|
142
|
+
"""Turn a chat completion body into calls, tolerating provider quirks."""
|
|
143
|
+
usage = body.get("usage") or {}
|
|
144
|
+
choices = body.get("choices") or [{}]
|
|
145
|
+
choice = choices[0] or {}
|
|
146
|
+
message = choice.get("message") or {}
|
|
147
|
+
finish_reason = choice.get("finish_reason") or ""
|
|
148
|
+
reasoning = message.get("reasoning") or message.get("reasoning_content") or ""
|
|
149
|
+
|
|
150
|
+
calls: list[Call] = []
|
|
151
|
+
for entry in message.get("tool_calls") or []:
|
|
152
|
+
function = entry.get("function") or {}
|
|
153
|
+
raw_args = function.get("arguments")
|
|
154
|
+
if isinstance(raw_args, dict):
|
|
155
|
+
calls.append(
|
|
156
|
+
Call(
|
|
157
|
+
id=entry.get("id"),
|
|
158
|
+
name=function.get("name", ""),
|
|
159
|
+
arguments=raw_args,
|
|
160
|
+
raw_arguments=json.dumps(raw_args),
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
continue
|
|
164
|
+
raw_args = raw_args or ""
|
|
165
|
+
try:
|
|
166
|
+
parsed = json.loads(raw_args) if raw_args.strip() else {}
|
|
167
|
+
if not isinstance(parsed, dict):
|
|
168
|
+
raise ValueError("arguments were not a JSON object")
|
|
169
|
+
calls.append(
|
|
170
|
+
Call(
|
|
171
|
+
id=entry.get("id"),
|
|
172
|
+
name=function.get("name", ""),
|
|
173
|
+
arguments=parsed,
|
|
174
|
+
raw_arguments=raw_args,
|
|
175
|
+
)
|
|
176
|
+
)
|
|
177
|
+
except Exception as exc: # noqa: BLE001
|
|
178
|
+
calls.append(
|
|
179
|
+
Call(
|
|
180
|
+
id=entry.get("id"),
|
|
181
|
+
name=function.get("name", ""),
|
|
182
|
+
raw_arguments=raw_args,
|
|
183
|
+
parse_error=str(exc),
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return Completion(
|
|
188
|
+
calls=calls,
|
|
189
|
+
content=message.get("content") or "",
|
|
190
|
+
prompt_tokens=int(usage.get("prompt_tokens") or 0),
|
|
191
|
+
completion_tokens=int(usage.get("completion_tokens") or 0),
|
|
192
|
+
latency_ms=latency_ms,
|
|
193
|
+
finish_reason=finish_reason,
|
|
194
|
+
reasoning=reasoning,
|
|
195
|
+
raw=body,
|
|
196
|
+
)
|
callprobe/coerce.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Separate wrong-type from wrong-value."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
TRUE = {"true", "yes", "1"}
|
|
7
|
+
FALSE = {"false", "no", "0"}
|
|
8
|
+
|
|
9
|
+
def _coerce_scalar(value, kind):
|
|
10
|
+
if not isinstance(value, str):
|
|
11
|
+
return value
|
|
12
|
+
text = value.strip()
|
|
13
|
+
try:
|
|
14
|
+
if kind == "integer":
|
|
15
|
+
return int(text, 10) if text.lstrip("-").isdigit() else int(float(text))
|
|
16
|
+
if kind == "number":
|
|
17
|
+
return float(text)
|
|
18
|
+
if kind == "boolean":
|
|
19
|
+
low = text.lower()
|
|
20
|
+
if low in TRUE:
|
|
21
|
+
return True
|
|
22
|
+
if low in FALSE:
|
|
23
|
+
return False
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return value
|
|
26
|
+
return value
|
|
27
|
+
|
|
28
|
+
def _types(schema):
|
|
29
|
+
declared = schema.get("type")
|
|
30
|
+
if isinstance(declared, str):
|
|
31
|
+
return [declared]
|
|
32
|
+
if isinstance(declared, list):
|
|
33
|
+
return [t for t in declared if isinstance(t, str)]
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
def coerce(value, schema):
|
|
37
|
+
if not isinstance(schema, dict):
|
|
38
|
+
return value
|
|
39
|
+
kinds = _types(schema)
|
|
40
|
+
if isinstance(value, str) and ({"array", "object"} & set(kinds)):
|
|
41
|
+
text = value.strip()
|
|
42
|
+
if text[:1] in "[{":
|
|
43
|
+
try:
|
|
44
|
+
value = json.loads(text)
|
|
45
|
+
except json.JSONDecodeError:
|
|
46
|
+
return value
|
|
47
|
+
if isinstance(value, dict):
|
|
48
|
+
properties = schema.get("properties") or {}
|
|
49
|
+
return {k: coerce(v, properties.get(k)) for k, v in value.items()}
|
|
50
|
+
if isinstance(value, list):
|
|
51
|
+
items = schema.get("items")
|
|
52
|
+
return [coerce(v, items) for v in value]
|
|
53
|
+
for kind in kinds:
|
|
54
|
+
if kind in ("integer", "number", "boolean"):
|
|
55
|
+
return _coerce_scalar(value, kind)
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
def coerce_arguments(arguments, parameters):
|
|
59
|
+
if not isinstance(arguments, dict):
|
|
60
|
+
return arguments, False
|
|
61
|
+
coerced = coerce(arguments, parameters or {})
|
|
62
|
+
return coerced, coerced != arguments
|