splitagent 0.0.3__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.
- splitagent/__init__.py +8 -0
- splitagent/__main__.py +6 -0
- splitagent/agents/__init__.py +10 -0
- splitagent/agents/base.py +477 -0
- splitagent/agents/blue.py +57 -0
- splitagent/agents/chat.py +60 -0
- splitagent/agents/prompts.py +462 -0
- splitagent/agents/red.py +75 -0
- splitagent/cli.py +701 -0
- splitagent/config.py +697 -0
- splitagent/core/__init__.py +19 -0
- splitagent/core/bus.py +62 -0
- splitagent/core/context.py +587 -0
- splitagent/core/context_manager.py +381 -0
- splitagent/core/engine.py +424 -0
- splitagent/core/models.py +310 -0
- splitagent/core/proc.py +73 -0
- splitagent/core/sandbox.py +184 -0
- splitagent/core/toolbox.py +520 -0
- splitagent/core/workspace.py +420 -0
- splitagent/desktop/__init__.py +7 -0
- splitagent/desktop/api.py +525 -0
- splitagent/desktop/app.py +1131 -0
- splitagent/desktop/web/app.js +3067 -0
- splitagent/desktop/web/assets/Inter.ttf +0 -0
- splitagent/desktop/web/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- splitagent/desktop/web/index.html +760 -0
- splitagent/desktop/web/styles.css +1612 -0
- splitagent/errors.py +27 -0
- splitagent/llm/__init__.py +8 -0
- splitagent/llm/client.py +488 -0
- splitagent/llm/types.py +172 -0
- splitagent/report/__init__.py +9 -0
- splitagent/report/cvss.py +93 -0
- splitagent/report/generator.py +733 -0
- splitagent/tools/__init__.py +8 -0
- splitagent/tools/base.py +135 -0
- splitagent/tools/defense.py +475 -0
- splitagent/tools/exploit.py +318 -0
- splitagent/tools/http_pool.py +109 -0
- splitagent/tools/knowledge.py +376 -0
- splitagent/tools/recon.py +182 -0
- splitagent/tools/registry.py +62 -0
- splitagent/tools/validate.py +908 -0
- splitagent/tools/web.py +386 -0
- splitagent/tools/workspace_tools.py +411 -0
- splitagent/ui/__init__.py +5 -0
- splitagent/ui/app.py +389 -0
- splitagent/ui/stream.py +234 -0
- splitagent/ui/theme.py +72 -0
- splitagent-0.0.3.dist-info/METADATA +987 -0
- splitagent-0.0.3.dist-info/RECORD +56 -0
- splitagent-0.0.3.dist-info/WHEEL +5 -0
- splitagent-0.0.3.dist-info/entry_points.txt +2 -0
- splitagent-0.0.3.dist-info/licenses/LICENSE +21 -0
- splitagent-0.0.3.dist-info/top_level.txt +1 -0
splitagent/errors.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Custom exception hierarchy for SplitAgent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SplitAgentError(Exception):
|
|
7
|
+
"""Base error for every SplitAgent failure."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigError(SplitAgentError):
|
|
11
|
+
"""Raised when configuration is missing or invalid."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LLMError(SplitAgentError):
|
|
15
|
+
"""Raised when the LLM provider call fails."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolError(SplitAgentError):
|
|
19
|
+
"""Raised when a tool cannot complete its operation."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SandboxError(SplitAgentError):
|
|
23
|
+
"""Raised when the Docker sandbox cannot be managed."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ScopeError(SplitAgentError):
|
|
27
|
+
"""Raised when an action would fall outside the authorised target scope."""
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""LLM provider abstraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from splitagent.llm.client import LLMClient
|
|
6
|
+
from splitagent.llm.types import ChatMessage, LLMEvent, ToolCall, ToolSpec
|
|
7
|
+
|
|
8
|
+
__all__ = ["ChatMessage", "LLMClient", "LLMEvent", "ToolCall", "ToolSpec"]
|
splitagent/llm/client.py
ADDED
|
@@ -0,0 +1,488 @@
|
|
|
1
|
+
"""Async LLM client supporting OpenAI-compatible and Anthropic APIs.
|
|
2
|
+
|
|
3
|
+
The framework never assumes a local model: every provider is reached over
|
|
4
|
+
HTTP using credentials stored in the global configuration. A single client
|
|
5
|
+
therefore works with OpenAI, OpenRouter, Groq, DeepSeek, Together, Mistral,
|
|
6
|
+
xAI, vLLM, LM Studio, Ollama (OpenAI compatibility mode), Anthropic and any
|
|
7
|
+
other OpenAI-compatible endpoint.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from collections.abc import AsyncIterator
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from splitagent.config import LLMSettings
|
|
21
|
+
from splitagent.errors import LLMError
|
|
22
|
+
from splitagent.llm.types import ChatMessage, LLMEvent, ToolCall, ToolSpec
|
|
23
|
+
|
|
24
|
+
# Status codes and message shapes worth a second attempt. Everything else
|
|
25
|
+
# (auth, bad request, model not found) fails fast so the operator sees the
|
|
26
|
+
# real cause instead of a slow retry loop.
|
|
27
|
+
_RETRYABLE_STATUS = (408, 409, 425, 429, 500, 502, 503, 504, 522, 524)
|
|
28
|
+
_RETRYABLE_HINTS = (
|
|
29
|
+
"rate limit",
|
|
30
|
+
"rate_limit",
|
|
31
|
+
"too many requests",
|
|
32
|
+
"overloaded",
|
|
33
|
+
"temporarily",
|
|
34
|
+
"timeout",
|
|
35
|
+
"timed out",
|
|
36
|
+
"connection reset",
|
|
37
|
+
"connection aborted",
|
|
38
|
+
"connection error",
|
|
39
|
+
"server error",
|
|
40
|
+
"bad gateway",
|
|
41
|
+
"service unavailable",
|
|
42
|
+
"gateway timeout",
|
|
43
|
+
"internal error",
|
|
44
|
+
"try again",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _is_retryable(message: str) -> bool:
|
|
49
|
+
"""Decide whether an error is transient."""
|
|
50
|
+
if not message:
|
|
51
|
+
return False
|
|
52
|
+
lowered = message.lower()
|
|
53
|
+
status = re.search(r"\bhttp\s+(\d{3})", lowered)
|
|
54
|
+
if status:
|
|
55
|
+
return int(status.group(1)) in _RETRYABLE_STATUS
|
|
56
|
+
return any(hint in lowered for hint in _RETRYABLE_HINTS)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LLMClient:
|
|
60
|
+
"""Thin async wrapper around the two supported wire protocols."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, settings: LLMSettings):
|
|
63
|
+
self.settings = settings
|
|
64
|
+
self._client: httpx.AsyncClient | None = None
|
|
65
|
+
# Populated when a retry happens, so the UI can explain a pause.
|
|
66
|
+
self.last_retry: dict[str, Any] | None = None
|
|
67
|
+
|
|
68
|
+
# -- lifecycle -------------------------------------------------------- #
|
|
69
|
+
async def __aenter__(self) -> LLMClient:
|
|
70
|
+
self._client = httpx.AsyncClient(timeout=self.settings.timeout)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
74
|
+
if self._client is not None:
|
|
75
|
+
await self._client.aclose()
|
|
76
|
+
self._client = None
|
|
77
|
+
|
|
78
|
+
def _http(self) -> httpx.AsyncClient:
|
|
79
|
+
if self._client is None:
|
|
80
|
+
self._client = httpx.AsyncClient(timeout=self.settings.timeout)
|
|
81
|
+
return self._client
|
|
82
|
+
|
|
83
|
+
# -- headers ---------------------------------------------------------- #
|
|
84
|
+
def _headers(self) -> dict[str, str]:
|
|
85
|
+
from splitagent import __version__
|
|
86
|
+
|
|
87
|
+
headers = {
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
# Identify the client, as requested by gateway providers (e.g. OpenCode Go).
|
|
90
|
+
"User-Agent": self.settings.user_agent or f"SplitAgent/{__version__}",
|
|
91
|
+
}
|
|
92
|
+
key = self.settings.resolved_api_key()
|
|
93
|
+
if self.settings.protocol == "anthropic":
|
|
94
|
+
if key:
|
|
95
|
+
headers["x-api-key"] = key
|
|
96
|
+
headers["anthropic-version"] = "2023-06-01"
|
|
97
|
+
else:
|
|
98
|
+
if key:
|
|
99
|
+
headers["Authorization"] = f"Bearer {key}"
|
|
100
|
+
# Stable per-conversation id for routing and prompt caching.
|
|
101
|
+
if self.settings.session_id:
|
|
102
|
+
headers["x-opencode-session"] = self.settings.session_id
|
|
103
|
+
headers.update(self.settings.extra_headers or {})
|
|
104
|
+
return headers
|
|
105
|
+
|
|
106
|
+
def _endpoint(self) -> str:
|
|
107
|
+
base = (self.settings.base_url or "").rstrip("/")
|
|
108
|
+
if self.settings.protocol == "anthropic":
|
|
109
|
+
if base.endswith("/messages"):
|
|
110
|
+
return base
|
|
111
|
+
return f"{base}/messages"
|
|
112
|
+
if base.endswith("/chat/completions"):
|
|
113
|
+
return base
|
|
114
|
+
return f"{base}/chat/completions"
|
|
115
|
+
|
|
116
|
+
# -- public API ------------------------------------------------------- #
|
|
117
|
+
def _with_cache_points(self, messages: list[ChatMessage]) -> list[ChatMessage]:
|
|
118
|
+
"""Mark cache breakpoints the way OpenCode does.
|
|
119
|
+
|
|
120
|
+
The system prompt plus the last exchanged messages are the stable
|
|
121
|
+
prefix a provider can reuse. This is a no-op when ``prompt_cache`` is
|
|
122
|
+
disabled or the provider has no explicit marker.
|
|
123
|
+
"""
|
|
124
|
+
if not self.settings.prompt_cache:
|
|
125
|
+
return messages
|
|
126
|
+
system = [m for m in messages if m.role == "system"][: self.settings.cache_system_messages]
|
|
127
|
+
tail = [m for m in messages if m.role != "system"][-self.settings.cache_tail_messages :]
|
|
128
|
+
for message in {id(m): m for m in [*system, *tail]}.values():
|
|
129
|
+
message.cache = True
|
|
130
|
+
return messages
|
|
131
|
+
|
|
132
|
+
async def stream(
|
|
133
|
+
self, messages: list[ChatMessage], tools: list[ToolSpec] | None = None
|
|
134
|
+
) -> AsyncIterator[LLMEvent]:
|
|
135
|
+
"""Yield incremental events for a completion request.
|
|
136
|
+
|
|
137
|
+
Transient provider failures are retried with exponential backoff. A
|
|
138
|
+
streaming response that fails *after* emitting content is not retried
|
|
139
|
+
blindly - only failures seen before any text arrives are, so a long
|
|
140
|
+
answer is never duplicated.
|
|
141
|
+
"""
|
|
142
|
+
messages = self._with_cache_points(messages)
|
|
143
|
+
attempts = max(1, self.settings.max_retries + 1)
|
|
144
|
+
delay = self.settings.retry_initial_delay
|
|
145
|
+
|
|
146
|
+
for attempt in range(1, attempts + 1):
|
|
147
|
+
emitted = False
|
|
148
|
+
retryable = ""
|
|
149
|
+
try:
|
|
150
|
+
stream = (
|
|
151
|
+
self._stream_anthropic(messages, tools or [])
|
|
152
|
+
if self.settings.protocol == "anthropic"
|
|
153
|
+
else self._stream_openai(messages, tools or [])
|
|
154
|
+
)
|
|
155
|
+
async for event in stream:
|
|
156
|
+
if event.type in ("text", "tool_call", "reasoning"):
|
|
157
|
+
emitted = True
|
|
158
|
+
if event.type == "error":
|
|
159
|
+
if not emitted and _is_retryable(event.error or ""):
|
|
160
|
+
retryable = event.error or ""
|
|
161
|
+
break
|
|
162
|
+
yield event
|
|
163
|
+
return
|
|
164
|
+
yield event
|
|
165
|
+
if not retryable:
|
|
166
|
+
return
|
|
167
|
+
except httpx.HTTPError as exc:
|
|
168
|
+
if emitted or not _is_retryable(str(exc)):
|
|
169
|
+
yield LLMEvent(type="error", error=f"Connection error: {exc}")
|
|
170
|
+
return
|
|
171
|
+
retryable = str(exc)
|
|
172
|
+
|
|
173
|
+
if not retryable or attempt == attempts:
|
|
174
|
+
yield LLMEvent(
|
|
175
|
+
type="error",
|
|
176
|
+
error=(
|
|
177
|
+
f"{retryable} (gave up after {attempts} attempts)"
|
|
178
|
+
if retryable
|
|
179
|
+
else "Unknown LLM error"
|
|
180
|
+
),
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
yield self._emit_retry(attempt, attempts, delay, retryable)
|
|
185
|
+
await asyncio.sleep(delay)
|
|
186
|
+
delay = min(delay * 2, self.settings.retry_max_delay)
|
|
187
|
+
|
|
188
|
+
def _emit_retry(self, attempt: int, attempts: int, delay: float, reason: str) -> LLMEvent:
|
|
189
|
+
"""Surface the retry so the UI can show it instead of seeming stuck."""
|
|
190
|
+
self.last_retry = {
|
|
191
|
+
"attempt": attempt,
|
|
192
|
+
"of": attempts,
|
|
193
|
+
"delay": delay,
|
|
194
|
+
"reason": reason[:300],
|
|
195
|
+
}
|
|
196
|
+
return LLMEvent(type="retry", data=dict(self.last_retry))
|
|
197
|
+
|
|
198
|
+
async def complete(
|
|
199
|
+
self, messages: list[ChatMessage], tools: list[ToolSpec] | None = None
|
|
200
|
+
) -> tuple[ChatMessage, dict[str, Any]]:
|
|
201
|
+
"""Run a full completion, gathering the streamed events."""
|
|
202
|
+
content_parts: list[str] = []
|
|
203
|
+
reasoning_parts: list[str] = []
|
|
204
|
+
calls: dict[str, ToolCall] = {}
|
|
205
|
+
usage: dict[str, Any] = {}
|
|
206
|
+
|
|
207
|
+
async for event in self.stream(messages, tools):
|
|
208
|
+
if event.type == "text":
|
|
209
|
+
content_parts.append(event.text)
|
|
210
|
+
elif event.type == "reasoning":
|
|
211
|
+
reasoning_parts.append(event.text)
|
|
212
|
+
elif event.type == "tool_call" and event.tool_call is not None:
|
|
213
|
+
calls[event.tool_call.id] = event.tool_call
|
|
214
|
+
elif event.type == "usage" and event.usage:
|
|
215
|
+
usage.update(event.usage)
|
|
216
|
+
elif event.type == "error":
|
|
217
|
+
raise LLMError(event.error or "Unknown LLM error")
|
|
218
|
+
|
|
219
|
+
message = ChatMessage(
|
|
220
|
+
role="assistant",
|
|
221
|
+
content="".join(content_parts),
|
|
222
|
+
reasoning="".join(reasoning_parts),
|
|
223
|
+
tool_calls=list(calls.values()),
|
|
224
|
+
)
|
|
225
|
+
return message, usage
|
|
226
|
+
|
|
227
|
+
# -- OpenAI-compatible ------------------------------------------------- #
|
|
228
|
+
async def _stream_openai(
|
|
229
|
+
self, messages: list[ChatMessage], tools: list[ToolSpec]
|
|
230
|
+
) -> AsyncIterator[LLMEvent]:
|
|
231
|
+
body: dict[str, Any] = {
|
|
232
|
+
"model": self.settings.model,
|
|
233
|
+
"messages": [m.to_openai(cache=m.cache) for m in messages],
|
|
234
|
+
"temperature": self.settings.temperature,
|
|
235
|
+
"max_tokens": self.settings.max_tokens,
|
|
236
|
+
"stream": bool(self.settings.stream),
|
|
237
|
+
}
|
|
238
|
+
if tools:
|
|
239
|
+
body["tools"] = [t.to_openai() for t in tools]
|
|
240
|
+
body["tool_choice"] = "auto"
|
|
241
|
+
if self.settings.stream:
|
|
242
|
+
body["stream_options"] = {"include_usage": True}
|
|
243
|
+
|
|
244
|
+
if not self.settings.stream:
|
|
245
|
+
message, nonstream_usage = await self._openai_nonstream(body)
|
|
246
|
+
if message.content:
|
|
247
|
+
yield LLMEvent(type="text", text=message.content)
|
|
248
|
+
for call in message.tool_calls:
|
|
249
|
+
yield LLMEvent(type="tool_call", tool_call=call)
|
|
250
|
+
yield LLMEvent(type="usage", usage=nonstream_usage)
|
|
251
|
+
yield LLMEvent(type="done")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
partial_calls: dict[int, dict[str, str]] = {}
|
|
255
|
+
usage: dict[str, Any] = {}
|
|
256
|
+
try:
|
|
257
|
+
async with self._http().stream(
|
|
258
|
+
"POST", self._endpoint(), json=body, headers=self._headers()
|
|
259
|
+
) as response:
|
|
260
|
+
if response.status_code >= 400:
|
|
261
|
+
detail = (await response.aread()).decode("utf-8", "replace")
|
|
262
|
+
yield LLMEvent(
|
|
263
|
+
type="error",
|
|
264
|
+
error=f"HTTP {response.status_code}: {detail[:800]}",
|
|
265
|
+
)
|
|
266
|
+
return
|
|
267
|
+
async for line in response.aiter_lines():
|
|
268
|
+
if not line or not line.startswith("data:"):
|
|
269
|
+
continue
|
|
270
|
+
payload = line[5:].strip()
|
|
271
|
+
if payload == "[DONE]":
|
|
272
|
+
break
|
|
273
|
+
try:
|
|
274
|
+
chunk = json.loads(payload)
|
|
275
|
+
except json.JSONDecodeError:
|
|
276
|
+
continue
|
|
277
|
+
if chunk.get("usage"):
|
|
278
|
+
usage.update(chunk["usage"])
|
|
279
|
+
choices = chunk.get("choices") or []
|
|
280
|
+
if not choices:
|
|
281
|
+
continue
|
|
282
|
+
delta = choices[0].get("delta") or {}
|
|
283
|
+
text = delta.get("content")
|
|
284
|
+
if text:
|
|
285
|
+
yield LLMEvent(type="text", text=text)
|
|
286
|
+
reasoning = delta.get("reasoning_content") or delta.get("reasoning")
|
|
287
|
+
if isinstance(reasoning, str) and reasoning:
|
|
288
|
+
yield LLMEvent(type="reasoning", text=reasoning)
|
|
289
|
+
for tc in delta.get("tool_calls") or []:
|
|
290
|
+
index = tc.get("index", 0)
|
|
291
|
+
slot = partial_calls.setdefault(
|
|
292
|
+
index, {"id": "", "name": "", "arguments": ""}
|
|
293
|
+
)
|
|
294
|
+
if tc.get("id"):
|
|
295
|
+
slot["id"] = tc["id"]
|
|
296
|
+
fn = tc.get("function") or {}
|
|
297
|
+
if fn.get("name"):
|
|
298
|
+
slot["name"] = fn["name"]
|
|
299
|
+
if fn.get("arguments"):
|
|
300
|
+
slot["arguments"] += fn["arguments"]
|
|
301
|
+
except httpx.HTTPError as exc:
|
|
302
|
+
yield LLMEvent(type="error", error=f"Connection error: {exc}")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
for index in sorted(partial_calls):
|
|
306
|
+
slot = partial_calls[index]
|
|
307
|
+
call = ToolCall(
|
|
308
|
+
id=slot["id"] or f"call_{index}",
|
|
309
|
+
name=slot["name"],
|
|
310
|
+
arguments=slot["arguments"] or "{}",
|
|
311
|
+
)
|
|
312
|
+
if call.name:
|
|
313
|
+
yield LLMEvent(type="tool_call", tool_call=call)
|
|
314
|
+
if usage:
|
|
315
|
+
yield LLMEvent(type="usage", usage=usage)
|
|
316
|
+
yield LLMEvent(type="done")
|
|
317
|
+
|
|
318
|
+
async def _openai_nonstream(self, body: dict[str, Any]) -> tuple[ChatMessage, dict[str, Any]]:
|
|
319
|
+
body = {**body, "stream": False}
|
|
320
|
+
try:
|
|
321
|
+
response = await self._http().post(self._endpoint(), json=body, headers=self._headers())
|
|
322
|
+
except httpx.HTTPError as exc:
|
|
323
|
+
raise LLMError(f"Connection error: {exc}") from exc
|
|
324
|
+
if response.status_code >= 400:
|
|
325
|
+
raise LLMError(f"HTTP {response.status_code}: {response.text[:800]}")
|
|
326
|
+
data = response.json()
|
|
327
|
+
usage = data.get("usage") or {}
|
|
328
|
+
choice = (data.get("choices") or [{}])[0]
|
|
329
|
+
message = choice.get("message") or {}
|
|
330
|
+
calls = [
|
|
331
|
+
ToolCall(
|
|
332
|
+
id=call.get("id", f"call_{i}"),
|
|
333
|
+
name=(call.get("function") or {}).get("name", ""),
|
|
334
|
+
arguments=(call.get("function") or {}).get("arguments", "{}"),
|
|
335
|
+
)
|
|
336
|
+
for i, call in enumerate(message.get("tool_calls") or [])
|
|
337
|
+
]
|
|
338
|
+
return (
|
|
339
|
+
ChatMessage(
|
|
340
|
+
role="assistant",
|
|
341
|
+
content=message.get("content") or "",
|
|
342
|
+
reasoning=message.get("reasoning_content") or message.get("reasoning") or "",
|
|
343
|
+
tool_calls=calls,
|
|
344
|
+
),
|
|
345
|
+
usage,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# -- Anthropic --------------------------------------------------------- #
|
|
349
|
+
async def _stream_anthropic(
|
|
350
|
+
self, messages: list[ChatMessage], tools: list[ToolSpec]
|
|
351
|
+
) -> AsyncIterator[LLMEvent]:
|
|
352
|
+
system_parts = [m.content for m in messages if m.role == "system"]
|
|
353
|
+
conversation = [m for m in messages if m.role != "system"]
|
|
354
|
+
body: dict[str, Any] = {
|
|
355
|
+
"model": self.settings.model,
|
|
356
|
+
"messages": [m.to_anthropic(cache=m.cache) for m in conversation],
|
|
357
|
+
"max_tokens": self.settings.max_tokens,
|
|
358
|
+
"temperature": self.settings.temperature,
|
|
359
|
+
"stream": True,
|
|
360
|
+
}
|
|
361
|
+
if system_parts:
|
|
362
|
+
# Anthropic caches the system block explicitly; this is the largest
|
|
363
|
+
# and most stable part of the prefix.
|
|
364
|
+
block: dict[str, Any] = {"type": "text", "text": "\n\n".join(system_parts)}
|
|
365
|
+
if self.settings.prompt_cache:
|
|
366
|
+
block["cache_control"] = {"type": "ephemeral"}
|
|
367
|
+
body["system"] = [block]
|
|
368
|
+
if tools:
|
|
369
|
+
body["tools"] = [t.to_anthropic() for t in tools]
|
|
370
|
+
|
|
371
|
+
current_tool: dict[str, Any] | None = None
|
|
372
|
+
usage: dict[str, Any] = {}
|
|
373
|
+
try:
|
|
374
|
+
async with self._http().stream(
|
|
375
|
+
"POST", self._endpoint(), json=body, headers=self._headers()
|
|
376
|
+
) as response:
|
|
377
|
+
if response.status_code >= 400:
|
|
378
|
+
detail = (await response.aread()).decode("utf-8", "replace")
|
|
379
|
+
yield LLMEvent(
|
|
380
|
+
type="error",
|
|
381
|
+
error=f"HTTP {response.status_code}: {detail[:800]}",
|
|
382
|
+
)
|
|
383
|
+
return
|
|
384
|
+
async for line in response.aiter_lines():
|
|
385
|
+
if not line or not line.startswith("data:"):
|
|
386
|
+
continue
|
|
387
|
+
payload = line[5:].strip()
|
|
388
|
+
if not payload:
|
|
389
|
+
continue
|
|
390
|
+
try:
|
|
391
|
+
event = json.loads(payload)
|
|
392
|
+
except json.JSONDecodeError:
|
|
393
|
+
continue
|
|
394
|
+
etype = event.get("type")
|
|
395
|
+
if etype == "message_start":
|
|
396
|
+
# Anthropic reports the input token count here, not in
|
|
397
|
+
# message_delta; without this the prompt side of the
|
|
398
|
+
# usage was always missing.
|
|
399
|
+
start_usage = (event.get("message") or {}).get("usage") or {}
|
|
400
|
+
usage.update(start_usage)
|
|
401
|
+
elif etype == "content_block_start":
|
|
402
|
+
block = event.get("content_block") or {}
|
|
403
|
+
if block.get("type") == "tool_use":
|
|
404
|
+
current_tool = {
|
|
405
|
+
"id": block.get("id", ""),
|
|
406
|
+
"name": block.get("name", ""),
|
|
407
|
+
"arguments": "",
|
|
408
|
+
}
|
|
409
|
+
elif etype == "content_block_delta":
|
|
410
|
+
delta = event.get("delta") or {}
|
|
411
|
+
if delta.get("type") == "text_delta":
|
|
412
|
+
yield LLMEvent(type="text", text=delta.get("text", ""))
|
|
413
|
+
elif delta.get("type") == "input_json_delta" and current_tool:
|
|
414
|
+
current_tool["arguments"] += delta.get("partial_json", "")
|
|
415
|
+
elif etype == "content_block_stop":
|
|
416
|
+
if current_tool:
|
|
417
|
+
call = ToolCall(
|
|
418
|
+
id=current_tool["id"] or "call_0",
|
|
419
|
+
name=current_tool["name"],
|
|
420
|
+
arguments=current_tool["arguments"] or "{}",
|
|
421
|
+
)
|
|
422
|
+
if call.name:
|
|
423
|
+
yield LLMEvent(type="tool_call", tool_call=call)
|
|
424
|
+
current_tool = None
|
|
425
|
+
elif etype == "message_delta":
|
|
426
|
+
if event.get("usage"):
|
|
427
|
+
usage.update(event["usage"])
|
|
428
|
+
elif etype == "error":
|
|
429
|
+
error = event.get("error") or {}
|
|
430
|
+
yield LLMEvent(
|
|
431
|
+
type="error",
|
|
432
|
+
error=str(error.get("message") or error),
|
|
433
|
+
)
|
|
434
|
+
return
|
|
435
|
+
except httpx.HTTPError as exc:
|
|
436
|
+
yield LLMEvent(type="error", error=f"Connection error: {exc}")
|
|
437
|
+
return
|
|
438
|
+
|
|
439
|
+
if usage:
|
|
440
|
+
yield LLMEvent(type="usage", usage=usage)
|
|
441
|
+
yield LLMEvent(type="done")
|
|
442
|
+
|
|
443
|
+
# -- utility ----------------------------------------------------------- #
|
|
444
|
+
async def list_models(self) -> list[dict[str, str]]:
|
|
445
|
+
"""Fetch the model catalogue from an OpenAI-compatible ``/models``."""
|
|
446
|
+
base = (self.settings.base_url or "").rstrip("/")
|
|
447
|
+
url = f"{base}/models"
|
|
448
|
+
headers = self._headers()
|
|
449
|
+
headers.pop("Content-Type", None)
|
|
450
|
+
try:
|
|
451
|
+
async with httpx.AsyncClient(timeout=25.0) as client:
|
|
452
|
+
response = await client.get(url, headers=headers)
|
|
453
|
+
except httpx.HTTPError as exc:
|
|
454
|
+
raise LLMError(f"Connection error: {exc}") from exc
|
|
455
|
+
if response.status_code >= 400:
|
|
456
|
+
raise LLMError(f"HTTP {response.status_code}: {response.text[:300]}")
|
|
457
|
+
data = response.json()
|
|
458
|
+
items = data.get("data") if isinstance(data, dict) else None
|
|
459
|
+
if items is None and isinstance(data, dict):
|
|
460
|
+
items = data.get("models")
|
|
461
|
+
if not isinstance(items, list):
|
|
462
|
+
return []
|
|
463
|
+
models: list[dict[str, str]] = []
|
|
464
|
+
for item in items:
|
|
465
|
+
if isinstance(item, str):
|
|
466
|
+
models.append({"id": item, "name": item})
|
|
467
|
+
continue
|
|
468
|
+
if not isinstance(item, dict):
|
|
469
|
+
continue
|
|
470
|
+
model_id = item.get("id") or item.get("name") or ""
|
|
471
|
+
if not model_id:
|
|
472
|
+
continue
|
|
473
|
+
models.append({"id": str(model_id), "name": str(item.get("name") or model_id)})
|
|
474
|
+
return models
|
|
475
|
+
|
|
476
|
+
async def test_connection(self) -> tuple[bool, str]:
|
|
477
|
+
"""Cheap round-trip used by the setup wizard to validate credentials."""
|
|
478
|
+
if not self.settings.session_id:
|
|
479
|
+
self.settings.session_id = "splitagent-connectivity"
|
|
480
|
+
try:
|
|
481
|
+
message, _ = await self.complete(
|
|
482
|
+
[ChatMessage(role="user", content="Reply with the single word: ok")]
|
|
483
|
+
)
|
|
484
|
+
except LLMError as exc:
|
|
485
|
+
return False, str(exc)
|
|
486
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
487
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
488
|
+
return True, (message.content or "").strip()[:200]
|