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,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"]