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.
- minimalcode/__init__.py +3 -0
- minimalcode/__main__.py +3 -0
- minimalcode/agent.py +1084 -0
- minimalcode/cli.py +1520 -0
- minimalcode/clipboard.py +212 -0
- minimalcode/config.py +1065 -0
- minimalcode/defaults.json +491 -0
- minimalcode/errors.py +31 -0
- minimalcode/highlight/__init__.py +47 -0
- minimalcode/highlight/code.py +261 -0
- minimalcode/highlight/color.py +58 -0
- minimalcode/highlight/markdown.py +710 -0
- minimalcode/highlight/palette.py +73 -0
- minimalcode/highlight/sanitize.py +175 -0
- minimalcode/highlight/thinking.py +71 -0
- minimalcode/httpclient.py +278 -0
- minimalcode/importer.py +253 -0
- minimalcode/prompt.py +195 -0
- minimalcode/provider/__init__.py +28 -0
- minimalcode/provider/anthropic.py +805 -0
- minimalcode/provider/anthropic_env.py +161 -0
- minimalcode/provider/chat.py +394 -0
- minimalcode/provider/client.py +243 -0
- minimalcode/provider/common.py +176 -0
- minimalcode/provider/deepseek_web.py +1158 -0
- minimalcode/provider/factory.py +63 -0
- minimalcode/provider/responses.py +692 -0
- minimalcode/provider/sha3_wasm_bg.wasm +0 -0
- minimalcode/provider/transport.py +241 -0
- minimalcode/provider/types.py +54 -0
- minimalcode/sandbox/__init__.py +64 -0
- minimalcode/sandbox/audit.py +120 -0
- minimalcode/sandbox/backends.py +343 -0
- minimalcode/sandbox/execution.py +435 -0
- minimalcode/sandbox/network.py +793 -0
- minimalcode/sandbox/policy.py +253 -0
- minimalcode/sandbox/runner.py +347 -0
- minimalcode/sandbox/sensitive.py +286 -0
- minimalcode/session.py +465 -0
- minimalcode/shell.py +333 -0
- minimalcode/subagent.py +120 -0
- minimalcode/tools/__init__.py +46 -0
- minimalcode/tools/files.py +1139 -0
- minimalcode/tools/process.py +164 -0
- minimalcode/tools/remote.py +74 -0
- minimalcode/tools/runtime.py +219 -0
- minimalcode/tools/schemas.py +418 -0
- minimalcode/tools/search.py +521 -0
- minimalcode/tools/search_tools.py +583 -0
- minimalcode/tools/validation.py +99 -0
- minimalcode/vision.py +231 -0
- minimalcode/web.py +238 -0
- minimalcode-1.5.2.dist-info/METADATA +646 -0
- minimalcode-1.5.2.dist-info/RECORD +58 -0
- minimalcode-1.5.2.dist-info/WHEEL +5 -0
- minimalcode-1.5.2.dist-info/entry_points.txt +2 -0
- minimalcode-1.5.2.dist-info/licenses/LICENSE +201 -0
- 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
|
+
]
|