seedcode-cli 6.1.5__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.
- seedcode/__init__.py +14 -0
- seedcode/__main__.py +12 -0
- seedcode/app.py +508 -0
- seedcode/apps/__init__.py +32 -0
- seedcode/apps/discovery.py +241 -0
- seedcode/apps/installer.py +164 -0
- seedcode/apps/launcher.py +156 -0
- seedcode/apps/verifier.py +119 -0
- seedcode/assets/logo.txt +15 -0
- seedcode/cli.py +95 -0
- seedcode/commands/__init__.py +81 -0
- seedcode/commands/about.py +34 -0
- seedcode/commands/agent.py +94 -0
- seedcode/commands/assist.py +201 -0
- seedcode/commands/clear.py +20 -0
- seedcode/commands/desktop.py +104 -0
- seedcode/commands/doctor.py +152 -0
- seedcode/commands/help.py +61 -0
- seedcode/commands/history.py +365 -0
- seedcode/commands/palette.py +100 -0
- seedcode/commands/provider.py +451 -0
- seedcode/commands/theme.py +76 -0
- seedcode/computer/__init__.py +98 -0
- seedcode/computer/browser.py +276 -0
- seedcode/computer/browser_cdp.py +567 -0
- seedcode/computer/browser_engine.py +546 -0
- seedcode/computer/browser_extract.py +301 -0
- seedcode/computer/browser_popups.py +329 -0
- seedcode/computer/browser_selenium.py +209 -0
- seedcode/computer/browser_skills.py +245 -0
- seedcode/computer/catalog.py +200 -0
- seedcode/computer/controller.py +324 -0
- seedcode/computer/dispatcher.py +272 -0
- seedcode/computer/dpi.py +185 -0
- seedcode/computer/engine.py +105 -0
- seedcode/computer/keyboard.py +101 -0
- seedcode/computer/logbook.py +104 -0
- seedcode/computer/mouse.py +48 -0
- seedcode/computer/ocr.py +213 -0
- seedcode/computer/operator_skills.py +577 -0
- seedcode/computer/permissions.py +203 -0
- seedcode/computer/recovery.py +115 -0
- seedcode/computer/registry.py +107 -0
- seedcode/computer/resolver.py +434 -0
- seedcode/computer/screen.py +130 -0
- seedcode/computer/screen_state.py +412 -0
- seedcode/computer/selfguard.py +197 -0
- seedcode/computer/semantic.py +100 -0
- seedcode/computer/skills.py +139 -0
- seedcode/computer/state.py +199 -0
- seedcode/computer/verifier.py +177 -0
- seedcode/computer/vision.py +327 -0
- seedcode/computer/windows.py +217 -0
- seedcode/config/__init__.py +8 -0
- seedcode/config/defaults.py +22 -0
- seedcode/config/manager.py +62 -0
- seedcode/core/__init__.py +31 -0
- seedcode/core/agent.py +534 -0
- seedcode/core/chat.py +128 -0
- seedcode/core/client.py +9 -0
- seedcode/core/errors.py +199 -0
- seedcode/core/identity.py +66 -0
- seedcode/core/identity_store.py +119 -0
- seedcode/core/lifecycle.py +240 -0
- seedcode/core/limits.py +35 -0
- seedcode/core/models.py +347 -0
- seedcode/core/project.py +96 -0
- seedcode/core/providers/__init__.py +58 -0
- seedcode/core/providers/aerolink.py +324 -0
- seedcode/core/providers/base.py +230 -0
- seedcode/core/providers/freemodel.py +931 -0
- seedcode/core/providers/ollama.py +262 -0
- seedcode/core/providers/openrouter.py +393 -0
- seedcode/core/streaming.py +21 -0
- seedcode/memory/__init__.py +8 -0
- seedcode/memory/manager.py +47 -0
- seedcode/memory/storage.py +38 -0
- seedcode/memory/store.py +257 -0
- seedcode/tools/__init__.py +35 -0
- seedcode/tools/base.py +179 -0
- seedcode/tools/desktop.py +371 -0
- seedcode/tools/filesystem.py +309 -0
- seedcode/tools/git.py +72 -0
- seedcode/tools/patch.py +170 -0
- seedcode/tools/permissions.py +288 -0
- seedcode/tools/search.py +137 -0
- seedcode/tools/terminal.py +200 -0
- seedcode/tools/textio.py +59 -0
- seedcode/ui/__init__.py +164 -0
- seedcode/ui/badges.py +64 -0
- seedcode/ui/banner.py +78 -0
- seedcode/ui/dashboard.py +197 -0
- seedcode/ui/dialog.py +62 -0
- seedcode/ui/fuzzy.py +128 -0
- seedcode/ui/layout.py +54 -0
- seedcode/ui/menu.py +61 -0
- seedcode/ui/palette.py +40 -0
- seedcode/ui/progress.py +41 -0
- seedcode/ui/prompts.py +16 -0
- seedcode/ui/renderer.py +36 -0
- seedcode/ui/searchbox.py +70 -0
- seedcode/ui/selector.py +514 -0
- seedcode/ui/statusbar.py +38 -0
- seedcode/ui/textbox.py +61 -0
- seedcode/ui/theme.py +204 -0
- seedcode/ui/tree.py +91 -0
- seedcode/utils/__init__.py +22 -0
- seedcode/utils/helpers.py +97 -0
- seedcode/utils/logger.py +65 -0
- seedcode_cli-6.1.5.dist-info/METADATA +368 -0
- seedcode_cli-6.1.5.dist-info/RECORD +114 -0
- seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
- seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
- seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""AeroLink backend: Anthropic-compatible gateway at capi.aerolink.lat.
|
|
2
|
+
|
|
3
|
+
AeroLink (https://aerolink.lat) exposes the Anthropic Messages API and
|
|
4
|
+
serves Claude-family models only. This provider speaks that protocol
|
|
5
|
+
directly over httpx: ``POST /v1/messages`` with SSE streaming, and
|
|
6
|
+
``GET /v1/models`` for the catalogue when the gateway supports it. No
|
|
7
|
+
models are hardcoded; if listing is unavailable the user types the model
|
|
8
|
+
ID from their AeroLink dashboard. Independent API key and model.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import httpx
|
|
19
|
+
|
|
20
|
+
from .base import (
|
|
21
|
+
ModelInfo,
|
|
22
|
+
Provider,
|
|
23
|
+
ProviderError,
|
|
24
|
+
StreamEvent,
|
|
25
|
+
TextDelta,
|
|
26
|
+
ToolCallEvent,
|
|
27
|
+
ToolSpec,
|
|
28
|
+
ValidationResult,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from ..models import AppConfig, Message
|
|
33
|
+
|
|
34
|
+
_BASE_URL = "https://capi.aerolink.lat"
|
|
35
|
+
_API_VERSION = "2023-06-01" # Anthropic Messages API version header
|
|
36
|
+
_TIMEOUT = httpx.Timeout(20.0, read=180.0)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _headers(api_key: str) -> dict[str, str]:
|
|
40
|
+
return {
|
|
41
|
+
"x-api-key": api_key,
|
|
42
|
+
"anthropic-version": _API_VERSION,
|
|
43
|
+
"content-type": "application/json",
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class AeroLinkProvider(Provider):
|
|
49
|
+
def __post_init__(self) -> None:
|
|
50
|
+
self.id = "aerolink"
|
|
51
|
+
self.label = "AeroLink"
|
|
52
|
+
self.base_url = _BASE_URL
|
|
53
|
+
self.requires_key = True
|
|
54
|
+
self.key_hint = "from your dashboard at https://aerolink.lat (API keys page)"
|
|
55
|
+
|
|
56
|
+
def validate_key(self, api_key: str) -> ValidationResult:
|
|
57
|
+
key = api_key.strip()
|
|
58
|
+
if not key:
|
|
59
|
+
return ValidationResult(False, "API key is empty.")
|
|
60
|
+
try:
|
|
61
|
+
response = httpx.get(
|
|
62
|
+
f"{_BASE_URL}/v1/models", headers=_headers(key), timeout=20.0
|
|
63
|
+
)
|
|
64
|
+
except httpx.TimeoutException:
|
|
65
|
+
return ValidationResult(False, "Validation timed out. Check your connection.")
|
|
66
|
+
except httpx.HTTPError:
|
|
67
|
+
return ValidationResult(False, "Could not reach AeroLink. Check your connection.")
|
|
68
|
+
if response.status_code == 200:
|
|
69
|
+
return ValidationResult(True, "API key verified.")
|
|
70
|
+
if response.status_code in (401, 403):
|
|
71
|
+
return ValidationResult(False, "API key was rejected by AeroLink.")
|
|
72
|
+
# Some gateways don't proxy /v1/models; accept and verify on first chat.
|
|
73
|
+
return ValidationResult(
|
|
74
|
+
True, "Key saved. AeroLink did not confirm it; it will be verified on first message."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def list_models(self, config: "AppConfig") -> list[ModelInfo]:
|
|
78
|
+
key = config.get_api_key("aerolink")
|
|
79
|
+
try:
|
|
80
|
+
response = httpx.get(
|
|
81
|
+
f"{_BASE_URL}/v1/models", headers=_headers(key), timeout=20.0
|
|
82
|
+
)
|
|
83
|
+
response.raise_for_status()
|
|
84
|
+
data = response.json().get("data", [])
|
|
85
|
+
except httpx.TimeoutException as exc:
|
|
86
|
+
raise ProviderError(
|
|
87
|
+
"Timed out fetching the AeroLink model list.", transient=True
|
|
88
|
+
) from exc
|
|
89
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
90
|
+
raise ProviderError(
|
|
91
|
+
"AeroLink did not return a model list. Enter a model ID from your "
|
|
92
|
+
"dashboard with: /model <model-id>",
|
|
93
|
+
) from exc
|
|
94
|
+
|
|
95
|
+
# AeroLink serves the Claude family only — filter anything else out.
|
|
96
|
+
models = [
|
|
97
|
+
ModelInfo(id=entry["id"], label=entry.get("display_name") or entry["id"])
|
|
98
|
+
for entry in data
|
|
99
|
+
if entry.get("id") and "claude" in entry["id"].lower()
|
|
100
|
+
]
|
|
101
|
+
if not models:
|
|
102
|
+
raise ProviderError(
|
|
103
|
+
"AeroLink returned no Claude-family models. Enter a model ID from "
|
|
104
|
+
"your dashboard with: /model <model-id>"
|
|
105
|
+
)
|
|
106
|
+
return models
|
|
107
|
+
|
|
108
|
+
def stream_chat(self, config: "AppConfig", messages: list["Message"]) -> Iterator[str]:
|
|
109
|
+
# The Messages API takes the system prompt as a top-level field.
|
|
110
|
+
system = next((m.content for m in messages if m.role == "system"), None)
|
|
111
|
+
turns = [m.to_api() for m in messages if m.role != "system"]
|
|
112
|
+
payload: dict[str, object] = {
|
|
113
|
+
"model": config.model,
|
|
114
|
+
"max_tokens": config.effective_max_tokens(),
|
|
115
|
+
"messages": turns,
|
|
116
|
+
"stream": True,
|
|
117
|
+
}
|
|
118
|
+
if system:
|
|
119
|
+
payload["system"] = system
|
|
120
|
+
yield from self._stream_payload(config, payload, _iter_sse_text)
|
|
121
|
+
|
|
122
|
+
# --- native tool calling -------------------------------------------------
|
|
123
|
+
def supports_tools(self, config: "AppConfig") -> bool:
|
|
124
|
+
"""AeroLink serves Claude models, which all take native tools."""
|
|
125
|
+
return True
|
|
126
|
+
|
|
127
|
+
def stream_chat_with_tools(
|
|
128
|
+
self, config: "AppConfig", messages: list["Message"], tools: list[ToolSpec]
|
|
129
|
+
) -> Iterator[StreamEvent]:
|
|
130
|
+
system = next((m.content for m in messages if m.role == "system"), None)
|
|
131
|
+
payload: dict[str, object] = {
|
|
132
|
+
"model": config.model,
|
|
133
|
+
"max_tokens": config.effective_max_tokens(),
|
|
134
|
+
"messages": _to_anthropic_turns(messages),
|
|
135
|
+
"tools": [
|
|
136
|
+
{
|
|
137
|
+
"name": t.name,
|
|
138
|
+
"description": t.description,
|
|
139
|
+
"input_schema": t.parameters,
|
|
140
|
+
}
|
|
141
|
+
for t in tools
|
|
142
|
+
],
|
|
143
|
+
"stream": True,
|
|
144
|
+
}
|
|
145
|
+
if system:
|
|
146
|
+
payload["system"] = system
|
|
147
|
+
yield from self._stream_payload(config, payload, _iter_sse_events)
|
|
148
|
+
|
|
149
|
+
def _stream_payload(self, config: "AppConfig", payload: dict, parser) -> Iterator:
|
|
150
|
+
"""POST /v1/messages with shared status handling; yield parser output."""
|
|
151
|
+
try:
|
|
152
|
+
with httpx.stream(
|
|
153
|
+
"POST",
|
|
154
|
+
f"{_BASE_URL}/v1/messages",
|
|
155
|
+
headers=_headers(config.get_api_key("aerolink")),
|
|
156
|
+
json=payload,
|
|
157
|
+
timeout=_TIMEOUT,
|
|
158
|
+
) as response:
|
|
159
|
+
if response.status_code in (401, 403):
|
|
160
|
+
raise ProviderError(
|
|
161
|
+
"Authentication failed. Your AeroLink key may be invalid — run /apikey."
|
|
162
|
+
)
|
|
163
|
+
if response.status_code == 402:
|
|
164
|
+
raise ProviderError(
|
|
165
|
+
"AeroLink says the account is out of credits (HTTP 402). "
|
|
166
|
+
"Check your plan at https://aerolink.lat."
|
|
167
|
+
)
|
|
168
|
+
if response.status_code == 404:
|
|
169
|
+
raise ProviderError(
|
|
170
|
+
f"Model '{config.model}' was not found on AeroLink — run /model."
|
|
171
|
+
)
|
|
172
|
+
if response.status_code == 408:
|
|
173
|
+
raise ProviderError(
|
|
174
|
+
"AeroLink timed out handling the request. Please try again.",
|
|
175
|
+
transient=True,
|
|
176
|
+
)
|
|
177
|
+
if response.status_code == 429:
|
|
178
|
+
raise ProviderError(
|
|
179
|
+
"Rate limited by AeroLink. Please wait and try again.", transient=True
|
|
180
|
+
)
|
|
181
|
+
if response.status_code >= 500:
|
|
182
|
+
raise ProviderError(
|
|
183
|
+
f"AeroLink had a server error (HTTP {response.status_code}). "
|
|
184
|
+
"Please try again.",
|
|
185
|
+
transient=True,
|
|
186
|
+
)
|
|
187
|
+
if response.status_code >= 400:
|
|
188
|
+
detail = response.read().decode("utf-8", "replace")[:300]
|
|
189
|
+
raise ProviderError(f"AeroLink error (HTTP {response.status_code}): {detail}")
|
|
190
|
+
yield from parser(response)
|
|
191
|
+
except httpx.TimeoutException as exc:
|
|
192
|
+
raise ProviderError(
|
|
193
|
+
"Timed out talking to AeroLink. Please try again.", transient=True
|
|
194
|
+
) from exc
|
|
195
|
+
except httpx.HTTPError as exc:
|
|
196
|
+
raise ProviderError(
|
|
197
|
+
"Network error reaching AeroLink. Check your connection.", transient=True
|
|
198
|
+
) from exc
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _iter_sse_text(response: httpx.Response) -> Iterator[str]:
|
|
202
|
+
"""Yield text deltas from an Anthropic-style SSE stream."""
|
|
203
|
+
for line in response.iter_lines():
|
|
204
|
+
if not line.startswith("data:"):
|
|
205
|
+
continue
|
|
206
|
+
data = line[5:].strip()
|
|
207
|
+
if not data or data == "[DONE]":
|
|
208
|
+
continue
|
|
209
|
+
try:
|
|
210
|
+
event = json.loads(data)
|
|
211
|
+
except ValueError:
|
|
212
|
+
continue # tolerate keep-alive noise
|
|
213
|
+
if event.get("type") == "content_block_delta":
|
|
214
|
+
delta = event.get("delta") or {}
|
|
215
|
+
if delta.get("type") == "text_delta" and delta.get("text"):
|
|
216
|
+
yield delta["text"]
|
|
217
|
+
elif event.get("type") == "error":
|
|
218
|
+
message = (event.get("error") or {}).get("message", "Unknown AeroLink error.")
|
|
219
|
+
raise ProviderError(f"AeroLink error: {message}")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _iter_sse_events(response: httpx.Response) -> Iterator[StreamEvent]:
|
|
223
|
+
"""Yield text deltas and COMPLETE tool calls from an Anthropic SSE stream.
|
|
224
|
+
|
|
225
|
+
``content_block_start`` with a ``tool_use`` block opens a call (id and
|
|
226
|
+
name arrive there); ``input_json_delta`` fragments accumulate its
|
|
227
|
+
argument JSON; ``content_block_stop`` closes and emits it.
|
|
228
|
+
"""
|
|
229
|
+
open_calls: dict[int, dict[str, str]] = {} # block index -> {id, name, json}
|
|
230
|
+
for line in response.iter_lines():
|
|
231
|
+
if not line.startswith("data:"):
|
|
232
|
+
continue
|
|
233
|
+
data = line[5:].strip()
|
|
234
|
+
if not data or data == "[DONE]":
|
|
235
|
+
continue
|
|
236
|
+
try:
|
|
237
|
+
event = json.loads(data)
|
|
238
|
+
except ValueError:
|
|
239
|
+
continue # tolerate keep-alive noise
|
|
240
|
+
kind = event.get("type")
|
|
241
|
+
if kind == "content_block_start":
|
|
242
|
+
block = event.get("content_block") or {}
|
|
243
|
+
if block.get("type") == "tool_use":
|
|
244
|
+
open_calls[event.get("index", 0)] = {
|
|
245
|
+
"id": block.get("id", ""),
|
|
246
|
+
"name": block.get("name", ""),
|
|
247
|
+
"json": "",
|
|
248
|
+
}
|
|
249
|
+
elif kind == "content_block_delta":
|
|
250
|
+
delta = event.get("delta") or {}
|
|
251
|
+
if delta.get("type") == "text_delta" and delta.get("text"):
|
|
252
|
+
yield TextDelta(delta["text"])
|
|
253
|
+
elif delta.get("type") == "input_json_delta":
|
|
254
|
+
slot = open_calls.get(event.get("index", 0))
|
|
255
|
+
if slot is not None:
|
|
256
|
+
slot["json"] += delta.get("partial_json", "")
|
|
257
|
+
elif kind == "content_block_stop":
|
|
258
|
+
slot = open_calls.pop(event.get("index", 0), None)
|
|
259
|
+
if slot is not None:
|
|
260
|
+
raw = slot["json"].strip() or "{}"
|
|
261
|
+
try:
|
|
262
|
+
arguments = json.loads(raw)
|
|
263
|
+
if not isinstance(arguments, dict):
|
|
264
|
+
raise ValueError("input must be a JSON object")
|
|
265
|
+
yield ToolCallEvent(
|
|
266
|
+
id=slot["id"], name=slot["name"], arguments=arguments
|
|
267
|
+
)
|
|
268
|
+
except ValueError as exc:
|
|
269
|
+
yield ToolCallEvent(
|
|
270
|
+
id=slot["id"], name=slot["name"], arguments={},
|
|
271
|
+
error=f"Tool call arguments were not valid JSON: {exc}",
|
|
272
|
+
)
|
|
273
|
+
elif kind == "error":
|
|
274
|
+
message = (event.get("error") or {}).get("message", "Unknown AeroLink error.")
|
|
275
|
+
raise ProviderError(f"AeroLink error: {message}")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _to_anthropic_turns(messages: list["Message"]) -> list[dict]:
|
|
279
|
+
"""Serialize history to Anthropic Messages turns with tool blocks.
|
|
280
|
+
|
|
281
|
+
Wire rules this must satisfy (the API 400s otherwise):
|
|
282
|
+
* an assistant turn that called tools carries ``tool_use`` content blocks;
|
|
283
|
+
* ALL of that turn's results arrive in the SINGLE next user message as
|
|
284
|
+
``tool_result`` blocks — consecutive role=="tool" messages are merged.
|
|
285
|
+
"""
|
|
286
|
+
turns: list[dict] = []
|
|
287
|
+
pending_results: list[dict] = []
|
|
288
|
+
|
|
289
|
+
def flush_results() -> None:
|
|
290
|
+
if pending_results:
|
|
291
|
+
turns.append({"role": "user", "content": list(pending_results)})
|
|
292
|
+
pending_results.clear()
|
|
293
|
+
|
|
294
|
+
for message in messages:
|
|
295
|
+
if message.role == "system":
|
|
296
|
+
continue
|
|
297
|
+
if message.role == "tool":
|
|
298
|
+
pending_results.append(
|
|
299
|
+
{
|
|
300
|
+
"type": "tool_result",
|
|
301
|
+
"tool_use_id": message.tool_call_id,
|
|
302
|
+
"content": message.content,
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
continue
|
|
306
|
+
flush_results()
|
|
307
|
+
if message.role == "assistant" and message.tool_calls:
|
|
308
|
+
content: list[dict] = []
|
|
309
|
+
if message.content.strip():
|
|
310
|
+
content.append({"type": "text", "text": message.content})
|
|
311
|
+
content += [
|
|
312
|
+
{
|
|
313
|
+
"type": "tool_use",
|
|
314
|
+
"id": call.id,
|
|
315
|
+
"name": call.name,
|
|
316
|
+
"input": call.arguments,
|
|
317
|
+
}
|
|
318
|
+
for call in message.tool_calls
|
|
319
|
+
]
|
|
320
|
+
turns.append({"role": "assistant", "content": content})
|
|
321
|
+
else:
|
|
322
|
+
turns.append({"role": message.role, "content": message.content})
|
|
323
|
+
flush_results()
|
|
324
|
+
return turns
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Provider abstraction: the contract every AI backend implements.
|
|
2
|
+
|
|
3
|
+
A provider is a fully independent backend: it owns its API key slot, base
|
|
4
|
+
URL, model catalogue, client, and connection status. The chat engine and the
|
|
5
|
+
UI never talk to a vendor API directly — they go through this interface, so
|
|
6
|
+
providers share ONLY the common chat contract, never each other's logic.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ..models import AppConfig, Message
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ProviderError(Exception):
|
|
21
|
+
"""A provider request failed with a user-presentable message.
|
|
22
|
+
|
|
23
|
+
``transient`` marks failures worth retrying automatically (timeouts,
|
|
24
|
+
connection drops, rate limits) as opposed to permanent ones (bad key,
|
|
25
|
+
unknown model).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, message: str, *, transient: bool = False) -> None:
|
|
29
|
+
super().__init__(message)
|
|
30
|
+
self.transient = transient
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class ValidationResult:
|
|
35
|
+
"""Outcome of an API key validation attempt."""
|
|
36
|
+
|
|
37
|
+
ok: bool
|
|
38
|
+
message: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(slots=True)
|
|
42
|
+
class ModelInfo:
|
|
43
|
+
"""One selectable model as reported by a provider.
|
|
44
|
+
|
|
45
|
+
``is_free`` is set by providers whose catalogue carries pricing
|
|
46
|
+
(True/False); ``None`` means pricing does not apply (e.g. local models).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
label: str = ""
|
|
51
|
+
detail: str = ""
|
|
52
|
+
is_free: bool | None = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
if not self.label:
|
|
56
|
+
self.label = self.id
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Connection-status display values (session-only, never persisted).
|
|
60
|
+
STATUS_UNKNOWN = "Not Checked"
|
|
61
|
+
STATUS_CONNECTED = "Connected"
|
|
62
|
+
STATUS_OFFLINE = "Offline"
|
|
63
|
+
STATUS_NO_KEY = "No API Key"
|
|
64
|
+
STATUS_BAD_KEY = "Invalid Key"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- native tool calling ------------------------------------------------------
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class ToolSpec:
|
|
70
|
+
"""Provider-neutral tool description.
|
|
71
|
+
|
|
72
|
+
``parameters`` is a JSON-schema object
|
|
73
|
+
(``{"type": "object", "properties": {...}, "required": [...]}``); each
|
|
74
|
+
provider converts it to its own wire format (OpenAI ``function``,
|
|
75
|
+
Anthropic ``input_schema``, ...).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name: str
|
|
79
|
+
description: str
|
|
80
|
+
parameters: dict
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True)
|
|
84
|
+
class TextDelta:
|
|
85
|
+
"""A streamed piece of assistant text."""
|
|
86
|
+
|
|
87
|
+
text: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class ToolCallEvent:
|
|
92
|
+
"""One COMPLETE native tool call.
|
|
93
|
+
|
|
94
|
+
Providers accumulate their own streamed argument fragments and emit a
|
|
95
|
+
single event per finished call, so consumers never see wire-format
|
|
96
|
+
details. When the accumulated argument JSON would not parse, ``error``
|
|
97
|
+
carries the parse problem (and ``arguments`` is empty) — the agent feeds
|
|
98
|
+
it back to the model like any other malformed call.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
id: str
|
|
102
|
+
name: str
|
|
103
|
+
arguments: dict
|
|
104
|
+
error: str = ""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
StreamEvent = TextDelta | ToolCallEvent
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@dataclass
|
|
111
|
+
class Provider(ABC):
|
|
112
|
+
"""Base class for AI backends.
|
|
113
|
+
|
|
114
|
+
Each concrete provider sets its identity in ``__post_init__`` and keeps
|
|
115
|
+
its own private client/catalogue caches — nothing is shared between
|
|
116
|
+
provider modules.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
id: str = field(init=False)
|
|
120
|
+
label: str = field(init=False)
|
|
121
|
+
base_url: str = field(init=False, default="")
|
|
122
|
+
requires_key: bool = field(init=False, default=True)
|
|
123
|
+
key_hint: str = field(init=False, default="")
|
|
124
|
+
# Human name of the API family this backend speaks (e.g. "Claude API",
|
|
125
|
+
# "Responses API"); shown on the dashboard. Empty means "<label> API".
|
|
126
|
+
backend_label: str = field(init=False, default="")
|
|
127
|
+
# True when the provider supports the 'auto' model sentinel (the best
|
|
128
|
+
# model is resolved from the live catalogue per request).
|
|
129
|
+
supports_auto: bool = field(init=False, default=False)
|
|
130
|
+
# Last known connection status for this provider (session-only cache;
|
|
131
|
+
# refreshed by :meth:`refresh_status` — reading it does no network I/O).
|
|
132
|
+
status: str = field(init=False, default=STATUS_UNKNOWN)
|
|
133
|
+
|
|
134
|
+
def prepare(self, config: "AppConfig") -> None:
|
|
135
|
+
"""Sync per-config state (e.g. an active sub-backend) before use.
|
|
136
|
+
|
|
137
|
+
Called by shared flows (key entry, validation, doctor) so a provider
|
|
138
|
+
with internal modes always acts on the configured one. Default: no-op.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def supports_images(self, config: "AppConfig") -> bool:
|
|
142
|
+
"""Whether this backend can accept image attachments on messages.
|
|
143
|
+
|
|
144
|
+
Providers that can forward base64 screenshots to a vision-capable
|
|
145
|
+
model override this; the default is text-only, and callers must not
|
|
146
|
+
attach images when it returns False.
|
|
147
|
+
"""
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def validate_key(self, api_key: str) -> ValidationResult:
|
|
152
|
+
"""Check credentials with a REAL API request (never heuristics).
|
|
153
|
+
|
|
154
|
+
Providers without keys validate connectivity instead.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
@abstractmethod
|
|
158
|
+
def list_models(self, config: "AppConfig") -> list[ModelInfo]:
|
|
159
|
+
"""Fetch the models the user may select. Raises ProviderError."""
|
|
160
|
+
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def stream_chat(self, config: "AppConfig", messages: list["Message"]) -> Iterator[str]:
|
|
163
|
+
"""Stream a reply for the conversation. Raises ProviderError."""
|
|
164
|
+
|
|
165
|
+
# --- native tool calling -------------------------------------------------
|
|
166
|
+
def supports_tools(self, config: "AppConfig") -> bool:
|
|
167
|
+
"""Whether this backend can take native tool/function definitions.
|
|
168
|
+
|
|
169
|
+
When False, the agent talks to the model through the text protocol
|
|
170
|
+
instead (fenced ``tool`` blocks). True is an *attempt*: a runtime
|
|
171
|
+
failure still falls back gracefully at the agent layer.
|
|
172
|
+
"""
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def stream_chat_with_tools(
|
|
176
|
+
self, config: "AppConfig", messages: list["Message"], tools: list[ToolSpec]
|
|
177
|
+
) -> Iterator[StreamEvent]:
|
|
178
|
+
"""Stream a reply as events: text deltas and complete tool calls.
|
|
179
|
+
|
|
180
|
+
Default degrades to plain text so a provider without an override
|
|
181
|
+
still works (through the text protocol). Raises ProviderError.
|
|
182
|
+
"""
|
|
183
|
+
for piece in self.stream_chat(config, messages):
|
|
184
|
+
yield TextDelta(piece)
|
|
185
|
+
|
|
186
|
+
# --- connection status ---------------------------------------------------
|
|
187
|
+
def refresh_status(self, config: "AppConfig") -> str:
|
|
188
|
+
"""Probe the backend with a real request and cache the outcome.
|
|
189
|
+
|
|
190
|
+
Never raises; the result is stored in :attr:`status` and returned so
|
|
191
|
+
the UI can refresh immediately after a provider switch.
|
|
192
|
+
"""
|
|
193
|
+
try:
|
|
194
|
+
if self.requires_key:
|
|
195
|
+
key = config.get_api_key(self.id).strip()
|
|
196
|
+
if not key:
|
|
197
|
+
self.status = STATUS_NO_KEY
|
|
198
|
+
return self.status
|
|
199
|
+
result = self.validate_key(key)
|
|
200
|
+
if result.ok:
|
|
201
|
+
self.status = STATUS_CONNECTED
|
|
202
|
+
elif "connection" in result.message.lower() or "timed out" in result.message.lower():
|
|
203
|
+
self.status = STATUS_OFFLINE
|
|
204
|
+
else:
|
|
205
|
+
self.status = STATUS_BAD_KEY
|
|
206
|
+
else:
|
|
207
|
+
self.status = STATUS_CONNECTED if self.detect(config) else STATUS_OFFLINE
|
|
208
|
+
except Exception: # a status probe must never break the UI
|
|
209
|
+
self.status = STATUS_OFFLINE
|
|
210
|
+
return self.status
|
|
211
|
+
|
|
212
|
+
def detect(self, config: "AppConfig") -> bool:
|
|
213
|
+
"""Backend reachability for key-less providers (default: unknown)."""
|
|
214
|
+
return False
|
|
215
|
+
|
|
216
|
+
# --- provider-specific settings -------------------------------------------
|
|
217
|
+
def extra_settings(self, config: "AppConfig") -> dict[str, str]:
|
|
218
|
+
"""Provider-specific settings shown on its own settings screen.
|
|
219
|
+
|
|
220
|
+
Returns ``{setting name: current display value}``; empty when the
|
|
221
|
+
provider has none. Values live in the provider's own config entry
|
|
222
|
+
(``ProviderConfig.options``), never shared.
|
|
223
|
+
"""
|
|
224
|
+
return {}
|
|
225
|
+
|
|
226
|
+
def set_extra_setting(
|
|
227
|
+
self, config: "AppConfig", name: str, value: str
|
|
228
|
+
) -> tuple[bool, str]:
|
|
229
|
+
"""Apply a provider-specific setting; returns (ok, user message)."""
|
|
230
|
+
return False, f"{self.label} has no setting '{name}'."
|