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