minimalcode 1.5.2__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 (58) hide show
  1. minimalcode/__init__.py +3 -0
  2. minimalcode/__main__.py +3 -0
  3. minimalcode/agent.py +1084 -0
  4. minimalcode/cli.py +1520 -0
  5. minimalcode/clipboard.py +212 -0
  6. minimalcode/config.py +1065 -0
  7. minimalcode/defaults.json +491 -0
  8. minimalcode/errors.py +31 -0
  9. minimalcode/highlight/__init__.py +47 -0
  10. minimalcode/highlight/code.py +261 -0
  11. minimalcode/highlight/color.py +58 -0
  12. minimalcode/highlight/markdown.py +710 -0
  13. minimalcode/highlight/palette.py +73 -0
  14. minimalcode/highlight/sanitize.py +175 -0
  15. minimalcode/highlight/thinking.py +71 -0
  16. minimalcode/httpclient.py +278 -0
  17. minimalcode/importer.py +253 -0
  18. minimalcode/prompt.py +195 -0
  19. minimalcode/provider/__init__.py +28 -0
  20. minimalcode/provider/anthropic.py +805 -0
  21. minimalcode/provider/anthropic_env.py +161 -0
  22. minimalcode/provider/chat.py +394 -0
  23. minimalcode/provider/client.py +243 -0
  24. minimalcode/provider/common.py +176 -0
  25. minimalcode/provider/deepseek_web.py +1158 -0
  26. minimalcode/provider/factory.py +63 -0
  27. minimalcode/provider/responses.py +692 -0
  28. minimalcode/provider/sha3_wasm_bg.wasm +0 -0
  29. minimalcode/provider/transport.py +241 -0
  30. minimalcode/provider/types.py +54 -0
  31. minimalcode/sandbox/__init__.py +64 -0
  32. minimalcode/sandbox/audit.py +120 -0
  33. minimalcode/sandbox/backends.py +343 -0
  34. minimalcode/sandbox/execution.py +435 -0
  35. minimalcode/sandbox/network.py +793 -0
  36. minimalcode/sandbox/policy.py +253 -0
  37. minimalcode/sandbox/runner.py +347 -0
  38. minimalcode/sandbox/sensitive.py +286 -0
  39. minimalcode/session.py +465 -0
  40. minimalcode/shell.py +333 -0
  41. minimalcode/subagent.py +120 -0
  42. minimalcode/tools/__init__.py +46 -0
  43. minimalcode/tools/files.py +1139 -0
  44. minimalcode/tools/process.py +164 -0
  45. minimalcode/tools/remote.py +74 -0
  46. minimalcode/tools/runtime.py +219 -0
  47. minimalcode/tools/schemas.py +418 -0
  48. minimalcode/tools/search.py +521 -0
  49. minimalcode/tools/search_tools.py +583 -0
  50. minimalcode/tools/validation.py +99 -0
  51. minimalcode/vision.py +231 -0
  52. minimalcode/web.py +238 -0
  53. minimalcode-1.5.2.dist-info/METADATA +646 -0
  54. minimalcode-1.5.2.dist-info/RECORD +58 -0
  55. minimalcode-1.5.2.dist-info/WHEEL +5 -0
  56. minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
  57. minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
  58. minimalcode-1.5.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,73 @@
1
+ """ANSI palettes and low-level color helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ RESET = "\x1b[0m"
8
+ ACTIVITY_TAG = "\x1b[38;5;114m"
9
+
10
+ # Both palettes expose the same keys so renderers never branch on the theme.
11
+ _PALETTES = {
12
+ "dark": {
13
+ "h1": "\x1b[1;38;5;51m",
14
+ "h2": "\x1b[1;38;5;45m",
15
+ "h3": "\x1b[1;38;5;48m",
16
+ "h4": "\x1b[1;38;5;111m",
17
+ "h5": "\x1b[1;38;5;249m",
18
+ "h6": "\x1b[2;38;5;245m",
19
+ "rule": "\x1b[38;5;238m",
20
+ "bold": "\x1b[1;38;5;229m",
21
+ "italic": "\x1b[3;38;5;250m",
22
+ "strike": "\x1b[9;38;5;245m",
23
+ "inline_code": "\x1b[38;5;222;48;5;236m",
24
+ "code_comment": "\x1b[3;38;5;244m",
25
+ "code_string": "\x1b[38;5;186m",
26
+ "code_number": "\x1b[38;5;141m",
27
+ "code_keyword": "\x1b[38;5;197m",
28
+ "code_name": "\x1b[38;5;81m",
29
+ "code_variable": "\x1b[38;5;114m",
30
+ "bullet": "\x1b[38;5;214m",
31
+ "quote": "\x1b[38;5;244m",
32
+ "link": "\x1b[4;38;5;75m",
33
+ "url": "\x1b[38;5;242m",
34
+ },
35
+ "light": {
36
+ "h1": "\x1b[1;38;5;25m",
37
+ "h2": "\x1b[1;38;5;24m",
38
+ "h3": "\x1b[1;38;5;29m",
39
+ "h4": "\x1b[1;38;5;55m",
40
+ "h5": "\x1b[1;38;5;238m",
41
+ "h6": "\x1b[2;38;5;240m",
42
+ "rule": "\x1b[38;5;250m",
43
+ "bold": "\x1b[1;38;5;130m",
44
+ "italic": "\x1b[3;38;5;238m",
45
+ "strike": "\x1b[9;38;5;240m",
46
+ "inline_code": "\x1b[38;5;166;48;5;254m",
47
+ "code_comment": "\x1b[3;38;5;244m",
48
+ "code_string": "\x1b[38;5;130m",
49
+ "code_number": "\x1b[38;5;91m",
50
+ "code_keyword": "\x1b[38;5;161m",
51
+ "code_name": "\x1b[38;5;25m",
52
+ "code_variable": "\x1b[38;5;28m",
53
+ "bullet": "\x1b[38;5;166m",
54
+ "quote": "\x1b[38;5;243m",
55
+ "link": "\x1b[4;38;5;25m",
56
+ "url": "\x1b[38;5;242m",
57
+ },
58
+ }
59
+
60
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
61
+
62
+
63
+ def strip_ansi(text: str) -> str:
64
+ """Return text without SGR color sequences."""
65
+ # Only SGR (``ESC[...m``) is removed, because that is all this renderer writes;
66
+ # arbitrary control sequences from model or shell output are handled by
67
+ # ``terminal sanitization`` instead.
68
+ return _ANSI_RE.sub("", text)
69
+
70
+
71
+ def _styled(text: str, style: str) -> str:
72
+ """Wrap ``text`` in an SGR style and the reset sequence."""
73
+ return f"{style}{text}{RESET}"
@@ -0,0 +1,175 @@
1
+ """Terminal control-sequence sanitization for untrusted streamed text."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Cap on the held tail. A real escape sequence is a few bytes, so anything larger
6
+ # means the stream opened a sequence that never terminated; dropping it keeps a
7
+ # hostile or broken stream from growing the buffer for the rest of the reply.
8
+ _CONTROL_PENDING_LIMIT = 64_000
9
+
10
+
11
+ def _csi_length(data: str, start: int) -> int | None:
12
+ """Return the length of a CSI sequence beginning at ``start``."""
13
+ index = start + 2
14
+ while index < len(data):
15
+ code = ord(data[index])
16
+ if 0x40 <= code <= 0x7E:
17
+ return index + 1 - start
18
+ if not 0x20 <= code <= 0x3F:
19
+ # Malformed CSI: consume only the ESC and '['.
20
+ return 2
21
+ index += 1
22
+ return None
23
+
24
+
25
+ def _c1_csi_length(data: str, start: int) -> int | None:
26
+ """Return the length of a single-byte C1 CSI (0x9B) sequence."""
27
+ index = start + 1
28
+ while index < len(data):
29
+ code = ord(data[index])
30
+ if 0x40 <= code <= 0x7E:
31
+ return index + 1 - start
32
+ if not 0x20 <= code <= 0x3F:
33
+ return 1
34
+ index += 1
35
+ return None
36
+
37
+
38
+ def _string_sequence_length(data: str, start: int, sequence_start: int) -> int | None:
39
+ """Return the length of an OSC/DCS-style string sequence."""
40
+ index = start
41
+ while index < len(data):
42
+ # OSC/DCS strings end with BEL (xterm) or ST (``ESC \``); both forms appear
43
+ # in the wild, so either terminates the sequence.
44
+ if data[index] == "\x07" or data[index] == "\x9c":
45
+ # BEL (xterm), ST (``ESC \``), and the single-byte C1 ST all
46
+ # terminate an OSC/DCS-style string.
47
+ return index + 1 - sequence_start
48
+ if data[index] == "\x1b":
49
+ if index + 1 >= len(data):
50
+ # A trailing ESC may be the first half of ST; hold the sequence.
51
+ return None
52
+ if data[index + 1] == "\\":
53
+ return index + 2 - sequence_start
54
+ index += 1
55
+ return None
56
+
57
+
58
+ class TerminalSanitizer:
59
+ """Strip terminal control sequences before writing untrusted text.
60
+
61
+ Model output and shell output are untrusted, but they still reach a real
62
+ terminal. Keep newlines and tabs, drop every other C0/C1 control character,
63
+ and remove complete or partially streamed escape sequences. Splitting the
64
+ state across ``feed`` calls also covers sequences cut between stream deltas.
65
+ """
66
+
67
+ def __init__(self) -> None:
68
+ self._pending = ""
69
+
70
+ @staticmethod
71
+ def _escape_length(data: str, start: int) -> int | None:
72
+ if start + 1 >= len(data):
73
+ return None
74
+ marker = data[start + 1]
75
+ if marker == "[":
76
+ return _csi_length(data, start)
77
+ if marker == "]":
78
+ return _string_sequence_length(data, start + 2, start)
79
+ if marker in "PX^_":
80
+ # DCS (P), SOS (X), PM (^), and APC (_) all use the same
81
+ # terminator-terminated body as OSC.
82
+ return _string_sequence_length(data, start + 2, start)
83
+ # Anything else is a two-byte escape (``ESC 7``, ``ESC c``). Two is returned
84
+ # rather than one so the parameter byte is consumed too; leaving it would
85
+ # print a stray character where the sequence used to be. A control byte
86
+ # after ESC cannot be a two-byte final, so only the ESC is removed —
87
+ # otherwise an isolated ESC before a newline would join two lines.
88
+ code = ord(marker)
89
+ if 0x20 <= code <= 0x2F:
90
+ # ``ESC`` plus intermediate bytes runs up to a final byte in
91
+ # 0x30-0x7E (``ESC ( B`` from `tput sgr0`). Consuming only the
92
+ # intermediate byte printed that final byte as ordinary text.
93
+ index = start + 1
94
+ while index < len(data):
95
+ current = ord(data[index])
96
+ if 0x20 <= current <= 0x2F:
97
+ index += 1
98
+ continue
99
+ if 0x30 <= current <= 0x7E:
100
+ return index + 1 - start
101
+ # A control byte cannot be a final byte: drop the prefix only.
102
+ return index - start
103
+ return None
104
+ return 2 if 0x20 <= code <= 0x7E else 1
105
+
106
+ def feed(self, text: str) -> str:
107
+ """Consume a delta and return the safe text that is complete so far."""
108
+ if not text:
109
+ return ""
110
+ data = self._pending + text
111
+ self._pending = ""
112
+ rendered: list[str] = []
113
+ index = 0
114
+ while index < len(data):
115
+ char = data[index]
116
+ if char == "\x1b":
117
+ length = self._escape_length(data, index)
118
+ if length is None:
119
+ # Hold an incomplete escape until the next delta.
120
+ self._pending = data[index:]
121
+ break
122
+ index += length
123
+ continue
124
+ if char == "\r":
125
+ if index + 1 >= len(data):
126
+ self._pending = "\r"
127
+ break
128
+ if data[index + 1] == "\n":
129
+ rendered.append("\r\n")
130
+ index += 2
131
+ else:
132
+ # A lone CR would move the cursor; drop it.
133
+ index += 1
134
+ continue
135
+ code = ord(char)
136
+ if char in "\n\t":
137
+ rendered.append(char)
138
+ elif code < 0x20 or code == 0x7F:
139
+ pass
140
+ elif 0x80 <= code <= 0x9F:
141
+ # Single-byte C1 controls include complete CSI/OSC/DCS
142
+ # sequences. Dropping only the lead byte printed their payload
143
+ # as ordinary text, so consume the whole sequence.
144
+ if code == 0x9B:
145
+ length = _c1_csi_length(data, index)
146
+ if length is None:
147
+ self._pending = data[index:]
148
+ break
149
+ index += length
150
+ continue
151
+ if code in {0x90, 0x98, 0x9D, 0x9E, 0x9F}:
152
+ length = _string_sequence_length(data, index + 1, index)
153
+ if length is None:
154
+ self._pending = data[index:]
155
+ break
156
+ index += length
157
+ continue
158
+ else:
159
+ rendered.append(char)
160
+ index += 1
161
+ if len(self._pending) > _CONTROL_PENDING_LIMIT:
162
+ # A hostile stream must not grow the pending buffer without bound.
163
+ self._pending = ""
164
+ return "".join(rendered)
165
+
166
+ def finish(self) -> str:
167
+ """Discard a trailing lone CR or unterminated escape sequence."""
168
+ self._pending = ""
169
+ return ""
170
+
171
+
172
+ def sanitize_terminal_text(text: str) -> str:
173
+ """Return ``text`` with all terminal escape/control sequences removed."""
174
+ sanitizer = TerminalSanitizer()
175
+ return sanitizer.feed(text) + sanitizer.finish()
@@ -0,0 +1,71 @@
1
+ """Render streamed reasoning text in the Markdown six-level heading style."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TextIO
6
+
7
+ from . import color
8
+ from .palette import _PALETTES, _styled
9
+ from .sanitize import TerminalSanitizer
10
+
11
+
12
+ def _detect_color(stream: TextIO) -> bool:
13
+ """Delegate to the color module so a single patch target stays effective."""
14
+ return color._supports_color(stream)
15
+
16
+
17
+ class ThinkingRenderer:
18
+ """Write reasoning deltas the way a ``######`` heading is styled.
19
+
20
+ Reasoning text is not Markdown, so it does not go through the parser: each
21
+ delta is sanitized and wrapped in the palette's ``h6`` sequence as one block.
22
+ Nothing is written when the stream cannot take ANSI, which keeps redirected
23
+ output and ``--json`` equal to the answer alone.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ stream: TextIO,
29
+ *,
30
+ theme: str = "dark",
31
+ color: bool | None = None,
32
+ ) -> None:
33
+ self.stream = stream
34
+ self.style = _PALETTES.get(theme, _PALETTES["dark"])["h6"]
35
+ self.enabled = _detect_color(stream) if color is None else color
36
+ self._sanitizer = TerminalSanitizer()
37
+ self._last_char = ""
38
+ self._open = False
39
+
40
+ def feed(self, delta: str) -> None:
41
+ """Write one reasoning delta in the heading style."""
42
+ if not self.enabled:
43
+ return
44
+ text = self._sanitizer.feed(delta)
45
+ if not text:
46
+ return
47
+ self.stream.write(_styled(text, self.style))
48
+ self.stream.flush()
49
+ self._last_char = text[-1]
50
+ self._open = True
51
+
52
+ def finish(self) -> bool:
53
+ """Close the reasoning block so the answer starts on its own line.
54
+
55
+ Returns True when a newline was written to the shared stream, so a
56
+ Markdown renderer drawing after this one can resynchronize its line
57
+ state.
58
+ """
59
+ # Reset even when no complete delta was written: a block whose entire
60
+ # content is still held as an incomplete escape sequence must not poison
61
+ # the next reasoning block with that pending state.
62
+ self._sanitizer.finish()
63
+ wrote_newline = False
64
+ if self.enabled and self._open and self._last_char != "\n":
65
+ self.stream.write("\n")
66
+ self.stream.flush()
67
+ wrote_newline = True
68
+ self._sanitizer = TerminalSanitizer()
69
+ self._last_char = ""
70
+ self._open = False
71
+ return wrote_newline
@@ -0,0 +1,278 @@
1
+ """Shared HTTP client construction and connection-retry policy.
2
+
3
+ The provider, web, and vision clients all speak HTTP to a configured endpoint,
4
+ so they share two decisions: which proxy settings apply, and how a request that
5
+ never produced a response is retried. Both used to be copied per client, which
6
+ is how a proxy bug could exist in one copy and not the others.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ipaddress
12
+ import math
13
+ import time
14
+ from typing import Any
15
+ from urllib.parse import urlsplit, urlunsplit
16
+
17
+ import httpx2
18
+
19
+ from . import __version__
20
+ from .errors import ToolError
21
+
22
+ # A request that never produced a response is safe to repeat: the provider did
23
+ # not start a billable generation, so a dropped keep-alive or a proxy reset is
24
+ # worth retrying. Failures after streaming starts are handled by the callers and
25
+ # are never retried, because the answer may already have been shown or billed.
26
+ CONNECTION_ATTEMPTS = 11 # the first try plus 10 retries
27
+ CONNECTION_BACKOFF_SECONDS = 0.5
28
+ CONNECTION_BACKOFF_CAP_SECONDS = 8.0
29
+ RETRYABLE_CONNECTION_ERRORS = (
30
+ httpx2.ConnectError,
31
+ httpx2.ConnectTimeout,
32
+ httpx2.RemoteProtocolError,
33
+ )
34
+
35
+
36
+ def connection_backoff(attempt: int) -> float:
37
+ """Return capped exponential backoff for a zero-based retry attempt."""
38
+ delay = CONNECTION_BACKOFF_SECONDS * (2**attempt)
39
+ return min(delay, CONNECTION_BACKOFF_CAP_SECONDS)
40
+
41
+
42
+ def join_url_path(base_url: str, path: str) -> str:
43
+ """Append a path segment before any query string or fragment."""
44
+ parsed = urlsplit(base_url)
45
+ base_path = parsed.path.rstrip("/")
46
+ suffix = path.strip("/")
47
+ joined = f"{base_path}/{suffix}" if suffix else base_path
48
+ return urlunsplit(
49
+ (parsed.scheme, parsed.netloc, joined, parsed.query, parsed.fragment)
50
+ )
51
+
52
+
53
+ def is_loopback_url(url: str) -> bool:
54
+ """Return True when ``url`` targets this machine.
55
+
56
+ Environment proxies are routinely configured for remote traffic (Clash on
57
+ ``127.0.0.1:7897``, corporate MITM proxies, and so on). Sending a request for
58
+ ``http://127.0.0.1:11434`` to such a proxy cannot work: a stopped local proxy
59
+ turns every local-provider call into a 502, and a running one has no route to
60
+ the caller's loopback interface. Loopback endpoints therefore opt out of
61
+ environment proxies unconditionally, which is also why the network-failure
62
+ tests no longer depend on the host having no proxy configured.
63
+ """
64
+ try:
65
+ host = urlsplit(url).hostname
66
+ except ValueError:
67
+ return False
68
+ if not host:
69
+ return False
70
+ lowered = host.lower().rstrip(".")
71
+ if lowered == "localhost" or lowered.endswith(".localhost"):
72
+ return True
73
+ if lowered == "0.0.0.0":
74
+ # Connecting to the wildcard address reaches this machine on the
75
+ # platforms where it is accepted, so a proxy would break it the same
76
+ # way it breaks 127.0.0.1.
77
+ return True
78
+ try:
79
+ return ipaddress.ip_address(lowered).is_loopback
80
+ except ValueError:
81
+ return False
82
+
83
+
84
+ def merge_headers(*sources: Any) -> dict[str, Any]:
85
+ """Merge header mappings left to right, letting later entries win."""
86
+ merged: dict[str, Any] = {}
87
+ for source in sources:
88
+ if isinstance(source, dict):
89
+ merged.update(source)
90
+ return merged
91
+
92
+
93
+ def build_headers(api_key: str | None, custom: Any) -> dict[str, str]:
94
+ """Build request headers, honouring a caller-supplied Authorization."""
95
+ headers = {
96
+ "Content-Type": "application/json",
97
+ "User-Agent": f"MinimalCode/{__version__}",
98
+ }
99
+ if isinstance(custom, dict):
100
+ headers.update({str(key): str(value) for key, value in custom.items()})
101
+ if api_key and not any(key.lower() == "authorization" for key in headers):
102
+ headers["Authorization"] = f"Bearer {api_key}"
103
+ return headers
104
+
105
+
106
+ def resolve_trust_env(
107
+ base_url: str,
108
+ *,
109
+ transport: httpx2.BaseTransport | None,
110
+ configured: Any,
111
+ ) -> bool:
112
+ """Decide whether environment proxy settings apply to this client.
113
+
114
+ An explicit transport means the caller injected the connection (tests and
115
+ embedders); proxy mounts would silently shadow it, so they stay off unless
116
+ asked for. Otherwise loopback targets ignore the environment, and everything
117
+ else keeps the previous default of honouring it.
118
+ """
119
+ if isinstance(configured, bool):
120
+ return configured
121
+ if transport is not None:
122
+ return False
123
+ return not is_loopback_url(base_url)
124
+
125
+
126
+ def build_client(
127
+ *,
128
+ base_url: str,
129
+ headers: dict[str, str],
130
+ timeout: float,
131
+ transport: httpx2.BaseTransport | None = None,
132
+ trust_env: Any = None,
133
+ ) -> httpx2.Client:
134
+ """Build one pooled client with the shared timeout and proxy policy.
135
+
136
+ Redirects are never followed automatically for JSON API calls. A 307/308
137
+ replays the complete request body (conversation, tool output, image data)
138
+ at the new origin, and httpx2 only strips ``Authorization`` on a cross
139
+ origin redirect; ``x-api-key`` and custom credential headers survive.
140
+ Callers surface a 3xx response as a provider error instead.
141
+ """
142
+ # The scalar timeout bounds a whole generation; capping the connect phase
143
+ # keeps an unreachable host from spending that entire budget on a SYN.
144
+ return httpx2.Client(
145
+ timeout=httpx2.Timeout(timeout, connect=min(20.0, timeout)),
146
+ headers=headers,
147
+ transport=transport,
148
+ follow_redirects=False,
149
+ trust_env=resolve_trust_env(
150
+ base_url, transport=transport, configured=trust_env
151
+ ),
152
+ )
153
+
154
+
155
+ def post_json(
156
+ client: httpx2.Client,
157
+ url: str,
158
+ payload: dict[str, Any],
159
+ *,
160
+ label: str,
161
+ timeout: float,
162
+ deadline: float | None = None,
163
+ ) -> dict[str, Any]:
164
+ """POST a JSON body and return the parsed object, retrying connect failures.
165
+
166
+ ``deadline`` is an absolute ``time.monotonic()`` value bounding the whole
167
+ call, including backoff sleeps; it defaults to one ``timeout`` window. Only
168
+ failures raised before a response arrives are retried.
169
+ """
170
+ budget_end = deadline if deadline is not None else time.monotonic() + timeout
171
+ attempts = 0
172
+ last_error: httpx2.HTTPError | None = None
173
+ last_status: int | None = None
174
+ status_retried = False
175
+ while attempts < CONNECTION_ATTEMPTS:
176
+ remaining = budget_end - time.monotonic()
177
+ if remaining <= 0:
178
+ break
179
+ attempts += 1
180
+ response: httpx2.Response | None = None
181
+ try:
182
+ response = client.post(
183
+ url,
184
+ json=payload,
185
+ timeout=httpx2.Timeout(remaining, connect=min(20.0, remaining)),
186
+ )
187
+ if response.status_code in {429, 503} and not status_retried:
188
+ status_retried = True
189
+ status_code = response.status_code
190
+ last_status = status_code
191
+ try:
192
+ requested_delay = float(response.headers.get("Retry-After", "1"))
193
+ if not math.isfinite(requested_delay):
194
+ requested_delay = 1.0
195
+ delay = min(max(requested_delay, 0.2), 5.0)
196
+ except ValueError:
197
+ delay = 1.0
198
+ response.close()
199
+ response = None
200
+ remaining = budget_end - time.monotonic()
201
+ if remaining <= 0:
202
+ # The single status retry needs time to run; without it the
203
+ # final message would report a connection error that never
204
+ # happened ("after 1 attempts: None") and drop the status.
205
+ raise ToolError(
206
+ f"provider {label} request failed with HTTP {status_code} "
207
+ "and no time was left to retry"
208
+ )
209
+ time.sleep(min(delay, remaining))
210
+ continue
211
+ if 300 <= response.status_code < 400:
212
+ location = response.headers.get("Location", "")
213
+ raise ToolError(
214
+ f"provider {label} request was redirected to "
215
+ f"{location or 'another location'}; refusing to forward "
216
+ "credentials and request data"
217
+ )
218
+ if response.status_code >= 400:
219
+ detail = response.text[:2000]
220
+ raise ToolError(
221
+ f"provider {label} request failed with HTTP "
222
+ f"{response.status_code}: {detail}"
223
+ )
224
+ try:
225
+ data = response.json()
226
+ except ValueError as exc:
227
+ raise ToolError(
228
+ f"provider {label} request returned invalid JSON"
229
+ ) from exc
230
+ except RETRYABLE_CONNECTION_ERRORS as exc:
231
+ last_error = exc
232
+ if attempts >= CONNECTION_ATTEMPTS:
233
+ break
234
+ remaining = budget_end - time.monotonic()
235
+ if remaining <= 0:
236
+ break
237
+ delay = min(connection_backoff(attempts - 1), remaining)
238
+ if delay > 0:
239
+ time.sleep(delay)
240
+ continue
241
+ except (httpx2.HTTPError, httpx2.InvalidURL) as exc:
242
+ raise ToolError(f"provider {label} request failed: {exc}") from exc
243
+ finally:
244
+ if response is not None:
245
+ response.close()
246
+ if not isinstance(data, dict):
247
+ raise ToolError(f"provider {label} request returned an invalid response")
248
+ return data
249
+ if attempts == 0:
250
+ # The budget was already spent before the first POST; reporting
251
+ # "after 0 attempts: None" hid that and printed a literal None.
252
+ raise ToolError(
253
+ f"provider {label} request timed out before the first attempt: "
254
+ "the request budget was already spent"
255
+ )
256
+ if last_error is None and last_status is not None:
257
+ raise ToolError(
258
+ f"provider {label} request failed with HTTP {last_status} "
259
+ "and no time was left to retry"
260
+ )
261
+ raise ToolError(
262
+ f"provider {label} request failed after {attempts} attempts: {last_error}"
263
+ ) from last_error
264
+
265
+
266
+ __all__ = [
267
+ "CONNECTION_ATTEMPTS",
268
+ "CONNECTION_BACKOFF_CAP_SECONDS",
269
+ "CONNECTION_BACKOFF_SECONDS",
270
+ "RETRYABLE_CONNECTION_ERRORS",
271
+ "build_client",
272
+ "build_headers",
273
+ "connection_backoff",
274
+ "is_loopback_url",
275
+ "merge_headers",
276
+ "post_json",
277
+ "resolve_trust_env",
278
+ ]