panoptes-agentgate 0.0.1__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.
Files changed (66) hide show
  1. agentgate/__init__.py +5 -0
  2. agentgate/_json.py +11 -0
  3. agentgate/_yaml.py +33 -0
  4. agentgate/agent/__init__.py +1 -0
  5. agentgate/agent/anthropic_model.py +422 -0
  6. agentgate/agent/loop.py +276 -0
  7. agentgate/agent/mock.py +325 -0
  8. agentgate/agent/model.py +103 -0
  9. agentgate/approvals.py +554 -0
  10. agentgate/audit/__init__.py +5 -0
  11. agentgate/audit/base.py +145 -0
  12. agentgate/audit/panoptes.py +314 -0
  13. agentgate/cli.py +1786 -0
  14. agentgate/clock.py +32 -0
  15. agentgate/context.py +21 -0
  16. agentgate/demo.py +899 -0
  17. agentgate/fixtures.py +419 -0
  18. agentgate/gate.py +2060 -0
  19. agentgate/gateway/__init__.py +11 -0
  20. agentgate/gateway/server.py +278 -0
  21. agentgate/model/__init__.py +1 -0
  22. agentgate/model/admission.py +355 -0
  23. agentgate/model/keys.py +217 -0
  24. agentgate/model/pricing.py +123 -0
  25. agentgate/model/proxy.py +435 -0
  26. agentgate/model/tokens.py +378 -0
  27. agentgate/models.py +96 -0
  28. agentgate/policy/__init__.py +1 -0
  29. agentgate/policy/addresses.py +81 -0
  30. agentgate/policy/engine.py +343 -0
  31. agentgate/policy/schema.py +313 -0
  32. agentgate/policy/testing.py +275 -0
  33. agentgate/policy/version.py +102 -0
  34. agentgate/py.typed +0 -0
  35. agentgate/redteam/__init__.py +41 -0
  36. agentgate/redteam/baseline.py +133 -0
  37. agentgate/redteam/bench.py +595 -0
  38. agentgate/redteam/core.py +939 -0
  39. agentgate/redteam/files.py +181 -0
  40. agentgate/redteam/leaks.py +353 -0
  41. agentgate/redteam/report.py +330 -0
  42. agentgate/redteam/scenarios/__init__.py +3 -0
  43. agentgate/redteam/scenarios/approval_bypass.py +534 -0
  44. agentgate/redteam/scenarios/audit_integrity.py +1417 -0
  45. agentgate/redteam/scenarios/benign.py +331 -0
  46. agentgate/redteam/scenarios/cross_tenant.py +677 -0
  47. agentgate/redteam/scenarios/examples.py +90 -0
  48. agentgate/redteam/scenarios/fail_closed.py +858 -0
  49. agentgate/redteam/scenarios/identity_spoofing.py +583 -0
  50. agentgate/redteam/scenarios/model_boundary.py +1473 -0
  51. agentgate/redteam/scenarios/privilege_escalation.py +506 -0
  52. agentgate/redteam/scenarios/prompt_injection.py +740 -0
  53. agentgate/redteam/scenarios/spend_runaway.py +763 -0
  54. agentgate/redteam/transport.py +236 -0
  55. agentgate/retrieval.py +422 -0
  56. agentgate/spend.py +366 -0
  57. agentgate/store.py +553 -0
  58. agentgate/tools/__init__.py +6 -0
  59. agentgate/tools/base.py +78 -0
  60. agentgate/tools/builtin.py +507 -0
  61. agentgate/world.py +758 -0
  62. panoptes_agentgate-0.0.1.dist-info/METADATA +225 -0
  63. panoptes_agentgate-0.0.1.dist-info/RECORD +66 -0
  64. panoptes_agentgate-0.0.1.dist-info/WHEEL +4 -0
  65. panoptes_agentgate-0.0.1.dist-info/entry_points.txt +2 -0
  66. panoptes_agentgate-0.0.1.dist-info/licenses/LICENSE +201 -0
agentgate/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """agentgate: decides what each agent run can read, write, spend and send."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("panoptes-agentgate")
agentgate/_json.py ADDED
@@ -0,0 +1,11 @@
1
+ """Strict JSON parsing shared by the key and rate-table loaders."""
2
+
3
+
4
+ def no_duplicate_keys(pairs: list[tuple[str, object]]) -> dict[str, object]:
5
+ """An object_pairs_hook that rejects a repeated key instead of keeping the last one."""
6
+ result: dict[str, object] = {}
7
+ for name, value in pairs:
8
+ if name in result:
9
+ raise ValueError("duplicate key")
10
+ result[name] = value
11
+ return result
agentgate/_yaml.py ADDED
@@ -0,0 +1,33 @@
1
+ """Strict YAML parsing shared by the fixture and policy loaders."""
2
+
3
+ from collections.abc import Hashable
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import yaml
8
+
9
+
10
+ class _UniqueKeyLoader(yaml.SafeLoader):
11
+ """SafeLoader that rejects duplicate mapping keys instead of keeping the last one."""
12
+
13
+ def construct_mapping(self, node: yaml.MappingNode, deep: bool = False) -> dict[Hashable, Any]:
14
+ seen: set[object] = set()
15
+ for key_node, _ in node.value:
16
+ key = self.construct_object(key_node, deep=deep)
17
+ if not isinstance(key, Hashable):
18
+ break # SafeConstructor reports unhashable keys itself.
19
+ if key in seen:
20
+ raise yaml.constructor.ConstructorError(
21
+ None, None, f"duplicate key {key!r}", key_node.start_mark
22
+ )
23
+ seen.add(key)
24
+ return super().construct_mapping(node, deep=deep)
25
+
26
+
27
+ def parse_yaml(text: str, path: Path, error: type[Exception]) -> Any:
28
+ """Parse text as YAML, raising `error` with the file path on any problem."""
29
+ try:
30
+ # _UniqueKeyLoader is a SafeLoader, so tags cannot build arbitrary Python objects.
31
+ return yaml.load(text, Loader=_UniqueKeyLoader)
32
+ except yaml.YAMLError as exc:
33
+ raise error(f"{path}: invalid YAML: {exc}") from exc
@@ -0,0 +1 @@
1
+ """A small reference agent that runs every tool call through the gate."""
@@ -0,0 +1,422 @@
1
+ """An optional real model: Claude through the official Anthropic Python SDK.
2
+
3
+ Nothing in agentgate needs this module. The demo, the red-team suite and CI run on the mock
4
+ models; this adapter is used only when AGENTGATE_MODEL=anthropic and the `anthropic` extra is
5
+ installed. The client is created on the first call, so constructing the adapter needs no key
6
+ and never touches the network.
7
+
8
+ The adapter only translates. Tool names are mapped to the provider's allowed alphabet
9
+ (`docs.search` becomes `docs_search`) and back; a name that does not map back is passed
10
+ through unchanged, so the gate denies it as an unknown tool. Tool schemas are sent without
11
+ `strict`, because the gate validates every argument itself and refuses what does not match.
12
+ Assistant turns keep the provider's content as `native` and send it back unchanged, since it
13
+ can carry thinking blocks the API expects to see again.
14
+
15
+ Errors from the SDK become ModelError carrying the exception class name and HTTP status, never
16
+ the request or response body, so a failed call cannot copy document text into logs.
17
+ """
18
+
19
+ import json
20
+ import math
21
+ import os
22
+ import re
23
+ from collections.abc import Mapping, Sequence
24
+ from dataclasses import dataclass
25
+ from decimal import Decimal
26
+ from typing import Any
27
+
28
+ from pydantic import JsonValue, TypeAdapter, ValidationError
29
+
30
+ try:
31
+ import anthropic
32
+ from anthropic.types import Message as SdkMessage
33
+ from anthropic.types import TextBlock, ToolUseBlock
34
+ from anthropic.types.beta import (
35
+ BetaFallbackMessageIterationUsage,
36
+ BetaMessage,
37
+ BetaMessageIterationUsage,
38
+ BetaTextBlock,
39
+ BetaToolUseBlock,
40
+ )
41
+ except ImportError as exc: # pragma: no cover - exercised only without the extra
42
+ raise ImportError(
43
+ "the Anthropic model needs the optional extra: pip install 'agentgate[anthropic]'"
44
+ ) from exc
45
+
46
+ from agentgate.agent.model import (
47
+ AssistantMessage,
48
+ Message,
49
+ ModelError,
50
+ ModelResponse,
51
+ ToolCall,
52
+ ToolResultsMessage,
53
+ ToolSpec,
54
+ Usage,
55
+ UserMessage,
56
+ )
57
+
58
+ DEFAULT_MODEL = "claude-opus-5-5"
59
+ MODEL_ENV = "AGENTGATE_ANTHROPIC_MODEL"
60
+
61
+ EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
62
+
63
+ # Server-side refusal fallbacks: on a policy decline the API reruns the request on the model
64
+ # Anthropic recommends for that refusal category, inside the same call.
65
+ FALLBACK_BETA = "server-side-fallback-2026-07-01"
66
+
67
+ _ERROR_STATUSES = frozenset({"denied", "error", "cap_reached"})
68
+ _TOOL_NAME = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
69
+ _INVALID_NAME_CHARS = re.compile(r"[^a-zA-Z0-9_-]")
70
+ _MILLION = Decimal(1_000_000)
71
+ # Writes to the 5-minute prompt cache cost 1.25 times the base input price.
72
+ _CACHE_WRITE_MULTIPLIER = Decimal("1.25")
73
+ _ARGS = TypeAdapter(dict[str, JsonValue])
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class Price:
78
+ """US dollars per million tokens."""
79
+
80
+ input: Decimal
81
+ output: Decimal
82
+ cache_read: Decimal
83
+
84
+ @property
85
+ def cache_write(self) -> Decimal:
86
+ return self.input * _CACHE_WRITE_MULTIPLIER
87
+
88
+
89
+ # The models this adapter can be configured with.
90
+ PRICES: Mapping[str, Price] = {
91
+ "claude-opus-5-5": Price(Decimal("4"), Decimal("20"), Decimal("0.20")),
92
+ "claude-sonnet-5-5": Price(Decimal("2"), Decimal("10"), Decimal("0.20")),
93
+ # Cache read assumed, not published here: 0.1x input for cache reads.
94
+ "claude-haiku-5-5": Price(Decimal("0.10"), Decimal("0.50"), Decimal("0.01")),
95
+ }
96
+
97
+ # Claude Haiku 5.5 charges more once the prompt is over 100K tokens.
98
+ _LONG_PROMPT_TOKENS = 100_000
99
+ _LONG_PROMPT_PRICES: Mapping[str, Price] = {
100
+ # Input and output are the published above-100K rates. Cache read assumed: 0.1x input
101
+ # for cache reads.
102
+ "claude-haiku-5-5": Price(Decimal("0.50"), Decimal("2.50"), Decimal("0.05")),
103
+ }
104
+
105
+ # Models a server-side fallback can serve from, priced so a fallback-served response is
106
+ # charged at the rate it was billed at. Claude Haiku 5.5 has no server-side fallback.
107
+ FALLBACK_TARGETS: Mapping[str, tuple[str, ...]] = {
108
+ "claude-opus-5-5": ("claude-opus-5", "claude-opus-4-8"),
109
+ "claude-sonnet-5-5": ("claude-sonnet-5",),
110
+ }
111
+ # Input and output are the published rates; every cache read here is assumed: 0.1x input
112
+ # for cache reads.
113
+ _FALLBACK_PRICES: Mapping[str, Price] = {
114
+ "claude-opus-5": Price(Decimal("5"), Decimal("25"), Decimal("0.50")), # cache read assumed
115
+ "claude-opus-4-8": Price(Decimal("5"), Decimal("25"), Decimal("0.50")), # cache read assumed
116
+ "claude-sonnet-5": Price(Decimal("2"), Decimal("10"), Decimal("0.20")), # cache read assumed
117
+ }
118
+
119
+ _KNOWN_PRICES = (*PRICES.values(), *_LONG_PROMPT_PRICES.values(), *_FALLBACK_PRICES.values())
120
+ # Used for a model this table does not know, so an unexpected model is never under-charged.
121
+ _CEILING_PRICE = Price(
122
+ max(p.input for p in _KNOWN_PRICES),
123
+ max(p.output for p in _KNOWN_PRICES),
124
+ max(p.cache_read for p in _KNOWN_PRICES),
125
+ )
126
+
127
+
128
+ def wire_name(tool: str) -> str:
129
+ """The provider-side name for a tool id: `docs.search` becomes `docs_search`."""
130
+ name = _INVALID_NAME_CHARS.sub("_", tool)[:64]
131
+ if not _TOOL_NAME.fullmatch(name):
132
+ raise ModelError("a tool id cannot be mapped to a provider tool name")
133
+ return name
134
+
135
+
136
+ def _price(model: str, prompt_tokens: int) -> Price:
137
+ if prompt_tokens > _LONG_PROMPT_TOKENS and model in _LONG_PROMPT_PRICES:
138
+ return _LONG_PROMPT_PRICES[model]
139
+ return PRICES.get(model) or _FALLBACK_PRICES.get(model) or _CEILING_PRICE
140
+
141
+
142
+ @dataclass(frozen=True)
143
+ class _Tokens:
144
+ model: str
145
+ input: int
146
+ cache_write: int
147
+ cache_read: int
148
+ output: int
149
+
150
+ @property
151
+ def prompt(self) -> int:
152
+ return self.input + self.cache_write + self.cache_read
153
+
154
+ def cost(self) -> Decimal:
155
+ price = _price(self.model, self.prompt)
156
+ dollars = (
157
+ self.input * price.input
158
+ + self.cache_write * price.cache_write
159
+ + self.cache_read * price.cache_read
160
+ + self.output * price.output
161
+ )
162
+ return dollars / _MILLION
163
+
164
+
165
+ class AnthropicModel:
166
+ """Claude as an agentgate Model. One request per `complete`, no streaming, no retries
167
+ beyond the SDK's own."""
168
+
169
+ def __init__(
170
+ self,
171
+ *,
172
+ model: str = DEFAULT_MODEL,
173
+ client: Any | None = None,
174
+ max_tokens: int = 2048,
175
+ effort: str = "medium",
176
+ fallbacks: bool = True,
177
+ cache: bool = True,
178
+ ) -> None:
179
+ if model not in PRICES:
180
+ raise ValueError(f"unknown Anthropic model {model!r}; known: {sorted(PRICES)}")
181
+ if effort not in EFFORTS:
182
+ raise ValueError(f"unknown effort {effort!r}; known: {sorted(EFFORTS)}")
183
+ if max_tokens < 1:
184
+ raise ValueError("max_tokens must be positive")
185
+ self._model = model
186
+ self._client = client
187
+ self._max_tokens = max_tokens
188
+ self._effort = effort
189
+ # A model with no server-side fallback (Claude Haiku 5.5) never sends the parameter.
190
+ self._fallbacks = fallbacks and model in FALLBACK_TARGETS
191
+ self._cache = cache
192
+
193
+ @classmethod
194
+ def from_env(cls, *, client: Any | None = None) -> "AnthropicModel":
195
+ """Read the model id from AGENTGATE_ANTHROPIC_MODEL. Credentials stay with the SDK."""
196
+ return cls(model=os.environ.get(MODEL_ENV) or DEFAULT_MODEL, client=client)
197
+
198
+ @property
199
+ def name(self) -> str:
200
+ return self._model
201
+
202
+ def estimate_cost(
203
+ self, system: str, messages: Sequence[Message], tools: Sequence[ToolSpec]
204
+ ) -> Decimal:
205
+ # Meant to over-reserve against spend caps: about three characters per token is
206
+ # more tokens than the tokenizer produces for ordinary text, the whole request JSON
207
+ # is counted, every input token is priced as a cache write (the dearest input rate),
208
+ # and the full max_tokens of output is assumed. With fallbacks on, a declined hop
209
+ # and the fallback hop that serves the response are both reserved.
210
+ request = self._request(system, messages, tools)
211
+ chars = len(json.dumps(request, default=_dump, sort_keys=True))
212
+ input_tokens = math.ceil(chars / 3)
213
+ models = [self._model]
214
+ if self._fallbacks:
215
+ models.append(max(FALLBACK_TARGETS[self._model], key=_hop_ceiling))
216
+ total = Decimal(0)
217
+ for model in models:
218
+ price = _price(model, input_tokens)
219
+ total += (
220
+ input_tokens * max(price.input, price.cache_write) + self._max_tokens * price.output
221
+ )
222
+ return total / _MILLION
223
+
224
+ def complete(
225
+ self, system: str, messages: Sequence[Message], tools: Sequence[ToolSpec]
226
+ ) -> ModelResponse:
227
+ request = self._request(system, messages, tools)
228
+ wire_to_tool = _tool_names(tools)
229
+ response = self._send(request)
230
+ if not isinstance(response, SdkMessage | BetaMessage):
231
+ raise ModelError(f"unexpected response type {type(response).__name__}")
232
+ tokens = self._tokens(response)
233
+ usage = Usage(
234
+ input_tokens=sum(t.prompt for t in tokens),
235
+ output_tokens=sum(t.output for t in tokens),
236
+ )
237
+ cost = sum((t.cost() for t in tokens), Decimal(0))
238
+ stop_reason = response.stop_reason or "end_turn"
239
+ return ModelResponse(
240
+ message=_parse(response, wire_to_tool),
241
+ usage=usage,
242
+ cost_usd=cost,
243
+ stop_reason=stop_reason,
244
+ )
245
+
246
+ def _request(
247
+ self, system: str, messages: Sequence[Message], tools: Sequence[ToolSpec]
248
+ ) -> dict[str, Any]:
249
+ request: dict[str, Any] = {
250
+ "model": self._model,
251
+ "max_tokens": self._max_tokens,
252
+ "messages": _messages(messages),
253
+ # Thinking is always on for Claude Opus 5.5 and cannot be disabled; effort is the
254
+ # only depth control, and its default differs by model, so it is always explicit.
255
+ "output_config": {"effort": self._effort},
256
+ }
257
+ if system:
258
+ request["system"] = system
259
+ if tools:
260
+ _tool_names(tools)
261
+ request["tools"] = [
262
+ {
263
+ "name": wire_name(spec.tool),
264
+ "description": spec.description,
265
+ "input_schema": spec.input_schema,
266
+ }
267
+ for spec in tools
268
+ ]
269
+ if self._cache:
270
+ # Caches the stable prefix: tools, system and the conversation so far.
271
+ request["cache_control"] = {"type": "ephemeral"}
272
+ return request
273
+
274
+ def _send(self, request: dict[str, Any]) -> object:
275
+ try:
276
+ if self._client is None:
277
+ self._client = anthropic.Anthropic()
278
+ if self._fallbacks:
279
+ return self._client.beta.messages.create(
280
+ **request, betas=[FALLBACK_BETA], fallbacks="default"
281
+ )
282
+ return self._client.messages.create(**request)
283
+ except anthropic.RateLimitError as exc:
284
+ raise ModelError(_describe(exc, exc.status_code)) from None
285
+ except anthropic.APIStatusError as exc:
286
+ raise ModelError(_describe(exc, exc.status_code)) from None
287
+ except anthropic.APITimeoutError as exc:
288
+ raise ModelError(_describe(exc, None)) from None
289
+ except anthropic.APIConnectionError as exc:
290
+ raise ModelError(_describe(exc, None)) from None
291
+ except anthropic.AnthropicError as exc:
292
+ raise ModelError(_describe(exc, None)) from None
293
+ except TypeError as exc:
294
+ # The SDK raises TypeError when it finds no credentials at request time.
295
+ raise ModelError(_describe(exc, None) + "; check Anthropic credentials") from None
296
+
297
+ def _tokens(self, response: SdkMessage | BetaMessage) -> list[_Tokens]:
298
+ usage = response.usage
299
+ iterations = getattr(usage, "iterations", None) or ()
300
+ hops = [
301
+ it
302
+ for it in iterations
303
+ if isinstance(it, BetaMessageIterationUsage | BetaFallbackMessageIterationUsage)
304
+ ]
305
+ if hops:
306
+ # A fallback-served response reports each hop with the model that ran it.
307
+ return [
308
+ _Tokens(
309
+ model=hop.model or self._model,
310
+ input=hop.input_tokens,
311
+ cache_write=hop.cache_creation_input_tokens,
312
+ cache_read=hop.cache_read_input_tokens,
313
+ output=hop.output_tokens,
314
+ )
315
+ for hop in hops
316
+ ]
317
+ return [
318
+ _Tokens(
319
+ model=response.model,
320
+ input=usage.input_tokens,
321
+ cache_write=usage.cache_creation_input_tokens or 0,
322
+ cache_read=usage.cache_read_input_tokens or 0,
323
+ output=usage.output_tokens,
324
+ )
325
+ ]
326
+
327
+
328
+ def _hop_ceiling(model: str) -> tuple[Decimal, Decimal]:
329
+ price = _price(model, 0)
330
+ return price.output, price.input
331
+
332
+
333
+ def _tool_names(tools: Sequence[ToolSpec]) -> dict[str, str]:
334
+ wire_to_tool: dict[str, str] = {}
335
+ for spec in tools:
336
+ name = wire_name(spec.tool)
337
+ if wire_to_tool.setdefault(name, spec.tool) != spec.tool:
338
+ raise ModelError("two tool ids map to the same provider tool name")
339
+ return wire_to_tool
340
+
341
+
342
+ def _messages(messages: Sequence[Message]) -> list[dict[str, Any]]:
343
+ converted: list[dict[str, Any]] = []
344
+ for message in messages:
345
+ match message:
346
+ case UserMessage(text=text):
347
+ converted.append({"role": "user", "content": text})
348
+ case AssistantMessage():
349
+ content = _assistant_content(message)
350
+ if content:
351
+ converted.append({"role": "assistant", "content": content})
352
+ case ToolResultsMessage(results=results):
353
+ if results:
354
+ converted.append(
355
+ {
356
+ "role": "user",
357
+ "content": [
358
+ {
359
+ "type": "tool_result",
360
+ "tool_use_id": outcome.call_id,
361
+ "content": json.dumps(outcome.content, sort_keys=True),
362
+ "is_error": outcome.status in _ERROR_STATUSES,
363
+ }
364
+ for outcome in results
365
+ ],
366
+ }
367
+ )
368
+ return converted
369
+
370
+
371
+ def _assistant_content(message: AssistantMessage) -> list[Any]:
372
+ if isinstance(message.native, list) and message.native:
373
+ # Sent back exactly as received: it can hold thinking blocks the API checks.
374
+ return message.native
375
+ content: list[Any] = []
376
+ if message.text:
377
+ content.append({"type": "text", "text": message.text})
378
+ content.extend(
379
+ {"type": "tool_use", "id": call.call_id, "name": wire_name(call.tool), "input": call.args}
380
+ for call in message.tool_calls
381
+ )
382
+ return content
383
+
384
+
385
+ def _parse(response: SdkMessage | BetaMessage, wire_to_tool: Mapping[str, str]) -> AssistantMessage:
386
+ if response.stop_reason == "refusal":
387
+ details = response.stop_details
388
+ category = (details.category if details else None) or "unspecified"
389
+ return AssistantMessage(text=f"The model declined to respond (refusal: {category}).")
390
+ texts: list[str] = []
391
+ calls: list[ToolCall] = []
392
+ for block in response.content:
393
+ if isinstance(block, TextBlock | BetaTextBlock):
394
+ if block.text:
395
+ texts.append(block.text)
396
+ elif isinstance(block, ToolUseBlock | BetaToolUseBlock):
397
+ try:
398
+ args = _ARGS.validate_python(block.input)
399
+ except ValidationError:
400
+ raise ModelError("a tool call's input is not a JSON object") from None
401
+ calls.append(
402
+ ToolCall(call_id=block.id, tool=wire_to_tool.get(block.name, block.name), args=args)
403
+ )
404
+ text = "\n".join(texts) or None
405
+ if response.stop_reason == "max_tokens":
406
+ # Output was cut off: the last tool call may be incomplete and a partial plan should
407
+ # not run, so every call is dropped. The content is not kept as native either, since
408
+ # tool_use blocks sent back without results would be rejected.
409
+ return AssistantMessage(text=text)
410
+ return AssistantMessage(text=text, tool_calls=tuple(calls), native=response.content)
411
+
412
+
413
+ def _describe(exc: BaseException, status: int | None) -> str:
414
+ suffix = f" (status {status})" if status is not None else ""
415
+ return f"anthropic request failed: {type(exc).__name__}{suffix}"
416
+
417
+
418
+ def _dump(value: object) -> object:
419
+ model_dump = getattr(value, "model_dump", None)
420
+ if callable(model_dump):
421
+ return model_dump(mode="json")
422
+ return str(value)