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
seedcode/core/models.py
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"""Pydantic data models for Seed Code.
|
|
2
|
+
|
|
3
|
+
All persisted and in-memory structured data flows through these models so that
|
|
4
|
+
validation happens in one place and the rest of the app can rely on typed data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, model_validator
|
|
13
|
+
|
|
14
|
+
Role = Literal["system", "user", "assistant", "tool"]
|
|
15
|
+
|
|
16
|
+
# Safe completion budget sent with chat requests when the user has not
|
|
17
|
+
# overridden it. Free-tier accounts are rejected (HTTP 402) when the requested
|
|
18
|
+
# budget exceeds what their credits could cover, so the default stays small.
|
|
19
|
+
DEFAULT_MAX_TOKENS = 1024
|
|
20
|
+
|
|
21
|
+
# Agent turns write code across files, so they get a higher ceiling than the
|
|
22
|
+
# plain-chat clamp (providers that cannot afford it still cap via the
|
|
23
|
+
# ``:free`` rule in :meth:`AppConfig.effective_max_tokens`).
|
|
24
|
+
AGENT_MAX_TOKENS = 8192
|
|
25
|
+
|
|
26
|
+
# The five supported backends (kept as a Literal so bad config fails loudly).
|
|
27
|
+
ProviderId = Literal[
|
|
28
|
+
"openrouter", "freemodel_claude", "freemodel_codex", "aerolink", "ollama"
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ToolCallRecord(BaseModel):
|
|
33
|
+
"""One native tool invocation recorded on an assistant message.
|
|
34
|
+
|
|
35
|
+
``id`` is the provider-issued call id ("" for text-protocol calls, which
|
|
36
|
+
have none); providers convert records to their own wire format when
|
|
37
|
+
serialising history.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
id: str = ""
|
|
41
|
+
name: str
|
|
42
|
+
arguments: dict[str, Any] = Field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Message(BaseModel):
|
|
46
|
+
"""A single chat message in a conversation."""
|
|
47
|
+
|
|
48
|
+
role: Role
|
|
49
|
+
content: str
|
|
50
|
+
timestamp: float = Field(default_factory=time.time)
|
|
51
|
+
# Base64 PNG attachments (desktop screenshots). Only providers that
|
|
52
|
+
# support vision read these; ``to_api`` stays text-only so plain
|
|
53
|
+
# backends are never sent a shape they cannot handle.
|
|
54
|
+
images: list[str] = Field(default_factory=list)
|
|
55
|
+
# Native tool calling. ``tool_calls`` is set on assistant messages that
|
|
56
|
+
# invoked tools; ``tool_call_id``/``tool_name`` are set on role=="tool"
|
|
57
|
+
# result messages. All default so old histories load unchanged, and
|
|
58
|
+
# providers that never learned these roles simply never see them (the
|
|
59
|
+
# agent downgrades native-era history before a text-only request).
|
|
60
|
+
tool_calls: list[ToolCallRecord] = Field(default_factory=list)
|
|
61
|
+
tool_call_id: str = ""
|
|
62
|
+
tool_name: str = ""
|
|
63
|
+
|
|
64
|
+
def to_api(self) -> dict[str, str]:
|
|
65
|
+
"""Return the minimal shape chat-completions style APIs expect."""
|
|
66
|
+
return {"role": self.role, "content": self.content}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class ProviderConfig(BaseModel):
|
|
70
|
+
"""Per-provider settings: each backend keeps its own key and model.
|
|
71
|
+
|
|
72
|
+
Switching providers never touches another provider's entry, so keys and
|
|
73
|
+
model choices are always remembered. Ollama simply leaves ``api_key``
|
|
74
|
+
empty (it does not use one). ``options`` holds provider-specific extras
|
|
75
|
+
(e.g. OpenRouter's free/pro mode, FreeModel's claude/codex backend) so
|
|
76
|
+
new providers can add settings without schema changes.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
api_key: str = ""
|
|
80
|
+
model: str = ""
|
|
81
|
+
options: dict[str, str] = Field(default_factory=dict)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_ALL_PROVIDERS = (
|
|
85
|
+
"openrouter", "freemodel_claude", "freemodel_codex", "aerolink", "ollama"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default_providers() -> dict[str, ProviderConfig]:
|
|
90
|
+
return {pid: ProviderConfig() for pid in _ALL_PROVIDERS}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class AppConfig(BaseModel):
|
|
94
|
+
"""Persisted application configuration.
|
|
95
|
+
|
|
96
|
+
Stored shape (config.json)::
|
|
97
|
+
|
|
98
|
+
active_provider: "openrouter" | "freemodel_claude" | "freemodel_codex"
|
|
99
|
+
| "aerolink" | "ollama"
|
|
100
|
+
providers:
|
|
101
|
+
openrouter: {api_key, model}
|
|
102
|
+
freemodel_claude: {api_key, model}
|
|
103
|
+
freemodel_codex: {api_key, model}
|
|
104
|
+
aerolink: {api_key, model}
|
|
105
|
+
ollama: {api_key(unused), model}
|
|
106
|
+
|
|
107
|
+
Models are never hardcoded — each provider's ``model`` starts empty and
|
|
108
|
+
the user selects one from the live catalogue. Older config formats
|
|
109
|
+
(v0.x flat ``api_key``, v1.x ``api_keys``/``models`` maps, v2.x single
|
|
110
|
+
``freemodel`` entry with claude/codex sub-backends) migrate
|
|
111
|
+
automatically on load.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
active_provider: ProviderId = "freemodel_claude"
|
|
115
|
+
providers: dict[str, ProviderConfig] = Field(default_factory=_default_providers)
|
|
116
|
+
ollama_host: str = "http://localhost:11434"
|
|
117
|
+
theme: str = "seed"
|
|
118
|
+
username: str = "You"
|
|
119
|
+
stream: bool = True
|
|
120
|
+
# Completion-token budget for chat requests. Users may override in
|
|
121
|
+
# config.json; the value is clamped before every request.
|
|
122
|
+
max_tokens: int = DEFAULT_MAX_TOKENS
|
|
123
|
+
# Agent mode: when on, the model may act on the project through the tool
|
|
124
|
+
# engine. permission_mode bounds what it may touch (see seedcode.tools).
|
|
125
|
+
agent_mode: bool = False
|
|
126
|
+
# Single hierarchical permission level (see seedcode.tools.permissions):
|
|
127
|
+
# read_only < workspace < desktop < full_system. Desktop automation is a
|
|
128
|
+
# capability of the ``desktop``/``full_system`` levels — there is no
|
|
129
|
+
# separate desktop toggle. ``full_access`` is accepted as a legacy alias
|
|
130
|
+
# for ``full_system`` and migrated on load.
|
|
131
|
+
permission_mode: Literal[
|
|
132
|
+
"read_only", "workspace", "desktop", "full_system"
|
|
133
|
+
] = "workspace"
|
|
134
|
+
|
|
135
|
+
@model_validator(mode="before")
|
|
136
|
+
@classmethod
|
|
137
|
+
def _migrate_legacy(cls, data: Any) -> Any:
|
|
138
|
+
"""Accept pre-3.x config files and keyword shorthand.
|
|
139
|
+
|
|
140
|
+
Handles: v0.x (flat ``api_key`` string, display-name provider),
|
|
141
|
+
v1.x (``provider``/``model`` fields plus ``api_keys``/``models``
|
|
142
|
+
maps), v2.x (single ``freemodel`` provider with claude/codex
|
|
143
|
+
sub-backends), and constructor convenience (``AppConfig(model=...)``).
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(data, dict):
|
|
146
|
+
return data
|
|
147
|
+
data = dict(data) # never mutate the caller's dict
|
|
148
|
+
|
|
149
|
+
def norm(pid: str) -> str:
|
|
150
|
+
"""Provider id normalisation (OpenRouter is a first-class backend)."""
|
|
151
|
+
return pid.strip().lower()
|
|
152
|
+
|
|
153
|
+
# Normalise nested provider entries to plain dicts we can merge into.
|
|
154
|
+
providers: dict[str, dict] = {}
|
|
155
|
+
for pid, entry in (data.get("providers") or {}).items():
|
|
156
|
+
if isinstance(entry, ProviderConfig):
|
|
157
|
+
providers[norm(pid)] = entry.model_dump()
|
|
158
|
+
elif isinstance(entry, dict):
|
|
159
|
+
providers[norm(pid)] = dict(entry)
|
|
160
|
+
|
|
161
|
+
# Active provider: new field, or legacy "provider" (any casing).
|
|
162
|
+
# Sanitised AFTER the legacy-freemodel split below, which may map it.
|
|
163
|
+
raw_active = data.pop("provider", None) or data.get("active_provider")
|
|
164
|
+
if isinstance(raw_active, str):
|
|
165
|
+
data["active_provider"] = norm(raw_active)
|
|
166
|
+
|
|
167
|
+
# v1.x per-provider maps.
|
|
168
|
+
for pid, key in (data.pop("api_keys", None) or {}).items():
|
|
169
|
+
providers.setdefault(norm(pid), {})["api_key"] = key
|
|
170
|
+
for pid, model in (data.pop("models", None) or {}).items():
|
|
171
|
+
providers.setdefault(norm(pid), {}).setdefault("model", model)
|
|
172
|
+
|
|
173
|
+
# v1.x top-level model belongs to the active provider.
|
|
174
|
+
top_model = data.pop("model", None)
|
|
175
|
+
if top_model:
|
|
176
|
+
active = data.get("active_provider", "freemodel_claude")
|
|
177
|
+
providers.setdefault(active, {})["model"] = top_model
|
|
178
|
+
|
|
179
|
+
# v0.x: single "api_key" string belonged to OpenRouter.
|
|
180
|
+
legacy_key = data.pop("api_key", None)
|
|
181
|
+
if legacy_key:
|
|
182
|
+
providers.setdefault("openrouter", {}).setdefault("api_key", legacy_key)
|
|
183
|
+
|
|
184
|
+
# v2.x: one "freemodel" provider with claude/codex sub-backends
|
|
185
|
+
# splits into the two first-class providers. The shared key goes to
|
|
186
|
+
# both; each backend's stashed model goes to its own entry.
|
|
187
|
+
legacy_fm = providers.pop("freemodel", None)
|
|
188
|
+
if legacy_fm is not None:
|
|
189
|
+
options = dict(legacy_fm.get("options") or {})
|
|
190
|
+
backend = (options.pop("backend", "") or "codex").strip().lower()
|
|
191
|
+
key = legacy_fm.get("api_key", "")
|
|
192
|
+
models_by_backend = {
|
|
193
|
+
"claude": options.pop("model_claude", ""),
|
|
194
|
+
"codex": options.pop("model_codex", ""),
|
|
195
|
+
}
|
|
196
|
+
if legacy_fm.get("model"):
|
|
197
|
+
models_by_backend[backend if backend in models_by_backend else "codex"] = (
|
|
198
|
+
legacy_fm["model"]
|
|
199
|
+
)
|
|
200
|
+
for suffix in ("claude", "codex"):
|
|
201
|
+
entry = providers.setdefault(f"freemodel_{suffix}", {})
|
|
202
|
+
entry.setdefault("api_key", key)
|
|
203
|
+
entry.setdefault("model", models_by_backend[suffix])
|
|
204
|
+
if data.get("active_provider") == "freemodel":
|
|
205
|
+
data["active_provider"] = (
|
|
206
|
+
"freemodel_claude" if backend == "claude" else "freemodel_codex"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Anything still unknown falls back to the default provider.
|
|
210
|
+
if "active_provider" in data and data["active_provider"] not in _ALL_PROVIDERS:
|
|
211
|
+
data["active_provider"] = "freemodel_claude"
|
|
212
|
+
|
|
213
|
+
# vNext: the standalone ``desktop_mode`` flag folded into the unified
|
|
214
|
+
# permission level. A legacy config with desktop_mode=true elevates a
|
|
215
|
+
# workspace level to ``desktop``; ``full_access`` renames to
|
|
216
|
+
# ``full_system``. ``desktop_mode`` is dropped from the model.
|
|
217
|
+
legacy_desktop = data.pop("desktop_mode", None)
|
|
218
|
+
raw_perm = str(data.get("permission_mode", "") or "").strip().lower()
|
|
219
|
+
if raw_perm in ("full_access", "fullaccess", "full", "system"):
|
|
220
|
+
data["permission_mode"] = "full_system"
|
|
221
|
+
raw_perm = "full_system"
|
|
222
|
+
if legacy_desktop and raw_perm in ("", "read_only", "workspace"):
|
|
223
|
+
# Desktop was on: grant at least the desktop level.
|
|
224
|
+
data["permission_mode"] = "desktop"
|
|
225
|
+
|
|
226
|
+
if providers or "providers" in data:
|
|
227
|
+
data["providers"] = providers
|
|
228
|
+
return data
|
|
229
|
+
|
|
230
|
+
@model_validator(mode="after")
|
|
231
|
+
def _ensure_all_providers(self) -> "AppConfig":
|
|
232
|
+
"""Every supported provider always has an entry."""
|
|
233
|
+
for pid in _ALL_PROVIDERS:
|
|
234
|
+
if pid not in self.providers:
|
|
235
|
+
self.providers[pid] = ProviderConfig()
|
|
236
|
+
return self
|
|
237
|
+
|
|
238
|
+
# --- desktop capability (derived from the permission level) --------------
|
|
239
|
+
@property
|
|
240
|
+
def desktop_mode(self) -> bool:
|
|
241
|
+
"""Whether the current permission level allows desktop automation.
|
|
242
|
+
|
|
243
|
+
Compatibility shim: desktop is no longer a separate flag but a
|
|
244
|
+
capability of the ``desktop``/``full_system`` levels. Reads derive from
|
|
245
|
+
``permission_mode``; assigning True/False raises or lowers the level so
|
|
246
|
+
older call sites keep working.
|
|
247
|
+
"""
|
|
248
|
+
from ..tools.permissions import PermissionLevel
|
|
249
|
+
|
|
250
|
+
return PermissionLevel.parse(self.permission_mode).allows_desktop
|
|
251
|
+
|
|
252
|
+
@desktop_mode.setter
|
|
253
|
+
def desktop_mode(self, value: bool) -> None:
|
|
254
|
+
from ..tools.permissions import PermissionLevel
|
|
255
|
+
|
|
256
|
+
current = PermissionLevel.parse(self.permission_mode)
|
|
257
|
+
if value and not current.allows_desktop:
|
|
258
|
+
self.permission_mode = "desktop" # type: ignore[assignment]
|
|
259
|
+
elif not value and current.allows_desktop:
|
|
260
|
+
# Drop desktop capability but keep the ability to edit the project.
|
|
261
|
+
self.permission_mode = "workspace" # type: ignore[assignment]
|
|
262
|
+
|
|
263
|
+
# --- active provider/model (compatibility + convenience) ----------------
|
|
264
|
+
@property
|
|
265
|
+
def provider(self) -> str:
|
|
266
|
+
"""Id of the active provider."""
|
|
267
|
+
return self.active_provider
|
|
268
|
+
|
|
269
|
+
@provider.setter
|
|
270
|
+
def provider(self, value: str) -> None:
|
|
271
|
+
self.active_provider = value # type: ignore[assignment]
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def model(self) -> str:
|
|
275
|
+
"""Model selected for the ACTIVE provider ('' if none yet)."""
|
|
276
|
+
return self.providers[self.active_provider].model
|
|
277
|
+
|
|
278
|
+
@model.setter
|
|
279
|
+
def model(self, value: str) -> None:
|
|
280
|
+
self.providers[self.active_provider].model = value
|
|
281
|
+
|
|
282
|
+
# --- key management -----------------------------------------------------
|
|
283
|
+
def get_api_key(self, provider_id: str | None = None) -> str:
|
|
284
|
+
"""Key for ``provider_id`` (default: the active provider)."""
|
|
285
|
+
pid = (provider_id or self.active_provider).lower()
|
|
286
|
+
entry = self.providers.get(pid)
|
|
287
|
+
return entry.api_key if entry else ""
|
|
288
|
+
|
|
289
|
+
def set_api_key(self, provider_id: str, key: str) -> None:
|
|
290
|
+
pid = provider_id.lower()
|
|
291
|
+
if pid not in self.providers:
|
|
292
|
+
self.providers[pid] = ProviderConfig()
|
|
293
|
+
self.providers[pid].api_key = key
|
|
294
|
+
|
|
295
|
+
def provider_options(self, provider_id: str) -> dict[str, str]:
|
|
296
|
+
"""Mutable provider-specific options dict for ``provider_id``."""
|
|
297
|
+
pid = provider_id.lower()
|
|
298
|
+
if pid not in self.providers:
|
|
299
|
+
self.providers[pid] = ProviderConfig()
|
|
300
|
+
return self.providers[pid].options
|
|
301
|
+
|
|
302
|
+
def is_configured(self) -> bool:
|
|
303
|
+
"""True when the active provider is usable and a model is chosen.
|
|
304
|
+
|
|
305
|
+
Ollama needs no key; the other providers need one.
|
|
306
|
+
"""
|
|
307
|
+
if not self.model:
|
|
308
|
+
return False
|
|
309
|
+
if self.active_provider == "ollama":
|
|
310
|
+
return True
|
|
311
|
+
return bool(self.get_api_key().strip())
|
|
312
|
+
|
|
313
|
+
def remember_model(self) -> None:
|
|
314
|
+
"""Compatibility no-op: models are stored per provider already."""
|
|
315
|
+
|
|
316
|
+
def recall_model(self) -> str:
|
|
317
|
+
"""Model saved for the active provider ('' if none yet)."""
|
|
318
|
+
return self.model
|
|
319
|
+
|
|
320
|
+
def effective_max_tokens(self) -> int:
|
|
321
|
+
"""Completion budget to send with a request, clamped to a safe range.
|
|
322
|
+
|
|
323
|
+
Plain chat clamps to [1, 4096] so an oversized config value can never
|
|
324
|
+
trigger the gateway's 402 "more credits or fewer max_tokens"
|
|
325
|
+
rejection. Agent mode raises the ceiling to ``AGENT_MAX_TOKENS``
|
|
326
|
+
(multi-file code generation needs room). Free models (``*:free``) are
|
|
327
|
+
further capped at ``DEFAULT_MAX_TOKENS`` since their credit ceiling
|
|
328
|
+
is lowest.
|
|
329
|
+
"""
|
|
330
|
+
ceiling = AGENT_MAX_TOKENS if self.agent_mode else 4096
|
|
331
|
+
max_tokens = max(1, min(self.max_tokens, ceiling))
|
|
332
|
+
# Agent turns generate whole files; the small chat default would
|
|
333
|
+
# truncate them. A user who explicitly set a different budget keeps it.
|
|
334
|
+
if self.agent_mode and self.max_tokens == DEFAULT_MAX_TOKENS:
|
|
335
|
+
max_tokens = AGENT_MAX_TOKENS
|
|
336
|
+
if self.model.endswith(":free"):
|
|
337
|
+
max_tokens = min(max_tokens, DEFAULT_MAX_TOKENS)
|
|
338
|
+
return max_tokens
|
|
339
|
+
|
|
340
|
+
def masked_key(self, provider_id: str | None = None) -> str:
|
|
341
|
+
"""Return the API key with the middle obscured for safe display."""
|
|
342
|
+
key = self.get_api_key(provider_id).strip()
|
|
343
|
+
if not key:
|
|
344
|
+
return "(not set)"
|
|
345
|
+
if len(key) <= 12:
|
|
346
|
+
return "*" * len(key)
|
|
347
|
+
return f"{key[:8]}...{key[-4:]}"
|
seedcode/core/project.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Project awareness: detect what kind of project the workspace holds.
|
|
2
|
+
|
|
3
|
+
Runs once at agent-engine construction and feeds a short summary into the
|
|
4
|
+
system prompt, so the model starts oriented (project type, git branch,
|
|
5
|
+
top-level layout) without spending tool calls on discovery. Detection is
|
|
6
|
+
marker-file based and touches only the top level — no recursive walk, no
|
|
7
|
+
subprocesses — so construction stays instant even in huge repositories.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
# Marker file/dir -> human-readable project kind. Checked in order; every
|
|
16
|
+
# match is reported (a repo can be several things at once).
|
|
17
|
+
_MARKERS: tuple[tuple[str, str], ...] = (
|
|
18
|
+
("pyproject.toml", "Python (pyproject.toml)"),
|
|
19
|
+
("setup.py", "Python (setup.py)"),
|
|
20
|
+
("package.json", "Node.js (package.json)"),
|
|
21
|
+
("Cargo.toml", "Rust (Cargo.toml)"),
|
|
22
|
+
("go.mod", "Go (go.mod)"),
|
|
23
|
+
("pom.xml", "Java (Maven)"),
|
|
24
|
+
("build.gradle", "Java/Kotlin (Gradle)"),
|
|
25
|
+
("build.gradle.kts", "Kotlin (Gradle)"),
|
|
26
|
+
("CMakeLists.txt", "C/C++ (CMake)"),
|
|
27
|
+
("Gemfile", "Ruby (Gemfile)"),
|
|
28
|
+
("composer.json", "PHP (Composer)"),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Top-level entries that add noise, not orientation.
|
|
32
|
+
_LISTING_SKIP = {
|
|
33
|
+
".git", "__pycache__", ".pytest_cache", "node_modules", ".venv", "venv",
|
|
34
|
+
"dist", "build", ".mypy_cache", ".ruff_cache", ".idea", ".vscode",
|
|
35
|
+
}
|
|
36
|
+
_MAX_LISTING = 40
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(slots=True)
|
|
40
|
+
class ProjectInfo:
|
|
41
|
+
"""What was detected about the workspace."""
|
|
42
|
+
|
|
43
|
+
kinds: list[str]
|
|
44
|
+
summary: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _git_branch(workspace: Path) -> str:
|
|
48
|
+
"""Current branch read straight from .git/HEAD (no subprocess)."""
|
|
49
|
+
head = workspace / ".git" / "HEAD"
|
|
50
|
+
try:
|
|
51
|
+
content = head.read_text(encoding="utf-8", errors="replace").strip()
|
|
52
|
+
except OSError:
|
|
53
|
+
return ""
|
|
54
|
+
if content.startswith("ref: refs/heads/"):
|
|
55
|
+
return content[len("ref: refs/heads/"):]
|
|
56
|
+
return content[:12] if content else "" # detached HEAD: short hash
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def detect_project(workspace: Path) -> ProjectInfo:
|
|
60
|
+
"""Detect project kinds and build the system-prompt summary block."""
|
|
61
|
+
kinds: list[str] = []
|
|
62
|
+
|
|
63
|
+
if (workspace / ".git").is_dir():
|
|
64
|
+
branch = _git_branch(workspace)
|
|
65
|
+
kinds.append(f"Git repository (branch: {branch})" if branch else "Git repository")
|
|
66
|
+
|
|
67
|
+
for marker, kind in _MARKERS:
|
|
68
|
+
if (workspace / marker).is_file():
|
|
69
|
+
kinds.append(kind)
|
|
70
|
+
|
|
71
|
+
if any(workspace.glob("*.csproj")):
|
|
72
|
+
kinds.append("C# (.csproj)")
|
|
73
|
+
|
|
74
|
+
lines: list[str] = []
|
|
75
|
+
if kinds:
|
|
76
|
+
lines.append("Detected: " + ", ".join(kinds))
|
|
77
|
+
lines.append("Top-level entries:")
|
|
78
|
+
try:
|
|
79
|
+
entries = sorted(
|
|
80
|
+
workspace.iterdir(), key=lambda p: (p.is_file(), p.name.lower())
|
|
81
|
+
)
|
|
82
|
+
except OSError:
|
|
83
|
+
entries = []
|
|
84
|
+
shown = 0
|
|
85
|
+
for entry in entries:
|
|
86
|
+
if entry.name in _LISTING_SKIP or entry.name.startswith("."):
|
|
87
|
+
continue
|
|
88
|
+
if shown >= _MAX_LISTING:
|
|
89
|
+
lines.append(" ... (more entries; use list_dir/project_index)")
|
|
90
|
+
break
|
|
91
|
+
lines.append(f" {entry.name}{'/' if entry.is_dir() else ''}")
|
|
92
|
+
shown += 1
|
|
93
|
+
if shown == 0:
|
|
94
|
+
lines.append(" (empty workspace)")
|
|
95
|
+
|
|
96
|
+
return ProjectInfo(kinds=kinds, summary="\n".join(lines))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Provider registry: the five supported AI backends.
|
|
2
|
+
|
|
3
|
+
The registry is the single source of truth for which providers exist:
|
|
4
|
+
OpenRouter, FreeModel Claude, FreeModel Codex, AeroLink, and Ollama. Each
|
|
5
|
+
is fully independent — own key slot, base URL, catalogue, client, and
|
|
6
|
+
connection status — sharing only the Provider chat contract. Everything
|
|
7
|
+
else (engine, commands, onboarding, menu) resolves providers through
|
|
8
|
+
:func:`get_provider` / :data:`PROVIDERS`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from .aerolink import AeroLinkProvider
|
|
14
|
+
from .base import ModelInfo, Provider, ProviderError, ValidationResult
|
|
15
|
+
from .freemodel import FreeModelClaudeProvider, FreeModelCodexProvider
|
|
16
|
+
from .ollama import OllamaProvider
|
|
17
|
+
from .openrouter import OpenRouterProvider
|
|
18
|
+
|
|
19
|
+
# Instantiated once; per-provider state (status, client cache) lives on the
|
|
20
|
+
# instance and is session-only. Insertion order IS the /provider menu order.
|
|
21
|
+
PROVIDERS: dict[str, Provider] = {
|
|
22
|
+
p.id: p
|
|
23
|
+
for p in (
|
|
24
|
+
OpenRouterProvider(),
|
|
25
|
+
FreeModelClaudeProvider(),
|
|
26
|
+
FreeModelCodexProvider(),
|
|
27
|
+
AeroLinkProvider(),
|
|
28
|
+
OllamaProvider(),
|
|
29
|
+
)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_provider(provider_id: str) -> Provider:
|
|
34
|
+
"""Resolve a provider by id, raising a friendly error for unknown ids."""
|
|
35
|
+
provider = PROVIDERS.get((provider_id or "").lower())
|
|
36
|
+
if provider is None:
|
|
37
|
+
known = ", ".join(sorted(PROVIDERS))
|
|
38
|
+
raise ProviderError(
|
|
39
|
+
f"Unknown provider '{provider_id}'. Choose one of: {known} (see /provider)."
|
|
40
|
+
)
|
|
41
|
+
return provider
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def provider_label(provider_id: str) -> str:
|
|
45
|
+
"""Display label for a provider id; safe on unset/unknown ids."""
|
|
46
|
+
provider = PROVIDERS.get((provider_id or "").lower())
|
|
47
|
+
return provider.label if provider else (provider_id or "(not set)")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"ModelInfo",
|
|
52
|
+
"PROVIDERS",
|
|
53
|
+
"Provider",
|
|
54
|
+
"ProviderError",
|
|
55
|
+
"ValidationResult",
|
|
56
|
+
"get_provider",
|
|
57
|
+
"provider_label",
|
|
58
|
+
]
|