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,262 @@
|
|
|
1
|
+
"""Ollama backend: talks to a local Ollama server.
|
|
2
|
+
|
|
3
|
+
Uses the native Ollama HTTP API: ``GET /api/tags`` to detect the server and
|
|
4
|
+
list installed models, ``POST /api/chat`` (NDJSON stream) for replies. No API
|
|
5
|
+
key is involved; the server address is configurable (``ollama_host``).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import TYPE_CHECKING
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .base import (
|
|
18
|
+
ModelInfo,
|
|
19
|
+
Provider,
|
|
20
|
+
ProviderError,
|
|
21
|
+
StreamEvent,
|
|
22
|
+
TextDelta,
|
|
23
|
+
ToolCallEvent,
|
|
24
|
+
ToolSpec,
|
|
25
|
+
ValidationResult,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from ..models import AppConfig, Message
|
|
30
|
+
|
|
31
|
+
_DETECT_TIMEOUT = 5.0
|
|
32
|
+
# Local generation can pause while a model loads into memory; be generous.
|
|
33
|
+
_CHAT_TIMEOUT = httpx.Timeout(10.0, read=300.0)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _not_running(host: str) -> ProviderError:
|
|
37
|
+
return ProviderError(
|
|
38
|
+
f"Ollama is not reachable at {host}. Start it with 'ollama serve' "
|
|
39
|
+
"(install from https://ollama.com) and try again."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _to_api_ollama(message: "Message") -> dict:
|
|
44
|
+
"""Ollama message shape; attaches base64 images when present."""
|
|
45
|
+
api = message.to_api()
|
|
46
|
+
if message.images:
|
|
47
|
+
api["images"] = list(message.images) # type: ignore[assignment]
|
|
48
|
+
return api
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _to_api_ollama_tools(message: "Message") -> dict:
|
|
52
|
+
"""Ollama message shape for tool-calling conversations.
|
|
53
|
+
|
|
54
|
+
Assistant tool calls use the OpenAI-style ``tool_calls`` field; result
|
|
55
|
+
messages are role=="tool" with plain content (Ollama pairs them by
|
|
56
|
+
order, it has no call ids).
|
|
57
|
+
"""
|
|
58
|
+
if message.role == "tool":
|
|
59
|
+
return {"role": "tool", "content": message.content}
|
|
60
|
+
if message.role == "assistant" and message.tool_calls:
|
|
61
|
+
return {
|
|
62
|
+
"role": "assistant",
|
|
63
|
+
"content": message.content,
|
|
64
|
+
"tool_calls": [
|
|
65
|
+
{"function": {"name": call.name, "arguments": call.arguments}}
|
|
66
|
+
for call in message.tool_calls
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
return _to_api_ollama(message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class OllamaProvider(Provider):
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
self.id = "ollama"
|
|
76
|
+
self.label = "Ollama (local)"
|
|
77
|
+
self.base_url = "" # per-user host lives in config.ollama_host
|
|
78
|
+
self.requires_key = False
|
|
79
|
+
self.key_hint = ""
|
|
80
|
+
|
|
81
|
+
def validate_key(self, api_key: str) -> ValidationResult:
|
|
82
|
+
"""No key needed; 'validation' means the local server responds."""
|
|
83
|
+
return ValidationResult(True, "Ollama needs no API key.")
|
|
84
|
+
|
|
85
|
+
def extra_settings(self, config: "AppConfig") -> dict[str, str]:
|
|
86
|
+
return {"host": config.ollama_host}
|
|
87
|
+
|
|
88
|
+
def set_extra_setting(
|
|
89
|
+
self, config: "AppConfig", name: str, value: str
|
|
90
|
+
) -> tuple[bool, str]:
|
|
91
|
+
if name != "host":
|
|
92
|
+
return False, f"{self.label} has no setting '{name}'."
|
|
93
|
+
host = value.strip().rstrip("/")
|
|
94
|
+
if not host.startswith(("http://", "https://")):
|
|
95
|
+
return False, "host expects a URL like http://localhost:11434."
|
|
96
|
+
config.ollama_host = host
|
|
97
|
+
return True, f"Ollama host set to {host}."
|
|
98
|
+
|
|
99
|
+
def detect(self, config: "AppConfig") -> bool:
|
|
100
|
+
"""True when the Ollama server answers on the configured host."""
|
|
101
|
+
try:
|
|
102
|
+
response = httpx.get(f"{config.ollama_host}/api/tags", timeout=_DETECT_TIMEOUT)
|
|
103
|
+
return response.status_code == 200
|
|
104
|
+
except httpx.HTTPError:
|
|
105
|
+
return False
|
|
106
|
+
|
|
107
|
+
def list_models(self, config: "AppConfig") -> list[ModelInfo]:
|
|
108
|
+
try:
|
|
109
|
+
response = httpx.get(f"{config.ollama_host}/api/tags", timeout=_DETECT_TIMEOUT)
|
|
110
|
+
response.raise_for_status()
|
|
111
|
+
entries = response.json().get("models", [])
|
|
112
|
+
except httpx.HTTPError as exc:
|
|
113
|
+
raise _not_running(config.ollama_host) from exc
|
|
114
|
+
except ValueError as exc:
|
|
115
|
+
raise ProviderError("Ollama returned an unreadable model list.") from exc
|
|
116
|
+
|
|
117
|
+
models = []
|
|
118
|
+
for entry in entries:
|
|
119
|
+
name = entry.get("name")
|
|
120
|
+
if not name:
|
|
121
|
+
continue
|
|
122
|
+
size = entry.get("size") or 0
|
|
123
|
+
detail = f"{size / 1e9:.1f} GB" if size else ""
|
|
124
|
+
models.append(ModelInfo(id=name, detail=detail))
|
|
125
|
+
if not models:
|
|
126
|
+
raise ProviderError(
|
|
127
|
+
"Ollama is running but has no models installed. "
|
|
128
|
+
"Pull one first, e.g.: ollama pull llama3.2"
|
|
129
|
+
)
|
|
130
|
+
return models
|
|
131
|
+
|
|
132
|
+
def supports_images(self, config: "AppConfig") -> bool:
|
|
133
|
+
"""Ollama's chat API accepts images natively (vision models use them)."""
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def stream_chat(self, config: "AppConfig", messages: list["Message"]) -> Iterator[str]:
|
|
137
|
+
payload = {
|
|
138
|
+
"model": config.model,
|
|
139
|
+
"messages": [_to_api_ollama(m) for m in messages],
|
|
140
|
+
"stream": True,
|
|
141
|
+
}
|
|
142
|
+
try:
|
|
143
|
+
with httpx.stream(
|
|
144
|
+
"POST", f"{config.ollama_host}/api/chat", json=payload, timeout=_CHAT_TIMEOUT
|
|
145
|
+
) as response:
|
|
146
|
+
if response.status_code == 404:
|
|
147
|
+
raise ProviderError(
|
|
148
|
+
f"Model '{config.model}' is not installed in Ollama. "
|
|
149
|
+
f"Run: ollama pull {config.model} (or pick another via /model)"
|
|
150
|
+
)
|
|
151
|
+
if response.status_code >= 500:
|
|
152
|
+
raise ProviderError(
|
|
153
|
+
f"Ollama had a server error (HTTP {response.status_code}). "
|
|
154
|
+
"Please try again.",
|
|
155
|
+
transient=True,
|
|
156
|
+
)
|
|
157
|
+
if response.status_code >= 400:
|
|
158
|
+
detail = response.read().decode("utf-8", "replace")[:300]
|
|
159
|
+
raise ProviderError(f"Ollama error (HTTP {response.status_code}): {detail}")
|
|
160
|
+
# NDJSON: one JSON object per line until "done": true.
|
|
161
|
+
for line in response.iter_lines():
|
|
162
|
+
if not line.strip():
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
event = json.loads(line)
|
|
166
|
+
except ValueError:
|
|
167
|
+
continue
|
|
168
|
+
if event.get("error"):
|
|
169
|
+
raise ProviderError(f"Ollama error: {event['error']}")
|
|
170
|
+
piece = (event.get("message") or {}).get("content")
|
|
171
|
+
if piece:
|
|
172
|
+
yield piece
|
|
173
|
+
if event.get("done"):
|
|
174
|
+
return
|
|
175
|
+
except httpx.TimeoutException as exc:
|
|
176
|
+
raise ProviderError(
|
|
177
|
+
"Timed out waiting for Ollama. The model may still be loading — try again.",
|
|
178
|
+
transient=True,
|
|
179
|
+
) from exc
|
|
180
|
+
except httpx.HTTPError as exc:
|
|
181
|
+
raise _not_running(config.ollama_host) from exc
|
|
182
|
+
|
|
183
|
+
# --- native tool calling -------------------------------------------------
|
|
184
|
+
def supports_tools(self, config: "AppConfig") -> bool:
|
|
185
|
+
"""Attempt native tools; models without support fail at runtime and
|
|
186
|
+
the agent layer falls back to the text protocol."""
|
|
187
|
+
return True
|
|
188
|
+
|
|
189
|
+
def stream_chat_with_tools(
|
|
190
|
+
self, config: "AppConfig", messages: list["Message"], tools: list[ToolSpec]
|
|
191
|
+
) -> Iterator[StreamEvent]:
|
|
192
|
+
payload = {
|
|
193
|
+
"model": config.model,
|
|
194
|
+
"messages": [_to_api_ollama_tools(m) for m in messages],
|
|
195
|
+
"tools": [
|
|
196
|
+
{
|
|
197
|
+
"type": "function",
|
|
198
|
+
"function": {
|
|
199
|
+
"name": t.name,
|
|
200
|
+
"description": t.description,
|
|
201
|
+
"parameters": t.parameters,
|
|
202
|
+
},
|
|
203
|
+
}
|
|
204
|
+
for t in tools
|
|
205
|
+
],
|
|
206
|
+
"stream": True,
|
|
207
|
+
}
|
|
208
|
+
call_counter = 0
|
|
209
|
+
try:
|
|
210
|
+
with httpx.stream(
|
|
211
|
+
"POST", f"{config.ollama_host}/api/chat", json=payload, timeout=_CHAT_TIMEOUT
|
|
212
|
+
) as response:
|
|
213
|
+
if response.status_code == 404:
|
|
214
|
+
raise ProviderError(
|
|
215
|
+
f"Model '{config.model}' is not installed in Ollama. "
|
|
216
|
+
f"Run: ollama pull {config.model} (or pick another via /model)"
|
|
217
|
+
)
|
|
218
|
+
if response.status_code >= 500:
|
|
219
|
+
raise ProviderError(
|
|
220
|
+
f"Ollama had a server error (HTTP {response.status_code}). "
|
|
221
|
+
"Please try again.",
|
|
222
|
+
transient=True,
|
|
223
|
+
)
|
|
224
|
+
if response.status_code >= 400:
|
|
225
|
+
detail = response.read().decode("utf-8", "replace")[:300]
|
|
226
|
+
raise ProviderError(f"Ollama error (HTTP {response.status_code}): {detail}")
|
|
227
|
+
# NDJSON stream; tool calls arrive with arguments already
|
|
228
|
+
# parsed as objects (no fragment assembly needed). Ollama has
|
|
229
|
+
# no call ids, so synthesize stable ones.
|
|
230
|
+
for line in response.iter_lines():
|
|
231
|
+
if not line.strip():
|
|
232
|
+
continue
|
|
233
|
+
try:
|
|
234
|
+
event = json.loads(line)
|
|
235
|
+
except ValueError:
|
|
236
|
+
continue
|
|
237
|
+
if event.get("error"):
|
|
238
|
+
raise ProviderError(f"Ollama error: {event['error']}")
|
|
239
|
+
message = event.get("message") or {}
|
|
240
|
+
piece = message.get("content")
|
|
241
|
+
if piece:
|
|
242
|
+
yield TextDelta(piece)
|
|
243
|
+
for call in message.get("tool_calls") or []:
|
|
244
|
+
fn = call.get("function") or {}
|
|
245
|
+
arguments = fn.get("arguments")
|
|
246
|
+
if not isinstance(arguments, dict):
|
|
247
|
+
arguments = {}
|
|
248
|
+
call_counter += 1
|
|
249
|
+
yield ToolCallEvent(
|
|
250
|
+
id=f"call_{call_counter}",
|
|
251
|
+
name=fn.get("name", ""),
|
|
252
|
+
arguments=arguments,
|
|
253
|
+
)
|
|
254
|
+
if event.get("done"):
|
|
255
|
+
return
|
|
256
|
+
except httpx.TimeoutException as exc:
|
|
257
|
+
raise ProviderError(
|
|
258
|
+
"Timed out waiting for Ollama. The model may still be loading — try again.",
|
|
259
|
+
transient=True,
|
|
260
|
+
) from exc
|
|
261
|
+
except httpx.HTTPError as exc:
|
|
262
|
+
raise _not_running(config.ollama_host) from exc
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""OpenRouter backend: the openrouter.ai catalogue with Free/Pro modes.
|
|
2
|
+
|
|
3
|
+
Fully independent provider: its own key slot, base URL, client cache, and
|
|
4
|
+
catalogue. Two modes share the same API key:
|
|
5
|
+
|
|
6
|
+
* **Free Models** (default) — the model picker shows only zero-cost models.
|
|
7
|
+
* **Pro Models** — the picker shows paid models.
|
|
8
|
+
|
|
9
|
+
The mode is a provider-specific setting persisted in OpenRouter's own
|
|
10
|
+
config entry. Nothing is hardcoded and no other provider's logic is used.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import TYPE_CHECKING, Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from ..streaming import iter_stream
|
|
23
|
+
from ...utils.logger import get_logger
|
|
24
|
+
from .base import (
|
|
25
|
+
ModelInfo,
|
|
26
|
+
Provider,
|
|
27
|
+
ProviderError,
|
|
28
|
+
StreamEvent,
|
|
29
|
+
TextDelta,
|
|
30
|
+
ToolCallEvent,
|
|
31
|
+
ToolSpec,
|
|
32
|
+
ValidationResult,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from ..models import AppConfig, Message
|
|
37
|
+
|
|
38
|
+
_log = get_logger("openrouter")
|
|
39
|
+
|
|
40
|
+
_BASE_URL = "https://openrouter.ai/api/v1"
|
|
41
|
+
_VALIDATE_URL = f"{_BASE_URL}/key"
|
|
42
|
+
_MODELS_URL = f"{_BASE_URL}/models"
|
|
43
|
+
_TIMEOUT = 20.0
|
|
44
|
+
_CHAT_TIMEOUT = httpx.Timeout(20.0, read=180.0)
|
|
45
|
+
_HEADERS = {
|
|
46
|
+
"HTTP-Referer": "https://github.com/Alshahriar-07/seedcode-cli",
|
|
47
|
+
"X-Title": "Seed Code",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Model-list modes (persisted in OpenRouter's own options; same API key).
|
|
52
|
+
MODE_FREE = "free"
|
|
53
|
+
MODE_PRO = "pro"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _entry_is_free(entry: dict[str, Any]) -> bool:
|
|
57
|
+
"""True when both prompt and completion pricing are exactly zero."""
|
|
58
|
+
pricing = entry.get("pricing") or {}
|
|
59
|
+
try:
|
|
60
|
+
return float(pricing.get("prompt", 1)) == 0.0 and float(
|
|
61
|
+
pricing.get("completion", 1)
|
|
62
|
+
) == 0.0
|
|
63
|
+
except (TypeError, ValueError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class OpenRouterProvider(Provider):
|
|
69
|
+
# Private per-provider client cache (key -> client); never shared.
|
|
70
|
+
_client: Any = field(init=False, default=None, repr=False)
|
|
71
|
+
_client_key: str = field(init=False, default="", repr=False)
|
|
72
|
+
|
|
73
|
+
def __post_init__(self) -> None:
|
|
74
|
+
self.id = "openrouter"
|
|
75
|
+
self.label = "OpenRouter"
|
|
76
|
+
self.base_url = _BASE_URL
|
|
77
|
+
self.requires_key = True
|
|
78
|
+
self.key_hint = "create a key at https://openrouter.ai/keys"
|
|
79
|
+
|
|
80
|
+
def validate_key(self, api_key: str) -> ValidationResult:
|
|
81
|
+
"""Validate with a real authenticated request — no heuristics."""
|
|
82
|
+
key = api_key.strip()
|
|
83
|
+
if not key:
|
|
84
|
+
return ValidationResult(False, "API key is empty.")
|
|
85
|
+
try:
|
|
86
|
+
response = httpx.get(
|
|
87
|
+
_VALIDATE_URL, headers={"Authorization": f"Bearer {key}"}, timeout=_TIMEOUT
|
|
88
|
+
)
|
|
89
|
+
except httpx.TimeoutException:
|
|
90
|
+
return ValidationResult(False, "Validation timed out. Check your connection.")
|
|
91
|
+
except httpx.HTTPError:
|
|
92
|
+
return ValidationResult(False, "Could not reach OpenRouter. Check your connection.")
|
|
93
|
+
if response.status_code == 200:
|
|
94
|
+
return ValidationResult(True, "API key verified.")
|
|
95
|
+
if response.status_code in (401, 403):
|
|
96
|
+
return ValidationResult(False, "API key was rejected by OpenRouter.")
|
|
97
|
+
return ValidationResult(
|
|
98
|
+
False, f"Unexpected response from OpenRouter (HTTP {response.status_code})."
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def mode(self, config: "AppConfig") -> str:
|
|
102
|
+
"""Current model-list mode: 'free' (default) or 'pro'."""
|
|
103
|
+
raw = (config.provider_options("openrouter").get("mode") or MODE_FREE).lower()
|
|
104
|
+
return raw if raw in (MODE_FREE, MODE_PRO) else MODE_FREE
|
|
105
|
+
|
|
106
|
+
def extra_settings(self, config: "AppConfig") -> dict[str, str]:
|
|
107
|
+
return {"mode": f"{self.mode(config)} (free = zero-cost models, pro = paid models)"}
|
|
108
|
+
|
|
109
|
+
def set_extra_setting(
|
|
110
|
+
self, config: "AppConfig", name: str, value: str
|
|
111
|
+
) -> tuple[bool, str]:
|
|
112
|
+
if name != "mode":
|
|
113
|
+
return False, f"{self.label} has no setting '{name}'."
|
|
114
|
+
mode = value.strip().lower()
|
|
115
|
+
if mode not in (MODE_FREE, MODE_PRO):
|
|
116
|
+
return False, "mode expects 'free' or 'pro'."
|
|
117
|
+
config.provider_options("openrouter")["mode"] = mode
|
|
118
|
+
return True, f"OpenRouter mode set to {mode} — /model now lists {mode} models."
|
|
119
|
+
|
|
120
|
+
def list_models(self, config: "AppConfig") -> list[ModelInfo]:
|
|
121
|
+
"""The live catalogue for the CURRENT mode (free or pro models)."""
|
|
122
|
+
try:
|
|
123
|
+
response = httpx.get(_MODELS_URL, timeout=_TIMEOUT)
|
|
124
|
+
response.raise_for_status()
|
|
125
|
+
data = response.json().get("data", [])
|
|
126
|
+
except httpx.TimeoutException as exc:
|
|
127
|
+
raise ProviderError(
|
|
128
|
+
"Timed out fetching the OpenRouter model list.", transient=True
|
|
129
|
+
) from exc
|
|
130
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
131
|
+
raise ProviderError(
|
|
132
|
+
"Could not fetch the OpenRouter model list. Check your connection.",
|
|
133
|
+
transient=True,
|
|
134
|
+
) from exc
|
|
135
|
+
|
|
136
|
+
want_free = self.mode(config) == MODE_FREE
|
|
137
|
+
models = []
|
|
138
|
+
for entry in data:
|
|
139
|
+
if not entry.get("id"):
|
|
140
|
+
continue
|
|
141
|
+
free = _entry_is_free(entry)
|
|
142
|
+
if free is not want_free:
|
|
143
|
+
continue
|
|
144
|
+
models.append(
|
|
145
|
+
ModelInfo(
|
|
146
|
+
id=entry["id"],
|
|
147
|
+
label=entry.get("name") or entry["id"],
|
|
148
|
+
detail=f"{'free' if free else 'paid'} · {entry.get('context_length') or '?'} ctx",
|
|
149
|
+
is_free=free,
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
models.sort(key=lambda m: m.id)
|
|
153
|
+
if not models:
|
|
154
|
+
raise ProviderError(
|
|
155
|
+
f"OpenRouter has no {self.mode(config)} models right now. "
|
|
156
|
+
"Switch mode in /settings (mode free|pro)."
|
|
157
|
+
)
|
|
158
|
+
return models
|
|
159
|
+
|
|
160
|
+
def supports_images(self, config: "AppConfig") -> bool:
|
|
161
|
+
"""OpenRouter routes to many vision models; let it try image parts."""
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
def stream_chat(self, config: "AppConfig", messages: list["Message"]) -> Iterator[str]:
|
|
165
|
+
# Heavy SDK import deferred to first use; client cached per key.
|
|
166
|
+
from openai import (
|
|
167
|
+
APIConnectionError,
|
|
168
|
+
APIError,
|
|
169
|
+
APITimeoutError,
|
|
170
|
+
AuthenticationError,
|
|
171
|
+
RateLimitError,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
client = self._get_client(config)
|
|
175
|
+
max_tokens = config.effective_max_tokens()
|
|
176
|
+
_log.debug("chat request: model=%s max_tokens=%d", config.model, max_tokens)
|
|
177
|
+
try:
|
|
178
|
+
stream = client.chat.completions.create(
|
|
179
|
+
model=config.model,
|
|
180
|
+
messages=[_to_api_multimodal(m) for m in messages], # type: ignore[arg-type]
|
|
181
|
+
max_tokens=max_tokens,
|
|
182
|
+
stream=True,
|
|
183
|
+
)
|
|
184
|
+
yield from iter_stream(stream)
|
|
185
|
+
except AuthenticationError as exc:
|
|
186
|
+
raise ProviderError(
|
|
187
|
+
"Authentication failed. Your OpenRouter key may be invalid — run /apikey."
|
|
188
|
+
) from exc
|
|
189
|
+
except RateLimitError as exc:
|
|
190
|
+
raise ProviderError(
|
|
191
|
+
"Rate limited by OpenRouter. Please wait and try again.", transient=True
|
|
192
|
+
) from exc
|
|
193
|
+
except APITimeoutError as exc:
|
|
194
|
+
raise ProviderError(
|
|
195
|
+
"The OpenRouter request timed out. Please try again.", transient=True
|
|
196
|
+
) from exc
|
|
197
|
+
except APIConnectionError as exc:
|
|
198
|
+
raise ProviderError(
|
|
199
|
+
"Network error reaching OpenRouter. Check your connection.", transient=True
|
|
200
|
+
) from exc
|
|
201
|
+
except APIError as exc:
|
|
202
|
+
raise _friendly_api_error(exc, config.model) from exc
|
|
203
|
+
|
|
204
|
+
# --- native tool calling -------------------------------------------------
|
|
205
|
+
def supports_tools(self, config: "AppConfig") -> bool:
|
|
206
|
+
"""OpenRouter forwards OpenAI-style tools to capable models."""
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def stream_chat_with_tools(
|
|
210
|
+
self, config: "AppConfig", messages: list["Message"], tools: list[ToolSpec]
|
|
211
|
+
) -> Iterator[StreamEvent]:
|
|
212
|
+
from openai import (
|
|
213
|
+
APIConnectionError,
|
|
214
|
+
APIError,
|
|
215
|
+
APITimeoutError,
|
|
216
|
+
AuthenticationError,
|
|
217
|
+
RateLimitError,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
client = self._get_client(config)
|
|
221
|
+
max_tokens = config.effective_max_tokens()
|
|
222
|
+
_log.debug(
|
|
223
|
+
"tool chat request: model=%s max_tokens=%d tools=%d",
|
|
224
|
+
config.model, max_tokens, len(tools),
|
|
225
|
+
)
|
|
226
|
+
try:
|
|
227
|
+
stream = client.chat.completions.create(
|
|
228
|
+
model=config.model,
|
|
229
|
+
messages=[_to_api_tools(m) for m in messages], # type: ignore[arg-type]
|
|
230
|
+
max_tokens=max_tokens,
|
|
231
|
+
tools=[
|
|
232
|
+
{
|
|
233
|
+
"type": "function",
|
|
234
|
+
"function": {
|
|
235
|
+
"name": t.name,
|
|
236
|
+
"description": t.description,
|
|
237
|
+
"parameters": t.parameters,
|
|
238
|
+
},
|
|
239
|
+
}
|
|
240
|
+
for t in tools
|
|
241
|
+
],
|
|
242
|
+
stream=True,
|
|
243
|
+
)
|
|
244
|
+
yield from _iter_tool_stream(stream)
|
|
245
|
+
except AuthenticationError as exc:
|
|
246
|
+
raise ProviderError(
|
|
247
|
+
"Authentication failed. Your OpenRouter key may be invalid — run /apikey."
|
|
248
|
+
) from exc
|
|
249
|
+
except RateLimitError as exc:
|
|
250
|
+
raise ProviderError(
|
|
251
|
+
"Rate limited by OpenRouter. Please wait and try again.", transient=True
|
|
252
|
+
) from exc
|
|
253
|
+
except APITimeoutError as exc:
|
|
254
|
+
raise ProviderError(
|
|
255
|
+
"The OpenRouter request timed out. Please try again.", transient=True
|
|
256
|
+
) from exc
|
|
257
|
+
except APIConnectionError as exc:
|
|
258
|
+
raise ProviderError(
|
|
259
|
+
"Network error reaching OpenRouter. Check your connection.", transient=True
|
|
260
|
+
) from exc
|
|
261
|
+
except APIError as exc:
|
|
262
|
+
raise _friendly_api_error(exc, config.model) from exc
|
|
263
|
+
|
|
264
|
+
def _get_client(self, config: "AppConfig") -> Any:
|
|
265
|
+
"""Cached OpenAI client for the current key (SDK import deferred)."""
|
|
266
|
+
from openai import OpenAI
|
|
267
|
+
|
|
268
|
+
api_key = config.get_api_key("openrouter")
|
|
269
|
+
if self._client is None or self._client_key != api_key:
|
|
270
|
+
self._client = OpenAI(
|
|
271
|
+
api_key=api_key,
|
|
272
|
+
base_url=_BASE_URL,
|
|
273
|
+
default_headers=_HEADERS,
|
|
274
|
+
timeout=_CHAT_TIMEOUT,
|
|
275
|
+
max_retries=0, # the engine owns retry policy
|
|
276
|
+
)
|
|
277
|
+
self._client_key = api_key
|
|
278
|
+
return self._client
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _to_api_multimodal(message: "Message") -> dict[str, Any]:
|
|
282
|
+
"""Message shape for the API: plain text, or content-parts with images.
|
|
283
|
+
|
|
284
|
+
Messages without attachments keep the simple string form (maximum model
|
|
285
|
+
compatibility); desktop screenshots become OpenAI-style image_url parts.
|
|
286
|
+
"""
|
|
287
|
+
if not message.images:
|
|
288
|
+
return message.to_api()
|
|
289
|
+
parts: list[dict[str, Any]] = [{"type": "text", "text": message.content}]
|
|
290
|
+
parts += [
|
|
291
|
+
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{img}"}}
|
|
292
|
+
for img in message.images
|
|
293
|
+
]
|
|
294
|
+
return {"role": message.role, "content": parts}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _to_api_tools(message: "Message") -> dict[str, Any]:
|
|
298
|
+
"""OpenAI wire shape for a message in a tool-calling conversation."""
|
|
299
|
+
if message.role == "tool":
|
|
300
|
+
return {
|
|
301
|
+
"role": "tool",
|
|
302
|
+
"tool_call_id": message.tool_call_id,
|
|
303
|
+
"content": message.content,
|
|
304
|
+
}
|
|
305
|
+
if message.role == "assistant" and message.tool_calls:
|
|
306
|
+
return {
|
|
307
|
+
"role": "assistant",
|
|
308
|
+
"content": message.content or None,
|
|
309
|
+
"tool_calls": [
|
|
310
|
+
{
|
|
311
|
+
"id": call.id,
|
|
312
|
+
"type": "function",
|
|
313
|
+
"function": {
|
|
314
|
+
"name": call.name,
|
|
315
|
+
"arguments": json.dumps(call.arguments, ensure_ascii=False),
|
|
316
|
+
},
|
|
317
|
+
}
|
|
318
|
+
for call in message.tool_calls
|
|
319
|
+
],
|
|
320
|
+
}
|
|
321
|
+
return _to_api_multimodal(message)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _iter_tool_stream(stream: Any) -> Iterator[StreamEvent]:
|
|
325
|
+
"""Assemble OpenAI streaming chunks into text deltas and complete calls.
|
|
326
|
+
|
|
327
|
+
``delta.tool_calls`` fragments are keyed by index: the id and function
|
|
328
|
+
name arrive on the first fragment, argument JSON streams in pieces. Each
|
|
329
|
+
call is emitted once, after the stream ends, when its JSON is whole.
|
|
330
|
+
"""
|
|
331
|
+
pending: dict[int, dict[str, str]] = {} # index -> {id, name, args}
|
|
332
|
+
for chunk in stream:
|
|
333
|
+
choices = getattr(chunk, "choices", None) or []
|
|
334
|
+
if not choices:
|
|
335
|
+
continue
|
|
336
|
+
delta = choices[0].delta
|
|
337
|
+
if getattr(delta, "content", None):
|
|
338
|
+
yield TextDelta(delta.content)
|
|
339
|
+
for fragment in getattr(delta, "tool_calls", None) or []:
|
|
340
|
+
slot = pending.setdefault(
|
|
341
|
+
fragment.index, {"id": "", "name": "", "args": ""}
|
|
342
|
+
)
|
|
343
|
+
if getattr(fragment, "id", None):
|
|
344
|
+
slot["id"] = fragment.id
|
|
345
|
+
fn = getattr(fragment, "function", None)
|
|
346
|
+
if fn is not None:
|
|
347
|
+
if getattr(fn, "name", None):
|
|
348
|
+
slot["name"] = fn.name
|
|
349
|
+
if getattr(fn, "arguments", None):
|
|
350
|
+
slot["args"] += fn.arguments
|
|
351
|
+
for index in sorted(pending):
|
|
352
|
+
slot = pending[index]
|
|
353
|
+
raw = slot["args"].strip() or "{}"
|
|
354
|
+
try:
|
|
355
|
+
arguments = json.loads(raw)
|
|
356
|
+
if not isinstance(arguments, dict):
|
|
357
|
+
raise ValueError("arguments must be a JSON object")
|
|
358
|
+
yield ToolCallEvent(id=slot["id"], name=slot["name"], arguments=arguments)
|
|
359
|
+
except ValueError as exc:
|
|
360
|
+
yield ToolCallEvent(
|
|
361
|
+
id=slot["id"], name=slot["name"], arguments={},
|
|
362
|
+
error=f"Tool call arguments were not valid JSON: {exc}",
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _friendly_api_error(exc: Any, model: str) -> ProviderError:
|
|
367
|
+
"""Translate OpenRouter API errors into actionable user messages."""
|
|
368
|
+
status = getattr(exc, "status_code", None)
|
|
369
|
+
detail = getattr(exc, "message", str(exc)) or "Unknown API error."
|
|
370
|
+
if status == 402:
|
|
371
|
+
return ProviderError(
|
|
372
|
+
"OpenRouter rejected the request for lack of credits (HTTP 402). "
|
|
373
|
+
"Pick a free model with /model (filter: free), or add credits."
|
|
374
|
+
)
|
|
375
|
+
if status == 403:
|
|
376
|
+
return ProviderError(
|
|
377
|
+
"OpenRouter refused the request (HTTP 403). Your key may lack access "
|
|
378
|
+
"to this model — pick another with /model."
|
|
379
|
+
)
|
|
380
|
+
if status == 404:
|
|
381
|
+
return ProviderError(
|
|
382
|
+
f"Model '{model}' was not found on OpenRouter. Pick another with /model."
|
|
383
|
+
)
|
|
384
|
+
if status == 408:
|
|
385
|
+
return ProviderError(
|
|
386
|
+
"OpenRouter timed out handling the request. Please try again.", transient=True
|
|
387
|
+
)
|
|
388
|
+
if status is not None and status >= 500:
|
|
389
|
+
return ProviderError(
|
|
390
|
+
f"OpenRouter had a server error (HTTP {status}). Please try again.",
|
|
391
|
+
transient=True,
|
|
392
|
+
)
|
|
393
|
+
return ProviderError(f"OpenRouter error: {detail}")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Low-level response streaming.
|
|
2
|
+
|
|
3
|
+
Iterates an OpenAI-compatible streaming completion and yields text pieces. Error
|
|
4
|
+
translation is handled one layer up in :mod:`seedcode.core.chat` so this stays a
|
|
5
|
+
pure, reusable generator.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def iter_stream(stream) -> Iterator[str]:
|
|
14
|
+
"""Yield non-empty content deltas from a streaming chat completion."""
|
|
15
|
+
for event in stream:
|
|
16
|
+
if not event.choices:
|
|
17
|
+
continue
|
|
18
|
+
delta = event.choices[0].delta
|
|
19
|
+
piece = getattr(delta, "content", None)
|
|
20
|
+
if piece:
|
|
21
|
+
yield piece
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Conversation memory: per-session storage and collection management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .manager import delete_session, list_sessions, load_session
|
|
6
|
+
from .storage import HistoryStore
|
|
7
|
+
|
|
8
|
+
__all__ = ["HistoryStore", "delete_session", "list_sessions", "load_session"]
|