toolwall 0.2.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.
toolwall/__init__.py ADDED
@@ -0,0 +1,56 @@
1
+ """toolwall: fail-closed firewall for AI agent tool calls.
2
+
3
+ Structured outputs guarantee your agent's tool calls are well-formed.
4
+ toolwall guarantees they're allowed.
5
+ """
6
+
7
+ from toolwall.gate import Gate, GateResult, ToolRegistry, Verdict
8
+ from toolwall.intake import IntakeError, ToolCall, parse_tool_calls
9
+ from toolwall.meter import Meter, RunEvent, RunReport, extract_usage
10
+ from toolwall.policy import (
11
+ Policy,
12
+ ends_with,
13
+ in_range,
14
+ matches,
15
+ max_len,
16
+ not_empty,
17
+ one_of,
18
+ starts_with,
19
+ )
20
+ from toolwall.schema import ToolSchema, schema_from_signature
21
+ from toolwall.shield import Finding, Shield
22
+ from toolwall.suggest import suggest_policies
23
+ from toolwall.mcp_guard import GuardedCall, MCPGuard, to_mcp_error
24
+
25
+ __version__ = "0.2.0"
26
+
27
+ __all__ = [
28
+ "Gate",
29
+ "GateResult",
30
+ "Verdict",
31
+ "ToolRegistry",
32
+ "ToolCall",
33
+ "IntakeError",
34
+ "parse_tool_calls",
35
+ "Meter",
36
+ "RunEvent",
37
+ "RunReport",
38
+ "extract_usage",
39
+ "Policy",
40
+ "in_range",
41
+ "one_of",
42
+ "matches",
43
+ "max_len",
44
+ "ends_with",
45
+ "starts_with",
46
+ "not_empty",
47
+ "ToolSchema",
48
+ "schema_from_signature",
49
+ "Shield",
50
+ "Finding",
51
+ "suggest_policies",
52
+ "MCPGuard",
53
+ "GuardedCall",
54
+ "to_mcp_error",
55
+ "__version__",
56
+ ]
toolwall/cli.py ADDED
@@ -0,0 +1,44 @@
1
+ """toolwall CLI — inspect meter/audit exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import sys
8
+ from pathlib import Path
9
+
10
+
11
+ def cmd_report(args: argparse.Namespace) -> int:
12
+ data = json.loads(Path(args.file).read_text(encoding="utf-8"))
13
+ summary = data.get("summary") or {}
14
+ print(f"Model: {data.get('model', 'unknown')}")
15
+ print(f"Events: {summary.get('event_count', len(data.get('events', [])))}")
16
+ for lane, stats in (summary.get("lanes") or {}).items():
17
+ print(f"\n[{lane}]")
18
+ for key in (
19
+ "prompt_tokens",
20
+ "completion_tokens",
21
+ "total_tokens",
22
+ "estimated_cost_usd",
23
+ "intercept_success_rate",
24
+ ):
25
+ print(f" {key}: {stats.get(key)}")
26
+ if summary.get("note"):
27
+ print(f"\nNote: {summary['note']}")
28
+ return 0
29
+
30
+
31
+ def main() -> None:
32
+ ap = argparse.ArgumentParser(prog="toolwall", description="toolwall dev tools")
33
+ sub = ap.add_subparsers(dest="command", required=True)
34
+
35
+ report = sub.add_parser("report", help="Print summary from a Meter JSON export")
36
+ report.add_argument("file", help="Path to meter/audit JSON")
37
+ report.set_defaults(func=cmd_report)
38
+
39
+ args = ap.parse_args()
40
+ sys.exit(args.func(args))
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
toolwall/gate.py ADDED
@@ -0,0 +1,381 @@
1
+ """toolwall: fail-closed checkpoint between LLM tool calls and execution.
2
+
3
+ gate = Gate(default="deny", meter=meter, shield=Shield(mode="block"))
4
+ gate.register("db_query", db_query,
5
+ schema=ToolSchema(required=["q"]),
6
+ policy=Policy(constraints={"limit": in_range(1, 100)}))
7
+ gate.budget(max_calls=20)
8
+ result = gate.run(openai_response) # check + execute only if allowed
9
+
10
+ Check order (first failure blocks, fail-closed):
11
+ intake -> known tool -> budget -> schema -> policy -> shield -> approval
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from collections.abc import Callable
17
+ from dataclasses import dataclass, field
18
+ from enum import Enum
19
+ from typing import Any, Literal
20
+
21
+ from toolwall.intake import IntakeError, ToolCall, parse_tool_calls
22
+ from toolwall.meter import Meter, RunEvent
23
+ from toolwall.policy import Policy
24
+ from toolwall.schema import ToolSchema, schema_from_signature
25
+ from toolwall.shield import Finding, Shield
26
+
27
+ ToolFn = Callable[..., Any]
28
+ DefaultMode = Literal["deny", "allow"]
29
+ ApprovalHandler = Callable[["GateResult"], bool]
30
+
31
+
32
+ class Verdict(str, Enum):
33
+ ALLOW = "allow"
34
+ BLOCK = "block"
35
+ NEEDS_APPROVAL = "needs_approval"
36
+
37
+
38
+ @dataclass
39
+ class ToolRegistry:
40
+ """Maps tool names to callables (+ optional schema and policy).
41
+
42
+ Registration is the allowlist: unregistered tools always block.
43
+ """
44
+
45
+ _tools: dict[str, ToolFn] = field(default_factory=dict)
46
+ _schemas: dict[str, ToolSchema] = field(default_factory=dict)
47
+ _policies: dict[str, Policy] = field(default_factory=dict)
48
+
49
+ def register(
50
+ self,
51
+ name: str,
52
+ fn: ToolFn,
53
+ schema: ToolSchema | None = None,
54
+ *,
55
+ policy: Policy | None = None,
56
+ infer_schema: bool = False,
57
+ ) -> None:
58
+ self._tools[name] = fn
59
+ if schema is not None:
60
+ self._schemas[name] = schema
61
+ elif infer_schema:
62
+ self._schemas[name] = schema_from_signature(fn)
63
+ if policy is not None:
64
+ self._policies[name] = policy
65
+
66
+ def get(self, name: str) -> ToolFn | None:
67
+ return self._tools.get(name)
68
+
69
+ def get_schema(self, name: str) -> ToolSchema | None:
70
+ return self._schemas.get(name)
71
+
72
+ def get_policy(self, name: str) -> Policy | None:
73
+ return self._policies.get(name)
74
+
75
+ def names(self) -> list[str]:
76
+ return list(self._tools.keys())
77
+
78
+
79
+ @dataclass
80
+ class GateResult:
81
+ verdict: Verdict
82
+ call: ToolCall | None = None
83
+ reasons: list[str] = field(default_factory=list)
84
+ executed: bool = False
85
+ return_value: Any = None
86
+ error: str | None = None
87
+ findings: list[Finding] = field(default_factory=list) # shield hits (value-free)
88
+ dry_run: bool = False # True when the gate simulated execution
89
+
90
+ @property
91
+ def allowed(self) -> bool:
92
+ return self.verdict is Verdict.ALLOW
93
+
94
+
95
+ class Gate:
96
+ """Fail-closed gate.
97
+
98
+ default="deny" -> a registered tool with NO schema is still blocked
99
+ default="allow" -> schema optional; registered tool without schema passes
100
+
101
+ approval: optional handler called by run()/run_all() when a policy demands
102
+ approval. Returns True to execute. Without a handler, NEEDS_APPROVAL calls
103
+ are never executed (fail closed).
104
+ """
105
+
106
+ def __init__(
107
+ self,
108
+ registry: ToolRegistry | None = None,
109
+ *,
110
+ default: DefaultMode = "deny",
111
+ meter: Meter | None = None,
112
+ shield: Shield | None = None,
113
+ approval: ApprovalHandler | None = None,
114
+ dry_run: bool = False,
115
+ lane: str = "gate",
116
+ ):
117
+ if default not in ("deny", "allow"):
118
+ raise ValueError(f"default must be 'deny' or 'allow', got {default!r}")
119
+ self.registry = registry or ToolRegistry()
120
+ self.default = default
121
+ self.meter = meter
122
+ self.shield = shield
123
+ self.approval = approval
124
+ self.dry_run = dry_run
125
+ self.lane = lane
126
+ self.history: list[GateResult] = [] # every checked result, for reports/suggest
127
+ self._budget: dict[str, Any] = {}
128
+ self._executed_calls = 0
129
+ self._executed_per_tool: dict[str, int] = {}
130
+
131
+ def register(
132
+ self,
133
+ name: str,
134
+ fn: ToolFn,
135
+ schema: ToolSchema | None = None,
136
+ *,
137
+ policy: Policy | None = None,
138
+ infer_schema: bool = False,
139
+ ) -> None:
140
+ self.registry.register(name, fn, schema, policy=policy, infer_schema=infer_schema)
141
+
142
+ # -- budget ---------------------------------------------------------------
143
+
144
+ def budget(
145
+ self,
146
+ *,
147
+ max_calls: int | None = None,
148
+ max_calls_per_tool: int | None = None,
149
+ max_usd: float | None = None,
150
+ ) -> None:
151
+ if max_usd is not None and self.meter is None:
152
+ raise ValueError("max_usd budget requires a meter (cost is meter-derived)")
153
+ self._budget = {
154
+ "max_calls": max_calls,
155
+ "max_calls_per_tool": max_calls_per_tool,
156
+ "max_usd": max_usd,
157
+ }
158
+
159
+ def _budget_violation(self, call: ToolCall) -> str | None:
160
+ if not self._budget:
161
+ return None
162
+ max_calls = self._budget.get("max_calls")
163
+ if max_calls is not None and self._executed_calls >= max_calls:
164
+ return f"budget exceeded: max_calls={max_calls} already executed"
165
+ per_tool = self._budget.get("max_calls_per_tool")
166
+ if per_tool is not None and self._executed_per_tool.get(call.name, 0) >= per_tool:
167
+ return f"budget exceeded: max_calls_per_tool={per_tool} for {call.name!r}"
168
+ max_usd = self._budget.get("max_usd")
169
+ if max_usd is not None and self.meter is not None:
170
+ spent = self.meter.report.estimate_cost_usd(self.lane)
171
+ if spent >= max_usd:
172
+ return f"budget exceeded: estimated ${spent:.4f} >= max_usd={max_usd}"
173
+ return None
174
+
175
+ # -- checking ---------------------------------------------------------------
176
+
177
+ def check_all(self, payload: Any) -> list[GateResult]:
178
+ try:
179
+ calls = parse_tool_calls(payload)
180
+ except IntakeError as exc:
181
+ return [self._record(GateResult(Verdict.BLOCK, reasons=[f"intake: {exc}"]))]
182
+ if not calls:
183
+ return [self._record(GateResult(Verdict.BLOCK, reasons=["intake: no tool calls in payload"]))]
184
+ return [self._check_one(call) for call in calls]
185
+
186
+ def check(self, payload: Any) -> GateResult:
187
+ results = self.check_all(payload)
188
+ if len(results) != 1:
189
+ raise ValueError(f"expected exactly 1 tool call, got {len(results)}; use check_all()")
190
+ return results[0]
191
+
192
+ def _check_one(self, call: ToolCall) -> GateResult:
193
+ if self.registry.get(call.name) is None:
194
+ return self._record(GateResult(Verdict.BLOCK, call, [f"unknown tool: {call.name!r}"]))
195
+
196
+ budget_reason = self._budget_violation(call)
197
+ if budget_reason is not None:
198
+ return self._record(GateResult(Verdict.BLOCK, call, [budget_reason]))
199
+
200
+ schema = self.registry.get_schema(call.name)
201
+ if schema is None:
202
+ if self.default == "deny":
203
+ return self._record(
204
+ GateResult(
205
+ Verdict.BLOCK,
206
+ call,
207
+ [f"no schema registered for {call.name!r} and gate default is deny"],
208
+ )
209
+ )
210
+ else:
211
+ errors = schema.validate(call.args)
212
+ if errors:
213
+ return self._record(GateResult(Verdict.BLOCK, call, errors))
214
+
215
+ policy = self.registry.get_policy(call.name)
216
+ if policy is not None:
217
+ errors = policy.validate(call.args)
218
+ if errors:
219
+ return self._record(GateResult(Verdict.BLOCK, call, errors))
220
+
221
+ findings: list[Finding] = []
222
+ if self.shield is not None:
223
+ if self.shield.mode == "redact":
224
+ call.args, findings = self.shield.redact_args(call.args)
225
+ else:
226
+ findings = self.shield.scan_args(call.args)
227
+ if findings and self.shield.mode == "block":
228
+ reasons = [
229
+ f"secret detected ({f.kind}) in arg {f.arg!r}" for f in findings
230
+ ]
231
+ return self._record(
232
+ GateResult(Verdict.BLOCK, call, reasons, findings=findings)
233
+ )
234
+
235
+ if policy is not None and policy.require_approval:
236
+ return self._record(
237
+ GateResult(
238
+ Verdict.NEEDS_APPROVAL,
239
+ call,
240
+ [f"approval required for {call.name!r}"],
241
+ findings=findings,
242
+ )
243
+ )
244
+
245
+ return self._record(GateResult(Verdict.ALLOW, call, findings=findings))
246
+
247
+ # -- execution ----------------------------------------------------------------
248
+
249
+ def execute(self, result: GateResult) -> GateResult:
250
+ if not result.allowed or result.call is None:
251
+ joined = "; ".join(result.reasons) or "no call"
252
+ raise PermissionError(f"refusing to execute a {result.verdict.value} result: {joined}")
253
+ self._executed_calls += 1
254
+ self._executed_per_tool[result.call.name] = (
255
+ self._executed_per_tool.get(result.call.name, 0) + 1
256
+ )
257
+ if self.dry_run:
258
+ result.dry_run = True
259
+ if self.meter is not None:
260
+ self.meter.record(
261
+ RunEvent(
262
+ lane=self.lane,
263
+ kind="tool",
264
+ ok=True,
265
+ namespace=result.call.name,
266
+ meta={"dry_run": True, "would_execute": True},
267
+ )
268
+ )
269
+ return result
270
+ tool = self.registry.get(result.call.name)
271
+ if tool is None: # registry mutated between check and execute
272
+ result.error = f"unknown tool at execute time: {result.call.name!r}"
273
+ else:
274
+ try:
275
+ result.return_value = tool(**result.call.args)
276
+ result.executed = True
277
+ except Exception as exc:
278
+ result.error = f"{type(exc).__name__}: {exc}"
279
+ if self.meter is not None:
280
+ self.meter.record(
281
+ RunEvent(
282
+ lane=self.lane,
283
+ kind="tool",
284
+ ok=result.executed,
285
+ namespace=result.call.name,
286
+ error=result.error,
287
+ )
288
+ )
289
+ return result
290
+
291
+ def _resolve_approval(self, result: GateResult) -> GateResult:
292
+ """Called by run/run_all on NEEDS_APPROVAL. Fail closed without a handler."""
293
+ if self.approval is None:
294
+ return result
295
+ try:
296
+ granted = bool(self.approval(result))
297
+ except Exception as exc:
298
+ result.verdict = Verdict.BLOCK
299
+ result.reasons.append(f"approval handler raised {type(exc).__name__}: fails closed")
300
+ return self._record_transition(result, "approval-error")
301
+ if granted:
302
+ result.verdict = Verdict.ALLOW
303
+ result.reasons = []
304
+ return self._record_transition(result, "approval-granted")
305
+ result.verdict = Verdict.BLOCK
306
+ result.reasons.append("approval denied")
307
+ return self._record_transition(result, "approval-denied")
308
+
309
+ def run(self, payload: Any) -> GateResult:
310
+ result = self.check(payload)
311
+ if result.verdict is Verdict.NEEDS_APPROVAL:
312
+ result = self._resolve_approval(result)
313
+ if result.allowed:
314
+ return self.execute(result)
315
+ return result
316
+
317
+ def run_all(self, payload: Any) -> list[GateResult]:
318
+ out: list[GateResult] = []
319
+ for result in self.check_all(payload):
320
+ if result.verdict is Verdict.NEEDS_APPROVAL:
321
+ result = self._resolve_approval(result)
322
+ out.append(self.execute(result) if result.allowed else result)
323
+ return out
324
+
325
+ # -- reporting ----------------------------------------------------------------
326
+
327
+ def report(self) -> dict[str, Any]:
328
+ """Summary of everything this gate has seen. Useful after a dry run."""
329
+ by_verdict: dict[str, int] = {}
330
+ by_tool: dict[str, dict[str, int]] = {}
331
+ blocked_reasons: list[str] = []
332
+ finding_kinds: dict[str, int] = {}
333
+ for r in self.history:
334
+ by_verdict[r.verdict.value] = by_verdict.get(r.verdict.value, 0) + 1
335
+ name = r.call.name if r.call else "(unparsed)"
336
+ tool_row = by_tool.setdefault(name, {"allow": 0, "block": 0, "needs_approval": 0})
337
+ tool_row[r.verdict.value] = tool_row.get(r.verdict.value, 0) + 1
338
+ if r.verdict is Verdict.BLOCK and r.reasons:
339
+ blocked_reasons.append(f"{name}: {r.reasons[0]}")
340
+ for f in r.findings:
341
+ finding_kinds[f.kind] = finding_kinds.get(f.kind, 0) + 1
342
+ return {
343
+ "dry_run": self.dry_run,
344
+ "calls_checked": len(self.history),
345
+ "verdicts": by_verdict,
346
+ "would_execute" if self.dry_run else "executed": self._executed_calls,
347
+ "by_tool": by_tool,
348
+ "blocked_reasons": blocked_reasons,
349
+ "secret_findings_by_kind": finding_kinds,
350
+ }
351
+
352
+ # -- internals -------------------------------------------------------------------
353
+
354
+ def _record(self, result: GateResult) -> GateResult:
355
+ self.history.append(result)
356
+ if self.meter is not None:
357
+ self.meter.record_intercept(
358
+ lane=self.lane,
359
+ ok=result.verdict is Verdict.ALLOW,
360
+ namespace=result.call.name if result.call else None,
361
+ error="; ".join(result.reasons) or None,
362
+ meta={
363
+ "verdict": result.verdict.value,
364
+ "default": self.default,
365
+ "findings": [f.kind for f in result.findings],
366
+ },
367
+ )
368
+ return result
369
+
370
+ def _record_transition(self, result: GateResult, event: str) -> GateResult:
371
+ if self.meter is not None:
372
+ self.meter.record(
373
+ RunEvent(
374
+ lane=self.lane,
375
+ kind="note",
376
+ ok=result.verdict is Verdict.ALLOW,
377
+ namespace=result.call.name if result.call else None,
378
+ meta={"approval": event},
379
+ )
380
+ )
381
+ return result
toolwall/intake.py ADDED
@@ -0,0 +1,177 @@
1
+ """Normalize LLM provider tool-call outputs into ToolCall records.
2
+
3
+ Accepted shapes (dicts, or SDK objects exposing model_dump()/to_dict()):
4
+
5
+ - Plain call: {"name"|"tool"|"action": str, "args"|"params"|"arguments"|"input": {...}}
6
+ - OpenAI chat: choices[*].message.tool_calls[*].function{name, arguments(JSON str)}
7
+ - OpenAI responses: output[*] where type == "function_call" {name, arguments(JSON str)}
8
+ - Anthropic: content[*] where type == "tool_use" {name, input}
9
+ - Gemini: candidates[*].content.parts[*].functionCall|function_call {name, args}
10
+
11
+ No provider SDK is imported here — toolwall core stays stdlib-only.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+
21
+ class IntakeError(ValueError):
22
+ """Payload could not be interpreted as tool call(s)."""
23
+
24
+
25
+ @dataclass
26
+ class ToolCall:
27
+ name: str
28
+ args: dict[str, Any] = field(default_factory=dict)
29
+ id: str | None = None
30
+ source: str = "dict"
31
+
32
+
33
+ _NAME_KEYS = ("name", "tool", "tool_name", "action", "namespace")
34
+ _ARG_KEYS = ("args", "params", "arguments", "parameters", "input", "inputs")
35
+
36
+
37
+ def _as_data(obj: Any) -> Any:
38
+ if isinstance(obj, (dict, list)):
39
+ return obj
40
+ for attr in ("model_dump", "to_dict", "to_json_dict"):
41
+ fn = getattr(obj, attr, None)
42
+ if callable(fn):
43
+ try:
44
+ data = fn()
45
+ except TypeError:
46
+ continue
47
+ if isinstance(data, (dict, list)):
48
+ return data
49
+ raise IntakeError(f"unsupported payload type: {type(obj).__name__}")
50
+
51
+
52
+ def _parse_args(value: Any) -> dict[str, Any]:
53
+ if value is None:
54
+ return {}
55
+ if isinstance(value, str):
56
+ try:
57
+ value = json.loads(value or "{}")
58
+ except json.JSONDecodeError as exc:
59
+ raise IntakeError(f"tool arguments are not valid JSON: {exc}") from exc
60
+ if not isinstance(value, dict):
61
+ raise IntakeError(f"tool args must be an object, got {type(value).__name__}")
62
+ return value
63
+
64
+
65
+ def _from_openai_chat(data: dict) -> list[ToolCall] | None:
66
+ choices = data.get("choices")
67
+ if not isinstance(choices, list):
68
+ return None
69
+ calls: list[ToolCall] = []
70
+ for choice in choices:
71
+ message = (choice or {}).get("message") or {}
72
+ for tc in message.get("tool_calls") or []:
73
+ fn = tc.get("function") or {}
74
+ name = fn.get("name")
75
+ if not name:
76
+ raise IntakeError("OpenAI tool_call missing function.name")
77
+ calls.append(
78
+ ToolCall(name=name, args=_parse_args(fn.get("arguments")), id=tc.get("id"), source="openai-chat")
79
+ )
80
+ return calls
81
+
82
+
83
+ def _from_openai_responses(data: dict) -> list[ToolCall] | None:
84
+ output = data.get("output")
85
+ if not isinstance(output, list):
86
+ return None
87
+ calls: list[ToolCall] = []
88
+ for item in output:
89
+ if isinstance(item, dict) and item.get("type") == "function_call":
90
+ name = item.get("name")
91
+ if not name:
92
+ raise IntakeError("OpenAI responses function_call missing name")
93
+ calls.append(
94
+ ToolCall(
95
+ name=name,
96
+ args=_parse_args(item.get("arguments")),
97
+ id=item.get("call_id") or item.get("id"),
98
+ source="openai-responses",
99
+ )
100
+ )
101
+ return calls
102
+
103
+
104
+ def _from_anthropic(data: dict) -> list[ToolCall] | None:
105
+ content = data.get("content")
106
+ if not isinstance(content, list):
107
+ return None
108
+ calls: list[ToolCall] = []
109
+ for block in content:
110
+ if isinstance(block, dict) and block.get("type") == "tool_use":
111
+ name = block.get("name")
112
+ if not name:
113
+ raise IntakeError("Anthropic tool_use block missing name")
114
+ calls.append(
115
+ ToolCall(name=name, args=_parse_args(block.get("input")), id=block.get("id"), source="anthropic")
116
+ )
117
+ return calls
118
+
119
+
120
+ def _from_gemini(data: dict) -> list[ToolCall] | None:
121
+ candidates = data.get("candidates")
122
+ if not isinstance(candidates, list):
123
+ return None
124
+ calls: list[ToolCall] = []
125
+ for cand in candidates:
126
+ parts = ((cand or {}).get("content") or {}).get("parts") or []
127
+ for part in parts:
128
+ if not isinstance(part, dict):
129
+ continue
130
+ fc = part.get("functionCall") or part.get("function_call")
131
+ if fc:
132
+ name = fc.get("name")
133
+ if not name:
134
+ raise IntakeError("Gemini functionCall missing name")
135
+ calls.append(ToolCall(name=name, args=_parse_args(fc.get("args")), source="gemini"))
136
+ return calls
137
+
138
+
139
+ def _from_plain(data: dict) -> ToolCall | None:
140
+ name = next((data[k] for k in _NAME_KEYS if isinstance(data.get(k), str)), None)
141
+ if name is None:
142
+ return None
143
+ raw_args = next((data[k] for k in _ARG_KEYS if k in data), None)
144
+ return ToolCall(name=name, args=_parse_args(raw_args), id=data.get("id"), source="dict")
145
+
146
+
147
+ _ENVELOPE_EXTRACTORS = (_from_openai_chat, _from_openai_responses, _from_anthropic, _from_gemini)
148
+
149
+
150
+ def parse_tool_calls(payload: Any) -> list[ToolCall]:
151
+ """Extract every tool call from a provider response or plain dict.
152
+
153
+ Returns [] when a recognized envelope contains no tool calls
154
+ (e.g. the model answered with plain text). Raises IntakeError when
155
+ the payload shape is not recognizable at all.
156
+ """
157
+ data = _as_data(payload)
158
+
159
+ if isinstance(data, list):
160
+ calls: list[ToolCall] = []
161
+ for item in data:
162
+ calls.extend(parse_tool_calls(item))
163
+ return calls
164
+
165
+ for extract in _ENVELOPE_EXTRACTORS:
166
+ calls = extract(data)
167
+ if calls is not None:
168
+ return calls
169
+
170
+ plain = _from_plain(data)
171
+ if plain is not None:
172
+ return [plain]
173
+
174
+ raise IntakeError(
175
+ "no recognizable tool-call shape (expected OpenAI/Anthropic/Gemini response or "
176
+ f"a dict with one of {_NAME_KEYS})"
177
+ )