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,243 @@
1
+ """Provider client shared by the Chat, Responses, Anthropic, and web mixins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Callable
7
+ from typing import Any
8
+ from urllib.parse import urlsplit, urlunsplit
9
+
10
+ import httpx2
11
+
12
+ from ..config import provider_api_key
13
+ from ..errors import ProviderError
14
+ from ..httpclient import build_client, build_headers
15
+ from .anthropic import AnthropicProtocolMixin
16
+ from .chat import ChatProtocolMixin
17
+ from .deepseek_web import CHAT_PATH, DeepSeekWebProtocolMixin
18
+ from .responses import ResponsesProtocolMixin
19
+ from .transport import TransportMixin
20
+ from .types import ProviderReply
21
+
22
+
23
+ class OpenAICompatibleProvider(
24
+ TransportMixin,
25
+ ChatProtocolMixin,
26
+ ResponsesProtocolMixin,
27
+ AnthropicProtocolMixin,
28
+ DeepSeekWebProtocolMixin,
29
+ ):
30
+ """HTTP client for OpenAI Chat/Responses and Anthropic Messages APIs."""
31
+
32
+ def __init__(
33
+ self,
34
+ *,
35
+ name: str,
36
+ config: dict[str, Any],
37
+ model: str,
38
+ thinking: str,
39
+ transport: httpx2.BaseTransport | None = None,
40
+ ):
41
+ protocol = str(config.get("protocol", "openai_chat"))
42
+ # Unknown names are rejected rather than defaulted to Chat: the dialects
43
+ # need different payload keys and parsers, so a fallback would send a
44
+ # request the server rejects or we misparse.
45
+ if protocol not in {
46
+ "openai_chat",
47
+ "openai_responses",
48
+ "anthropic_messages",
49
+ "deepseek_web",
50
+ }:
51
+ raise ProviderError(
52
+ f"provider {name!r} uses unsupported protocol {protocol!r}; "
53
+ "expected 'openai_chat', 'openai_responses', "
54
+ "'anthropic_messages', or 'deepseek_web'"
55
+ )
56
+ self.name = name
57
+ self.config = config
58
+ self.model = model
59
+ self.thinking = thinking
60
+ self.protocol = protocol
61
+ # `_endpoint` appends the protocol path, so a trailing slash here would
62
+ # produce `//chat/completions` on servers that keep the path verbatim.
63
+ self.base_url = str(config.get("base_url", "")).rstrip("/")
64
+ if not self.base_url:
65
+ raise ProviderError(f"provider {name!r} requires base_url")
66
+ try:
67
+ parsed_base = urlsplit(self.base_url)
68
+ hostname = parsed_base.hostname
69
+ # Reading `.port` is deliberate: it raises on a malformed port here,
70
+ # at construction, instead of on every request that follows.
71
+ _ = parsed_base.port
72
+ except ValueError as exc:
73
+ raise ProviderError(f"provider {name!r} has an invalid base_url") from exc
74
+ if parsed_base.scheme not in {"http", "https"} or not hostname:
75
+ raise ProviderError(f"provider {name!r} has an invalid base_url")
76
+ try:
77
+ self.context_window = int(config.get("context_window", 128_000))
78
+ self.timeout_seconds = float(config.get("timeout_seconds", 120))
79
+ self.max_tokens = int(config.get("max_tokens", 8192))
80
+ except (TypeError, ValueError, OverflowError) as exc:
81
+ raise ProviderError(
82
+ f"provider {name!r} has invalid numeric settings"
83
+ ) from exc
84
+ if self.context_window < 1:
85
+ raise ProviderError(f"provider {name!r} requires a positive context_window")
86
+ if not math.isfinite(self.timeout_seconds) or self.timeout_seconds <= 0:
87
+ raise ProviderError(
88
+ f"provider {name!r} requires a positive timeout_seconds"
89
+ )
90
+ if self.max_tokens < 1:
91
+ raise ProviderError(f"provider {name!r} requires a positive max_tokens")
92
+ self.stream_enabled = bool(config.get("stream", True))
93
+ self.include_usage = bool(config.get("include_usage", True))
94
+ self.send_reasoning_effort = bool(config.get("send_reasoning_effort", False))
95
+ # Optional sink for retry notices; the agent points this at its activity
96
+ # stream so ten silent retries do not look like a hang.
97
+ self.notify: Callable[[str], None] | None = None
98
+
99
+ api_key = provider_api_key(config)
100
+ key_env = config.get("api_key_env")
101
+ if not api_key and key_env and config.get("require_api_key", True):
102
+ raise ProviderError(
103
+ f"provider {name!r} requires API key in environment variable {key_env}"
104
+ )
105
+
106
+ if protocol == "deepseek_web" and api_key:
107
+ # The browser sends `Bearer <token>`, but people paste the whole
108
+ # header value into DEEPSEEK_WEB_TOKEN, which produced
109
+ # `Authorization: Bearer Bearer <token>` and a 401.
110
+ stripped = api_key.strip()
111
+ if stripped.lower().startswith("bearer "):
112
+ stripped = stripped[len("bearer ") :].strip()
113
+ api_key = stripped or None
114
+ custom_headers = config.get("headers")
115
+ custom_names = (
116
+ {str(key).lower() for key in custom_headers}
117
+ if isinstance(custom_headers, dict)
118
+ else set()
119
+ )
120
+ headers = build_headers(api_key, custom_headers)
121
+ if protocol == "anthropic_messages":
122
+ # Anthropic authenticates with x-api-key; Authorization is accepted
123
+ # only by some gateways (DeepSeek accepts both). A caller-supplied
124
+ # auth header always wins over the one derived from api_key.
125
+ if (
126
+ api_key
127
+ and "x-api-key" not in custom_names
128
+ and "authorization" not in custom_names
129
+ ):
130
+ headers["x-api-key"] = api_key
131
+ if "authorization" not in custom_names:
132
+ headers.pop("Authorization", None)
133
+ if "anthropic-version" not in custom_names:
134
+ headers["anthropic-version"] = "2023-06-01"
135
+ self.headers = headers
136
+ self._client = build_client(
137
+ base_url=self.base_url,
138
+ headers=headers,
139
+ timeout=self.timeout_seconds,
140
+ transport=transport,
141
+ trust_env=config.get("trust_env"),
142
+ )
143
+
144
+ def close(self) -> None:
145
+ """Close the underlying HTTP client and its connection pool."""
146
+ self._client.close()
147
+
148
+ def __enter__(self):
149
+ return self
150
+
151
+ def __exit__(self, exc_type, exc, tb):
152
+ self.close()
153
+ return False
154
+
155
+ def _endpoint(self) -> str:
156
+ """Return the full protocol endpoint, accepting a full URL as-is.
157
+
158
+ A manually configured endpoint may carry a query string (Azure OpenAI's
159
+ ``api-version`` is the common case). Appending a path with plain string
160
+ concatenation would put it after the query, so path and query are
161
+ joined separately.
162
+ """
163
+ parsed = urlsplit(self.base_url)
164
+ path = parsed.path.rstrip("/")
165
+ if self.protocol == "deepseek_web":
166
+ new_path = path if path.endswith(CHAT_PATH) else f"{path}{CHAT_PATH}"
167
+ return urlunsplit(
168
+ (parsed.scheme, parsed.netloc, new_path, parsed.query, parsed.fragment)
169
+ )
170
+ if self.protocol == "anthropic_messages":
171
+ if path.endswith("/messages"):
172
+ new_path = path
173
+ elif path.endswith("/v1"):
174
+ new_path = f"{path}/messages"
175
+ else:
176
+ # DeepSeek's Anthropic base is https://api.deepseek.com/anthropic,
177
+ # so the version segment is appended unless the caller supplied it.
178
+ new_path = f"{path}/v1/messages"
179
+ elif self.protocol == "openai_responses":
180
+ new_path = path if path.endswith("/responses") else f"{path}/responses"
181
+ else:
182
+ new_path = (
183
+ path
184
+ if path.endswith("/chat/completions")
185
+ else f"{path}/chat/completions"
186
+ )
187
+ return urlunsplit(
188
+ (parsed.scheme, parsed.netloc, new_path, parsed.query, parsed.fragment)
189
+ )
190
+
191
+ def stream_chat(
192
+ self,
193
+ messages: list[dict[str, Any]],
194
+ *,
195
+ tools: list[dict[str, Any]] | None = None,
196
+ on_text: Callable[[str], None] | None = None,
197
+ on_thinking: Callable[[str], None] | None = None,
198
+ ) -> ProviderReply:
199
+ """Send one conversation turn using the configured wire protocol."""
200
+ if self.protocol == "deepseek_web":
201
+ return self._ds_web_turn(
202
+ messages, tools, on_text=on_text, on_thinking=on_thinking
203
+ )
204
+ if self.protocol == "anthropic_messages":
205
+ payload = self._anthropic_payload(messages, tools)
206
+ if self.stream_enabled:
207
+ return self._anthropic_stream_request(
208
+ payload, on_text=on_text, on_thinking=on_thinking
209
+ )
210
+ return self._anthropic_regular_request(
211
+ payload, on_text=on_text, on_thinking=on_thinking
212
+ )
213
+ if self.protocol == "openai_responses":
214
+ payload = self._responses_payload(messages, tools)
215
+ if self.stream_enabled:
216
+ return self._responses_stream_request(
217
+ payload, on_text=on_text, on_thinking=on_thinking
218
+ )
219
+ return self._responses_regular_request(
220
+ payload, on_text=on_text, on_thinking=on_thinking
221
+ )
222
+ payload = self._chat_payload(messages, tools)
223
+ if self.stream_enabled:
224
+ return self._chat_stream_request(
225
+ payload, on_text=on_text, on_thinking=on_thinking
226
+ )
227
+ return self._chat_regular_request(
228
+ payload, on_text=on_text, on_thinking=on_thinking
229
+ )
230
+
231
+ def complete(self, messages: list[dict[str, Any]]) -> ProviderReply:
232
+ """Request one plain-text completion and reject abnormal finish reasons."""
233
+ reply = self.stream_chat(messages, tools=None, on_text=None)
234
+ if reply.finish_reason not in {None, "stop", "completed"}:
235
+ raise ProviderError(
236
+ "provider stopped the summary with finish reason "
237
+ f"{reply.finish_reason!r}"
238
+ )
239
+ return reply
240
+
241
+ def complete_text(self, messages: list[dict[str, Any]]) -> str:
242
+ """Request a plain text completion, returning only its content."""
243
+ return self.complete(messages).content
@@ -0,0 +1,176 @@
1
+ """Shared parsing helpers for OpenAI-compatible payloads and streams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Iterator
7
+ from typing import Any
8
+
9
+ import httpx2
10
+
11
+ from ..errors import ProviderError
12
+
13
+
14
+ def _is_json(text: str) -> bool:
15
+ """Return True when ``text`` is a complete JSON value."""
16
+ try:
17
+ json.loads(text)
18
+ except ValueError:
19
+ return False
20
+ return True
21
+
22
+
23
+ def iter_sse_data(lines: Iterable[str]) -> Iterator[str]:
24
+ """Yield one payload per SSE event, joining multi-line `data:` fields.
25
+
26
+ The event stream format allows a single event's payload to be split across
27
+ several `data:` lines. Parsing one line at a time made `json.loads` fail on
28
+ every fragment and dropped the event silently, so a gateway that wraps long
29
+ JSON produced an empty reply with no error at all.
30
+
31
+ Two framings are in the wild: a blank line terminates the event, and some
32
+ gateways simply put one complete payload on each `data:` line with no blank
33
+ separator. A buffer that already parses as complete JSON is therefore
34
+ yielded before the next line is appended.
35
+ """
36
+ buffer = ""
37
+ for raw in lines:
38
+ line = strip_sse_bom(raw).rstrip("\r\n")
39
+ if not line.strip():
40
+ if buffer:
41
+ yield buffer
42
+ buffer = ""
43
+ continue
44
+ if not line.startswith("data:"):
45
+ # `event:`, `id:`, `retry:`, and comments carry no JSON payload.
46
+ continue
47
+ payload = line[5:].removeprefix(" ")
48
+ if not payload:
49
+ continue
50
+ if buffer and _is_json(buffer):
51
+ yield buffer
52
+ buffer = ""
53
+ buffer = f"{buffer}\n{payload}" if buffer else payload
54
+ if buffer:
55
+ yield buffer
56
+
57
+
58
+ _USAGE_KEYS = frozenset(
59
+ {
60
+ "prompt_tokens",
61
+ "input_tokens",
62
+ "completion_tokens",
63
+ "output_tokens",
64
+ "total_tokens",
65
+ }
66
+ )
67
+
68
+
69
+ def normalize_usage(raw: Any) -> dict[str, int]:
70
+ """Normalize Chat and Responses usage objects to one stable key set."""
71
+ if not isinstance(raw, dict) or not any(key in raw for key in _USAGE_KEYS):
72
+ return {}
73
+ prompt = raw.get("prompt_tokens", raw.get("input_tokens", 0))
74
+ completion = raw.get("completion_tokens", raw.get("output_tokens", 0))
75
+ total = raw.get("total_tokens", 0)
76
+ prompt_details = raw.get("prompt_tokens_details") or raw.get("input_tokens_details")
77
+ completion_details = raw.get("completion_tokens_details") or raw.get(
78
+ "output_tokens_details"
79
+ )
80
+ cached = (
81
+ prompt_details.get("cached_tokens", 0)
82
+ if isinstance(prompt_details, dict)
83
+ else 0
84
+ )
85
+ reasoning = (
86
+ completion_details.get("reasoning_tokens", 0)
87
+ if isinstance(completion_details, dict)
88
+ else 0
89
+ )
90
+ try:
91
+ prompt_int = int(prompt or 0)
92
+ completion_int = int(completion or 0)
93
+ total_int = int(total or prompt_int + completion_int)
94
+ cached_int = int(cached or 0)
95
+ reasoning_int = int(reasoning or 0)
96
+ except (TypeError, ValueError, OverflowError):
97
+ # Usage is diagnostic only; a malformed object (including a non-finite
98
+ # JSON number such as Infinity) must not fail a turn.
99
+ return {}
100
+ return {
101
+ "prompt_tokens": prompt_int,
102
+ "completion_tokens": completion_int,
103
+ "total_tokens": total_int,
104
+ "cached_tokens": cached_int,
105
+ "reasoning_tokens": reasoning_int,
106
+ }
107
+
108
+
109
+ def content_text(value: Any) -> str:
110
+ """Flatten string, object, or multimodal-part content into plain text."""
111
+ if isinstance(value, str):
112
+ return value
113
+ if isinstance(value, dict):
114
+ text = value.get("text")
115
+ if isinstance(text, str):
116
+ return text
117
+ refusal = value.get("refusal")
118
+ return refusal if isinstance(refusal, str) else ""
119
+ if isinstance(value, list):
120
+ parts: list[str] = []
121
+ for item in value:
122
+ if isinstance(item, str):
123
+ parts.append(item)
124
+ elif isinstance(item, dict):
125
+ if isinstance(item.get("text"), str):
126
+ parts.append(item["text"])
127
+ elif isinstance(item.get("refusal"), str):
128
+ parts.append(item["refusal"])
129
+ # No separator is inserted because this same helper flattens streaming
130
+ # deltas and finished messages; adding one would make the reassembled
131
+ # text differ from what the model actually produced.
132
+ return "".join(parts)
133
+ return ""
134
+
135
+
136
+ def tool_arguments(value: Any) -> str:
137
+ """Return tool arguments as the JSON string the message format requires."""
138
+ # The wire format wants a JSON *string*, and "" is what models emit for a
139
+ # no-argument call; leaving it empty would fail json.loads on the next turn.
140
+ if isinstance(value, str):
141
+ return value or "{}"
142
+ if value is None:
143
+ return "{}"
144
+ try:
145
+ return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
146
+ except (TypeError, ValueError):
147
+ return "{}"
148
+
149
+
150
+ def strip_sse_bom(line: str) -> str:
151
+ """Remove a UTF-8 BOM that a gateway left at the start of an SSE line."""
152
+ return line.lstrip("\ufeff") if line else line
153
+
154
+
155
+ def is_event_stream(response: httpx2.Response) -> bool:
156
+ """Return True when the response advertises server-sent events."""
157
+ content_type = response.headers.get("content-type", "").lower()
158
+ return "text/event-stream" in content_type
159
+
160
+
161
+ def decode_json_body(body: bytes) -> tuple[bool, Any]:
162
+ """Attempt to decode a non-streaming response body."""
163
+ try:
164
+ return True, json.loads(body)
165
+ except ValueError:
166
+ return False, None
167
+
168
+
169
+ def stream_event_error(event: dict[str, Any]) -> ProviderError:
170
+ """Build a ProviderError from an SSE ``error`` event."""
171
+ error = event.get("error")
172
+ if isinstance(error, dict):
173
+ message = error.get("message") or error.get("type") or error
174
+ else:
175
+ message = error or event
176
+ return ProviderError(f"provider stream returned an error: {message}")