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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. 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}'."