seedcode-cli 7.1.0__py3-none-any.whl → 7.2.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 +1 -1
- seedcode/app.py +51 -5
- seedcode/branding.py +5 -4
- seedcode/commands/provider.py +281 -18
- seedcode/core/chat.py +88 -11
- seedcode/core/models.py +237 -8
- seedcode/core/providers/__init__.py +96 -12
- seedcode/core/providers/custom.py +159 -0
- seedcode/core/providers/failover.py +102 -0
- seedcode/core/providers/health.py +223 -0
- seedcode/core/providers/ollama_start.py +135 -0
- seedcode/core/session.py +164 -9
- seedcode/core/tasks.py +30 -6
- seedcode/ui/badges.py +9 -2
- seedcode/ui/codemode_header.py +6 -2
- seedcode/ui/dashboard.py +100 -210
- seedcode/ui/session_view.py +1 -0
- {seedcode_cli-7.1.0.dist-info → seedcode_cli-7.2.5.dist-info}/METADATA +53 -34
- {seedcode_cli-7.1.0.dist-info → seedcode_cli-7.2.5.dist-info}/RECORD +22 -18
- seedcode_cli-7.2.5.dist-info/licenses/LICENSE +82 -0
- seedcode_cli-7.1.0.dist-info/licenses/LICENSE +0 -21
- {seedcode_cli-7.1.0.dist-info → seedcode_cli-7.2.5.dist-info}/WHEEL +0 -0
- {seedcode_cli-7.1.0.dist-info → seedcode_cli-7.2.5.dist-info}/entry_points.txt +0 -0
seedcode/__init__.py
CHANGED
|
@@ -6,7 +6,7 @@ Plant ideas. Grow code.
|
|
|
6
6
|
# Single source of truth for the application version. Every consumer reads it:
|
|
7
7
|
# the PyPI build (via [tool.hatch.version]), the CLI --version flag, the
|
|
8
8
|
# Windows installer metadata, the GitHub release tag, and the WinGet manifest.
|
|
9
|
-
__version__ = "7.
|
|
9
|
+
__version__ = "7.2.5"
|
|
10
10
|
__author__ = "Al Shahriar Sowan"
|
|
11
11
|
__publisher__ = "Eagox Studio"
|
|
12
12
|
# Current author/company links, surfaced by /about and the README credits.
|
seedcode/app.py
CHANGED
|
@@ -32,7 +32,12 @@ from .core.agent import AgentEngine, strip_tool_blocks
|
|
|
32
32
|
from .core.chat import ChatEngine, ChatError
|
|
33
33
|
from .core.lifecycle import LifecycleError, lifecycle
|
|
34
34
|
from .core.models import AppConfig
|
|
35
|
-
from .core.providers import
|
|
35
|
+
from .core.providers import (
|
|
36
|
+
PROVIDERS,
|
|
37
|
+
provider_label,
|
|
38
|
+
provider_ready,
|
|
39
|
+
sync_custom_providers,
|
|
40
|
+
)
|
|
36
41
|
from .core.providers.freemodel import AUTO_MODEL
|
|
37
42
|
from .memory import HistoryStore
|
|
38
43
|
from .tools import PermissionManager, PermissionMode
|
|
@@ -310,6 +315,14 @@ class _TaskPresenter:
|
|
|
310
315
|
return
|
|
311
316
|
if kind == "error":
|
|
312
317
|
self.ui.dim(f" ✖ {detail.splitlines()[0][:120]}")
|
|
318
|
+
elif kind == "switching_provider":
|
|
319
|
+
# A provider request failed and a healthy fallback took over; the
|
|
320
|
+
# task continues rather than restarting (v7.2.5).
|
|
321
|
+
self.ui.warning(f" Switching provider: {detail}")
|
|
322
|
+
if flow is not None:
|
|
323
|
+
flow.set_activity("Switching provider")
|
|
324
|
+
elif kind == "retry":
|
|
325
|
+
self.ui.dim(f" {detail}")
|
|
313
326
|
elif kind == "limit":
|
|
314
327
|
self.ui.warning(f"Assist stopped: {detail}")
|
|
315
328
|
elif kind == "call" and flow is None:
|
|
@@ -384,10 +397,23 @@ class _TaskPresenter:
|
|
|
384
397
|
self.ui.warning(detail)
|
|
385
398
|
elif kind == "task_failed":
|
|
386
399
|
self.ui.error(detail)
|
|
400
|
+
elif kind == "task_skipped":
|
|
401
|
+
# A skipped task is resolved, never verified work (v7.2.5).
|
|
402
|
+
self.ui.dim(f" – {detail}")
|
|
387
403
|
elif kind == "final_verify":
|
|
388
404
|
self.ui.dim(" final verification…")
|
|
389
405
|
if flow is not None:
|
|
390
406
|
flow.set_activity("Verifying the project (tests / build)")
|
|
407
|
+
elif kind == "reconnect":
|
|
408
|
+
# The connection dropped; the session is reconnecting and the task
|
|
409
|
+
# is preserved rather than failed (v7.2.5).
|
|
410
|
+
self.ui.warning(f" {detail}")
|
|
411
|
+
if flow is not None:
|
|
412
|
+
flow.set_activity("Reconnecting to the provider")
|
|
413
|
+
elif kind == "reconnected":
|
|
414
|
+
self.ui.dim(f" {detail}")
|
|
415
|
+
elif kind == "network_lost":
|
|
416
|
+
self.ui.warning(f"Network: {detail}")
|
|
391
417
|
elif kind in ("retry", "continue"):
|
|
392
418
|
self.ui.dim(f" {detail}")
|
|
393
419
|
if flow is not None and session is not None:
|
|
@@ -586,7 +612,13 @@ def _handle_codemode(
|
|
|
586
612
|
if flow is not None:
|
|
587
613
|
flow.set_state("paused")
|
|
588
614
|
flow.stop()
|
|
589
|
-
|
|
615
|
+
# Surface the real reason (a network pause says so) but keep the
|
|
616
|
+
# actionable instruction identical for every pause.
|
|
617
|
+
reason = (session.reason or "").strip()
|
|
618
|
+
if reason and "connection" in reason.lower():
|
|
619
|
+
ui.warning(f"{reason}")
|
|
620
|
+
else:
|
|
621
|
+
ui.warning("Session paused — state saved. Send /resume to continue.")
|
|
590
622
|
session_mod.set_current_session(session)
|
|
591
623
|
return
|
|
592
624
|
|
|
@@ -620,6 +652,10 @@ def _report_session_summary(ui: UI, session) -> None:
|
|
|
620
652
|
graph = session.graph
|
|
621
653
|
done, total = graph.progress()
|
|
622
654
|
ui.success(f"Project completed — {done}/{total} tasks verified")
|
|
655
|
+
skipped = graph.skipped_count()
|
|
656
|
+
if skipped:
|
|
657
|
+
# A skipped task is resolved, never counted as verified work.
|
|
658
|
+
ui.dim(f" • Skipped: {skipped} task(s) not needed")
|
|
623
659
|
evidence = session.session_evidence
|
|
624
660
|
if evidence.tests:
|
|
625
661
|
# What matters is the final state: the last test run passed.
|
|
@@ -1007,6 +1043,9 @@ def run(ui: UI) -> None:
|
|
|
1007
1043
|
interactive menu remains available via /exit for provider/model/settings.
|
|
1008
1044
|
"""
|
|
1009
1045
|
config = load_config()
|
|
1046
|
+
# Register the saved custom providers before anything resolves a provider
|
|
1047
|
+
# id (v7.2.5): the registry must reflect config.json from the first turn.
|
|
1048
|
+
sync_custom_providers(config)
|
|
1010
1049
|
set_active_theme(config.theme)
|
|
1011
1050
|
ui.apply_theme(config.theme)
|
|
1012
1051
|
ui.banner(config)
|
|
@@ -1017,8 +1056,15 @@ def run(ui: UI) -> None:
|
|
|
1017
1056
|
config.is_configured(),
|
|
1018
1057
|
)
|
|
1019
1058
|
|
|
1059
|
+
def presenter_on_event(kind: str, detail: str) -> None:
|
|
1060
|
+
"""Surface provider retry/failover on the plain chat prompt (v7.2.5)."""
|
|
1061
|
+
if kind == "switching_provider":
|
|
1062
|
+
ui.warning(f"Switching provider: {detail}")
|
|
1063
|
+
elif kind == "retry":
|
|
1064
|
+
ui.dim(f" {detail}")
|
|
1065
|
+
|
|
1020
1066
|
active_backend = config.provider
|
|
1021
|
-
engine = ChatEngine(config)
|
|
1067
|
+
engine = ChatEngine(config, on_event=presenter_on_event)
|
|
1022
1068
|
history = HistoryStore(provider_id=active_backend)
|
|
1023
1069
|
chat_session = _make_chat_session(ui)
|
|
1024
1070
|
|
|
@@ -1031,7 +1077,7 @@ def run(ui: UI) -> None:
|
|
|
1031
1077
|
if config.provider != active_backend:
|
|
1032
1078
|
# Guided setup switched providers: rebuild the backend state.
|
|
1033
1079
|
active_backend = config.provider
|
|
1034
|
-
engine = ChatEngine(config)
|
|
1080
|
+
engine = ChatEngine(config, on_event=presenter_on_event)
|
|
1035
1081
|
history = HistoryStore(provider_id=active_backend)
|
|
1036
1082
|
_chat_loop(ui, config, engine, history, chat_session)
|
|
1037
1083
|
else:
|
|
@@ -1048,7 +1094,7 @@ def run(ui: UI) -> None:
|
|
|
1048
1094
|
# backend/context and the new provider's own history store.
|
|
1049
1095
|
_log.info("backend switched: %s -> %s", active_backend, config.provider)
|
|
1050
1096
|
active_backend = config.provider
|
|
1051
|
-
engine = ChatEngine(config)
|
|
1097
|
+
engine = ChatEngine(config, on_event=presenter_on_event)
|
|
1052
1098
|
history = HistoryStore(provider_id=active_backend)
|
|
1053
1099
|
|
|
1054
1100
|
try:
|
seedcode/branding.py
CHANGED
|
@@ -8,10 +8,11 @@ the exact palette so every surface renders identical art:
|
|
|
8
8
|
bitmaps, the canonical ``assets/logo.png`` and the preview render
|
|
9
9
|
* the installer wizard and the Windows taskbar/Explorer surfaces
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
terminal
|
|
11
|
+
v7.2.5 note: this module stays a packaging/desktop asset. The terminal UI
|
|
12
|
+
renders its own branding from :data:`seedcode.ui.dashboard.LOGO_LINES` (the
|
|
13
|
+
exact ANSI wordmark, with a text fallback on narrow or non-Unicode consoles),
|
|
14
|
+
so no terminal surface imports this module and the pixel-art mark never costs
|
|
15
|
+
anything at startup.
|
|
15
16
|
|
|
16
17
|
Stdlib only (struct/zlib): safe to import from anywhere in the package,
|
|
17
18
|
costs nothing at startup, and keeps the brand byte-reproducible.
|
seedcode/commands/provider.py
CHANGED
|
@@ -11,12 +11,16 @@ switching behave identically.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
from ..config import save_config
|
|
14
|
+
from ..core.models import CustomProviderConfig, valid_base_url
|
|
14
15
|
from ..core.providers import (
|
|
15
16
|
PROVIDERS,
|
|
16
17
|
ModelInfo,
|
|
17
18
|
Provider,
|
|
18
19
|
ProviderError,
|
|
19
20
|
get_provider,
|
|
21
|
+
is_custom_provider_id,
|
|
22
|
+
sync_custom_providers,
|
|
23
|
+
visible_provider_ids,
|
|
20
24
|
)
|
|
21
25
|
from ..core.providers.base import STATUS_CONNECTED, STATUS_OFFLINE
|
|
22
26
|
from ..core.providers.freemodel import AUTO_MODEL
|
|
@@ -26,6 +30,9 @@ from ..ui.selector import Option, select
|
|
|
26
30
|
from ..ui.textbox import read_text
|
|
27
31
|
from . import CommandContext, CommandResult, command
|
|
28
32
|
|
|
33
|
+
#: Sentinel returned by the provider picker for "add a custom provider".
|
|
34
|
+
_ADD_CUSTOM = "__add_custom__"
|
|
35
|
+
|
|
29
36
|
|
|
30
37
|
# --- provider selection ------------------------------------------------------
|
|
31
38
|
|
|
@@ -52,9 +59,12 @@ def _resolve_provider(text: str) -> Provider | None:
|
|
|
52
59
|
_GROUP_BUILTIN = "Built-in · no API key needed"
|
|
53
60
|
_GROUP_BYOK = "Your own API key"
|
|
54
61
|
_GROUP_LOCAL = "Local · runs on this machine"
|
|
62
|
+
_GROUP_CUSTOM = "Custom · your own endpoint"
|
|
55
63
|
|
|
56
64
|
|
|
57
65
|
def _provider_group(provider: Provider) -> str:
|
|
66
|
+
if is_custom_provider_id(provider.id):
|
|
67
|
+
return _GROUP_CUSTOM
|
|
58
68
|
if provider.local:
|
|
59
69
|
return _GROUP_LOCAL
|
|
60
70
|
if provider.id == "default":
|
|
@@ -83,14 +93,31 @@ def _provider_key_column(provider: Provider, config) -> str:
|
|
|
83
93
|
return "API key needed"
|
|
84
94
|
|
|
85
95
|
|
|
86
|
-
def
|
|
87
|
-
"""
|
|
96
|
+
def _provider_model_column(config, provider_id: str) -> str:
|
|
97
|
+
"""The provider's currently selected model, or an em dash."""
|
|
98
|
+
custom = config.custom_provider(provider_id)
|
|
99
|
+
if custom is not None:
|
|
100
|
+
model = custom.model
|
|
101
|
+
else:
|
|
102
|
+
entry = config.providers.get(provider_id)
|
|
103
|
+
model = entry.model if entry else ""
|
|
104
|
+
if model == AUTO_MODEL:
|
|
105
|
+
return "Auto"
|
|
106
|
+
return model or "—"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _provider_menu(ui, config) -> str | None:
|
|
110
|
+
"""Interactive provider selector: badge, backend, model, and key state.
|
|
111
|
+
|
|
112
|
+
Offers the v7.2.5 list — OpenRouter, Ollama and every saved custom
|
|
113
|
+
provider — plus an entry to add a new custom endpoint. A legacy built-in
|
|
114
|
+
appears only while the user is on it (backward compatibility).
|
|
115
|
+
"""
|
|
88
116
|
options = []
|
|
89
|
-
for
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
model = "Auto"
|
|
117
|
+
for pid in visible_provider_ids(config):
|
|
118
|
+
p = PROVIDERS.get(pid)
|
|
119
|
+
if p is None:
|
|
120
|
+
continue
|
|
94
121
|
options.append(
|
|
95
122
|
Option(
|
|
96
123
|
p.label,
|
|
@@ -98,12 +125,20 @@ def _provider_menu(ui, config) -> Provider | None:
|
|
|
98
125
|
badge=badge_for_status(p.status),
|
|
99
126
|
columns=(
|
|
100
127
|
_provider_backend(p, config),
|
|
101
|
-
|
|
128
|
+
_provider_model_column(config, p.id),
|
|
102
129
|
_provider_key_column(p, config),
|
|
103
130
|
),
|
|
104
131
|
group=_provider_group(p),
|
|
105
132
|
)
|
|
106
133
|
)
|
|
134
|
+
options.append(
|
|
135
|
+
Option(
|
|
136
|
+
"Add a custom provider…",
|
|
137
|
+
value=_ADD_CUSTOM,
|
|
138
|
+
detail="any OpenAI-compatible API: name, base URL, key",
|
|
139
|
+
group=_GROUP_CUSTOM,
|
|
140
|
+
)
|
|
141
|
+
)
|
|
107
142
|
chosen = select(
|
|
108
143
|
options,
|
|
109
144
|
title="Provider",
|
|
@@ -113,7 +148,7 @@ def _provider_menu(ui, config) -> Provider | None:
|
|
|
113
148
|
if chosen is None:
|
|
114
149
|
ui.dim("Cancelled.")
|
|
115
150
|
return None
|
|
116
|
-
return
|
|
151
|
+
return str(chosen)
|
|
117
152
|
|
|
118
153
|
|
|
119
154
|
def _collect_key(ui, config, provider: Provider, *, replacing: bool = False) -> bool:
|
|
@@ -154,14 +189,18 @@ def _ensure_ready(ui, config, provider: Provider) -> bool:
|
|
|
154
189
|
Returns False only when the user cancels key entry. An unreachable
|
|
155
190
|
key-less backend (local Ollama, or the built-in Default connection in a
|
|
156
191
|
build that carries no credential) is reported with its own actionable
|
|
157
|
-
message but is never fatal —
|
|
158
|
-
provider later.
|
|
192
|
+
message but is never fatal — Ollama is auto-started (v7.2.5), and the
|
|
193
|
+
built-in Default connection can be replaced by another provider later.
|
|
159
194
|
"""
|
|
160
195
|
if not provider.requires_key:
|
|
196
|
+
if provider.id == "ollama":
|
|
197
|
+
return _ensure_ollama(ui, config, provider)
|
|
198
|
+
# Use the provider's own status refresh so a build with no built-in
|
|
199
|
+
# credential reports "No API Key" (a setup state) instead of a false
|
|
200
|
+
# "Offline" (v7.2.5 pip-install fix).
|
|
161
201
|
with ui.thinking(f"Checking {provider.label}"):
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
if reachable:
|
|
202
|
+
status = provider.refresh_status(config)
|
|
203
|
+
if status == STATUS_CONNECTED:
|
|
165
204
|
ui.success(f"{provider.label} is ready.")
|
|
166
205
|
else:
|
|
167
206
|
ui.warning(provider.unavailable_hint(config))
|
|
@@ -180,15 +219,30 @@ def select_provider(ui, config, target: str = "") -> bool:
|
|
|
180
219
|
"""Switch the active provider; returns True when the switch completed.
|
|
181
220
|
|
|
182
221
|
Only ``active_provider`` changes — every provider keeps its own saved
|
|
183
|
-
API key and model, so switching back restores them untouched.
|
|
222
|
+
API key and model, so switching back restores them untouched. Custom
|
|
223
|
+
providers are synchronised into the registry first so they resolve.
|
|
184
224
|
"""
|
|
225
|
+
sync_custom_providers(config)
|
|
185
226
|
chosen: Provider | None = None
|
|
186
227
|
if target:
|
|
187
228
|
chosen = _resolve_provider(target)
|
|
229
|
+
if chosen is None:
|
|
230
|
+
entry = config.custom_provider(target)
|
|
231
|
+
if entry is not None:
|
|
232
|
+
chosen = get_provider(entry.id)
|
|
188
233
|
if chosen is None:
|
|
189
234
|
ui.warning(f"Unknown provider '{target}'.")
|
|
190
235
|
if chosen is None:
|
|
191
|
-
|
|
236
|
+
picked = _provider_menu(ui, config)
|
|
237
|
+
if picked is None:
|
|
238
|
+
return False
|
|
239
|
+
if picked == _ADD_CUSTOM:
|
|
240
|
+
entry = _add_custom_flow(ui, config)
|
|
241
|
+
if entry is None:
|
|
242
|
+
return False
|
|
243
|
+
chosen = get_provider(entry.id)
|
|
244
|
+
else:
|
|
245
|
+
chosen = PROVIDERS.get(picked)
|
|
192
246
|
if chosen is None:
|
|
193
247
|
return False
|
|
194
248
|
|
|
@@ -198,6 +252,11 @@ def select_provider(ui, config, target: str = "") -> bool:
|
|
|
198
252
|
config.provider = previous # cancelled key entry: keep the old backend
|
|
199
253
|
return False
|
|
200
254
|
|
|
255
|
+
# An explicit selection clears any prior failure state, so a provider the
|
|
256
|
+
# user deliberately chose is tried again even if it had cooled down.
|
|
257
|
+
from ..core.providers import health as health_mod
|
|
258
|
+
|
|
259
|
+
health_mod.tracker().reset(chosen.id)
|
|
201
260
|
save_config(config)
|
|
202
261
|
ui.success(f"Provider set to {chosen.label}.")
|
|
203
262
|
# The provider's own saved model is active again automatically.
|
|
@@ -208,6 +267,201 @@ def select_provider(ui, config, target: str = "") -> bool:
|
|
|
208
267
|
return True
|
|
209
268
|
|
|
210
269
|
|
|
270
|
+
# --- Ollama auto-start -------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _ensure_ollama(ui, config, provider: Provider) -> bool:
|
|
274
|
+
"""Ensure the local Ollama server is running, starting it if needed.
|
|
275
|
+
|
|
276
|
+
Starting the server never fails the selection: if it cannot be started,
|
|
277
|
+
the user is told exactly what to do and may pick another provider.
|
|
278
|
+
"""
|
|
279
|
+
from ..core.providers.ollama_start import ensure_running
|
|
280
|
+
|
|
281
|
+
if provider.detect(config):
|
|
282
|
+
provider.status = STATUS_CONNECTED
|
|
283
|
+
ui.success(f"{provider.label} is ready.")
|
|
284
|
+
return True
|
|
285
|
+
|
|
286
|
+
ui.info("Ollama server not detected — starting it…")
|
|
287
|
+
ok, message = ensure_running(config, on_status=lambda line: ui.dim(f" {line}"))
|
|
288
|
+
if ok:
|
|
289
|
+
provider.status = STATUS_CONNECTED
|
|
290
|
+
ui.success(message)
|
|
291
|
+
else:
|
|
292
|
+
provider.status = STATUS_OFFLINE
|
|
293
|
+
ui.warning(message)
|
|
294
|
+
return True
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
# --- custom providers --------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def custom_providers_menu(ui, config) -> None:
|
|
301
|
+
"""Manage the saved custom providers: add, use, edit, test, reorder, delete."""
|
|
302
|
+
sync_custom_providers(config)
|
|
303
|
+
while True:
|
|
304
|
+
entries = config.ordered_custom_providers()
|
|
305
|
+
items = [MenuItem("Add a custom provider…", "add", badge="new")]
|
|
306
|
+
for entry in entries:
|
|
307
|
+
state = "enabled" if entry.enabled else "disabled"
|
|
308
|
+
items.append(
|
|
309
|
+
MenuItem(
|
|
310
|
+
entry.name,
|
|
311
|
+
f"pick:{entry.id}",
|
|
312
|
+
status=f"{entry.base_url} · {state} · {config.masked_key(entry.id)}",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
items.append(MenuItem("Back", "back"))
|
|
316
|
+
choice = run_menu(
|
|
317
|
+
items,
|
|
318
|
+
title=f"Custom providers ({len(entries)})",
|
|
319
|
+
hint="↑↓ move Enter select Esc back",
|
|
320
|
+
)
|
|
321
|
+
if choice is None or choice == "back":
|
|
322
|
+
return
|
|
323
|
+
if choice == "add":
|
|
324
|
+
_add_custom_flow(ui, config)
|
|
325
|
+
continue
|
|
326
|
+
_custom_actions(ui, config, str(choice).split(":", 1)[1])
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _add_custom_flow(ui, config) -> CustomProviderConfig | None:
|
|
330
|
+
"""Prompt for Name, Base URL, API Key (and optional model); save + test."""
|
|
331
|
+
ui.info("Add a custom provider — any OpenAI-compatible API.")
|
|
332
|
+
while True:
|
|
333
|
+
name = read_text("Name > ")
|
|
334
|
+
if name is None or not name.strip():
|
|
335
|
+
ui.dim("Cancelled — no provider added.")
|
|
336
|
+
return None
|
|
337
|
+
base_url = read_text("Base URL > ")
|
|
338
|
+
if base_url is None or not base_url.strip():
|
|
339
|
+
ui.dim("Cancelled — no provider added.")
|
|
340
|
+
return None
|
|
341
|
+
if not valid_base_url(base_url):
|
|
342
|
+
ui.error("Base URL must start with http:// or https://")
|
|
343
|
+
continue
|
|
344
|
+
key = read_text("API Key > ", password=True)
|
|
345
|
+
if key is None:
|
|
346
|
+
ui.dim("Cancelled — no provider added.")
|
|
347
|
+
return None
|
|
348
|
+
model = read_text("Model (optional) > ") or ""
|
|
349
|
+
try:
|
|
350
|
+
entry = config.add_custom_provider(name, base_url, key, model)
|
|
351
|
+
except ValueError as exc:
|
|
352
|
+
ui.error(str(exc))
|
|
353
|
+
continue
|
|
354
|
+
sync_custom_providers(config)
|
|
355
|
+
save_config(config)
|
|
356
|
+
ui.success(f"Saved custom provider '{entry.name}'.")
|
|
357
|
+
_test_connection(ui, config, entry.id)
|
|
358
|
+
return entry
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _edit_custom_flow(ui, config, provider_id: str) -> None:
|
|
362
|
+
"""Edit a saved custom provider; an empty answer keeps the current value."""
|
|
363
|
+
entry = config.custom_provider(provider_id)
|
|
364
|
+
if entry is None:
|
|
365
|
+
return
|
|
366
|
+
ui.info(f"Editing '{entry.name}' — press Enter to keep the current value.")
|
|
367
|
+
name = read_text(f"Name [{entry.name}] > ")
|
|
368
|
+
base_url = read_text(f"Base URL [{entry.base_url}] > ")
|
|
369
|
+
key = read_text("API Key (keep current) > ", password=True)
|
|
370
|
+
model = read_text(f"Model [{entry.model or 'none'}] > ")
|
|
371
|
+
try:
|
|
372
|
+
config.update_custom_provider(
|
|
373
|
+
provider_id,
|
|
374
|
+
name=(name.strip() if name and name.strip() else None),
|
|
375
|
+
base_url=(base_url.strip() if base_url and base_url.strip() else None),
|
|
376
|
+
api_key=(key.strip() if key and key.strip() else None),
|
|
377
|
+
model=(model.strip() if model and model.strip() else None),
|
|
378
|
+
)
|
|
379
|
+
except ValueError as exc:
|
|
380
|
+
ui.error(str(exc))
|
|
381
|
+
return
|
|
382
|
+
save_config(config)
|
|
383
|
+
sync_custom_providers(config)
|
|
384
|
+
ui.success("Saved.")
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _test_connection(ui, config, provider_id: str) -> bool:
|
|
388
|
+
"""Validate a provider's own credential with a real request."""
|
|
389
|
+
from ..core.providers import health as health_mod
|
|
390
|
+
|
|
391
|
+
try:
|
|
392
|
+
provider = get_provider(provider_id)
|
|
393
|
+
except ProviderError as exc:
|
|
394
|
+
ui.error(str(exc))
|
|
395
|
+
return False
|
|
396
|
+
with ui.thinking(f"Testing {provider.label}"):
|
|
397
|
+
result = provider.validate_key(config.get_api_key(provider_id))
|
|
398
|
+
if result.ok:
|
|
399
|
+
provider.status = STATUS_CONNECTED
|
|
400
|
+
health_mod.tracker().record_success(provider_id)
|
|
401
|
+
ui.success(result.message)
|
|
402
|
+
else:
|
|
403
|
+
provider.status = STATUS_OFFLINE
|
|
404
|
+
health_mod.tracker().record_failure(provider_id, reason=result.message)
|
|
405
|
+
ui.warning(result.message)
|
|
406
|
+
return result.ok
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _custom_actions(ui, config, provider_id: str) -> None:
|
|
410
|
+
"""Per-provider actions: use, edit, test, enable/disable, reorder, delete."""
|
|
411
|
+
while True:
|
|
412
|
+
entry = config.custom_provider(provider_id)
|
|
413
|
+
if entry is None:
|
|
414
|
+
return
|
|
415
|
+
state = "enabled" if entry.enabled else "disabled"
|
|
416
|
+
choice = run_menu(
|
|
417
|
+
[
|
|
418
|
+
MenuItem("Use this provider", "use"),
|
|
419
|
+
MenuItem("Edit", "edit"),
|
|
420
|
+
MenuItem("Test connection", "test"),
|
|
421
|
+
MenuItem("Disable" if entry.enabled else "Enable", "toggle"),
|
|
422
|
+
MenuItem("Move up", "up"),
|
|
423
|
+
MenuItem("Move down", "down"),
|
|
424
|
+
MenuItem("Delete", "delete"),
|
|
425
|
+
MenuItem("Back", "back"),
|
|
426
|
+
],
|
|
427
|
+
title=f"{entry.name} — {state}",
|
|
428
|
+
hint="↑↓ move Enter select Esc back",
|
|
429
|
+
)
|
|
430
|
+
if choice is None or choice == "back":
|
|
431
|
+
return
|
|
432
|
+
if choice == "use":
|
|
433
|
+
select_provider(ui, config, entry.id)
|
|
434
|
+
return
|
|
435
|
+
if choice == "edit":
|
|
436
|
+
_edit_custom_flow(ui, config, provider_id)
|
|
437
|
+
elif choice == "test":
|
|
438
|
+
_test_connection(ui, config, provider_id)
|
|
439
|
+
elif choice == "toggle":
|
|
440
|
+
config.set_custom_enabled(provider_id, not entry.enabled)
|
|
441
|
+
save_config(config)
|
|
442
|
+
sync_custom_providers(config)
|
|
443
|
+
ui.success(f"'{entry.name}' {'disabled' if entry.enabled else 'enabled'}.")
|
|
444
|
+
elif choice in ("up", "down"):
|
|
445
|
+
moved = config.move_custom_provider(
|
|
446
|
+
provider_id, -1 if choice == "up" else 1
|
|
447
|
+
)
|
|
448
|
+
save_config(config)
|
|
449
|
+
sync_custom_providers(config)
|
|
450
|
+
ui.dim("Reordered." if moved else "Already at the edge.")
|
|
451
|
+
elif choice == "delete":
|
|
452
|
+
from ..ui.dialog import confirm_dialog
|
|
453
|
+
|
|
454
|
+
name = entry.name
|
|
455
|
+
if confirm_dialog(
|
|
456
|
+
f"Delete '{name}'?", yes_label="Delete", no_label="Keep", danger=True
|
|
457
|
+
):
|
|
458
|
+
config.remove_custom_provider(provider_id)
|
|
459
|
+
save_config(config)
|
|
460
|
+
sync_custom_providers(config)
|
|
461
|
+
ui.success(f"Deleted '{name}'.")
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
|
|
211
465
|
# --- model selection ---------------------------------------------------------
|
|
212
466
|
|
|
213
467
|
|
|
@@ -387,14 +641,23 @@ def select_model(ui, config, target: str = "") -> None:
|
|
|
387
641
|
|
|
388
642
|
@command(
|
|
389
643
|
"provider",
|
|
390
|
-
"Select the active provider "
|
|
391
|
-
"(Default, OpenRouter, FreeModel Claude, FreeModel Codex, AeroLink, Ollama)",
|
|
644
|
+
"Select the active provider (OpenRouter, Ollama, or a saved custom provider)",
|
|
392
645
|
)
|
|
393
646
|
def _provider_cmd(ctx: CommandContext, arg: str) -> CommandResult:
|
|
394
647
|
select_provider(ctx.ui, ctx.config, arg.strip())
|
|
395
648
|
return CommandResult()
|
|
396
649
|
|
|
397
650
|
|
|
651
|
+
@command(
|
|
652
|
+
"custom",
|
|
653
|
+
"Manage custom providers (add, edit, test, reorder, enable, delete)",
|
|
654
|
+
aliases=("custom-providers", "providers"),
|
|
655
|
+
)
|
|
656
|
+
def _custom_cmd(ctx: CommandContext, arg: str) -> CommandResult:
|
|
657
|
+
custom_providers_menu(ctx.ui, ctx.config)
|
|
658
|
+
return CommandResult()
|
|
659
|
+
|
|
660
|
+
|
|
398
661
|
@command("model", "Browse and select a model for the active provider", aliases=("show",))
|
|
399
662
|
def _model_cmd(ctx: CommandContext, arg: str) -> CommandResult:
|
|
400
663
|
target = arg.strip()
|