ctf-flightdeck 0.1.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.
- ctf_flightdeck/__init__.py +8 -0
- ctf_flightdeck/__main__.py +3 -0
- ctf_flightdeck/agent/__init__.py +1 -0
- ctf_flightdeck/agent/anthropic_client.py +631 -0
- ctf_flightdeck/agent/client.py +578 -0
- ctf_flightdeck/agent/driver.py +876 -0
- ctf_flightdeck/agent/external.py +2319 -0
- ctf_flightdeck/agent/fallback.py +207 -0
- ctf_flightdeck/agent/fastmode.py +105 -0
- ctf_flightdeck/agent/l1stage.py +107 -0
- ctf_flightdeck/agent/prompts.py +180 -0
- ctf_flightdeck/agent/tools.py +724 -0
- ctf_flightdeck/agent/workspace.py +1207 -0
- ctf_flightdeck/apicheck.py +410 -0
- ctf_flightdeck/build.py +855 -0
- ctf_flightdeck/build_cli.py +154 -0
- ctf_flightdeck/cli.py +741 -0
- ctf_flightdeck/clock.py +16 -0
- ctf_flightdeck/config.py +896 -0
- ctf_flightdeck/console/__init__.py +2 -0
- ctf_flightdeck/console/app.py +604 -0
- ctf_flightdeck/console/commands.py +128 -0
- ctf_flightdeck/console/fleet.py +539 -0
- ctf_flightdeck/console/focus.py +243 -0
- ctf_flightdeck/console/room.py +85 -0
- ctf_flightdeck/console/screens.py +369 -0
- ctf_flightdeck/console/takeover.py +54 -0
- ctf_flightdeck/console/terminal.py +65 -0
- ctf_flightdeck/console/theme.py +64 -0
- ctf_flightdeck/console/views.py +876 -0
- ctf_flightdeck/console/widgets.py +184 -0
- ctf_flightdeck/core/__init__.py +3 -0
- ctf_flightdeck/core/attention.py +95 -0
- ctf_flightdeck/core/deck.py +1620 -0
- ctf_flightdeck/core/driver.py +128 -0
- ctf_flightdeck/core/interventions.py +131 -0
- ctf_flightdeck/core/model.py +370 -0
- ctf_flightdeck/core/summary.py +364 -0
- ctf_flightdeck/credentials.py +217 -0
- ctf_flightdeck/detect/__init__.py +61 -0
- ctf_flightdeck/detect/base.py +137 -0
- ctf_flightdeck/detect/detectors.py +545 -0
- ctf_flightdeck/detect/hints.py +351 -0
- ctf_flightdeck/detect/hints.toml +334 -0
- ctf_flightdeck/detect/judge.py +184 -0
- ctf_flightdeck/detect/watch.py +256 -0
- ctf_flightdeck/eventlog.py +174 -0
- ctf_flightdeck/flagformat.py +101 -0
- ctf_flightdeck/fmt.py +40 -0
- ctf_flightdeck/gaps.py +582 -0
- ctf_flightdeck/gaps_cli.py +128 -0
- ctf_flightdeck/hardware.py +477 -0
- ctf_flightdeck/keys.py +102 -0
- ctf_flightdeck/l1/__init__.py +17 -0
- ctf_flightdeck/l1/_boot.py +128 -0
- ctf_flightdeck/l1/_capture.py +125 -0
- ctf_flightdeck/l1/cli.py +297 -0
- ctf_flightdeck/l1/container.py +202 -0
- ctf_flightdeck/l1/importer.py +349 -0
- ctf_flightdeck/l1/manifest.py +509 -0
- ctf_flightdeck/l1/result.py +259 -0
- ctf_flightdeck/l1/router.py +243 -0
- ctf_flightdeck/l1/runner.py +705 -0
- ctf_flightdeck/lane/Dockerfile +15 -0
- ctf_flightdeck/lane/__init__.py +28 -0
- ctf_flightdeck/leftovers.py +1047 -0
- ctf_flightdeck/light.py +1041 -0
- ctf_flightdeck/localmodel.py +575 -0
- ctf_flightdeck/localmodel_cases.jsonl +58 -0
- ctf_flightdeck/localmodel_cli.py +521 -0
- ctf_flightdeck/mock/__init__.py +1 -0
- ctf_flightdeck/mock/scenarios.py +1054 -0
- ctf_flightdeck/mock/screenshots.py +229 -0
- ctf_flightdeck/mock/script.py +242 -0
- ctf_flightdeck/mock/world.py +97 -0
- ctf_flightdeck/model_catalog.py +640 -0
- ctf_flightdeck/models.toml +229 -0
- ctf_flightdeck/models_cli.py +485 -0
- ctf_flightdeck/needle.py +156 -0
- ctf_flightdeck/platform/__init__.py +9 -0
- ctf_flightdeck/platform/ctfd.py +1808 -0
- ctf_flightdeck/platform/ctfd_cli.py +114 -0
- ctf_flightdeck/platform/manual.py +545 -0
- ctf_flightdeck/postflight.py +1515 -0
- ctf_flightdeck/postflight_cli.py +97 -0
- ctf_flightdeck/practice/README.md +86 -0
- ctf_flightdeck/practice/answers.sha256.json +16 -0
- ctf_flightdeck/practice/board.json +4 -0
- ctf_flightdeck/practice/crab-walk/challenge.json +11 -0
- ctf_flightdeck/practice/crab-walk/postcard.txt +8 -0
- ctf_flightdeck/practice/fine-print/challenge.json +11 -0
- ctf_flightdeck/practice/fine-print/harbour_dawn.png +0 -0
- ctf_flightdeck/practice/half-and-half/challenge.json +11 -0
- ctf_flightdeck/practice/half-and-half/halves.txt +6 -0
- ctf_flightdeck/practice/harbour-memo/challenge.json +11 -0
- ctf_flightdeck/practice/harbour-memo/memo.enc +3 -0
- ctf_flightdeck/practice/mille-feuille/challenge.json +11 -0
- ctf_flightdeck/practice/mille-feuille/layers.txt +1 -0
- ctf_flightdeck/practice/nesting-dolls/challenge.json +11 -0
- ctf_flightdeck/practice/nesting-dolls/dolls.zip +0 -0
- ctf_flightdeck/practice/pancake-stack/challenge.json +12 -0
- ctf_flightdeck/practice/pancake-stack/program.bin +0 -0
- ctf_flightdeck/practice/pancake-stack/vm.py +85 -0
- ctf_flightdeck/practice/postmarked/challenge.json +12 -0
- ctf_flightdeck/practice/postmarked/envelope.txt +7 -0
- ctf_flightdeck/practice/postmarked/letter.txt +9 -0
- ctf_flightdeck/practice/quiet-night/access_log +497 -0
- ctf_flightdeck/practice/quiet-night/challenge.json +11 -0
- ctf_flightdeck/practice/signal-lamp/challenge.json +11 -0
- ctf_flightdeck/practice/signal-lamp/lamp_log.csv +99 -0
- ctf_flightdeck/practice/three-wishes/challenge.json +12 -0
- ctf_flightdeck/practice/three-wishes/encrypt.py +44 -0
- ctf_flightdeck/practice/three-wishes/output.txt +3 -0
- ctf_flightdeck/practice/turnstile/challenge.json +11 -0
- ctf_flightdeck/practice/turnstile/turnstile.py +35 -0
- ctf_flightdeck/practice/welcome-mat/challenge.json +13 -0
- ctf_flightdeck/practice/welcome-mat/index.html +22 -0
- ctf_flightdeck/practice/welcome-mat/portal.js +42 -0
- ctf_flightdeck/practice/welcome-mat/style.css +5 -0
- ctf_flightdeck/practice/wrong-jar/challenge.json +11 -0
- ctf_flightdeck/practice/wrong-jar/minutes.pdf +0 -0
- ctf_flightdeck/practice_cli.py +115 -0
- ctf_flightdeck/practice_mode.py +1119 -0
- ctf_flightdeck/practice_set.py +16 -0
- ctf_flightdeck/preflight.py +853 -0
- ctf_flightdeck/preflight_cli.py +131 -0
- ctf_flightdeck/pricing.py +42 -0
- ctf_flightdeck/run.py +1296 -0
- ctf_flightdeck/runrecord.py +514 -0
- ctf_flightdeck/setup.py +868 -0
- ctf_flightdeck/team.py +892 -0
- ctf_flightdeck/team_cli.py +69 -0
- ctf_flightdeck/text.py +71 -0
- ctf_flightdeck/transcript.py +104 -0
- ctf_flightdeck/writeup.py +1136 -0
- ctf_flightdeck/writeup_cli.py +256 -0
- ctf_flightdeck-0.1.0.dist-info/METADATA +307 -0
- ctf_flightdeck-0.1.0.dist-info/RECORD +141 -0
- ctf_flightdeck-0.1.0.dist-info/WHEEL +4 -0
- ctf_flightdeck-0.1.0.dist-info/entry_points.txt +5 -0
- ctf_flightdeck-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""ctf-flightdeck: a terminal harness for playing CTFs with AI agents."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("ctf-flightdeck") # pyproject.toml's version, as installed: one place to bump
|
|
7
|
+
except PackageNotFoundError: # the source tree imported without an install: say so rather than guess
|
|
8
|
+
__version__ = "unknown (not installed)"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent lanes: the model API client, lane workspaces and tools, and the driver that runs them."""
|
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
"""The Anthropic Messages API client: ModelClient's chat(), for Claude models.
|
|
2
|
+
|
|
3
|
+
A lane's driver keeps its conversation as OpenAI-style message dicts (agent/client.py). This
|
|
4
|
+
client translates both ways and calls the API through the official `anthropic` SDK:
|
|
5
|
+
|
|
6
|
+
- The leading system message becomes `system`: a text block with a cache breakpoint. Tools
|
|
7
|
+
render before the system prompt and both are the same every turn, so that one breakpoint
|
|
8
|
+
caches tools and system together; top-level automatic caching covers the growing tail.
|
|
9
|
+
- An assistant message this client made carries the response's raw content blocks under
|
|
10
|
+
CONTENT_KEY ("_anthropic_content"), and they go back exactly as received: thinking blocks
|
|
11
|
+
must come back unchanged, and so must the tool_use blocks the tool results answer. An
|
|
12
|
+
assistant message without them (an OpenAI-style API made it) becomes a text block and
|
|
13
|
+
tool_use blocks. An assistant message with no blocks at all is left out.
|
|
14
|
+
- Consecutive "tool" messages become one user message of tool_result blocks, and a user
|
|
15
|
+
note after them joins that message as a text block: consecutive messages of one role are
|
|
16
|
+
merged, so the request alternates user and assistant.
|
|
17
|
+
- Tools: an OpenAI function schema becomes name, description and input_schema.
|
|
18
|
+
|
|
19
|
+
The reply comes back in ModelClient's terms: the assistant message for the history (its
|
|
20
|
+
OpenAI-style content and tool_calls, so an OpenAI-style API can take the context over, and
|
|
21
|
+
the raw blocks under CONTENT_KEY), the text, the tool calls with their input as JSON text,
|
|
22
|
+
the stop reason, the usage (prompt tokens = input + cache read + cache write, cached tokens
|
|
23
|
+
= cache read, no cost: flightdeck prices it from the config) and the model that answered.
|
|
24
|
+
|
|
25
|
+
What is sent, as Anthropic's documentation says for these models: no `thinking` (left out, they
|
|
26
|
+
think adaptively), no `temperature` (they reject it, and SDK 2 has no such argument), no
|
|
27
|
+
`tool_choice` (forcing a tool is a 400; auto is the default). A model in
|
|
28
|
+
SERVER_FALLBACK_MODELS (Claude Sonnet 5.5 and Opus 5.5 among them) carries the server-side
|
|
29
|
+
refusal fallback (beta server-side-fallback-2026-07-01, fallbacks "default"): a request its
|
|
30
|
+
classifiers decline runs again, inside the same call, on the model Anthropic recommends for
|
|
31
|
+
that category. A final stop_reason "refusal" means the whole chain declined: a ModelError,
|
|
32
|
+
declined and never retried, naming the category. Claude Haiku 5.5 has no server-side
|
|
33
|
+
fallback, so its requests carry none. A client made with server_fallback=False sends none at
|
|
34
|
+
all (the light role: a refusal stays a refusal, and the player sees it).
|
|
35
|
+
|
|
36
|
+
Fast mode (`fast`, from [api] fast; agent/fastmode.py has the facts). A model listed there is
|
|
37
|
+
called with `speed: "fast"`, plus the beta flag fast-mode-2026-02-01 for a research-preview
|
|
38
|
+
model. Fast mode has its own rate limit: a 429 on a fast request is not waited out but tried
|
|
39
|
+
again at once at standard speed, and that lane session stays at standard speed (switching
|
|
40
|
+
speed invalidates the prompt cache, so it does not switch back and forth); the reply carries
|
|
41
|
+
a notice saying so. A fast call is priced here, at the fast rate, unless the API says it ran
|
|
42
|
+
at standard speed (usage.speed): its Usage.cost is set, so the config's standard price is not
|
|
43
|
+
used for it. `effort` (the light role's "low") goes out as output_config.effort.
|
|
44
|
+
|
|
45
|
+
Retries are this client's own, as in ModelClient: the SDK's are off (max_retries=0), so the
|
|
46
|
+
driver's before_retry gate holds a paused lane between tries. A status is retried as the
|
|
47
|
+
SDK itself would (408, 409, 429 and every 5xx, 529 "overloaded" included, unless the server
|
|
48
|
+
says otherwise in x-should-retry), and so are connection failures and timeouts.
|
|
49
|
+
|
|
50
|
+
Secrets. The key and the base URL are passed to the SDK explicitly, so it reads no other
|
|
51
|
+
credential (ANTHROPIC_AUTH_TOKEN, a profile) and no ANTHROPIC_BASE_URL from the environment.
|
|
52
|
+
An SDK exception is described and dropped, never chained onto a ModelError: its request
|
|
53
|
+
holds the Authorization header. Server text has the key cut out. A redirect is an answer,
|
|
54
|
+
not a new place to send the key. The SDK's OpenTelemetry spans are off, so lane content
|
|
55
|
+
cannot leave through an exporter the environment might configure.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
from __future__ import annotations
|
|
59
|
+
|
|
60
|
+
import asyncio
|
|
61
|
+
import json
|
|
62
|
+
from collections.abc import Awaitable, Callable, Iterable, Sequence
|
|
63
|
+
from dataclasses import dataclass, replace
|
|
64
|
+
|
|
65
|
+
import anthropic
|
|
66
|
+
import httpx2
|
|
67
|
+
|
|
68
|
+
from ..pricing import cost
|
|
69
|
+
from ..text import untrusted
|
|
70
|
+
from .client import (
|
|
71
|
+
_KEY_CHARS,
|
|
72
|
+
CONNECT_TIMEOUT,
|
|
73
|
+
MAX_ERROR_TEXT,
|
|
74
|
+
ModelError,
|
|
75
|
+
Reply,
|
|
76
|
+
ToolCall,
|
|
77
|
+
Usage,
|
|
78
|
+
_count,
|
|
79
|
+
_finite,
|
|
80
|
+
_phrase,
|
|
81
|
+
retry_delay,
|
|
82
|
+
)
|
|
83
|
+
from .fastmode import FAST_BETA, FAST_MODES, fast_problem
|
|
84
|
+
|
|
85
|
+
DEFAULT_BASE_URL = "https://api.anthropic.com" # the SDK's own default; it adds /v1/messages
|
|
86
|
+
DEFAULT_TIMEOUT = 600.0 # seconds per try: the SDK's default request timeout (10 minutes)
|
|
87
|
+
DEFAULT_MAX_TOKENS = 16_000 # when the caller gives none: Anthropic's documented default for a request that does not stream
|
|
88
|
+
CONTENT_KEY = "_anthropic_content" # the raw content blocks of a reply, on its history message
|
|
89
|
+
FALLBACK_BETA = "server-side-fallback-2026-07-01" # the beta that takes fallbacks="default"
|
|
90
|
+
SERVER_FALLBACK_MODELS = frozenset(
|
|
91
|
+
{"claude-sonnet-5-5", "claude-opus-5-5", "claude-opus-5", "claude-fable-5-1", "claude-fable-5-2"}
|
|
92
|
+
)
|
|
93
|
+
"""The models whose requests carry the server-side refusal fallback by default (Anthropic's
|
|
94
|
+
documentation). Claude Haiku 5.5 is not one: it has none, and a fallbacks list is a 400 there."""
|
|
95
|
+
|
|
96
|
+
EFFORTS = ("low", "medium", "high", "xhigh", "max")
|
|
97
|
+
EFFORT_MODELS = frozenset({
|
|
98
|
+
"claude-fable-5", "claude-fable-5-1", "claude-fable-5-2", "claude-mythos-5", "claude-mythos-5-1",
|
|
99
|
+
"claude-mythos-5-2", "claude-opus-5-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6",
|
|
100
|
+
"claude-opus-4-5", "claude-sonnet-5-5", "claude-sonnet-5", "claude-sonnet-4-6", "claude-haiku-5-5",
|
|
101
|
+
})
|
|
102
|
+
"""The models that take output_config.effort (Anthropic's documentation, its thinking table); it is an
|
|
103
|
+
error on Claude Haiku 4.5 and Sonnet 4.5, so it goes only to these."""
|
|
104
|
+
|
|
105
|
+
TRANSIENT = (httpx2.TimeoutException, httpx2.NetworkError, httpx2.RemoteProtocolError, httpx2.ProxyError)
|
|
106
|
+
"""Transport errors a retry can fix, as the SDK's APIConnectionError carries them."""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class _Failure:
|
|
111
|
+
"""One failed try, in our words: what happened, the status if the API answered, whether
|
|
112
|
+
a retry may help, and the server's Retry-After."""
|
|
113
|
+
|
|
114
|
+
text: str
|
|
115
|
+
status: int | None
|
|
116
|
+
retryable: bool
|
|
117
|
+
retry_after: str | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class _Malformed:
|
|
122
|
+
"""A 2xx reply the client cannot use: `what` in our words, `detail` in the server's."""
|
|
123
|
+
|
|
124
|
+
what: str
|
|
125
|
+
detail: str = ""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class _Declined:
|
|
130
|
+
"""stop_reason "refusal": the model (and any server-side fallback) declined."""
|
|
131
|
+
|
|
132
|
+
category: str
|
|
133
|
+
explanation: str
|
|
134
|
+
usage: Usage
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class AnthropicClient:
|
|
138
|
+
"""Claude through the Anthropic Messages API, with ModelClient's chat() and aclose().
|
|
139
|
+
|
|
140
|
+
Like ModelClient: shared by the lanes on one event loop, `timeout` bounds each try as a
|
|
141
|
+
whole (connecting gets at most CONNECT_TIMEOUT of it), and a call makes at most
|
|
142
|
+
`max_retries + 1` tries, waiting retry_delay() between them. `max_tokens` ([api]
|
|
143
|
+
max_tokens) goes with every call that gives none; without it, DEFAULT_MAX_TOKENS.
|
|
144
|
+
"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
149
|
+
api_key: str | None = None,
|
|
150
|
+
*,
|
|
151
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
152
|
+
max_retries: int = 4,
|
|
153
|
+
backoff: float = 1.0,
|
|
154
|
+
fast: Iterable[str] = (),
|
|
155
|
+
server_fallback: bool = True,
|
|
156
|
+
effort: str | None = None,
|
|
157
|
+
max_tokens: int | None = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
where = _base_url(base_url)
|
|
160
|
+
if not isinstance(api_key, str) or not _KEY_CHARS.fullmatch(api_key):
|
|
161
|
+
raise ValueError(
|
|
162
|
+
"the Anthropic API needs an API key that an HTTP header can carry (printable ASCII, no "
|
|
163
|
+
"spaces or line breaks); check the variable it came from"
|
|
164
|
+
)
|
|
165
|
+
if not timeout > 0:
|
|
166
|
+
raise ValueError(f"timeout must be a positive number of seconds, got {timeout}")
|
|
167
|
+
if max_retries < 0:
|
|
168
|
+
raise ValueError(f"max_retries must not be negative, got {max_retries}")
|
|
169
|
+
if not backoff >= 0:
|
|
170
|
+
raise ValueError(f"backoff must not be negative, got {backoff}")
|
|
171
|
+
fast = frozenset(fast)
|
|
172
|
+
for model in sorted(fast):
|
|
173
|
+
problem = fast_problem(model)
|
|
174
|
+
if problem is not None:
|
|
175
|
+
raise ValueError(f"fast mode cannot be used: {problem}")
|
|
176
|
+
if effort is not None and effort not in EFFORTS:
|
|
177
|
+
raise ValueError(f"effort must be one of {', '.join(EFFORTS)}, got {effort!r}")
|
|
178
|
+
if max_tokens is not None and (not isinstance(max_tokens, int) or isinstance(max_tokens, bool) or max_tokens < 1):
|
|
179
|
+
raise ValueError(f"max_tokens must be a positive whole number, got {max_tokens!r}")
|
|
180
|
+
self.max_tokens = max_tokens
|
|
181
|
+
self.timeout = timeout
|
|
182
|
+
self.max_retries = max_retries
|
|
183
|
+
self.backoff = backoff
|
|
184
|
+
self.fast = fast # models called in fast mode
|
|
185
|
+
self.server_fallback = server_fallback
|
|
186
|
+
self.effort = effort
|
|
187
|
+
self._standard: set[str] = set() # lane sessions whose fast mode hit its rate limit: standard speed from then on
|
|
188
|
+
self._key = api_key
|
|
189
|
+
self._where = where
|
|
190
|
+
limit = anthropic.Timeout(timeout, connect=min(timeout, CONNECT_TIMEOUT))
|
|
191
|
+
self._sdk = anthropic.AsyncAnthropic(
|
|
192
|
+
api_key=api_key, # explicit: the SDK then reads no credential from the environment
|
|
193
|
+
base_url=base_url, # explicit: ANTHROPIC_BASE_URL is never read
|
|
194
|
+
max_retries=0, # our retries, so the driver's before_retry gate runs between them
|
|
195
|
+
timeout=limit,
|
|
196
|
+
http_client=anthropic.DefaultAsyncHttpxClient(timeout=limit, follow_redirects=False),
|
|
197
|
+
open_telemetry=False,
|
|
198
|
+
)
|
|
199
|
+
self._closed = False
|
|
200
|
+
|
|
201
|
+
def __repr__(self) -> str:
|
|
202
|
+
return f"AnthropicClient({self._where!r}, key={'set' if self._key else 'none'})"
|
|
203
|
+
|
|
204
|
+
async def __aenter__(self) -> AnthropicClient:
|
|
205
|
+
return self
|
|
206
|
+
|
|
207
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
208
|
+
await self.aclose()
|
|
209
|
+
|
|
210
|
+
async def aclose(self) -> None:
|
|
211
|
+
self._closed = True
|
|
212
|
+
await self._sdk.close()
|
|
213
|
+
|
|
214
|
+
async def chat(
|
|
215
|
+
self,
|
|
216
|
+
model: str,
|
|
217
|
+
messages: list[dict],
|
|
218
|
+
tools: list[dict],
|
|
219
|
+
*,
|
|
220
|
+
session_id: str,
|
|
221
|
+
max_tokens: int | None = None,
|
|
222
|
+
temperature: float | None = None,
|
|
223
|
+
before_retry: Callable[[], Awaitable[None]] | None = None,
|
|
224
|
+
) -> Reply:
|
|
225
|
+
"""The model's next message. Raises ModelError when the call fails for good.
|
|
226
|
+
|
|
227
|
+
session_id is not sent: the Messages API caches by content, not by session; it is what
|
|
228
|
+
keeps a lane at standard speed once its fast mode hit the rate limit. Giving a
|
|
229
|
+
temperature is a ValueError: these models reject it, and flightdeck does not drop a
|
|
230
|
+
setting quietly. before_retry is awaited before each retry, after the backoff, as in
|
|
231
|
+
ModelClient (and before the standard-speed try after a fast-mode 429); whatever it
|
|
232
|
+
raises comes out of chat() as it is."""
|
|
233
|
+
if temperature is not None:
|
|
234
|
+
raise ValueError(
|
|
235
|
+
"the Anthropic client sends no temperature: Claude's current models reject it; "
|
|
236
|
+
"leave temperature unset for this provider"
|
|
237
|
+
)
|
|
238
|
+
if max_tokens is None:
|
|
239
|
+
max_tokens = self.max_tokens
|
|
240
|
+
fast = model in self.fast and session_id not in self._standard
|
|
241
|
+
params = self._params(model, messages, tools, max_tokens, fast=fast)
|
|
242
|
+
if self._closed:
|
|
243
|
+
raise RuntimeError("the model client is closed")
|
|
244
|
+
tries = self.max_retries + 1
|
|
245
|
+
made = 0
|
|
246
|
+
notice = ""
|
|
247
|
+
failure = _Failure("no try was made", None, False)
|
|
248
|
+
while True:
|
|
249
|
+
outcome = await self._try(params)
|
|
250
|
+
if not isinstance(outcome, _Failure):
|
|
251
|
+
return self._reply(outcome, fast=model if fast else None, notice=notice)
|
|
252
|
+
failure = outcome
|
|
253
|
+
if fast and failure.status == 429:
|
|
254
|
+
# Fast mode's own rate limit: go on at standard speed now, and stay there for this
|
|
255
|
+
# session, since every switch of speed invalidates the prompt cache.
|
|
256
|
+
fast = False
|
|
257
|
+
self._standard.add(session_id)
|
|
258
|
+
params = self._params(model, messages, tools, max_tokens, fast=False)
|
|
259
|
+
notice = (
|
|
260
|
+
f"fast mode's own rate limit was reached (HTTP 429): {model} goes on at standard speed for "
|
|
261
|
+
"this lane's session (switching speed starts the prompt cache again)"
|
|
262
|
+
)
|
|
263
|
+
if before_retry is not None:
|
|
264
|
+
await before_retry()
|
|
265
|
+
continue
|
|
266
|
+
made += 1
|
|
267
|
+
if not failure.retryable:
|
|
268
|
+
raise self._error(failure.text, status=failure.status)
|
|
269
|
+
if made >= tries:
|
|
270
|
+
break
|
|
271
|
+
await asyncio.sleep(retry_delay(made - 1, self.backoff, failure.retry_after))
|
|
272
|
+
if before_retry is not None:
|
|
273
|
+
await before_retry()
|
|
274
|
+
raise self._error(f"{failure.text}; gave up after {tries} tries", status=failure.status, retryable=True)
|
|
275
|
+
|
|
276
|
+
def _params(self, model: str, messages: Sequence[dict], tools: Sequence[dict], max_tokens: int | None, *,
|
|
277
|
+
fast: bool) -> dict:
|
|
278
|
+
effort = self.effort if self.effort is not None and model in EFFORT_MODELS else None
|
|
279
|
+
return request(model, messages, tools, max_tokens, fast=fast, effort=effort, server_fallback=self.server_fallback)
|
|
280
|
+
|
|
281
|
+
async def _try(self, params: dict) -> dict | _Failure:
|
|
282
|
+
"""One try: the message as a dict, or what went wrong.
|
|
283
|
+
|
|
284
|
+
The SDK's exception is described here and dropped: its request holds the key."""
|
|
285
|
+
try:
|
|
286
|
+
async with asyncio.timeout(self.timeout):
|
|
287
|
+
message = await self._sdk.beta.messages.create(**params)
|
|
288
|
+
except TimeoutError: # our deadline on the whole try passed
|
|
289
|
+
return _Failure(f"no answer from the Anthropic API at {self._where}: timed out after {self.timeout:g} s", None, True)
|
|
290
|
+
except anthropic.APIStatusError as exc:
|
|
291
|
+
return self._answered(exc)
|
|
292
|
+
except anthropic.APIConnectionError as exc:
|
|
293
|
+
cause = exc.__cause__
|
|
294
|
+
transient = cause is None or isinstance(exc, anthropic.APITimeoutError) or isinstance(cause, TRANSIENT)
|
|
295
|
+
what = type(cause).__name__ if cause is not None else type(exc).__name__
|
|
296
|
+
said = str(cause) if cause is not None else ""
|
|
297
|
+
detail = f"{what}: {said}" if said else what
|
|
298
|
+
return _Failure(f"no answer from the Anthropic API at {self._where}: {self._short(detail)}", None, transient)
|
|
299
|
+
except anthropic.AnthropicError as exc: # the SDK could not use what came back
|
|
300
|
+
return _Failure(f"the Anthropic API sent a reply the SDK could not read ({type(exc).__name__})", None, False)
|
|
301
|
+
except (ValueError, RecursionError) as exc:
|
|
302
|
+
# what the SDK raises for a 2xx body that is not JSON, or nested too deep to parse
|
|
303
|
+
return _Failure(f"the Anthropic API sent a reply that is not a JSON message ({type(exc).__name__})", 200, False)
|
|
304
|
+
if not isinstance(message, anthropic.BaseModel): # JSON, but not an object: the SDK hands it back as it is
|
|
305
|
+
return _Failure("the Anthropic API sent a reply that is not a JSON message (not an object)", 200, False)
|
|
306
|
+
# warnings off: a block type newer than this SDK is kept as sent, without a warning per turn
|
|
307
|
+
return message.to_dict(mode="python", warnings=False)
|
|
308
|
+
|
|
309
|
+
def _answered(self, exc: anthropic.APIStatusError) -> _Failure:
|
|
310
|
+
"""'the Anthropic API answered HTTP 529 (overloaded_error): Overloaded'."""
|
|
311
|
+
status = exc.status_code
|
|
312
|
+
kind, said = _error_parts(exc.body)
|
|
313
|
+
kind = self._short(kind)[:60] if kind else ""
|
|
314
|
+
text = f"the Anthropic API answered HTTP {status}" + (f" ({kind})" if kind else _phrase(status))
|
|
315
|
+
if said:
|
|
316
|
+
text += f": {self._short(said)}"
|
|
317
|
+
headers = exc.response.headers
|
|
318
|
+
return _Failure(text, status, _retryable(status, headers.get("x-should-retry")), headers.get("retry-after"))
|
|
319
|
+
|
|
320
|
+
def _reply(self, data: dict, *, fast: str | None = None, notice: str = "") -> Reply:
|
|
321
|
+
"""The reply, its usage priced at the fast rate when `fast` names the model of a fast
|
|
322
|
+
request, and `notice` in front of its own."""
|
|
323
|
+
outcome = parse(data)
|
|
324
|
+
if isinstance(outcome, Reply):
|
|
325
|
+
reply = outcome if fast is None else replace(outcome, usage=fast_priced(outcome.usage, fast))
|
|
326
|
+
if notice:
|
|
327
|
+
reply = replace(reply, notice="; ".join(n for n in (notice, reply.notice) if n))
|
|
328
|
+
return reply
|
|
329
|
+
if isinstance(outcome, _Declined):
|
|
330
|
+
text = f"the model declined ({self._short(outcome.category) if outcome.category else 'no category given'})"
|
|
331
|
+
if outcome.explanation:
|
|
332
|
+
text += f": {self._short(outcome.explanation)}"
|
|
333
|
+
usage = outcome.usage if fast is None else fast_priced(outcome.usage, fast)
|
|
334
|
+
raise self._error(text, status=200, declined=True, usage=usage)
|
|
335
|
+
text = f"{outcome.what} (HTTP 200)" + (f": {self._short(outcome.detail)}" if outcome.detail else "")
|
|
336
|
+
raise self._error(text, status=200)
|
|
337
|
+
|
|
338
|
+
def _redact(self, text: str) -> str:
|
|
339
|
+
return text.replace(self._key, "[key]") if self._key else text
|
|
340
|
+
|
|
341
|
+
def _short(self, text: str) -> str:
|
|
342
|
+
"""The server's words, safe to show: no key, no control characters, one line, 300 at most."""
|
|
343
|
+
text = untrusted(self._redact(text)[: MAX_ERROR_TEXT * 4], one_line=True).strip()
|
|
344
|
+
return text if len(text) <= MAX_ERROR_TEXT else text[: MAX_ERROR_TEXT - 3] + "..."
|
|
345
|
+
|
|
346
|
+
def _error(self, message: str, *, status: int | None = None, retryable: bool = False,
|
|
347
|
+
declined: bool = False, usage: Usage | None = None) -> ModelError:
|
|
348
|
+
return ModelError(untrusted(self._redact(message), one_line=True), status=status, retryable=retryable,
|
|
349
|
+
declined=declined, usage=usage)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------- the request
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def request(model: str, messages: Sequence[dict], tools: Sequence[dict], max_tokens: int | None, *,
|
|
356
|
+
fast: bool = False, effort: str | None = None, server_fallback: bool = True) -> dict:
|
|
357
|
+
"""The keyword arguments for the SDK's beta messages.create(): the same every try.
|
|
358
|
+
|
|
359
|
+
fast: `speed: "fast"` (and the fast-mode beta for a research-preview model); the model
|
|
360
|
+
must be one agent/fastmode.py lists. effort: output_config.effort. server_fallback=False
|
|
361
|
+
leaves the server-side refusal fallback out even for a model that has one."""
|
|
362
|
+
if not isinstance(model, str) or not model:
|
|
363
|
+
raise ValueError("model must be a non-empty string")
|
|
364
|
+
if max_tokens is not None and (not isinstance(max_tokens, int) or isinstance(max_tokens, bool) or max_tokens < 1):
|
|
365
|
+
raise ValueError(f"max_tokens must be a positive whole number, got {max_tokens!r}")
|
|
366
|
+
system, rest = _split_system(messages)
|
|
367
|
+
params: dict = {
|
|
368
|
+
"model": model,
|
|
369
|
+
"max_tokens": max_tokens or DEFAULT_MAX_TOKENS,
|
|
370
|
+
"messages": to_anthropic(rest),
|
|
371
|
+
"cache_control": {"type": "ephemeral"}, # automatic caching: the breakpoint follows the conversation's end
|
|
372
|
+
}
|
|
373
|
+
if system:
|
|
374
|
+
blocks = [{"type": "text", "text": text} for text in system]
|
|
375
|
+
blocks[-1]["cache_control"] = {"type": "ephemeral"} # tools + system: the fixed prefix
|
|
376
|
+
params["system"] = blocks
|
|
377
|
+
if tools:
|
|
378
|
+
params["tools"] = [_tool(tool) for tool in tools]
|
|
379
|
+
betas: list[str] = []
|
|
380
|
+
if server_fallback and model in SERVER_FALLBACK_MODELS:
|
|
381
|
+
betas.append(FALLBACK_BETA)
|
|
382
|
+
params["fallbacks"] = "default"
|
|
383
|
+
if fast:
|
|
384
|
+
problem = fast_problem(model)
|
|
385
|
+
if problem is not None:
|
|
386
|
+
raise ValueError(f"fast mode cannot be used: {problem}")
|
|
387
|
+
params["speed"] = "fast"
|
|
388
|
+
if FAST_MODES[model].beta:
|
|
389
|
+
betas.append(FAST_BETA)
|
|
390
|
+
if effort is not None:
|
|
391
|
+
params["output_config"] = {"effort": effort}
|
|
392
|
+
if betas:
|
|
393
|
+
params["betas"] = betas
|
|
394
|
+
return params
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def fast_priced(usage: Usage, model: str) -> Usage:
|
|
398
|
+
"""`usage` of a fast request to `model`, priced at its fast rate (cached tokens at the fast
|
|
399
|
+
input rate: the documentation gives no other), unless the API says it ran at standard speed."""
|
|
400
|
+
if usage.speed == "standard":
|
|
401
|
+
return usage
|
|
402
|
+
dollars = cost(FAST_MODES[model].price, usage.prompt_tokens, usage.completion_tokens, usage.cached_tokens)
|
|
403
|
+
return replace(usage, cost=dollars)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _split_system(messages: Sequence[dict]) -> tuple[list[str], list[dict]]:
|
|
407
|
+
"""The leading system messages' texts, and the messages after them."""
|
|
408
|
+
texts: list[str] = []
|
|
409
|
+
for index, message in enumerate(messages):
|
|
410
|
+
if not isinstance(message, dict):
|
|
411
|
+
raise ValueError(f"message {index} is not a dict")
|
|
412
|
+
if message.get("role") != "system":
|
|
413
|
+
return texts, list(messages[index:])
|
|
414
|
+
text = _text_of(message.get("content"), f"message {index}")
|
|
415
|
+
if text:
|
|
416
|
+
texts.append(text)
|
|
417
|
+
return texts, []
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def to_anthropic(messages: Sequence[dict]) -> list[dict]:
|
|
421
|
+
"""The conversation after the system prompt as Messages API turns (see the module doc)."""
|
|
422
|
+
turns: list[dict] = []
|
|
423
|
+
for index, message in enumerate(messages):
|
|
424
|
+
where = f"message {index}"
|
|
425
|
+
if not isinstance(message, dict):
|
|
426
|
+
raise ValueError(f"{where} is not a dict")
|
|
427
|
+
role = message.get("role")
|
|
428
|
+
if role == "tool":
|
|
429
|
+
call_id = message.get("tool_call_id")
|
|
430
|
+
if not isinstance(call_id, str) or not call_id:
|
|
431
|
+
raise ValueError(f"{where}: a tool result without a tool_call_id")
|
|
432
|
+
result = {"type": "tool_result", "tool_use_id": call_id, "content": _text_of(message.get("content"), where)}
|
|
433
|
+
_add(turns, "user", [result])
|
|
434
|
+
elif role == "user":
|
|
435
|
+
text = _text_of(message.get("content"), where)
|
|
436
|
+
if text:
|
|
437
|
+
_add(turns, "user", [{"type": "text", "text": text}])
|
|
438
|
+
elif role == "assistant":
|
|
439
|
+
raw = message.get(CONTENT_KEY)
|
|
440
|
+
if raw is not None:
|
|
441
|
+
if not isinstance(raw, list):
|
|
442
|
+
raise ValueError(f"{where}: {CONTENT_KEY} is not a list of content blocks")
|
|
443
|
+
blocks = list(raw) # the blocks themselves go back exactly as they came
|
|
444
|
+
else:
|
|
445
|
+
blocks = _from_openai(message, where)
|
|
446
|
+
if blocks:
|
|
447
|
+
_add(turns, "assistant", blocks)
|
|
448
|
+
elif role == "system":
|
|
449
|
+
raise ValueError(f"{where}: a system message after the conversation started; flightdeck puts none there")
|
|
450
|
+
else:
|
|
451
|
+
raise ValueError(f"{where}: unknown role {role!r}")
|
|
452
|
+
return turns
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _add(turns: list[dict], role: str, blocks: list[dict]) -> None:
|
|
456
|
+
"""Append blocks as a turn of `role`, joining the last turn when it has the same role."""
|
|
457
|
+
if turns and turns[-1]["role"] == role:
|
|
458
|
+
turns[-1]["content"].extend(blocks)
|
|
459
|
+
else:
|
|
460
|
+
turns.append({"role": role, "content": list(blocks)})
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _from_openai(message: dict, where: str) -> list[dict]:
|
|
464
|
+
"""An OpenAI-style assistant message as content blocks: its text, then its tool calls."""
|
|
465
|
+
blocks: list[dict] = []
|
|
466
|
+
text = _text_of(message.get("content"), where)
|
|
467
|
+
if text.strip():
|
|
468
|
+
blocks.append({"type": "text", "text": text})
|
|
469
|
+
for call in message.get("tool_calls") or []:
|
|
470
|
+
function = call.get("function") if isinstance(call, dict) else None
|
|
471
|
+
call_id = call.get("id") if isinstance(call, dict) else None
|
|
472
|
+
name = function.get("name") if isinstance(function, dict) else None
|
|
473
|
+
if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
|
|
474
|
+
raise ValueError(f"{where}: a tool call without an id or a function name")
|
|
475
|
+
try:
|
|
476
|
+
arguments = json.loads(function.get("arguments") or "{}")
|
|
477
|
+
except (TypeError, ValueError, RecursionError):
|
|
478
|
+
arguments = {} # the call ran with arguments that were not JSON, and its result said so
|
|
479
|
+
blocks.append({"type": "tool_use", "id": call_id, "name": name,
|
|
480
|
+
"input": arguments if isinstance(arguments, dict) else {}})
|
|
481
|
+
return blocks
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _text_of(content: object, where: str) -> str:
|
|
485
|
+
"""A message's text: a string, or the text parts of a list of content parts."""
|
|
486
|
+
if content is None:
|
|
487
|
+
return ""
|
|
488
|
+
if isinstance(content, str):
|
|
489
|
+
return content
|
|
490
|
+
if isinstance(content, list):
|
|
491
|
+
parts = []
|
|
492
|
+
for part in content:
|
|
493
|
+
if isinstance(part, dict) and part.get("type", "text") == "text" and isinstance(part.get("text"), str):
|
|
494
|
+
parts.append(part["text"])
|
|
495
|
+
else:
|
|
496
|
+
raise ValueError(f"{where}: content parts other than text cannot go to the Anthropic API")
|
|
497
|
+
return "".join(parts)
|
|
498
|
+
raise ValueError(f"{where}: content of an unknown kind ({type(content).__name__})")
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _tool(tool: dict) -> dict:
|
|
502
|
+
"""An OpenAI function tool as a Messages API tool: name, description, input_schema."""
|
|
503
|
+
function = tool.get("function") if isinstance(tool, dict) and tool.get("type") == "function" else None
|
|
504
|
+
name = function.get("name") if isinstance(function, dict) else None
|
|
505
|
+
if not isinstance(name, str) or not name:
|
|
506
|
+
raise ValueError("a tool that is not an OpenAI function with a name")
|
|
507
|
+
out: dict = {"name": name, "input_schema": function.get("parameters") or {"type": "object", "properties": {}}}
|
|
508
|
+
description = function.get("description")
|
|
509
|
+
if isinstance(description, str) and description:
|
|
510
|
+
out["description"] = description
|
|
511
|
+
return out
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ---------------------------------------------------------------------- the reply
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def parse(data: dict) -> Reply | _Malformed | _Declined:
|
|
518
|
+
"""A message (as the SDK's to_dict() gives it) as a Reply; or what is wrong with it."""
|
|
519
|
+
reported = data.get("error")
|
|
520
|
+
if isinstance(reported, dict): # an error in a 2xx answer
|
|
521
|
+
return _Malformed("the Anthropic API reported an error", _string(reported.get("message")) or "no message given")
|
|
522
|
+
content = data.get("content")
|
|
523
|
+
if not isinstance(content, list) or not all(isinstance(block, dict) for block in content):
|
|
524
|
+
return _Malformed("the Anthropic API sent a message without a list of content blocks")
|
|
525
|
+
usage = _usage(data.get("usage"))
|
|
526
|
+
stop = _string(data.get("stop_reason"))
|
|
527
|
+
if stop == "refusal":
|
|
528
|
+
details = data.get("stop_details")
|
|
529
|
+
details = details if isinstance(details, dict) else {}
|
|
530
|
+
return _Declined(_string(details.get("category")), _string(details.get("explanation")), usage)
|
|
531
|
+
if not _finite(content):
|
|
532
|
+
# it goes back into the history, and the next request's body could not encode it
|
|
533
|
+
return _Malformed("the Anthropic API sent a message holding a number JSON cannot carry (NaN or infinity)")
|
|
534
|
+
texts: list[str] = []
|
|
535
|
+
calls: list[ToolCall] = []
|
|
536
|
+
entries: list[dict] = []
|
|
537
|
+
notices: list[str] = []
|
|
538
|
+
for block in content:
|
|
539
|
+
kind = block.get("type")
|
|
540
|
+
if kind == "text":
|
|
541
|
+
text = block.get("text")
|
|
542
|
+
if not isinstance(text, str):
|
|
543
|
+
return _Malformed("the Anthropic API sent a text block without text")
|
|
544
|
+
texts.append(text)
|
|
545
|
+
elif kind == "tool_use":
|
|
546
|
+
call_id, name, arguments = block.get("id"), block.get("name"), block.get("input")
|
|
547
|
+
if not isinstance(call_id, str) or not call_id or not isinstance(name, str) or not name:
|
|
548
|
+
return _Malformed("the Anthropic API sent a tool_use block without an id or a name")
|
|
549
|
+
if not isinstance(arguments, dict):
|
|
550
|
+
return _Malformed("the Anthropic API sent a tool_use block whose input is not an object")
|
|
551
|
+
arguments_text = json.dumps(arguments)
|
|
552
|
+
calls.append(ToolCall(call_id, name, arguments_text))
|
|
553
|
+
entries.append({"id": call_id, "type": "function", "function": {"name": name, "arguments": arguments_text}})
|
|
554
|
+
elif kind == "fallback":
|
|
555
|
+
notices.append(_fallback_notice(block))
|
|
556
|
+
text = "".join(texts)
|
|
557
|
+
message: dict = {"role": "assistant", "content": text if text or not entries else None, CONTENT_KEY: content}
|
|
558
|
+
if entries:
|
|
559
|
+
message["tool_calls"] = entries
|
|
560
|
+
return Reply(
|
|
561
|
+
message=message,
|
|
562
|
+
content=text,
|
|
563
|
+
tool_calls=tuple(calls),
|
|
564
|
+
finish_reason=stop,
|
|
565
|
+
usage=usage,
|
|
566
|
+
model=_string(data.get("model")),
|
|
567
|
+
notice="; ".join(notices),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _usage(raw: object) -> Usage:
|
|
572
|
+
"""prompt tokens = input + cache read + cache write; cached = cache read; cost unknown; the
|
|
573
|
+
speed the API says the request ran at ("fast" or "standard"), "" when it says neither."""
|
|
574
|
+
if not isinstance(raw, dict):
|
|
575
|
+
return Usage()
|
|
576
|
+
read = _count(raw.get("cache_read_input_tokens"))
|
|
577
|
+
prompt = _count(raw.get("input_tokens")) + read + _count(raw.get("cache_creation_input_tokens"))
|
|
578
|
+
speed = raw.get("speed")
|
|
579
|
+
return Usage(prompt_tokens=prompt, completion_tokens=_count(raw.get("output_tokens")), cached_tokens=read, cost=None,
|
|
580
|
+
speed=speed if speed in ("fast", "standard") else "")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _fallback_notice(block: dict) -> str:
|
|
584
|
+
"""'Anthropic's refusal fallback: claude-opus-5-5 declined (cyber), claude-opus-4-8 answered'."""
|
|
585
|
+
|
|
586
|
+
def model(side: str) -> str:
|
|
587
|
+
info = block.get(side)
|
|
588
|
+
name = info.get("model") if isinstance(info, dict) else None
|
|
589
|
+
return untrusted(name, one_line=True)[:80] if isinstance(name, str) and name else "a model"
|
|
590
|
+
|
|
591
|
+
trigger = block.get("trigger")
|
|
592
|
+
category = trigger.get("category") if isinstance(trigger, dict) else None
|
|
593
|
+
why = f" ({untrusted(category, one_line=True)[:40]})" if isinstance(category, str) and category else ""
|
|
594
|
+
return f"Anthropic's refusal fallback: {model('from')} declined{why}, {model('to')} answered"
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _error_parts(body: object) -> tuple[str, str]:
|
|
598
|
+
"""(error.type, error.message) of an Anthropic error body, or ("", the body's text)."""
|
|
599
|
+
if isinstance(body, dict):
|
|
600
|
+
error = body.get("error")
|
|
601
|
+
if isinstance(error, dict):
|
|
602
|
+
return _string(error.get("type")), _string(error.get("message"))
|
|
603
|
+
return "", ""
|
|
604
|
+
return ("", body) if isinstance(body, str) else ("", "")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _retryable(status: int, should_retry: str | None) -> bool:
|
|
608
|
+
"""The SDK's own retry policy: the server's x-should-retry wins; else 408, 409, 429, 5xx."""
|
|
609
|
+
if should_retry == "true":
|
|
610
|
+
return True
|
|
611
|
+
if should_retry == "false":
|
|
612
|
+
return False
|
|
613
|
+
return status in (408, 409, 429) or status >= 500
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _base_url(base_url: str) -> str:
|
|
617
|
+
"""The base URL as errors show it (no user info, query or fragment); ValueError when it
|
|
618
|
+
is not an http(s) URL with a host."""
|
|
619
|
+
if not isinstance(base_url, str) or not base_url.startswith(("http://", "https://")):
|
|
620
|
+
raise ValueError("base_url must start with http:// or https://")
|
|
621
|
+
try:
|
|
622
|
+
url = httpx2.URL(base_url)
|
|
623
|
+
except httpx2.InvalidURL as exc:
|
|
624
|
+
raise ValueError(f"base_url is not a valid URL: {exc}") from None
|
|
625
|
+
if not url.host:
|
|
626
|
+
raise ValueError("base_url has no host")
|
|
627
|
+
return str(url.copy_with(userinfo=b"", query=None, fragment=None))
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _string(value: object) -> str:
|
|
631
|
+
return value if isinstance(value, str) else ""
|