deeptrade-quant 0.0.2__tar.gz → 0.1.0__tar.gz
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.
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/.gitignore +3 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/PKG-INFO +2 -1
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/__init__.py +1 -1
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/deeptrade_plugin.yaml +2 -2
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/cli.py +33 -6
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/cli_config.py +66 -2
- deeptrade_quant-0.1.0/deeptrade/cli_plugin.py +330 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/config.py +86 -3
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/config_migrations.py +44 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/db.py +4 -0
- deeptrade_quant-0.1.0/deeptrade/core/github_fetch.py +218 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/llm_manager.py +24 -7
- deeptrade_quant-0.0.2/deeptrade/core/migrations/core/20260427_001_init.sql → deeptrade_quant-0.1.0/deeptrade/core/migrations/core/20260509_001_init.sql +2 -10
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/notifier.py +4 -2
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/plugin_manager.py +40 -7
- deeptrade_quant-0.1.0/deeptrade/core/plugin_source.py +194 -0
- deeptrade_quant-0.1.0/deeptrade/core/registry.py +191 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/deeptrade_plugin.yaml +3 -21
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/runtime.py +7 -15
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260430_001_init.sql → deeptrade_quant-0.1.0/deeptrade/strategies_builtin/limit_up_board/migrations/20260509_001_init.sql +57 -19
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/deeptrade_plugin.yaml +3 -9
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/volume_anomaly/migrations/20260430_001_init.sql → deeptrade_quant-0.1.0/deeptrade/strategies_builtin/volume_anomaly/migrations/20260509_001_init.sql +35 -18
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/cli.py +6 -7
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/data.py +5 -2
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/render.py +1 -1
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runtime.py +6 -12
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/pyproject.toml +2 -1
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/cli/test_config_cmd.py +1 -0
- deeptrade_quant-0.1.0/tests/cli/test_plugin_cmd.py +320 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/cli/test_routing.py +0 -6
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_config.py +101 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_config_migrations.py +57 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_db.py +3 -8
- deeptrade_quant-0.1.0/tests/core/test_github_fetch.py +206 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_llm_manager.py +33 -0
- deeptrade_quant-0.1.0/tests/core/test_plugin_source.py +256 -0
- deeptrade_quant-0.1.0/tests/core/test_plugin_upgrade.py +142 -0
- deeptrade_quant-0.1.0/tests/core/test_registry.py +261 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_candidate_features.py +0 -1
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_prompt_consistency.py +0 -1
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_realized_returns.py +0 -2
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_screen_rules.py +0 -1
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_stats_query.py +0 -1
- deeptrade_quant-0.0.2/deeptrade/cli_plugin.py +0 -176
- deeptrade_quant-0.0.2/deeptrade/core/migrations/core/20260501_002_drop_llm_calls_stage.sql +0 -10
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260501_002_lub_stage_results_llm_provider.sql +0 -8
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_001_lub_lhb_tables.sql +0 -36
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_002_lub_cyq_perf.sql +0 -18
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_003_lub_lhb_pk_fix.sql +0 -46
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_004_lub_lhb_drop_pk.sql +0 -53
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/limit_up_board/migrations/20260508_005_lub_config.sql +0 -17
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_001_realized_returns.sql +0 -44
- deeptrade_quant-0.0.2/deeptrade/strategies_builtin/volume_anomaly/migrations/20260601_002_dimension_scores.sql +0 -13
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/LICENSE +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/README.md +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/__init__.py +0 -0
- /deeptrade_quant-0.0.2/deeptrade/channels_builtin/stdout/migrations/20260429_001_init.sql → /deeptrade_quant-0.1.0/deeptrade/channels_builtin/stdout/migrations/20260509_001_init.sql +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/stdout_channel/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/channels_builtin/stdout/stdout_channel/channel.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/cli_data.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/llm_client.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/logging_config.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/migrations/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/migrations/core/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/paths.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/run_status.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/secrets.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/core/tushare_client.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/base.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/channel.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/events.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/llm.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/metadata.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/plugins_api/notify.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/calendar.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/cli.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/config.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/data.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/pipeline.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/plugin.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/profiles.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/prompts.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/render.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/runner.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/limit_up_board/limit_up_board/schemas.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/calendar.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/pipeline.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/plugin.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/profiles.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/prompts_examples.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/runner.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/schemas.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/strategies_builtin/volume_anomaly/volume_anomaly/stats.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/deeptrade/theme.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/cli/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/conftest.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_llm_client.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_notifier.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_paths.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_plugin_install.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_secrets.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/core/test_tushare_client.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/plugins_api/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/plugins_api/test_notify.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/plugins_api/test_protocol.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/test_phase_a_factors.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/test_phase_b_factors.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/limit_up_board/test_v04_settings.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/__init__.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_alpha_features.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/strategies_builtin/volume_anomaly/test_dimension_scores.py +0 -0
- {deeptrade_quant-0.0.2 → deeptrade_quant-0.1.0}/tests/test_smoke.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: deeptrade-quant
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: LLM-driven A-share (Shanghai/Shenzhen main board) stock screening CLI
|
|
5
5
|
Project-URL: Homepage, https://github.com/ty19880929/deeptrade
|
|
6
6
|
Project-URL: Repository, https://github.com/ty19880929/deeptrade
|
|
@@ -14,6 +14,7 @@ Requires-Dist: click>=8.1
|
|
|
14
14
|
Requires-Dist: duckdb>=1.0
|
|
15
15
|
Requires-Dist: keyring>=25.0
|
|
16
16
|
Requires-Dist: openai>=1.0
|
|
17
|
+
Requires-Dist: packaging>=23.0
|
|
17
18
|
Requires-Dist: pandas>=2.2
|
|
18
19
|
Requires-Dist: pydantic>=2.7
|
|
19
20
|
Requires-Dist: pyyaml>=6.0
|
|
@@ -15,8 +15,8 @@ permissions:
|
|
|
15
15
|
llm_tools: false
|
|
16
16
|
|
|
17
17
|
migrations:
|
|
18
|
-
- version: "
|
|
19
|
-
file: migrations/
|
|
18
|
+
- version: "20260509_001"
|
|
19
|
+
file: migrations/20260509_001_init.sql
|
|
20
20
|
checksum: "sha256:7317d23d4ac91b9a1259e2ba4d990863491de8fe1115e19052b02f83939399cc"
|
|
21
21
|
|
|
22
22
|
tables:
|
|
@@ -184,14 +184,8 @@ def init(
|
|
|
184
184
|
fresh = not db_file.exists()
|
|
185
185
|
db = Database(db_file)
|
|
186
186
|
try:
|
|
187
|
-
applied = apply_core_migrations(db)
|
|
188
187
|
if fresh:
|
|
189
188
|
typer.echo(f"✔ Database created: {db_file}")
|
|
190
|
-
if applied:
|
|
191
|
-
for v in applied:
|
|
192
|
-
typer.echo(f"✔ Schema applied: {v}")
|
|
193
|
-
else:
|
|
194
|
-
typer.echo("✔ Database already initialized; schema up-to-date")
|
|
195
189
|
finally:
|
|
196
190
|
db.close()
|
|
197
191
|
|
|
@@ -210,5 +204,38 @@ def init(
|
|
|
210
204
|
cmd_set_llm()
|
|
211
205
|
|
|
212
206
|
|
|
207
|
+
|
|
208
|
+
@app.command(name="db", context_settings={"ignore_unknown_options": True, "allow_extra_args": True})
|
|
209
|
+
def db_cmd(ctx: click.Context) -> None:
|
|
210
|
+
"""Database migration and management commands (legacy stub; use `deeptrade db init` via group if added)."""
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
db_app = typer.Typer(name="db", help="Database migration and management commands.")
|
|
214
|
+
app.add_typer(db_app, name="db")
|
|
215
|
+
|
|
216
|
+
@db_app.command("init")
|
|
217
|
+
def db_init() -> None:
|
|
218
|
+
"""Initialize the core database tables and apply migrations."""
|
|
219
|
+
paths.ensure_layout()
|
|
220
|
+
db_file = paths.db_path()
|
|
221
|
+
fresh = not db_file.exists()
|
|
222
|
+
db = Database(db_file)
|
|
223
|
+
try:
|
|
224
|
+
applied = apply_core_migrations(db)
|
|
225
|
+
if fresh:
|
|
226
|
+
typer.echo(f"✔ Database created: {db_file}")
|
|
227
|
+
if applied:
|
|
228
|
+
for v in applied:
|
|
229
|
+
typer.echo(f"✔ Schema applied: {v}")
|
|
230
|
+
else:
|
|
231
|
+
typer.echo("✔ Database already initialized; schema up-to-date")
|
|
232
|
+
finally:
|
|
233
|
+
db.close()
|
|
234
|
+
|
|
235
|
+
@db_app.command("upgrade")
|
|
236
|
+
def db_upgrade() -> None:
|
|
237
|
+
"""Apply any pending core migrations."""
|
|
238
|
+
db_init()
|
|
239
|
+
|
|
213
240
|
if __name__ == "__main__":
|
|
214
241
|
app()
|
|
@@ -191,13 +191,35 @@ def _set_llm_new(svc: ConfigService) -> None:
|
|
|
191
191
|
typer.echo(f"Provider {name!r} already exists; pick edit instead.")
|
|
192
192
|
raise typer.Exit(2)
|
|
193
193
|
default_base = _DEFAULT_BASE_URLS.get(name.split("-")[0], "")
|
|
194
|
-
|
|
194
|
+
# Adding into an empty dict auto-promotes to default; offer the choice
|
|
195
|
+
# only when a default is already in place.
|
|
196
|
+
promote_default: bool | None = None
|
|
197
|
+
if cfg.llm_providers:
|
|
198
|
+
promote_default = bool(
|
|
199
|
+
questionary.confirm(
|
|
200
|
+
f"Set {name!r} as the default LLM provider?",
|
|
201
|
+
default=False,
|
|
202
|
+
).ask()
|
|
203
|
+
)
|
|
204
|
+
_prompt_and_save_provider(
|
|
205
|
+
svc,
|
|
206
|
+
name,
|
|
207
|
+
defaults=None,
|
|
208
|
+
default_base_url=default_base,
|
|
209
|
+
is_default=promote_default,
|
|
210
|
+
)
|
|
195
211
|
|
|
196
212
|
|
|
197
213
|
def _set_llm_edit(svc: ConfigService, name: str) -> None:
|
|
198
214
|
cfg = svc.get_app_config()
|
|
199
215
|
cur = cfg.llm_providers[name]
|
|
200
|
-
_prompt_and_save_provider(
|
|
216
|
+
_prompt_and_save_provider(
|
|
217
|
+
svc,
|
|
218
|
+
name,
|
|
219
|
+
defaults=cur.model_dump(),
|
|
220
|
+
default_base_url=cur.base_url,
|
|
221
|
+
is_default=None,
|
|
222
|
+
)
|
|
201
223
|
|
|
202
224
|
|
|
203
225
|
def _prompt_and_save_provider(
|
|
@@ -206,6 +228,7 @@ def _prompt_and_save_provider(
|
|
|
206
228
|
*,
|
|
207
229
|
defaults: dict | None,
|
|
208
230
|
default_base_url: str,
|
|
231
|
+
is_default: bool | None = None,
|
|
209
232
|
) -> None:
|
|
210
233
|
base_url_default = defaults.get("base_url", default_base_url) if defaults else default_base_url
|
|
211
234
|
model_default = defaults.get("model", "") if defaults else ""
|
|
@@ -251,6 +274,7 @@ def _prompt_and_save_provider(
|
|
|
251
274
|
model=model,
|
|
252
275
|
timeout=timeout,
|
|
253
276
|
api_key=api_key if api_key else None,
|
|
277
|
+
is_default=is_default,
|
|
254
278
|
)
|
|
255
279
|
except ValueError as e:
|
|
256
280
|
typer.echo(f"Invalid provider: {e}")
|
|
@@ -274,8 +298,48 @@ def _set_llm_delete(svc: ConfigService, existing: list[str]) -> None:
|
|
|
274
298
|
).ask()
|
|
275
299
|
if not confirm:
|
|
276
300
|
raise typer.Exit(1)
|
|
301
|
+
prior_default = svc.get_default_llm_provider()
|
|
277
302
|
svc.delete_llm_provider(name)
|
|
278
303
|
typer.echo(f"✔ Deleted LLM provider {name!r}")
|
|
304
|
+
new_default = svc.get_default_llm_provider()
|
|
305
|
+
if prior_default == name and new_default is not None:
|
|
306
|
+
typer.echo(f"✔ Default LLM provider auto-switched to {new_default!r}")
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@app.command("set-default-llm")
|
|
310
|
+
def cmd_set_default_llm(
|
|
311
|
+
name: str = typer.Argument(..., help="Provider name to mark as default."),
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Mark ``name`` as the default LLM provider.
|
|
314
|
+
|
|
315
|
+
The default is consumed by ``LLMManager.get_client()`` when callers
|
|
316
|
+
don't name a provider (non-debate plugin path). Switching default
|
|
317
|
+
clears the flag on every other provider so the
|
|
318
|
+
"exactly-one-default" invariant holds.
|
|
319
|
+
"""
|
|
320
|
+
db, svc = _open_service()
|
|
321
|
+
try:
|
|
322
|
+
cfg = svc.get_app_config()
|
|
323
|
+
provider = cfg.llm_providers.get(name)
|
|
324
|
+
if provider is None:
|
|
325
|
+
typer.echo(
|
|
326
|
+
f"Unknown LLM provider: {name!r}; configured providers: "
|
|
327
|
+
+ (", ".join(sorted(cfg.llm_providers.keys())) or "(none)")
|
|
328
|
+
)
|
|
329
|
+
raise typer.Exit(2)
|
|
330
|
+
if provider.is_default:
|
|
331
|
+
typer.echo(f"{name!r} is already the default LLM provider")
|
|
332
|
+
return
|
|
333
|
+
svc.set_llm_provider(
|
|
334
|
+
name,
|
|
335
|
+
base_url=provider.base_url,
|
|
336
|
+
model=provider.model,
|
|
337
|
+
timeout=provider.timeout,
|
|
338
|
+
is_default=True,
|
|
339
|
+
)
|
|
340
|
+
typer.echo(f"✔ Default LLM provider set to {name!r}")
|
|
341
|
+
finally:
|
|
342
|
+
db.close()
|
|
279
343
|
|
|
280
344
|
|
|
281
345
|
@app.command("list-llm")
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""`deeptrade plugin` subcommand group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
import typer
|
|
7
|
+
import yaml
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
|
|
11
|
+
from deeptrade.core import paths
|
|
12
|
+
from deeptrade.core.db import Database
|
|
13
|
+
from deeptrade.core.github_fetch import (
|
|
14
|
+
GitHubFetchError,
|
|
15
|
+
NoMatchingReleaseError,
|
|
16
|
+
TarballFetchError,
|
|
17
|
+
)
|
|
18
|
+
from deeptrade.core.plugin_manager import (
|
|
19
|
+
PluginInstallError,
|
|
20
|
+
PluginManager,
|
|
21
|
+
PluginNotFoundError,
|
|
22
|
+
UpgradeNoop,
|
|
23
|
+
_load_metadata_yaml,
|
|
24
|
+
summarize_for_install,
|
|
25
|
+
)
|
|
26
|
+
from deeptrade.core.plugin_source import (
|
|
27
|
+
ResolvedSource,
|
|
28
|
+
SourceResolveError,
|
|
29
|
+
SourceResolver,
|
|
30
|
+
)
|
|
31
|
+
from deeptrade.core.registry import (
|
|
32
|
+
RegistryClient,
|
|
33
|
+
RegistryFetchError,
|
|
34
|
+
RegistryNotFoundError,
|
|
35
|
+
RegistrySchemaError,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
app = typer.Typer(help="Install / manage plugins", no_args_is_help=True)
|
|
39
|
+
|
|
40
|
+
_RESOLVE_ERRORS: tuple[type[Exception], ...] = (
|
|
41
|
+
RegistryNotFoundError,
|
|
42
|
+
RegistryFetchError,
|
|
43
|
+
RegistrySchemaError,
|
|
44
|
+
NoMatchingReleaseError,
|
|
45
|
+
TarballFetchError,
|
|
46
|
+
GitHubFetchError,
|
|
47
|
+
SourceResolveError,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _open() -> tuple[Database, PluginManager]:
|
|
52
|
+
db = Database(paths.db_path())
|
|
53
|
+
return db, PluginManager(db)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _format_origin(resolved: ResolvedSource) -> str:
|
|
57
|
+
d = resolved.origin_detail
|
|
58
|
+
if resolved.origin == "local":
|
|
59
|
+
return f"本地路径 ({d.get('local_path', resolved.path)})"
|
|
60
|
+
if resolved.origin == "github_registry":
|
|
61
|
+
return (
|
|
62
|
+
f"GitHub 注册表 ({d['repo']}@{d['ref']}, subdir={d['subdir']})"
|
|
63
|
+
)
|
|
64
|
+
if resolved.origin == "github_url":
|
|
65
|
+
return f"GitHub URL ({d['repo']}@{d['ref']})"
|
|
66
|
+
return resolved.origin
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command("install")
|
|
70
|
+
def cmd_install(
|
|
71
|
+
source: str = typer.Argument(
|
|
72
|
+
..., help="短名(注册表)/ 本地路径 / GitHub URL"
|
|
73
|
+
),
|
|
74
|
+
ref: str | None = typer.Option(
|
|
75
|
+
None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
|
|
76
|
+
),
|
|
77
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip the confirmation prompt"),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Install a plugin from the registry, a GitHub URL, or a local directory."""
|
|
80
|
+
resolver = SourceResolver()
|
|
81
|
+
try:
|
|
82
|
+
resolved = resolver.resolve(source, ref)
|
|
83
|
+
except _RESOLVE_ERRORS as e:
|
|
84
|
+
typer.echo(f"✘ {e}")
|
|
85
|
+
raise typer.Exit(2) from e
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
try:
|
|
89
|
+
meta = _load_metadata_yaml(resolved.path / "deeptrade_plugin.yaml")
|
|
90
|
+
except PluginInstallError as e:
|
|
91
|
+
typer.echo(f"✘ {e}")
|
|
92
|
+
raise typer.Exit(2) from e
|
|
93
|
+
|
|
94
|
+
typer.echo("─── 即将安装 ─────────────────────────────")
|
|
95
|
+
typer.echo(f"来源: {_format_origin(resolved)}")
|
|
96
|
+
typer.echo(summarize_for_install(meta, resolved.path))
|
|
97
|
+
typer.echo("──────────────────────────────────────────")
|
|
98
|
+
if not yes:
|
|
99
|
+
ok = questionary.confirm("确认安装?", default=False).ask()
|
|
100
|
+
if not ok:
|
|
101
|
+
typer.echo("Aborted.")
|
|
102
|
+
raise typer.Exit(1)
|
|
103
|
+
|
|
104
|
+
db, mgr = _open()
|
|
105
|
+
try:
|
|
106
|
+
rec = mgr.install(resolved.path)
|
|
107
|
+
except PluginInstallError as e:
|
|
108
|
+
typer.echo(f"✘ Install failed: {e}")
|
|
109
|
+
raise typer.Exit(2) from e
|
|
110
|
+
finally:
|
|
111
|
+
db.close()
|
|
112
|
+
|
|
113
|
+
typer.echo(f"✔ 已安装: {rec.plugin_id} v{rec.version}")
|
|
114
|
+
finally:
|
|
115
|
+
if resolved.cleanup is not None:
|
|
116
|
+
resolved.cleanup()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@app.command("list")
|
|
120
|
+
def cmd_list() -> None:
|
|
121
|
+
"""List installed plugins."""
|
|
122
|
+
db, mgr = _open()
|
|
123
|
+
try:
|
|
124
|
+
records = mgr.list_all()
|
|
125
|
+
finally:
|
|
126
|
+
db.close()
|
|
127
|
+
|
|
128
|
+
console = Console()
|
|
129
|
+
table = Table(title="Installed Plugins")
|
|
130
|
+
table.add_column("plugin_id", style="cyan")
|
|
131
|
+
table.add_column("name")
|
|
132
|
+
table.add_column("version")
|
|
133
|
+
table.add_column("enabled", style="green")
|
|
134
|
+
if not records:
|
|
135
|
+
typer.echo("(no plugins installed)")
|
|
136
|
+
return
|
|
137
|
+
for r in records:
|
|
138
|
+
table.add_row(r.plugin_id, r.name, r.version, "yes" if r.enabled else "no")
|
|
139
|
+
console.print(table)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@app.command("info")
|
|
143
|
+
def cmd_info(plugin_id: str = typer.Argument(...)) -> None:
|
|
144
|
+
"""Show metadata for a plugin.
|
|
145
|
+
|
|
146
|
+
If installed locally: shows the full installed metadata.yaml.
|
|
147
|
+
If not installed: falls back to the registry entry (with an install hint).
|
|
148
|
+
"""
|
|
149
|
+
db, mgr = _open()
|
|
150
|
+
try:
|
|
151
|
+
try:
|
|
152
|
+
rec = mgr.info(plugin_id)
|
|
153
|
+
typer.echo(
|
|
154
|
+
yaml.safe_dump(rec.metadata.model_dump(mode="json"), allow_unicode=True)
|
|
155
|
+
)
|
|
156
|
+
return
|
|
157
|
+
except PluginNotFoundError:
|
|
158
|
+
pass # fall through to registry lookup
|
|
159
|
+
finally:
|
|
160
|
+
db.close()
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
entry = RegistryClient().resolve(plugin_id)
|
|
164
|
+
except RegistryNotFoundError as e:
|
|
165
|
+
typer.echo(f"✘ {plugin_id} 既未安装,也不在注册表中")
|
|
166
|
+
raise typer.Exit(2) from e
|
|
167
|
+
except (RegistryFetchError, RegistrySchemaError) as e:
|
|
168
|
+
typer.echo(f"✘ {plugin_id} 未安装;查询注册表失败: {e}")
|
|
169
|
+
raise typer.Exit(2) from e
|
|
170
|
+
|
|
171
|
+
typer.echo(f"(未安装) {entry.plugin_id}")
|
|
172
|
+
typer.echo(f" name: {entry.name}")
|
|
173
|
+
typer.echo(f" type: {entry.type}")
|
|
174
|
+
typer.echo(f" description: {entry.description}")
|
|
175
|
+
typer.echo(f" repo: {entry.repo}")
|
|
176
|
+
typer.echo(f" subdir: {entry.subdir}")
|
|
177
|
+
typer.echo(f" install: deeptrade plugin install {entry.plugin_id}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@app.command("disable")
|
|
181
|
+
def cmd_disable(plugin_id: str = typer.Argument(...)) -> None:
|
|
182
|
+
db, mgr = _open()
|
|
183
|
+
try:
|
|
184
|
+
try:
|
|
185
|
+
mgr.disable(plugin_id)
|
|
186
|
+
except PluginNotFoundError as e:
|
|
187
|
+
typer.echo(f"✘ {plugin_id} not installed")
|
|
188
|
+
raise typer.Exit(2) from e
|
|
189
|
+
typer.echo(f"✔ disabled: {plugin_id}")
|
|
190
|
+
finally:
|
|
191
|
+
db.close()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
@app.command("enable")
|
|
195
|
+
def cmd_enable(plugin_id: str = typer.Argument(...)) -> None:
|
|
196
|
+
db, mgr = _open()
|
|
197
|
+
try:
|
|
198
|
+
try:
|
|
199
|
+
mgr.enable(plugin_id)
|
|
200
|
+
except PluginNotFoundError as e:
|
|
201
|
+
typer.echo(f"✘ {plugin_id} not installed")
|
|
202
|
+
raise typer.Exit(2) from e
|
|
203
|
+
typer.echo(f"✔ enabled: {plugin_id}")
|
|
204
|
+
finally:
|
|
205
|
+
db.close()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
@app.command("uninstall")
|
|
209
|
+
def cmd_uninstall(
|
|
210
|
+
plugin_id: str = typer.Argument(...),
|
|
211
|
+
purge: bool = typer.Option(False, "--purge", help="DROP plugin tables and forget all data"),
|
|
212
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
|
|
213
|
+
) -> None:
|
|
214
|
+
"""Uninstall a plugin. Default: keep tables (just disable). --purge: drop tables."""
|
|
215
|
+
db, mgr = _open()
|
|
216
|
+
try:
|
|
217
|
+
try:
|
|
218
|
+
rec = mgr.info(plugin_id)
|
|
219
|
+
except PluginNotFoundError as e:
|
|
220
|
+
typer.echo(f"✘ {plugin_id} not installed")
|
|
221
|
+
raise typer.Exit(2) from e
|
|
222
|
+
|
|
223
|
+
if purge and not yes:
|
|
224
|
+
tables = [t.name for t in rec.metadata.tables if t.purge_on_uninstall]
|
|
225
|
+
typer.echo(f"将删除以下表(不可恢复): {tables}")
|
|
226
|
+
ok = questionary.confirm("确认 --purge?", default=False).ask()
|
|
227
|
+
if not ok:
|
|
228
|
+
typer.echo("Aborted.")
|
|
229
|
+
raise typer.Exit(1)
|
|
230
|
+
|
|
231
|
+
result = mgr.uninstall(plugin_id, purge=purge)
|
|
232
|
+
action = "purged" if purge else "disabled"
|
|
233
|
+
typer.echo(f"✔ {action}: {plugin_id} (dropped tables: {result['purged_tables']})")
|
|
234
|
+
finally:
|
|
235
|
+
db.close()
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@app.command("upgrade")
|
|
239
|
+
def cmd_upgrade(
|
|
240
|
+
source: str = typer.Argument(
|
|
241
|
+
..., help="短名(注册表)/ 本地路径 / GitHub URL"
|
|
242
|
+
),
|
|
243
|
+
ref: str | None = typer.Option(
|
|
244
|
+
None, "--ref", help="Tag / branch / sha (默认 = 该插件最新 release)"
|
|
245
|
+
),
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Upgrade an installed plugin.
|
|
248
|
+
|
|
249
|
+
Exit codes:
|
|
250
|
+
0 — upgraded, or already at the candidate version
|
|
251
|
+
2 — not installed / candidate < installed (downgrade forbidden) /
|
|
252
|
+
network or registry failure
|
|
253
|
+
"""
|
|
254
|
+
resolver = SourceResolver()
|
|
255
|
+
try:
|
|
256
|
+
resolved = resolver.resolve(source, ref)
|
|
257
|
+
except _RESOLVE_ERRORS as e:
|
|
258
|
+
typer.echo(f"✘ {e}")
|
|
259
|
+
raise typer.Exit(2) from e
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
db, mgr = _open()
|
|
263
|
+
try:
|
|
264
|
+
try:
|
|
265
|
+
result = mgr.upgrade(resolved.path)
|
|
266
|
+
except PluginNotFoundError as e:
|
|
267
|
+
try:
|
|
268
|
+
meta = _load_metadata_yaml(resolved.path / "deeptrade_plugin.yaml")
|
|
269
|
+
pid = meta.plugin_id
|
|
270
|
+
except PluginInstallError:
|
|
271
|
+
pid = source
|
|
272
|
+
typer.echo(
|
|
273
|
+
f'✘ 插件 "{pid}" 未安装,请先执行 deeptrade plugin install'
|
|
274
|
+
)
|
|
275
|
+
raise typer.Exit(2) from e
|
|
276
|
+
except PluginInstallError as e:
|
|
277
|
+
typer.echo(f"✘ Upgrade failed: {e}")
|
|
278
|
+
raise typer.Exit(2) from e
|
|
279
|
+
|
|
280
|
+
if isinstance(result, UpgradeNoop):
|
|
281
|
+
typer.echo(f"已是最新版本 v{result.version}")
|
|
282
|
+
return
|
|
283
|
+
typer.echo(f"✔ upgraded: {result.plugin_id} → v{result.version}")
|
|
284
|
+
finally:
|
|
285
|
+
db.close()
|
|
286
|
+
finally:
|
|
287
|
+
if resolved.cleanup is not None:
|
|
288
|
+
resolved.cleanup()
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@app.command("search")
|
|
292
|
+
def cmd_search(
|
|
293
|
+
keyword: str | None = typer.Argument(
|
|
294
|
+
None, help="可选过滤关键词(匹配 plugin_id / name / description)"
|
|
295
|
+
),
|
|
296
|
+
no_cache: bool = typer.Option(
|
|
297
|
+
False, "--no-cache", help="强制刷新注册表(旁路 ETag 缓存)"
|
|
298
|
+
),
|
|
299
|
+
) -> None:
|
|
300
|
+
"""List plugins available in the official registry."""
|
|
301
|
+
try:
|
|
302
|
+
registry = RegistryClient().fetch(force=no_cache)
|
|
303
|
+
except (RegistryFetchError, RegistrySchemaError) as e:
|
|
304
|
+
typer.echo(f"✘ {e}")
|
|
305
|
+
raise typer.Exit(2) from e
|
|
306
|
+
|
|
307
|
+
rows = list(registry.plugins.values())
|
|
308
|
+
if keyword:
|
|
309
|
+
kw = keyword.lower()
|
|
310
|
+
rows = [
|
|
311
|
+
entry
|
|
312
|
+
for entry in rows
|
|
313
|
+
if kw in entry.plugin_id.lower()
|
|
314
|
+
or kw in entry.name.lower()
|
|
315
|
+
or kw in entry.description.lower()
|
|
316
|
+
]
|
|
317
|
+
|
|
318
|
+
if not rows:
|
|
319
|
+
typer.echo("(no plugins matched)" if keyword else "(registry is empty)")
|
|
320
|
+
return
|
|
321
|
+
|
|
322
|
+
console = Console()
|
|
323
|
+
table = Table(title="Available Plugins")
|
|
324
|
+
table.add_column("plugin_id", style="cyan")
|
|
325
|
+
table.add_column("name")
|
|
326
|
+
table.add_column("type", style="green")
|
|
327
|
+
table.add_column("description")
|
|
328
|
+
for entry in sorted(rows, key=lambda x: x.plugin_id):
|
|
329
|
+
table.add_row(entry.plugin_id, entry.name, entry.type, entry.description)
|
|
330
|
+
console.print(table)
|
|
@@ -51,12 +51,20 @@ class LLMProviderConfig(BaseModel):
|
|
|
51
51
|
|
|
52
52
|
The api_key is NOT stored here; it lives in ``secret_store`` under the
|
|
53
53
|
key ``llm.<name>.api_key`` and is routed via ``is_secret_key()``.
|
|
54
|
+
|
|
55
|
+
``is_default`` marks the provider used by ``LLMManager.get_client()``
|
|
56
|
+
when the caller does not name a provider (non-debate plugin path).
|
|
57
|
+
Invariant: while ``llm_providers`` is non-empty, exactly one entry
|
|
58
|
+
has ``is_default=True``. ``ConfigService.set_llm_provider`` /
|
|
59
|
+
``delete_llm_provider`` maintain this invariant; legacy configs are
|
|
60
|
+
repaired by ``config_migrations``.
|
|
54
61
|
"""
|
|
55
62
|
|
|
56
63
|
model_config = ConfigDict(extra="forbid")
|
|
57
64
|
base_url: str
|
|
58
65
|
model: str
|
|
59
66
|
timeout: int = Field(default=180, ge=10)
|
|
67
|
+
is_default: bool = False
|
|
60
68
|
|
|
61
69
|
|
|
62
70
|
class AppConfig(BaseModel):
|
|
@@ -317,9 +325,23 @@ class ConfigService:
|
|
|
317
325
|
model: str,
|
|
318
326
|
timeout: int = 180,
|
|
319
327
|
api_key: str | None = None,
|
|
328
|
+
is_default: bool | None = None,
|
|
320
329
|
) -> None:
|
|
321
330
|
"""Insert or update a provider entry. If ``api_key`` is given, also
|
|
322
331
|
persist it to secret_store under ``llm.<name>.api_key``.
|
|
332
|
+
|
|
333
|
+
Default-provider invariant (one entry has ``is_default=True`` while
|
|
334
|
+
``llm.providers`` is non-empty) is maintained here:
|
|
335
|
+
|
|
336
|
+
* Adding the first provider auto-marks it default regardless of
|
|
337
|
+
the ``is_default`` argument.
|
|
338
|
+
* ``is_default=True`` on a non-first add or update promotes this
|
|
339
|
+
provider and clears the flag on every other entry.
|
|
340
|
+
* ``is_default=None`` on update preserves the existing flag (no
|
|
341
|
+
silent demotion); on a non-first add it defaults to False.
|
|
342
|
+
* ``is_default=False`` is honored only when another provider
|
|
343
|
+
already holds the default flag — never used to leave the dict
|
|
344
|
+
with zero defaults.
|
|
323
345
|
"""
|
|
324
346
|
if not name or "." in name:
|
|
325
347
|
raise ValueError(
|
|
@@ -327,18 +349,79 @@ class ConfigService:
|
|
|
327
349
|
)
|
|
328
350
|
current_raw = self.get("llm.providers")
|
|
329
351
|
current: dict[str, Any] = dict(current_raw) if isinstance(current_raw, dict) else {}
|
|
330
|
-
|
|
352
|
+
is_first = len(current) == 0
|
|
353
|
+
existing = dict(current.get(name) or {})
|
|
354
|
+
prior_default = bool(existing.get("is_default", False))
|
|
355
|
+
|
|
356
|
+
# Resolve the new is_default flag for this provider.
|
|
357
|
+
if is_first:
|
|
358
|
+
new_default = True
|
|
359
|
+
elif is_default is True:
|
|
360
|
+
new_default = True
|
|
361
|
+
elif is_default is None:
|
|
362
|
+
new_default = prior_default
|
|
363
|
+
else: # is_default is False
|
|
364
|
+
# Only honor an explicit demotion if some other provider is
|
|
365
|
+
# already default; otherwise we'd leave the dict with no
|
|
366
|
+
# default at all. Preserve prior_default in that case.
|
|
367
|
+
other_has_default = any(
|
|
368
|
+
k != name and bool((v or {}).get("is_default"))
|
|
369
|
+
for k, v in current.items()
|
|
370
|
+
)
|
|
371
|
+
new_default = prior_default if not other_has_default else False
|
|
372
|
+
|
|
373
|
+
# If this provider is becoming default, demote every other entry so
|
|
374
|
+
# the invariant "at most one default" holds.
|
|
375
|
+
if new_default:
|
|
376
|
+
for other_name, other_cfg in current.items():
|
|
377
|
+
if other_name != name and isinstance(other_cfg, dict) and other_cfg.get("is_default"):
|
|
378
|
+
current[other_name] = {**other_cfg, "is_default": False}
|
|
379
|
+
|
|
380
|
+
current[name] = {
|
|
381
|
+
"base_url": base_url,
|
|
382
|
+
"model": model,
|
|
383
|
+
"timeout": timeout,
|
|
384
|
+
"is_default": new_default,
|
|
385
|
+
}
|
|
331
386
|
self.set("llm.providers", current)
|
|
332
387
|
if api_key is not None:
|
|
333
388
|
self.set(f"llm.{name}.api_key", api_key)
|
|
334
389
|
|
|
335
390
|
def delete_llm_provider(self, name: str) -> None:
|
|
336
|
-
"""Remove a provider entry plus its api_key. Idempotent on missing name.
|
|
391
|
+
"""Remove a provider entry plus its api_key. Idempotent on missing name.
|
|
392
|
+
|
|
393
|
+
If the removed provider held ``is_default=True`` and ≥1 entry
|
|
394
|
+
survives, the first remaining entry (insertion order) is auto-
|
|
395
|
+
promoted to default to maintain the invariant.
|
|
396
|
+
"""
|
|
337
397
|
current_raw = self.get("llm.providers")
|
|
338
398
|
current: dict[str, Any] = dict(current_raw) if isinstance(current_raw, dict) else {}
|
|
339
|
-
current.pop(name, None)
|
|
399
|
+
removed = current.pop(name, None)
|
|
400
|
+
removed_was_default = bool(isinstance(removed, dict) and removed.get("is_default"))
|
|
401
|
+
|
|
402
|
+
if removed_was_default and current:
|
|
403
|
+
first_key = next(iter(current.keys()))
|
|
404
|
+
first_cfg = current[first_key]
|
|
405
|
+
if isinstance(first_cfg, dict):
|
|
406
|
+
current[first_key] = {**first_cfg, "is_default": True}
|
|
407
|
+
|
|
340
408
|
if current:
|
|
341
409
|
self.set("llm.providers", current)
|
|
342
410
|
else:
|
|
343
411
|
self.delete("llm.providers")
|
|
344
412
|
self.delete(f"llm.{name}.api_key")
|
|
413
|
+
|
|
414
|
+
def get_default_llm_provider(self) -> str | None:
|
|
415
|
+
"""Return the name of the provider with ``is_default=True``, or None
|
|
416
|
+
if no providers are configured.
|
|
417
|
+
|
|
418
|
+
Used by :class:`LLMManager.get_client` to resolve ``name=None`` calls
|
|
419
|
+
from non-debate plugins. Not exposed as a CLI; the CLI sets the
|
|
420
|
+
default via ``set-default-llm`` which routes through
|
|
421
|
+
:meth:`set_llm_provider`.
|
|
422
|
+
"""
|
|
423
|
+
cfg = self.get_app_config()
|
|
424
|
+
for provider_name, provider in cfg.llm_providers.items():
|
|
425
|
+
if provider.is_default:
|
|
426
|
+
return provider_name
|
|
427
|
+
return None
|