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.
Files changed (141) hide show
  1. ctf_flightdeck/__init__.py +8 -0
  2. ctf_flightdeck/__main__.py +3 -0
  3. ctf_flightdeck/agent/__init__.py +1 -0
  4. ctf_flightdeck/agent/anthropic_client.py +631 -0
  5. ctf_flightdeck/agent/client.py +578 -0
  6. ctf_flightdeck/agent/driver.py +876 -0
  7. ctf_flightdeck/agent/external.py +2319 -0
  8. ctf_flightdeck/agent/fallback.py +207 -0
  9. ctf_flightdeck/agent/fastmode.py +105 -0
  10. ctf_flightdeck/agent/l1stage.py +107 -0
  11. ctf_flightdeck/agent/prompts.py +180 -0
  12. ctf_flightdeck/agent/tools.py +724 -0
  13. ctf_flightdeck/agent/workspace.py +1207 -0
  14. ctf_flightdeck/apicheck.py +410 -0
  15. ctf_flightdeck/build.py +855 -0
  16. ctf_flightdeck/build_cli.py +154 -0
  17. ctf_flightdeck/cli.py +741 -0
  18. ctf_flightdeck/clock.py +16 -0
  19. ctf_flightdeck/config.py +896 -0
  20. ctf_flightdeck/console/__init__.py +2 -0
  21. ctf_flightdeck/console/app.py +604 -0
  22. ctf_flightdeck/console/commands.py +128 -0
  23. ctf_flightdeck/console/fleet.py +539 -0
  24. ctf_flightdeck/console/focus.py +243 -0
  25. ctf_flightdeck/console/room.py +85 -0
  26. ctf_flightdeck/console/screens.py +369 -0
  27. ctf_flightdeck/console/takeover.py +54 -0
  28. ctf_flightdeck/console/terminal.py +65 -0
  29. ctf_flightdeck/console/theme.py +64 -0
  30. ctf_flightdeck/console/views.py +876 -0
  31. ctf_flightdeck/console/widgets.py +184 -0
  32. ctf_flightdeck/core/__init__.py +3 -0
  33. ctf_flightdeck/core/attention.py +95 -0
  34. ctf_flightdeck/core/deck.py +1620 -0
  35. ctf_flightdeck/core/driver.py +128 -0
  36. ctf_flightdeck/core/interventions.py +131 -0
  37. ctf_flightdeck/core/model.py +370 -0
  38. ctf_flightdeck/core/summary.py +364 -0
  39. ctf_flightdeck/credentials.py +217 -0
  40. ctf_flightdeck/detect/__init__.py +61 -0
  41. ctf_flightdeck/detect/base.py +137 -0
  42. ctf_flightdeck/detect/detectors.py +545 -0
  43. ctf_flightdeck/detect/hints.py +351 -0
  44. ctf_flightdeck/detect/hints.toml +334 -0
  45. ctf_flightdeck/detect/judge.py +184 -0
  46. ctf_flightdeck/detect/watch.py +256 -0
  47. ctf_flightdeck/eventlog.py +174 -0
  48. ctf_flightdeck/flagformat.py +101 -0
  49. ctf_flightdeck/fmt.py +40 -0
  50. ctf_flightdeck/gaps.py +582 -0
  51. ctf_flightdeck/gaps_cli.py +128 -0
  52. ctf_flightdeck/hardware.py +477 -0
  53. ctf_flightdeck/keys.py +102 -0
  54. ctf_flightdeck/l1/__init__.py +17 -0
  55. ctf_flightdeck/l1/_boot.py +128 -0
  56. ctf_flightdeck/l1/_capture.py +125 -0
  57. ctf_flightdeck/l1/cli.py +297 -0
  58. ctf_flightdeck/l1/container.py +202 -0
  59. ctf_flightdeck/l1/importer.py +349 -0
  60. ctf_flightdeck/l1/manifest.py +509 -0
  61. ctf_flightdeck/l1/result.py +259 -0
  62. ctf_flightdeck/l1/router.py +243 -0
  63. ctf_flightdeck/l1/runner.py +705 -0
  64. ctf_flightdeck/lane/Dockerfile +15 -0
  65. ctf_flightdeck/lane/__init__.py +28 -0
  66. ctf_flightdeck/leftovers.py +1047 -0
  67. ctf_flightdeck/light.py +1041 -0
  68. ctf_flightdeck/localmodel.py +575 -0
  69. ctf_flightdeck/localmodel_cases.jsonl +58 -0
  70. ctf_flightdeck/localmodel_cli.py +521 -0
  71. ctf_flightdeck/mock/__init__.py +1 -0
  72. ctf_flightdeck/mock/scenarios.py +1054 -0
  73. ctf_flightdeck/mock/screenshots.py +229 -0
  74. ctf_flightdeck/mock/script.py +242 -0
  75. ctf_flightdeck/mock/world.py +97 -0
  76. ctf_flightdeck/model_catalog.py +640 -0
  77. ctf_flightdeck/models.toml +229 -0
  78. ctf_flightdeck/models_cli.py +485 -0
  79. ctf_flightdeck/needle.py +156 -0
  80. ctf_flightdeck/platform/__init__.py +9 -0
  81. ctf_flightdeck/platform/ctfd.py +1808 -0
  82. ctf_flightdeck/platform/ctfd_cli.py +114 -0
  83. ctf_flightdeck/platform/manual.py +545 -0
  84. ctf_flightdeck/postflight.py +1515 -0
  85. ctf_flightdeck/postflight_cli.py +97 -0
  86. ctf_flightdeck/practice/README.md +86 -0
  87. ctf_flightdeck/practice/answers.sha256.json +16 -0
  88. ctf_flightdeck/practice/board.json +4 -0
  89. ctf_flightdeck/practice/crab-walk/challenge.json +11 -0
  90. ctf_flightdeck/practice/crab-walk/postcard.txt +8 -0
  91. ctf_flightdeck/practice/fine-print/challenge.json +11 -0
  92. ctf_flightdeck/practice/fine-print/harbour_dawn.png +0 -0
  93. ctf_flightdeck/practice/half-and-half/challenge.json +11 -0
  94. ctf_flightdeck/practice/half-and-half/halves.txt +6 -0
  95. ctf_flightdeck/practice/harbour-memo/challenge.json +11 -0
  96. ctf_flightdeck/practice/harbour-memo/memo.enc +3 -0
  97. ctf_flightdeck/practice/mille-feuille/challenge.json +11 -0
  98. ctf_flightdeck/practice/mille-feuille/layers.txt +1 -0
  99. ctf_flightdeck/practice/nesting-dolls/challenge.json +11 -0
  100. ctf_flightdeck/practice/nesting-dolls/dolls.zip +0 -0
  101. ctf_flightdeck/practice/pancake-stack/challenge.json +12 -0
  102. ctf_flightdeck/practice/pancake-stack/program.bin +0 -0
  103. ctf_flightdeck/practice/pancake-stack/vm.py +85 -0
  104. ctf_flightdeck/practice/postmarked/challenge.json +12 -0
  105. ctf_flightdeck/practice/postmarked/envelope.txt +7 -0
  106. ctf_flightdeck/practice/postmarked/letter.txt +9 -0
  107. ctf_flightdeck/practice/quiet-night/access_log +497 -0
  108. ctf_flightdeck/practice/quiet-night/challenge.json +11 -0
  109. ctf_flightdeck/practice/signal-lamp/challenge.json +11 -0
  110. ctf_flightdeck/practice/signal-lamp/lamp_log.csv +99 -0
  111. ctf_flightdeck/practice/three-wishes/challenge.json +12 -0
  112. ctf_flightdeck/practice/three-wishes/encrypt.py +44 -0
  113. ctf_flightdeck/practice/three-wishes/output.txt +3 -0
  114. ctf_flightdeck/practice/turnstile/challenge.json +11 -0
  115. ctf_flightdeck/practice/turnstile/turnstile.py +35 -0
  116. ctf_flightdeck/practice/welcome-mat/challenge.json +13 -0
  117. ctf_flightdeck/practice/welcome-mat/index.html +22 -0
  118. ctf_flightdeck/practice/welcome-mat/portal.js +42 -0
  119. ctf_flightdeck/practice/welcome-mat/style.css +5 -0
  120. ctf_flightdeck/practice/wrong-jar/challenge.json +11 -0
  121. ctf_flightdeck/practice/wrong-jar/minutes.pdf +0 -0
  122. ctf_flightdeck/practice_cli.py +115 -0
  123. ctf_flightdeck/practice_mode.py +1119 -0
  124. ctf_flightdeck/practice_set.py +16 -0
  125. ctf_flightdeck/preflight.py +853 -0
  126. ctf_flightdeck/preflight_cli.py +131 -0
  127. ctf_flightdeck/pricing.py +42 -0
  128. ctf_flightdeck/run.py +1296 -0
  129. ctf_flightdeck/runrecord.py +514 -0
  130. ctf_flightdeck/setup.py +868 -0
  131. ctf_flightdeck/team.py +892 -0
  132. ctf_flightdeck/team_cli.py +69 -0
  133. ctf_flightdeck/text.py +71 -0
  134. ctf_flightdeck/transcript.py +104 -0
  135. ctf_flightdeck/writeup.py +1136 -0
  136. ctf_flightdeck/writeup_cli.py +256 -0
  137. ctf_flightdeck-0.1.0.dist-info/METADATA +307 -0
  138. ctf_flightdeck-0.1.0.dist-info/RECORD +141 -0
  139. ctf_flightdeck-0.1.0.dist-info/WHEEL +4 -0
  140. ctf_flightdeck-0.1.0.dist-info/entry_points.txt +5 -0
  141. 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,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -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 ""