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,161 @@
1
+ """Claude Code style environment configuration for the Anthropic protocol.
2
+
3
+ Claude Code is configured through process environment variables
4
+ (``ANTHROPIC_BASE_URL``, ``ANTHROPIC_AUTH_TOKEN``, ``ANTHROPIC_MODEL`` and the
5
+ ``CLAUDE_CODE_*`` knobs). ``minimalcode --anthropic-env`` reuses that convention
6
+ so an existing Claude Code setup can start MinimalCode without editing
7
+ ``settings.json``. Nothing here is written to disk: the caller injects the
8
+ returned provider entry into the in-memory settings only.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ from collections.abc import Mapping
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from ..config import EFFORT_LEVELS
20
+ from ..errors import ConfigError
21
+
22
+ # The provider name a session records when the environment is the source. It is
23
+ # not written to settings.json, so it cannot collide with a configured provider.
24
+ ANTHROPIC_ENV_PROVIDER = "anthropic-env"
25
+ DEFAULT_ANTHROPIC_BASE_URL = "https://api.deepseek.com/anthropic"
26
+ # Anthropic's standard Messages window. DeepSeek and other 1M endpoints opt in
27
+ # with the Claude Code model suffix (``deepseek-flash[1m]``) or an explicit
28
+ # ANTHROPIC_CONTEXT_WINDOW, so no hostname check is needed here.
29
+ DEFAULT_CONTEXT_WINDOW = 200_000
30
+ DEFAULT_MAX_TOKENS = 8192
31
+ # Claude Code parses ``model[1m]`` client side and sends the bare model name;
32
+ # the suffix only selects a context window.
33
+ _CONTEXT_SUFFIX = re.compile(r"^(?P<model>.+?)\[(?P<size>\d+)(?P<unit>[kKmM])\]$")
34
+
35
+
36
+ @dataclass(slots=True)
37
+ class AnthropicEnvConfig:
38
+ """Provider entry derived from the environment, plus in-memory overrides."""
39
+
40
+ provider: str
41
+ config: dict[str, Any]
42
+ model: str
43
+ thinking: str | None
44
+ compact_at: float | None
45
+
46
+
47
+ def _text(environ: Mapping[str, str], name: str) -> str | None:
48
+ value = environ.get(name)
49
+ if isinstance(value, str) and value.strip():
50
+ return value.strip()
51
+ return None
52
+
53
+
54
+ def _positive_int(text: str, name: str) -> int:
55
+ try:
56
+ value = int(text)
57
+ except (TypeError, ValueError) as exc:
58
+ raise ConfigError(f"{name} must be a positive integer: {text!r}") from exc
59
+ if value < 1:
60
+ raise ConfigError(f"{name} must be a positive integer: {text!r}")
61
+ return value
62
+
63
+
64
+ def _split_model(text: str) -> tuple[str, int | None]:
65
+ """Strip Claude Code's ``[Nk]``/``[Nm]`` suffix and return its window."""
66
+ match = _CONTEXT_SUFFIX.match(text)
67
+ if match is None:
68
+ return text, None
69
+ size = int(match.group("size"))
70
+ multiplier = 1_000 if match.group("unit").lower() == "k" else 1_000_000
71
+ return match.group("model"), max(1, size * multiplier)
72
+
73
+
74
+ def from_anthropic_env(
75
+ environ: Mapping[str, str] | None = None,
76
+ *,
77
+ model: str | None = None,
78
+ thinking: str | None = None,
79
+ ) -> AnthropicEnvConfig:
80
+ """Build an Anthropic provider entry from Claude Code environment variables.
81
+
82
+ Explicit ``model`` / ``thinking`` arguments (the CLI flags) win over the
83
+ environment. The API key is never echoed in an error message.
84
+ """
85
+ env = os.environ if environ is None else environ
86
+
87
+ base_url = _text(env, "ANTHROPIC_BASE_URL") or DEFAULT_ANTHROPIC_BASE_URL
88
+ auth_token = _text(env, "ANTHROPIC_AUTH_TOKEN")
89
+ api_key = _text(env, "ANTHROPIC_API_KEY")
90
+ if not auth_token and not api_key:
91
+ raise ConfigError(
92
+ "--anthropic-env requires ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY"
93
+ )
94
+
95
+ raw_model = (
96
+ (model or "").strip()
97
+ or _text(env, "ANTHROPIC_MODEL")
98
+ or _text(env, "ANTHROPIC_DEFAULT_SONNET_MODEL")
99
+ )
100
+ if not raw_model:
101
+ raise ConfigError(
102
+ "--anthropic-env requires a model: pass --model or set ANTHROPIC_MODEL"
103
+ )
104
+ model_id, suffix_window = _split_model(raw_model)
105
+
106
+ context_window = suffix_window or DEFAULT_CONTEXT_WINDOW
107
+ window_override = _text(env, "ANTHROPIC_CONTEXT_WINDOW")
108
+ if window_override is not None:
109
+ context_window = _positive_int(window_override, "ANTHROPIC_CONTEXT_WINDOW")
110
+
111
+ max_tokens = DEFAULT_MAX_TOKENS
112
+ max_tokens_override = _text(env, "ANTHROPIC_MAX_TOKENS")
113
+ if max_tokens_override is not None:
114
+ max_tokens = _positive_int(max_tokens_override, "ANTHROPIC_MAX_TOKENS")
115
+
116
+ compact_at: float | None = None
117
+ compact_window = _text(env, "CLAUDE_CODE_AUTO_COMPACT_WINDOW")
118
+ if compact_window is not None:
119
+ threshold = _positive_int(compact_window, "CLAUDE_CODE_AUTO_COMPACT_WINDOW")
120
+ ratio = threshold / context_window
121
+ # context.compact_at must stay inside (0, 1); a window larger than the
122
+ # context size means "compact as late as possible", not "never".
123
+ compact_at = round(min(0.95, max(0.1, ratio)), 4)
124
+
125
+ headers: dict[str, str] = {}
126
+ config: dict[str, Any] = {
127
+ "protocol": "anthropic_messages",
128
+ "base_url": base_url,
129
+ "model": model_id,
130
+ "context_window": context_window,
131
+ "max_tokens": max_tokens,
132
+ "timeout_seconds": 120,
133
+ "stream": True,
134
+ "send_reasoning_effort": True,
135
+ "headers": headers,
136
+ "extra_body": {},
137
+ }
138
+ if auth_token is not None:
139
+ # Claude Code's ANTHROPIC_AUTH_TOKEN is a bearer credential; an
140
+ # ANTHROPIC_API_KEY keeps the x-api-key header the client generates.
141
+ headers["Authorization"] = f"Bearer {auth_token}"
142
+ else:
143
+ config["api_key"] = api_key
144
+
145
+ effort = thinking or _text(env, "CLAUDE_CODE_EFFORT_LEVEL")
146
+ if effort is not None:
147
+ effort = effort.strip().lower()
148
+ if effort not in EFFORT_LEVELS:
149
+ if thinking is not None:
150
+ # An explicit flag is validated by argparse; reaching here means
151
+ # the caller passed something else and should hear about it.
152
+ raise ConfigError(f"unknown effort: {thinking!r}")
153
+ effort = None
154
+
155
+ return AnthropicEnvConfig(
156
+ provider=ANTHROPIC_ENV_PROVIDER,
157
+ config=config,
158
+ model=model_id,
159
+ thinking=effort,
160
+ compact_at=compact_at,
161
+ )
@@ -0,0 +1,394 @@
1
+ """OpenAI Chat Completions payloads, streams, and response parsing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Callable, Iterable
7
+ from typing import Any
8
+
9
+ import httpx2
10
+
11
+ from ..errors import ProviderError
12
+ from .common import (
13
+ content_text,
14
+ decode_json_body,
15
+ is_event_stream,
16
+ normalize_usage,
17
+ stream_event_error,
18
+ strip_sse_bom,
19
+ tool_arguments,
20
+ )
21
+ from .types import ProviderReply, ToolCall
22
+
23
+
24
+ class ChatProtocolMixin:
25
+ """Chat Completions wire format for the provider client."""
26
+
27
+ # Attributes supplied by OpenAICompatibleProvider.
28
+ config: dict[str, Any]
29
+ model: str
30
+ thinking: str
31
+ stream_enabled: bool
32
+ include_usage: bool
33
+ send_reasoning_effort: bool
34
+
35
+ def _open(self, payload: dict[str, Any]) -> httpx2.Response:
36
+ raise NotImplementedError
37
+
38
+ @staticmethod
39
+ def _chat_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
40
+ """Drop the Anthropic-only history field from Chat messages."""
41
+ return [
42
+ {key: value for key, value in message.items() if key != "provider_content"}
43
+ for message in messages
44
+ if isinstance(message, dict) and isinstance(message.get("role"), str)
45
+ ]
46
+
47
+ def _chat_payload(
48
+ self,
49
+ messages: list[dict[str, Any]],
50
+ tools: list[dict[str, Any]] | None,
51
+ ) -> dict[str, Any]:
52
+ """Build the Chat Completions request body."""
53
+ payload: dict[str, Any] = {
54
+ "model": self.model,
55
+ "messages": self._chat_messages(messages),
56
+ "stream": self.stream_enabled,
57
+ }
58
+ if tools:
59
+ payload["tools"] = tools
60
+ payload["tool_choice"] = "auto"
61
+ if self.stream_enabled and self.include_usage:
62
+ payload["stream_options"] = {"include_usage": True}
63
+ if self.send_reasoning_effort:
64
+ effort = self.thinking.strip().lower()
65
+ if effort == "none":
66
+ payload["thinking"] = {"type": "disabled"}
67
+ elif effort:
68
+ payload["reasoning_effort"] = effort
69
+ extra = self.config.get("extra_body")
70
+ if isinstance(extra, dict):
71
+ # Never let extra_body override protocol-critical fields.
72
+ reserved = {"model", "messages", "stream", "tools", "tool_choice"}
73
+ payload.update(
74
+ {key: value for key, value in extra.items() if key not in reserved}
75
+ )
76
+ return payload
77
+
78
+ def _chat_stream_request(
79
+ self,
80
+ payload: dict[str, Any],
81
+ *,
82
+ on_text: Callable[[str], None] | None,
83
+ on_thinking: Callable[[str], None] | None,
84
+ ) -> ProviderReply:
85
+ """Request a streaming reply, tolerating JSON when SSE is misconfigured."""
86
+ response = self._open(payload)
87
+ try:
88
+ if not is_event_stream(response):
89
+ body = self._read_body(response)
90
+ parsed, data = decode_json_body(body)
91
+ if parsed:
92
+ return self._parse_chat_payload(
93
+ data, on_text=on_text, on_thinking=on_thinking
94
+ )
95
+ text = body.decode("utf-8", errors="replace")
96
+ if "data:" not in text:
97
+ raise ProviderError("provider returned an invalid response")
98
+ lines: Iterable[str] = text.splitlines()
99
+ else:
100
+ lines = response.iter_lines()
101
+ return self._parse_chat_stream(
102
+ lines, on_text=on_text, on_thinking=on_thinking
103
+ )
104
+ finally:
105
+ response.close()
106
+
107
+ def _parse_chat_stream(
108
+ self,
109
+ lines: Iterable[str],
110
+ *,
111
+ on_text: Callable[[str], None] | None,
112
+ on_thinking: Callable[[str], None] | None,
113
+ ) -> ProviderReply:
114
+ """Accumulate content, tool-call fragments, usage, and finish state."""
115
+ content_parts: list[str] = []
116
+ # Calls arrive as fragments keyed by `index`: id and name are sent once,
117
+ # while the arguments JSON string is split across many chunks, so each
118
+ # entry accumulates instead of being overwritten.
119
+ calls: dict[int, dict[str, str]] = {}
120
+ last_call_index = -1
121
+ finish_reason: str | None = None
122
+ saw_terminal = False
123
+ usage: dict[str, int] = {}
124
+
125
+ try:
126
+ for data in self._sse_events(lines):
127
+ # The assembler strips a line-leading BOM; a payload that
128
+ # carries one after `data: ` is stripped here.
129
+ data = strip_sse_bom(data).strip()
130
+ if not data:
131
+ continue
132
+ if data == "[DONE]":
133
+ saw_terminal = True
134
+ continue
135
+ try:
136
+ event = json.loads(data)
137
+ except json.JSONDecodeError:
138
+ # Unknown SSE fields are ignored; malformed JSON is not a
139
+ # protocol event and must not abort an otherwise valid stream.
140
+ continue
141
+ if not isinstance(event, dict):
142
+ continue
143
+ if event.get("error"):
144
+ raise stream_event_error(event)
145
+
146
+ raw_usage = event.get("usage")
147
+ if isinstance(raw_usage, dict):
148
+ parsed_usage = normalize_usage(raw_usage)
149
+ if parsed_usage:
150
+ usage = parsed_usage
151
+
152
+ choices = event.get("choices")
153
+ if not isinstance(choices, list) or not choices:
154
+ continue
155
+ choice = choices[0]
156
+ if not isinstance(choice, dict):
157
+ continue
158
+ delta = choice.get("delta")
159
+ if not isinstance(delta, dict):
160
+ delta = {}
161
+ text = content_text(delta.get("content")) or content_text(
162
+ delta.get("refusal")
163
+ )
164
+ if text:
165
+ content_parts.append(text)
166
+ if on_text is not None:
167
+ on_text(text)
168
+
169
+ if on_thinking is not None:
170
+ # DeepSeek/Qwen stream `reasoning_content`; OpenRouter uses
171
+ # `reasoning`. Neither belongs in `content`: reasoning is
172
+ # display-only and must never enter the assistant message.
173
+ reasoning = content_text(
174
+ delta.get("reasoning_content")
175
+ ) or content_text(delta.get("reasoning"))
176
+ if reasoning:
177
+ on_thinking(reasoning)
178
+
179
+ raw_calls = delta.get("tool_calls")
180
+ if isinstance(raw_calls, list):
181
+ for raw_call in raw_calls:
182
+ if not isinstance(raw_call, dict):
183
+ continue
184
+ try:
185
+ index = int(raw_call["index"])
186
+ except (KeyError, TypeError, ValueError):
187
+ # Some OpenAI-compatible servers omit `index`. A
188
+ # fragment belongs to the call whose id it repeats,
189
+ # so two interleaved parallel calls must be matched
190
+ # by id: opening a new entry per fragment left the
191
+ # earlier call with half its arguments and dropped
192
+ # the rest with the unnamed entry.
193
+ incoming_id = str(raw_call.get("id") or "")
194
+ matched = next(
195
+ (
196
+ position
197
+ for position, state in calls.items()
198
+ if incoming_id and state["id"] == incoming_id
199
+ ),
200
+ None,
201
+ )
202
+ if matched is not None:
203
+ index = matched
204
+ elif last_call_index >= 0 and (
205
+ # An unlabelled continuation of the call that
206
+ # just streamed, or the fragment that supplies
207
+ # the id of a call whose first fragment had
208
+ # none. Opening a new entry here truncated the
209
+ # arguments of the common "id once, arguments in
210
+ # many chunks" frame.
211
+ not incoming_id
212
+ or not calls[last_call_index]["id"]
213
+ or calls[last_call_index]["id"] == incoming_id
214
+ ):
215
+ index = last_call_index
216
+ else:
217
+ index = max(calls, default=-1) + 1
218
+ else:
219
+ # A non-conforming server can reuse an index for a
220
+ # new call id. Treat that as a new call instead of
221
+ # overwriting the first one and concatenating its
222
+ # arguments into invalid JSON. Each slot records the
223
+ # wire index it belongs to, so the unlabelled
224
+ # continuation fragments follow the replacement call
225
+ # rather than falling back to the first one.
226
+ wire_index = int(raw_call["index"])
227
+ index = next(
228
+ (
229
+ position
230
+ for position in reversed(calls)
231
+ if calls[position].get("wire") == str(wire_index)
232
+ ),
233
+ wire_index,
234
+ )
235
+ existing = calls.get(index)
236
+ incoming_id = str(raw_call.get("id") or "")
237
+ if (
238
+ existing is not None
239
+ and incoming_id
240
+ and existing["id"]
241
+ and incoming_id != existing["id"]
242
+ ):
243
+ index = max(calls, default=-1) + 1
244
+ calls.setdefault(
245
+ index,
246
+ {"id": "", "name": "", "arguments": ""},
247
+ )["wire"] = str(wire_index)
248
+ last_call_index = index
249
+ entry = calls.setdefault(
250
+ index,
251
+ {"id": "", "name": "", "arguments": ""},
252
+ )
253
+ if raw_call.get("id"):
254
+ entry["id"] = str(raw_call["id"])
255
+ function = raw_call.get("function")
256
+ if isinstance(function, dict):
257
+ if function.get("name"):
258
+ entry["name"] = str(function["name"])
259
+ arguments = function.get("arguments")
260
+ if isinstance(arguments, str):
261
+ if entry.get("object_arguments"):
262
+ # The call was already completed as an
263
+ # object; appending a stray fragment would
264
+ # produce `{"a":1}{"a":1}`.
265
+ continue
266
+ entry["arguments"] += arguments
267
+ elif arguments is not None:
268
+ # A whole-object argument value is complete on
269
+ # its own, so it replaces any partial string
270
+ # earlier chunks accumulated: keeping the
271
+ # fragment left invalid JSON behind.
272
+ entry["arguments"] = tool_arguments(arguments)
273
+ entry["object_arguments"] = True
274
+ if choice.get("finish_reason"):
275
+ finish_reason = str(choice["finish_reason"])
276
+ saw_terminal = True
277
+ except httpx2.HTTPError as exc:
278
+ raise ProviderError(f"provider stream interrupted: {exc}") from exc
279
+
280
+ tool_calls: list[ToolCall] = []
281
+ # Sorting by stream index replays the calls in the order the model emitted
282
+ # them; dict insertion order can differ when a later chunk fills in an
283
+ # earlier index.
284
+ for index in sorted(calls):
285
+ entry = calls[index]
286
+ if not entry["name"]:
287
+ continue
288
+ tool_calls.append(
289
+ ToolCall(
290
+ id=entry["id"] or f"call_{index}",
291
+ name=entry["name"],
292
+ arguments=entry["arguments"] or "{}",
293
+ )
294
+ )
295
+ if not saw_terminal:
296
+ # The connection closed before the provider sent finish_reason or
297
+ # [DONE]. Treating that as a normal stop silently truncates answers
298
+ # and can execute a half-received tool call.
299
+ finish_reason = "incomplete"
300
+ elif finish_reason is None:
301
+ finish_reason = "tool_calls" if tool_calls else "stop"
302
+ if not content_parts and not tool_calls and not saw_terminal:
303
+ # `saw_terminal` is re-read here on purpose: a stream that terminated
304
+ # cleanly with no content is a legal empty answer, while one that died
305
+ # without a terminal event is a broken response.
306
+ raise ProviderError("provider returned an empty stream")
307
+ return ProviderReply(
308
+ content="".join(content_parts),
309
+ tool_calls=tool_calls,
310
+ finish_reason=finish_reason,
311
+ usage=usage,
312
+ )
313
+
314
+ def _parse_chat_payload(
315
+ self,
316
+ data: Any,
317
+ *,
318
+ on_text: Callable[[str], None] | None,
319
+ on_thinking: Callable[[str], None] | None,
320
+ ) -> ProviderReply:
321
+ """Parse one non-streaming Chat Completions response body."""
322
+ if not isinstance(data, dict):
323
+ raise ProviderError("provider response must be an object")
324
+ if data.get("error"):
325
+ raise ProviderError(f"provider response failed: {data['error']}")
326
+ choices = data.get("choices")
327
+ if not isinstance(choices, list) or not choices:
328
+ raise ProviderError("provider response has no choices")
329
+ choice = choices[0]
330
+ if not isinstance(choice, dict):
331
+ raise ProviderError("provider response choice must be an object")
332
+ message = choice.get("message")
333
+ if not isinstance(message, dict):
334
+ raise ProviderError("provider response has no message")
335
+ content = content_text(message.get("content")) or content_text(
336
+ message.get("refusal")
337
+ )
338
+ if content and on_text is not None:
339
+ on_text(content)
340
+ if on_thinking is not None:
341
+ reasoning = content_text(message.get("reasoning_content")) or content_text(
342
+ message.get("reasoning")
343
+ )
344
+ if reasoning:
345
+ on_thinking(reasoning)
346
+ calls: list[ToolCall] = []
347
+ raw_calls = message.get("tool_calls")
348
+ if isinstance(raw_calls, list):
349
+ for index, raw_call in enumerate(raw_calls):
350
+ if not isinstance(raw_call, dict):
351
+ continue
352
+ function = raw_call.get("function")
353
+ if not isinstance(function, dict):
354
+ continue
355
+ name = str(function.get("name") or "")
356
+ if not name:
357
+ continue
358
+ calls.append(
359
+ ToolCall(
360
+ id=str(raw_call.get("id") or f"call_{index}"),
361
+ name=name,
362
+ arguments=tool_arguments(function.get("arguments")),
363
+ )
364
+ )
365
+ finish_reason = choice.get("finish_reason")
366
+ return ProviderReply(
367
+ content=content,
368
+ tool_calls=calls,
369
+ finish_reason=str(finish_reason) if finish_reason is not None else None,
370
+ usage=normalize_usage(data.get("usage")),
371
+ )
372
+
373
+ def _chat_regular_request(
374
+ self,
375
+ payload: dict[str, Any],
376
+ *,
377
+ on_text: Callable[[str], None] | None,
378
+ on_thinking: Callable[[str], None] | None,
379
+ ) -> ProviderReply:
380
+ """Request a non-streaming reply, accepting SSE if the server sends it."""
381
+ response = self._open(payload)
382
+ try:
383
+ # Some gateways ignore `stream: false` and answer with SSE anyway, so
384
+ # the content type, not the request flag, selects the parser.
385
+ if is_event_stream(response):
386
+ return self._parse_chat_stream(
387
+ response.iter_lines(), on_text=on_text, on_thinking=on_thinking
388
+ )
389
+ parsed, data = decode_json_body(self._read_body(response))
390
+ if not parsed:
391
+ raise ProviderError("provider returned invalid JSON")
392
+ finally:
393
+ response.close()
394
+ return self._parse_chat_payload(data, on_text=on_text, on_thinking=on_thinking)