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