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.
- agentgate/__init__.py +5 -0
- agentgate/_json.py +11 -0
- agentgate/_yaml.py +33 -0
- agentgate/agent/__init__.py +1 -0
- agentgate/agent/anthropic_model.py +422 -0
- agentgate/agent/loop.py +276 -0
- agentgate/agent/mock.py +325 -0
- agentgate/agent/model.py +103 -0
- agentgate/approvals.py +554 -0
- agentgate/audit/__init__.py +5 -0
- agentgate/audit/base.py +145 -0
- agentgate/audit/panoptes.py +314 -0
- agentgate/cli.py +1786 -0
- agentgate/clock.py +32 -0
- agentgate/context.py +21 -0
- agentgate/demo.py +899 -0
- agentgate/fixtures.py +419 -0
- agentgate/gate.py +2060 -0
- agentgate/gateway/__init__.py +11 -0
- agentgate/gateway/server.py +278 -0
- agentgate/model/__init__.py +1 -0
- agentgate/model/admission.py +355 -0
- agentgate/model/keys.py +217 -0
- agentgate/model/pricing.py +123 -0
- agentgate/model/proxy.py +435 -0
- agentgate/model/tokens.py +378 -0
- agentgate/models.py +96 -0
- agentgate/policy/__init__.py +1 -0
- agentgate/policy/addresses.py +81 -0
- agentgate/policy/engine.py +343 -0
- agentgate/policy/schema.py +313 -0
- agentgate/policy/testing.py +275 -0
- agentgate/policy/version.py +102 -0
- agentgate/py.typed +0 -0
- agentgate/redteam/__init__.py +41 -0
- agentgate/redteam/baseline.py +133 -0
- agentgate/redteam/bench.py +595 -0
- agentgate/redteam/core.py +939 -0
- agentgate/redteam/files.py +181 -0
- agentgate/redteam/leaks.py +353 -0
- agentgate/redteam/report.py +330 -0
- agentgate/redteam/scenarios/__init__.py +3 -0
- agentgate/redteam/scenarios/approval_bypass.py +534 -0
- agentgate/redteam/scenarios/audit_integrity.py +1417 -0
- agentgate/redteam/scenarios/benign.py +331 -0
- agentgate/redteam/scenarios/cross_tenant.py +677 -0
- agentgate/redteam/scenarios/examples.py +90 -0
- agentgate/redteam/scenarios/fail_closed.py +858 -0
- agentgate/redteam/scenarios/identity_spoofing.py +583 -0
- agentgate/redteam/scenarios/model_boundary.py +1473 -0
- agentgate/redteam/scenarios/privilege_escalation.py +506 -0
- agentgate/redteam/scenarios/prompt_injection.py +740 -0
- agentgate/redteam/scenarios/spend_runaway.py +763 -0
- agentgate/redteam/transport.py +236 -0
- agentgate/retrieval.py +422 -0
- agentgate/spend.py +366 -0
- agentgate/store.py +553 -0
- agentgate/tools/__init__.py +6 -0
- agentgate/tools/base.py +78 -0
- agentgate/tools/builtin.py +507 -0
- agentgate/world.py +758 -0
- panoptes_agentgate-0.0.1.dist-info/METADATA +225 -0
- panoptes_agentgate-0.0.1.dist-info/RECORD +66 -0
- panoptes_agentgate-0.0.1.dist-info/WHEEL +4 -0
- panoptes_agentgate-0.0.1.dist-info/entry_points.txt +2 -0
- panoptes_agentgate-0.0.1.dist-info/licenses/LICENSE +201 -0
agentgate/__init__.py
ADDED
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)
|