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,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}")
|