From 421a664336ae53134ef079cbb642055a41e0ef3e Mon Sep 17 00:00:00 2001 From: m4 Date: Tue, 21 Jul 2026 18:10:23 +0800 Subject: [PATCH] feat(runtime)!: complete legacy removal, local snapshot entries, and TTL cleanup - Remove legacy provider profiles, admin-token auth, /model command, model picker widget, and config.yaml LLM fields (design doc section 10) - Wire CLI/channels/cron and async sub-agents through the local snapshot entry; run creation rejects model config outside runtime_snapshot_id - Add periodic run-snapshot TTL cleanup to the config service lifespan - Isolate tests from the real config dir and activate the registry where run/model paths fail closed in bootstrap Co-Authored-By: Claude Opus 4.7 --- EvoScientist/EvoScientist.py | 135 +- EvoScientist/__init__.py | 5 - EvoScientist/cli/_agent_loader.py | 9 +- EvoScientist/cli/agent.py | 17 + EvoScientist/cli/channel.py | 2 +- EvoScientist/cli/commands.py | 156 +-- EvoScientist/cli/interactive.py | 18 +- EvoScientist/cli/rich_command_ui.py | 35 - EvoScientist/cli/tui_interactive.py | 112 +- EvoScientist/cli/widgets/model_picker.py | 390 ------ EvoScientist/commands/_completion_engine.py | 2 +- EvoScientist/commands/base.py | 6 - .../commands/implementation/__init__.py | 2 - EvoScientist/commands/implementation/model.py | 204 --- EvoScientist/config/__init__.py | 19 - EvoScientist/config/legacy_artifacts.py | 132 ++ EvoScientist/config/onboard/__init__.py | 5 +- EvoScientist/config/onboard/constants.py | 30 +- EvoScientist/config/onboard/helpers.py | 199 +-- EvoScientist/config/onboard/prompter.py | 33 +- EvoScientist/config/onboard/steps.py | 638 +--------- EvoScientist/config/onboard/validators.py | 416 +----- EvoScientist/config/onboard/wizard.py | 405 +----- EvoScientist/config/provider_admin.py | 70 - EvoScientist/config/provider_profiles.py | 831 ------------ EvoScientist/config/settings.py | 127 +- EvoScientist/deploy/server.py | 9 +- EvoScientist/deploy/webui.py | 12 - EvoScientist/gateway/local.py | 20 + EvoScientist/gateway/server.py | 11 + EvoScientist/langgraph_dev/http.py | 847 +----------- EvoScientist/llm/__init__.py | 30 +- EvoScientist/llm/models.py | 1132 ----------------- EvoScientist/llm/ollama_discovery.py | 2 +- EvoScientist/llm/patches.py | 25 +- EvoScientist/llm/provider_operations.py | 519 -------- EvoScientist/llm/runtime_snapshots.py | 401 ------ EvoScientist/middleware/__init__.py | 2 - EvoScientist/middleware/background.py | 10 +- EvoScientist/middleware/configurable_model.py | 102 +- EvoScientist/middleware/message_budget.py | 202 +-- EvoScientist/model_registry/runtime.py | 42 + .../scripts/improve_description.py | 31 +- .../skills/skill-creator/scripts/run_eval.py | 65 +- .../skills/skill-creator/scripts/run_loop.py | 9 +- EvoScientist/stream/events.py | 6 + EvoScientist/subagents/_factory.py | 49 +- tests/conftest.py | 24 + tests/fakes.py | 8 - tests/test_ask_user.py | 9 +- tests/test_async_subagent_factory.py | 67 +- tests/test_auxiliary_model.py | 29 +- tests/test_cli_completion.py | 20 +- tests/test_cli_serve.py | 9 + tests/test_cli_startup_gates.py | 72 ++ tests/test_cli_tui_dispatch.py | 6 +- tests/test_config.py | 308 ++--- tests/test_configurable_model_middleware.py | 112 +- tests/test_context_editing_middleware.py | 6 +- tests/test_graph_gateway.py | 23 +- tests/test_langgraph_dev_http.py | 687 +--------- tests/test_legacy_artifacts.py | 97 ++ tests/test_llm.py | 1080 +--------------- tests/test_message_budget.py | 240 +++- tests/test_minimax_integration.py | 51 - tests/test_model_command.py | 941 -------------- tests/test_model_passthrough_patch.py | 35 +- tests/test_onboard.py | 629 +-------- tests/test_provider_admin.py | 42 - tests/test_provider_operations.py | 280 ---- tests/test_provider_profiles.py | 485 ------- tests/test_rich_command_ui.py | 71 +- tests/test_runtime_context_middleware.py | 3 +- tests/test_runtime_snapshots.py | 152 --- tests/test_snapshot_runtime.py | 31 + tests/test_tool_selector_middleware.py | 6 +- tests/test_tui_banner_position.py | 7 + tests/test_tui_command_sync.py | 14 +- tests/test_tui_widgets.py | 273 ---- tests/test_workspace_scope_http.py | 7 + uv.lock | 2 + 湘江实验室科研项目指南建议征集表.docx | Bin 0 -> 298960 bytes 82 files changed, 1563 insertions(+), 11787 deletions(-) delete mode 100644 EvoScientist/cli/widgets/model_picker.py delete mode 100644 EvoScientist/commands/implementation/model.py create mode 100644 EvoScientist/config/legacy_artifacts.py delete mode 100644 EvoScientist/config/provider_admin.py delete mode 100644 EvoScientist/config/provider_profiles.py delete mode 100644 EvoScientist/llm/models.py delete mode 100644 EvoScientist/llm/provider_operations.py delete mode 100644 EvoScientist/llm/runtime_snapshots.py create mode 100644 tests/test_cli_startup_gates.py create mode 100644 tests/test_legacy_artifacts.py delete mode 100644 tests/test_minimax_integration.py delete mode 100644 tests/test_model_command.py delete mode 100644 tests/test_provider_admin.py delete mode 100644 tests/test_provider_operations.py delete mode 100644 tests/test_provider_profiles.py delete mode 100644 tests/test_runtime_snapshots.py create mode 100644 湘江实验室科研项目指南建议征集表.docx diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 9210437..f928208 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -55,12 +55,11 @@ DEFAULT_SKILL_SOURCES = ("/skills/",) _config = None _chat_model = None -# Track the (model, provider) binding of _chat_model so cache invalidates -# when config.model/provider change (e.g. via /model). Without this, -# _ensure_chat_model() returns the stale cached instance even after -# _ensure_config(new_cfg) has overwritten the active config — causing -# /model switch to lag one step (see issue #179). -_chat_model_key: tuple[str | None, str | None] | None = None +# Track the (provider_id, model_key, registry_revision) binding of +# _chat_model so the cache invalidates when the registry defaults change. +# The compile-time binding is only a placeholder — per-run resolution +# always comes from the run snapshot via ConfigurableModelMiddleware. +_chat_model_key: tuple[str, str, int] | None = None # Auxiliary model for background/helper LLM calls (memory workers + main-agent # tool selector). Cached separately from the main model; resolved through the @@ -89,10 +88,10 @@ _EvoScientist_agent = None def set_active_config(cfg) -> None: """Commit *cfg* as the active module config. - Public commit path for callers (e.g. ``/model``) that built an agent on - the pure ``create_cli_agent(config=..., chat_model=...)`` path and now - want it to become the session-wide active config. This is the write half - of ``_ensure_config(cfg)`` extracted so the pure path can defer the commit + Public commit path for callers that built an agent on the pure + ``create_cli_agent(config=..., chat_model=...)`` path and now want it + to become the session-wide active config. This is the write half of + ``_ensure_config(cfg)`` extracted so the pure path can defer the commit until the agent has been built successfully. """ global _config @@ -119,25 +118,11 @@ def _ensure_config(config=None): return _config -def _build_chat_model(cfg): - """Build a chat model from *cfg* without writing any module globals. - - Pure-construction counterpart to ``_ensure_chat_model``: used by ``/model`` - to verify a switch before committing, and threaded into - ``create_cli_agent(chat_model=...)`` so the new agent binds the requested - model without touching the cached ``_chat_model``. - """ - from .llm import get_chat_model - - return get_chat_model(model=cfg.model, provider=cfg.provider) - - -def _replace_chat_model(instance, key: tuple[str | None, str | None]) -> None: +def _replace_chat_model(instance, key: tuple[str, str, int]) -> None: """Install a new chat model and propagate the related invariants. Single write point for ``_chat_model`` / ``_chat_model_key`` / - ``_EvoScientist_agent``: both ``_ensure_chat_model`` (cache-miss - rebuild) and ``set_chat_model`` (explicit switch via ``/model``) + ``_EvoScientist_agent``: ``_ensure_chat_model`` cache-miss rebuilds funnel through here so the three globals can never drift. """ global _chat_model, _chat_model_key, _EvoScientist_agent @@ -149,18 +134,24 @@ def _replace_chat_model(instance, key: tuple[str | None, str | None]) -> None: def _ensure_chat_model(): - """Return cached chat model, rebuilding if cfg.model/provider changed. + """Return the compile-time main chat model, built from registry defaults. - The cache key is the current config's ``(model, provider)``. If it - differs from the key that built ``_chat_model``, rebuild — this makes - ``create_cli_agent(config=temp_cfg)`` bind the freshly requested model - into the new agent without requiring callers to interleave - ``set_chat_model()`` calls in any particular order. + The model is resolved from the active registry's ``defaults.primary`` + and cached under its ``(provider_id, model_key, registry_revision)`` + key. It is only the compile-time placeholder: every run re-resolves its + model from the run snapshot via ``ConfigurableModelMiddleware``. + + Raises: + ModelRegistryError: ``MODEL_REGISTRY_NOT_READY`` when the registry + is still in bootstrap (no enabled primary model configured). """ - cfg = _ensure_config() - key = (cfg.model, cfg.provider) + from .model_registry.runtime import get_snapshot_runtime + + runtime = get_snapshot_runtime() + primary_ref, _, revision = runtime.registry_defaults() + key = (primary_ref.provider_id, primary_ref.model_key, revision) if _chat_model is None or _chat_model_key != key: - _replace_chat_model(_build_chat_model(cfg), key) + _replace_chat_model(runtime.build_default_role_model("primary"), key) return _chat_model @@ -170,26 +161,19 @@ def _ensure_auxiliary_chat_model(): Resolves the ``auxiliary`` role through the model registry role mapping (design doc 6.1): ``registry.defaults.auxiliary ?? defaults.primary``, constructed via ``build_chat_model`` from the resolved configuration. - The legacy ``cfg.auxiliary_model``/``cfg.auxiliary_provider`` free - strings are no longer consulted. - When the registry is still in bootstrap (the CLI local snapshot entry - point is wired separately) or no auxiliary default is configured — or it - matches the primary default — returns the main ``_ensure_chat_model()`` - instance directly, so no second client is built. Otherwise the model is - cached under its ``(provider_id, model_key, registry_revision)`` key. + When no auxiliary default is configured — or it matches the primary + default — returns the main ``_ensure_chat_model()`` instance directly, + so no second client is built. Otherwise the model is cached under its + ``(provider_id, model_key, registry_revision)`` key. A bootstrap + registry propagates ``MODEL_REGISTRY_NOT_READY`` from + ``_ensure_chat_model()``. """ global _auxiliary_chat_model, _auxiliary_chat_model_key - from .model_registry.errors import MODEL_REGISTRY_NOT_READY, ModelRegistryError from .model_registry.runtime import get_snapshot_runtime runtime = get_snapshot_runtime() - try: - primary_ref, auxiliary_ref, revision = runtime.registry_defaults() - except ModelRegistryError as exc: - if exc.code == MODEL_REGISTRY_NOT_READY: - return _ensure_chat_model() - raise + primary_ref, auxiliary_ref, revision = runtime.registry_defaults() if auxiliary_ref is None or auxiliary_ref == primary_ref: return _ensure_chat_model() key: tuple[str, str, int] = ( @@ -203,43 +187,6 @@ def _ensure_auxiliary_chat_model(): return _auxiliary_chat_model -def set_chat_model(model: str, provider: str | None = None): - """Replace the cached chat model with a new one. - - Called by ``/model`` to switch the LLM mid-session. No-op when the - cache already holds the requested ``(model, provider)`` — avoids - spawning a second ``get_chat_model`` instance (and its HTTP client) - under the ``/model`` flow where ``_ensure_chat_model`` has already - rebuilt ``_chat_model`` during the preceding ``_load_agent`` call. - Returns the current chat model instance. - """ - from .llm import get_chat_model - - # Invalidate the auxiliary cache too: when the registry has no distinct - # auxiliary default the auxiliary model mirrors the main one, so a /model - # switch must let it re-resolve to the new main. - global _auxiliary_chat_model, _auxiliary_chat_model_key - _auxiliary_chat_model = None - _auxiliary_chat_model_key = None - - key = (model, provider) - if _chat_model is None or _chat_model_key != key: - _replace_chat_model(get_chat_model(model=model, provider=provider), key) - return _chat_model - - -def set_chat_model_instance(instance, key: tuple[str | None, str | None]) -> None: - """Commit an already-built chat model *instance* as the active model. - - Companion to ``set_active_config`` for the pure path: installs a model that - ``_build_chat_model`` already constructed (e.g. during a ``/model`` verify) - without rebuilding it, keeping ``_chat_model`` / ``_chat_model_key`` / - ``_EvoScientist_agent`` in sync via ``_replace_chat_model``. Unlike - ``set_chat_model``, the caller owns the ``(model, provider)`` *key*. - """ - _replace_chat_model(instance, key) - - # ============================================================================= # MCP caching # ============================================================================= @@ -781,13 +728,13 @@ def _get_default_middleware( elif chat_model is None: tool_selector_model = _ensure_auxiliary_chat_model() else: - # Pure path (explicit model + config): no free-string auxiliary - # resolution — the threaded model stands in until the local snapshot - # entry point is wired (Task 7). + # Pure path (explicit model + config): the threaded model stands in + # for tool selection at build time; per-call resolution still comes + # from the run snapshot. tool_selector_model = model mw = [ ConfigurableModelMiddleware(role=snapshot_role), - create_message_budget_middleware(model, backend), + create_message_budget_middleware(model, backend, snapshot_role=snapshot_role), create_context_editing_middleware(model), ContextOverflowMapperMiddleware(), ToolErrorHandlerMiddleware(), @@ -948,10 +895,10 @@ def create_cli_agent( **Pure path:** when *both* ``config`` and ``chat_model`` are explicit, this writes none of the cached config/model module globals (``_config``, ``_chat_model``, ``_chat_model_key``, ``_EvoScientist_agent``) — the agent - is built purely from the passed-in locals. The caller commits the switch - on success via ``set_active_config`` / ``set_chat_model_instance`` (see - ``/model``). Otherwise the existing module-global path runs (langgraph - dev, notebooks, and CLI startup, which pass ``config=`` only). + is built purely from the passed-in locals. The caller commits the config + on success via ``set_active_config``. Otherwise the existing + module-global path runs (langgraph dev, notebooks, and CLI startup, which + pass ``config=`` only). Args: workspace_dir: Per-session workspace directory. If ``None``, diff --git a/EvoScientist/__init__.py b/EvoScientist/__init__.py index cabee0a..9e8e1b3 100644 --- a/EvoScientist/__init__.py +++ b/EvoScientist/__init__.py @@ -23,11 +23,6 @@ _EXPORTS: dict[str, tuple[str, str]] = { "save_config": (".config", "save_config"), "get_effective_config": (".config", "get_effective_config"), "get_config_path": (".config", "get_config_path"), - # LLM - "get_chat_model": (".llm", "get_chat_model"), - "MODELS": (".llm", "MODELS"), - "list_models": (".llm", "list_models"), - "DEFAULT_MODEL": (".llm", "DEFAULT_MODEL"), # Prompts "get_system_prompt": (".prompts", "get_system_prompt"), # Tools diff --git a/EvoScientist/cli/_agent_loader.py b/EvoScientist/cli/_agent_loader.py index d7256df..bfd19b6 100644 --- a/EvoScientist/cli/_agent_loader.py +++ b/EvoScientist/cli/_agent_loader.py @@ -150,11 +150,10 @@ class BackgroundAgentLoader(Generic[AgentT]): def adopt(self, agent: AgentT) -> None: """Install an externally-built agent and supersede any in-flight load. - Used by ``/model`` (and any other caller that constructs a - replacement agent directly): bumps the generation token so a - late-arriving background load can't clobber ``self.agent`` via - the done-callback, cancels the in-flight wrapper, and seats the - new agent immediately. + Used by any caller that constructs a replacement agent directly: + bumps the generation token so a late-arriving background load can't + clobber ``self.agent`` via the done-callback, cancels the in-flight + wrapper, and seats the new agent immediately. """ prev = self._task if prev is not None and not prev.done(): diff --git a/EvoScientist/cli/agent.py b/EvoScientist/cli/agent.py index ce12fd4..f8f42b8 100644 --- a/EvoScientist/cli/agent.py +++ b/EvoScientist/cli/agent.py @@ -62,6 +62,23 @@ def _create_session_workspace(name: str | None = None) -> str: return workspace_dir +def current_model_label() -> str: + """Return the display label for the registry's primary default model. + + The CLI no longer carries ``config.model``/``config.provider`` free + strings; the status bar and run metadata label the active model as + ``provider_id/model_key`` from the Model Registry defaults. Returns + ``"unconfigured"`` when the registry is still in bootstrap. + """ + try: + from ..model_registry.runtime import get_snapshot_runtime + + primary, _, _ = get_snapshot_runtime().registry_defaults() + except Exception: + return "unconfigured" + return f"{primary.provider_id}/{primary.model_key}" + + def _load_agent( workspace_dir: str | None = None, checkpointer=None, diff --git a/EvoScientist/cli/channel.py b/EvoScientist/cli/channel.py index 67ae4fc..8019913 100644 --- a/EvoScientist/cli/channel.py +++ b/EvoScientist/cli/channel.py @@ -306,7 +306,7 @@ async def dispatch_channel_slash_command( resolution, or the dispatcher's input agent when no resolver is supplied. Callers can compare ``ctx.agent`` with ``original_agent`` to detect command-driven swaps. Used by Rich - CLI to (a) adopt an agent swap (``/model``) back into the + CLI to (a) adopt an agent swap back into the running session and (b) refresh the status snapshot for commands that mutate session-level state (``/new``, ``/compact``) — mirrors the REPL dispatch at diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py index 2b95042..3509129 100644 --- a/EvoScientist/cli/commands.py +++ b/EvoScientist/cli/commands.py @@ -74,16 +74,9 @@ if TYPE_CHECKING: @app.command() def onboard( skip_validation: bool = typer.Option( - False, "--skip-validation", help="Skip API key validation during setup" + False, "--skip-validation", help="Skip Tavily key validation during setup" ), # ---- Pre-fill answers (any subset; remaining prompts stay interactive) - provider: str | None = typer.Option( - None, "--provider", help="Pre-set LLM provider (e.g. anthropic, openai)" - ), - model: str | None = typer.Option(None, "--model", help="Pre-set model name"), - api_key: str | None = typer.Option( - None, "--api-key", help="Pre-set API key for the chosen --provider" - ), tavily_key: str | None = typer.Option( None, "--tavily-key", help="Pre-set Tavily API key" ), @@ -120,16 +113,16 @@ def onboard( ): """Interactive setup wizard for EvoScientist. - Guides you through configuring API keys, model selection, - workspace settings, and agent parameters. + Guides you through workspace settings, channels, and agent parameters. + Models and provider credentials are configured through the Model + Registry (WebUI configuration page or the model-registry API), not + here. - Any answer can be pre-set via a flag (``--provider anthropic - --model claude-sonnet-4-6 ...``); prompts for unset answers stay + Any answer can be pre-set via a flag; prompts for unset answers stay interactive unless ``--non-interactive`` is passed, in which case any missing required answer aborts the wizard. """ from ..config.onboard.constants import ( - VALID_PROVIDERS, VALID_UI_BACKENDS, VALID_WORKSPACE_MODES, ) @@ -149,11 +142,6 @@ def onboard( f"--workspace-mode must be one of {sorted(VALID_WORKSPACE_MODES)}", param_hint="--workspace-mode", ) - if provider is not None and provider not in VALID_PROVIDERS: - raise typer.BadParameter( - f"--provider must be one of {sorted(VALID_PROVIDERS)}", - param_hint="--provider", - ) # Match the interactive prompt's range (1024 < port < 65536). Without # this check, --port 80 or --port 99999 would land in config and break # the langgraph dev server on startup. @@ -169,12 +157,6 @@ def onboard( answers["ui"] = ui if port is not None: answers["port"] = str(port) - if provider is not None: - answers["provider"] = provider - if model is not None: - answers["model"] = model - if api_key is not None: - answers["api_key"] = api_key if tavily_key is not None: answers["tavily_key"] = tavily_key if workspace_mode is not None: @@ -212,8 +194,6 @@ def onboard( _CONFIGURE_SECTIONS = { "ui": "UI backend", "port": "LangGraph server port", - "provider": "LLM provider + auth + API key", - "model": "Model + reasoning effort", "tavily": "Tavily search key", "workspace": "Workspace mode", "thinking": "Thinking panel", @@ -263,29 +243,6 @@ def configure_port(): _configure_section("port") -@configure_app.command("provider") -def configure_provider( - skip_validation: bool = typer.Option(False, "--skip-validation"), -): - """Re-run LLM provider, auth mode, and API key prompts. - - Model selection is automatically re-run after provider — the model list - depends on the provider, and silently leaving e.g. ``model="claude-...""`` - when the provider was switched to ``openai`` would break the first - request. Press Enter on the model picker to keep the current default. - """ - _run_onboard_cli( - skip_validation=skip_validation, - only_sections={"provider", "model"}, - ) - - -@configure_app.command("model") -def configure_model(): - """Re-run model selection (and reasoning effort for OpenRouter).""" - _configure_section("model") - - @configure_app.command("tavily") def configure_tavily( skip_validation: bool = typer.Option(False, "--skip-validation"), @@ -1022,7 +979,7 @@ def _make_serve_cmd_completed_hook( ): """Build the ``on_cmd_completed`` hook used by serve mode. - Adopts ``/model`` agent swaps and ``/resume`` thread/workspace + Adopts command-driven agent swaps and ``/resume`` thread/workspace swaps back into ``runtime_state`` so the outer poll loop picks up the new handles on subsequent messages. Also keeps ``channel_runtime`` in sync so the bus sees the new values. @@ -1389,6 +1346,47 @@ def _serve_drain_notifications( _notif_loop.close() +def _startup_gates() -> None: + """Refuse agent startup on legacy artifacts or a bootstrap registry. + + Design doc section 10 step 4 and section 8.1: + + 1. Legacy model-configuration artifacts (old ``providers.yaml``, old + ``run-runtime-snapshots.sqlite3``, leftover LLM fields in + ``config.yaml``) abort startup with an explicit reset guide — + nothing is read partially. + 2. A bootstrap registry aborts startup with + ``MODEL_REGISTRY_NOT_READY`` — there is no implicit default model + anymore; configure and enable a primary model through the Model + Registry first. + """ + from ..config.legacy_artifacts import ( + LegacyArtifactsError, + assert_no_legacy_artifacts, + ) + from ..model_registry.errors import MODEL_REGISTRY_NOT_READY, ModelRegistryError + + try: + assert_no_legacy_artifacts() + except LegacyArtifactsError as exc: + console.print(f"[red]{exc}[/red]") + raise typer.Exit(1) from exc + try: + from ..model_registry.runtime import get_snapshot_runtime + + get_snapshot_runtime().registry_defaults() + except ModelRegistryError as exc: + if exc.code != MODEL_REGISTRY_NOT_READY: + raise + console.print( + f"[red]{exc.code}: {exc}[/red]\n" + "Configure a provider/model through the Model Registry (WebUI " + "configuration page or the model-registry API), run the provider " + "test, enable the model, and set it as the primary default." + ) + raise typer.Exit(1) from exc + + @app.command() def serve( no_thinking: bool = typer.Option( @@ -1452,20 +1450,7 @@ def serve( if debug: _configure_logging() - # Auto-start ccproxy if any provider uses OAuth mode - _ccproxy_proc_serve = None - if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth": - try: - from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy - - _ccproxy_proc_serve = maybe_start_ccproxy(config) - if _ccproxy_proc_serve: - import atexit - - atexit.register(stop_ccproxy, _ccproxy_proc_serve) - except RuntimeError as exc: - console.print(f"[red]{exc}[/red]") - raise typer.Exit(1) from exc + _startup_gates() if not config.channel_enabled: console.print("[red]No channels configured.[/red]") @@ -1565,6 +1550,9 @@ def serve( _orig_sigterm = signal.signal(signal.SIGTERM, _handle_shutdown) try: + from .agent import current_model_label + + model_label = current_model_label() while not shutdown_event.is_set(): try: msg = _message_queue.get(timeout=0.5) @@ -1577,7 +1565,7 @@ def serve( _serve_process_message( msg, runtime_state=runtime_state, - model=config.model, + model=model_label, workspace_dir=ws, show_thinking=effective_channel_thinking, on_cmd_completed=_serve_on_cmd_completed, @@ -1593,7 +1581,7 @@ def serve( if async_notifier.has_pending_notifications(runtime_state.thread_id): _serve_drain_notifications( runtime_state=runtime_state, - model=config.model, + model=model_label, workspace_dir=ws, show_thinking=effective_channel_thinking, ) @@ -2084,11 +2072,6 @@ def _main_callback( "--dangerous", help="DANGEROUS: real-filesystem access (no workspace confinement); implies --auto-approve", ), - auth_mode: str | None = typer.Option( - None, - "--auth-mode", - help="Auth mode for Anthropic/OpenAI: api_key (default) or oauth (ccproxy).", - ), ui: str | None = typer.Option( None, "--ui", @@ -2168,29 +2151,11 @@ def _main_callback( cli_overrides["enable_ask_user"] = True if dangerous: cli_overrides["dangerous_mode"] = True - if auth_mode: - if auth_mode not in ("api_key", "oauth"): - raise typer.BadParameter("--auth-mode must be 'api_key' or 'oauth'") - cli_overrides["anthropic_auth_mode"] = auth_mode - cli_overrides["openai_auth_mode"] = auth_mode config = get_effective_config(cli_overrides) apply_config_to_env(config) - # Auto-start ccproxy if any provider uses OAuth mode - _ccproxy_proc = None - if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth": - try: - from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy - - _ccproxy_proc = maybe_start_ccproxy(config) - if _ccproxy_proc: - import atexit - - atexit.register(stop_ccproxy, _ccproxy_proc) - except RuntimeError as exc: - console.print(f"[red]{exc}[/red]") - raise typer.Exit(1) from exc + _startup_gates() show_thinking = config.show_thinking if not no_thinking else False effective_channel_thinking = config.channel_send_thinking and (not no_thinking) @@ -2356,6 +2321,9 @@ def _main_callback( config=config, ) try: + from .agent import current_model_label + + model_label = current_model_label() if effective_output_format == "stream-json": # Headless JSONL path: drive the sink through the gateway # directly. We are already inside the async single-shot @@ -2364,7 +2332,7 @@ def _main_callback( request = RunRequest( message=prompt, thread_id=tid, - metadata=build_metadata(workspace_dir, config.model), + metadata=build_metadata(workspace_dir, model_label), target=GraphTarget( local_graph=agent, workspace_dir=workspace_dir ), @@ -2388,7 +2356,7 @@ def _main_callback( thread_id=tid, show_thinking=show_thinking, workspace_dir=workspace_dir, - model=config.model, + model=model_label, ui_backend=config.ui_backend, runtime_gateways=runtime_gateways, ) @@ -2403,6 +2371,7 @@ def _main_callback( nest_asyncio.apply() asyncio.get_event_loop().run_until_complete(_single_shot()) else: + from .agent import current_model_label from .interactive import cmd_interactive # Interactive mode (default) — checkpointer managed inside cmd_interactive @@ -2412,8 +2381,7 @@ def _main_callback( workspace_dir=workspace_dir, workspace_fixed=workspace_fixed, mode=effective_mode, - model=config.model, - provider=config.provider, + model=current_model_label(), run_name=name, thread_id=thread_id, ui_backend=config.ui_backend, diff --git a/EvoScientist/cli/interactive.py b/EvoScientist/cli/interactive.py index a79c050..23bc7b0 100644 --- a/EvoScientist/cli/interactive.py +++ b/EvoScientist/cli/interactive.py @@ -961,7 +961,7 @@ def cmd_interactive( ctx: CommandContext, original_agent: Any, cmd: Command ) -> None: """Mirror the REPL adoption block at - ``interactive.py:1005-1030`` so ``/model`` and similar + ``interactive.py:1005-1030`` so agent-swap and similar state-mutating commands invoked via a channel actually rebind the running session and keep the status bar in sync.""" @@ -970,11 +970,10 @@ def cmd_interactive( ctx.agent is not None and ctx.agent is not original_agent ) if agent_swapped: - from ..EvoScientist import _ensure_config + from .agent import current_model_label agent_loader.adopt(ctx.agent) - cfg = _ensure_config() - model = cfg.model + model = current_model_label() state["status_base_snapshot"] = make_empty_status_snapshot( model ) @@ -1326,7 +1325,7 @@ def cmd_interactive( if not state["running"]: break - # Agent swap (e.g. /model successfully built a + # Agent swap (a command successfully built a # new agent): adopt into loader + reset status # snapshot + sync channel runtime. agent_swapped = ( @@ -1334,11 +1333,10 @@ def cmd_interactive( and ctx.agent is not _agent_for_ctx ) if agent_swapped: - from ..EvoScientist import _ensure_config + from .agent import current_model_label agent_loader.adopt(ctx.agent) - cfg = _ensure_config() - model = cfg.model + model = current_model_label() state["status_base_snapshot"] = ( make_empty_status_snapshot(model) ) @@ -1361,8 +1359,8 @@ def cmd_interactive( # Commands that mutate status fields need an # async refresh here (/compact + /new use sync - # callbacks; /model swaps the agent). /resume - # awaits its own refresh inline inside the + # callbacks; an agent swap rebuilds the snapshot). + # /resume awaits its own refresh inline inside the # async callback. if agent_swapped or _cmd.name in ("/compact", "/new"): await _refresh_status_snapshot( diff --git a/EvoScientist/cli/rich_command_ui.py b/EvoScientist/cli/rich_command_ui.py index 26c5056..72fc89e 100644 --- a/EvoScientist/cli/rich_command_ui.py +++ b/EvoScientist/cli/rich_command_ui.py @@ -19,7 +19,6 @@ from collections.abc import Awaitable, Callable from typing import Any from rich.console import Console -from rich.table import Table from ..commands.base import CommandUI @@ -73,40 +72,6 @@ class RichCLICommandUI(CommandUI): # Rich console flushes synchronously; nothing to await. return - # ── /model interactive picker fallback ────────────────── - - async def wait_for_model_pick( - self, - entries: list[tuple[str, str, str]], - current_model: str | None, - current_provider: str | None, - ) -> tuple[str, str] | None: - """Print the model table and return ``None``; user re-runs with - ``/model `` since the CLI has no interactive picker.""" - table = Table( - title="Available Models", - show_header=True, - header_style="bold cyan", - ) - table.add_column("Name", style="bold") - table.add_column("Provider", style="dim") - for name, _mid, prov in entries: - marker = " *" if name == current_model and prov == current_provider else "" - table.add_row(f"{name}{marker}", prov) - self.console.print(table) - self.console.print( - "[dim]Usage: /model [provider] [--save] — " - "provider is optional, auto-detected from model name[/dim]" - ) - return None - - def update_status_after_model_change( - self, new_model: str, new_provider: str | None = None - ) -> None: - """No-op; the CLI REPL refreshes status itself after detecting an - ``ctx.agent`` change post-``cmd_manager.execute``.""" - return - # ── Interactive pickers ──────────────────────────────── async def wait_for_thread_pick( diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 49767ab..f4e0fd4 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -222,13 +222,12 @@ async def _sync_tui_command_completion( """Adopt successful command-side state changes back into the TUI app.""" agent_swapped = ctx.agent is not None and ctx.agent is not original_agent if agent_swapped: - from ..EvoScientist import _ensure_config + from .agent import current_model_label app._agent_loader.adopt(ctx.agent) - cfg = _ensure_config() update_model = getattr(app, "update_status_after_model_change", None) if callable(update_model): - update_model(cfg.model, cfg.provider) + update_model(current_model_label()) # Rebind the runtime whenever the agent OR thread_id may have moved # — ``/new`` and ``/resume`` rotate ``app._conversation_tid`` @@ -456,7 +455,6 @@ def run_textual_interactive( self._picker_future: asyncio.Future | None = None self._browser_future: asyncio.Future | None = None self._mcp_browser_future: asyncio.Future | None = None - self._model_picker_future: asyncio.Future | None = None self._history_suggester = HistorySuggester(DATA_DIR / "history") self._history_index: int = -1 # -1 = not browsing history self._history_saved_input: str = "" # saved current input before browsing @@ -627,26 +625,6 @@ def run_textual_interactive( return await self._wait_for_mcp_browse(browser) - async def wait_for_model_pick( - self, - entries: list[tuple[str, str, str]], - current_model: str | None, - current_provider: str | None, - ) -> tuple[str, str] | None: - from .widgets.model_picker import ModelPickerWidget - - container = self.query_one("#chat", VerticalScroll) - picker = ModelPickerWidget( - entries, - current_model=current_model, - current_provider=current_provider, - ) - await container.mount(picker) - self._anchor_chat(container) - picker.focus() - - return await self._wait_for_model_pick(picker) - def clear_chat(self) -> None: container = self.query_one("#chat", VerticalScroll) welcome = self.query_one("#welcome", Static) @@ -1243,34 +1221,6 @@ def run_textual_interactive( if self._mcp_browser_future and not self._mcp_browser_future.done(): self._mcp_browser_future.set_result(None) - async def _wait_for_model_pick(self, picker_widget) -> tuple[str, str] | None: - """Wait for user to pick a model from ModelPickerWidget. - - Returns ``(name, provider)`` or ``None`` on cancel/timeout. - """ - self._model_picker_future = asyncio.get_event_loop().create_future() - try: - return await asyncio.wait_for(self._model_picker_future, timeout=120) - except (TimeoutError, asyncio.CancelledError): - return None - finally: - self._model_picker_future = None - try: - picker_widget.remove() - except Exception: - _channel_logger.debug("model picker cleanup failed", exc_info=True) - self.query_one("#prompt", ChatTextArea).focus() - - def on_model_picker_widget_picked(self, event) -> None: # type: ignore[override] - """Handle ModelPickerWidget.Picked message.""" - if self._model_picker_future and not self._model_picker_future.done(): - self._model_picker_future.set_result((event.name, event.provider)) - - def on_model_picker_widget_cancelled(self, event) -> None: # type: ignore[override] - """Handle ModelPickerWidget.Cancelled message.""" - if self._model_picker_future and not self._model_picker_future.done(): - self._model_picker_future.set_result(None) - # ── Streaming core ───────────────────────────────────── async def _stream_with_widgets( @@ -2489,7 +2439,6 @@ def run_textual_interactive( if focused is not None: from .widgets.approval_widget import ApprovalWidget from .widgets.mcp_browser import MCPBrowserWidget - from .widgets.model_picker import ModelPickerWidget from .widgets.skill_browser import SkillBrowserWidget from .widgets.thread_selector import ThreadPickerWidget @@ -2505,33 +2454,6 @@ def run_textual_interactive( if isinstance(focused, MCPBrowserWidget): focused.action_cancel() return - # ModelPickerWidget: when in "Custom Ollama" input mode, its - # child Input widget owns focus, so ``focused`` isn't the - # picker itself. Walk the parent chain to find it, then let - # the widget's own action_cancel decide whether to close the - # picker (list mode) or just exit input mode. - picker: ModelPickerWidget | None = None - if isinstance(focused, ModelPickerWidget): - picker = focused - else: - node = focused.parent - while node is not None and not isinstance(node, ModelPickerWidget): - node = node.parent - picker = node - if picker is not None: - prev_mode = getattr(picker, "_mode", "list") - picker.action_cancel() - # In list mode action_cancel posted Cancelled; resolve the - # future immediately to avoid a frame of lag. In input - # mode action_cancel flipped back to list — keep picker - # open, do NOT close the future. - if prev_mode == "list": - if ( - self._model_picker_future - and not self._model_picker_future.done() - ): - self._model_picker_future.set_result(None) - return if self._queued_messages: self._queued_messages.pop() self._render_queue_indicator() @@ -2555,7 +2477,6 @@ def run_textual_interactive( from .widgets.approval_widget import ApprovalWidget from .widgets.ask_user_widget import AskUserWidget from .widgets.mcp_browser import MCPBrowserWidget - from .widgets.model_picker import ModelPickerWidget from .widgets.skill_browser import SkillBrowserWidget from .widgets.thread_selector import ThreadPickerWidget @@ -2574,20 +2495,6 @@ def run_textual_interactive( if isinstance(focused, MCPBrowserWidget): focused.action_move_up() return - # ModelPickerWidget: Up from the Custom Ollama Input child - # must reach the picker (to exit input mode). See the Esc - # handler above for the parent-walk rationale. - picker_up: ModelPickerWidget | None = None - if isinstance(focused, ModelPickerWidget): - picker_up = focused - else: - node = focused.parent - while node is not None and not isinstance(node, ModelPickerWidget): - node = node.parent - picker_up = node - if picker_up is not None: - picker_up.action_move_up() - return if self._queued_messages: last = self._queued_messages.pop() prompt = self.query_one("#prompt", ChatTextArea) @@ -2623,7 +2530,6 @@ def run_textual_interactive( from .widgets.approval_widget import ApprovalWidget from .widgets.ask_user_widget import AskUserWidget from .widgets.mcp_browser import MCPBrowserWidget - from .widgets.model_picker import ModelPickerWidget from .widgets.skill_browser import SkillBrowserWidget from .widgets.thread_selector import ThreadPickerWidget @@ -2642,18 +2548,6 @@ def run_textual_interactive( if isinstance(focused, MCPBrowserWidget): focused.action_move_down() return - # Same parent-walk rationale as action_edit_queued / cancel. - picker_down: ModelPickerWidget | None = None - if isinstance(focused, ModelPickerWidget): - picker_down = focused - else: - node = focused.parent - while node is not None and not isinstance(node, ModelPickerWidget): - node = node.parent - picker_down = node - if picker_down is not None: - picker_down.action_move_down() - return # History browsing (down key) if self._history_index >= 0: @@ -3069,7 +2963,7 @@ def run_textual_interactive( def update_status_after_model_change( self, new_model: str, new_provider: str | None = None ) -> None: - """Update the status bar and welcome banner after /model switches the LLM.""" + """Update the status bar and welcome banner after an agent swap.""" self._current_model = new_model if new_provider is not None: self._current_provider = new_provider diff --git a/EvoScientist/cli/widgets/model_picker.py b/EvoScientist/cli/widgets/model_picker.py deleted file mode 100644 index b7a44ee..0000000 --- a/EvoScientist/cli/widgets/model_picker.py +++ /dev/null @@ -1,390 +0,0 @@ -"""Inline model picker widget for /model command in TUI. - -Keyboard-driven widget mounted directly into the chat container. -Models are grouped by provider with a search/filter input. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, ClassVar, Literal - -from rich.text import Text -from textual.binding import Binding, BindingType -from textual.containers import Container -from textual.message import Message -from textual.widget import Widget -from textual.widgets import Input, Static - -if TYPE_CHECKING: - from textual import events - from textual.app import ComposeResult - - -# Sentinel ``model_id`` used for the "Custom Ollama model..." pseudo-row. -# Selecting this row switches the widget into free-text input mode instead -# of posting ``Picked`` — the user types a model name, Enter confirms. -_CUSTOM_OLLAMA_ID = "__custom_ollama__" - - -def _build_items( - entries: list[tuple[str, str, str]], - current_model: str | None = None, - current_provider: str | None = None, - filter_text: str = "", -) -> list[dict]: - """Build the flat item list rendered by ModelPickerWidget. - - Returns a list of:: - - {"type": "header", "label": str} - {"type": "model", "name": str, "model_id": str, "provider": str, "current": bool} - """ - # Apply filter. The Custom Ollama sentinel is the user's escape hatch - # when no local models match; it must remain visible regardless of filter. - if filter_text: - ft = filter_text.lower() - entries = [ - (n, mid, p) - for n, mid, p in entries - if mid == _CUSTOM_OLLAMA_ID or ft in n.lower() or ft in p.lower() - ] - - # Group by provider preserving order. Deduplicate the Custom Ollama - # sentinel defensively — if callers somehow pass two sentinel rows - # (state reuse, stale merges), collapse them into one to avoid - # rendering duplicate "Custom Ollama model..." rows in the picker. - groups: dict[str, list[tuple[str, str, str]]] = {} - seen_sentinel = False - for name, model_id, provider in entries: - if model_id == _CUSTOM_OLLAMA_ID: - if seen_sentinel: - continue - seen_sentinel = True - if provider not in groups: - groups[provider] = [] - groups[provider].append((name, model_id, provider)) - - items: list[dict] = [] - for provider, models in groups.items(): - items.append({"type": "header", "label": provider}) - for name, model_id, prov in models: - is_current = name == current_model and prov == current_provider - items.append( - { - "type": "model", - "name": name, - "model_id": model_id, - "provider": prov, - "current": is_current, - } - ) - return items - - -class ModelPickerWidget(Widget): - """Inline model picker -- mounts in chat, keyboard-driven. - - Posts ``Picked(name, provider)`` on Enter, ``Cancelled()`` on Esc. - Type to filter models. - """ - - can_focus = True - # Required so the Custom Ollama ``Input`` child can hold focus when the - # user is typing a model name. - can_focus_children = True - - DEFAULT_CSS = """ - ModelPickerWidget { - height: auto; - max-height: 30; - margin: 1 0; - padding: 0 1; - background: $surface; - border: solid $primary; - } - ModelPickerWidget .picker-custom-input { - height: 3; - margin: 1 0 0 0; - } - ModelPickerWidget .picker-title { - height: 1; - text-style: bold; - color: $primary; - } - ModelPickerWidget .picker-filter { - height: 1; - padding: 0 1; - color: $text; - } - ModelPickerWidget .picker-rows { - height: auto; - max-height: 22; - overflow-y: auto; - } - ModelPickerWidget .picker-header { - height: 1; - padding: 0 1; - margin-top: 1; - } - ModelPickerWidget .picker-row { - height: 1; - padding: 0 1; - } - ModelPickerWidget .picker-row-selected { - background: $primary; - text-style: bold; - } - ModelPickerWidget .picker-help { - height: 1; - color: $text-muted; - text-style: italic; - } - """ - - BINDINGS: ClassVar[list[BindingType]] = [ - Binding("up", "move_up", "Up", show=False), - Binding("down", "move_down", "Down", show=False), - Binding("enter", "select", "Select", show=False), - Binding("escape", "cancel", "Cancel", show=False), - Binding("backspace", "backspace", "Backspace", show=False), - ] - - class Picked(Message): - def __init__(self, name: str, provider: str) -> None: - super().__init__() - self.name = name - self.provider = provider - - class Cancelled(Message): - """Posted when user cancels selection.""" - - def __init__( - self, - entries: list[tuple[str, str, str]], - *, - current_model: str | None = None, - current_provider: str | None = None, - title: str = ">>> Select model <<<", - **kwargs: Any, - ) -> None: - super().__init__(**kwargs) - self._entries = entries - self._current_model = current_model - self._current_provider = current_provider - self._title = title - self._filter_text = "" - self._items = _build_items( - entries, - current_model=current_model, - current_provider=current_provider, - ) - self._selected = self._first_model_index() - self._row_widgets: list[Static] = [] - self._filter_widget: Static | None = None - # "list" = arrow-key selection over models; "input" = free-text entry - # for Custom Ollama model name. Transitions: selecting the sentinel - # row enters input mode; Esc or Up arrow inside input mode returns to - # list mode without closing the picker. - self._mode: Literal["list", "input"] = "list" - self._custom_input: Input | None = None - - def _first_model_index(self) -> int: - for i, item in enumerate(self._items): - if item["type"] == "model": - return i - return 0 - - def _move(self, direction: int) -> None: - if not self._items: - return - i = (self._selected + direction) % len(self._items) - steps = 0 - while self._items[i]["type"] != "model" and steps < len(self._items): - i = (i + direction) % len(self._items) - steps += 1 - if self._items[i]["type"] == "model": - self._selected = i - self._update_rows() - - def _rebuild(self) -> None: - """Rebuild items from filter and re-render.""" - self._items = _build_items( - self._entries, - current_model=self._current_model, - current_provider=self._current_provider, - filter_text=self._filter_text, - ) - self._selected = self._first_model_index() - # Re-mount rows - rows_container = self.query_one(".picker-rows", Container) - for w in list(rows_container.children): - w.remove() - self._row_widgets.clear() - for item in self._items: - css = "picker-header" if item["type"] == "header" else "picker-row" - widget = Static("", classes=css) - self._row_widgets.append(widget) - rows_container.mount(widget) - self._update_rows() - self._update_filter() - - def compose(self) -> ComposeResult: - yield Static(self._title, classes="picker-title") - self._filter_widget = Static("", classes="picker-filter") - yield self._filter_widget - with Container(classes="picker-rows"): - for item in self._items: - css = "picker-header" if item["type"] == "header" else "picker-row" - widget = Static("", classes=css) - self._row_widgets.append(widget) - yield widget - # Hidden until the user selects "Custom Ollama model..." \u2014 then shown - # and focused for free-text entry of an Ollama model name. - self._custom_input = Input( - placeholder="Type Ollama model name (e.g. llama3.3)...", - classes="picker-custom-input", - ) - self._custom_input.display = False - yield self._custom_input - yield Static( - "\u2191/\u2193 navigate \u00b7 Enter select \u00b7 Type to filter \u00b7 Esc cancel", - classes="picker-help", - ) - - def on_mount(self) -> None: - self._update_rows() - self._update_filter() - self.call_later(self.focus) - - def _update_filter(self) -> None: - if self._filter_widget is not None: - if self._filter_text: - t = Text() - t.append(" Filter: ", style="dim") - t.append(self._filter_text, style="bold") - t.append("\u2588", style="blink") - self._filter_widget.update(t) - else: - self._filter_widget.update( - Text(" Type to filter...", style="dim italic") - ) - - def _update_rows(self) -> None: - for i, (item, widget) in enumerate( - zip(self._items, self._row_widgets, strict=False) - ): - widget.remove_class("picker-row-selected") - if item["type"] == "header": - t = Text() - t.append("\u2500\u2500 ", style="bold cyan") - t.append(item["label"], style="bold cyan") - widget.update(t) - else: - is_selected = i == self._selected - t = Text() - cursor = "\u25b8 " if is_selected else " " - t.append(cursor, style="bold cyan" if is_selected else "dim") - t.append(item["name"], style="bold" if is_selected else "") - if item["current"]: - t.append(" *", style="bold green") - t.append(f" ({item['provider']})", style="dim italic") - widget.update(t) - if is_selected: - widget.add_class("picker-row-selected") - widget.scroll_visible() - - def on_key(self, event: events.Key) -> None: - # In input mode, the Input child owns printable keys + backspace. - if self._mode == "input": - return - # Let bindings handle special keys - if event.key in ("up", "down", "enter", "escape", "backspace"): - return - # Printable characters -> filter - if event.character and event.character.isprintable(): - self._filter_text += event.character - self._rebuild() - event.prevent_default() - - def action_backspace(self) -> None: - if self._mode == "input": - # Input widget handles its own backspace. - return - if self._filter_text: - self._filter_text = self._filter_text[:-1] - self._rebuild() - - def action_move_up(self) -> None: - if self._mode == "input": - # Up from the Input field escapes back to list selection. - self._exit_input_mode() - return - self._move(-1) - - def action_move_down(self) -> None: - if self._mode == "input": - # Down in input mode is ambiguous; absorb rather than toggle. - return - self._move(1) - - def action_select(self) -> None: - if self._mode == "input": - self._submit_custom_input() - return - if not self._items or self._selected >= len(self._items): - self.post_message(self.Cancelled()) - return - item = self._items[self._selected] - if item["type"] != "model": - self.post_message(self.Cancelled()) - return - if item["provider"] == "ollama" and item["model_id"] == _CUSTOM_OLLAMA_ID: - self._enter_input_mode() - return - self.post_message(self.Picked(item["name"], item["provider"])) - - def action_cancel(self) -> None: - if self._mode == "input": - # Esc returns to list selection; does NOT close the picker. - self._exit_input_mode() - return - self.post_message(self.Cancelled()) - - def on_blur(self, event: events.Blur) -> None: - # When the Input child has focus we must NOT steal it back. - if self._mode == "input": - return - self.call_after_refresh(self.focus) - - def on_input_submitted(self, event: Input.Submitted) -> None: - """Safety net: Enter fired inside the Input widget rather than - bubbling to ``action_select``. Route to the same submit path.""" - if event.input is self._custom_input: - event.stop() - self._submit_custom_input() - - def _enter_input_mode(self) -> None: - """Show the Custom Ollama Input and move focus into it.""" - self._mode = "input" - if self._custom_input is not None: - self._custom_input.display = True - # Carry any filter text over as a nice touch — user may have - # started typing a model name thinking it would filter. - self._custom_input.value = self._filter_text - self._custom_input.focus() - - def _exit_input_mode(self) -> None: - """Hide the Input and return focus to the list.""" - self._mode = "list" - if self._custom_input is not None: - self._custom_input.display = False - self._custom_input.value = "" - self.focus() - - def _submit_custom_input(self) -> None: - """Confirm the typed Ollama model name. Empty input is a no-op — - user can Esc out or keep typing.""" - typed = (self._custom_input.value if self._custom_input else "").strip() - if not typed: - return - self.post_message(self.Picked(typed, "ollama")) diff --git a/EvoScientist/commands/_completion_engine.py b/EvoScientist/commands/_completion_engine.py index f693276..f6faec7 100644 --- a/EvoScientist/commands/_completion_engine.py +++ b/EvoScientist/commands/_completion_engine.py @@ -12,7 +12,7 @@ class CompletionKind(StrEnum): EMPTY = "empty" -_CATEGORY_ORDER = ["Session", "Skills", "MCP", "Channels", "Model", "General"] +_CATEGORY_ORDER = ["Session", "Skills", "MCP", "Channels", "General"] @dataclass(frozen=True) diff --git a/EvoScientist/commands/base.py b/EvoScientist/commands/base.py index c125996..dcaf18e 100644 --- a/EvoScientist/commands/base.py +++ b/EvoScientist/commands/base.py @@ -47,12 +47,6 @@ class CommandUI(Protocol): async def wait_for_mcp_browse( self, servers: list, installed_names: set[str], pre_filter_tag: str ) -> list | None: ... - async def wait_for_model_pick( - self, - entries: list[tuple[str, str, str]], - current_model: str | None, - current_provider: str | None, - ) -> tuple[str, str] | None: ... def clear_chat(self) -> None: ... def request_quit(self) -> None: ... def force_quit(self) -> None: ... diff --git a/EvoScientist/commands/implementation/__init__.py b/EvoScientist/commands/implementation/__init__.py index 998bf51..dae9049 100644 --- a/EvoScientist/commands/implementation/__init__.py +++ b/EvoScientist/commands/implementation/__init__.py @@ -5,7 +5,6 @@ from . import ( channel, general, mcp, - model, schedule, session, skills, @@ -16,7 +15,6 @@ __all__ = [ "channel", "general", "mcp", - "model", "schedule", "session", "skills", diff --git a/EvoScientist/commands/implementation/model.py b/EvoScientist/commands/implementation/model.py deleted file mode 100644 index 0172650..0000000 --- a/EvoScientist/commands/implementation/model.py +++ /dev/null @@ -1,204 +0,0 @@ -from __future__ import annotations - -from typing import ClassVar - -from ..base import Argument, Command, CommandContext -from ..manager import manager - - -def extract_model_and_provider(args: list[str]) -> tuple[str, str]: - """Parse model name and provider from argument list. - - Args: - args: Non-empty argument list (model_name [provider]). - - Returns: - ``(model_name, provider)`` tuple. - - Raises: - ValueError: If the model is not in the registry. Skipped when - ``provider_override == "ollama"``, since Ollama models are - locally-installed and never appear in ``MODELS``. - """ - from ...llm.models import MODELS - - model_name = args[0] - provider_override = args[1] if len(args) > 1 else None - - # Ollama models are locally-installed — not in the registry. Pass the name - # through verbatim; get_chat_model's "Assume full model ID" fallback - # (models.py) accepts them. - if provider_override == "ollama": - return model_name, "ollama" - - if model_name not in MODELS: - raise ValueError(f"Unknown model '{model_name}'") - - if provider_override: - provider = provider_override - else: - _, provider = MODELS[model_name] - - return model_name, provider - - -class ModelCommand(Command): - """Switch the LLM model for the current session.""" - - name = "/model" - description = "Switch model (--save to persist)" - category = "Model" - # ``--save`` is parsed manually in ``execute`` via ``"--save" in args``; - # ``type=bool`` below is declarative metadata, not enforced by the manager. - arguments: ClassVar[list[Argument]] = [ - Argument( - name="model_name", - type=str, - description="Model short name (e.g. claude-sonnet-4-6). Opens picker if omitted.", - required=False, - ), - Argument( - name="--save", - type=bool, - description="Save the choice to config file", - required=False, - ), - ] - - async def execute(self, ctx: CommandContext, args: list[str]) -> None: - from ...EvoScientist import _ensure_config - from ...llm.models import list_model_picker_entries - - cfg = _ensure_config() - current_model = cfg.model - current_provider = cfg.provider - - # Parse --save flag - save = "--save" in args - args = [a for a in args if a != "--save"] - - if args: - try: - model_name, provider = extract_model_and_provider(args) - except ValueError: - ctx.ui.append_system( - f"Unknown model '{args[0]}'. Use /model to browse available models.", - style="red", - ) - return - - await self._apply_model(ctx, model_name, provider, save=save) - return - - # Interactive picker - if not ctx.ui.supports_interactive: - ctx.ui.append_system( - "Usage: /model [provider] [--save]", - style="yellow", - ) - return - - entries = await list_model_picker_entries( - getattr(cfg, "ollama_base_url", None), - include_custom_ollama=True, - ) - - result = await ctx.ui.wait_for_model_pick( - entries, - current_model=current_model, - current_provider=current_provider, - ) - if result is None: - return - - name, provider = result - # Defense-in-depth: the widget should have replaced the sentinel with - # the user-typed name. If it didn't, treat as cancel rather than try - # to switch to a literal "__custom_ollama__" model. - if provider == "ollama" and name in ( - "Custom Ollama model...", - "__custom_ollama__", - ): - return - await self._apply_model(ctx, name, provider, save=save) - - async def _apply_model( - self, - ctx: CommandContext, - model_name: str, - provider: str, - *, - save: bool = False, - ) -> None: - import copy - - from ...cli.agent import _load_agent - from ...EvoScientist import ( - _build_chat_model, - _ensure_config, - set_active_config, - set_chat_model_instance, - ) - - cfg = _ensure_config() - - # Build a temporary config + its chat model and verify the agent can be - # built before committing anything. ``create_cli_agent(config=..., - # chat_model=...)`` is pure (issue #183) — it writes none of the cached - # config/model module globals — so a failure below leaves the session - # on the original model with no snapshot/restore needed. - temp_cfg = copy.copy(cfg) - temp_cfg.model = model_name - temp_cfg.provider = provider - - try: - new_chat_model = _build_chat_model(temp_cfg) - new_agent = _load_agent( - workspace_dir=ctx.workspace_dir, - checkpointer=ctx.checkpointer, - config=temp_cfg, - chat_model=new_chat_model, - ) - except Exception as e: - ctx.ui.append_system(f"Failed to switch model: {e}", style="red") - return - - # Agent built with no global mutation — commit the switch atomically. - # These are pure assignments and cannot fail, so the session can never - # be left half-switched. Apply the switch to the LIVE ``cfg`` in place - # (the active config object) instead of rebinding ``_config`` to the - # fresh ``temp_cfg`` — callers that hold the active config by reference - # (e.g. serve's ``agent_holder["config"]`` and its workspace-changing - # ``/resume`` reload) must observe the new model/provider. The verify - # build above used the ``temp_cfg`` copy, so a failed build never reaches - # here and the live ``cfg`` stays untouched (failure still no-ops). - cfg.model = model_name - cfg.provider = provider - set_active_config(cfg) - set_chat_model_instance(new_chat_model, (model_name, provider)) - ctx.agent = new_agent - - # Persist to config file if --save was given - if save: - from ...config.settings import set_config_value - - set_config_value("model", model_name) - set_config_value("provider", provider) - - # Propagate to the channel runtime if channels are running so the - # bus picks up the new agent on the next inbound message. - if ctx.channel_runtime is not None and ctx.channel_runtime.agent is not None: - ctx.channel_runtime.agent = new_agent - - # Update status bar if available - update_model_fn = getattr(ctx.ui, "update_status_after_model_change", None) - if callable(update_model_fn): - update_model_fn(model_name, provider) - - saved_note = " (saved to config)" if save else "" - ctx.ui.append_system( - f"Switched to {model_name} ({provider}){saved_note}", style="green" - ) - - -manager.register(ModelCommand()) diff --git a/EvoScientist/config/__init__.py b/EvoScientist/config/__init__.py index ac6af28..69305ea 100644 --- a/EvoScientist/config/__init__.py +++ b/EvoScientist/config/__init__.py @@ -8,16 +8,6 @@ The onboard module is loaded lazily because it pulls in heavy dependencies (langchain, llm) that are not needed for normal config operations. """ -from .provider_profiles import ( - ProviderModel, - ProviderProfile, - ProviderProfileError, - ProviderProfiles, - get_builtin_provider_profile, - get_provider_profiles_path, - load_provider_profiles, - save_provider_profiles, -) from .settings import ( EvoScientistConfig, MemoryControls, @@ -46,29 +36,20 @@ __all__ = [ "MemoryObservationWriter", "MemorySkillSynthesisCadence", "MemorySkillSynthesisMode", - # provider profiles - "ProviderModel", - "ProviderProfile", - "ProviderProfileError", - "ProviderProfiles", "apply_config_to_env", - "get_builtin_provider_profile", # settings "get_config_dir", "get_config_path", "get_config_value", "get_default_workspace_dir", "get_effective_config", - "get_provider_profiles_path", "is_config_applied_env", "list_config", "load_config", - "load_provider_profiles", "reset_config", # onboard (lazy) "run_onboard", "save_config", - "save_provider_profiles", "set_config_value", ] diff --git a/EvoScientist/config/legacy_artifacts.py b/EvoScientist/config/legacy_artifacts.py new file mode 100644 index 0000000..7f16213 --- /dev/null +++ b/EvoScientist/config/legacy_artifacts.py @@ -0,0 +1,132 @@ +"""Startup detection of pre-Registry legacy model configuration artifacts. + +Design doc section 10 step 4: this project is in development and keeps no +historical compatibility. When the config service or the CLI finds legacy +artifacts — an old ``providers.yaml``, an old +``run-runtime-snapshots.sqlite3``, or LLM fields left behind in +``config.yaml`` — it must refuse to start and log an explicit reset guide +instead of partially reading them. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import yaml + +from .settings import get_config_dir + +logger = logging.getLogger(__name__) + +#: LLM configuration keys that ``config.yaml`` must no longer carry +#: (section 10 step 2). Platform configuration (workspace, MCP, ports, +#: storage, scheduling, security) stays; everything model/provider/credential +#: related lives in the Model Registry (``model-runtime.sqlite3``) only. +LEGACY_CONFIG_YAML_KEYS = frozenset( + { + "provider", + "model", + "model_catalog", + "model_fallbacks", + "auxiliary_provider", + "auxiliary_model", + "anthropic_api_key", + "anthropic_base_url", + "anthropic_auth_mode", + "openai_api_key", + "openai_auth_mode", + "nvidia_api_key", + "google_api_key", + "minimax_api_key", + "minimax_base_url", + "siliconflow_api_key", + "openrouter_api_key", + "deepseek_api_key", + "zhipu_api_key", + "volcengine_api_key", + "dashscope_api_key", + "moonshot_api_key", + "kimi_api_key", + "custom_openai_api_key", + "custom_openai_base_url", + "custom_anthropic_api_key", + "custom_anthropic_base_url", + "ollama_base_url", + "use_responses_api", + "openrouter_anthropic_prompt_cache", + } +) + +_LEGACY_PROVIDERS_FILE = "providers.yaml" +_LEGACY_SNAPSHOTS_DB = "run-runtime-snapshots.sqlite3" + +_RESET_GUIDANCE = """\ +EvoScientist no longer reads legacy model configuration (design doc §10). +To reset the development environment: + 1. Delete {config_dir}/providers.yaml (Provider Profiles are superseded + by the Model Registry). + 2. Delete {config_dir}/run-runtime-snapshots.sqlite3 (the old snapshot + store; run snapshots now live in model-runtime.sqlite3). + 3. Remove the leftover LLM fields listed above from {config_path} — + platform fields (workspace, MCP, ports, scheduling, security) stay. + 4. Configure providers/models through the Model Registry (WebUI + configuration page or the model-registry API), run the provider test, + then enable the models you need. +Startup is refused — no legacy artifact is read, even partially.\ +""" + + +class LegacyArtifactsError(RuntimeError): + """Raised at startup when pre-Registry configuration artifacts remain.""" + + +def find_legacy_artifacts(config_dir: Path | None = None) -> list[str]: + """Return human-readable descriptions of every legacy artifact found.""" + config_dir = config_dir if config_dir is not None else get_config_dir() + found: list[str] = [] + + providers_yaml = config_dir / _LEGACY_PROVIDERS_FILE + if providers_yaml.exists(): + found.append(f"legacy Provider Profiles file: {providers_yaml}") + + snapshots_db = config_dir / _LEGACY_SNAPSHOTS_DB + if snapshots_db.exists(): + found.append(f"legacy run snapshot database: {snapshots_db}") + + config_path = config_dir / "config.yaml" + if config_path.exists(): + try: + with open(config_path, encoding="utf-8") as handle: + data = yaml.safe_load(handle) or {} + except yaml.YAMLError: + data = {} + if isinstance(data, dict): + leftover = sorted(LEGACY_CONFIG_YAML_KEYS & data.keys()) + if leftover: + found.append( + f"leftover LLM fields in {config_path}: {', '.join(leftover)}" + ) + return found + + +def assert_no_legacy_artifacts(config_dir: Path | None = None) -> None: + """Refuse startup when any legacy model configuration artifact remains. + + Logs the findings plus the explicit reset guide (section 10 step 4) and + raises :class:`LegacyArtifactsError`. Nothing is read partially: the + caller must not catch-and-continue. + """ + found = find_legacy_artifacts(config_dir) + if not found: + return + config_dir = config_dir if config_dir is not None else get_config_dir() + guidance = _RESET_GUIDANCE.format( + config_dir=config_dir, + config_path=config_dir / "config.yaml", + ) + message = "Legacy model configuration artifacts detected:\n" + "\n".join( + f" - {item}" for item in found + ) + logger.error("%s\n%s", message, guidance) + raise LegacyArtifactsError(f"{message}\n\n{guidance}") diff --git a/EvoScientist/config/onboard/__init__.py b/EvoScientist/config/onboard/__init__.py index b039564..3e9ef57 100644 --- a/EvoScientist/config/onboard/__init__.py +++ b/EvoScientist/config/onboard/__init__.py @@ -7,13 +7,12 @@ Everything else lives in submodules — import directly from them: ``STEPS``, ``render_progress`` - :mod:`EvoScientist.config.onboard.steps` — per-step functions - :mod:`EvoScientist.config.onboard.channels` — channel selection + setup -- :mod:`EvoScientist.config.onboard.helpers` — API-key prompt, ccproxy, +- :mod:`EvoScientist.config.onboard.helpers` — API-key prompt, npx/node, LaTeX, iMessage helpers - :mod:`EvoScientist.config.onboard.style` — Rich styles + ``_checkbox_ask`` - :mod:`EvoScientist.config.onboard.validators` — input validators - :mod:`EvoScientist.config.onboard.prompter` — ``NonInteractivePrompter`` - (CLI-answer container) + ``select_navigation_active`` / ``GoBack`` for - keyboard nav + (CLI-answer container) + ``select_navigation_active`` for keyboard nav - :mod:`EvoScientist.config.onboard.constants` — canonical valid-value sets This module used to re-export every symbol from every submodule for diff --git a/EvoScientist/config/onboard/constants.py b/EvoScientist/config/onboard/constants.py index 924287e..44725b9 100644 --- a/EvoScientist/config/onboard/constants.py +++ b/EvoScientist/config/onboard/constants.py @@ -2,46 +2,22 @@ The interactive ``_step_*`` functions in ``steps.py`` use these for ``Choice`` construction (or are checked against them by tests). The CLI ``onboard`` -command in ``cli/commands.py`` uses them to validate ``--provider`` / -``--ui`` / ``--workspace-mode`` flag inputs. +command in ``cli/commands.py`` uses them to validate ``--ui`` / +``--workspace-mode`` flag inputs. -Single source of truth — adding a new provider here AND to the corresponding +Single source of truth — adding a new value here AND to the corresponding ``Choice(value=...)`` in ``steps.py`` is required; a drift test in ``tests/test_onboard.py`` keeps both sides in sync. """ from __future__ import annotations -VALID_PROVIDERS: frozenset[str] = frozenset( - { - "anthropic", - "openai", - "google-genai", - "minimax", - "zhipu", - "zhipu-code", - "volcengine", - "dashscope", - "dashscope-code", - "deepseek", - "moonshot", - "kimi-coding", - "ollama", - "nvidia", - "siliconflow", - "openrouter", - "custom-openai", - "custom-anthropic", - } -) - VALID_UI_BACKENDS: frozenset[str] = frozenset({"tui", "cli", "webui"}) VALID_WORKSPACE_MODES: frozenset[str] = frozenset({"daemon", "run"}) __all__ = [ - "VALID_PROVIDERS", "VALID_UI_BACKENDS", "VALID_WORKSPACE_MODES", ] diff --git a/EvoScientist/config/onboard/helpers.py b/EvoScientist/config/onboard/helpers.py index 970330f..8904eb5 100644 --- a/EvoScientist/config/onboard/helpers.py +++ b/EvoScientist/config/onboard/helpers.py @@ -1,4 +1,4 @@ -"""Cross-step helpers: API key prompt loop, ccproxy login, npx/node bootstrapping, +"""Cross-step helpers: API key prompt loop, npx/node bootstrapping, LaTeX detection/install, iMessage setup. """ @@ -11,126 +11,7 @@ import sys import questionary -from ..settings import EvoScientistConfig from .style import QMARK, WIZARD_STYLE, console -from .validators import ( - validate_anthropic_key, - validate_dashscope_code_key, - validate_dashscope_key, - validate_deepseek_key, - validate_google_key, - validate_kimi_key, - validate_minimax_key, - validate_moonshot_key, - validate_nvidia_key, - validate_openai_key, - validate_openrouter_key, - validate_siliconflow_key, - validate_volcengine_key, - validate_zhipu_key, -) - - -def _provider_key_info(config: EvoScientistConfig, provider: str): - """Return (display_name, current_value, validate_fn) for a provider.""" - mapping = { - "anthropic": ( - "Anthropic", - config.anthropic_api_key or os.environ.get("ANTHROPIC_API_KEY", ""), - validate_anthropic_key, - ), - "minimax": ( - "MiniMax", - config.minimax_api_key or os.environ.get("MINIMAX_API_KEY", ""), - lambda key: validate_minimax_key( - key, - base_url=config.minimax_base_url - or os.environ.get( - "MINIMAX_BASE_URL", "https://api.minimaxi.com/anthropic" - ), - ), - ), - "nvidia": ( - "NVIDIA", - config.nvidia_api_key or os.environ.get("NVIDIA_API_KEY", ""), - validate_nvidia_key, - ), - "google-genai": ( - "Google", - config.google_api_key or os.environ.get("GOOGLE_API_KEY", ""), - validate_google_key, - ), - "siliconflow": ( - "SiliconFlow", - config.siliconflow_api_key or os.environ.get("SILICONFLOW_API_KEY", ""), - validate_siliconflow_key, - ), - "openrouter": ( - "OpenRouter", - config.openrouter_api_key or os.environ.get("OPENROUTER_API_KEY", ""), - validate_openrouter_key, - ), - "deepseek": ( - "DeepSeek", - config.deepseek_api_key or os.environ.get("DEEPSEEK_API_KEY", ""), - validate_deepseek_key, - ), - "zhipu": ( - "ZhipuAI", - config.zhipu_api_key or os.environ.get("ZHIPU_API_KEY", ""), - validate_zhipu_key, - ), - "zhipu-code": ( - "ZhipuAI CodePlan", - config.zhipu_api_key or os.environ.get("ZHIPU_API_KEY", ""), - validate_zhipu_key, - ), - "volcengine": ( - "Volcengine", - config.volcengine_api_key or os.environ.get("VOLCENGINE_API_KEY", ""), - validate_volcengine_key, - ), - "dashscope": ( - "DashScope", - config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""), - validate_dashscope_key, - ), - "dashscope-code": ( - "DashScope Coding Plan", - config.dashscope_api_key or os.environ.get("DASHSCOPE_API_KEY", ""), - validate_dashscope_code_key, - ), - "moonshot": ( - "Moonshot", - config.moonshot_api_key or os.environ.get("MOONSHOT_API_KEY", ""), - validate_moonshot_key, - ), - "kimi-coding": ( - "Kimi Coding Plan", - config.kimi_api_key or os.environ.get("KIMI_API_KEY", ""), - validate_kimi_key, - ), - "custom-openai": ( - "OpenAI-compatible", - config.custom_openai_api_key or os.environ.get("CUSTOM_OPENAI_API_KEY", ""), - None, - ), - "custom-anthropic": ( - "Custom Anthropic", - config.custom_anthropic_api_key - or os.environ.get("CUSTOM_ANTHROPIC_API_KEY", ""), - None, - ), - "ollama": ("Ollama", "__no_key__", None), - } - return mapping.get( - provider, - ( - "OpenAI", - config.openai_api_key or os.environ.get("OPENAI_API_KEY", ""), - validate_openai_key, - ), - ) def _prompt_and_validate_api_key( @@ -192,66 +73,6 @@ def _prompt_and_validate_api_key( return new_key or None -def _prompt_ccproxy_port(config: EvoScientistConfig) -> None: - """Prompt the user for a ccproxy port and save it to config.""" - - def valid_port(value: str) -> bool: - if not value: # empty = keep default - return True - try: - return 0 < int(value) < 2**16 - except (ValueError, TypeError): - return False - - current_port = getattr(config, "ccproxy_port", 8000) - try: - raw = questionary.text( - f"Enter port number for ccproxy to run on (Current: {current_port}, Enter to keep):", - validate=valid_port, - style=WIZARD_STYLE, - qmark=QMARK, - ).ask() - if raw is None: - raise KeyboardInterrupt() - raw = raw.strip() - ccproxy_port = int(raw) if raw else current_port - except (ValueError, TypeError): - ccproxy_port = current_port - console.print(f" [dim]Using default port: {ccproxy_port}[/dim]") - - config.ccproxy_port = ccproxy_port - console.print( - f" [green]✓ ccproxy will run on http://127.0.0.1:{ccproxy_port}[/green]" - ) - - -def _run_ccproxy_login(provider: str, label: str) -> None: - """Run ccproxy auth login for the given provider and show status.""" - from ...ccproxy_manager import _ccproxy_exe, check_ccproxy_auth - - console.print(" [dim]Opening browser for authentication...[/dim]") - try: - proc = subprocess.run( - [_ccproxy_exe() or "ccproxy", "auth", "login", provider], - capture_output=True, - text=True, - timeout=120, - ) - for line in proc.stdout.splitlines(): - if line.strip().startswith("https://"): - console.print(f" [dim]Visit: {line.strip()}[/dim]") - break - authed, msg = check_ccproxy_auth(provider) - if authed: - console.print(f" [green]✓ {label}: {msg}[/green]") - else: - console.print(f" [red]Authentication failed: {msg}[/red]") - except subprocess.TimeoutExpired: - console.print(" [red]Login timed out.[/red]") - except Exception as exc: - console.print(f" [red]Login error: {exc}[/red]") - - def _check_npx() -> bool: """Check if npx is available on the system. @@ -591,24 +412,6 @@ def validate_imessage() -> tuple[bool, str]: return True, f"imsg{version_str} at {cli_path}" -def _install_ccproxy() -> bool: - """Run pip install for ccproxy (evoscientist[oauth]). - - Uses uv pip install when available (uv-managed envs don't ship pip). - - Returns: - True if installation succeeded and ccproxy is available. - """ - from ...ccproxy_manager import is_ccproxy_available - from ...mcp.registry import install_library - - ok = install_library("evoscientist[oauth]") - if not ok: - console.print(" [red]✗ Installation failed.[/red]") - return False - return is_ccproxy_available() - - def _install_imsg() -> bool: """Run brew install for imsg CLI. diff --git a/EvoScientist/config/onboard/prompter.py b/EvoScientist/config/onboard/prompter.py index e02d3e1..85a3e2a 100644 --- a/EvoScientist/config/onboard/prompter.py +++ b/EvoScientist/config/onboard/prompter.py @@ -5,31 +5,13 @@ from __future__ import annotations from typing import Any -class GoBack(Exception): - """Raised inside the provider sub-loop to rewind to provider selection.""" - - -# Sentinel value the back-keybinding writes into the prompt result, and the -# value the trailing ``← Back`` menu item carries. Same string so the two -# code paths converge to a single ``GoBack`` raise. -BACK_SENTINEL = "__back__" - - -def install_navigation_keys( - question, - *, - with_back: bool = False, - sentinel: str = BACK_SENTINEL, -) -> None: +def install_navigation_keys(question) -> None: """Add keyboard shortcuts on a questionary select ``Question``. Bindings (merged in front of questionary's defaults — Ctrl+C/Ctrl+D still cancel the wizard): - ``→`` — accept the option under the cursor and advance (mirrors Enter). - - ``Esc`` / ``←`` (only when ``with_back=True``) — exit with ``sentinel`` - so the wizard can rewind. Used in the provider sub-loop's auth_mode - prompts. """ from prompt_toolkit.key_binding import KeyBindings, merge_key_bindings @@ -49,13 +31,6 @@ def install_navigation_keys( event.app.exit(result=pointed.value) return - if with_back: - - @kb.add("escape", eager=True) - @kb.add("left", eager=True) - def _back(event): - event.app.exit(result=sentinel) - question.application.key_bindings = merge_key_bindings( [kb, question.application.key_bindings] ) @@ -78,7 +53,7 @@ def select_navigation_active(): def _wrapped(*args, **kwargs): q = original(*args, **kwargs) try: - install_navigation_keys(q, with_back=False) + install_navigation_keys(q) except Exception: # Don't let a stray keybinding error block the wizard. pass @@ -92,7 +67,7 @@ def select_navigation_active(): class NonInteractivePrompter: - """Container for CLI-supplied wizard answers (``--provider``, ``--model``…). + """Container for CLI-supplied wizard answers (``--ui``, ``--tavily-key``…). ``strict=True`` makes missing presets fatal instead of falling back to interactive. Wizard reads ``answers`` / ``skip_set`` / ``strict`` directly. @@ -113,8 +88,6 @@ class NonInteractivePrompter: __all__ = [ - "BACK_SENTINEL", - "GoBack", "NonInteractivePrompter", "install_navigation_keys", "select_navigation_active", diff --git a/EvoScientist/config/onboard/steps.py b/EvoScientist/config/onboard/steps.py index 184e0bd..805c315 100644 --- a/EvoScientist/config/onboard/steps.py +++ b/EvoScientist/config/onboard/steps.py @@ -1,8 +1,8 @@ """Individual wizard step functions. Each ``_step_*`` prompts the user for one logical decision and returns the -chosen value. Conditional steps (auth mode, base URL) are only called by -``run_onboard`` when the provider needs them. +chosen value. LLM provider/model/API-key configuration no longer happens in +the wizard — models are configured via the WebUI / model registry. """ from __future__ import annotations @@ -14,24 +14,17 @@ import questionary from prompt_toolkit.formatted_text import FormattedText from questionary import Choice -from ...llm import get_models_for_provider -from ...llm.ollama_discovery import validate_ollama_connection from ..settings import EvoScientistConfig from .helpers import ( _auto_install_latexmk, _check_latex_components, _detect_tinytex_install_method, _ensure_npx, - _install_ccproxy, _install_tinytex, _print_latex_status, _prompt_and_validate_api_key, - _prompt_ccproxy_port, - _provider_key_info, - _run_ccproxy_login, ) from .style import ( - CONFIRM_STYLE, QMARK, WIZARD_STYLE, _checkbox_ask, @@ -228,633 +221,6 @@ def _step_webui_port(config: EvoScientistConfig) -> int: return port -def _step_provider( - config: EvoScientistConfig, - *, - label: str | None = None, - default_value: str | None = None, -) -> str: - """Step 1: Select LLM provider. - - Args: - config: Current configuration. - label: Optional role label (e.g. "co-pilot") to clarify which model this - provider is for. When omitted, the generic main-model prompt is used. - default_value: Preselect this provider instead of ``config.provider`` - (e.g. the auxiliary provider when configuring the co-pilot). - - Returns: - Selected provider name. - """ - choices = [ - # Direct providers - Choice(title="Anthropic (Claude models — API / OAuth)", value="anthropic"), - Choice(title="OpenAI (GPT models — API / OAuth)", value="openai"), - Choice(title="Google GenAI (Gemini models)", value="google-genai"), - Choice( - title="MiniMax (M2 — M3 models, up to 1M context, thinking)", - value="minimax", - ), - Choice(title="ZhipuAI (智谱 — GLM models)", value="zhipu"), - Choice( - title="ZhipuAI CodePlan (智谱代码计划 — GLM models for coding)", - value="zhipu-code", - ), - Choice( - title="Volcengine (火山引擎 — Doubao models)", - value="volcengine", - ), - Choice( - title="DashScope (阿里云 — Qwen models)", - value="dashscope", - ), - Choice( - title="DashScope Coding Plan (阿里云代码计划 — Qwen models)", - value="dashscope-code", - ), - Choice( - title="DeepSeek (DeepSeek-R1, DeepSeek-V3)", - value="deepseek", - ), - Choice( - title="Moonshot (月之暗面 — Moonshot models)", - value="moonshot", - ), - Choice( - title="Kimi Coding Plan (Kimi 代码计划 — coding-focused)", - value="kimi-coding", - ), - # Local - Choice(title="Ollama (local models)", value="ollama"), - # Third-party / aggregator - Choice(title="NVIDIA (third party — limited free requests)", value="nvidia"), - Choice( - title="SiliconFlow (aggregator — GLM, Kimi, MiniMax, etc.)", - value="siliconflow", - ), - Choice( - title="OpenRouter (aggregator — Grok, Gemini, Qwen, etc.)", - value="openrouter", - ), - Choice( - title="OpenAI-compatible (third-party OpenAI endpoint)", - value="custom-openai", - ), - Choice( - title="Claude-compatible (third-party Anthropic endpoint)", - value="custom-anthropic", - ), - ] - - # Set default based on current config (or an explicit override). - valid_providers = {c.value for c in choices} - preferred = default_value or config.provider - default = preferred if preferred in valid_providers else "anthropic" - - provider = questionary.select( - f"Select {label} provider:" if label else "Select your LLM provider:", - choices=choices, - default=default, - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, - ).ask() - - if provider is None: - raise KeyboardInterrupt() - - return provider - - -_MINIMAX_REGIONS: dict[str, str] = { - "global": "https://api.minimax.io/anthropic", - "cn": "https://api.minimaxi.com/anthropic", -} - - -def _step_minimax_region(config: EvoScientistConfig) -> str: - """Step 2a (MiniMax): Select API region. - - MiniMax has two regional endpoints — Global (api.minimax.io) and - Mainland China (api.minimaxi.com). API keys are region-bound. - - Returns: - The selected base URL. - """ - current = config.minimax_base_url or os.environ.get("MINIMAX_BASE_URL", "") - if current == _MINIMAX_REGIONS["global"]: - default = "global" - else: - default = "cn" - - region = questionary.select( - "Select MiniMax API region (must match where your key was created):", - choices=[ - Choice( - title="Global (api.minimax.io — platform.minimax.io keys)", - value="global", - ), - Choice( - title="Mainland China (api.minimaxi.com — platform.minimaxi.com keys)", - value="cn", - ), - ], - default=default, - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, - ).ask() - - if region is None: - raise KeyboardInterrupt() - - return _MINIMAX_REGIONS[region] - - -def _step_oauth_auth_mode( - config: EvoScientistConfig, - *, - provider_label: str, - ccproxy_provider: str, - config_attr: str, - prompt_login_label: str, - oauth_choice_label: str | None = None, - status_label: str | None = None, - question_label: str | None = None, -) -> str: - """Select API-key vs ccproxy OAuth authentication for a provider. - - Args: - config: Current configuration. - provider_label: Provider display name for direct API-key access. - ccproxy_provider: ccproxy auth provider name. - config_attr: Config attribute storing this provider's auth mode. - prompt_login_label: Label used in "Log in to ..." prompts. - oauth_choice_label: Optional display label for the OAuth choice. - status_label: Optional display label for status messages. - question_label: Optional prompt label override. - - Returns: - Selected auth mode: "api_key" or "oauth". - """ - from ...ccproxy_manager import check_ccproxy_auth, is_ccproxy_available - - ccproxy_available = is_ccproxy_available() - - from .prompter import BACK_SENTINEL, GoBack, install_navigation_keys - - oauth_label = oauth_choice_label or f"{prompt_login_label} OAuth" - auth_status_label = status_label or oauth_label - auth_question_label = question_label or f"{provider_label} authentication mode" - - choices = [ - Choice(title=f"API Key (direct {provider_label} access)", value="api_key"), - Choice( - title=f"{oauth_label} (via ccproxy — no API key needed)" - + ( - "" - if ccproxy_available - else " [requires: pip install evoscientist[oauth]]" - ), - value="oauth", - ), - questionary.Separator(), - Choice(title="← Back (re-pick provider)", value=BACK_SENTINEL), - ] - - current = getattr(config, config_attr) - if current not in ("api_key", "oauth"): - current = "api_key" - - question = questionary.select( - f"{auth_question_label} [Esc/← to go back]:", - choices=choices, - default=current, - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, - ) - install_navigation_keys(question, with_back=True) - auth_mode = question.ask() - - if auth_mode is None: - raise KeyboardInterrupt() - if auth_mode == BACK_SENTINEL: - raise GoBack() - - if auth_mode == "oauth" and not ccproxy_available: - console.print(" [yellow]✗ ccproxy not installed[/yellow]") - console.print() - install = questionary.confirm( - 'Install ccproxy now? (pip install "evoscientist[oauth]")', - default=True, - style=WIZARD_STYLE, - qmark=f" {QMARK}", - ).ask() - if install is None: - raise KeyboardInterrupt() - if install: - console.print() - if _install_ccproxy(): - console.print(" [green]✓ ccproxy installed successfully.[/green]") - else: - console.print(" [yellow]Falling back to API key mode.[/yellow]") - return "api_key" - else: - console.print( - ' [dim]Skipped. Install manually: pip install "evoscientist[oauth]"[/dim]' - ) - return "api_key" - - if auth_mode == "oauth": - _prompt_ccproxy_port(config) - - authed, msg = check_ccproxy_auth(ccproxy_provider) - if authed: - console.print(f" [green]✓ {auth_status_label}: {msg}[/green]") - relogin = questionary.confirm( - "Re-authenticate to refresh credentials?", - default=False, - style=CONFIRM_STYLE, - qmark=QMARK, - ).ask() - if relogin is None: - raise KeyboardInterrupt() - if relogin: - _run_ccproxy_login(ccproxy_provider, auth_status_label) - else: - console.print( - f" [yellow]{auth_status_label} not authenticated: {msg}[/yellow]" - ) - login = questionary.confirm( - f"Log in to {prompt_login_label} now?", - default=True, - style=CONFIRM_STYLE, - qmark=QMARK, - ).ask() - if login is None: - raise KeyboardInterrupt() - if login: - _run_ccproxy_login(ccproxy_provider, auth_status_label) - - return auth_mode - - -def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str: - """Step 2a: Select Anthropic authentication mode (API key vs OAuth). - - Args: - config: Current configuration. - - Returns: - Selected auth mode: "api_key" or "oauth". - """ - return _step_oauth_auth_mode( - config, - provider_label="Anthropic", - ccproxy_provider="claude_api", - config_attr="anthropic_auth_mode", - prompt_login_label="Claude", - oauth_choice_label="Claude Code OAuth", - status_label="OAuth", - question_label="Authentication mode", - ) - - -def _step_openai_auth_mode(config: EvoScientistConfig) -> str: - """Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth). - - Args: - config: Current configuration. - - Returns: - Selected auth mode: "api_key" or "oauth". - """ - return _step_oauth_auth_mode( - config, - provider_label="OpenAI", - ccproxy_provider="codex", - config_attr="openai_auth_mode", - prompt_login_label="Codex", - oauth_choice_label="Codex OAuth", - status_label="Codex OAuth", - question_label="OpenAI authentication mode", - ) - - -def _step_provider_api_key( - config: EvoScientistConfig, - provider: str, - skip_validation: bool = False, -) -> str | None: - """Step 2: Enter API key for the selected provider. - - Args: - config: Current configuration. - provider: Selected provider name. - skip_validation: Skip API key validation. - - Returns: - New API key or None if unchanged. - """ - key_name, current, validate_fn = _provider_key_info(config, provider) - - hint = f"Current: ***{current[-4:]}" if current else "Not set" - prompt_text = f"Enter {key_name} API key ({hint}, Enter to keep):" - - return _prompt_and_validate_api_key( - prompt_text, - current, - validate_fn, - skip_validation, - ) - - -def _step_base_url(config: EvoScientistConfig, current_value: str | None = None) -> str: - """Prompt for custom provider base URL. - - Args: - config: Current configuration. - current_value: Current base URL value (if None, defaults to empty). - - Returns: - Base URL string. - """ - current = current_value if current_value is not None else "" - hint = f"Current: {current}" if current else "" - default = current or "" - - url = questionary.text( - f"Base URL{' (' + hint + ', Enter to keep)' if hint else ''}:", - default=default, - style=WIZARD_STYLE, - qmark=QMARK, - placeholder=FormattedText([("fg:#858585", " e.g. https://api.example.com/v1")]) - if not default - else None, - ).ask() - if url is None: - raise KeyboardInterrupt() - return url.strip() - - -def _step_ollama_base_url(config: EvoScientistConfig) -> tuple[str, list[str]]: - """Prompt for Ollama server base URL and validate connection. - - Args: - config: Current configuration. - - Returns: - Tuple of (base_url, detected_model_names). - """ - current = config.ollama_base_url or os.environ.get("OLLAMA_BASE_URL", "") - default = current or "http://localhost:11434" - - url = questionary.text( - f"Ollama base URL (Enter for {default}):", - default=default, - style=WIZARD_STYLE, - qmark=QMARK, - ).ask() - if url is None: - raise KeyboardInterrupt() - url = url.strip() - - detected_models: list[str] = [] - if url: - console.print(" [dim]Checking Ollama connection...[/dim]", end="") - valid, msg, detected_models = validate_ollama_connection(url) - if valid: - console.print(f"\r [green]\u2713 {msg}[/green] ") - else: - console.print(f"\r [yellow]\u2717 {msg}[/yellow] ") - console.print(" [dim]You can start Ollama later and it will work.[/dim]") - - return url, detected_models - - -def _step_model( - config: EvoScientistConfig, - provider: str, - *, - ollama_detected_models: list[str] | None = None, - label: str | None = None, - default_value: str | None = None, -) -> str: - """Step 3: Select model for the provider. - - Args: - config: Current configuration. - provider: Selected provider name. - ollama_detected_models: Model names detected from a live Ollama server. - label: Optional role label (e.g. "co-pilot") for the prompt. When omitted, - the generic main-model prompt is used. - default_value: Preselect this model instead of ``config.model`` (e.g. the - auxiliary model when configuring the co-pilot). - - Returns: - Selected model name. - """ - model_prompt = f"Select {label} model:" if label else "Select model:" - model_default = default_value or config.model - # Ollama: show only what's actually pulled on the server - if provider == "ollama": - if ollama_detected_models: - _CUSTOM_SENTINEL = "__custom__" - choices = [ - Choice(title=name, value=name) for name in ollama_detected_models - ] - choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL)) - - default = ollama_detected_models[0] - if model_default in ollama_detected_models: - default = model_default - - selected = questionary.select( - model_prompt, - choices=choices, - default=default, - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, - ).ask() - if selected is None: - raise KeyboardInterrupt() - - if selected != _CUSTOM_SENTINEL: - return selected - - # No detected models (server down or empty) — direct text input - if not ollama_detected_models: - console.print( - " [dim]No models detected — type the model name you plan to pull.[/dim]" - ) - model = questionary.text( - "Model name:", - style=WIZARD_STYLE, - qmark=QMARK, - placeholder=FormattedText([("fg:#858585", " e.g. qwen3-coder-next")]), - ).ask() - if model is None: - raise KeyboardInterrupt() - model = model.strip() - if not model: - model = "qwen3-coder-next" - console.print(f" [dim]Using default: {model}[/dim]") - return model - - # Get models for the selected provider - entries = get_models_for_provider(provider) - - if not entries: - # Custom / unknown provider: direct text input. - # Keep prompting until a non-empty model name is provided — saving an - # empty string here leaves the first request broken with an opaque - # "model required" error from the provider SDK. - while True: - model = questionary.text( - "Model name:", - style=WIZARD_STYLE, - qmark=QMARK, - placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]), - default=model_default or "", - ).ask() - if model is None: - raise KeyboardInterrupt() - model = model.strip() - if model: - return model - console.print( - " [yellow]Model name cannot be empty for a custom provider. " - "Press Ctrl+C to cancel.[/yellow]" - ) - - provider_models = [name for name, _ in entries] - - # Create choices with model IDs as hints - _CUSTOM_SENTINEL = "__custom__" - choices = [] - for name, model_id in entries: - choices.append(Choice(title=f"{name} ({model_id})", value=name)) - choices.append(Choice(title="Type a model name...", value=_CUSTOM_SENTINEL)) - - # Determine default. An explicit ``default_value`` override (e.g. a saved - # co-pilot model on a re-run) that isn't a registry model is a custom name: - # preselect "Type a model name..." and prefill it. A plain ``config.model`` - # that just isn't in the current provider's list (e.g. the provider was - # changed) falls back to the first model, NOT the custom entry. - custom_default = ( - default_value if default_value and default_value not in provider_models else "" - ) - if model_default in provider_models: - default = model_default - elif custom_default: - default = _CUSTOM_SENTINEL - else: - default = provider_models[0] - - selected = questionary.select( - model_prompt, - choices=choices, - default=default, - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, - ).ask() - - if selected is None: - raise KeyboardInterrupt() - - if selected != _CUSTOM_SENTINEL: - return selected - - model = questionary.text( - "Model name:", - default=custom_default, - style=WIZARD_STYLE, - qmark=QMARK, - placeholder=FormattedText([("fg:#858585", " e.g. owner/model-name")]), - ).ask() - if model is None: - raise KeyboardInterrupt() - model = model.strip() - if not model: - model = provider_models[0] - console.print(f" [dim]Using default: {model}[/dim]") - return model - - -def _step_auxiliary_enable(config: EvoScientistConfig) -> bool: - """Step 3.25: Choose whether to assemble a co-pilot (auxiliary) model. - - The co-pilot runs background/helper LLM calls — EvoMemory (memory workers) - and the main agent's tool selector — so it can be a cheaper/faster model. - Returns True when the user picks "Assemble"; the caller then runs the - provider/key/model pickers. Returns False to keep the pilot (main model) - everywhere. - """ - console.print( - " [dim]A cheaper/faster co-pilot for EvoMemory (memory workers).[/dim]" - ) - choice = questionary.select( - "Co-pilot (auxiliary model):", - choices=[ - Choice( - title="Skip — single pilot (main model handles everything)", - value="skip", - ), - Choice( - title="Assemble a co-pilot — separate cheaper/faster model", - value="assemble", - ), - ], - default="assemble" if config.auxiliary_model else "skip", - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, - ).ask() - if choice is None: - raise KeyboardInterrupt() - return choice == "assemble" - - -def _step_reasoning_effort(config: EvoScientistConfig) -> str: - """Step 3.5: Configure OpenRouter reasoning effort level. - - Only shown when the selected provider is OpenRouter. See: - https://openrouter.ai/docs/guides/best-practices/reasoning-tokens - - Args: - config: Current configuration. - - Returns: - Selected reasoning effort level, or empty string to use default. - """ - effort_choices = [ - Choice(title="xhigh — ~95% of max_tokens for reasoning", value="xhigh"), - Choice(title="high — ~80% of max_tokens (recommended)", value="high"), - Choice(title="medium — ~50% of max_tokens", value="medium"), - Choice(title="low — ~20% of max_tokens", value="low"), - Choice(title="minimal — ~10% of max_tokens", value="minimal"), - Choice(title="none — disable reasoning entirely", value="none"), - ] - - current = config.reasoning_effort or "high" - effort = questionary.select( - "Select reasoning effort level:", - choices=effort_choices, - default=current, - style=WIZARD_STYLE, - qmark=QMARK, - use_indicator=True, - ).ask() - - if effort is None: - raise KeyboardInterrupt() - - return effort - - def _step_tavily_key( config: EvoScientistConfig, skip_validation: bool = False, diff --git a/EvoScientist/config/onboard/validators.py b/EvoScientist/config/onboard/validators.py index 6bd415c..83a1c0d 100644 --- a/EvoScientist/config/onboard/validators.py +++ b/EvoScientist/config/onboard/validators.py @@ -1,7 +1,7 @@ """Input validators for the onboarding wizard. - IntegerValidator / ChoiceValidator: prompt_toolkit Validators -- validate_*_key: per-provider API key validators (live HTTP probes) +- validate_tavily_key: Tavily API key validator (live HTTP probe) """ from __future__ import annotations @@ -97,415 +97,6 @@ def _classify_validation_error(error: BaseException) -> tuple[bool, str] | None: return None -def validate_anthropic_key(api_key: str) -> tuple[bool, str]: - """Validate an Anthropic API key by making a test request. - - Args: - api_key: The API key to validate. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import anthropic - - client = anthropic.Anthropic(api_key=api_key) - # Make a minimal request to validate the key - client.models.list() - return True, "Valid" - except anthropic.AuthenticationError: - return False, "Invalid API key" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_openai_key(api_key: str) -> tuple[bool, str]: - """Validate an OpenAI API key by making a test request. - - Args: - api_key: The API key to validate. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI(api_key=api_key) - # Make a minimal request to validate the key - client.models.list() - return True, "Valid" - except openai.AuthenticationError: - return False, "Invalid API key" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_nvidia_key(api_key: str) -> tuple[bool, str]: - """Validate an NVIDIA API key by making a test request. - - Args: - api_key: The API key to validate. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - # NOTE: ``ChatNVIDIA(api_key=...)`` does NOT send a network request — it - # only stores the key in a client object. We must actually invoke the - # API (e.g. ``get_available_models()``) to verify the key is good. - try: - from langchain_nvidia_ai_endpoints import ChatNVIDIA - - client = ChatNVIDIA(api_key=api_key, model="meta/llama-3.1-8b-instruct") - # Force a real authenticated request via model discovery. - client.get_available_models() - return True, "Valid" - - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_google_key(api_key: str) -> tuple[bool, str]: - """Validate a Google GenAI API key by making a test request. - - Args: - api_key: The API key to validate. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - from google import genai - - client = genai.Client(api_key=api_key) - # Make a minimal request to validate the key - pager = client.models.list(config={"page_size": 1}) - next(iter(pager)) # fetch first model only - return True, "Valid" - except StopIteration: - # Empty result but request succeeded — key is valid - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - # Google-specific 400 phrasing not in the shared hint list. - error_str = str(e).lower() - if "api_key_invalid" in error_str or "api key invalid" in error_str: - return False, "Invalid API key" - return False, f"Error: {e}" - - -def validate_minimax_key( - api_key: str, - base_url: str = "https://api.minimaxi.com/anthropic", -) -> tuple[bool, str]: - """Validate a MiniMax API key without consuming tokens. - - Sends a messages.create() with an empty model string. MiniMax checks - auth *before* validating request params, so a valid key returns 400 - (bad model) while an invalid key returns 401. - - Args: - api_key: The MiniMax API key to validate. - base_url: Anthropic-compatible endpoint (global or mainland China). - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import anthropic - - client = anthropic.Anthropic( - api_key=api_key, - base_url=base_url, - ) - client.messages.create( - model="", - max_tokens=1, - messages=[{"role": "user", "content": "hi"}], - ) - # Unexpected success — treat as valid - return True, "Valid" - except anthropic.AuthenticationError: - return False, "Invalid API key" - except anthropic.APIStatusError: - # Any non-auth HTTP error (400 bad model, 500 insufficient balance, - # etc.) means the key itself was accepted → treat as valid. - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_siliconflow_key(api_key: str) -> tuple[bool, str]: - """Validate a SiliconFlow API key by making a test request. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI( - api_key=api_key, base_url="https://api.siliconflow.cn/v1" - ) - client.models.list() - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_openrouter_key(api_key: str) -> tuple[bool, str]: - """Validate an OpenRouter API key via the authenticated /auth/key endpoint. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import httpx - - resp = httpx.get( - "https://openrouter.ai/api/v1/auth/key", - headers={"Authorization": f"Bearer {api_key}"}, - timeout=10, - ) - if resp.status_code == 200: - return True, "Valid" - # Only 401/403 mean the key is actually rejected. 429 (rate-limit) - # and 5xx (OpenRouter incident) leave the key validity unknown — - # surface the real status so the user doesn't go re-roll a good key - # during an outage. - if resp.status_code in (401, 403): - return False, "Invalid API key" - return False, f"Validation inconclusive (HTTP {resp.status_code})" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_deepseek_key(api_key: str) -> tuple[bool, str]: - """Validate a DeepSeek API key by making a test request. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI(api_key=api_key, base_url="https://api.deepseek.com") - client.models.list() - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_zhipu_key(api_key: str) -> tuple[bool, str]: - """Validate a ZhipuAI API key by making a test request. - - Uses the general endpoint for validation — both zhipu and zhipu-code - share the same API key, only the base_url differs at runtime. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI( - api_key=api_key, base_url="https://open.bigmodel.cn/api/paas/v4" - ) - client.models.list() - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_volcengine_key(api_key: str) -> tuple[bool, str]: - """Validate a Volcengine API key by making a test request. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI( - api_key=api_key, - base_url="https://ark.cn-beijing.volces.com/api/v3", - ) - client.models.list() - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_dashscope_key(api_key: str) -> tuple[bool, str]: - """Validate a DashScope API key by making a test request. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI( - api_key=api_key, - base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - ) - client.models.list() - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_dashscope_code_key(api_key: str) -> tuple[bool, str]: - """Validate a DashScope Coding Plan API key (sk-sp-* subscription keys). - - The coding endpoint at coding.dashscope.aliyuncs.com does not expose - /models (returns 404), so validation issues a minimal chat completion - instead of the usual models.list() probe. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI( - api_key=api_key, - base_url="https://coding.dashscope.aliyuncs.com/v1", - ) - client.chat.completions.create( - model="qwen3-coder-plus", - messages=[{"role": "user", "content": "hi"}], - max_tokens=1, - ) - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_moonshot_key(api_key: str) -> tuple[bool, str]: - """Validate a Moonshot API key by making a test request. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import openai - - client = openai.OpenAI( - api_key=api_key, - base_url="https://api.moonshot.cn/v1", - ) - client.models.list() - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - -def validate_kimi_key(api_key: str) -> tuple[bool, str]: - """Validate a Kimi Coding Plan API key by making a test request. - - Uses the Anthropic-compatible endpoint at api.kimi.com/coding/. - - Returns: - Tuple of (is_valid, message). - """ - if not api_key: - return True, "Skipped (no key provided)" - - try: - import anthropic - - client = anthropic.Anthropic( - api_key=api_key, - base_url="https://api.kimi.com/coding/", - default_headers={"User-Agent": "claude-code/0.1.0"}, - ) - client.models.list() - return True, "Valid" - except Exception as e: - classified = _classify_validation_error(e) - if classified is not None: - return classified - return False, f"Error: {e}" - - def validate_tavily_key(api_key: str) -> tuple[bool, str]: """Validate a Tavily API key by making a test request. @@ -530,8 +121,3 @@ def validate_tavily_key(api_key: str) -> tuple[bool, str]: if classified is not None: return classified return False, f"Error: {e}" - - -# ============================================================================= -# Display Helpers -# ============================================================================= diff --git a/EvoScientist/config/onboard/wizard.py b/EvoScientist/config/onboard/wizard.py index 3994db3..4e4eaff 100644 --- a/EvoScientist/config/onboard/wizard.py +++ b/EvoScientist/config/onboard/wizard.py @@ -3,7 +3,6 @@ from __future__ import annotations import copy -import os import questionary from rich.panel import Panel @@ -17,18 +16,8 @@ from ..settings import ( ) from .channels import _step_channels from .steps import ( - _step_anthropic_auth_mode, - _step_auxiliary_enable, - _step_base_url, _step_langgraph_dev_port, _step_mcp_servers, - _step_minimax_region, - _step_model, - _step_ollama_base_url, - _step_openai_auth_mode, - _step_provider, - _step_provider_api_key, - _step_reasoning_effort, _step_skills, _step_tavily_key, _step_thinking, @@ -41,7 +30,6 @@ from .style import ( CONFIRM_STYLE, QMARK, _print_header, - _print_section, _print_step_skipped, console, ) @@ -49,10 +37,6 @@ from .style import ( STEPS = [ "UI", "LangGraph Port", - "Provider", - "API Key", - "Model", - "Auxiliary Model", "Tavily Key", "Workspace", "Thinking", @@ -110,32 +94,6 @@ def render_progress(current_step: int, completed: set[int]) -> Panel: # ============================================================================= -_PROVIDER_KEY_ATTR = { - "anthropic": "anthropic_api_key", - "minimax": "minimax_api_key", - "nvidia": "nvidia_api_key", - "google-genai": "google_api_key", - "siliconflow": "siliconflow_api_key", - "openrouter": "openrouter_api_key", - "deepseek": "deepseek_api_key", - "zhipu": "zhipu_api_key", - "zhipu-code": "zhipu_api_key", - "volcengine": "volcengine_api_key", - "dashscope": "dashscope_api_key", - "dashscope-code": "dashscope_api_key", - "moonshot": "moonshot_api_key", - "kimi-coding": "kimi_api_key", - "custom-openai": "custom_openai_api_key", - "custom-anthropic": "custom_anthropic_api_key", -} - -_MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io/anthropic" -_CUSTOM_PROVIDER_BASE_URL = { - "custom-openai": ("custom_openai_base_url", "CUSTOM_OPENAI_BASE_URL"), - "custom-anthropic": ("custom_anthropic_base_url", "CUSTOM_ANTHROPIC_BASE_URL"), -} - - def _autosave(config: EvoScientistConfig) -> None: """Persist current config to disk between phases. @@ -148,208 +106,10 @@ def _autosave(config: EvoScientistConfig) -> None: pass -def _configure_provider_base_url( - config: EvoScientistConfig, - provider: str, - *, - strict: bool, -) -> list[str]: - """Configure provider-specific base URL/region and return Ollama models.""" - if provider in _CUSTOM_PROVIDER_BASE_URL: - attr_name, env_name = _CUSTOM_PROVIDER_BASE_URL[provider] - current_base_url = getattr(config, attr_name) or os.environ.get(env_name, "") - if strict: - if not current_base_url: - raise RuntimeError( - f"--non-interactive: {provider} provider needs a base URL. " - f"Set the {env_name} env var or run without --non-interactive." - ) - setattr(config, attr_name, current_base_url) - else: - setattr( - config, - attr_name, - _step_base_url(config, current_value=current_base_url), - ) - elif provider == "minimax": - if strict: - config.minimax_base_url = ( - config.minimax_base_url or _MINIMAX_GLOBAL_BASE_URL - ) - else: - config.minimax_base_url = _step_minimax_region(config) - elif provider == "ollama": - if strict: - config.ollama_base_url = ( - config.ollama_base_url - or os.environ.get("OLLAMA_BASE_URL", "") - or "http://localhost:11434" - ) - else: - ollama_url, ollama_detected_models = _step_ollama_base_url(config) - config.ollama_base_url = ollama_url - return ollama_detected_models - return [] - - -def _configure_provider_auth_mode( - config: EvoScientistConfig, - provider: str, - *, - strict: bool, -) -> None: - """Configure Anthropic/OpenAI auth mode for the selected provider.""" - if provider == "anthropic": - if strict: - config.anthropic_auth_mode = "api_key" - else: - config.anthropic_auth_mode = _step_anthropic_auth_mode(config) - elif provider == "openai": - if strict: - config.openai_auth_mode = "api_key" - else: - config.openai_auth_mode = _step_openai_auth_mode(config) - - -def _active_llm_providers(config: EvoScientistConfig) -> set[str]: - """Return providers currently selected by the main and auxiliary models.""" - providers = {config.provider} - if config.auxiliary_provider: - providers.add(config.auxiliary_provider) - return providers - - -def _reconcile_oauth_modes(config: EvoScientistConfig) -> None: - """Clear OAuth flags for providers no selected model uses.""" - active_providers = _active_llm_providers(config) - if "anthropic" not in active_providers: - config.anthropic_auth_mode = "api_key" - if "openai" not in active_providers: - config.openai_auth_mode = "api_key" - - -def _provider_uses_oauth(config: EvoScientistConfig, provider: str) -> bool: - return (provider == "anthropic" and config.anthropic_auth_mode == "oauth") or ( - provider == "openai" and config.openai_auth_mode == "oauth" - ) - - -def _apply_preset_provider_api_key( - config: EvoScientistConfig, - provider: str, - preset_api_key: str, - *, - skip_validation: bool, -) -> None: - """Validate and store a CLI-supplied provider API key.""" - if not skip_validation: - from .helpers import _provider_key_info - - _info = _provider_key_info(config, provider) - validate_fn = _info[2] if _info else None - if validate_fn is not None: - console.print(" [dim]Validating preset API key...[/dim]", end="") - valid, msg = validate_fn(preset_api_key) - if valid: - console.print(f"\r [green]✓ {msg}[/green] ") - else: - console.print(f"\r [red]✗ {msg}[/red] ") - raise RuntimeError( - f"--api-key rejected by {provider} validator: {msg}. " - "Pass --skip-validation to override." - ) - - key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key") - setattr(config, key_attr, preset_api_key) - console.print( - f" [green]✓ API key: ***{preset_api_key[-4:]}[/green] [dim](--api-key)[/dim]" - ) - - -def _configure_provider_api_key( - config: EvoScientistConfig, - provider: str, - *, - skip_validation: bool, - preset_api_key: str | None = None, - require_api_key=None, -) -> None: - """Configure provider API key unless the provider does not need one.""" - if provider == "ollama" or _provider_uses_oauth(config, provider): - return - - key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key") - if preset_api_key is not None: - _apply_preset_provider_api_key( - config, - provider, - preset_api_key, - skip_validation=skip_validation, - ) - return - - if require_api_key is not None: - require_api_key() - new_key = _step_provider_api_key(config, provider, skip_validation) - if new_key is not None: - setattr(config, key_attr, new_key) - elif not getattr(config, key_attr): - _print_step_skipped("API Key", "not set") - - -def _provider_connection_configured(config: EvoScientistConfig, provider: str) -> bool: - """Return True when provider-level setup can be safely reused.""" - if provider == "ollama": - return bool(config.ollama_base_url) - if provider == "custom-openai" and not config.custom_openai_base_url: - return False - if provider == "custom-anthropic" and not config.custom_anthropic_base_url: - return False - if provider == "minimax" and not config.minimax_base_url: - return False - if _provider_uses_oauth(config, provider): - return True - key_attr = _PROVIDER_KEY_ATTR.get(provider, "openai_api_key") - return bool(getattr(config, key_attr)) - - -def _configure_provider_connection( - config: EvoScientistConfig, - provider: str, - *, - strict: bool, - skip_validation: bool, - preset_api_key: str | None = None, - require_api_key=None, -) -> list[str]: - """Configure provider base URL/region, auth mode, and API key.""" - ollama_detected_models = _configure_provider_base_url( - config, - provider, - strict=strict, - ) - _configure_provider_auth_mode( - config, - provider, - strict=strict, - ) - _configure_provider_api_key( - config, - provider, - skip_validation=skip_validation, - preset_api_key=preset_api_key, - require_api_key=require_api_key, - ) - return ollama_detected_models - - # Sections offered in Keep/Modify/Reset → which step labels they enable. _SECTION_LABELS: list[tuple[str, str]] = [ ("ui", "UI backend"), ("port", "LangGraph server port"), - ("provider", "LLM provider + auth + API key"), - ("model", "Model + reasoning effort"), - ("auxiliary_model", "Auxiliary model (optional)"), ("tavily", "Tavily search key"), ("workspace", "Workspace mode"), ("thinking", "Thinking panel"), @@ -360,17 +120,10 @@ _SECTION_LABELS: list[tuple[str, str]] = [ ] _ALL_SECTIONS: frozenset[str] = frozenset(s for s, _ in _SECTION_LABELS) -# Each preset flag implies the section(s) it would change. ``--provider`` also -# cascades into ``model`` because the model list depends on the provider — -# silently keeping a stale model id would leave the first request broken. +# Each preset flag implies the section(s) it would change. _FLAG_TO_SECTIONS: dict[str, frozenset[str]] = { "ui": frozenset({"ui"}), "port": frozenset({"port"}), - "provider": frozenset({"provider", "model"}), - # ``--api-key`` re-runs the provider section, which can change provider — - # cascade to model for the same reason ``--provider`` does. - "api_key": frozenset({"provider", "model"}), - "model": frozenset({"model"}), "tavily_key": frozenset({"tavily"}), "workspace_mode": frozenset({"workspace"}), "show_thinking": frozenset({"thinking"}), @@ -481,7 +234,7 @@ def run_onboard( Args: skip_validation: Skip API key validation. prompter: Optional :class:`NonInteractivePrompter` carrying - CLI-supplied answers (``--provider``, ``--model``, …) and + CLI-supplied answers (``--ui``, ``--tavily-key``, …) and ``skip_set`` (sections to bypass). When None, all prompts fall through to the interactive questionary form. only_sections: If given, restrict the wizard to exactly these section @@ -556,9 +309,9 @@ def run_onboard( # Decide which sections this run should cover. # - # - ``only_sections`` (programmatic, e.g. ``configure provider``): + # - ``only_sections`` (programmatic, e.g. ``configure mcp``): # run exactly those sections, no Keep/Modify/Reset prompt. - # - Any preset flag (``--provider``/``--model``/…): treat as + # - Any preset flag (``--ui``/``--tavily-key``/…): treat as # explicit user intent — skip Keep/Modify/Reset and run ONLY # the sections each flag implies (see ``_FLAG_TO_SECTIONS``). # - Strict ``--non-interactive`` with no preset flags: run all @@ -654,156 +407,6 @@ def run_onboard( config.langgraph_dev_port = _step_langgraph_dev_port(config) _autosave(config) - ollama_detected_models: list[str] = [] - if "provider" in sections_to_run: - from .prompter import GoBack - - _print_section("EvoScientist · Pilot (Main model)") - _require("provider", "LLM provider") - # Provider sub-loop: auth_mode can raise GoBack to re-pick provider. - # We snapshot config at the top of each iteration so a GoBack can - # roll back partial writes (base_url, minimax region, ollama URL, - # provider id itself) — otherwise picking `custom-openai`, entering - # a base URL, going Back, then picking `anthropic` would leave a - # stale ``custom_openai_base_url`` in the final saved config. - while True: - loop_snapshot = copy.deepcopy(config) - preset_provider = _preset("provider") - if preset_provider is not None: - provider = preset_provider - config.provider = provider - console.print( - f" [green]✓ Provider: {provider}[/green] " - "[dim](--provider)[/dim]" - ) - else: - provider = _step_provider(config) - config.provider = provider - - try: - ollama_detected_models = _configure_provider_connection( - config, - provider, - strict=strict, - skip_validation=skip_validation, - preset_api_key=_preset("api_key"), - require_api_key=lambda provider=provider: _require( - "api_key", f"{provider} API key" - ), - ) - except GoBack: - # User picked "← Back" — restore config to its state at the - # top of this iteration (drops any base_url / region / - # provider writes), then discard ALL provider-coupled - # presets and re-prompt. Clearing only ``provider`` - # leaves a stale ``--model`` / ``--api-key`` that would - # be re-applied under a different provider, producing - # an invalid pair (e.g. ``provider=openai`` + - # ``model=claude-sonnet-4-6``). - for field_name in vars(loop_snapshot): - setattr( - config, field_name, getattr(loop_snapshot, field_name) - ) - if p: - for stale_key in ("provider", "model", "api_key"): - p.answers.pop(stale_key, None) - ollama_detected_models = [] - console.print(" [dim]↩ Returning to provider selection.[/dim]") - continue - break # Provider setup succeeded — exit sub-loop - - _reconcile_oauth_modes(config) - _autosave(config) - else: - # Provider section skipped — keep prior provider value to drive - # downstream sections that depend on it (e.g., model picker). - provider = config.provider - - if "model" in sections_to_run: - _require("model", "Model") - preset_model = _preset("model") - if preset_model is not None: - config.model = preset_model - console.print( - f" [green]✓ Model: {preset_model}[/green] [dim](--model)[/dim]" - ) - else: - config.model = _step_model( - config, provider, ollama_detected_models=ollama_detected_models - ) - if provider == "openrouter" and _preset("model") is None: - config.reasoning_effort = _step_reasoning_effort(config) - _autosave(config) - - if "auxiliary_model" in sections_to_run: - _print_section("Co-pilot (Auxiliary model)") - if strict: - # Optional; never prompt under --non-interactive. Keep - # current (default empty = use main model). - _print_step_skipped( - "Auxiliary Model", - "kept current" if config.auxiliary_model else "not set", - ) - elif _step_auxiliary_enable(config): - from .prompter import GoBack - - aux_ollama_detected_models: list[str] = [] - while True: - loop_snapshot = copy.deepcopy(config) - aux_provider = _step_provider( - config, - label="co-pilot", - default_value=config.auxiliary_provider, - ) - config.auxiliary_provider = aux_provider - if ( - aux_provider == config.provider - and _provider_connection_configured(config, aux_provider) - ): - if aux_provider == "ollama": - aux_ollama_detected_models = ollama_detected_models - _print_step_skipped( - "Co-pilot credentials", - "reusing main provider settings", - ) - else: - try: - aux_ollama_detected_models = ( - _configure_provider_connection( - config, - aux_provider, - strict=False, - skip_validation=skip_validation, - ) - ) - except GoBack: - for field_name in vars(loop_snapshot): - setattr( - config, - field_name, - getattr(loop_snapshot, field_name), - ) - aux_ollama_detected_models = [] - console.print( - " [dim]↩ Returning to co-pilot provider " - "selection.[/dim]" - ) - continue - break - config.auxiliary_model = _step_model( - config, - aux_provider, - ollama_detected_models=aux_ollama_detected_models, - label="co-pilot", - default_value=config.auxiliary_model, - ) - else: - # Skip: single driver — clear any prior auxiliary config. - config.auxiliary_provider = "" - config.auxiliary_model = "" - _reconcile_oauth_modes(config) - _autosave(config) - if "tavily" in sections_to_run: preset_tavily = _preset("tavily_key") if preset_tavily is not None: diff --git a/EvoScientist/config/provider_admin.py b/EvoScientist/config/provider_admin.py deleted file mode 100644 index d9efcd5..0000000 --- a/EvoScientist/config/provider_admin.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Shared local authentication token for WebUI model administration.""" - -from __future__ import annotations - -import os -import secrets -import threading -from pathlib import Path - -from .settings import get_config_dir - -_TOKEN_FILE_NAME = "provider-admin-token" -_TOKEN_LOCK = threading.RLock() -_cached_file_token: tuple[Path, str] | None = None - - -def get_provider_admin_token_path() -> Path: - """Return the user-scoped provider administration token path.""" - return get_config_dir() / _TOKEN_FILE_NAME - - -def _read_token(path: Path) -> str: - try: - return path.read_text(encoding="utf-8").strip() - except OSError: - return "" - - -def get_provider_admin_token() -> str: - """Return an explicit token or create a shared local token when possible. - - Separate local backend and WebUI processes share the user-scoped token file. - Remote/container deployments can set ``EVOSCIENTIST_PROVIDER_ADMIN_TOKEN`` - explicitly when the processes do not share a filesystem. - """ - explicit = os.environ.get("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "").strip() - if explicit: - return explicit - - path = get_provider_admin_token_path() - global _cached_file_token - with _TOKEN_LOCK: - if _cached_file_token is not None and _cached_file_token[0] == path: - return _cached_file_token[1] - - token = _read_token(path) - if not token: - token = secrets.token_urlsafe(32) - try: - path.parent.mkdir(parents=True, exist_ok=True) - try: - path.parent.chmod(0o700) - except OSError: - pass - descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) - with os.fdopen(descriptor, "w", encoding="utf-8") as file: - file.write(f"{token}\n") - except FileExistsError: - token = _read_token(path) - except OSError: - return "" - - if not token: - return "" - try: - path.chmod(0o600) - except OSError: - pass - _cached_file_token = (path, token) - return token diff --git a/EvoScientist/config/provider_profiles.py b/EvoScientist/config/provider_profiles.py deleted file mode 100644 index 687c86c..0000000 --- a/EvoScientist/config/provider_profiles.py +++ /dev/null @@ -1,831 +0,0 @@ -"""Persistent, runtime-editable LLM provider profiles. - -The registry stores managed built-in connections and custom provider profiles. -``config.yaml`` remains backward compatible with the onboarding flow and acts -as a legacy fallback plus the default provider/model pointer. -""" - -from __future__ import annotations - -import hashlib -import json -import os -import re -import tempfile -import threading -from dataclasses import dataclass -from pathlib import Path -from typing import Any -from urllib.parse import urlsplit, urlunsplit - -import yaml - -from .settings import get_config_dir - -PROVIDER_PROFILES_VERSION = 3 -SUPPORTED_PROVIDER_ADAPTERS = ( - "openai", - "anthropic", - "google-genai", - "grok", - "openrouter", - "nvidia", - "antigravity", - "openai-compatible", - "anthropic-compatible", - "ollama", -) - -_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") -_BASE_URL_REQUIRED_ADAPTERS = { - "openai-compatible", - "anthropic-compatible", - "antigravity", - "ollama", -} -_DEFAULT_API_PATHS = { - "openai": "/v1", - "openai-compatible": "/v1", - "grok": "/v1", - "openrouter": "/api/v1", - "nvidia": "/v1", - "antigravity": "/v1", - "google-genai": "/v1beta", -} -_RESERVED_PROVIDER_IDS = { - "anthropic", - "openai", - "google-genai", - "minimax", - "nvidia", - "siliconflow", - "openrouter", - "zhipu", - "zhipu-code", - "volcengine", - "dashscope", - "dashscope-code", - "deepseek", - "moonshot", - "kimi-coding", - "custom-openai", - "custom-anthropic", - "ollama", -} -_STORE_LOCK = threading.RLock() - - -class ProviderProfileError(ValueError): - """Raised when a provider profile document or selection is invalid.""" - - -@dataclass(frozen=True) -class ProviderRuntime: - timeout_seconds: int = 120 - max_retries: int = 2 - default_temperature: float | None = None - default_top_p: float | None = None - default_reasoning_effort: str = "auto" - - -@dataclass(frozen=True) -class ModelRuntime: - limit_mode: str | None = None - context_window_tokens: int | None = None - max_input_tokens: int | None = None - max_output_tokens: int = 4096 - min_effective_input_tokens: int = 4096 - limits_status: str = "needs_confirmation" - limits_source: str = "user" - temperature: float | None = None - top_p: float | None = None - reasoning_effort: str = "auto" - capabilities: tuple[tuple[str, bool | str], ...] = ( - ("tools", "auto"), - ("vision", "auto"), - ("structured_output", "auto"), - ) - - -@dataclass(frozen=True) -class ProviderModel: - id: str - name: str - model_id: str - enabled: bool = True - runtime: ModelRuntime = ModelRuntime() - - -@dataclass(frozen=True) -class ProviderProfile: - id: str - name: str - adapter: str - base_url: str - api_key: str - enabled: bool - models: tuple[ProviderModel, ...] - auth_mode: str = "api_key" - runtime: ProviderRuntime = ProviderRuntime() - - -@dataclass(frozen=True) -class ProviderProfiles: - version: int = PROVIDER_PROFILES_VERSION - builtins: tuple[ProviderProfile, ...] = () - providers: tuple[ProviderProfile, ...] = () - - -def normalize_provider_base_url(adapter: str, base_url: str) -> str: - """Add the adapter's standard API path when only an origin is supplied.""" - normalized = base_url.strip().rstrip("/") - default_path = _DEFAULT_API_PATHS.get(adapter) - if not normalized or not default_path: - return normalized - - parsed = urlsplit(normalized) - if parsed.path not in {"", "/"} or parsed.query or parsed.fragment: - return normalized - return urlunsplit((parsed.scheme, parsed.netloc, default_path, "", "")) - - -def get_provider_profiles_path() -> Path: - """Return the user-scoped provider profile path.""" - return get_config_dir() / "providers.yaml" - - -def _required_string( - raw: dict[str, Any], - key: str, - *, - context: str, - max_length: int, -) -> str: - value = raw.get(key) - if not isinstance(value, str) or not value.strip(): - raise ProviderProfileError(f"{context}.{key} is required.") - value = value.strip() - if len(value) > max_length: - raise ProviderProfileError( - f"{context}.{key} must be at most {max_length} characters." - ) - return value - - -def _validate_id(value: str, *, context: str) -> str: - if not _ID_PATTERN.fullmatch(value): - raise ProviderProfileError( - f"{context} must start with a lowercase letter or digit and contain only " - "lowercase letters, digits, dots, underscores, or hyphens." - ) - return value - - -def _optional_number( - raw: dict[str, Any], key: str, *, context: str, minimum: float, maximum: float -) -> float | None: - value = raw.get(key) - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, (int, float)): - raise ProviderProfileError(f"{context}.{key} must be a number or null.") - numeric = float(value) - if not minimum <= numeric <= maximum: - raise ProviderProfileError( - f"{context}.{key} must be between {minimum:g} and {maximum:g}." - ) - return numeric - - -def _optional_positive_int(raw: dict[str, Any], key: str, *, context: str) -> int | None: - value = raw.get(key) - if value is None: - return None - if isinstance(value, bool) or not isinstance(value, int) or value <= 0: - raise ProviderProfileError(f"{context}.{key} must be a positive integer or null.") - return value - - -def _parse_provider_runtime(raw: Any, *, context: str) -> ProviderRuntime: - if raw is None: - return ProviderRuntime() - if not isinstance(raw, dict): - raise ProviderProfileError(f"{context}.runtime must be an object.") - timeout_seconds = raw.get("timeout_seconds", 120) - max_retries = raw.get("max_retries", 2) - if isinstance(timeout_seconds, bool) or not isinstance(timeout_seconds, int): - raise ProviderProfileError(f"{context}.runtime.timeout_seconds must be an integer.") - if not 10 <= timeout_seconds <= 600: - raise ProviderProfileError( - f"{context}.runtime.timeout_seconds must be between 10 and 600." - ) - if isinstance(max_retries, bool) or not isinstance(max_retries, int): - raise ProviderProfileError(f"{context}.runtime.max_retries must be an integer.") - if not 0 <= max_retries <= 5: - raise ProviderProfileError( - f"{context}.runtime.max_retries must be between 0 and 5." - ) - effort = raw.get("default_reasoning_effort", "auto") - if effort not in {"auto", "low", "medium", "high"}: - raise ProviderProfileError( - f"{context}.runtime.default_reasoning_effort is invalid." - ) - return ProviderRuntime( - timeout_seconds=timeout_seconds, - max_retries=max_retries, - default_temperature=_optional_number( - raw, "default_temperature", context=f"{context}.runtime", minimum=0, maximum=2 - ), - default_top_p=_optional_number( - raw, "default_top_p", context=f"{context}.runtime", minimum=0.000001, maximum=1 - ), - default_reasoning_effort=effort, - ) - - -def resolve_model_input_limit(runtime: ModelRuntime) -> int: - """Return the prompt limit declared by a confirmed model runtime.""" - if runtime.limits_status != "confirmed" or runtime.limit_mode is None: - raise ProviderProfileError("Model limits require confirmation before use.") - if runtime.limit_mode == "combined": - if runtime.context_window_tokens is None: - raise ProviderProfileError("context_window_tokens is required for combined limits.") - return runtime.context_window_tokens - runtime.max_output_tokens - if runtime.limit_mode == "input_only": - if runtime.max_input_tokens is None: - raise ProviderProfileError("max_input_tokens is required for input_only limits.") - return runtime.max_input_tokens - raise ProviderProfileError("limit_mode must be combined or input_only.") - - -def _parse_model_runtime(raw: Any, *, context: str) -> ModelRuntime: - if raw is None: - return ModelRuntime() - if not isinstance(raw, dict): - raise ProviderProfileError(f"{context}.runtime must be an object.") - status = raw.get("limits_status", "needs_confirmation") - if status not in {"confirmed", "needs_confirmation"}: - raise ProviderProfileError(f"{context}.runtime.limits_status is invalid.") - source = raw.get("limits_source", "user") - if source not in {"catalog", "provider", "user"}: - raise ProviderProfileError(f"{context}.runtime.limits_source is invalid.") - limit_mode = raw.get("limit_mode") - if limit_mode is not None and limit_mode not in {"combined", "input_only"}: - raise ProviderProfileError(f"{context}.runtime.limit_mode is invalid.") - max_output = raw.get("max_output_tokens", 4096) - min_effective = raw.get("min_effective_input_tokens", 4096) - if isinstance(max_output, bool) or not isinstance(max_output, int) or max_output <= 0: - raise ProviderProfileError(f"{context}.runtime.max_output_tokens must be positive.") - if isinstance(min_effective, bool) or not isinstance(min_effective, int) or min_effective < 1024: - raise ProviderProfileError( - f"{context}.runtime.min_effective_input_tokens must be at least 1024." - ) - effort = raw.get("reasoning_effort", "auto") - if effort not in {"auto", "low", "medium", "high"}: - raise ProviderProfileError(f"{context}.runtime.reasoning_effort is invalid.") - capabilities_raw = raw.get("capabilities", {}) - if not isinstance(capabilities_raw, dict): - raise ProviderProfileError(f"{context}.runtime.capabilities must be an object.") - capabilities: list[tuple[str, bool | str]] = [] - for key in ("tools", "vision", "structured_output"): - value = capabilities_raw.get(key, "auto") - if value not in {True, False, "auto"}: - raise ProviderProfileError( - f"{context}.runtime.capabilities.{key} must be true, false, or auto." - ) - capabilities.append((key, value)) - runtime = ModelRuntime( - limit_mode=limit_mode, - context_window_tokens=_optional_positive_int( - raw, "context_window_tokens", context=f"{context}.runtime" - ), - max_input_tokens=_optional_positive_int( - raw, "max_input_tokens", context=f"{context}.runtime" - ), - max_output_tokens=max_output, - min_effective_input_tokens=min_effective, - limits_status=status, - limits_source=source, - temperature=_optional_number( - raw, "temperature", context=f"{context}.runtime", minimum=0, maximum=2 - ), - top_p=_optional_number( - raw, "top_p", context=f"{context}.runtime", minimum=0.000001, maximum=1 - ), - reasoning_effort=effort, - capabilities=tuple(capabilities), - ) - if status == "confirmed": - limit = resolve_model_input_limit(runtime) - safety = max(2048, int(limit * 0.10 + 0.999999)) - if limit - safety < min_effective: - raise ProviderProfileError( - f"{context}.runtime leaves less than min_effective_input_tokens after safety reserve." - ) - return runtime - - -def _parse_model(raw: Any, *, provider_id: str, index: int) -> ProviderModel: - context = f"providers[{provider_id}].models[{index}]" - if not isinstance(raw, dict): - raise ProviderProfileError(f"{context} must be an object.") - model_id = _validate_id( - _required_string(raw, "id", context=context, max_length=64), - context=f"{context}.id", - ) - return ProviderModel( - id=model_id, - name=_required_string(raw, "name", context=context, max_length=120), - model_id=_required_string(raw, "model_id", context=context, max_length=300), - enabled=bool(raw.get("enabled", True)), - runtime=_parse_model_runtime(raw.get("runtime"), context=context), - ) - - -def _parse_profile(raw: Any, *, index: int, builtin: bool = False) -> ProviderProfile: - collection = "builtins" if builtin else "providers" - context = f"{collection}[{index}]" - if not isinstance(raw, dict): - raise ProviderProfileError(f"{context} must be an object.") - - provider_id = _validate_id( - _required_string(raw, "id", context=context, max_length=64), - context=f"{context}.id", - ) - if builtin and provider_id not in _RESERVED_PROVIDER_IDS: - raise ProviderProfileError( - f"Built-in provider ID {provider_id!r} is not recognized." - ) - if not builtin and provider_id in _RESERVED_PROVIDER_IDS: - raise ProviderProfileError( - f"Provider ID {provider_id!r} is reserved for a built-in provider. " - f"Use a unique ID such as {f'{provider_id}-custom'!r}." - ) - adapter = _required_string(raw, "adapter", context=context, max_length=64) - if adapter not in SUPPORTED_PROVIDER_ADAPTERS: - raise ProviderProfileError( - f"{context}.adapter must be one of: " - f"{', '.join(SUPPORTED_PROVIDER_ADAPTERS)}." - ) - - base_url_raw = raw.get("base_url", "") - if not isinstance(base_url_raw, str): - raise ProviderProfileError(f"{context}.base_url must be a string.") - base_url = base_url_raw.strip().rstrip("/") - if len(base_url) > 2048: - raise ProviderProfileError( - f"{context}.base_url must be at most 2048 characters." - ) - if adapter in _BASE_URL_REQUIRED_ADAPTERS and not base_url: - raise ProviderProfileError( - f"{context}.base_url is required for adapter {adapter}." - ) - if base_url and not base_url.startswith(("http://", "https://")): - raise ProviderProfileError(f"{context}.base_url must use http:// or https://.") - base_url = normalize_provider_base_url(adapter, base_url) - - api_key_raw = raw.get("api_key", "") - if not isinstance(api_key_raw, str): - raise ProviderProfileError(f"{context}.api_key must be a string.") - if len(api_key_raw) > 8192: - raise ProviderProfileError( - f"{context}.api_key must be at most 8192 characters." - ) - - auth_mode = raw.get("auth_mode", "api_key") - if not isinstance(auth_mode, str) or auth_mode not in {"api_key", "oauth"}: - raise ProviderProfileError(f"{context}.auth_mode must be 'api_key' or 'oauth'.") - - models_raw = raw.get("models", []) - if not isinstance(models_raw, list): - raise ProviderProfileError(f"{context}.models must be a list.") - models = tuple( - _parse_model(model, provider_id=provider_id, index=model_index) - for model_index, model in enumerate(models_raw) - ) - model_ids = [model.id for model in models] - if len(model_ids) != len(set(model_ids)): - raise ProviderProfileError( - f"Provider {provider_id!r} contains duplicate model IDs." - ) - portable_reasoning_adapters = { - "openai", - "openai-compatible", - "grok", - "antigravity", - "openrouter", - } - if adapter not in portable_reasoning_adapters: - for model in models: - if model.runtime.reasoning_effort != "auto": - raise ProviderProfileError( - f"{context}.models[{model.id}].runtime.reasoning_effort is " - f"not supported by adapter {adapter!r}." - ) - - return ProviderProfile( - id=provider_id, - name=_required_string(raw, "name", context=context, max_length=120), - adapter=adapter, - base_url=base_url, - api_key=api_key_raw, - enabled=bool(raw.get("enabled", True)), - models=models, - auth_mode=auth_mode, - runtime=_parse_provider_runtime(raw.get("runtime"), context=context), - ) - - -def _parse_document(raw: Any) -> ProviderProfiles: - if raw is None: - return ProviderProfiles() - if not isinstance(raw, dict): - raise ProviderProfileError("Provider profile document must be an object.") - version = raw.get("version") - if version != PROVIDER_PROFILES_VERSION: - raise ProviderProfileError( - "PROVIDER_PROFILE_RESET_REQUIRED: only version 3 provider profiles are supported." - ) - builtins_raw = raw.get("builtins", []) - if not isinstance(builtins_raw, list): - raise ProviderProfileError("builtins must be a list.") - providers_raw = raw.get("providers", []) - if not isinstance(providers_raw, list): - raise ProviderProfileError("providers must be a list.") - providers = tuple( - _parse_profile(profile, index=index, builtin=False) - for index, profile in enumerate(providers_raw) - ) - builtins = tuple( - _parse_profile(profile, index=index, builtin=True) - for index, profile in enumerate(builtins_raw) - ) - provider_ids = [provider.id for provider in providers] - if len(provider_ids) != len(set(provider_ids)): - raise ProviderProfileError("Provider IDs must be unique.") - builtin_ids = [provider.id for provider in builtins] - if len(builtin_ids) != len(set(builtin_ids)): - raise ProviderProfileError("Built-in provider IDs must be unique.") - return ProviderProfiles( - version=PROVIDER_PROFILES_VERSION, - builtins=builtins, - providers=providers, - ) - - -def _profile_to_dict(profile: ProviderProfile) -> dict[str, Any]: - return { - "id": profile.id, - "name": profile.name, - "adapter": profile.adapter, - "base_url": profile.base_url, - "api_key": profile.api_key, - "auth_mode": profile.auth_mode, - "enabled": profile.enabled, - "runtime": { - "timeout_seconds": profile.runtime.timeout_seconds, - "max_retries": profile.runtime.max_retries, - "default_temperature": profile.runtime.default_temperature, - "default_top_p": profile.runtime.default_top_p, - "default_reasoning_effort": profile.runtime.default_reasoning_effort, - }, - "models": [ - { - "id": model.id, - "name": model.name, - "model_id": model.model_id, - "enabled": model.enabled, - "runtime": { - "limit_mode": model.runtime.limit_mode, - "context_window_tokens": model.runtime.context_window_tokens, - "max_input_tokens": model.runtime.max_input_tokens, - "max_output_tokens": model.runtime.max_output_tokens, - "min_effective_input_tokens": model.runtime.min_effective_input_tokens, - "limits_status": model.runtime.limits_status, - "limits_source": model.runtime.limits_source, - "temperature": model.runtime.temperature, - "top_p": model.runtime.top_p, - "reasoning_effort": model.runtime.reasoning_effort, - "capabilities": dict(model.runtime.capabilities), - }, - } - for model in profile.models - ], - } - - -def _document_to_dict(document: ProviderProfiles) -> dict[str, Any]: - return { - "version": PROVIDER_PROFILES_VERSION, - "builtins": [_profile_to_dict(profile) for profile in document.builtins], - "providers": [_profile_to_dict(profile) for profile in document.providers], - } - - -def load_provider_profiles() -> ProviderProfiles: - """Load provider profiles, returning an empty document when absent.""" - path = get_provider_profiles_path() - with _STORE_LOCK: - if not path.exists(): - return ProviderProfiles() - try: - with path.open(encoding="utf-8") as handle: - return _parse_document(yaml.safe_load(handle)) - except ProviderProfileError: - raise - except Exception as exc: - raise ProviderProfileError( - f"Could not load provider profiles: {exc}" - ) from exc - - -def save_provider_profiles(document: ProviderProfiles) -> None: - """Atomically persist provider profiles with private permissions.""" - path = get_provider_profiles_path() - payload = _document_to_dict(document) - with _STORE_LOCK: - path.parent.mkdir(parents=True, exist_ok=True) - try: - path.parent.chmod(0o700) - except OSError: - pass - fd, temporary = tempfile.mkstemp( - prefix="providers-", suffix=".yaml.tmp", dir=path.parent - ) - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - yaml.safe_dump( - payload, - handle, - default_flow_style=False, - sort_keys=False, - allow_unicode=True, - ) - handle.flush() - os.fsync(handle.fileno()) - try: - Path(temporary).chmod(0o600) - except OSError: - pass - os.replace(temporary, path) - try: - path.chmod(0o600) - except OSError: - pass - finally: - try: - Path(temporary).unlink(missing_ok=True) - except OSError: - pass - - -def replace_provider_profiles(raw: Any) -> ProviderProfiles: - """Replace supplied profile collections while preserving the other side. - - Existing API keys are retained when an existing profile sends no ``api_key`` - or sends an empty value. ``clear_api_key: true`` explicitly removes it. - """ - if not isinstance(raw, dict): - raise ProviderProfileError("Request body must be an object.") - if "providers" not in raw and "builtins" not in raw: - raise ProviderProfileError("providers or builtins must be supplied.") - - current = load_provider_profiles() - - def merge_collection( - key: str, - supplied: Any, - existing_profiles: tuple[ProviderProfile, ...], - ) -> list[dict[str, Any]]: - if supplied is None: - return [_profile_to_dict(profile) for profile in existing_profiles] - if not isinstance(supplied, list): - raise ProviderProfileError(f"{key} must be a list.") - existing = {profile.id: profile for profile in existing_profiles} - merged: list[dict[str, Any]] = [] - for index, item in enumerate(supplied): - if not isinstance(item, dict): - raise ProviderProfileError(f"{key}[{index}] must be an object.") - candidate = dict(item) - provider_id = candidate.get("id") - prior = existing.get(provider_id) if isinstance(provider_id, str) else None - if candidate.pop("clear_api_key", False): - candidate["api_key"] = "" - elif not candidate.get("api_key") and prior is not None: - candidate["api_key"] = prior.api_key - else: - candidate.setdefault("api_key", "") - merged.append(candidate) - return merged - - document = _parse_document( - { - "version": PROVIDER_PROFILES_VERSION, - "builtins": merge_collection( - "builtins", raw.get("builtins"), current.builtins - ), - "providers": merge_collection( - "providers", raw.get("providers"), current.providers - ), - } - ) - save_provider_profiles(document) - return document - - -def resolve_provider_profile_draft(raw: Any) -> ProviderProfile: - """Validate a WebUI draft and restore its saved API key when omitted. - - Provider discovery and model tests operate on unsaved form values. The - browser never receives an existing secret, so an empty ``api_key`` keeps - the saved value for the matching provider ID. ``clear_api_key`` explicitly - opts out of that fallback. - """ - if not isinstance(raw, dict): - raise ProviderProfileError("provider must be an object.") - - candidate = dict(raw) - provider_id = candidate.get("id") - is_builtin = isinstance(provider_id, str) and provider_id in _RESERVED_PROVIDER_IDS - prior = ( - get_builtin_provider_profile(provider_id) - if is_builtin - else get_provider_profile(provider_id) - if isinstance(provider_id, str) - else None - ) - if candidate.pop("clear_api_key", False): - candidate["api_key"] = "" - elif not candidate.get("api_key") and prior is not None: - candidate["api_key"] = prior.api_key - else: - candidate.setdefault("api_key", "") - candidate.setdefault("models", []) - return _parse_profile(candidate, index=0, builtin=is_builtin) - - -def _api_key_hint(api_key: str) -> str | None: - if not api_key: - return None - if len(api_key) <= 8: - return "configured" - return f"{api_key[:3]}...{api_key[-4:]}" - - -def _provider_profiles_public_payload(document: ProviderProfiles) -> dict[str, Any]: - def profile_payload(profile: ProviderProfile) -> dict[str, Any]: - return { - "id": profile.id, - "name": profile.name, - "adapter": profile.adapter, - "base_url": profile.base_url, - "auth_mode": profile.auth_mode, - "enabled": profile.enabled, - "api_key_configured": bool(profile.api_key), - "api_key_hint": _api_key_hint(profile.api_key), - "runtime": { - "timeout_seconds": profile.runtime.timeout_seconds, - "max_retries": profile.runtime.max_retries, - "default_temperature": profile.runtime.default_temperature, - "default_top_p": profile.runtime.default_top_p, - "default_reasoning_effort": profile.runtime.default_reasoning_effort, - }, - "models": [ - { - "id": model.id, - "name": model.name, - "model_id": model.model_id, - "enabled": model.enabled, - "runtime": { - "limit_mode": model.runtime.limit_mode, - "context_window_tokens": model.runtime.context_window_tokens, - "max_input_tokens": model.runtime.max_input_tokens, - "max_output_tokens": model.runtime.max_output_tokens, - "min_effective_input_tokens": model.runtime.min_effective_input_tokens, - "limits_status": model.runtime.limits_status, - "limits_source": model.runtime.limits_source, - "temperature": model.runtime.temperature, - "top_p": model.runtime.top_p, - "reasoning_effort": model.runtime.reasoning_effort, - "capabilities": dict(model.runtime.capabilities), - }, - } - for model in profile.models - ], - } - - return { - "version": document.version, - "builtins": [profile_payload(profile) for profile in document.builtins], - "providers": [profile_payload(profile) for profile in document.providers], - } - - -def provider_profiles_public( - document: ProviderProfiles | None = None, -) -> dict[str, Any]: - """Return a browser-safe representation with all secrets removed.""" - document = document or load_provider_profiles() - public = _provider_profiles_public_payload(document) - public["reserved_provider_ids"] = sorted(_RESERVED_PROVIDER_IDS) - public["revision"] = provider_profiles_public_revision(document) - return public - - -def provider_profiles_public_revision( - document: ProviderProfiles | None = None, -) -> str: - """Return a browser-safe digest that excludes API key material.""" - document = document or load_provider_profiles() - encoded = json.dumps( - _provider_profiles_public_payload(document), - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - return hashlib.sha256(encoded).hexdigest()[:16] - - -def provider_profiles_revision( - document: ProviderProfiles | None = None, -) -> str: - """Return a private revision that changes for connection secrets too.""" - document = document or load_provider_profiles() - encoded = json.dumps( - _document_to_dict(document), sort_keys=True, separators=(",", ":") - ).encode("utf-8") - return hashlib.sha256(encoded).hexdigest()[:16] - - -def get_provider_profile(provider_id: str) -> ProviderProfile | None: - for profile in load_provider_profiles().providers: - if profile.id == provider_id: - return profile - return None - - -def get_builtin_provider_profile(provider_id: str) -> ProviderProfile | None: - for profile in load_provider_profiles().builtins: - if profile.id == provider_id: - return profile - return None - - -def get_provider_profile_revision(provider_id: str) -> str | None: - profile = get_builtin_provider_profile(provider_id) or get_provider_profile( - provider_id - ) - if profile is None: - return None - encoded = json.dumps( - _profile_to_dict(profile), sort_keys=True, separators=(",", ":") - ).encode("utf-8") - return hashlib.sha256(encoded).hexdigest()[:16] - - -def resolve_provider_model( - provider_id: str, model_name: str -) -> tuple[ProviderProfile, ProviderModel] | None: - """Resolve a configured ``(provider_id, model alias)`` pair.""" - profile = get_provider_profile(provider_id) - if profile is None: - return None - if not profile.enabled: - raise ProviderProfileError(f"Provider {provider_id!r} is disabled.") - for model in profile.models: - if model.id == model_name: - if not model.enabled: - raise ProviderProfileError( - f"Model {model_name!r} in provider {provider_id!r} is disabled." - ) - if model.runtime.limits_status != "confirmed": - raise ProviderProfileError( - f"RUNTIME_LIMITS_CONFIRMATION_REQUIRED: model {model_name!r} needs confirmed limits." - ) - return profile, model - raise ProviderProfileError( - f"Model {model_name!r} is not configured for provider {provider_id!r}." - ) - - -def list_configured_model_entries() -> list[tuple[str, str, str]]: - """Return enabled dynamic models as ``(alias, upstream id, provider id)``.""" - entries: list[tuple[str, str, str]] = [] - for profile in load_provider_profiles().providers: - if not profile.enabled: - continue - for model in profile.models: - if model.enabled: - entries.append((model.id, model.model_id, profile.id)) - return entries - - -def list_configured_builtin_model_entries() -> list[tuple[str, str, str]]: - """Return enabled managed built-in models.""" - entries: list[tuple[str, str, str]] = [] - for profile in load_provider_profiles().builtins: - if not profile.enabled: - continue - for model in profile.models: - if model.enabled: - entries.append((model.id, model.model_id, profile.id)) - return entries diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py index 5e8d915..2872108 100644 --- a/EvoScientist/config/settings.py +++ b/EvoScientist/config/settings.py @@ -146,60 +146,21 @@ def get_default_workspace_dir() -> Path: class EvoScientistConfig: """EvoScientist configuration settings. + LLM provider / model / API key configuration lives in the model + registry (model-runtime.sqlite3), not here — this dataclass only holds + platform settings (workspace, UI, channels, memory, HITL, …). + Attributes: - anthropic_api_key: Anthropic API key for Claude models. - openai_api_key: OpenAI API key for GPT models. - nvidia_api_key: NVIDIA API key for NVIDIA models. - google_api_key: Google API key for Gemini models. tavily_api_key: Tavily API key for web search. - provider: Default LLM provider ('anthropic', 'openai', 'google-genai', or 'nvidia'). - model: Default model name (short name or full ID). - auxiliary_provider: Provider for auxiliary_model (empty = use main provider). - auxiliary_model: Model for memory workers + tool selector + scheduler (empty = use main model). default_mode: Default workspace mode ('daemon' or 'run'). default_workdir: Default workspace directory (empty = use ~/.evoscientist/workspace). show_thinking: Whether to show thinking panels in CLI. """ - # API Keys - anthropic_api_key: str = "" - anthropic_base_url: str = "" - anthropic_auth_mode: str = "api_key" # "api_key" | "oauth" - openai_api_key: str = "" - openai_auth_mode: str = "api_key" # "api_key" | "oauth" - nvidia_api_key: str = "" - google_api_key: str = "" - minimax_api_key: str = "" - minimax_base_url: str = "" - siliconflow_api_key: str = "" - openrouter_api_key: str = "" - deepseek_api_key: str = "" - zhipu_api_key: str = "" - volcengine_api_key: str = "" - dashscope_api_key: str = "" - moonshot_api_key: str = "" - kimi_api_key: str = "" - custom_openai_api_key: str = "" - custom_openai_base_url: str = "" - custom_anthropic_api_key: str = "" - custom_anthropic_base_url: str = "" - ollama_base_url: str = "" + # API Keys (non-LLM tools only) tavily_api_key: str = "" - # LLM Settings - provider: str = "anthropic" - model: str = "claude-sonnet-4-6" - # Legacy built-in model allowlist. New WebUI saves store built-in and - # custom model catalogs in providers.yaml; this remains as a read-only - # compatibility fallback until the first Registry save. - model_catalog: list[dict[str, Any]] | None = None - model_fallbacks: str = "" # "model:provider,model:provider" fallback chain - # Optional auxiliary model for background/helper LLM calls (memory workers + - # tool selector). Empty = fall back to the main model/provider. - auxiliary_provider: str = "" # empty = use main provider - auxiliary_model: str = "" # empty = use main model - # Async Sub-agent Settings # When True (default), the EvoSci CLI auto-starts a langgraph dev subprocess # so any sub-agent flagged ``async: true`` in subagents/.yaml runs @@ -302,9 +263,6 @@ class EvoScientistConfig: ui_backend: Literal["cli", "tui", "webui"] = "tui" log_level: str = "warning" reasoning_effort: str = "high" - # Anthropic prompt caching for OpenRouter anthropic/* models. Opt out if - # cache-write costs outweigh the benefit for a workflow. - openrouter_anthropic_prompt_cache: bool = True # Channel Settings channel_enabled: str = "" # "imessage" | "telegram" | "discord" | "slack" | "wechat" | "dingtalk" | "feishu" | "email" | "qq" | "signal" | "" (comma-separated for multiple) @@ -460,9 +418,6 @@ class EvoScientistConfig: # DM access control policy dm_policy: str = "allowlist" - # OpenAI API mode - "" = auto, "true" = force Responses, "false" = force Completions - use_responses_api: str = "" - # ccproxy ccproxy_port: int = 8000 @@ -497,7 +452,9 @@ class EvoScientistConfig: "workspace_isolation must be one of legacy, optional, required" ) if self.scope_registry_topology != "single-host": - raise ValueError("v1 workspace isolation only supports single-host topology") + raise ValueError( + "v1 workspace isolation only supports single-host topology" + ) if self.strict_executor != "oci": raise ValueError("strict_executor must be oci") if self.strict_code_interpreter not in {"disabled", "scoped"}: @@ -513,7 +470,9 @@ class EvoScientistConfig: or isinstance(self.workspace_trash_retention_days, bool) or self.workspace_trash_retention_days <= 0 ): - raise ValueError("workspace_trash_retention_days must be a positive integer") + raise ValueError( + "workspace_trash_retention_days must be a positive integer" + ) if self.workspace_isolation == "required" and self.dangerous_mode: raise ValueError( "dangerous_mode is incompatible with required workspace isolation" @@ -802,40 +761,12 @@ def list_config() -> dict[str, Any]: # Environment variable mappings _ENV_MAPPINGS = { - "anthropic_api_key": "ANTHROPIC_API_KEY", - "anthropic_base_url": "ANTHROPIC_BASE_URL", - "anthropic_auth_mode": "EVOSCIENTIST_ANTHROPIC_AUTH_MODE", - "openai_api_key": "OPENAI_API_KEY", - "openai_auth_mode": "EVOSCIENTIST_OPENAI_AUTH_MODE", - "nvidia_api_key": "NVIDIA_API_KEY", - "google_api_key": "GOOGLE_API_KEY", - "minimax_api_key": "MINIMAX_API_KEY", - "minimax_base_url": "MINIMAX_BASE_URL", - "siliconflow_api_key": "SILICONFLOW_API_KEY", - "openrouter_api_key": "OPENROUTER_API_KEY", - "deepseek_api_key": "DEEPSEEK_API_KEY", - "zhipu_api_key": "ZHIPU_API_KEY", - "volcengine_api_key": "VOLCENGINE_API_KEY", - "dashscope_api_key": "DASHSCOPE_API_KEY", - "moonshot_api_key": "MOONSHOT_API_KEY", - "kimi_api_key": "KIMI_API_KEY", - "custom_openai_api_key": "CUSTOM_OPENAI_API_KEY", - "custom_openai_base_url": "CUSTOM_OPENAI_BASE_URL", - "custom_anthropic_api_key": "CUSTOM_ANTHROPIC_API_KEY", - "custom_anthropic_base_url": "CUSTOM_ANTHROPIC_BASE_URL", - "ollama_base_url": "OLLAMA_BASE_URL", "tavily_api_key": "TAVILY_API_KEY", "default_mode": "EVOSCIENTIST_DEFAULT_MODE", "default_workdir": "EVOSCIENTIST_WORKSPACE_DIR", "ui_backend": "EVOSCIENTIST_UI_BACKEND", "log_level": "EVOSCIENTIST_LOG_LEVEL", - "model_fallbacks": "EVOSCIENTIST_MODEL_FALLBACKS", - "auxiliary_provider": "EVOSCIENTIST_AUXILIARY_PROVIDER", - "auxiliary_model": "EVOSCIENTIST_AUXILIARY_MODEL", "reasoning_effort": "EVOSCIENTIST_REASONING_EFFORT", - "openrouter_anthropic_prompt_cache": ( - "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE" - ), "dangerous_mode": "EVOSCIENTIST_DANGEROUS_MODE", "workspace_isolation": "EVOSCIENTIST_WORKSPACE_ISOLATION", "scope_registry_topology": "EVOSCIENTIST_SCOPE_REGISTRY_TOPOLOGY", @@ -846,7 +777,6 @@ _ENV_MAPPINGS = { "workspace_trash_retention_days": "EVOSCIENTIST_WORKSPACE_TRASH_RETENTION_DAYS", "channel_debug_tracing": "EVOSCIENTIST_CHANNEL_DEBUG_TRACING", "ccproxy_port": "EVOSCIENTIST_CCPROXY_PORT", - "use_responses_api": "EVOSCIENTIST_USE_RESPONSES_API", "checkpoint_keep_per_thread": "EVOSCIENTIST_CHECKPOINT_KEEP_PER_THREAD", "enable_async_subagents": "EVOSCIENTIST_ENABLE_ASYNC_SUBAGENTS", "langgraph_dev_port": "EVOSCIENTIST_LANGGRAPH_DEV_PORT", @@ -918,42 +848,19 @@ def get_effective_config( def apply_config_to_env(config: EvoScientistConfig) -> None: - """Apply config API keys to environment variables if not already set. + """Apply config values to environment variables if not already set. - This allows the config file to provide API keys that downstream - libraries (like langchain-anthropic) can pick up. + LLM provider keys are no longer injected here — they live in the model + registry (model-runtime.sqlite3) and are applied by the model runtime. + Only non-LLM tool keys and platform round-trips remain. Args: config: Configuration to apply. """ - _apply_config_env("ANTHROPIC_API_KEY", config.anthropic_api_key) - _apply_config_env("ANTHROPIC_BASE_URL", config.anthropic_base_url) - _apply_config_env("OPENAI_API_KEY", config.openai_api_key) - _apply_config_env("NVIDIA_API_KEY", config.nvidia_api_key) - _apply_config_env("GOOGLE_API_KEY", config.google_api_key) - _apply_config_env("MINIMAX_API_KEY", config.minimax_api_key) - _apply_config_env("MINIMAX_BASE_URL", config.minimax_base_url) - _apply_config_env("SILICONFLOW_API_KEY", config.siliconflow_api_key) - _apply_config_env("OPENROUTER_API_KEY", config.openrouter_api_key) - _apply_config_env("DEEPSEEK_API_KEY", config.deepseek_api_key) - _apply_config_env("ZHIPU_API_KEY", config.zhipu_api_key) - _apply_config_env("VOLCENGINE_API_KEY", config.volcengine_api_key) - _apply_config_env("DASHSCOPE_API_KEY", config.dashscope_api_key) - _apply_config_env("MOONSHOT_API_KEY", config.moonshot_api_key) - _apply_config_env("KIMI_API_KEY", config.kimi_api_key) - _apply_config_env("CUSTOM_OPENAI_API_KEY", config.custom_openai_api_key) - _apply_config_env("CUSTOM_OPENAI_BASE_URL", config.custom_openai_base_url) - _apply_config_env("CUSTOM_ANTHROPIC_API_KEY", config.custom_anthropic_api_key) - _apply_config_env("CUSTOM_ANTHROPIC_BASE_URL", config.custom_anthropic_base_url) - _apply_config_env("OLLAMA_BASE_URL", config.ollama_base_url) if config.tavily_api_key and not os.environ.get("TAVILY_API_KEY"): os.environ["TAVILY_API_KEY"] = config.tavily_api_key if config.reasoning_effort and not os.environ.get("EVOSCIENTIST_REASONING_EFFORT"): os.environ["EVOSCIENTIST_REASONING_EFFORT"] = config.reasoning_effort - if not config.openrouter_anthropic_prompt_cache and not os.environ.get( - "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE" - ): - os.environ["EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE"] = "false" # Round-trip dangerous_mode to env so it survives a fresh get_effective_config() # (warning banner, run_in_background) and is inherited by the langgraph dev # subprocess — otherwise a --dangerous CLI flag (not persisted to file/env) @@ -964,7 +871,3 @@ def apply_config_to_env(config: EvoScientistConfig) -> None: os.environ["EVOSCIENTIST_DANGEROUS_MODE"] = "true" else: os.environ.pop("EVOSCIENTIST_DANGEROUS_MODE", None) - if config.use_responses_api and not os.environ.get( - "EVOSCIENTIST_USE_RESPONSES_API" - ): - os.environ["EVOSCIENTIST_USE_RESPONSES_API"] = config.use_responses_api diff --git a/EvoScientist/deploy/server.py b/EvoScientist/deploy/server.py index cf1b78b..17e20fa 100644 --- a/EvoScientist/deploy/server.py +++ b/EvoScientist/deploy/server.py @@ -254,9 +254,14 @@ def deploy( "trust.[/bold red]" ) - # 6. ccproxy lifecycle (only if any provider uses OAuth) + # 6. ccproxy lifecycle (only if any provider uses OAuth). The legacy + # config.yaml auth-mode fields are gone with the unified model registry; + # getattr degrades to "api_key" so the OAuth branch simply stays off here. _ccproxy_proc = None - if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth": + if ( + getattr(config, "anthropic_auth_mode", "api_key") == "oauth" + or getattr(config, "openai_auth_mode", "api_key") == "oauth" + ): try: from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy diff --git a/EvoScientist/deploy/webui.py b/EvoScientist/deploy/webui.py index f6d1786..aaae425 100644 --- a/EvoScientist/deploy/webui.py +++ b/EvoScientist/deploy/webui.py @@ -63,7 +63,6 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: tears down both subprocesses. Never returns a value. """ from ..config import apply_config_to_env, get_default_workspace_dir - from ..config.provider_admin import get_provider_admin_token from ..langgraph_dev.manager import ( _DEFAULT_PORT, RUNTIME, @@ -149,16 +148,6 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: usage_env = {} os.environ.update(usage_env) - # The provider editor uses a server-to-server management token. Resolve it - # through the shared user-level token file so a separately started WebUI, - # a fresh backend, and a reused backend all authenticate with the same - # value. The browser never receives it; the Next.js proxy adds it. - provider_admin_token = get_provider_admin_token() - if not provider_admin_token: - console.print("[red]Could not create the provider management token.[/red]") - raise typer.Exit(1) - os.environ["EVOSCIENTIST_PROVIDER_ADMIN_TOKEN"] = provider_admin_token - # 4. Backend (langgraph dev): reuse an EvoSci server already on the port, # else start a fresh deploy-mode one (full MCP + async). Refuse a foreign # occupant — that's a configuration error, not something to silently share. @@ -237,7 +226,6 @@ def run_webui(config: Any, workspace_dir: str | None = None) -> None: **usage_env, "EVOSCIENTIST_LANGGRAPH_DEV_PORT": str(backend_port), "EVOSCIENTIST_BACKEND_URL": f"http://127.0.0.1:{backend_port}", - "EVOSCIENTIST_PROVIDER_ADMIN_TOKEN": provider_admin_token, "EVOSCIENTIST_BACKEND_SERVICE_TOKEN": scope_service_token, "EVOSCIENTIST_WORKSPACE_DIR": ws, "PORT": str(webui_port), diff --git a/EvoScientist/gateway/local.py b/EvoScientist/gateway/local.py index aac0096..8ad7738 100644 --- a/EvoScientist/gateway/local.py +++ b/EvoScientist/gateway/local.py @@ -147,14 +147,34 @@ class LocalGraphGateway: target: GraphTarget, request: RunRequest, ) -> AsyncIterator[GraphEvent]: + from langgraph.types import Command + from ..stream.events import stream_agent_events + # Local snapshot entry (design doc 8.1): freeze the registry + # defaults into a per-turn run snapshot and carry only its ID in + # configurable. Resumed runs (HITL Command(resume=...)) keep the + # snapshot the original turn froze — a resume must not re-freeze + # newer defaults. A bootstrap registry raises + # MODEL_REGISTRY_NOT_READY here instead of falling back to any + # implicit default model. + runtime_snapshot_id: str | None = None + if not (isinstance(request.message, Command) and request.message.resume): + from ..model_registry.runtime import get_snapshot_runtime + + runtime_snapshot_id = ( + get_snapshot_runtime() + .create_local_snapshot(request.thread_id) + .snapshot_id + ) + inner = stream_agent_events( local_graph, request.message, request.thread_id, metadata=request.metadata, media=request.media, + runtime_snapshot_id=runtime_snapshot_id, ) try: async for event in inner: diff --git a/EvoScientist/gateway/server.py b/EvoScientist/gateway/server.py index 31b14df..442ce0d 100644 --- a/EvoScientist/gateway/server.py +++ b/EvoScientist/gateway/server.py @@ -568,6 +568,17 @@ class LangGraphServerGateway: request.message, media=request.media, ) + # Local snapshot entry (design doc 8.1): the langgraph server shares + # the same model-runtime store, so the snapshot frozen here is the + # one the server-side run resolves. Resume turns returned above and + # keep the snapshot the original turn froze. + from ..model_registry.runtime import get_snapshot_runtime + + config["configurable"]["runtime_snapshot_id"] = ( + get_snapshot_runtime() + .create_local_snapshot(request.thread_id) + .snapshot_id + ) await stream.run.start( input=run_input, config=config, diff --git a/EvoScientist/langgraph_dev/http.py b/EvoScientist/langgraph_dev/http.py index 9877ea3..ad89b9c 100644 --- a/EvoScientist/langgraph_dev/http.py +++ b/EvoScientist/langgraph_dev/http.py @@ -14,22 +14,20 @@ Starlette; adding FastAPI would pull in pydantic v1-vs-v2 reconciliation the deploy doesn't need. The one route here has no input model, just a JSON body, so the lower-level surface is sufficient. -Lightweight by design — module-level imports stick to ``config``, -``llm.models`` (registry only; no chat-model construction), and -Starlette itself. Nothing on this surface should pull the agent into -memory. +Lightweight by design — module-level imports stick to +``model_registry.http_api``, ``sessions``, and Starlette itself. Nothing +on this surface should pull the agent into memory. """ from __future__ import annotations import asyncio -import hashlib +import contextlib import json import logging import os import secrets import sqlite3 -import threading from pathlib import Path from typing import Any @@ -42,39 +40,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from starlette.routing import Route -from EvoScientist.config import ( - get_effective_config, - is_config_applied_env, - load_config, - save_config, -) -from EvoScientist.config.provider_admin import get_provider_admin_token -from EvoScientist.config.provider_profiles import ( - ProviderModel, - ProviderProfileError, - list_configured_builtin_model_entries, - list_configured_model_entries, - load_provider_profiles, - provider_profiles_public, - provider_profiles_revision, - replace_provider_profiles, - resolve_provider_profile_draft, -) -from EvoScientist.llm.models import ( - list_builtin_model_catalog_entries, - list_model_picker_entries, - list_models_by_provider, - normalize_builtin_model_catalog, -) -from EvoScientist.llm.provider_operations import ( - ProviderOperationError, - build_builtin_provider_profile, - builtin_provider_adapter, - builtin_provider_ids, - builtin_provider_legacy_api_key, - discover_provider_models, - test_provider_model, -) +from EvoScientist.config.legacy_artifacts import assert_no_legacy_artifacts from EvoScientist.model_registry.http_api import model_registry_routes from EvoScientist.sessions import ( MAIN_THREAD_FILTER_PARAMS, @@ -85,730 +51,6 @@ from EvoScientist.sessions import ( ) _logger = logging.getLogger(__name__) -_config_file_lock = threading.RLock() - -_LLM_CONFIG_VALUE_FIELDS = ( - "provider", - "model", - "model_fallbacks", - "auxiliary_provider", - "auxiliary_model", - "anthropic_base_url", - "anthropic_auth_mode", - "openai_auth_mode", - "minimax_base_url", - "custom_openai_base_url", - "custom_anthropic_base_url", - "ollama_base_url", -) -_LLM_CONFIG_SECRET_FIELDS = ( - "anthropic_api_key", - "openai_api_key", - "nvidia_api_key", - "google_api_key", - "minimax_api_key", - "siliconflow_api_key", - "openrouter_api_key", - "deepseek_api_key", - "zhipu_api_key", - "volcengine_api_key", - "dashscope_api_key", - "moonshot_api_key", - "kimi_api_key", - "custom_openai_api_key", - "custom_anthropic_api_key", -) -_LLM_CONFIG_ENV_VARS = { - "anthropic_api_key": "ANTHROPIC_API_KEY", - "anthropic_base_url": "ANTHROPIC_BASE_URL", - "anthropic_auth_mode": "EVOSCIENTIST_ANTHROPIC_AUTH_MODE", - "openai_api_key": "OPENAI_API_KEY", - "openai_auth_mode": "EVOSCIENTIST_OPENAI_AUTH_MODE", - "nvidia_api_key": "NVIDIA_API_KEY", - "google_api_key": "GOOGLE_API_KEY", - "minimax_api_key": "MINIMAX_API_KEY", - "minimax_base_url": "MINIMAX_BASE_URL", - "siliconflow_api_key": "SILICONFLOW_API_KEY", - "openrouter_api_key": "OPENROUTER_API_KEY", - "deepseek_api_key": "DEEPSEEK_API_KEY", - "zhipu_api_key": "ZHIPU_API_KEY", - "volcengine_api_key": "VOLCENGINE_API_KEY", - "dashscope_api_key": "DASHSCOPE_API_KEY", - "moonshot_api_key": "MOONSHOT_API_KEY", - "kimi_api_key": "KIMI_API_KEY", - "custom_openai_api_key": "CUSTOM_OPENAI_API_KEY", - "custom_openai_base_url": "CUSTOM_OPENAI_BASE_URL", - "custom_anthropic_api_key": "CUSTOM_ANTHROPIC_API_KEY", - "custom_anthropic_base_url": "CUSTOM_ANTHROPIC_BASE_URL", - "ollama_base_url": "OLLAMA_BASE_URL", - "model_fallbacks": "EVOSCIENTIST_MODEL_FALLBACKS", - "auxiliary_provider": "EVOSCIENTIST_AUXILIARY_PROVIDER", - "auxiliary_model": "EVOSCIENTIST_AUXILIARY_MODEL", -} -_LLM_CONFIG_BASE_URL_FIELDS = { - "anthropic_base_url", - "minimax_base_url", - "custom_openai_base_url", - "custom_anthropic_base_url", - "ollama_base_url", -} -_LLM_CONFIG_AUTH_MODE_FIELDS = {"anthropic_auth_mode", "openai_auth_mode"} -_LLM_CONFIG_IMMEDIATE_FIELDS = { - "provider", - "model", - "model_catalog", - "builtin_providers", -} - - -class _ConfigRevisionConflict(ValueError): - """Raised when config.yaml changed after the WebUI loaded it.""" - - -def _llm_config_revision(config: Any) -> str: - values = { - field: getattr(config, field) - for field in (*_LLM_CONFIG_VALUE_FIELDS, *_LLM_CONFIG_SECRET_FIELDS) - } - values["model_catalog"] = getattr(config, "model_catalog", None) - values["provider_registry_revision"] = provider_profiles_revision() - encoded = json.dumps(values, sort_keys=True, separators=(",", ":")).encode("utf-8") - return hashlib.sha256(encoded).hexdigest()[:16] - - -def _model_catalog_profile_models( - config: Any, provider_id: str -) -> tuple[ProviderModel, ...]: - try: - catalog = normalize_builtin_model_catalog(config.model_catalog) or [] - except ValueError: - return () - return tuple( - ProviderModel( - id=entry["id"], - name=entry["name"], - model_id=entry["model_id"], - enabled=entry["enabled"], - ) - for entry in catalog - if entry["provider"] == provider_id - ) - - -def _builtin_profiles_public(config: Any, document: Any) -> list[dict[str, Any]]: - managed_by_id = {profile.id: profile for profile in document.builtins} - result: list[dict[str, Any]] = [] - for provider_id in builtin_provider_ids(): - managed = managed_by_id.get(provider_id) - effective = build_builtin_provider_profile( - config, - provider_id, - saved_profile=managed, - load_saved=False, - ) - models = ( - managed.models - if managed is not None - else _model_catalog_profile_models(config, provider_id) - ) - result.append( - { - "id": provider_id, - "name": managed.name if managed is not None else provider_id, - "adapter": builtin_provider_adapter(provider_id), - "base_url": effective.base_url, - "auth_mode": effective.auth_mode, - "enabled": managed.enabled if managed is not None else True, - "managed": managed is not None, - "api_key_configured": bool(effective.api_key), - "api_key_hint": ( - f"...{effective.api_key[-4:]}" if effective.api_key else None - ), - "models": [ - { - "id": model.id, - "name": model.name, - "model_id": model.model_id, - "enabled": model.enabled, - } - for model in models - ], - } - ) - return result - - -def _llm_config_public(config: Any) -> dict[str, Any]: - values = {field: getattr(config, field) for field in _LLM_CONFIG_VALUE_FIELDS} - secret_status = {} - for field in _LLM_CONFIG_SECRET_FIELDS: - value = getattr(config, field) - secret_status[field] = { - "configured": bool(value), - "hint": f"...{value[-4:]}" if value else None, - } - env_overrides = { - field: env_name - for field, env_name in _LLM_CONFIG_ENV_VARS.items() - if os.environ.get(env_name) and not is_config_applied_env(env_name) - } - catalog_error = None - provider_document = load_provider_profiles() - managed_builtins = provider_document.builtins - if managed_builtins: - model_catalog = [ - { - "provider": profile.id, - "id": model.id, - "name": model.name, - "model_id": model.model_id, - "enabled": model.enabled, - } - for profile in managed_builtins - for model in profile.models - ] - else: - try: - model_catalog = normalize_builtin_model_catalog(config.model_catalog) - except ValueError as exc: - model_catalog = [] - catalog_error = str(exc) - return { - "revision": _llm_config_revision(config), - "values": values, - "secrets": secret_status, - "env_overrides": env_overrides, - "model_catalog": model_catalog, - "model_catalog_error": catalog_error, - "builtin_providers": _builtin_profiles_public(config, provider_document), - "builtin_model_candidates": [ - { - "id": name, - "name": name, - "model_id": model_id, - "provider": provider, - "enabled": True, - } - for name, model_id, provider in list_models_by_provider() - ], - } - - -def _read_llm_config() -> dict[str, Any]: - with _config_file_lock: - return _llm_config_public(load_config()) - - -def _validated_llm_config_value(field: str, value: Any) -> str: - if not isinstance(value, str): - raise ValueError(f"{field} must be a string.") - value = value.strip() - max_length = 4096 if field == "model_fallbacks" else 2048 - if field in {"provider", "auxiliary_provider"}: - max_length = 64 - elif field in {"model", "auxiliary_model"}: - max_length = 300 - elif field in _LLM_CONFIG_AUTH_MODE_FIELDS: - max_length = 16 - if len(value) > max_length: - raise ValueError(f"{field} must be at most {max_length} characters.") - if field in _LLM_CONFIG_AUTH_MODE_FIELDS and value not in {"api_key", "oauth"}: - raise ValueError(f"{field} must be 'api_key' or 'oauth'.") - if ( - field in _LLM_CONFIG_BASE_URL_FIELDS - and value - and not value.startswith(("http://", "https://")) - ): - raise ValueError(f"{field} must use http:// or https://.") - return value - - -def _patch_llm_config(payload: Any) -> dict[str, Any]: - if not isinstance(payload, dict): - raise ValueError("Request body must be an object.") - revision = payload.get("revision") - if not isinstance(revision, str) or not revision: - raise ValueError("revision is required.") - values = payload.get("values", {}) - secrets_payload = payload.get("secrets", {}) - clear_secrets = payload.get("clear_secrets", []) - builtin_profiles_supplied = "builtin_providers" in payload - builtin_profiles_payload = payload.get("builtin_providers") - if builtin_profiles_supplied and not isinstance(builtin_profiles_payload, list): - raise ValueError("builtin_providers must be a list.") - catalog_supplied = "model_catalog" in payload and not builtin_profiles_supplied - normalized_catalog = ( - normalize_builtin_model_catalog(payload.get("model_catalog")) - if catalog_supplied - else None - ) - if not isinstance(values, dict): - raise ValueError("values must be an object.") - if not isinstance(secrets_payload, dict): - raise ValueError("secrets must be an object.") - if not isinstance(clear_secrets, list) or not all( - isinstance(field, str) for field in clear_secrets - ): - raise ValueError("clear_secrets must be a list of strings.") - - unknown_values = set(values) - set(_LLM_CONFIG_VALUE_FIELDS) - unknown_secrets = set(secrets_payload) - set(_LLM_CONFIG_SECRET_FIELDS) - unknown_clears = set(clear_secrets) - set(_LLM_CONFIG_SECRET_FIELDS) - unknown = sorted(unknown_values | unknown_secrets | unknown_clears) - if unknown: - raise ValueError(f"Unsupported config fields: {', '.join(unknown)}.") - conflicting_secrets = sorted(set(secrets_payload) & set(clear_secrets)) - if conflicting_secrets: - raise ValueError( - "Secrets cannot be replaced and cleared together: " - f"{', '.join(conflicting_secrets)}." - ) - - with _config_file_lock: - config = load_config() - if revision != _llm_config_revision(config): - raise _ConfigRevisionConflict( - "config.yaml changed after it was loaded. Reload and try again." - ) - - changed_fields: list[str] = [] - for field, raw_value in values.items(): - value = _validated_llm_config_value(field, raw_value) - if getattr(config, field) != value: - setattr(config, field, value) - changed_fields.append(field) - - for field, raw_value in secrets_payload.items(): - if not isinstance(raw_value, str): - raise ValueError(f"{field} must be a string.") - value = raw_value.strip() - if len(value) > 8192: - raise ValueError(f"{field} must be at most 8192 characters.") - if value and getattr(config, field) != value: - setattr(config, field, value) - changed_fields.append(field) - - for field in clear_secrets: - if getattr(config, field): - setattr(config, field, "") - changed_fields.append(field) - - if catalog_supplied and config.model_catalog != normalized_catalog: - config.model_catalog = normalized_catalog - changed_fields.append("model_catalog") - - if builtin_profiles_supplied: - current_document = load_provider_profiles() - current_by_id = { - profile.id: profile for profile in current_document.builtins - } - prepared: list[dict[str, Any]] = [] - for index, raw_profile in enumerate(builtin_profiles_payload): - if not isinstance(raw_profile, dict): - raise ValueError(f"builtin_providers[{index}] must be an object.") - if raw_profile.get("managed") is False: - continue - candidate = dict(raw_profile) - provider_id = candidate.get("id") - if not isinstance(provider_id, str) or not provider_id.strip(): - raise ValueError(f"builtin_providers[{index}].id is required.") - provider_id = provider_id.strip() - candidate["id"] = provider_id - candidate.setdefault("name", provider_id) - candidate["adapter"] = builtin_provider_adapter(provider_id) - candidate.pop("managed", None) - candidate.pop("api_key_configured", None) - candidate.pop("api_key_hint", None) - if ( - not candidate.get("api_key") - and not candidate.get("clear_api_key") - and provider_id not in current_by_id - ): - candidate["api_key"] = builtin_provider_legacy_api_key( - config, provider_id - ) - prepared.append(candidate) - - if not current_document.builtins: - prepared_by_id = {candidate["id"]: candidate for candidate in prepared} - try: - legacy_catalog = ( - normalize_builtin_model_catalog(config.model_catalog) or [] - ) - except ValueError: - legacy_catalog = [] - migration_ids = {entry["provider"] for entry in legacy_catalog} - if config.provider in builtin_provider_ids(): - migration_ids.add(config.provider) - for provider_id in migration_ids: - if provider_id in prepared_by_id: - continue - effective = build_builtin_provider_profile( - config, - provider_id, - load_saved=False, - use_environment=False, - ) - models = [ - { - "id": entry["id"], - "name": entry["name"], - "model_id": entry["model_id"], - "enabled": entry["enabled"], - } - for entry in legacy_catalog - if entry["provider"] == provider_id - ] - if not models and provider_id == config.provider: - known = next( - ( - (name, model_id) - for name, model_id, candidate_provider in list_models_by_provider() - if candidate_provider == provider_id - and name == config.model - ), - None, - ) - models = [ - { - "id": config.model, - "name": config.model, - "model_id": known[1] if known else config.model, - "enabled": True, - } - ] - prepared.append( - { - "id": provider_id, - "name": provider_id, - "adapter": builtin_provider_adapter(provider_id), - "base_url": effective.base_url, - "api_key": builtin_provider_legacy_api_key( - config, provider_id - ), - "auth_mode": effective.auth_mode, - "enabled": True, - "models": models, - } - ) - - prepared_entries = { - (model.get("id"), candidate.get("id")) - for candidate in prepared - if candidate.get("enabled", True) - for model in candidate.get("models", []) - if isinstance(model, dict) and model.get("enabled", True) - } - prepared_entries.update( - (model.id, profile.id) - for profile in current_document.providers - if profile.enabled - for model in profile.models - if model.enabled - ) - if (config.model, config.provider) not in prepared_entries: - raise ValueError( - f"Default model {config.model!r} is not enabled for provider " - f"{config.provider!r}." - ) - - before = current_document.builtins - updated_document = replace_provider_profiles({"builtins": prepared}) - if updated_document.builtins != before: - changed_fields.append("builtin_providers") - for profile in updated_document.builtins: - if profile.id == "anthropic" and ( - config.anthropic_auth_mode != profile.auth_mode - ): - config.anthropic_auth_mode = profile.auth_mode - changed_fields.append("anthropic_auth_mode") - if profile.id == "openai" and ( - config.openai_auth_mode != profile.auth_mode - ): - config.openai_auth_mode = profile.auth_mode - changed_fields.append("openai_auth_mode") - - managed_builtin_entries = list_configured_builtin_model_entries() - if managed_builtin_entries or config.model_catalog is not None: - available = set(managed_builtin_entries) - if not managed_builtin_entries: - available.update( - list_builtin_model_catalog_entries(config.model_catalog) or [] - ) - available.update(list_configured_model_entries()) - selected = (config.model, config.provider) - if not any( - name == selected[0] and provider == selected[1] - for name, _, provider in available - ): - raise ValueError( - f"Default model {config.model!r} is not enabled for provider " - f"{config.provider!r}." - ) - - if any(field != "builtin_providers" for field in changed_fields): - save_config(config) - response = _llm_config_public(config) - response["changed_fields"] = sorted(set(changed_fields)) - response["restart_required"] = any( - field not in _LLM_CONFIG_IMMEDIATE_FIELDS for field in changed_fields - ) - return response - - -def _provider_admin_error(request: Request) -> JSONResponse | None: - expected = get_provider_admin_token() - if not expected: - return JSONResponse( - {"error": "Provider management is not enabled for this deployment."}, - status_code=503, - ) - supplied = request.headers.get("x-evoscientist-admin-token", "") - if not supplied or not secrets.compare_digest(supplied, expected): - return JSONResponse({"error": "Forbidden."}, status_code=403) - return None - - -async def provider_profiles_endpoint(request: Request) -> JSONResponse: - """Read or replace runtime-editable provider profiles. - - API keys are accepted on writes but never returned. Existing keys are kept - when the client sends an empty value, allowing the WebUI to edit metadata - without receiving the secret first. - """ - auth_error = _provider_admin_error(request) - if auth_error is not None: - return auth_error - - try: - if request.method == "GET": - document = await asyncio.to_thread(load_provider_profiles) - else: - payload = await request.json() - document = await asyncio.to_thread(replace_provider_profiles, payload) - return JSONResponse(provider_profiles_public(document)) - except ProviderProfileError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - except (json.JSONDecodeError, UnicodeDecodeError): - return JSONResponse( - {"error": "Request body must be valid JSON."}, status_code=400 - ) - except Exception: - _logger.exception("Provider profile request failed") - return JSONResponse( - {"error": "Provider profile request failed."}, status_code=500 - ) - - -async def llm_config_endpoint(request: Request) -> JSONResponse: - """Read or patch the LLM-related fields persisted in config.yaml.""" - auth_error = _provider_admin_error(request) - if auth_error is not None: - return auth_error - - try: - if request.method == "GET": - response = await asyncio.to_thread(_read_llm_config) - response["changed_fields"] = [] - response["restart_required"] = False - return JSONResponse(response) - payload = await request.json() - if request.method == "POST": - if not isinstance(payload, dict): - raise ValueError("Request body must be an object.") - action = payload.get("action") - provider_draft = payload.get("provider") - if not isinstance(provider_draft, dict): - raise ValueError("provider must be an object.") - provider_id = provider_draft.get("id") - if not isinstance(provider_id, str) or not provider_id.strip(): - raise ValueError("provider.id is required.") - cfg = await asyncio.to_thread(get_effective_config) - base_url = provider_draft.get("base_url") - api_key = provider_draft.get("api_key") - if base_url is not None and not isinstance(base_url, str): - raise ValueError("provider.base_url must be a string.") - if api_key is not None and not isinstance(api_key, str): - raise ValueError("provider.api_key must be a string.") - profile = build_builtin_provider_profile( - cfg, - provider_id.strip(), - base_url_override=base_url, - api_key_override=api_key, - clear_api_key=provider_draft.get("clear_api_key") is True, - ) - if action == "list_models": - models = await discover_provider_models(profile) - return JSONResponse( - { - "models": [ - {"model_id": model.model_id, "name": model.name} - for model in models - ] - } - ) - if action == "test_model": - model = _provider_model_from_payload(payload.get("model")) - result = await test_provider_model(profile, model) - return JSONResponse( - { - "ok": True, - "latency_ms": result.latency_ms, - "response": result.response, - } - ) - raise ValueError("action must be list_models or test_model.") - return JSONResponse(await asyncio.to_thread(_patch_llm_config, payload)) - except _ConfigRevisionConflict as exc: - return JSONResponse({"error": str(exc)}, status_code=409) - except (json.JSONDecodeError, UnicodeDecodeError): - return JSONResponse( - {"error": "Request body must be valid JSON."}, status_code=400 - ) - except ValueError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - except ProviderOperationError as exc: - return JSONResponse({"error": str(exc)}, status_code=502) - except Exception: - _logger.exception("LLM config request failed") - return JSONResponse({"error": "LLM config request failed."}, status_code=500) - - -def _persist_default_model(model: str, provider: str) -> None: - """Update the persisted default pair without disturbing other settings.""" - with _config_file_lock: - config = load_config() - config.model = model - config.provider = provider - save_config(config) - - -async def default_model_endpoint(request: Request) -> JSONResponse: - """Validate and persist the deployment's default model selection.""" - auth_error = _provider_admin_error(request) - if auth_error is not None: - return auth_error - - try: - payload = await request.json() - if not isinstance(payload, dict): - raise ValueError("Request body must be an object.") - - model = payload.get("model") - provider = payload.get("provider") - if not isinstance(model, str) or not model.strip(): - raise ValueError("model is required.") - if not isinstance(provider, str) or not provider.strip(): - raise ValueError("provider is required.") - model = model.strip() - provider = provider.strip() - if len(model) > 300: - raise ValueError("model must be at most 300 characters.") - if len(provider) > 64: - raise ValueError("provider must be at most 64 characters.") - - cfg = await asyncio.to_thread(get_effective_config) - entries = await list_model_picker_entries( - getattr(cfg, "ollama_base_url", None), - include_custom_ollama=False, - model_catalog=getattr(cfg, "model_catalog", None), - ) - if not any( - name == model and entry_provider == provider - for name, _, entry_provider in entries - ): - raise ValueError( - f"Model {model!r} is not configured for provider {provider!r}." - ) - - await asyncio.to_thread(_persist_default_model, model, provider) - return JSONResponse({"default": {"name": model, "provider": provider}}) - except (json.JSONDecodeError, UnicodeDecodeError): - return JSONResponse( - {"error": "Request body must be valid JSON."}, status_code=400 - ) - except (ProviderProfileError, ValueError) as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - except Exception: - _logger.exception("Default model request failed") - return JSONResponse({"error": "Default model request failed."}, status_code=500) - - -def _provider_model_from_payload(raw: Any) -> ProviderModel: - if not isinstance(raw, dict): - raise ProviderProfileError("model must be an object.") - model_id = raw.get("id") - name = raw.get("name") - upstream_id = raw.get("model_id") - if not isinstance(model_id, str) or not model_id.strip(): - raise ProviderProfileError("model.id is required.") - if not isinstance(name, str) or not name.strip(): - raise ProviderProfileError("model.name is required.") - if not isinstance(upstream_id, str) or not upstream_id.strip(): - raise ProviderProfileError("model.model_id is required.") - if len(model_id.strip()) > 64: - raise ProviderProfileError("model.id must be at most 64 characters.") - if len(name.strip()) > 120: - raise ProviderProfileError("model.name must be at most 120 characters.") - if len(upstream_id.strip()) > 300: - raise ProviderProfileError("model.model_id must be at most 300 characters.") - return ProviderModel( - id=model_id.strip(), - name=name.strip(), - model_id=upstream_id.strip(), - enabled=True, - ) - - -async def provider_actions_endpoint(request: Request) -> JSONResponse: - """Discover provider models or run a real connectivity test.""" - auth_error = _provider_admin_error(request) - if auth_error is not None: - return auth_error - - try: - payload = await request.json() - if not isinstance(payload, dict): - raise ProviderProfileError("Request body must be an object.") - action = payload.get("action") - profile = await asyncio.to_thread( - resolve_provider_profile_draft, payload.get("provider") - ) - - if action == "list_models": - models = await discover_provider_models(profile) - return JSONResponse( - { - "models": [ - {"model_id": model.model_id, "name": model.name} - for model in models - ] - } - ) - if action == "test_model": - model = _provider_model_from_payload(payload.get("model")) - result = await test_provider_model(profile, model) - return JSONResponse( - { - "ok": True, - "latency_ms": result.latency_ms, - "response": result.response, - } - ) - raise ProviderProfileError("action must be list_models or test_model.") - except (json.JSONDecodeError, UnicodeDecodeError): - return JSONResponse( - {"error": "Request body must be valid JSON."}, status_code=400 - ) - except ProviderProfileError as exc: - return JSONResponse({"error": str(exc)}, status_code=400) - except ProviderOperationError as exc: - return JSONResponse({"error": str(exc)}, status_code=502) - except Exception: - _logger.exception("Provider action request failed") - return JSONResponse( - {"error": "Provider action request failed."}, status_code=500 - ) def _message_type(message: Any) -> str | None: @@ -1573,33 +815,66 @@ async def bind_workspace_turn(request: Request) -> JSONResponse: return JSONResponse(_run_reservation_payload(run)) +def _refuse_startup_on_legacy_artifacts() -> None: + """Config-service startup gate (design doc section 10 step 4). + + Raising here aborts the Starlette lifespan startup, so the config + service refuses to serve until the legacy artifacts are reset. + """ + + assert_no_legacy_artifacts() + + +_SNAPSHOT_CLEANUP_INTERVAL_SECONDS = 60 + + +async def _snapshot_cleanup_loop(stop: asyncio.Event) -> None: + """Expire prepared snapshots past TTL and bound snapshots past retention. + + The TTL/retention rules (design doc 5.2/8.2) only take effect if a + cleanup task transitions rows to ``expired``; without one, stale + prepared rows would hold their UNIQUE slot forever. + """ + while not stop.is_set(): + try: + await asyncio.wait_for(stop.wait(), timeout=_SNAPSHOT_CLEANUP_INTERVAL_SECONDS) + except asyncio.TimeoutError: + pass + if stop.is_set(): + return + try: + from EvoScientist.model_registry.runtime import get_snapshot_runtime + + expired = await asyncio.to_thread( + get_snapshot_runtime().snapshots.cleanup_expired + ) + if expired: + _logger.info("Expired %d run snapshot(s): %s", len(expired), expired) + except Exception: + _logger.exception("Run snapshot cleanup pass failed") + + +@contextlib.asynccontextmanager +async def _lifespan(_app: Starlette): + _refuse_startup_on_legacy_artifacts() + stop = asyncio.Event() + cleanup_task = asyncio.create_task(_snapshot_cleanup_loop(stop)) + try: + yield + finally: + stop.set() + await cleanup_task + + app = Starlette( + lifespan=_lifespan, routes=[ # The unified model registry API (design doc section 9) owns - # /api/models and /api/runtime-snapshots; the legacy handlers for - # those paths were removed here and the remaining legacy provider - # routes are deleted in Task 7. + # /api/models and /api/runtime-snapshots. The legacy provider + # routes (/api/provider-profiles, /api/provider-actions, + # /api/config, /api/default-model) and the admin-token check were + # removed with the unified model configuration refactor. *model_registry_routes(), - Route( - "/api/provider-profiles", - provider_profiles_endpoint, - methods=["GET", "PUT"], - ), - Route( - "/api/config", - llm_config_endpoint, - methods=["GET", "PATCH", "POST"], - ), - Route( - "/api/default-model", - default_model_endpoint, - methods=["PUT"], - ), - Route( - "/api/provider-actions", - provider_actions_endpoint, - methods=["POST"], - ), Route( "/api/threads/{thread_id}/final-answer", get_final_answer, diff --git a/EvoScientist/llm/__init__.py b/EvoScientist/llm/__init__.py index 1704632..8f4f7a6 100644 --- a/EvoScientist/llm/__init__.py +++ b/EvoScientist/llm/__init__.py @@ -1,33 +1,31 @@ """LLM module for EvoScientist. -Provides a unified interface for creating chat model instances -with support for multiple providers. +The static model catalog and free-string model factory were removed in the +unified model-configuration refactor: all chat-model construction now goes +through :mod:`EvoScientist.model_registry` (SnapshotRuntime + +``build_chat_model`` + ``ResolvedModelConfig``). -``models`` is attached lazily via :mod:`lazy_loader` (SPEC-1 / PEP 562) so -that importing ``EvoScientist.llm`` (or any of its submodules, like -``context_window``) does not eagerly drag in ``langchain.chat_models`` and -its transitive ``langchain_anthropic``/``langchain_openai`` stack — that's -roughly 1 s of wall time on every CLI invocation. +What remains here: + +- ``context_window`` — context-window resolution helpers consumed by the + middleware layer (e.g. ``context_editing``); +- ``patches`` — LangChain provider monkey-patches, attached lazily via + :mod:`lazy_loader` (SPEC-1 / PEP 562) so that importing + ``EvoScientist.llm`` does not eagerly drag in ``langchain.chat_models`` + and its transitive ``langchain_anthropic``/``langchain_openai`` stack — + that's roughly 1 s of wall time on every CLI invocation. """ import lazy_loader as _lazy __getattr__, __dir__, __all__ = _lazy.attach( __name__, - submodules=["context_window", "models", "patches"], + submodules=["context_window", "patches"], submod_attrs={ "context_window": [ "DEFAULT_CONTEXT_WINDOW_FALLBACK", "get_context_window", "resolve_context_window", ], - "models": [ - "DEFAULT_MODEL", - "MODELS", - "get_chat_model", - "get_model_info", - "get_models_for_provider", - "list_models", - ], }, ) diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py deleted file mode 100644 index 2f038fb..0000000 --- a/EvoScientist/llm/models.py +++ /dev/null @@ -1,1132 +0,0 @@ -"""LLM model configuration based on LangChain init_chat_model. - -This module provides a unified interface for creating chat model instances -with support for multiple providers (Anthropic, OpenAI, Google GenAI, MiniMax -(Anthropic-compatible), NVIDIA, SiliconFlow, OpenRouter, ZhipuAI, Volcengine, -DashScope, DashScope-Code, DeepSeek, Ollama, and custom OpenAI/Anthropic-compatible -endpoints) and convenient short names for common models. -""" - -from __future__ import annotations - -import asyncio -import hashlib -import json -import os -import re -import warnings -from dataclasses import dataclass -from typing import Any - -from langchain.chat_models import init_chat_model - -from ..config.provider_profiles import ( - ProviderModel, - ProviderProfile, - ProviderProfileError, - get_builtin_provider_profile, - get_provider_profile, - get_provider_profile_revision, - get_provider_profiles_path, - list_configured_builtin_model_entries, - list_configured_model_entries, - resolve_model_input_limit, - resolve_provider_model, -) -from .context_window import apply_known_context_window -from .patches import ( - _is_ccproxy_codex, - _patch_ccproxy_system_to_developer, - _patch_deepseek_reasoning_passback, - _patch_openai_compat_content, - _patch_openrouter_strip_responses_reasoning, -) - -_MINIMAX_ANTHROPIC_BASE_URL = "https://api.minimaxi.com/anthropic" -_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1" - -_ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4" -_ZHIPU_CODE_BASE_URL = "https://open.bigmodel.cn/api/coding/paas/v4" -_VOLCENGINE_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3" -_DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" -_DASHSCOPE_CODE_BASE_URL = "https://coding.dashscope.aliyuncs.com/v1" - -_DEEPSEEK_BASE_URL = "https://api.deepseek.com" -_MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1" -_KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/" - -# ``glm`` is a common shorthand in existing configuration files. The -# supported provider identifier is ``zhipu`` because it describes the API -# vendor rather than a single model family. Normalize it at the boundary so -# legacy configuration reaches the same authenticated OpenAI-compatible route. -_PROVIDER_ALIASES: dict[str, str] = { - "glm": "zhipu", -} - - -def normalize_provider_id(provider: str) -> str: - """Return the canonical provider ID accepted by the model registry.""" - normalized = provider.strip().lower() - return _PROVIDER_ALIASES.get(normalized, normalized) - -# Providers routed through the OpenAI provider with a custom base_url. -# Maps provider name → (base_url or None, env var for API key). -_OPENAI_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { - "deepseek": (_DEEPSEEK_BASE_URL, "DEEPSEEK_API_KEY"), - "moonshot": (_MOONSHOT_BASE_URL, "MOONSHOT_API_KEY"), - "siliconflow": (_SILICONFLOW_BASE_URL, "SILICONFLOW_API_KEY"), - "zhipu": (_ZHIPU_BASE_URL, "ZHIPU_API_KEY"), - "zhipu-code": (_ZHIPU_CODE_BASE_URL, "ZHIPU_API_KEY"), - "volcengine": (_VOLCENGINE_BASE_URL, "VOLCENGINE_API_KEY"), - "dashscope": (_DASHSCOPE_BASE_URL, "DASHSCOPE_API_KEY"), - "dashscope-code": (_DASHSCOPE_CODE_BASE_URL, "DASHSCOPE_API_KEY"), - "custom-openai": ( - None, - "CUSTOM_OPENAI_API_KEY", - ), # base_url from CUSTOM_OPENAI_BASE_URL env -} - -# Providers routed through the Anthropic provider with a custom base_url. -# Maps provider name → (base_url or None, env var for API key). -_ANTHROPIC_ROUTED_PROVIDERS: dict[str, tuple[str | None, str]] = { - "minimax": (_MINIMAX_ANTHROPIC_BASE_URL, "MINIMAX_API_KEY"), - "kimi-coding": (_KIMI_CODING_BASE_URL, "KIMI_API_KEY"), - "custom-anthropic": (None, "CUSTOM_ANTHROPIC_API_KEY"), -} - -# Anthropic-routed providers that support extended thinking. -_THINKING_CAPABLE_PROVIDERS: set[str] = {"minimax"} - -_TRUTHY_ENV_VALUES = {"1", "true", "yes", "on"} -_FALSEY_ENV_VALUES = {"0", "false", "no", "off"} - - -@dataclass(frozen=True) -class ResolvedRuntimeOptions: - """Provider-neutral parameters resolved once for a configured model.""" - - profile_revision: str | None - model_id: str - adapter_id: str - limit_mode: str - resolved_input_limit: int - max_output_tokens: int - min_effective_input_tokens: int - timeout_seconds: int - max_retries: int - temperature: float | None - top_p: float | None - reasoning_effort: str - limits_source: str - capabilities: tuple[tuple[str, bool | str], ...] - - -def resolve_runtime_options( - profile: ProviderProfile, - model: ProviderModel, - call_overrides: dict[str, Any] | None = None, -) -> ResolvedRuntimeOptions: - """Resolve the bounded runtime options for one configured model. - - Only sampling values and a lower output budget may be overridden by an - internal caller. Connection and capacity fields always come from the - persisted profile. - """ - overrides = call_overrides or {} - runtime = model.runtime - input_limit = resolve_model_input_limit(runtime) - output_limit = runtime.max_output_tokens - override_output = overrides.get("max_tokens") - if isinstance(override_output, int) and not isinstance(override_output, bool): - if override_output <= 0 or override_output > output_limit: - raise ValueError("max_tokens override must be positive and no greater than the model limit.") - output_limit = override_output - temperature = overrides.get("temperature", runtime.temperature) - if temperature is None: - temperature = profile.runtime.default_temperature - top_p = overrides.get("top_p", runtime.top_p) - if top_p is None: - top_p = profile.runtime.default_top_p - return ResolvedRuntimeOptions( - profile_revision=get_provider_profile_revision(profile.id), - model_id=model.model_id, - adapter_id=profile.adapter, - limit_mode=runtime.limit_mode or "input_only", - resolved_input_limit=input_limit, - max_output_tokens=output_limit, - min_effective_input_tokens=runtime.min_effective_input_tokens, - timeout_seconds=profile.runtime.timeout_seconds, - max_retries=profile.runtime.max_retries, - temperature=temperature, - top_p=top_p, - reasoning_effort=( - runtime.reasoning_effort - if runtime.reasoning_effort != "auto" - else profile.runtime.default_reasoning_effort - ), - limits_source=runtime.limits_source, - capabilities=runtime.capabilities, - ) - -# Model registry: list of (short_name, model_id, provider) -# Allows same short_name across different providers. -_MODEL_ENTRIES: list[tuple[str, str, str]] = [ - # Custom Anthropic (third-party Claude-compatible endpoints, current-gen defaults) - # Listed BEFORE native anthropic so MODELS dict defaults to native provider - ("claude-sonnet-4-6", "claude-sonnet-4-6", "custom-anthropic"), - ("claude-haiku-4-5", "claude-haiku-4-5", "custom-anthropic"), - # Custom OpenAI (third-party OpenAI-compatible endpoints, 3 defaults) - # Listed BEFORE native openai so MODELS dict defaults to native provider - ("gpt-5.5-pro", "gpt-5.5-pro", "custom-openai"), - ("gpt-5.5", "gpt-5.5", "custom-openai"), - ("gpt-5.4", "gpt-5.4", "custom-openai"), - ("gpt-5.3-codex", "gpt-5.3-codex", "custom-openai"), - ("gpt-5-mini", "gpt-5-mini", "custom-openai"), - # Anthropic (current generation) - ("claude-fable-5", "claude-fable-5", "anthropic"), - ("claude-opus-4-8", "claude-opus-4-8", "anthropic"), - ("claude-sonnet-5", "claude-sonnet-5", "anthropic"), - ("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"), - ("claude-haiku-4-5", "claude-haiku-4-5", "anthropic"), - # OpenAI - ("gpt-5.6-sol", "gpt-5.6-sol", "openai"), - ("gpt-5.6-terra", "gpt-5.6-terra", "openai"), - ("gpt-5.6-luna", "gpt-5.6-luna", "openai"), - ("gpt-5.5-pro", "gpt-5.5-pro", "openai"), - ("gpt-5.5", "gpt-5.5", "openai"), - ("gpt-5.4", "gpt-5.4", "openai"), - ("gpt-5.4-mini", "gpt-5.4-mini", "openai"), - ("gpt-5.4-nano", "gpt-5.4-nano", "openai"), - ("gpt-5.3-codex", "gpt-5.3-codex", "openai"), - ("gpt-5.2-codex", "gpt-5.2-codex", "openai"), - ("gpt-5.2", "gpt-5.2", "openai"), - ("gpt-5.1", "gpt-5.1", "openai"), - ("gpt-5", "gpt-5", "openai"), - ("gpt-5-mini", "gpt-5-mini", "openai"), - ("gpt-5-nano", "gpt-5-nano", "openai"), - # Google GenAI - ("gemini-3.5-flash", "gemini-3.5-flash", "google-genai"), - ("gemini-3.1-pro", "gemini-3.1-pro-preview", "google-genai"), - ( - "gemini-3.1-pro-customtools", - "gemini-3.1-pro-preview-customtools", - "google-genai", - ), - ("gemini-3.1-flash-lite", "gemini-3.1-flash-lite-preview", "google-genai"), - ("gemini-3-flash", "gemini-3-flash-preview", "google-genai"), - ("gemini-2.5-flash", "gemini-2.5-flash", "google-genai"), - ("gemini-2.5-flash-lite", "gemini-2.5-flash-lite", "google-genai"), - ("gemini-2.5-pro", "gemini-2.5-pro", "google-genai"), - # MiniMax (direct API — Anthropic-compatible; default: api.minimaxi.com, global: api.minimax.io) - ("minimax-m3", "MiniMax-M3", "minimax"), - ("minimax-m2.7", "MiniMax-M2.7", "minimax"), - ("minimax-m2.7-highspeed", "MiniMax-M2.7-highspeed", "minimax"), - ("minimax-m2.5", "MiniMax-M2.5", "minimax"), - ("minimax-m2.5-highspeed", "MiniMax-M2.5-highspeed", "minimax"), - # NVIDIA - ("nemotron-super", "nvidia/nemotron-3-super-120b-a12b", "nvidia"), - ("nemotron-nano", "nvidia/nemotron-3-nano-30b-a3b", "nvidia"), - ("glm-5.2", "z-ai/glm-5.2", "nvidia"), - ("glm4.7", "z-ai/glm4.7", "nvidia"), - ("deepseek-v3.2", "deepseek-ai/deepseek-v3.2", "nvidia"), - ("deepseek-v3.1", "deepseek-ai/deepseek-v3.1-terminus", "nvidia"), - ("kimi-k2.5", "moonshotai/kimi-k2.5", "nvidia"), - ("kimi-k2-thinking", "moonshotai/kimi-k2-thinking", "nvidia"), - ("minimax-m2.5", "minimaxai/minimax-m2.5", "nvidia"), - ("minimax-m2.1", "minimaxai/minimax-m2.1", "nvidia"), - ("qwen3.5-397b", "qwen/qwen3.5-397b-a17b", "nvidia"), - ("step-3.5-flash", "stepfun-ai/step-3.5-flash", "nvidia"), - # SiliconFlow - ("minimax-m2.5", "Pro/MiniMaxAI/MiniMax-M2.5", "siliconflow"), - ("glm-5.2", "Pro/zai-org/GLM-5.2", "siliconflow"), - ("glm-5", "Pro/zai-org/GLM-5", "siliconflow"), - ("kimi-k2.5", "Pro/moonshotai/Kimi-K2.5", "siliconflow"), - ("glm-4.7", "Pro/zai-org/GLM-4.7", "siliconflow"), - # OpenRouter - ("claude-fable-5", "anthropic/claude-fable-5", "openrouter"), - ("claude-opus-4.8", "anthropic/claude-opus-4.8", "openrouter"), - ("claude-opus-4.8-fast", "anthropic/claude-opus-4.8-fast", "openrouter"), - ("claude-sonnet-5", "anthropic/claude-sonnet-5", "openrouter"), - ("claude-sonnet-4.6", "anthropic/claude-sonnet-4.6", "openrouter"), - ("gpt-5.6-sol", "openai/gpt-5.6-sol", "openrouter"), - ("gpt-5.6-terra", "openai/gpt-5.6-terra", "openrouter"), - ("gpt-5.6-luna", "openai/gpt-5.6-luna", "openrouter"), - ("gpt-5.5-pro", "openai/gpt-5.5-pro", "openrouter"), - ("gpt-5.5", "openai/gpt-5.5", "openrouter"), - ("gpt-5.4", "openai/gpt-5.4", "openrouter"), - ("gpt-5.3-codex", "openai/gpt-5.3-codex", "openrouter"), - ("gemini-3.5-flash", "google/gemini-3.5-flash", "openrouter"), - ("gemini-3.1-pro", "google/gemini-3.1-pro-preview", "openrouter"), - ("gemini-3-flash", "google/gemini-3-flash-preview", "openrouter"), - ("kimi-k2.6", "moonshotai/kimi-k2.6", "openrouter"), - ("glm-5.2", "z-ai/glm-5.2", "openrouter"), - ("glm-5v-turbo", "z-ai/glm-5v-turbo", "openrouter"), - ("minimax-m3", "minimax/minimax-m3", "openrouter"), - ("mimo-v2.5-pro", "xiaomi/mimo-v2.5-pro", "openrouter"), - ("mimo-v2.5", "xiaomi/mimo-v2.5", "openrouter"), - ("grok-build-0.1", "x-ai/grok-build-0.1", "openrouter"), - ("grok-4.5", "x-ai/grok-4.5", "openrouter"), - ("hy3", "tencent/hy3", "openrouter"), - ("qwen3.7-max", "qwen/qwen3.7-max", "openrouter"), - ("qwen3.7-plus", "qwen/qwen3.7-plus", "openrouter"), - ("qwen3.6-flash", "qwen/qwen3.6-flash", "openrouter"), - ("qwen3.5-122b", "qwen/qwen3.5-122b-a10b", "openrouter"), - ("deepseek-v4-pro", "deepseek/deepseek-v4-pro", "openrouter"), - ("deepseek-v4-flash", "deepseek/deepseek-v4-flash", "openrouter"), - # Zhipu CodePlan (智谱代码计划 — coding-only endpoint) - ("glm-5.2", "glm-5.2", "zhipu-code"), - ("glm-5.1", "glm-5.1", "zhipu-code"), - ("glm-5", "glm-5", "zhipu-code"), - ("glm-5-turbo", "glm-5-turbo", "zhipu-code"), - ("glm-5v-turbo", "glm-5v-turbo", "zhipu-code"), - ("glm-4.7", "glm-4.7", "zhipu-code"), - # Zhipu (智谱 — general endpoint, default for simple lookups) - ("glm-5.2", "glm-5.2", "zhipu"), - ("glm-5.1", "glm-5.1", "zhipu"), - ("glm-5", "glm-5", "zhipu"), - ("glm-5-turbo", "glm-5-turbo", "zhipu"), - ("glm-5v-turbo", "glm-5v-turbo", "zhipu"), - ("glm-4.7", "glm-4.7", "zhipu"), - # Volcengine (火山引擎 — Doubao models) - ("doubao-seed-2.0-pro", "doubao-seed-2-0-pro-260215", "volcengine"), - ("doubao-seed-2.0-lite", "doubao-seed-2-0-lite-260215", "volcengine"), - ("doubao-seed-2.0-mini", "doubao-seed-2-0-mini-260215", "volcengine"), - ("doubao-seed-2.0-code", "doubao-seed-2-0-code-preview-260215", "volcengine"), - ("doubao-seed-1.6", "doubao-seed-1.6", "volcengine"), - ("doubao-1.5-pro", "doubao-1.5-pro-256k", "volcengine"), - ("doubao-1.5-thinking-pro", "doubao-1.5-thinking-pro", "volcengine"), - # DashScope Coding Plan (阿里云代码计划 — subscription sk-sp-* endpoint) - ("qwen3.7-max", "qwen3.7-max", "dashscope-code"), - ("qwen3.7-plus", "qwen3.7-plus", "dashscope-code"), - ("qwen3.6-max", "qwen3.6-max-preview", "dashscope-code"), - ("qwen3.6-plus", "qwen3.6-plus", "dashscope-code"), - ("qwen3.6-flash", "qwen3.6-flash", "dashscope-code"), - ("qwen3-coder", "qwen3-coder-plus", "dashscope-code"), - ("qwen3-coder-next", "qwen3-coder-next", "dashscope-code"), - ("qwen3-max", "qwen3-max", "dashscope-code"), - ("qwen3.5-plus", "qwen3.5-plus", "dashscope-code"), - # DashScope (阿里云 — Qwen models, default for simple lookups) - ("qwen3.7-max", "qwen3.7-max", "dashscope"), - ("qwen3.7-plus", "qwen3.7-plus", "dashscope"), - ("qwen3.6-max", "qwen3.6-max-preview", "dashscope"), - ("qwen3.6-plus", "qwen3.6-plus", "dashscope"), - ("qwen3.6-flash", "qwen3.6-flash", "dashscope"), - ("qwen3-coder", "qwen3-coder-plus", "dashscope"), - ("qwen3-235b", "qwen3-235b-a22b", "dashscope"), - ("qwen-max", "qwen-max", "dashscope"), - ("qwq-plus", "qwq-plus", "dashscope"), - # DeepSeek - ("deepseek-v4-pro", "deepseek-v4-pro", "deepseek"), - ("deepseek-v4-flash", "deepseek-v4-flash", "deepseek"), - # Legacy aliases (deprecated 2026-07-24; route to v4-flash thinking/non-thinking) - ("deepseek-r1", "deepseek-reasoner", "deepseek"), - ("deepseek-v3", "deepseek-chat", "deepseek"), - # Moonshot (OpenAI-compatible) - ("kimi-k2.6", "kimi-k2.6", "moonshot"), - ("kimi-k2.5", "kimi-k2.5", "moonshot"), - ("kimi-k2-thinking", "kimi-k2-thinking", "moonshot"), - ("kimi-k2-thinking-turbo", "kimi-k2-thinking-turbo", "moonshot"), - ("moonshot-v1-auto", "moonshot-v1-auto", "moonshot"), - ("moonshot-v1-128k", "moonshot-v1-128k", "moonshot"), - ("moonshot-v1-32k", "moonshot-v1-32k", "moonshot"), - ("moonshot-v1-8k", "moonshot-v1-8k", "moonshot"), - # Kimi Coding Plan (Anthropic-compatible) - ("kimi-for-coding", "kimi-for-coding", "kimi-coding"), -] - -# Public dict for simple lookups (last entry wins for duplicate names). -# Use get_models_for_provider() for provider-aware lookups. -MODELS: dict[str, tuple[str, str]] = { - name: (model_id, provider) for name, model_id, provider in _MODEL_ENTRIES -} - -DEFAULT_MODEL = "claude-sonnet-4-6" -_STATIC_PROVIDER_IDS = {provider for _, _, provider in _MODEL_ENTRIES} | {"ollama"} -_MODEL_CATALOG_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$") - - -def is_static_provider_id(provider: str) -> bool: - """Return whether a provider is configured outside custom profiles.""" - return normalize_provider_id(provider) in _STATIC_PROVIDER_IDS - - -def normalize_builtin_model_catalog(raw: Any) -> list[dict[str, Any]] | None: - """Validate and normalize the optional config.yaml built-in model catalog.""" - if raw is None: - return None - if not isinstance(raw, list): - raise ValueError("model_catalog must be a list or null.") - - normalized: list[dict[str, Any]] = [] - seen: set[tuple[str, str]] = set() - for index, item in enumerate(raw): - context = f"model_catalog[{index}]" - if not isinstance(item, dict): - raise ValueError(f"{context} must be an object.") - - provider = item.get("provider") - alias = item.get("id") - name = item.get("name") - model_id = item.get("model_id") - if not isinstance(provider, str) or not provider.strip(): - raise ValueError(f"{context}.provider is required.") - if not isinstance(alias, str) or not alias.strip(): - raise ValueError(f"{context}.id is required.") - if not isinstance(name, str) or not name.strip(): - raise ValueError(f"{context}.name is required.") - if not isinstance(model_id, str) or not model_id.strip(): - raise ValueError(f"{context}.model_id is required.") - - provider = provider.strip() - alias = alias.strip() - name = name.strip() - model_id = model_id.strip() - if provider not in _STATIC_PROVIDER_IDS: - raise ValueError( - f"{context}.provider must be a built-in provider, got {provider!r}." - ) - if not _MODEL_CATALOG_ID_PATTERN.fullmatch(alias): - raise ValueError( - f"{context}.id must start with a lowercase letter or digit and " - "contain only lowercase letters, digits, dots, underscores, or hyphens." - ) - if len(name) > 120: - raise ValueError(f"{context}.name must be at most 120 characters.") - if len(model_id) > 300: - raise ValueError(f"{context}.model_id must be at most 300 characters.") - - key = (provider, alias) - if key in seen: - raise ValueError( - f"Model alias {alias!r} is duplicated for provider {provider!r}." - ) - seen.add(key) - normalized.append( - { - "provider": provider, - "id": alias, - "name": name, - "model_id": model_id, - "enabled": bool(item.get("enabled", True)), - } - ) - return normalized - - -def list_builtin_model_catalog_entries( - raw: Any, -) -> list[tuple[str, str, str]] | None: - """Return enabled catalog entries, or ``None`` for legacy registry mode.""" - catalog = normalize_builtin_model_catalog(raw) - if catalog is None: - return None - return [ - (entry["id"], entry["model_id"], entry["provider"]) - for entry in catalog - if entry["enabled"] - ] - - -def _resolve_builtin_catalog_model(provider: str, model: str) -> str | None: - from ..config.settings import load_config - - try: - profile = get_builtin_provider_profile(provider) - except ProviderProfileError: - # Custom profiles use an explicit v3 reset path during development. - # Built-in model selection must still work from config.yaml while that - # reset has not happened, because it never consumes the old profile. - profile = None - if profile is not None: - if not profile.enabled: - return None - for candidate in profile.models: - if candidate.id == model: - return candidate.model_id if candidate.enabled else None - return None - try: - catalog = normalize_builtin_model_catalog(load_config().model_catalog) - except ValueError: - return None - if catalog is None: - return None - for entry in catalog: - if entry["provider"] == provider and entry["id"] == model: - return entry["model_id"] if entry["enabled"] else None - return None - - -def get_model_runtime_revision(provider: str | None) -> str | None: - """Return the dynamic profile revision used in model cache keys.""" - if not provider: - return None - if provider in _STATIC_PROVIDER_IDS: - try: - managed_revision = get_provider_profile_revision(provider) - except ProviderProfileError: - managed_revision = None - if managed_revision is not None: - return managed_revision - from ..config.settings import load_config - - try: - catalog = normalize_builtin_model_catalog(load_config().model_catalog) - except ValueError: - return "invalid" - if catalog is None: - return None - provider_catalog = [entry for entry in catalog if entry["provider"] == provider] - encoded = json.dumps( - provider_catalog, sort_keys=True, separators=(",", ":") - ).encode("utf-8") - return hashlib.sha256(encoded).hexdigest()[:16] - try: - return get_provider_profile_revision(provider) - except ProviderProfileError: - return None - - -def get_models_for_provider(provider: str) -> list[tuple[str, str]]: - """Get all models for a specific provider. - - Args: - provider: Provider name (e.g., 'anthropic', 'openrouter'). - - Returns: - List of (short_name, model_id) tuples for the provider. - """ - if provider in _STATIC_PROVIDER_IDS: - try: - managed = get_builtin_provider_profile(provider) - except ProviderProfileError: - managed = None - if managed is not None: - if not managed.enabled: - return [] - return [ - (model.id, model.model_id) for model in managed.models if model.enabled - ] - return [ - (name, model_id) - for name, model_id, entry_provider in _MODEL_ENTRIES - if entry_provider == provider - ] - profile = get_provider_profile(provider) - if profile is not None: - if not profile.enabled: - return [] - return [(model.id, model.model_id) for model in profile.models if model.enabled] - return [] - - -def _env_flag_enabled(name: str) -> bool: - return os.environ.get(name, "").strip().lower() in _TRUTHY_ENV_VALUES - - -def _env_flag_disabled(name: str) -> bool: - value = os.environ.get(name) - return value is not None and value.strip().lower() in _FALSEY_ENV_VALUES - - -def _supports_openrouter_anthropic_prompt_cache(provider: str, model_id: str) -> bool: - """Return whether EvoScientist should declare OpenRouter Claude caching.""" - return provider == "openrouter" and model_id.startswith( - ("anthropic/", "~anthropic/") - ) - - -def _has_cache_control_override(kwargs: dict[str, Any]) -> bool: - """Return whether the caller already supplied cache-control settings.""" - if "cache_control" in kwargs: - return True - model_kwargs = kwargs.get("model_kwargs") - if model_kwargs is None: - return False - if not isinstance(model_kwargs, dict): - warnings.warn( - "OpenRouter Anthropic prompt caching was not applied because " - "`model_kwargs` is not a dict; pass cache_control explicitly or use " - "a dict-shaped model_kwargs.", - UserWarning, - stacklevel=3, - ) - return True - return "cache_control" in model_kwargs - - -def _apply_openrouter_anthropic_prompt_cache( - provider: str, - model_id: str, - kwargs: dict[str, Any], -) -> None: - """Declare OpenRouter Claude prompt caching unless explicitly disabled. - - OpenRouter already handles implicit caching for most providers, but Claude - prompt caching needs Anthropic-style cache-control declaration. - """ - if _env_flag_disabled("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE"): - return - if not _supports_openrouter_anthropic_prompt_cache(provider, model_id): - return - if _has_cache_control_override(kwargs): - return - kwargs.setdefault("model_kwargs", {})["cache_control"] = {"type": "ephemeral"} - - -def _apply_auto_config( - provider: str, - model_id: str, - is_third_party: bool, - kwargs: dict[str, Any], - original_provider: str | None = None, -) -> None: - """Auto-enable provider-specific features (thinking, reasoning, etc.). - - Mutates *kwargs* in place. Only sets keys that the caller hasn't already - provided, so explicit user settings are never overridden. - """ - # Anthropic: extended thinking - if provider == "anthropic" and "thinking" not in kwargs: - _supports_thinking = original_provider in _THINKING_CAPABLE_PROVIDERS - # Detect local proxy (e.g. ccproxy): thinking blocks in conversation - # history cause 422 errors because the proxy doesn't accept 'thinking' - # as a valid content block type on round-trip. - if not is_third_party: - base_url = os.environ.get("ANTHROPIC_BASE_URL", "") - _is_proxy = "127.0.0.1" in base_url or "localhost" in base_url - else: - _is_proxy = False - if _is_proxy or (is_third_party and not _supports_thinking): - pass - elif "fable" in model_id or model_id.endswith(("4-6", "4-7", "4-8")): - kwargs["thinking"] = {"type": "adaptive", "display": "summarized"} - kwargs.setdefault("effort", "max") - else: - kwargs["thinking"] = {"type": "enabled", "budget_tokens": 10000} - - # OpenAI (native, not third-party routed): reasoning - if provider == "openai" and not is_third_party and "reasoning" not in kwargs: - if _is_ccproxy_codex(): - # ccproxy uses Chat Completions which doesn't support reasoning. - pass - else: - _eff = ( - "xhigh" - if ("5.4" in model_id or "5.5" in model_id or "codex" in model_id) - else "high" - ) - kwargs["reasoning"] = {"effort": _eff, "summary": "auto"} - - # Google GenAI: surface thinking traces - if provider == "google-genai": - kwargs.setdefault("include_thoughts", True) - - # Ollama: separate reasoning content from response for thinking models - if provider == "ollama" and "reasoning" not in kwargs: - kwargs["reasoning"] = True - - -def get_chat_model( - model: str | None = None, - provider: str | None = None, - *, - profile_override: ProviderProfile | None = None, - model_override: ProviderModel | None = None, - skip_auto_config: bool = False, - **kwargs: Any, -) -> Any: - """Get a chat model instance. - - Args: - model: Model name (short name like 'claude-sonnet-4-6' or full ID - like 'claude-sonnet-4-6-20250929'). Defaults to DEFAULT_MODEL. - provider: Override the provider (e.g., 'anthropic', 'openai'). - If not specified, inferred from model name or defaults to 'anthropic'. - **kwargs: Additional arguments passed to init_chat_model (e.g., temperature). - - Returns: - A LangChain chat model instance. - - Examples: - >>> model = get_chat_model() # Uses default (claude-sonnet-4-6) - >>> model = get_chat_model("claude-opus-4-8") # Use short name - >>> model = get_chat_model("gpt-4o") # OpenAI model - >>> model = get_chat_model("claude-3-opus-20240229", provider="anthropic") # Full ID - """ - model = model or DEFAULT_MODEL - if provider is not None: - provider = normalize_provider_id(provider) - - dynamic_profile = profile_override - dynamic_model = model_override - resolved_runtime: ResolvedRuntimeOptions | None = None - if dynamic_profile is not None: - provider = dynamic_profile.id - if dynamic_model is None: - dynamic_model = next( - ( - candidate - for candidate in dynamic_profile.models - if candidate.id == model or candidate.model_id == model - ), - None, - ) - if dynamic_model is None: - raise ValueError( - f"Model {model!r} is not configured for provider {dynamic_profile.id!r}." - ) - elif provider and provider not in _STATIC_PROVIDER_IDS: - try: - dynamic_resolution = resolve_provider_model(provider, model) - except ProviderProfileError as exc: - # An opaque provider override remains a valid LangChain escape - # hatch. A stale v1/v2 registry cannot be allowed to block it; - # configured custom profiles are rejected earlier by the snapshot - # endpoint and never silently selected from that document. - if str(exc).startswith("PROVIDER_PROFILE_RESET_REQUIRED"): - dynamic_resolution = None - else: - raise ValueError(str(exc)) from exc - if dynamic_resolution is not None: - dynamic_profile, dynamic_model = dynamic_resolution - - if dynamic_profile is not None and dynamic_model is not None: - resolved_runtime = resolve_runtime_options(dynamic_profile, dynamic_model, kwargs) - kwargs.setdefault("timeout", resolved_runtime.timeout_seconds) - kwargs.setdefault("max_retries", resolved_runtime.max_retries) - kwargs.setdefault("max_tokens", resolved_runtime.max_output_tokens) - if resolved_runtime.temperature is not None: - kwargs.setdefault("temperature", resolved_runtime.temperature) - if resolved_runtime.top_p is not None: - kwargs.setdefault("top_p", resolved_runtime.top_p) - - # Look up short name in the configured catalog, then the legacy registry. - model_id = dynamic_model.model_id if dynamic_model is not None else None - if model_id is None and provider in _STATIC_PROVIDER_IDS: - model_id = _resolve_builtin_catalog_model(provider, model) - if provider and dynamic_profile is None and model_id is None: - # Try exact match with provider first - for name, mid, p in _MODEL_ENTRIES: - if name == model and p == provider: - model_id = mid - break - if model_id is None and dynamic_profile is None and model in MODELS: - model_id, default_provider = MODELS[model] - provider = provider or default_provider - - if model_id is None: - # Assume it's a full model ID - model_id = model - # Try to infer provider from model ID prefix - if provider is None: - if model_id.startswith(("claude-", "anthropic")): - provider = "anthropic" - elif model_id.startswith(("gpt-", "o1", "davinci", "text-")): - provider = "openai" - elif model_id.startswith("gemini"): - provider = "google-genai" - elif model_id.startswith("ollama:"): - provider = "ollama" - model_id = model_id.removeprefix("ollama:") - else: - provider = "anthropic" # Default fallback - - # Anthropic base_url override (e.g. ccproxy at localhost:8000/api/v1) - _is_third_party = ( - provider in _OPENAI_ROUTED_PROVIDERS or provider in _ANTHROPIC_ROUTED_PROVIDERS - ) - _is_openai_proxy = False - _original_provider: str | None = None - if provider in _STATIC_PROVIDER_IDS: - try: - managed_builtin_profile = get_builtin_provider_profile(provider) - except ProviderProfileError: - managed_builtin_profile = None - else: - managed_builtin_profile = None - if managed_builtin_profile is not None: - if not managed_builtin_profile.enabled: - raise ValueError(f"Provider {provider!r} is disabled.") - from ..config.settings import get_effective_config - from .provider_operations import build_builtin_provider_profile - - effective_profile = build_builtin_provider_profile( - get_effective_config(), provider - ) - if effective_profile.api_key and provider != "ollama": - kwargs.setdefault("api_key", effective_profile.api_key) - if effective_profile.base_url: - kwargs.setdefault("base_url", effective_profile.base_url) - if dynamic_profile is not None: - adapter = dynamic_profile.adapter - if dynamic_profile.api_key and adapter != "ollama": - kwargs.setdefault("api_key", dynamic_profile.api_key) - if dynamic_profile.base_url: - kwargs.setdefault("base_url", dynamic_profile.base_url) - elif adapter == "grok": - kwargs.setdefault("base_url", "https://api.x.ai/v1") - - if adapter in {"openai-compatible", "grok", "antigravity"}: - provider = "openai" - _original_provider = dynamic_profile.id - _is_third_party = True - elif adapter == "anthropic-compatible": - provider = "anthropic" - _original_provider = dynamic_profile.id - _is_third_party = True - else: - provider = adapter - _is_third_party = adapter in { - "openrouter", - "openai-compatible", - "anthropic-compatible", - "grok", - "antigravity", - } or (adapter in {"openai", "anthropic"} and bool(dynamic_profile.base_url)) - if _is_third_party: - _original_provider = dynamic_profile.id - - # Some OpenAI-compatible reseller gateways reject the SDK's default - # ``OpenAI/Python`` User-Agent with a generic 403. WebUI profiles with a - # custom Base URL are third-party endpoints, so use the same compatible - # User-Agent as the legacy ``custom-openai`` provider. - if provider == "openai" and dynamic_profile.base_url: - kwargs.setdefault("default_headers", {}).setdefault( - "User-Agent", "codex_cli_rs/0.0.0" - ) - - if provider == "openrouter" and not skip_auto_config: - effort = ( - os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or "high" - ) - kwargs.setdefault("reasoning", {"effort": effort, "summary": "auto"}) - _patch_openrouter_strip_responses_reasoning() - - if resolved_runtime is not None and resolved_runtime.reasoning_effort != "auto": - if dynamic_profile.adapter in { - "openai", - "openai-compatible", - "grok", - "antigravity", - "openrouter", - }: - kwargs.setdefault( - "reasoning", {"effort": resolved_runtime.reasoning_effort} - ) - else: - raise ValueError( - f"REASONING_EFFORT_UNSUPPORTED: adapter {dynamic_profile.adapter!r} " - "does not support a portable reasoning effort setting." - ) - - elif provider == "anthropic": - base_url = os.environ.get("ANTHROPIC_BASE_URL", "") - if base_url: - kwargs.setdefault("base_url", base_url) - api_key = os.environ.get("ANTHROPIC_API_KEY", "") - if api_key: - kwargs.setdefault("api_key", api_key) - - # Native OpenAI base_url override (e.g. ccproxy Codex at localhost:8000/codex/v1) - elif provider == "openai": - base_url = os.environ.get("OPENAI_BASE_URL", "") - if base_url: - kwargs.setdefault("base_url", base_url) - _is_openai_proxy = _is_ccproxy_codex() - if _is_openai_proxy: - # Use Responses API for ccproxy: bypasses the format chain - # converter (Chat→Responses→Chat) which returns 502 on - # complex responses. System messages are converted to - # developer role by _patch_ccproxy_system_to_developer(). - kwargs.setdefault("use_responses_api", True) - # Streaming must stay ON for Responses API: ccproxy's - # StreamingBufferService loses output when assembling - # non-streaming responses. (The old streaming=False was - # for Chat Completions tool_call duplication — not an issue - # with the Responses API SSE format.) - kwargs.pop("streaming", None) # remove if set elsewhere - api_key = os.environ.get("OPENAI_API_KEY", "") - if api_key: - kwargs.setdefault("api_key", api_key) - - # OpenAI-routed providers → route through OpenAI provider with base_url - elif provider in _OPENAI_ROUTED_PROVIDERS: - _original_provider = provider - base_url_default, api_key_env = _OPENAI_ROUTED_PROVIDERS[provider] - if provider == "custom-openai": - base_url = os.environ.get("CUSTOM_OPENAI_BASE_URL", "") or kwargs.get( - "base_url", "" - ) - if not base_url: - raise ValueError( - "CUSTOM_OPENAI_BASE_URL environment variable is required when using " - "the 'custom-openai' provider. Please set it to your " - "OpenAI-compatible API endpoint URL (e.g. https://api.openai.com/v1)." - ) - base_url = base_url.rstrip("/") - else: - base_url = base_url_default - if base_url: - kwargs.setdefault("base_url", base_url) - api_key = os.environ.get(api_key_env, "") - # ChatOpenAI otherwise falls back to OPENAI_API_KEY when ``api_key`` - # is omitted. That is unsafe for routed providers: a missing Zhipu - # credential must never send an unrelated OpenAI credential to Zhipu. - kwargs.setdefault("api_key", api_key or None) - # SiliconFlow: disable thinking — LangChain drops reasoning_content - # from history, causing error 20015 on multi-turn requests. - if provider == "siliconflow": - kwargs.setdefault("extra_body", {})["enable_thinking"] = False - # Moonshot: disable thinking for all models to prevent LangChain from dropping - # reasoning_content, which causes multi-turn conversation errors (error 20015). - # Even native thinking models like kimi-k2-thinking operate in non-thinking mode. - if provider == "moonshot": - kwargs.setdefault("extra_body", {})["thinking"] = {"type": "disabled"} - # custom-openai: some Codex-orientated reseller gateways (e.g. - # hunnuapi.top) blacklist the openai SDK's default - # "OpenAI/Python" User-Agent at the app layer and return - # 403 "Your request was blocked." to any langchain-openai client. - # Spoof a Codex-CLI UA so requests go through. - if provider == "custom-openai": - kwargs.setdefault("default_headers", {}).setdefault( - "User-Agent", "codex_cli_rs/0.0.0" - ) - provider = "openai" - - # OpenRouter → native ChatOpenRouter via init_chat_model. - elif provider == "openrouter": - _is_third_party = True - api_key = os.environ.get("OPENROUTER_API_KEY", "") - if api_key: - kwargs.setdefault("api_key", api_key) - # Reasoning via `effort` + `summary: "auto"` so a readable reasoning - # summary is returned for display. OpenAI-Responses also emits encrypted - # reasoning items (`rs_*` id) that can't be replayed on multi-turn - # passback (OpenRouter's `/responses` beta is stateless, store=false — - # "Item with id 'rs_...' not found"); the patch strips them on passback, - # so enabling `summary` is safe. See langchain-ai/langchain#37777. - effort = os.environ.get("EVOSCIENTIST_REASONING_EFFORT", "").strip() or "high" - kwargs.setdefault("reasoning", {"effort": effort, "summary": "auto"}) - _patch_openrouter_strip_responses_reasoning() - - # Anthropic-routed providers → route through Anthropic provider with base_url - elif provider in _ANTHROPIC_ROUTED_PROVIDERS: - _original_provider = provider - base_url_default, api_key_env = _ANTHROPIC_ROUTED_PROVIDERS[provider] - if provider == "custom-anthropic": - base_url = os.environ.get("CUSTOM_ANTHROPIC_BASE_URL", "") or kwargs.get( - "base_url", "" - ) - if not base_url: - raise ValueError( - "CUSTOM_ANTHROPIC_BASE_URL environment variable is required when using " - "the 'custom-anthropic' provider. Please set it to your " - "Anthropic-compatible API endpoint URL (e.g. https://api.anthropic.com)." - ) - base_url = base_url.rstrip("/") - elif provider == "minimax": - base_url = os.environ.get("MINIMAX_BASE_URL", base_url_default).rstrip("/") - else: - base_url = base_url_default - if base_url: - kwargs.setdefault("base_url", base_url) - api_key = os.environ.get(api_key_env, "") - if api_key: - kwargs.setdefault("api_key", api_key) - # Kimi Coding Plan requires claude-code User-Agent header - if provider == "kimi-coding": - kwargs.setdefault("default_headers", {})["User-Agent"] = "claude-code/0.1.0" - provider = "anthropic" - - elif provider == "ollama": - base_url = os.environ.get("OLLAMA_BASE_URL", "") - if base_url: - kwargs.setdefault("base_url", base_url) - - if not skip_auto_config: - _apply_auto_config( - provider, model_id, _is_third_party, kwargs, _original_provider - ) - _apply_openrouter_anthropic_prompt_cache(provider, model_id, kwargs) - - # User-level override for the OpenAI Responses API vs Chat Completions. - # When "false", force Chat Completions and drop reasoning (which triggers - # the Responses API path in langchain-openai). Only applies to OpenAI. - if provider == "openai": - _responses_api_setting = ( - os.environ.get("EVOSCIENTIST_USE_RESPONSES_API", "").strip().lower() - ) - if _responses_api_setting == "false": - kwargs["use_responses_api"] = False - kwargs.pop("reasoning", None) - elif _responses_api_setting == "true": - kwargs["use_responses_api"] = True - - # Usage tracking is an integrated-WebUI concern and is inert unless the - # launcher supplied the complete sink environment. Preserve the logical - # profile identity before compatible providers are represented by a native - # OpenAI/Anthropic adapter. - from ..usage import UsageModelIdentity, attach_usage_callback - - _usage_profile_id = ( - dynamic_profile.id - if dynamic_profile is not None - else (_original_provider or provider or "unknown") - ) - _usage_identity = UsageModelIdentity( - provider_profile_id=_usage_profile_id, - provider_revision=get_model_runtime_revision(_usage_profile_id), - provider_adapter=provider or "unknown", - model_alias=model, - upstream_model_id=model_id, - ) - - chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs) - chat_model = attach_usage_callback(chat_model, _usage_identity) - - # Flatten list content to strings for strict OpenAI-compatible providers - # (DeepSeek, SiliconFlow, OpenRouter, custom-openai, etc.) and - # native OpenAI through a proxy, to avoid "sequence expected string" errors. - # Moonshot and Kimi Coding support standard format, no patch needed. - _no_patch_providers = {"moonshot", "kimi-coding"} - if ( - _is_third_party or _is_openai_proxy - ) and _original_provider not in _no_patch_providers: - # Anthropic-routed providers accept media in tool results natively; - # only OpenAI-compatible providers need tool-media hoisting. - _hoist = _original_provider not in _ANTHROPIC_ROUTED_PROVIDERS - _patch_openai_compat_content(chat_model, hoist_tool_media=_hoist) - - # DeepSeek thinking mode requires reasoning_content passback in multi-turn - # + tool_use scenarios. - if _original_provider == "deepseek": - _patch_deepseek_reasoning_passback(chat_model) - - if _is_openai_proxy: - _patch_ccproxy_system_to_developer(chat_model) - - apply_known_context_window(chat_model) - if resolved_runtime is not None: - profile_data = getattr(chat_model, "profile", None) - base_profile = profile_data if isinstance(profile_data, dict) else {} - try: - chat_model.profile = { - **base_profile, - "max_input_tokens": resolved_runtime.resolved_input_limit, - "min_effective_input_tokens": ( - resolved_runtime.min_effective_input_tokens - ), - "runtime_capabilities": dict(resolved_runtime.capabilities), - } - except Exception: - pass - - return chat_model - - -def get_profile_chat_model( - profile: ProviderProfile, - model: ProviderModel, - *, - skip_auto_config: bool = False, - **kwargs: Any, -) -> Any: - """Build a model from an unsaved or persisted provider profile.""" - return get_chat_model( - model=model.id, - provider=profile.id, - profile_override=profile, - model_override=model, - skip_auto_config=skip_auto_config, - **kwargs, - ) - - -def list_models() -> list[str]: - """List all available model short names. - - Returns: - List of unique model short names that can be passed to get_chat_model(). - """ - seen = set() - result = [] - for name, _, _ in _MODEL_ENTRIES: - if name not in seen: - seen.add(name) - result.append(name) - return result - - -def list_models_by_provider() -> list[tuple[str, str, str]]: - """List all unique (short_name, model_id, provider) entries. - - Returns: - De-duplicated list of model entries preserving registry order. - """ - seen: set[tuple[str, str]] = set() - result: list[tuple[str, str, str]] = [] - for name, model_id, provider in _MODEL_ENTRIES: - key = (name, provider) - if key not in seen: - seen.add(key) - result.append((name, model_id, provider)) - return result - - -async def list_model_picker_entries( - ollama_base_url: str | None, - *, - include_custom_ollama: bool, - model_catalog: Any = None, -) -> list[tuple[str, str, str]]: - """Return models from the WebUI registry, or legacy entries before migration.""" - try: - registry_configured = await asyncio.to_thread( - get_provider_profiles_path().exists - ) - configured_builtin_entries = ( - await asyncio.to_thread(list_configured_builtin_model_entries) - if registry_configured - else list_builtin_model_catalog_entries(model_catalog) - ) - except (ProviderProfileError, ValueError): - configured_builtin_entries = [] - legacy_mode = configured_builtin_entries is None - entries = ( - list_models_by_provider() - if configured_builtin_entries is None - else configured_builtin_entries - ) - seen = {(name, provider) for name, _, provider in entries} - try: - configured_entries = await asyncio.to_thread(list_configured_model_entries) - except ProviderProfileError: - configured_entries = [] - for name, model_id, provider in configured_entries: - key = (name, provider) - if key not in seen: - entries.append((name, model_id, provider)) - seen.add(key) - if legacy_mode and ollama_base_url: - from .ollama_discovery import discover_ollama_models - - for detected_name in await discover_ollama_models( - ollama_base_url, - timeout=1.5, - ): - entries.append((detected_name, detected_name, "ollama")) - if include_custom_ollama: - entries.append(("Custom Ollama model...", "__custom_ollama__", "ollama")) - return entries - - -def get_model_info(model: str) -> tuple[str, str] | None: - """Get the (model_id, provider) tuple for a short name. - - Args: - model: Short model name. - - Returns: - Tuple of (model_id, provider) or None if not found. - """ - return MODELS.get(model) diff --git a/EvoScientist/llm/ollama_discovery.py b/EvoScientist/llm/ollama_discovery.py index 55dc18e..3dd4c3f 100644 --- a/EvoScientist/llm/ollama_discovery.py +++ b/EvoScientist/llm/ollama_discovery.py @@ -1,7 +1,7 @@ """Ollama server probing — shared by onboard wizard and /model picker. Ollama models are whatever the user has ``ollama pull``ed locally; they -cannot be enumerated in ``_MODEL_ENTRIES``. Both the setup wizard and the +cannot be enumerated in a static model catalog. Both the setup wizard and the interactive model picker need to hit ``GET {base_url}/api/tags`` to see what is actually installed. diff --git a/EvoScientist/llm/patches.py b/EvoScientist/llm/patches.py index 08a3982..ad7f4c4 100644 --- a/EvoScientist/llm/patches.py +++ b/EvoScientist/llm/patches.py @@ -882,7 +882,9 @@ def _patch_deepseek_reasoning_passback(model: Any) -> None: # resolves its chat model per run from ``configurable.runtime_snapshot_id`` # via ``ConfigurableModelMiddleware`` (design doc 8.2/8.3). Injecting # ``model``/``model_provider`` here would be rejected with -# ``MODEL_CONFIG_OUTSIDE_SNAPSHOT``. +# ``MODEL_CONFIG_OUTSIDE_SNAPSHOT``. Instead, a fresh local run snapshot +# bound to the child thread is created here (section 8.1: sub-agents are a +# local entry point sharing the same SnapshotService and snapshot table). # # Mechanism: wrap deepagents' ``_build_start_tool`` and ``_build_update_tool`` # factories. Each wrapped factory calls the original with a proxied client @@ -986,6 +988,27 @@ def _merge_runs_config_kwargs(kwargs: dict) -> dict: if not isinstance(existing_configurable, dict): existing_configurable = {} merged_configurable = {**existing_configurable, **inherited_scope} + + # Async sub-agent snapshot entry (design doc 8.1): the deployed child + # graph runs on its own thread, so it cannot reuse the parent's + # snapshot binding. Freeze a fresh local snapshot bound to the child + # thread; the child's ConfigurableModelMiddleware resolves it per call. + # A bootstrap registry raises MODEL_REGISTRY_NOT_READY here, surfacing + # as a structured start/update_async_task tool error on the parent. + child_thread_id = kwargs.get("thread_id") + if ( + isinstance(child_thread_id, str) + and child_thread_id + and "runtime_snapshot_id" not in merged_configurable + ): + from EvoScientist.model_registry.runtime import get_snapshot_runtime + + merged_configurable["runtime_snapshot_id"] = ( + get_snapshot_runtime() + .create_local_snapshot(child_thread_id) + .snapshot_id + ) + kwargs = dict(kwargs) if merged_configurable or "config" in kwargs: kwargs["config"] = {**existing, "configurable": merged_configurable} diff --git a/EvoScientist/llm/provider_operations.py b/EvoScientist/llm/provider_operations.py deleted file mode 100644 index 02b9aef..0000000 --- a/EvoScientist/llm/provider_operations.py +++ /dev/null @@ -1,519 +0,0 @@ -"""Provider model discovery and lightweight connectivity tests.""" - -from __future__ import annotations - -import asyncio -import os -import time -from dataclasses import dataclass -from typing import Any - -import httpx - -from ..config.provider_profiles import ( - ProviderModel, - ProviderProfile, - get_builtin_provider_profile, - normalize_provider_base_url, -) -from ..config.settings import is_config_applied_env - -_DEFAULT_BASE_URLS = { - "openai": "https://api.openai.com/v1", - "anthropic": "https://api.anthropic.com", - "google-genai": "https://generativelanguage.googleapis.com/v1beta", - "grok": "https://api.x.ai/v1", - "openrouter": "https://openrouter.ai/api/v1", - "nvidia": "https://integrate.api.nvidia.com/v1", - "ollama": "http://127.0.0.1:11434", -} -_API_KEY_REQUIRED_ADAPTERS = { - "openai", - "anthropic", - "google-genai", - "grok", - "openrouter", - "nvidia", -} -_MAX_DISCOVERED_MODELS = 2_000 -_MAX_PAGES = 100 -_MAX_REQUEST_ATTEMPTS = 2 - - -@dataclass(frozen=True) -class BuiltinProviderConnection: - adapter: str - api_key_field: str | None - base_url_field: str | None = None - default_base_url: str = "" - - -_BUILTIN_PROVIDER_CONNECTIONS = { - "anthropic": BuiltinProviderConnection( - "anthropic", "anthropic_api_key", "anthropic_base_url" - ), - "openai": BuiltinProviderConnection("openai", "openai_api_key"), - "google-genai": BuiltinProviderConnection("google-genai", "google_api_key"), - "minimax": BuiltinProviderConnection( - "anthropic-compatible", - "minimax_api_key", - "minimax_base_url", - "https://api.minimaxi.com/anthropic", - ), - "nvidia": BuiltinProviderConnection("nvidia", "nvidia_api_key"), - "siliconflow": BuiltinProviderConnection( - "openai-compatible", - "siliconflow_api_key", - default_base_url="https://api.siliconflow.cn/v1", - ), - "openrouter": BuiltinProviderConnection("openrouter", "openrouter_api_key"), - "zhipu": BuiltinProviderConnection( - "openai-compatible", - "zhipu_api_key", - default_base_url="https://open.bigmodel.cn/api/paas/v4", - ), - "zhipu-code": BuiltinProviderConnection( - "openai-compatible", - "zhipu_api_key", - default_base_url="https://open.bigmodel.cn/api/coding/paas/v4", - ), - "volcengine": BuiltinProviderConnection( - "openai-compatible", - "volcengine_api_key", - default_base_url="https://ark.cn-beijing.volces.com/api/v3", - ), - "dashscope": BuiltinProviderConnection( - "openai-compatible", - "dashscope_api_key", - default_base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", - ), - "dashscope-code": BuiltinProviderConnection( - "openai-compatible", - "dashscope_api_key", - default_base_url="https://coding.dashscope.aliyuncs.com/v1", - ), - "deepseek": BuiltinProviderConnection( - "openai-compatible", - "deepseek_api_key", - default_base_url="https://api.deepseek.com", - ), - "moonshot": BuiltinProviderConnection( - "openai-compatible", - "moonshot_api_key", - default_base_url="https://api.moonshot.cn/v1", - ), - "kimi-coding": BuiltinProviderConnection( - "anthropic-compatible", - "kimi_api_key", - default_base_url="https://api.kimi.com/coding/", - ), - "custom-openai": BuiltinProviderConnection( - "openai-compatible", - "custom_openai_api_key", - "custom_openai_base_url", - ), - "custom-anthropic": BuiltinProviderConnection( - "anthropic-compatible", - "custom_anthropic_api_key", - "custom_anthropic_base_url", - ), - "ollama": BuiltinProviderConnection("ollama", None, "ollama_base_url"), -} -_BUILTIN_API_KEY_ENVS = { - "anthropic": "ANTHROPIC_API_KEY", - "openai": "OPENAI_API_KEY", - "google-genai": "GOOGLE_API_KEY", - "minimax": "MINIMAX_API_KEY", - "nvidia": "NVIDIA_API_KEY", - "siliconflow": "SILICONFLOW_API_KEY", - "openrouter": "OPENROUTER_API_KEY", - "zhipu": "ZHIPU_API_KEY", - "zhipu-code": "ZHIPU_API_KEY", - "volcengine": "VOLCENGINE_API_KEY", - "dashscope": "DASHSCOPE_API_KEY", - "dashscope-code": "DASHSCOPE_API_KEY", - "deepseek": "DEEPSEEK_API_KEY", - "moonshot": "MOONSHOT_API_KEY", - "kimi-coding": "KIMI_API_KEY", - "custom-openai": "CUSTOM_OPENAI_API_KEY", - "custom-anthropic": "CUSTOM_ANTHROPIC_API_KEY", -} -_BUILTIN_BASE_URL_ENVS = { - "anthropic": "ANTHROPIC_BASE_URL", - "openai": "OPENAI_BASE_URL", - "minimax": "MINIMAX_BASE_URL", - "custom-openai": "CUSTOM_OPENAI_BASE_URL", - "custom-anthropic": "CUSTOM_ANTHROPIC_BASE_URL", - "ollama": "OLLAMA_BASE_URL", -} - - -class ProviderOperationError(RuntimeError): - """Raised when provider discovery or testing cannot complete.""" - - -def build_builtin_provider_profile( - config: Any, - provider_id: str, - *, - saved_profile: ProviderProfile | None = None, - load_saved: bool = True, - use_environment: bool = True, - base_url_override: str | None = None, - api_key_override: str | None = None, - clear_api_key: bool = False, -) -> ProviderProfile: - """Resolve a built-in profile with env > registry > legacy config priority.""" - spec = _BUILTIN_PROVIDER_CONNECTIONS.get(provider_id) - if spec is None: - raise ProviderOperationError(f"Unknown built-in provider {provider_id!r}.") - - saved = get_builtin_provider_profile(provider_id) if load_saved else saved_profile - base_url = saved.base_url if saved is not None else spec.default_base_url - if saved is None and spec.base_url_field: - configured_base_url = getattr(config, spec.base_url_field, "") - if isinstance(configured_base_url, str) and configured_base_url.strip(): - base_url = configured_base_url.strip() - base_url_env = _BUILTIN_BASE_URL_ENVS.get(provider_id) - if ( - base_url_env - and use_environment - and os.environ.get(base_url_env, "").strip() - and (saved is None or not is_config_applied_env(base_url_env)) - ): - base_url = os.environ[base_url_env].strip() - if base_url_override is not None: - base_url = base_url_override.strip() or spec.default_base_url - - api_key = saved.api_key if saved is not None else "" - if saved is None and spec.api_key_field: - configured_api_key = getattr(config, spec.api_key_field, "") - if isinstance(configured_api_key, str): - api_key = configured_api_key.strip() - api_key_env = _BUILTIN_API_KEY_ENVS.get(provider_id) - if ( - api_key_env - and use_environment - and os.environ.get(api_key_env, "").strip() - and (saved is None or not is_config_applied_env(api_key_env)) - ): - api_key = os.environ[api_key_env].strip() - if clear_api_key: - api_key = "" - elif api_key_override is not None and api_key_override.strip(): - api_key = api_key_override.strip() - - return ProviderProfile( - id=provider_id, - name=provider_id, - adapter=spec.adapter, - base_url=base_url, - api_key=api_key, - enabled=saved.enabled if saved is not None else True, - models=saved.models if saved is not None else (), - auth_mode=( - saved.auth_mode - if saved is not None - else getattr(config, f"{provider_id}_auth_mode", "api_key") - if provider_id in {"anthropic", "openai"} - else "api_key" - ), - ) - - -def builtin_provider_ids() -> tuple[str, ...]: - return tuple(_BUILTIN_PROVIDER_CONNECTIONS) - - -def builtin_provider_adapter(provider_id: str) -> str: - spec = _BUILTIN_PROVIDER_CONNECTIONS.get(provider_id) - if spec is None: - raise ProviderOperationError(f"Unknown built-in provider {provider_id!r}.") - return spec.adapter - - -def builtin_provider_legacy_api_key(config: Any, provider_id: str) -> str: - """Return only the config.yaml key, excluding environment overrides.""" - spec = _BUILTIN_PROVIDER_CONNECTIONS.get(provider_id) - if spec is None or spec.api_key_field is None: - return "" - value = getattr(config, spec.api_key_field, "") - return value.strip() if isinstance(value, str) else "" - - -@dataclass(frozen=True) -class DiscoveredProviderModel: - model_id: str - name: str - - -@dataclass(frozen=True) -class ProviderModelTestResult: - latency_ms: int - response: str - - -def _base_url(profile: ProviderProfile) -> str: - base_url = profile.base_url or _DEFAULT_BASE_URLS.get(profile.adapter, "") - if not base_url: - raise ProviderOperationError( - f"Base URL is required for adapter {profile.adapter}." - ) - return normalize_provider_base_url(profile.adapter, base_url) - - -def _models_url(profile: ProviderProfile) -> str: - base_url = _base_url(profile) - if profile.adapter in {"anthropic", "anthropic-compatible"}: - return ( - f"{base_url}/models" - if base_url.endswith(("/v1", "/v1beta")) - else f"{base_url}/v1/models" - ) - if profile.adapter == "ollama": - return f"{base_url}/api/tags" - return f"{base_url}/models" - - -def _headers(profile: ProviderProfile) -> dict[str, str]: - if profile.adapter in _API_KEY_REQUIRED_ADAPTERS and not profile.api_key: - raise ProviderOperationError( - f"API key is required for adapter {profile.adapter}." - ) - if profile.adapter in {"anthropic", "anthropic-compatible"}: - headers = {"anthropic-version": "2023-06-01"} - if profile.api_key: - headers["x-api-key"] = profile.api_key - return headers - if profile.adapter == "google-genai": - return {"x-goog-api-key": profile.api_key} - if profile.adapter == "ollama" or not profile.api_key: - return {} - return {"Authorization": f"Bearer {profile.api_key}"} - - -def _provider_error_message(response: httpx.Response) -> str: - message = "" - try: - payload = response.json() - if isinstance(payload, dict): - error = payload.get("error") - if isinstance(error, dict): - raw_message = error.get("message") - if isinstance(raw_message, str): - message = raw_message.strip() - elif isinstance(error, str): - message = error.strip() - if not message: - raw_message = payload.get("message") - if isinstance(raw_message, str): - message = raw_message.strip() - except (ValueError, TypeError): - pass - suffix = f": {message[:500]}" if message else "" - return f"Provider returned HTTP {response.status_code}{suffix}" - - -def _model_name(raw: dict[str, Any], model_id: str) -> str: - for key in ("display_name", "displayName", "name"): - value = raw.get(key) - if isinstance(value, str) and value.strip(): - return value.strip()[:120] - return model_id[:120] - - -def _parse_models( - profile: ProviderProfile, payload: Any -) -> list[DiscoveredProviderModel]: - if not isinstance(payload, dict): - raise ProviderOperationError("Provider returned an invalid model list.") - - records = ( - payload.get("models") if profile.adapter == "ollama" else payload.get("data") - ) - if profile.adapter == "google-genai": - records = payload.get("models") - elif not isinstance(records, list): - records = payload.get("models") - if not isinstance(records, list): - raise ProviderOperationError("Provider response does not contain a model list.") - - result: list[DiscoveredProviderModel] = [] - for raw in records: - if not isinstance(raw, dict): - continue - model_id_raw = ( - (raw.get("model") or raw.get("name")) - if profile.adapter == "ollama" - else raw.get("id") - ) - if profile.adapter == "google-genai": - model_id_raw = raw.get("name") - methods = raw.get("supportedGenerationMethods") - if isinstance(methods, list) and "generateContent" not in methods: - continue - if not isinstance(model_id_raw, str) or not model_id_raw.strip(): - continue - model_id = model_id_raw.strip() - if profile.adapter == "google-genai": - model_id = model_id.removeprefix("models/") - result.append( - DiscoveredProviderModel( - model_id=model_id[:300], - name=_model_name(raw, model_id), - ) - ) - return result - - -async def discover_provider_models( - profile: ProviderProfile, - *, - transport: httpx.AsyncBaseTransport | None = None, -) -> list[DiscoveredProviderModel]: - """Fetch every model exposed by a provider's model-list endpoint.""" - url = _models_url(profile) - headers = _headers(profile) - params: dict[str, str | int] = {} - discovered: list[DiscoveredProviderModel] = [] - seen: set[str] = set() - - timeout = httpx.Timeout(30.0, connect=10.0) - async with httpx.AsyncClient(timeout=timeout, transport=transport) as client: - for _ in range(_MAX_PAGES): - response: httpx.Response | None = None - last_request_error: httpx.HTTPError | None = None - for attempt in range(_MAX_REQUEST_ATTEMPTS): - try: - response = await client.get(url, headers=headers, params=params) - break - except (httpx.TimeoutException, httpx.NetworkError) as exc: - last_request_error = exc - if attempt + 1 < _MAX_REQUEST_ATTEMPTS: - await asyncio.sleep(0.25 * (attempt + 1)) - if response is None: - if isinstance(last_request_error, httpx.TimeoutException): - raise ProviderOperationError( - f"Provider model discovery timed out after " - f"{_MAX_REQUEST_ATTEMPTS} attempts." - ) from last_request_error - error_name = ( - last_request_error.__class__.__name__ - if last_request_error is not None - else "NetworkError" - ) - raise ProviderOperationError( - f"Could not connect to provider after " - f"{_MAX_REQUEST_ATTEMPTS} attempts: {error_name}." - ) from last_request_error - if response.status_code >= 400: - raise ProviderOperationError(_provider_error_message(response)) - try: - payload = response.json() - except ValueError as exc: - content_type = ( - response.headers.get("content-type", "unknown") - .split(";", 1)[0] - .strip() - or "unknown" - ) - hint = ( - " OpenAI-compatible Base URLs normally end in /v1." - if profile.adapter - in { - "openai", - "openai-compatible", - "grok", - "openrouter", - "nvidia", - "antigravity", - } - else "" - ) - raise ProviderOperationError( - f"Provider returned a non-JSON model list from {url} " - f"(Content-Type: {content_type}).{hint}" - ) from exc - - for model in _parse_models(profile, payload): - if model.model_id in seen: - continue - seen.add(model.model_id) - discovered.append(model) - if len(discovered) >= _MAX_DISCOVERED_MODELS: - return sorted(discovered, key=lambda item: item.model_id.lower()) - - if profile.adapter in {"anthropic", "anthropic-compatible"}: - if not isinstance(payload, dict) or payload.get("has_more") is not True: - break - last_id = payload.get("last_id") - if not isinstance(last_id, str) or not last_id: - break - params = {"after_id": last_id, "limit": 100} - continue - if profile.adapter == "google-genai": - next_page = ( - payload.get("nextPageToken") if isinstance(payload, dict) else None - ) - if not isinstance(next_page, str) or not next_page: - break - params = {"pageToken": next_page, "pageSize": 1000} - continue - break - - return sorted(discovered, key=lambda item: item.model_id.lower()) - - -def _response_text(response: Any) -> str: - content = getattr(response, "content", response) - if isinstance(content, str): - return content.strip() - if isinstance(content, list): - parts: list[str] = [] - for block in content: - if isinstance(block, str): - parts.append(block) - elif isinstance(block, dict): - text = block.get("text") or block.get("content") - if isinstance(text, str): - parts.append(text) - return "\n".join(parts).strip() - return str(content).strip() - - -def _safe_test_error(exc: Exception, profile: ProviderProfile) -> str: - message = str(exc).strip() or exc.__class__.__name__ - if profile.api_key: - message = message.replace(profile.api_key, "[redacted]") - return message[:800] - - -async def test_provider_model( - profile: ProviderProfile, - model: ProviderModel, -) -> ProviderModelTestResult: - """Build the configured model and run a minimal real inference request.""" - from .models import get_profile_chat_model - - started = time.perf_counter() - try: - chat_model = await asyncio.to_thread( - get_profile_chat_model, - profile, - model, - max_tokens=64, - skip_auto_config=True, - ) - response = await asyncio.wait_for( - chat_model.ainvoke("Reply with exactly: OK"), timeout=45.0 - ) - except TimeoutError as exc: - raise ProviderOperationError("Model test timed out after 45 seconds.") from exc - except Exception as exc: - raise ProviderOperationError(_safe_test_error(exc, profile)) from exc - - latency_ms = max(1, round((time.perf_counter() - started) * 1000)) - return ProviderModelTestResult( - latency_ms=latency_ms, - response=_response_text(response)[:500], - ) diff --git a/EvoScientist/llm/runtime_snapshots.py b/EvoScientist/llm/runtime_snapshots.py deleted file mode 100644 index 3dcfee2..0000000 --- a/EvoScientist/llm/runtime_snapshots.py +++ /dev/null @@ -1,401 +0,0 @@ -"""Server-side, per-run snapshots for custom provider model configuration. - -The browser and LangGraph run configuration carry only a random snapshot ID. -The SQLite record stores non-secret connection metadata and runtime limits. -Credentials stay in a process-local cache for the lifetime of a development -deployment, so a restarted deployment fails an old run explicitly instead of -silently using a newly edited credential. -""" - -from __future__ import annotations - -import json -import sqlite3 -import threading -import time -from dataclasses import dataclass, replace -from pathlib import Path -from typing import Any - -from ..config.provider_profiles import ( - ModelRuntime, - ProviderModel, - ProviderProfile, - ProviderProfileError, - ProviderRuntime, - get_provider_profile_revision, - resolve_model_input_limit, - resolve_provider_model, -) -from ..config.settings import get_config_dir -from .models import ( - ResolvedRuntimeOptions, - is_static_provider_id, - normalize_provider_id, - resolve_runtime_options, -) - -_SNAPSHOT_TTL_SECONDS = 7 * 24 * 60 * 60 -_MAX_SNAPSHOT_ID_LENGTH = 128 -_DB_LOCK = threading.RLock() -_MODEL_CACHE_LOCK = threading.RLock() -_MODEL_CACHE: dict[str, Any] = {} -_SNAPSHOT_SECRETS: dict[str, str] = {} - - -def _validate_static_provider_credentials(provider: str) -> None: - """Reject known static selections that lack their provider-specific key. - - Runtime snapshots are created by the WebUI before a run is queued. This is - the latest point at which we can return a useful client error without - making the graph fail after the user has already submitted a message. - """ - if provider not in {"zhipu", "zhipu-code"}: - return - - from ..config.settings import get_effective_config - from .provider_operations import build_builtin_provider_profile - - config = get_effective_config() - try: - profile = build_builtin_provider_profile(config, provider) - except ProviderProfileError as exc: - # A stale custom registry must not hide configuration supplied through - # config.yaml or the environment for a built-in provider. - if not str(exc).startswith("PROVIDER_PROFILE_RESET_REQUIRED"): - raise - profile = build_builtin_provider_profile( - config, provider, load_saved=False - ) - if not profile.api_key.strip(): - raise ProviderProfileError( - "ZHIPU_API_KEY_NOT_CONFIGURED: GLM requires a valid Zhipu API key. " - "Configure ZHIPU_API_KEY before starting a conversation." - ) - - -@dataclass(frozen=True) -class RunRuntimeSnapshot: - """An immutable server-side configuration captured before one run starts.""" - - snapshot_id: str - created_at: int - expires_at: int - profile_revision: str | None - profile: ProviderProfile - model: ProviderModel - options: ResolvedRuntimeOptions - - def public_payload(self) -> dict[str, Any]: - """Return diagnostic fields that are safe to attach to a run.""" - return { - "snapshot_id": self.snapshot_id, - "provider": self.profile.id, - "model": self.model.id, - "profile_revision": self.profile_revision, - "created_at": self.created_at, - "expires_at": self.expires_at, - "runtime": { - "max_input_tokens": self.options.resolved_input_limit, - "max_output_tokens": self.options.max_output_tokens, - "min_effective_input_tokens": self.options.min_effective_input_tokens, - "timeout_seconds": self.options.timeout_seconds, - "max_retries": self.options.max_retries, - "temperature": self.options.temperature, - "top_p": self.options.top_p, - "reasoning_effort": self.options.reasoning_effort, - "limits_source": self.options.limits_source, - "capabilities": dict(self.options.capabilities), - }, - } - - -def _database_path() -> Path: - return get_config_dir() / "run-runtime-snapshots.sqlite3" - - -def _connect() -> sqlite3.Connection: - path = _database_path() - path.parent.mkdir(parents=True, exist_ok=True) - try: - path.parent.chmod(0o700) - except OSError: - pass - connection = sqlite3.connect(path) - connection.execute( - """ - CREATE TABLE IF NOT EXISTS run_runtime_snapshots ( - snapshot_id TEXT PRIMARY KEY, - expires_at INTEGER NOT NULL, - payload_json TEXT NOT NULL - ) - """ - ) - try: - path.chmod(0o600) - except OSError: - pass - return connection - - -def _validate_snapshot_id(snapshot_id: str) -> str: - normalized = snapshot_id.strip() - if not normalized or len(normalized) > _MAX_SNAPSHOT_ID_LENGTH: - raise ProviderProfileError("runtime snapshot ID is invalid.") - if any(ord(character) < 33 or ord(character) > 126 for character in normalized): - raise ProviderProfileError("runtime snapshot ID is invalid.") - return normalized - - -def _provider_runtime_payload(runtime: ProviderRuntime) -> dict[str, Any]: - return { - "timeout_seconds": runtime.timeout_seconds, - "max_retries": runtime.max_retries, - "default_temperature": runtime.default_temperature, - "default_top_p": runtime.default_top_p, - "default_reasoning_effort": runtime.default_reasoning_effort, - } - - -def _model_runtime_payload(runtime: ModelRuntime) -> dict[str, Any]: - return { - "limit_mode": runtime.limit_mode, - "context_window_tokens": runtime.context_window_tokens, - "max_input_tokens": runtime.max_input_tokens, - "max_output_tokens": runtime.max_output_tokens, - "min_effective_input_tokens": runtime.min_effective_input_tokens, - "limits_status": runtime.limits_status, - "limits_source": runtime.limits_source, - "temperature": runtime.temperature, - "top_p": runtime.top_p, - "reasoning_effort": runtime.reasoning_effort, - "capabilities": dict(runtime.capabilities), - } - - -def _profile_payload(profile: ProviderProfile, model: ProviderModel) -> dict[str, Any]: - return { - "id": profile.id, - "name": profile.name, - "adapter": profile.adapter, - "base_url": profile.base_url, - # Credentials stay in the process-local secret cache. The persisted - # snapshot remains safe to inspect through diagnostics and never - # duplicates a Provider Profile API key. - "api_key": "", - "auth_mode": profile.auth_mode, - "enabled": profile.enabled, - "runtime": _provider_runtime_payload(profile.runtime), - "models": [ - { - "id": model.id, - "name": model.name, - "model_id": model.model_id, - "enabled": model.enabled, - "runtime": _model_runtime_payload(model.runtime), - } - ], - } - - -def _parse_snapshot_payload( - payload: dict[str, Any], *, require_secret: bool = True -) -> RunRuntimeSnapshot: - try: - profile_raw = payload["profile"] - if not isinstance(profile_raw, dict): - raise ProviderProfileError("runtime snapshot profile is invalid.") - runtime_raw = payload["runtime"] - if not isinstance(runtime_raw, dict): - raise ProviderProfileError("runtime snapshot options are invalid.") - - # The source is a private snapshot, not a browser draft. Reuse the - # validated profile parser without looking up the current registry. - from ..config.provider_profiles import _parse_profile - - profile = _parse_profile(profile_raw, index=0) - model = profile.models[0] - resolved_input_limit = int(runtime_raw["resolved_input_limit"]) - if resolved_input_limit != resolve_model_input_limit(model.runtime): - raise ProviderProfileError("runtime snapshot limits are invalid.") - capabilities_raw = dict(runtime_raw["capabilities"]) - options = ResolvedRuntimeOptions( - profile_revision=( - payload["profile_revision"] - if isinstance(payload.get("profile_revision"), str) - else None - ), - model_id=str(runtime_raw["model_id"]), - adapter_id=str(runtime_raw["adapter_id"]), - limit_mode=str(runtime_raw["limit_mode"]), - resolved_input_limit=resolved_input_limit, - max_output_tokens=int(runtime_raw["max_output_tokens"]), - min_effective_input_tokens=int( - runtime_raw["min_effective_input_tokens"] - ), - timeout_seconds=int(runtime_raw["timeout_seconds"]), - max_retries=int(runtime_raw["max_retries"]), - temperature=runtime_raw.get("temperature"), - top_p=runtime_raw.get("top_p"), - reasoning_effort=str(runtime_raw["reasoning_effort"]), - limits_source=str(runtime_raw["limits_source"]), - capabilities=tuple( - (key, capabilities_raw[key]) - for key in ("tools", "vision", "structured_output") - if capabilities_raw.get(key) in {True, False, "auto"} - ), - ) - snapshot_id = _validate_snapshot_id(str(payload["snapshot_id"])) - secret = _SNAPSHOT_SECRETS.get(snapshot_id) - if require_secret and snapshot_id not in _SNAPSHOT_SECRETS: - raise ProviderProfileError( - "RUN_RUNTIME_SNAPSHOT_CREDENTIAL_UNAVAILABLE: the deployment was " - "restarted after this run began. Start the message again." - ) - return RunRuntimeSnapshot( - snapshot_id=snapshot_id, - created_at=int(payload["created_at"]), - expires_at=int(payload["expires_at"]), - profile_revision=options.profile_revision, - profile=replace(profile, api_key=secret or ""), - model=model, - options=options, - ) - except (KeyError, TypeError, ValueError) as exc: - raise ProviderProfileError("runtime snapshot is invalid.") from exc - - -def _snapshot_payload(snapshot: RunRuntimeSnapshot) -> dict[str, Any]: - return { - "snapshot_id": snapshot.snapshot_id, - "created_at": snapshot.created_at, - "expires_at": snapshot.expires_at, - "profile_revision": snapshot.profile_revision, - "profile": _profile_payload(snapshot.profile, snapshot.model), - "runtime": { - "model_id": snapshot.options.model_id, - "adapter_id": snapshot.options.adapter_id, - "limit_mode": snapshot.options.limit_mode, - "resolved_input_limit": snapshot.options.resolved_input_limit, - "max_output_tokens": snapshot.options.max_output_tokens, - "min_effective_input_tokens": snapshot.options.min_effective_input_tokens, - "timeout_seconds": snapshot.options.timeout_seconds, - "max_retries": snapshot.options.max_retries, - "temperature": snapshot.options.temperature, - "top_p": snapshot.options.top_p, - "reasoning_effort": snapshot.options.reasoning_effort, - "limits_source": snapshot.options.limits_source, - "capabilities": dict(snapshot.options.capabilities), - }, - } - - -def create_run_runtime_snapshot( - snapshot_id: str, - *, - model: str, - provider: str, -) -> RunRuntimeSnapshot | None: - """Freeze one custom provider model configuration for a forthcoming run. - - Static built-in providers remain managed by their existing deployment - configuration and return ``None``. A missing custom model is an error so - a model selection never silently falls back to a different model. - """ - snapshot_id = _validate_snapshot_id(snapshot_id) - model = model.strip() - provider = normalize_provider_id(provider) - if not model or not provider: - raise ProviderProfileError("model and provider are required for a runtime snapshot.") - - # Built-in providers are resolved from the deployment configuration and - # have no browser-managed secret or runtime profile to freeze. Avoid - # loading custom profiles at all, so a stale development registry cannot - # block a normal built-in run. - if is_static_provider_id(provider): - _validate_static_provider_credentials(provider) - return None - - existing = get_run_runtime_snapshot(snapshot_id) - if existing is not None: - if existing.model.id != model or existing.profile.id != provider: - raise ProviderProfileError( - "runtime snapshot ID is already bound to a different model selection." - ) - return existing - - resolved = resolve_provider_model(provider, model) - if resolved is None: - return None - profile, provider_model = resolved - options = resolve_runtime_options(profile, provider_model) - now = int(time.time()) - snapshot = RunRuntimeSnapshot( - snapshot_id=snapshot_id, - created_at=now, - expires_at=now + _SNAPSHOT_TTL_SECONDS, - profile_revision=get_provider_profile_revision(provider), - profile=profile, - model=provider_model, - options=options, - ) - encoded = json.dumps( - _snapshot_payload(snapshot), sort_keys=True, separators=(",", ":") - ) - with _DB_LOCK, _connect() as connection: - connection.execute( - "DELETE FROM run_runtime_snapshots WHERE expires_at <= ?", (now,) - ) - connection.execute( - "INSERT OR REPLACE INTO run_runtime_snapshots(snapshot_id, expires_at, payload_json) " - "VALUES (?, ?, ?)", - (snapshot.snapshot_id, snapshot.expires_at, encoded), - ) - connection.commit() - _SNAPSHOT_SECRETS[snapshot.snapshot_id] = profile.api_key - return snapshot - - -def get_run_runtime_snapshot(snapshot_id: str) -> RunRuntimeSnapshot | None: - """Load a still-valid runtime snapshot by its opaque ID.""" - snapshot_id = _validate_snapshot_id(snapshot_id) - now = int(time.time()) - with _DB_LOCK, _connect() as connection: - connection.execute( - "DELETE FROM run_runtime_snapshots WHERE expires_at <= ?", (now,) - ) - row = connection.execute( - "SELECT expires_at, payload_json FROM run_runtime_snapshots WHERE snapshot_id = ?", - (snapshot_id,), - ).fetchone() - connection.commit() - if row is None or int(row[0]) <= now: - _SNAPSHOT_SECRETS.pop(snapshot_id, None) - return None - try: - payload = json.loads(str(row[1])) - except json.JSONDecodeError as exc: - raise ProviderProfileError("runtime snapshot is unreadable.") from exc - if not isinstance(payload, dict): - raise ProviderProfileError("runtime snapshot is unreadable.") - return _parse_snapshot_payload(payload) - - -def get_snapshot_chat_model(snapshot_id: str) -> Any: - """Build (or reuse) the chat model bound to a frozen runtime snapshot.""" - snapshot = get_run_runtime_snapshot(snapshot_id) - if snapshot is None: - raise ProviderProfileError( - "RUN_RUNTIME_SNAPSHOT_UNAVAILABLE: the run configuration snapshot " - "expired or is unavailable. Start the message again." - ) - with _MODEL_CACHE_LOCK: - cached = _MODEL_CACHE.get(snapshot.snapshot_id) - if cached is not None: - return cached - from .models import get_profile_chat_model - - model = get_profile_chat_model(snapshot.profile, snapshot.model) - with _MODEL_CACHE_LOCK: - _MODEL_CACHE[snapshot.snapshot_id] = model - return model diff --git a/EvoScientist/middleware/__init__.py b/EvoScientist/middleware/__init__.py index 550baf3..07c4dbd 100644 --- a/EvoScientist/middleware/__init__.py +++ b/EvoScientist/middleware/__init__.py @@ -28,7 +28,6 @@ from .memory_lifecycle import ( default_memory_scheduler, ) from .message_budget import ( - MessageReservePolicy, count_message_text_tokens, create_message_budget_middleware, ) @@ -50,7 +49,6 @@ __all__ = [ "ContextOverflowMapperMiddleware", "EvoMemoryLifecycleMiddleware", "EvoMemoryMiddleware", - "MessageReservePolicy", "Question", "RuntimeContextMiddleware", "SchedulerMiddleware", diff --git a/EvoScientist/middleware/background.py b/EvoScientist/middleware/background.py index d1c6ee6..f533742 100644 --- a/EvoScientist/middleware/background.py +++ b/EvoScientist/middleware/background.py @@ -11,6 +11,7 @@ sub-agents are *tasks*, future cron is *schedules*). from __future__ import annotations +import os from datetime import UTC, datetime from langchain.agents.middleware import AgentMiddleware @@ -80,9 +81,12 @@ def run_in_background( # apply_config_to_env round-trips at startup (and the subprocess inherits) — # cheaper than reloading the full config from disk on every launch, and uses # the same truthy parsing as every other bool env flag. - from ..llm.models import _env_flag_enabled - - dangerous = _env_flag_enabled("EVOSCIENTIST_DANGEROUS_MODE") + dangerous = os.getenv("EVOSCIENTIST_DANGEROUS_MODE", "").strip().lower() in { + "1", + "true", + "yes", + "on", + } # Same path-rewriting + validation as execute (shared helper) so virtual paths # resolve to the workspace and the command can't bypass the sandbox checks. command, error = prepare_sandbox_command( diff --git a/EvoScientist/middleware/configurable_model.py b/EvoScientist/middleware/configurable_model.py index dd638b2..f29aa0d 100644 --- a/EvoScientist/middleware/configurable_model.py +++ b/EvoScientist/middleware/configurable_model.py @@ -13,10 +13,15 @@ out-of-snapshot model parameter is rejected with ``MODEL_CONFIG_OUTSIDE_SNAPSHOT`` (422 semantics, section 8.2) — run creation must never mix snapshot and non-snapshot model configuration. -When no ``runtime_snapshot_id`` is present the middleware is a pass-through: -local entry points (CLI/channels/scheduler) are wired to create local -snapshots separately, and their compile-time model binding applies until -then. +Local entry points (CLI/channels/scheduler/sub-agents, section 8.1) create +snapshots through ``SnapshotRuntime.create_local_snapshot`` and put the +``runtime_snapshot_id`` into ``configurable`` before the run starts. Runs +whose entry point could not inject a snapshot up front (langgraph-dev cron +fires, deployed async sub-agent graphs) are healed lazily: the middleware +creates the local snapshot itself, bound to the run's own thread. A run +carrying neither a snapshot nor a bindable thread ID is rejected, and a +bootstrap registry fails closed with ``MODEL_REGISTRY_NOT_READY`` — there +is no pass-through fallback to a compile-time model anymore. **Reading the config**: ``Runtime`` (per its own docstring) does NOT include ``config``. The official path to reach ``RunnableConfig`` from inside any @@ -107,6 +112,47 @@ def read_snapshot_binding( ) +def ensure_snapshot_binding( + configurable: Mapping[str, Any], + runtime: SnapshotRuntime, +) -> tuple[str, str, str]: + """Return ``(snapshot_id, deployment_id, thread_id)`` for the active run. + + The run's explicit ``runtime_snapshot_id`` wins (section 8.2 binding). + Without one, the run is a local entry that could not inject a snapshot + up front — a langgraph-dev cron fire or a deployed sub-agent graph — so + the snapshot is created lazily through the same ``SnapshotService`` + (section 8.1): ``deployment_id`` is the platform local deployment ID, + ``model_selection_revision`` is ``0``, and ``primary`` inherits the + registry defaults. The lazy ``run_request_id`` is derived from the + thread, so every middleware in the run converges on one snapshot and a + fresh thread (each cron fire, each sub-agent task) freezes fresh + defaults. + + Raises: + ModelRegistryError: ``MODEL_CONFIG_OUTSIDE_SNAPSHOT`` when the run + carries neither a snapshot nor a thread ID to bind one to; + ``MODEL_REGISTRY_NOT_READY`` when the registry is in bootstrap. + """ + binding = read_snapshot_binding(configurable) + if binding is not None: + snapshot_id, deployment_id, thread_id = binding + return (snapshot_id, deployment_id or runtime.local_deployment_id, thread_id or "") + thread_id = configurable.get("thread_id") + if not isinstance(thread_id, str) or not thread_id: + raise ModelRegistryError( + MODEL_CONFIG_OUTSIDE_SNAPSHOT, + "Model configuration must come from a run snapshot; this run " + "carries neither 'runtime_snapshot_id' nor a 'thread_id' a " + "local snapshot could be bound to.", + details=[{"path": "runtime_snapshot_id", "code": MODEL_CONFIG_OUTSIDE_SNAPSHOT}], + ) + snapshot = runtime.create_local_snapshot( + thread_id, run_request_id=f"auto:{thread_id}" + ) + return (snapshot.snapshot_id, snapshot.deployment_id, snapshot.thread_id) + + class ConfigurableModelMiddleware(AgentMiddleware): """Re-resolve the chat model from the run snapshot on every call. @@ -173,51 +219,48 @@ class ConfigurableModelMiddleware(AgentMiddleware): *message_args, ) - def _load_snapshot(self, snapshot_id: str) -> RuntimeSnapshot: - """Load the snapshot, verifying its deployment/thread binding.""" + def _load_snapshot(self) -> RuntimeSnapshot: + """Load the run's snapshot, verifying its deployment/thread binding. + + The snapshot is the only model configuration entry point: outside + model keys are rejected first, and a run without an explicit + ``runtime_snapshot_id`` gets a lazily created local snapshot bound + to its own thread (never a silent compile-time fallback). + """ configurable = _current_configurable() - binding = read_snapshot_binding(configurable) - assert binding is not None # guarded by the caller - _, deployment_id, thread_id = binding + check_no_outside_snapshot_model_config(configurable) runtime = self._snapshot_runtime() + snapshot_id, deployment_id, thread_id = ensure_snapshot_binding( + configurable, runtime + ) return runtime.get_snapshot( snapshot_id, - deployment_id=deployment_id or runtime.local_deployment_id, - thread_id=thread_id or "", + deployment_id=deployment_id, + thread_id=thread_id, ) - def _resolve(self, snapshot_id: str) -> Any: - """Return a cached or freshly-built chat model for the snapshot.""" + def _resolve(self) -> Any: + """Return a cached or freshly-built chat model for the run's snapshot.""" + snapshot = self._load_snapshot() with self._lock: - cached = self._cache.get(snapshot_id) + cached = self._cache.get(snapshot.snapshot_id) if cached is not None: return cached - snapshot = self._load_snapshot(snapshot_id) # Build outside the lock (SDK init can be slow); two concurrent # first-time misses for the same snapshot may build twice but the # second result simply overwrites the first — both are equivalent. new_model = self._snapshot_runtime().build_role_model(snapshot, self._role) with self._lock: - self._cache[snapshot_id] = new_model + self._cache[snapshot.snapshot_id] = new_model self._log_override(snapshot) return new_model - def _snapshot_id_for_request(self) -> str | None: - """Return the run's snapshot ID after the outside-config check.""" - configurable = _current_configurable() - check_no_outside_snapshot_model_config(configurable) - binding = read_snapshot_binding(configurable) - return binding[0] if binding is not None else None - def wrap_model_call( self, request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse], ) -> ModelResponse: - snapshot_id = self._snapshot_id_for_request() - if snapshot_id is None: - return handler(request) - new_model = self._resolve(snapshot_id) + new_model = self._resolve() return handler(request.override(model=new_model)) async def awrap_model_call( @@ -225,13 +268,10 @@ class ConfigurableModelMiddleware(AgentMiddleware): request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[ModelResponse]], ) -> ModelResponse: - snapshot_id = self._snapshot_id_for_request() - if snapshot_id is None: - return await handler(request) # Offload first-call SDK init off the event loop: ``_resolve`` reads # SQLite and can spend hundreds of ms building HTTP clients on a # cache miss, which would block every other coroutine on the same # langgraph dev event loop. Cache hits are still fast; the # thread-pool overhead is irrelevant once warm. - new_model = await asyncio.to_thread(self._resolve, snapshot_id) + new_model = await asyncio.to_thread(self._resolve) return await handler(request.override(model=new_model)) diff --git a/EvoScientist/middleware/message_budget.py b/EvoScientist/middleware/message_budget.py index dc71319..1ef303a 100644 --- a/EvoScientist/middleware/message_budget.py +++ b/EvoScientist/middleware/message_budget.py @@ -6,13 +6,17 @@ consume the fixed reserves frozen into the run snapshot. The measured value is only textual conversation messages and tool-result text, which is the part compaction can actually reduce. -Snapshot mode (design doc 6.5, 8.3): when the run carries a -``runtime_snapshot_id``, the input limit and the three fixed reserves come -from the frozen ``ResolvedModelConfig.budget`` — never from the legacy -``llm/runtime_snapshots.py`` payloads, and never from an implicit 32K -default. The per-call message budget is recomputed on every invocation -from those frozen reserves with the current ``has_tools``/``has_attachments`` -mode; it is never carried over from a previous call: +Snapshot mode (design doc 6.5, 8.3): every run carries — or lazily creates, +via the section 8.1 local entry convention — a run snapshot; the input +limit and the three fixed reserves come from the frozen +``ResolvedModelConfig.budget`` of the middleware's own ``snapshot_role`` +(``primary`` for the main agent and working sub-agents, ``auxiliary`` for +unattended helpers such as the scheduler). There is no 32K default and no +compile-time profile fallback: a bootstrap registry fails closed with +``MODEL_REGISTRY_NOT_READY``. The per-call message budget is recomputed on +every invocation from those frozen reserves with the current +``has_tools``/``has_attachments`` mode; it is never carried over from a +previous call: message_budget = resolved_input_limit - fixed_system_reserve_tokens @@ -33,10 +37,6 @@ scales proportionally (``x 0.70``), replacing the removed ``safety_reserve`` (section 6.5). Budget satisfiability against ``min_effective_input_tokens`` is guaranteed at snapshot creation (run-creation stage, all four tool/attachment modes), so the middleware does not re-check it per call. - -Interim local mode: runs without a snapshot ID (CLI local path before the -local snapshot entry point is wired) keep the legacy behavior — limits read -from the compile-time model's ``profile`` with the default reserve policy. """ from __future__ import annotations @@ -45,11 +45,12 @@ import threading from collections.abc import Iterable, Mapping from contextvars import ContextVar from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, get_args from langchain_core.messages import AnyMessage, SystemMessage -from .configurable_model import _current_configurable, read_snapshot_binding +from ..model_registry.schemas import ModelRole +from .configurable_model import _current_configurable, ensure_snapshot_binding if TYPE_CHECKING: from ..model_registry.runtime import SnapshotRuntime @@ -61,57 +62,7 @@ _ESTIMATE_HARD_FRACTION = 0.90 _SOFT_FRACTION = 0.70 _KEEP_FRACTION = 0.35 - -@dataclass(frozen=True) -class MessageReservePolicy: - """Static non-message reserves for the interim no-snapshot local path.""" - - system_tokens: int = 4_096 - memory_tokens: int = 8_192 - tool_tokens: int = 8_192 - attachment_tokens: int = 8_192 - safety_fraction: float = 0.10 - minimum_safety_tokens: int = 2_048 - soft_fraction: float = 0.70 - keep_fraction: float = 0.35 - - def fixed_tokens( - self, *, has_tools: bool = False, has_attachments: bool = False - ) -> int: - """Return only the fixed reserves applicable to this model request.""" - return ( - self.system_tokens - + self.memory_tokens - + (self.tool_tokens if has_tools else 0) - + (self.attachment_tokens if has_attachments else 0) - ) - - def hard_budget( - self, - input_limit: int, - *, - has_tools: bool = False, - has_attachments: bool = False, - ) -> int: - safety = max( - self.minimum_safety_tokens, int(input_limit * self.safety_fraction) - ) - return max( - 1_024, - input_limit - - self.fixed_tokens(has_tools=has_tools, has_attachments=has_attachments) - - safety, - ) - - def soft_budget(self, input_limit: int, **mode: bool) -> int: - return max( - 1_024, int(self.hard_budget(input_limit, **mode) * self.soft_fraction) - ) - - def keep_budget(self, input_limit: int, **mode: bool) -> int: - return max( - 1_024, int(self.hard_budget(input_limit, **mode) * self.keep_fraction) - ) +_MODEL_ROLES = get_args(ModelRole) @dataclass(frozen=True) @@ -126,10 +77,6 @@ class MessageBudget: has_attachments: bool -class ContextBudgetUnsatisfiableError(ValueError): - """Raised before a call when static reserves leave no safe message budget.""" - - _ACTIVE_BUDGET: ContextVar[MessageBudget | None] = ContextVar( "evoscientist_message_budget", default=None ) @@ -177,12 +124,16 @@ def _has_attachments(messages: Iterable[AnyMessage]) -> bool: def _snapshot_message_budget( - snapshot: RuntimeSnapshot, *, has_tools: bool, has_attachments: bool + snapshot: RuntimeSnapshot, + role: ModelRole, + *, + has_tools: bool, + has_attachments: bool, ) -> MessageBudget: """Recompute the section 6.5 per-call budget from the frozen reserves.""" from ..model_registry.snapshots import config_for_role - budget = config_for_role(snapshot, "primary").budget + budget = config_for_role(snapshot, role).budget reserves = budget.fixed_reserves message_budget = ( budget.resolved_input_limit @@ -209,34 +160,44 @@ class MessageBudgetMiddleware: model: Any, backend: Any, *, - policy: MessageReservePolicy | None = None, has_tools: bool = True, + snapshot_role: ModelRole = "primary", runtime: SnapshotRuntime | None = None, ): """Create the runtime-aware DeepAgents summarization middleware. Args: - model: Compile-time fallback model, used for the summarizer and - for budget limits when the run carries no snapshot. + model: Compile-time placeholder model, required by the base + summarization middleware; every per-call budget and the + summarizer model are resolved from the run snapshot. backend: Graph backend forwarded to the summarization middleware. - policy: Reserve policy for the interim no-snapshot local path. has_tools: Conservative section 6.5 tool-mode flag: true when the agent has tool capability and a configured toolset. Decided once here, never from a single request's bound tool count. + snapshot_role: The snapshot role whose frozen limits size this + agent's budget (``primary`` for the main agent and working + sub-agents, ``auxiliary`` for unattended helper agents such + as the scheduler). The summarizer keeps using the snapshot's + ``summary`` role mapping regardless. runtime: Snapshot runtime override; defaults to the shared process runtime (tests inject an isolated one). """ from deepagents.middleware.summarization import SummarizationMiddleware + if snapshot_role not in _MODEL_ROLES: + raise ValueError( + f"Unknown model role {snapshot_role!r}; " + f"expected one of {list(_MODEL_ROLES)}." + ) + class _RuntimeMessageBudgetMiddleware(SummarizationMiddleware): name = "message_budget" def __init__(self) -> None: - self._fallback_model = model self._snapshot_model_cache: dict[str, Any] = {} self._model_cache_lock = threading.RLock() - self._policy = policy or MessageReservePolicy() self._has_tools = has_tools + self._snapshot_role = snapshot_role self._runtime = runtime # Triggering and cutoff are overridden below. The base class is # still used for safe AI/tool-pair handling, offloading, and @@ -263,25 +224,28 @@ class MessageBudgetMiddleware: return get_snapshot_runtime() - def _snapshot(self) -> RuntimeSnapshot | None: - """Load the run's snapshot, verifying its binding.""" - binding = read_snapshot_binding(_current_configurable()) - if binding is None: - return None - snapshot_id, deployment_id, thread_id = binding + def _snapshot(self) -> RuntimeSnapshot: + """Load the run's snapshot, verifying its binding. + + A run without an explicit ``runtime_snapshot_id`` gets a + lazily created local snapshot bound to its own thread + (section 8.1); a bootstrap registry fails closed with + ``MODEL_REGISTRY_NOT_READY`` instead of a 32K fallback. + """ runtime = self._snapshot_runtime() + snapshot_id, deployment_id, thread_id = ensure_snapshot_binding( + _current_configurable(), runtime + ) return runtime.get_snapshot( snapshot_id, - deployment_id=deployment_id or runtime.local_deployment_id, - thread_id=thread_id or "", + deployment_id=deployment_id, + thread_id=thread_id, ) @property def model(self) -> Any: # type: ignore[override] """Summarizer model: the snapshot's ``summary`` role mapping.""" snapshot = self._snapshot() - if snapshot is None: - return self._fallback_model with self._model_cache_lock: cached = self._snapshot_model_cache.get(snapshot.snapshot_id) if cached is not None: @@ -293,63 +257,23 @@ class MessageBudgetMiddleware: self._snapshot_model_cache[snapshot.snapshot_id] = resolved return resolved - def _interim_budget(self, *, has_attachments: bool) -> MessageBudget: - """Legacy local path: limits from the compile-time profile.""" - profile = getattr(self._fallback_model, "profile", None) - input_limit = 32_768 - minimum_effective = 1_024 - if isinstance(profile, Mapping): - value = profile.get("max_input_tokens") - if ( - isinstance(value, int) - and not isinstance(value, bool) - and value > 0 - ): - input_limit = value - value = profile.get("min_effective_input_tokens") - if ( - isinstance(value, int) - and not isinstance(value, bool) - and value > 0 - ): - minimum_effective = value - mode = { - "has_tools": self._has_tools, - "has_attachments": has_attachments, - } - hard = self._policy.hard_budget(input_limit, **mode) - if hard < minimum_effective: - raise ContextBudgetUnsatisfiableError( - "CONTEXT_BUDGET_UNSATISFIABLE: configured input limit " - f"{input_limit:,} leaves only {hard:,} " - "tokens after fixed reserves; increase the model window, reduce " - "the output budget, or disable tools/attachments." - ) - return MessageBudget( - input_limit=input_limit, - hard_tokens=hard, - soft_tokens=self._policy.soft_budget(input_limit, **mode), - keep_tokens=self._policy.keep_budget(input_limit, **mode), + def _budget_for_request(self, request: Any) -> MessageBudget: + has_attachments = _has_attachments(getattr(request, "messages", [])) + return _snapshot_message_budget( + self._snapshot(), + self._snapshot_role, has_tools=self._has_tools, has_attachments=has_attachments, ) - def _budget_for_request(self, request: Any) -> MessageBudget: - has_attachments = _has_attachments(getattr(request, "messages", [])) - snapshot = self._snapshot() - if snapshot is not None: - return _snapshot_message_budget( - snapshot, - has_tools=self._has_tools, - has_attachments=has_attachments, - ) - return self._interim_budget(has_attachments=has_attachments) - def _active_budget(self) -> MessageBudget: active = _ACTIVE_BUDGET.get() - if active is not None: - return active - return self._interim_budget(has_attachments=False) + if active is None: + raise RuntimeError( + "message budget accessed outside a model call; " + "wrap_model_call establishes the per-call budget." + ) + return active def wrap_model_call(self, request: Any, handler: Any) -> Any: token = _ACTIVE_BUDGET.set(self._budget_for_request(request)) @@ -408,11 +332,11 @@ def create_message_budget_middleware( model: Any, backend: Any, *, - policy: MessageReservePolicy | None = None, has_tools: bool = True, + snapshot_role: ModelRole = "primary", runtime: SnapshotRuntime | None = None, ): """Construct automatic compaction middleware for a graph backend.""" return MessageBudgetMiddleware.create( - model, backend, policy=policy, has_tools=has_tools, runtime=runtime + model, backend, has_tools=has_tools, snapshot_role=snapshot_role, runtime=runtime ) diff --git a/EvoScientist/model_registry/runtime.py b/EvoScientist/model_registry/runtime.py index 1933d96..7fedae9 100644 --- a/EvoScientist/model_registry/runtime.py +++ b/EvoScientist/model_registry/runtime.py @@ -22,6 +22,7 @@ retry option. Secrets are resolved per call and never logged. from __future__ import annotations import threading +import uuid from pathlib import Path import yaml @@ -122,6 +123,47 @@ class SnapshotRuntime: config = self.resolve_role_config(role) return self._build(config, self._resolve_credential(config)) + # --- local entry points (design doc 8.1) --------------------------------- + + def create_local_snapshot( + self, thread_id: str, *, run_request_id: str | None = None + ) -> RuntimeSnapshot: + """Create (or reuse) a run snapshot for a local entry point. + + Local entries — CLI, channels, scheduled tasks, and sub-agents — do + not go through the BFF; they share the same ``SnapshotService`` and + the same snapshot table as the HTTP API (section 8.1). The binding + convention is fixed: ``deployment_id`` is the platform + ``local_deployment_id``, ``thread_id`` is the local session/task + identifier, ``model_selection_revision`` is ``0``, and ``primary`` + is ``None`` (inherit — the registry defaults are resolved and frozen + at creation time). + + ``run_request_id`` scopes idempotency: reusing the same + ``{deployment_id, thread_id, run_request_id}`` triple returns the + existing snapshot instead of creating a new one. Pass a fresh ID + (the default, a generated UUID) to freeze a new snapshot per run, or + a deterministic one to share a snapshot across collaborators in the + same run. + + Raises: + ModelRegistryError: ``MODEL_REGISTRY_NOT_READY`` when the + registry is still in bootstrap (no enabled primary model). + """ + from .snapshots import SnapshotCreateRequest + + creation = self._snapshots.create( + SnapshotCreateRequest( + run_request_id=run_request_id or uuid.uuid4().hex, + thread_id=thread_id, + deployment_id=self._local_deployment_id, + model_selection_revision=0, + primary=None, + auxiliary=None, + ) + ) + return creation.snapshot + # --- internals ------------------------------------------------------------- def _resolve_credential(self, config: ResolvedModelConfig) -> str: diff --git a/EvoScientist/skills/skill-creator/scripts/improve_description.py b/EvoScientist/skills/skill-creator/scripts/improve_description.py index fefa983..b67344c 100644 --- a/EvoScientist/skills/skill-creator/scripts/improve_description.py +++ b/EvoScientist/skills/skill-creator/scripts/improve_description.py @@ -2,7 +2,8 @@ """Improve a skill description based on eval results. Takes eval results (from run_eval.py) and generates an improved description -using EvoSci's LLM layer (multi-provider support). +using ``langchain.chat_models.init_chat_model`` (multi-provider support via +environment-provided API keys, optionally seeded from the EvoSci config). """ import argparse @@ -16,8 +17,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from langchain_core.messages import HumanMessage -from EvoScientist.config import apply_config_to_env, get_effective_config -from EvoScientist.llm import get_chat_model +from scripts.run_eval import _config_defaults, _init_chat_model from scripts.utils import parse_skill_md @@ -141,13 +141,15 @@ I'd encourage you to be creative and mix up the style in different iterations si Please respond with only the new description text in tags, nothing else.""" - # Initialise model via EvoSci's LLM layer - config = get_effective_config() - apply_config_to_env(config) - chat_model = get_chat_model( - model=model or config.model, - provider=provider or config.provider, - ) + # Initialise model (API keys come from the environment / EvoSci config) + default_model, default_provider = _config_defaults() + effective_model = model or default_model + if not effective_model: + raise RuntimeError( + "No model specified: pass --model or configure one (or set a " + "provider's default model via environment)." + ) + chat_model = _init_chat_model(effective_model, provider or default_provider) response = chat_model.invoke([HumanMessage(content=prompt)]) @@ -253,9 +255,8 @@ def main(): name, _, content = parse_skill_md(skill_path) current_description = eval_results["description"] - # Load EvoSci config for defaults - config = get_effective_config() - apply_config_to_env(config) + # Load EvoSci config for defaults (best-effort; env vars alone suffice) + default_model, default_provider = _config_defaults() if args.verbose: print(f"Current: {current_description}", file=sys.stderr) @@ -270,8 +271,8 @@ def main(): current_description=current_description, eval_results=eval_results, history=history, - model=args.model or config.model, - provider=args.provider or config.provider, + model=args.model or default_model, + provider=args.provider or default_provider, ) if args.verbose: diff --git a/EvoScientist/skills/skill-creator/scripts/run_eval.py b/EvoScientist/skills/skill-creator/scripts/run_eval.py index b345a85..aad4fa9 100644 --- a/EvoScientist/skills/skill-creator/scripts/run_eval.py +++ b/EvoScientist/skills/skill-creator/scripts/run_eval.py @@ -2,8 +2,10 @@ """Run trigger evaluation for a skill description. Tests whether a skill's description causes an LLM to trigger (load the skill) -for a set of queries. Uses EvoSci's multi-provider LLM layer with tool calling -to simulate the agent's skill selection behavior. +for a set of queries. Uses ``langchain.chat_models.init_chat_model`` with +tool calling to simulate the agent's skill selection behavior. API keys come +from the environment (optionally seeded from the EvoSci config when +available). """ import argparse @@ -18,13 +20,28 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) from scripts.utils import parse_skill_md -def _init_config(): - """Initialize EvoSci config and apply env vars (once per process).""" - from EvoScientist.config import apply_config_to_env, get_effective_config +def _config_defaults() -> tuple[str | None, str | None]: + """Best-effort (model, provider) defaults from the EvoSci config. - config = get_effective_config() - apply_config_to_env(config) - return config + Also applies configured API keys to the process environment so + ``init_chat_model`` can pick them up. Returns ``(None, None)`` when the + config layer is unavailable — environment variables alone suffice. + """ + try: + from EvoScientist.config import apply_config_to_env, get_effective_config + + config = get_effective_config() + apply_config_to_env(config) + return getattr(config, "model", None), getattr(config, "provider", None) + except Exception: + return None, None + + +def _init_chat_model(model: str, provider: str | None, **kwargs): + """Build a chat model; API keys are read from the environment.""" + from langchain.chat_models import init_chat_model + + return init_chat_model(model=model, model_provider=provider, **kwargs) def run_single_query( @@ -43,12 +60,14 @@ def run_single_query( from langchain_core.messages import HumanMessage, SystemMessage from langchain_core.tools import tool - from EvoScientist.llm import get_chat_model - - config = _init_config() - - effective_model = model or config.model - effective_provider = provider or config.provider + default_model, default_provider = _config_defaults() + effective_model = model or default_model + effective_provider = provider or default_provider + if not effective_model: + raise RuntimeError( + "No model specified: pass --model or configure one (or set a " + "provider's default model via environment)." + ) @tool def load_skill(name: str) -> str: @@ -72,9 +91,9 @@ If no skill is relevant, respond directly to the user without calling any tools. pass try: - chat_model = get_chat_model( - model=effective_model, - provider=effective_provider, + chat_model = _init_chat_model( + effective_model, + effective_provider, **eval_kwargs, ) model_with_tools = chat_model.bind_tools([load_skill]) @@ -98,9 +117,9 @@ If no skill is relevant, respond directly to the user without calling any tools. # Fallback for providers that don't support tool calling: # Use a text-based approach try: - chat_model = get_chat_model( - model=effective_model, - provider=effective_provider, + chat_model = _init_chat_model( + effective_model, + effective_provider, **eval_kwargs, ) fallback_prompt = f"""You are a helpful AI assistant with specialized skills available. @@ -221,12 +240,14 @@ def main(): "--trigger-threshold", type=float, default=0.5, help="Trigger rate threshold" ) parser.add_argument( - "--model", default=None, help="Model to use (default: user's configured model)" + "--model", + default=None, + help="Model to use (default: EvoSci config model; required otherwise)", ) parser.add_argument( "--provider", default=None, - help="LLM provider (default: user's configured provider)", + help="LLM provider (default: EvoSci config provider, else inferred from model)", ) parser.add_argument( "--verbose", action="store_true", help="Print progress to stderr" diff --git a/EvoScientist/skills/skill-creator/scripts/run_loop.py b/EvoScientist/skills/skill-creator/scripts/run_loop.py index e27306b..ab7d492 100644 --- a/EvoScientist/skills/skill-creator/scripts/run_loop.py +++ b/EvoScientist/skills/skill-creator/scripts/run_loop.py @@ -18,10 +18,9 @@ from pathlib import Path # Ensure skill-creator root is on sys.path for `from scripts.xxx` imports sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) -from EvoScientist.config import apply_config_to_env, get_effective_config from scripts.generate_report import generate_html from scripts.improve_description import improve_description -from scripts.run_eval import run_eval +from scripts.run_eval import _config_defaults, run_eval from scripts.utils import parse_skill_md @@ -66,8 +65,10 @@ def run_loop( log_dir: Path | None = None, ) -> dict: """Run the eval + improvement loop.""" - config = get_effective_config() - apply_config_to_env(config) + # Seed API keys into the environment from the EvoSci config (best-effort); + # model/provider defaults are resolved per call by run_eval / + # improve_description when not passed explicitly. + _config_defaults() name, original_description, content = parse_skill_md(skill_path) current_description = description_override or original_description diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py index f22c161..274c050 100644 --- a/EvoScientist/stream/events.py +++ b/EvoScientist/stream/events.py @@ -797,6 +797,7 @@ async def stream_agent_events( thread_id: str, metadata: dict[str, Any] | None = None, media: list[str] | None = None, + runtime_snapshot_id: str | None = None, ) -> AsyncGenerator[dict[str, Any], None]: """Stream events from a DeepAgents/LangGraph v3 run. @@ -812,6 +813,9 @@ async def stream_agent_events( metadata: Optional metadata dict merged into the LangGraph config (e.g. agent_name, updated_at for checkpoint persistence). media: Optional list of local file paths for attachments. + runtime_snapshot_id: Optional frozen run snapshot ID (design doc + 8.1/8.2) placed into ``configurable`` — the only model + configuration a run may carry. Yields: Event dicts: thinking, text, tool_call, tool_result, @@ -821,6 +825,8 @@ async def stream_agent_events( config: dict[str, Any] = {"configurable": {"thread_id": thread_id}} if metadata: config["metadata"] = metadata + if runtime_snapshot_id is not None: + config["configurable"]["runtime_snapshot_id"] = runtime_snapshot_id emitter = StreamEventEmitter() existing_summarization_event: Mapping[str, object] | None = None try: diff --git a/EvoScientist/subagents/_factory.py b/EvoScientist/subagents/_factory.py index 1d0ff54..797add2 100644 --- a/EvoScientist/subagents/_factory.py +++ b/EvoScientist/subagents/_factory.py @@ -103,13 +103,52 @@ def build_async_subagent_graph(name: str) -> Any: # context and `/memories/profile/...` file guidance as the main agent. # # The compile-time model binding is resolved from the active registry's - # defaults via build_chat_model — never from config.yaml free strings. - # Per-run calls are re-resolved from the run snapshot by - # ConfigurableModelMiddleware; the scheduler (an unattended timer task) - # binds the cheaper auxiliary role, working sub-agents bind primary. + # defaults — never from config.yaml free strings. Per-run calls are + # re-resolved from the run snapshot by ConfigurableModelMiddleware; the + # scheduler (an unattended timer task) binds the cheaper auxiliary role, + # working sub-agents bind primary. + # + # Bootstrap registry: no defaults exist yet, so no compile-time model + # can be built. The graph must still materialize — one failing factory + # must not take down the whole langgraph dev service — so we bind a + # placeholder that raises MODEL_REGISTRY_NOT_READY on the first model + # call. Every run fails with that clear structured error until a + # primary model is configured and enabled; no 32K/implicit fallback. + from langchain_core.language_models.chat_models import BaseChatModel + from langchain_core.messages import BaseMessage + from langchain_core.outputs import ChatResult + + from EvoScientist.model_registry.errors import ( + MODEL_REGISTRY_NOT_READY, + ModelRegistryError, + ) + + class _RegistryNotReadyChatModel(BaseChatModel): + """Placeholder that fails every call with MODEL_REGISTRY_NOT_READY.""" + + detail: str + + @property + def _llm_type(self) -> str: + return "evoscientist-registry-not-ready" + + def _generate( + self, + messages: list[BaseMessage], + stop: list[str] | None = None, + run_manager: Any = None, + **kwargs: Any, + ) -> ChatResult: + raise ModelRegistryError(MODEL_REGISTRY_NOT_READY, self.detail) + runtime = get_snapshot_runtime() snapshot_role = "auxiliary" if name == "scheduler" else "primary" - model = runtime.build_default_role_model(snapshot_role) + try: + model = runtime.build_default_role_model(snapshot_role) + except ModelRegistryError as exc: + if exc.code != MODEL_REGISTRY_NOT_READY: + raise + model = _RegistryNotReadyChatModel(detail=str(exc)) subagents = [] _ensure_general_purpose_subagent(subagents) diff --git a/tests/conftest.py b/tests/conftest.py index 8e08194..756b391 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,16 @@ import pytest +@pytest.fixture(autouse=True) +def _isolated_config_home(tmp_path, monkeypatch): + """Keep every test away from the real ``~/.config/evoscientist``. + + The startup legacy-artifact guard reads the real config dir otherwise, + and tests must never depend on (or trip over) developer-machine state. + """ + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "xdg-config")) + + @pytest.fixture(autouse=True) def _reset_tool_selection_state(): """Isolate the process-global tool-selection state around every test. @@ -46,6 +56,20 @@ def isolated_snapshot_runtime(tmp_path): set_snapshot_runtime_for_tests(None) +@pytest.fixture +def active_snapshot_runtime(isolated_snapshot_runtime): + """Shared runtime backed by an active registry with verified models. + + For tests that exercise code paths building compile-time models or + creating local run snapshots (both fail closed with + ``MODEL_REGISTRY_NOT_READY`` against the default bootstrap registry). + """ + from tests.registry_fixtures import activate_store + + activate_store(isolated_snapshot_runtime.store) + return isolated_snapshot_runtime + + @pytest.fixture def sample_tool_call(): """A minimal tool call dict.""" diff --git a/tests/fakes.py b/tests/fakes.py index aeabb4c..43de36a 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -77,14 +77,6 @@ class FakeCommandUI(CommandUI): ) -> list | None: return None - async def wait_for_model_pick( - self, - entries: list[tuple[str, str, str]], - current_model: str | None, - current_provider: str | None, - ) -> tuple[str, str] | None: - return None - def clear_chat(self) -> None: self.chat_cleared = True diff --git a/tests/test_ask_user.py b/tests/test_ask_user.py index f1f9787..74ae9c5 100644 --- a/tests/test_ask_user.py +++ b/tests/test_ask_user.py @@ -539,10 +539,11 @@ class TestConfig: @patch("EvoScientist.middleware.create_tool_selector_middleware", return_value=[]) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") def test_auto_approve_still_includes_ask_user_middleware( - mock_config, mock_model, mock_tool_selector + mock_config, mock_model, mock_aux_model, mock_tool_selector ): cfg = MagicMock() cfg.enable_ask_user = True @@ -560,10 +561,11 @@ def test_auto_approve_still_includes_ask_user_middleware( @patch("EvoScientist.middleware.create_tool_selector_middleware", return_value=[]) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") def test_auto_mode_disables_ask_user_middleware( - mock_config, mock_model, mock_tool_selector + mock_config, mock_model, mock_aux_model, mock_tool_selector ): cfg = MagicMock() cfg.enable_ask_user = True @@ -581,10 +583,11 @@ def test_auto_mode_disables_ask_user_middleware( @patch("EvoScientist.middleware.create_tool_selector_middleware", return_value=[]) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") def test_for_async_subagent_omits_ask_user_middleware( - mock_config, mock_model, mock_tool_selector + mock_config, mock_model, mock_aux_model, mock_tool_selector ): """``AskUserMiddleware`` uses ``interrupt()`` to wait on user input. diff --git a/tests/test_async_subagent_factory.py b/tests/test_async_subagent_factory.py index 665a0c2..6b328b3 100644 --- a/tests/test_async_subagent_factory.py +++ b/tests/test_async_subagent_factory.py @@ -11,6 +11,8 @@ from __future__ import annotations from unittest.mock import MagicMock, patch +import pytest + from EvoScientist.config import MemoryObservationWriter from EvoScientist.memory import MemorySourceType @@ -167,6 +169,65 @@ def test_scheduler_binds_auxiliary_role( assert mock_create.call_args.kwargs["model"] is model +@patch("deepagents.create_deep_agent") +@patch("EvoScientist.EvoScientist._load_mcp_tools_cached", return_value={}) +@patch("EvoScientist.EvoScientist._get_default_middleware", return_value=[]) +@patch("EvoScientist.EvoScientist._get_default_backend") +@patch("EvoScientist.model_registry.runtime.get_snapshot_runtime") +@patch("EvoScientist.utils.load_subagents") +@patch("EvoScientist.config.apply_config_to_env") +@patch("EvoScientist.config.get_effective_config") +def test_bootstrap_registry_binds_not_ready_placeholder( + mock_get_cfg, + mock_apply_env, + mock_load_subs, + mock_get_runtime, + mock_backend, + mock_get_mw, + mock_mcp, + mock_create, +): + """T6-b: bootstrap registry must not take down graph materialization. + + The factory binds a placeholder chat model whose every call raises + ``MODEL_REGISTRY_NOT_READY`` instead of crashing the whole service. + """ + from EvoScientist.model_registry.errors import ( + MODEL_REGISTRY_NOT_READY, + ModelRegistryError, + ) + + cfg = MagicMock() + cfg.recursion_limit = 1_000_000 + cfg.memory_profile_enabled = False + cfg.memory_observations_enabled = False + cfg.memory_observation_writer = MemoryObservationWriter.ALL + cfg.memory_workers_enabled = False + mock_get_cfg.return_value = cfg + mock_get_runtime.return_value.build_default_role_model.side_effect = ( + ModelRegistryError(MODEL_REGISTRY_NOT_READY, "bootstrap") + ) + mock_load_subs.return_value = [ + { + "name": "writing-agent", + "system_prompt": "", + "tools": [], + "skills": None, + } + ] + mock_create.return_value.with_config.return_value = MagicMock() + + from EvoScientist.subagents._factory import build_async_subagent_graph + + build_async_subagent_graph("writing-agent") # must not raise + + model = mock_create.call_args.kwargs["model"] + assert type(model).__name__ == "_RegistryNotReadyChatModel" + with pytest.raises(ModelRegistryError) as excinfo: + model.invoke([]) + assert excinfo.value.code == MODEL_REGISTRY_NOT_READY + + @patch("EvoScientist.EvoScientist._ensure_chat_model") def test_inject_subagent_adds_memory_middleware(mock_model, tmp_path): mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000}) @@ -251,10 +312,11 @@ def test_inject_subagent_worker_only_observation_writer_keeps_live_tool_off( "EvoScientist.middleware.create_tool_selector_middleware", return_value=[MagicMock()], ) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") def test_all_observation_writer_schedules_turn_worker_without_profile_memory( - mock_config, mock_chat, mock_tool_selector + mock_config, mock_chat, mock_aux, mock_tool_selector ): cfg = MagicMock() cfg.enable_ask_user = False @@ -303,10 +365,11 @@ def test_all_observation_writer_schedules_turn_worker_without_profile_memory( "EvoScientist.middleware.create_tool_selector_middleware", return_value=[MagicMock()], ) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") def test_async_subagent_mode_filters_ask_user( - mock_config, mock_chat, mock_tool_selector + mock_config, mock_chat, mock_aux, mock_tool_selector ): """``_get_default_middleware(for_async_subagent=True)`` must drop ``AskUserMiddleware`` even when ``enable_ask_user`` is on. diff --git a/tests/test_auxiliary_model.py b/tests/test_auxiliary_model.py index d10aa74..77068e9 100644 --- a/tests/test_auxiliary_model.py +++ b/tests/test_auxiliary_model.py @@ -14,6 +14,10 @@ from unittest.mock import MagicMock, patch import pytest import EvoScientist.EvoScientist as E +from EvoScientist.model_registry.errors import ( + MODEL_REGISTRY_NOT_READY, + ModelRegistryError, +) from tests.registry_fixtures import activate_store @@ -27,13 +31,11 @@ def _reset_model_caches(monkeypatch): class TestAuxiliaryResolver: - def test_bootstrap_registry_returns_main_instance( - self, monkeypatch, isolated_snapshot_runtime - ): - """Pre-cutover CLI (bootstrap registry): auxiliary mirrors main.""" - main = object() - monkeypatch.setattr(E, "_ensure_chat_model", lambda: main) - assert E._ensure_auxiliary_chat_model() is main + def test_bootstrap_registry_fails_closed(self, isolated_snapshot_runtime): + """Bootstrap registry: auxiliary resolution propagates NOT_READY.""" + with pytest.raises(ModelRegistryError) as excinfo: + E._ensure_auxiliary_chat_model() + assert excinfo.value.code == MODEL_REGISTRY_NOT_READY def test_no_auxiliary_default_returns_main_instance( self, monkeypatch, isolated_snapshot_runtime @@ -81,19 +83,6 @@ class TestAuxiliaryResolver: assert E._ensure_auxiliary_chat_model() is first assert build.call_count == 1 - def test_set_chat_model_resets_aux_cache(self, monkeypatch): - monkeypatch.setattr(E, "_auxiliary_chat_model", object(), raising=False) - monkeypatch.setattr( - E, "_auxiliary_chat_model_key", ("x", "y", 1), raising=False - ) - monkeypatch.setattr( - "EvoScientist.llm.get_chat_model", MagicMock(return_value=object()) - ) - E.set_chat_model("new-m", "new-p") - assert E._auxiliary_chat_model is None - assert E._auxiliary_chat_model_key is None - - def _mock_cfg(): cfg = MagicMock() cfg.enable_ask_user = False diff --git a/tests/test_cli_completion.py b/tests/test_cli_completion.py index 2750835..c81a828 100644 --- a/tests/test_cli_completion.py +++ b/tests/test_cli_completion.py @@ -30,19 +30,19 @@ class TestSlashCommandCompleter: assert completions == [] def test_exact_command_hides_even_when_prefix_of_another(self): - """Regression for #293: ``/model`` must hide on exact match so Enter - submits it, even when another command shares the prefix. Before + """Regression for #293: an exact command match must hide the popup so + Enter submits it, even when another command shares the prefix. Before the fix the popup stayed visible (two prefix matches) and the TUI's Enter handler completed the text instead of executing the command. - The original prefix sibling (``/model-fallback``) was removed with - the fallback chain; a temporary stub command recreates the scenario. + A temporary stub command recreates the prefix-sibling scenario + against the existing ``/help`` command. """ from EvoScientist.commands.base import Command from EvoScientist.commands.manager import manager class _StubCommand(Command): - name = "/model-extra" + name = "/help-extra" description = "stub" async def execute(self, ctx, args): @@ -52,13 +52,13 @@ class TestSlashCommandCompleter: try: completer = SlashCommandCompleter() # Sanity: both commands share the prefix, so a partial prefix lists both. - partial = {c.text for c in completer.get_completions(_doc("/mode"), None)} - assert {"/model", "/model-extra"} <= partial - # Exact ``/model`` with no trailing space → hide. - completions = list(completer.get_completions(_doc("/model"), None)) + partial = {c.text for c in completer.get_completions(_doc("/hel"), None)} + assert {"/help", "/help-extra"} <= partial + # Exact ``/help`` with no trailing space → hide. + completions = list(completer.get_completions(_doc("/help"), None)) assert completions == [] finally: - manager._commands.pop("/model-extra", None) + manager._commands.pop("/help-extra", None) def test_non_slash_returns_empty(self): completer = SlashCommandCompleter() diff --git a/tests/test_cli_serve.py b/tests/test_cli_serve.py index d19234b..56bf06e 100644 --- a/tests/test_cli_serve.py +++ b/tests/test_cli_serve.py @@ -5,10 +5,19 @@ from __future__ import annotations import os from types import SimpleNamespace +import pytest + from EvoScientist.cli import commands from EvoScientist.config import MemoryObservationWriter +@pytest.fixture(autouse=True) +def _active_registry(active_snapshot_runtime): + """Run entry points freeze a local snapshot per run; that fails closed + against the default bootstrap registry, so activate it for these tests.""" + return active_snapshot_runtime + + def _make_config( *, default_workdir: str = "", diff --git a/tests/test_cli_startup_gates.py b/tests/test_cli_startup_gates.py new file mode 100644 index 0000000..62a9b03 --- /dev/null +++ b/tests/test_cli_startup_gates.py @@ -0,0 +1,72 @@ +"""Tests for the CLI startup gates (design doc §10 step 4, §8.1). + +``_startup_gates`` is the shared refuse-startup path for the chat entry and +``serve``: legacy model-configuration artifacts abort with the reset guide, +and a bootstrap registry aborts with ``MODEL_REGISTRY_NOT_READY`` — both +with exit code 1, never a silent fall-through to an implicit model. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +import typer + +import EvoScientist.config.legacy_artifacts as legacy_artifacts +import EvoScientist.model_registry.runtime as runtime_module +from EvoScientist.cli.commands import _startup_gates +from EvoScientist.config.legacy_artifacts import LegacyArtifactsError +from EvoScientist.model_registry.errors import ( + MODEL_REGISTRY_NOT_READY, + ModelRegistryError, +) + + +def test_legacy_artifacts_abort_startup(monkeypatch): + monkeypatch.setattr( + legacy_artifacts, + "assert_no_legacy_artifacts", + MagicMock(side_effect=LegacyArtifactsError("legacy providers.yaml found")), + ) + with pytest.raises(typer.Exit) as excinfo: + _startup_gates() + assert excinfo.value.exit_code == 1 + + +def test_bootstrap_registry_aborts_startup(monkeypatch): + monkeypatch.setattr( + legacy_artifacts, "assert_no_legacy_artifacts", MagicMock() + ) + runtime = MagicMock() + runtime.registry_defaults.side_effect = ModelRegistryError( + MODEL_REGISTRY_NOT_READY, "bootstrap" + ) + monkeypatch.setattr(runtime_module, "get_snapshot_runtime", lambda: runtime) + with pytest.raises(typer.Exit) as excinfo: + _startup_gates() + assert excinfo.value.exit_code == 1 + + +def test_clean_environment_passes(monkeypatch): + monkeypatch.setattr( + legacy_artifacts, "assert_no_legacy_artifacts", MagicMock() + ) + runtime = MagicMock() + runtime.registry_defaults.return_value = (MagicMock(), None, 2) + monkeypatch.setattr(runtime_module, "get_snapshot_runtime", lambda: runtime) + _startup_gates() # must not raise + + +def test_non_bootstrap_registry_error_propagates(monkeypatch): + """Only NOT_READY is converted to a guided exit; other errors bubble up.""" + monkeypatch.setattr( + legacy_artifacts, "assert_no_legacy_artifacts", MagicMock() + ) + runtime = MagicMock() + runtime.registry_defaults.side_effect = ModelRegistryError( + "SNAPSHOT_EXPIRED", "expired" + ) + monkeypatch.setattr(runtime_module, "get_snapshot_runtime", lambda: runtime) + with pytest.raises(ModelRegistryError): + _startup_gates() diff --git a/tests/test_cli_tui_dispatch.py b/tests/test_cli_tui_dispatch.py index 508ea9a..5f684b5 100644 --- a/tests/test_cli_tui_dispatch.py +++ b/tests/test_cli_tui_dispatch.py @@ -64,14 +64,16 @@ def _invoke_main(monkeypatch, argv): return calls, result -def test_main_callback_launches_webui_for_fresh_session(monkeypatch): +def test_main_callback_launches_webui_for_fresh_session( + monkeypatch, active_snapshot_runtime +): """Bare `EvoSci` with ui_backend=webui opens the browser app.""" calls, result = _invoke_main(monkeypatch, []) assert result.exit_code == 0 assert calls.get("dispatch") == "webui" -def test_main_callback_resume_falls_back_to_cli(monkeypatch): +def test_main_callback_resume_falls_back_to_cli(monkeypatch, active_snapshot_runtime): """`EvoSci --resume ` with ui_backend=webui does NOT open the browser; it resumes the conversation in the Rich CLI (ui_backend forced to 'cli').""" calls, result = _invoke_main(monkeypatch, ["--resume", "abc123"]) diff --git a/tests/test_config.py b/tests/test_config.py index 81a9844..d934ace 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -59,10 +59,8 @@ def temp_config_dir(tmp_path, monkeypatch): "EvoScientist.config.settings.find_dotenv", lambda *a, **k: str(tmp_path / ".env"), ) - # Also clear any API keys from environment + # Also clear any relevant keys from environment for key in [ - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", "TAVILY_API_KEY", "EVOSCIENTIST_DEFAULT_MODE", "EVOSCIENTIST_WORKSPACE_DIR", @@ -75,9 +73,6 @@ def temp_config_dir(tmp_path, monkeypatch): "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE", "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE", "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME", - "EVOSCIENTIST_AUXILIARY_MODEL", - "EVOSCIENTIST_AUXILIARY_PROVIDER", - "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "EVOSCIENTIST_DANGEROUS_MODE", ]: monkeypatch.delenv(key, raising=False) @@ -88,8 +83,6 @@ def temp_config_dir(tmp_path, monkeypatch): def clean_env(monkeypatch): """Remove environment variables that affect config (but keep temp config dir).""" for key in [ - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", "TAVILY_API_KEY", "EVOSCIENTIST_DEFAULT_MODE", "EVOSCIENTIST_WORKSPACE_DIR", @@ -102,9 +95,6 @@ def clean_env(monkeypatch): "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_MODE", "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_CADENCE", "EVOSCIENTIST_MEMORY_SKILL_SYNTHESIS_TIME", - "EVOSCIENTIST_AUXILIARY_MODEL", - "EVOSCIENTIST_AUXILIARY_PROVIDER", - "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "EVOSCIENTIST_DANGEROUS_MODE", ]: monkeypatch.delenv(key, raising=False) @@ -120,18 +110,13 @@ class TestEvoScientistConfig: """Test that default values are set correctly.""" config = EvoScientistConfig() - assert config.anthropic_api_key == "" - assert config.openai_api_key == "" assert config.tavily_api_key == "" - assert config.provider == "anthropic" - assert config.model == "claude-sonnet-4-6" assert config.default_mode == "daemon" assert config.default_workdir == "" assert config.show_thinking is True assert config.ui_backend == "tui" assert config.log_level == "warning" assert config.reasoning_effort == "high" - assert config.openrouter_anthropic_prompt_cache is True assert config.memory_profile_enabled is True assert config.memory_observations_enabled is True assert config.memory_observation_writer == MemoryObservationWriter.ALL @@ -142,44 +127,58 @@ class TestEvoScientistConfig: config.memory_skill_synthesis_cadence == MemorySkillSynthesisCadence.WEEKLY ) assert config.memory_skill_synthesis_time == "03:00" - assert config.ollama_base_url == "" assert config.channel_debug_tracing is False assert config.imessage_enabled is False assert config.imessage_allowed_senders == "" - def test_auth_mode_default(self): - """Test that anthropic_auth_mode defaults to api_key.""" + def test_llm_fields_removed(self): + """LLM provider/model/API-key fields live in the model registry, not config.""" config = EvoScientistConfig() - assert config.anthropic_auth_mode == "api_key" - - def test_auth_mode_set(self): - """Test that anthropic_auth_mode can be set.""" - config = EvoScientistConfig(anthropic_auth_mode="oauth") - assert config.anthropic_auth_mode == "oauth" - - def test_openai_auth_mode_default(self): - """Test that openai_auth_mode defaults to api_key.""" - config = EvoScientistConfig() - assert config.openai_auth_mode == "api_key" - - def test_openai_auth_mode_set(self): - """Test that openai_auth_mode can be set.""" - config = EvoScientistConfig(openai_auth_mode="oauth") - assert config.openai_auth_mode == "oauth" + for field_name in [ + "provider", + "model", + "model_catalog", + "model_fallbacks", + "auxiliary_provider", + "auxiliary_model", + "anthropic_api_key", + "anthropic_base_url", + "anthropic_auth_mode", + "openai_api_key", + "openai_auth_mode", + "nvidia_api_key", + "google_api_key", + "minimax_api_key", + "minimax_base_url", + "siliconflow_api_key", + "openrouter_api_key", + "deepseek_api_key", + "zhipu_api_key", + "volcengine_api_key", + "dashscope_api_key", + "moonshot_api_key", + "kimi_api_key", + "custom_openai_api_key", + "custom_openai_base_url", + "custom_anthropic_api_key", + "custom_anthropic_base_url", + "ollama_base_url", + "openrouter_anthropic_prompt_cache", + "use_responses_api", + ]: + assert not hasattr(config, field_name), field_name def test_custom_values(self): """Test that custom values can be set.""" config = EvoScientistConfig( - anthropic_api_key="sk-ant-test", - provider="openai", - model="gpt-4o", + tavily_api_key="tvly-test", default_mode="run", + show_thinking=False, ) - assert config.anthropic_api_key == "sk-ant-test" - assert config.provider == "openai" - assert config.model == "gpt-4o" + assert config.tavily_api_key == "tvly-test" assert config.default_mode == "run" + assert config.show_thinking is False def test_dangerous_mode_default(self): """dangerous_mode defaults off and does not force auto_approve.""" @@ -252,12 +251,12 @@ class TestLoadSaveReset: def test_load_returns_defaults_when_no_file(self, temp_config_dir, clean_env): """Test that load returns defaults when config file doesn't exist.""" config = load_config() - assert config.provider == "anthropic" - assert config.model == "claude-sonnet-4-6" + assert config.default_mode == "daemon" + assert config.ui_backend == "tui" def test_save_creates_file(self, temp_config_dir, clean_env): """Test that save creates the config file.""" - config = EvoScientistConfig(provider="openai", model="gpt-4o") + config = EvoScientistConfig(default_mode="run", log_level="INFO") save_config(config) config_path = get_config_path() @@ -265,14 +264,14 @@ class TestLoadSaveReset: with open(config_path) as f: data = yaml.safe_load(f) - assert data["provider"] == "openai" - assert data["model"] == "gpt-4o" + assert data["default_mode"] == "run" + assert data["log_level"] == "INFO" def test_save_restricts_config_permissions(self, temp_config_dir, clean_env): """Config file permissions should not depend on the process umask.""" original_umask = os.umask(0) try: - save_config(EvoScientistConfig(anthropic_api_key="test-key")) + save_config(EvoScientistConfig(tavily_api_key="test-key")) finally: os.umask(original_umask) @@ -288,28 +287,28 @@ class TestLoadSaveReset: def test_load_reads_saved_config(self, temp_config_dir, clean_env): """Test that load reads previously saved config.""" original = EvoScientistConfig( - anthropic_api_key="test-key", - provider="openai", + tavily_api_key="test-key", + default_mode="run", ) save_config(original) loaded = load_config() - assert loaded.anthropic_api_key == "test-key" - assert loaded.provider == "openai" + assert loaded.tavily_api_key == "test-key" + assert loaded.default_mode == "run" def test_load_reads_manually_edited_utf8_config(self, temp_config_dir, clean_env): """Config loading handles localized content from manually edited YAML.""" config_path = get_config_path() config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text( - "provider: openai\nmodel: gpt-4o\ndefault_workdir: /tmp/café\n", + "default_mode: run\nlog_level: INFO\ndefault_workdir: /tmp/café\n", encoding="utf-8", ) loaded = load_config() - assert loaded.provider == "openai" - assert loaded.model == "gpt-4o" + assert loaded.default_mode == "run" + assert loaded.log_level == "INFO" assert loaded.default_workdir == "/tmp/café" def test_save_writes_utf8_config(self, temp_config_dir, clean_env): @@ -323,7 +322,7 @@ class TestLoadSaveReset: def test_reset_deletes_config_file(self, temp_config_dir, clean_env): """Test that reset deletes the config file.""" - config = EvoScientistConfig(provider="openai") + config = EvoScientistConfig(default_mode="run") save_config(config) config_path = get_config_path() @@ -344,7 +343,7 @@ class TestLoadSaveReset: with open(config_path, "w") as f: yaml.safe_dump( { - "provider": "openai", + "default_mode": "run", "unknown_field": "should_be_ignored", "another_bad": 123, }, @@ -352,9 +351,25 @@ class TestLoadSaveReset: ) config = load_config() - assert config.provider == "openai" + assert config.default_mode == "run" assert not hasattr(config, "unknown_field") + def test_load_ignores_legacy_llm_fields(self, temp_config_dir, clean_env): + """Legacy LLM keys left in config.yaml are dropped, not loaded.""" + config_path = get_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + config_path.write_text( + "provider: openai\nmodel: gpt-4o\nanthropic_api_key: sk-old\n" + "default_mode: run\n", + encoding="utf-8", + ) + + config = load_config() + assert config.default_mode == "run" + assert not hasattr(config, "provider") + assert not hasattr(config, "model") + assert not hasattr(config, "anthropic_api_key") + # ============================================================================= # Test get/set single values @@ -364,24 +379,31 @@ class TestLoadSaveReset: class TestGetSetValues: def test_get_config_value(self, temp_config_dir, clean_env): """Test getting a single config value.""" - config = EvoScientistConfig(model="gpt-4o-mini") + config = EvoScientistConfig(log_level="INFO") save_config(config) - assert get_config_value("model") == "gpt-4o-mini" + assert get_config_value("log_level") == "INFO" def test_get_config_value_invalid_key(self, temp_config_dir, clean_env): """Test getting an invalid key returns None.""" assert get_config_value("nonexistent_key") is None + def test_get_config_value_removed_llm_key(self, temp_config_dir, clean_env): + """Removed LLM keys are invalid config keys now.""" + assert get_config_value("anthropic_api_key") is None + assert set_config_value("anthropic_api_key", "sk-x") is False + assert set_config_value("provider", "openai") is False + assert set_config_value("model", "gpt-4o") is False + def test_set_config_value(self, temp_config_dir, clean_env): """Test setting a single config value.""" save_config(EvoScientistConfig()) - result = set_config_value("model", "gpt-4o") + result = set_config_value("log_level", "INFO") assert result is True config = load_config() - assert config.model == "gpt-4o" + assert config.log_level == "INFO" def test_set_config_value_invalid_key(self, temp_config_dir, clean_env): """Test setting an invalid key returns False.""" @@ -509,13 +531,13 @@ class TestGetSetValues: def test_list_config(self, temp_config_dir, clean_env): """Test listing all config values.""" - config = EvoScientistConfig(provider="openai", model="gpt-4o") + config = EvoScientistConfig(default_mode="run", log_level="INFO") save_config(config) all_config = list_config() assert isinstance(all_config, dict) - assert all_config["provider"] == "openai" - assert all_config["model"] == "gpt-4o" + assert all_config["default_mode"] == "run" + assert all_config["log_level"] == "INFO" # ============================================================================= @@ -528,8 +550,6 @@ class TestPriorityChain: """Test defaults are used when nothing is configured.""" # Ensure no env vars affect the test for key in [ - "ANTHROPIC_API_KEY", - "OPENAI_API_KEY", "TAVILY_API_KEY", "EVOSCIENTIST_DEFAULT_MODE", "EVOSCIENTIST_WORKSPACE_DIR", @@ -537,15 +557,15 @@ class TestPriorityChain: ]: monkeypatch.delenv(key, raising=False) config = get_effective_config() - assert config.provider == "anthropic" + assert config.ui_backend == "tui" assert config.default_mode == "daemon" def test_file_overrides_defaults(self, temp_config_dir, clean_env): """Test file config overrides defaults.""" - save_config(EvoScientistConfig(provider="openai")) + save_config(EvoScientistConfig(default_mode="run")) config = get_effective_config() - assert config.provider == "openai" + assert config.default_mode == "run" def test_env_overrides_file(self, temp_config_dir, monkeypatch): """Test environment variables override file config.""" @@ -564,10 +584,10 @@ class TestPriorityChain: def test_cli_overrides_file(self, temp_config_dir, clean_env): """Test CLI arguments override file config.""" - save_config(EvoScientistConfig(model="gpt-4o")) + save_config(EvoScientistConfig(log_level="INFO")) - config = get_effective_config(cli_overrides={"model": "claude-opus-4-8"}) - assert config.model == "claude-opus-4-8" + config = get_effective_config(cli_overrides={"log_level": "DEBUG"}) + assert config.log_level == "DEBUG" def test_env_ui_backend_override(self, temp_config_dir, monkeypatch): """UI backend can be selected via environment variable.""" @@ -590,6 +610,14 @@ class TestPriorityChain: config = get_effective_config() assert config.reasoning_effort == "high" + def test_env_tavily_key_override(self, temp_config_dir, monkeypatch): + """Tavily key from env overrides file (search tool key, not LLM).""" + save_config(EvoScientistConfig(tavily_api_key="file-key")) + monkeypatch.setenv("TAVILY_API_KEY", "env-key") + + config = get_effective_config() + assert config.tavily_api_key == "env-key" + def test_env_channel_debug_tracing_override(self, temp_config_dir, monkeypatch): """Channel tracing can be enabled via environment variable.""" save_config(EvoScientistConfig(channel_debug_tracing=False)) @@ -664,47 +692,6 @@ class TestPriorityChain: assert set_config_value("sandbox_execute_timeout", 600) is True assert get_config_value("sandbox_execute_timeout") == 600 - def test_env_api_key_override(self, temp_config_dir, monkeypatch): - """Test API keys from env override file.""" - save_config(EvoScientistConfig(anthropic_api_key="file-key")) - monkeypatch.setenv("ANTHROPIC_API_KEY", "env-key") - - config = get_effective_config() - assert config.anthropic_api_key == "env-key" - - def test_env_auth_mode_override(self, temp_config_dir, monkeypatch): - """Test auth mode from env overrides file.""" - save_config(EvoScientistConfig(anthropic_auth_mode="api_key")) - monkeypatch.setenv("EVOSCIENTIST_ANTHROPIC_AUTH_MODE", "oauth") - - config = get_effective_config() - assert config.anthropic_auth_mode == "oauth" - - def test_env_openai_auth_mode_override(self, temp_config_dir, monkeypatch): - """Test openai_auth_mode from env overrides file.""" - save_config(EvoScientistConfig(openai_auth_mode="api_key")) - monkeypatch.setenv("EVOSCIENTIST_OPENAI_AUTH_MODE", "oauth") - - config = get_effective_config() - assert config.openai_auth_mode == "oauth" - - def test_env_openrouter_anthropic_prompt_cache_opt_out( - self, temp_config_dir, monkeypatch - ): - """Test OpenRouter Anthropic prompt cache opt-out env overrides file.""" - save_config(EvoScientistConfig(openrouter_anthropic_prompt_cache=True)) - monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "false") - - config = get_effective_config() - assert config.openrouter_anthropic_prompt_cache is False - - def test_set_openrouter_anthropic_prompt_cache(self, temp_config_dir, clean_env): - """Test OpenRouter Anthropic prompt cache can be set through config.""" - save_config(EvoScientistConfig()) - - assert set_config_value("openrouter_anthropic_prompt_cache", "false") is True - assert get_config_value("openrouter_anthropic_prompt_cache") is False - # ============================================================================= # Test apply_config_to_env @@ -712,51 +699,38 @@ class TestPriorityChain: class TestApplyConfigToEnv: - def test_applies_api_keys_when_not_set(self, clean_env): - """Test that API keys are applied to env when not already set.""" - config = EvoScientistConfig( - anthropic_api_key="config-ant-key", - openai_api_key="config-oai-key", - tavily_api_key="config-tav-key", - ) + def test_applies_tavily_key_when_not_set(self, clean_env): + """Test that the Tavily key is applied to env when not already set.""" + config = EvoScientistConfig(tavily_api_key="config-tav-key") apply_config_to_env(config) - assert os.environ.get("ANTHROPIC_API_KEY") == "config-ant-key" - assert os.environ.get("OPENAI_API_KEY") == "config-oai-key" assert os.environ.get("TAVILY_API_KEY") == "config-tav-key" def test_does_not_override_existing_env(self, monkeypatch): """Test that existing env vars are not overridden.""" - monkeypatch.setenv("ANTHROPIC_API_KEY", "existing-key") + monkeypatch.setenv("TAVILY_API_KEY", "existing-key") - config = EvoScientistConfig(anthropic_api_key="config-key") + config = EvoScientistConfig(tavily_api_key="config-key") apply_config_to_env(config) - assert os.environ.get("ANTHROPIC_API_KEY") == "existing-key" + assert os.environ.get("TAVILY_API_KEY") == "existing-key" - def test_empty_config_keys_not_applied(self, clean_env): + def test_empty_config_keys_not_applied(self, clean_env, monkeypatch): """Test that empty config keys don't create env vars.""" - config = EvoScientistConfig() # All empty + monkeypatch.delenv("EVOSCIENTIST_REASONING_EFFORT", raising=False) + config = EvoScientistConfig(tavily_api_key="") apply_config_to_env(config) - assert os.environ.get("ANTHROPIC_API_KEY") is None - assert os.environ.get("OPENAI_API_KEY") is None - assert os.environ.get("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE") is None + assert os.environ.get("TAVILY_API_KEY") is None - def test_openrouter_anthropic_prompt_cache_opt_out_applied( - self, clean_env, monkeypatch - ): - """Test OpenRouter Anthropic prompt cache opt-out config is applied to env.""" - monkeypatch.delenv( - "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", raising=False - ) - config = EvoScientistConfig(openrouter_anthropic_prompt_cache=False) + def test_reasoning_effort_applied(self, clean_env, monkeypatch): + """reasoning_effort is round-tripped to env for downstream consumers.""" + monkeypatch.delenv("EVOSCIENTIST_REASONING_EFFORT", raising=False) + config = EvoScientistConfig(reasoning_effort="medium") apply_config_to_env(config) - assert os.environ.get("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE") == ( - "false" - ) + assert os.environ.get("EVOSCIENTIST_REASONING_EFFORT") == "medium" def test_dangerous_mode_round_trips_to_env(self, clean_env, monkeypatch): """dangerous_mode set via CLI override must survive a fresh re-read. @@ -796,58 +770,6 @@ class TestApplyConfigToEnv: apply_config_to_env(EvoScientistConfig()) # dangerous off assert os.environ.get("EVOSCIENTIST_DANGEROUS_MODE") is None - def test_ollama_base_url_applied(self, clean_env, monkeypatch): - """Test that ollama_base_url is applied to OLLAMA_BASE_URL env var.""" - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - config = EvoScientistConfig(ollama_base_url="http://localhost:11434") - apply_config_to_env(config) - - assert os.environ.get("OLLAMA_BASE_URL") == "http://localhost:11434" - - def test_ollama_base_url_not_overridden(self, monkeypatch): - """Test that existing OLLAMA_BASE_URL env var is not overridden.""" - monkeypatch.setenv("OLLAMA_BASE_URL", "http://existing:11434") - config = EvoScientistConfig(ollama_base_url="http://new:11434") - apply_config_to_env(config) - - assert os.environ.get("OLLAMA_BASE_URL") == "http://existing:11434" - - -class TestAuxiliaryModelConfig: - """auxiliary_model / auxiliary_provider config fields (plain str, optional).""" - - def test_defaults_empty(self): - cfg = EvoScientistConfig() - assert cfg.auxiliary_model == "" - assert cfg.auxiliary_provider == "" - - def test_save_and_load_round_trip(self, temp_config_dir, clean_env): - save_config( - EvoScientistConfig( - auxiliary_model="claude-haiku-4-5", - auxiliary_provider="anthropic", - ) - ) - loaded = load_config() - assert loaded.auxiliary_model == "claude-haiku-4-5" - assert loaded.auxiliary_provider == "anthropic" - - def test_get_set_value(self, temp_config_dir, clean_env): - save_config(EvoScientistConfig()) - assert set_config_value("auxiliary_model", "qwen3.6-flash") is True - assert set_config_value("auxiliary_provider", "dashscope") is True - assert get_config_value("auxiliary_model") == "qwen3.6-flash" - assert get_config_value("auxiliary_provider") == "dashscope" - - def test_env_overrides_file(self, temp_config_dir, monkeypatch): - save_config(EvoScientistConfig(auxiliary_model="claude-haiku-4-5")) - monkeypatch.setenv("EVOSCIENTIST_AUXILIARY_MODEL", "gpt-5.5") - monkeypatch.setenv("EVOSCIENTIST_AUXILIARY_PROVIDER", "openai") - - config = get_effective_config() - assert config.auxiliary_model == "gpt-5.5" - assert config.auxiliary_provider == "openai" - def test_scheduler_config_defaults_and_env(monkeypatch): from EvoScientist.config.settings import EvoScientistConfig, get_effective_config diff --git a/tests/test_configurable_model_middleware.py b/tests/test_configurable_model_middleware.py index b0d89a8..2d8335c 100644 --- a/tests/test_configurable_model_middleware.py +++ b/tests/test_configurable_model_middleware.py @@ -4,7 +4,8 @@ Verifies the snapshot-only contract: the middleware resolves the frozen ``ResolvedModelConfig`` for its role from ``configurable["runtime_snapshot_id"]`` and builds the model via ``build_chat_model``; ``model``/``model_provider`` in ``configurable`` are rejected with ``MODEL_CONFIG_OUTSIDE_SNAPSHOT``; a -run without a snapshot ID passes through unchanged. +run without a snapshot ID gets a lazily created local snapshot bound to its +thread (section 8.1), or fails closed when no thread ID exists either. """ from __future__ import annotations @@ -19,15 +20,18 @@ from langchain_openai import ChatOpenAI from EvoScientist.middleware.configurable_model import ( ConfigurableModelMiddleware, check_no_outside_snapshot_model_config, + ensure_snapshot_binding, read_snapshot_binding, ) from EvoScientist.model_registry.errors import ( MODEL_CONFIG_OUTSIDE_SNAPSHOT, + MODEL_REGISTRY_NOT_READY, SNAPSHOT_EXPIRED, SNAPSHOT_NOT_FOUND, ModelRegistryError, ) from EvoScientist.model_registry.runtime import SnapshotRuntime +from EvoScientist.model_registry.store import ModelRuntimeStore from tests.registry_fixtures import ( ZHIPU_SECRET, make_active_store, @@ -178,42 +182,102 @@ class TestReadSnapshotBinding: # ============================================================================= -# 3. Pass-through behavior (no snapshot in configurable) +# 3. Fail-closed behavior (no snapshot in configurable, section 8.1) # ============================================================================= -class TestPassThrough: - def test_sync_no_snapshot_passes_request_unchanged(self, runtime): +class TestFailClosed: + def test_sync_no_snapshot_no_thread_rejected(self, runtime): mw = ConfigurableModelMiddleware(runtime=runtime) req = _make_request() - sentinel = object() - handler = MagicMock(return_value=sentinel) - with _patched_config({}): - result = mw.wrap_model_call(req, handler) - assert result is sentinel - handler.assert_called_once_with(req) + handler = MagicMock() + with ( + _patched_config({}), + pytest.raises(ModelRegistryError) as excinfo, + ): + mw.wrap_model_call(req, handler) + assert excinfo.value.code == MODEL_CONFIG_OUTSIDE_SNAPSHOT + handler.assert_not_called() req.override.assert_not_called() - async def test_async_no_snapshot_passes_request_unchanged(self, runtime): + async def test_async_no_snapshot_no_thread_rejected(self, runtime): mw = ConfigurableModelMiddleware(runtime=runtime) req = _make_request() async def handler(r): - assert r is req - return "ok" + raise AssertionError("handler must not run") # pragma: no cover - with _patched_config({}): - result = await mw.awrap_model_call(req, handler) - assert result == "ok" + with ( + _patched_config({}), + pytest.raises(ModelRegistryError) as excinfo, + ): + await mw.awrap_model_call(req, handler) + assert excinfo.value.code == MODEL_CONFIG_OUTSIDE_SNAPSHOT req.override.assert_not_called() - def test_outside_runnable_context_passes_through(self, runtime): + def test_outside_runnable_context_fails_closed(self, runtime): + """No runnable context means no thread binding — never pass through.""" mw = ConfigurableModelMiddleware(runtime=runtime) req = _make_request() - handler = MagicMock(return_value="ok") - with _patched_config(None): + handler = MagicMock() + with ( + _patched_config(None), + pytest.raises(ModelRegistryError) as excinfo, + ): mw.wrap_model_call(req, handler) - handler.assert_called_once_with(req) + assert excinfo.value.code == MODEL_CONFIG_OUTSIDE_SNAPSHOT + handler.assert_not_called() + + def test_bootstrap_registry_fails_closed_not_ready(self, tmp_path): + bootstrap_runtime = SnapshotRuntime( + ModelRuntimeStore(config_dir=tmp_path / "model-runtime") + ) + mw = ConfigurableModelMiddleware(runtime=bootstrap_runtime) + req = _make_request() + with ( + _patched_config({"thread_id": "cli-thread"}), + pytest.raises(ModelRegistryError) as excinfo, + ): + mw.wrap_model_call(req, MagicMock()) + assert excinfo.value.code == MODEL_REGISTRY_NOT_READY + + +class TestLazyLocalSnapshot: + def test_thread_without_snapshot_lazily_creates_one(self, store, runtime): + mw = ConfigurableModelMiddleware(runtime=runtime) + req = _make_request() + captured = {} + + def handler(r): + captured["model"] = r.model + return "ok" + + with _patched_config({"thread_id": "cron-thread-1"}): + assert mw.wrap_model_call(req, handler) == "ok" + # The override carries the registry-default primary model. + assert captured["model"] is not req.model + # A snapshot was created and bound to the run's own thread. + found = store.find_active_run_snapshot( + deployment_id=runtime.local_deployment_id, + thread_id="cron-thread-1", + run_request_id="auto:cron-thread-1", + ) + assert found is not None + + def test_lazy_creation_converges_within_a_thread(self, store, runtime): + """Every middleware in the run lands on the same lazily created snapshot.""" + first = ensure_snapshot_binding({"thread_id": "t-1"}, runtime) + second = ensure_snapshot_binding({"thread_id": "t-1"}, runtime) + assert first == second + assert first[1] == runtime.local_deployment_id + + def test_explicit_snapshot_wins_over_lazy_creation(self, store, runtime): + snapshot = make_snapshot(store) + binding = ensure_snapshot_binding( + {"runtime_snapshot_id": snapshot.snapshot_id, "thread_id": "other"}, + runtime, + ) + assert binding[0] == snapshot.snapshot_id # ============================================================================= @@ -455,7 +519,7 @@ class TestRunnableContextVarIntegration: assert isinstance(model, ChatOpenAI) assert model.model_name == "glm-5.2" - def test_real_contextvar_unset_passes_through(self, runtime): + def test_real_contextvar_unset_fails_closed(self, runtime): from langchain_core.runnables.config import var_child_runnable_config # Defensive: ensure no leftover contextvar from another test. @@ -464,9 +528,11 @@ class TestRunnableContextVarIntegration: mw = ConfigurableModelMiddleware(runtime=runtime) req = _make_request() handler = MagicMock(return_value="ok") - mw.wrap_model_call(req, handler) + with pytest.raises(ModelRegistryError) as excinfo: + mw.wrap_model_call(req, handler) finally: var_child_runnable_config.reset(token) - handler.assert_called_once_with(req) + assert excinfo.value.code == MODEL_CONFIG_OUTSIDE_SNAPSHOT + handler.assert_not_called() req.override.assert_not_called() diff --git a/tests/test_context_editing_middleware.py b/tests/test_context_editing_middleware.py index c7db267..dc5a679 100644 --- a/tests/test_context_editing_middleware.py +++ b/tests/test_context_editing_middleware.py @@ -107,9 +107,10 @@ def test_create_middleware_model_none_fallback(mock_model): "EvoScientist.middleware.create_tool_selector_middleware", return_value=[MagicMock(), MagicMock()], ) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") -def test_default_middleware_includes_context_editing(mock_config, mock_model, mock_ts): +def test_default_middleware_includes_context_editing(mock_config, mock_model, mock_aux, mock_ts): mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000}) cfg = MagicMock() cfg.enable_ask_user = False @@ -143,9 +144,10 @@ def test_inject_subagent_includes_context_editing(mock_model): "EvoScientist.middleware.create_tool_selector_middleware", return_value=[MagicMock(), MagicMock()], ) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") -def test_context_editing_before_overflow_mapper(mock_config, mock_model, mock_ts): +def test_context_editing_before_overflow_mapper(mock_config, mock_model, mock_aux, mock_ts): mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000}) cfg = MagicMock() cfg.enable_ask_user = False diff --git a/tests/test_graph_gateway.py b/tests/test_graph_gateway.py index ebd2859..ca8e851 100644 --- a/tests/test_graph_gateway.py +++ b/tests/test_graph_gateway.py @@ -29,6 +29,13 @@ from tests.fakes import ( ) +@pytest.fixture(autouse=True) +def _active_registry(active_snapshot_runtime): + """Run entry points freeze a local snapshot per run; that fails closed + against the default bootstrap registry, so activate it for these tests.""" + return active_snapshot_runtime + + async def test_local_gateway_streams_from_injected_streamer(): seen: dict[str, Any] = {} @@ -743,13 +750,15 @@ async def test_langgraph_server_gateway_streams_root_protocol_events(): assert update_metadata["workspace_dir"] == "/tmp/ws" assert isinstance(update_metadata["updated_at"], str) assert threads.stream_calls == [("abc12345", "writing-agent")] - assert stream.run.starts == [ - { - "input": {"messages": [{"role": "user", "content": "hi"}]}, - "config": {"configurable": {"thread_id": "abc12345"}}, - "metadata": {"workspace_dir": "/tmp/ws"}, - } - ] + assert len(stream.run.starts) == 1 + start = stream.run.starts[0] + assert start["input"] == {"messages": [{"role": "user", "content": "hi"}]} + assert start["metadata"] == {"workspace_dir": "/tmp/ws"} + configurable = start["config"]["configurable"] + assert configurable["thread_id"] == "abc12345" + # The gateway freezes a local run snapshot per run (design doc 8.1). + assert isinstance(configurable["runtime_snapshot_id"], str) + assert set(configurable) == {"thread_id", "runtime_snapshot_id"} assert events == [ {"type": "text", "content": "hello"}, {"type": "done", "content": "hello", "response": "hello"}, diff --git a/tests/test_langgraph_dev_http.py b/tests/test_langgraph_dev_http.py index 1bf6343..d914146 100644 --- a/tests/test_langgraph_dev_http.py +++ b/tests/test_langgraph_dev_http.py @@ -1,11 +1,14 @@ -"""Smoke tests for the legacy admin routes mounted via langgraph.json's -``http`` field. We test the Starlette app directly — no need to spin up -langgraph dev. +"""Smoke tests for the custom routes mounted via langgraph.json's ``http`` +field. We test the Starlette app directly — no need to spin up langgraph dev. -``GET /api/models`` and ``POST /api/runtime-snapshots`` now belong to the -unified model registry API (``EvoScientist.model_registry.http_api``); their -contract tests live in ``tests/test_model_registry_http.py`` and -``tests/test_delegation_auth.py``. +``GET /api/models`` and ``POST /api/runtime-snapshots`` belong to the unified +model registry API (``EvoScientist.model_registry.http_api``); their contract +tests live in ``tests/test_model_registry_http.py`` and +``tests/test_delegation_auth.py``. The legacy provider routes +(``/api/provider-profiles``, ``/api/provider-actions``, ``/api/config``, +``/api/default-model``) and the ``x-evoscientist-admin-token`` check were +removed with the unified model configuration refactor; the first test pins +down that they are gone. """ from __future__ import annotations @@ -16,674 +19,28 @@ import pytest from langchain_core.messages import AIMessage, HumanMessage from starlette.testclient import TestClient -from EvoScientist.config import EvoScientistConfig, load_config, save_config -from EvoScientist.config.provider_profiles import ( - load_provider_profiles, -) from EvoScientist.langgraph_dev.http import app -from EvoScientist.llm.provider_operations import ( - DiscoveredProviderModel, - ProviderModelTestResult, - ProviderOperationError, -) client = TestClient(app) -@pytest.fixture(autouse=True) -def isolate_xdg_config(tmp_path, monkeypatch): - """Keep HTTP tests independent from the developer's saved providers.""" - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - - -def test_provider_profiles_api_requires_admin_token_header(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False) - response = client.get("/api/provider-profiles") - assert response.status_code == 403 - - -def test_provider_profiles_api_round_trip_redacts_secret(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - payload = { - "providers": [ - { - "id": "lab-openai", - "name": "Lab OpenAI", - "adapter": "openai-compatible", - "base_url": "https://llm.example.test/v1", - "api_key": "provider-secret", - "enabled": True, - "runtime": {"timeout_seconds": 90, "max_retries": 1}, - "models": [ - { - "id": "lab-model", - "name": "Lab Model", - "model_id": "vendor/model", - "enabled": True, - "runtime": { - "limit_mode": "combined", - "context_window_tokens": 32_768, - "max_output_tokens": 4_096, - "min_effective_input_tokens": 4_096, - "limits_status": "confirmed", - "limits_source": "provider", - }, - } - ], - } - ] - } - - put_response = client.put("/api/provider-profiles", headers=headers, json=payload) - assert put_response.status_code == 200 - assert "provider-secret" not in put_response.text - assert put_response.json()["providers"][0]["api_key_configured"] is True - assert "openai" in put_response.json()["reserved_provider_ids"] - - get_response = client.get("/api/provider-profiles", headers=headers) - assert get_response.status_code == 200 - assert get_response.json()["providers"][0]["models"][0]["id"] == "lab-model" - assert "provider-secret" not in get_response.text - - -def test_llm_config_api_requires_admin_token_header(monkeypatch): - monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False) - - response = client.get("/api/config") - - assert response.status_code == 403 - - -def test_llm_config_api_redacts_secrets_and_reports_env_overrides(monkeypatch): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - monkeypatch.setenv("OPENAI_API_KEY", "environment-secret") - save_config( - EvoScientistConfig( - provider="openai", - model="gpt-5.4", - openai_api_key="file-secret-1234", - default_workdir="/tmp/research", - ) - ) - - response = client.get( - "/api/config", - headers={"X-EvoScientist-Admin-Token": "admin-secret"}, - ) - - assert response.status_code == 200 - body = response.json() - assert body["values"]["provider"] == "openai" - assert body["values"]["model"] == "gpt-5.4" - assert "openai_api_key" not in body["values"] - assert body["secrets"]["openai_api_key"] == { - "configured": True, - "hint": "...1234", - } - assert body["env_overrides"]["openai_api_key"] == "OPENAI_API_KEY" - assert "file-secret-1234" not in response.text - assert "environment-secret" not in response.text - assert "default_workdir" not in body["values"] - assert body["model_catalog"] is None - assert body["builtin_model_candidates"] - openai = next( - provider for provider in body["builtin_providers"] if provider["id"] == "openai" - ) - assert openai["managed"] is False - assert openai["api_key_configured"] is True - assert "environment-secret" not in response.text - - -def test_llm_config_api_saves_builtin_registry_without_rewriting_legacy_secret( - monkeypatch, -): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - save_config( - EvoScientistConfig( - provider="openai", - model="chat-main", - openai_api_key="legacy-secret", - default_workdir="/tmp/research", - ) - ) - loaded = client.get("/api/config", headers=headers).json() - openai = next( - provider - for provider in loaded["builtin_providers"] - if provider["id"] == "openai" - ) - openai.update( - { - "managed": True, - "base_url": "https://proxy.example.test/v1", - "models": [ - { - "id": "chat-main", - "name": "Chat Main", - "model_id": "gpt-upstream", - "enabled": True, - } - ], - } - ) - - response = client.patch( - "/api/config", - headers=headers, - json={ - "revision": loaded["revision"], - "values": loaded["values"], - "secrets": {}, - "clear_secrets": [], - "builtin_providers": [openai], - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["restart_required"] is False - assert "builtin_providers" in body["changed_fields"] - saved_profile = load_provider_profiles().builtins[0] - assert saved_profile.id == "openai" - assert saved_profile.api_key == "legacy-secret" - assert saved_profile.base_url == "https://proxy.example.test/v1" - saved_config = load_config() - assert saved_config.openai_api_key == "legacy-secret" - assert saved_config.default_workdir == "/tmp/research" - assert saved_config.model_catalog is None - - -def test_first_builtin_registry_save_migrates_legacy_default_model(monkeypatch): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://environment.example.test") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - save_config(EvoScientistConfig(provider="anthropic", model="claude-sonnet-4-6")) - loaded = client.get("/api/config", headers=headers).json() - ollama = next( - provider - for provider in loaded["builtin_providers"] - if provider["id"] == "ollama" - ) - ollama.update( - { - "managed": True, - "base_url": "http://127.0.0.1:11434", - "models": [], - } - ) - - response = client.patch( - "/api/config", - headers=headers, - json={ - "revision": loaded["revision"], - "values": loaded["values"], - "secrets": {}, - "clear_secrets": [], - "builtin_providers": [ollama], - }, - ) - - assert response.status_code == 200 - builtins = {profile.id: profile for profile in load_provider_profiles().builtins} - assert set(builtins) == {"anthropic", "ollama"} - assert builtins["anthropic"].models[0].id == "claude-sonnet-4-6" - assert builtins["anthropic"].base_url == "" - - -def test_llm_config_api_persists_model_catalog(monkeypatch): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - save_config(EvoScientistConfig(provider="openai", model="chat-main")) - revision = client.get("/api/config", headers=headers).json()["revision"] - catalog = [ - { - "provider": "openai", - "id": "chat-main", - "name": "Chat Main", - "model_id": "gpt-upstream", - "enabled": True, - } - ] - - response = client.patch( - "/api/config", - headers=headers, - json={ - "revision": revision, - "values": {}, - "secrets": {}, - "clear_secrets": [], - "model_catalog": catalog, - }, - ) - - assert response.status_code == 200 - assert response.json()["model_catalog"] == catalog - assert response.json()["restart_required"] is False - assert load_config().model_catalog == catalog - - -def test_llm_config_api_rejects_default_outside_model_catalog(monkeypatch): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - save_config(EvoScientistConfig(provider="openai", model="gpt-default")) - revision = client.get("/api/config", headers=headers).json()["revision"] - - response = client.patch( - "/api/config", - headers=headers, - json={ - "revision": revision, - "values": {}, - "secrets": {}, - "clear_secrets": [], - "model_catalog": [ - { - "provider": "openai", - "id": "different-model", - "name": "Different", - "model_id": "gpt-different", - "enabled": True, - } - ], - }, - ) - - assert response.status_code == 400 - assert "Default model" in response.json()["error"] - assert load_config().model_catalog is None - - -def test_llm_config_api_patch_preserves_unrelated_fields_and_secret_by_default( - monkeypatch, -): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - save_config( - EvoScientistConfig( - openai_api_key="saved-openai-key", - anthropic_api_key="saved-anthropic-key", - default_workdir="/tmp/research", - ) - ) - revision = client.get("/api/config", headers=headers).json()["revision"] - - response = client.patch( - "/api/config", - headers=headers, - json={ - "revision": revision, - "values": { - "provider": "openai", - "model": "gpt-5.4", - "ollama_base_url": "http://127.0.0.1:11434", - }, - "secrets": {"openai_api_key": "replacement-openai-key"}, - "clear_secrets": ["anthropic_api_key"], - }, - ) - - assert response.status_code == 200 - body = response.json() - assert body["restart_required"] is True - assert set(body["changed_fields"]) == { - "anthropic_api_key", - "model", - "ollama_base_url", - "openai_api_key", - "provider", - } - assert "replacement-openai-key" not in response.text - saved = load_config() - assert saved.provider == "openai" - assert saved.model == "gpt-5.4" - assert saved.ollama_base_url == "http://127.0.0.1:11434" - assert saved.openai_api_key == "replacement-openai-key" - assert saved.anthropic_api_key == "" - assert saved.default_workdir == "/tmp/research" - - -def test_llm_config_api_rejects_stale_revision(monkeypatch): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - save_config(EvoScientistConfig(model="claude-sonnet-4-6")) - revision = client.get("/api/config", headers=headers).json()["revision"] - save_config(EvoScientistConfig(model="gpt-5.4", provider="openai")) - - response = client.patch( - "/api/config", - headers=headers, - json={ - "revision": revision, - "values": {"model": "claude-opus-4-8"}, - }, - ) - - assert response.status_code == 409 - assert "Reload and try again" in response.json()["error"] - assert load_config().model == "gpt-5.4" - - @pytest.mark.parametrize( - ("payload", "message"), + ("method", "path"), [ - ( - {"values": {"openai_auth_mode": "password"}}, - "openai_auth_mode must be 'api_key' or 'oauth'", - ), - ( - {"values": {"default_workdir": "/tmp/other"}}, - "Unsupported config fields: default_workdir", - ), - ( - {"values": {"ollama_base_url": "localhost:11434"}}, - "ollama_base_url must use http:// or https://", - ), + ("GET", "/api/provider-profiles"), + ("PUT", "/api/provider-profiles"), + ("GET", "/api/config"), + ("PATCH", "/api/config"), + ("POST", "/api/config"), + ("POST", "/api/provider-actions"), + ("PUT", "/api/default-model"), ], ) -def test_llm_config_api_validates_updates(monkeypatch, payload, message): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") +def test_legacy_provider_routes_are_gone(method, path): + """The removed routes must not exist — even with the old admin token.""" headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - revision = client.get("/api/config", headers=headers).json()["revision"] - - response = client.patch( - "/api/config", - headers=headers, - json={"revision": revision, **payload}, - ) - - assert response.status_code == 400 - assert message in response.json()["error"] - - -def test_provider_actions_api_requires_admin_token_header(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False) - - response = client.post("/api/provider-actions", json={}) - - assert response.status_code == 403 - - -def test_provider_actions_lists_models_with_saved_api_key(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - provider = { - "id": "lab-openai", - "name": "Lab OpenAI", - "adapter": "openai-compatible", - "base_url": "https://llm.example.test/v1", - "api_key": "provider-secret", - "enabled": True, - "models": [], - } - assert ( - client.put( - "/api/provider-profiles", - headers=headers, - json={"providers": [provider]}, - ).status_code - == 200 - ) - provider["api_key"] = "" - - async def fake_discover(profile): - assert profile.api_key == "provider-secret" - return [DiscoveredProviderModel("vendor/model", "Vendor Model")] - - with patch( - "EvoScientist.langgraph_dev.http.discover_provider_models", - new=fake_discover, - ): - response = client.post( - "/api/provider-actions", - headers=headers, - json={"action": "list_models", "provider": provider}, - ) - - assert response.status_code == 200 - assert response.json() == { - "models": [{"model_id": "vendor/model", "name": "Vendor Model"}] - } - - -def test_provider_actions_tests_model(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - - async def fake_test(profile, model): - assert profile.adapter == "grok" - assert model.model_id == "grok-4" - return ProviderModelTestResult(latency_ms=123, response="OK") - - with patch("EvoScientist.langgraph_dev.http.test_provider_model", new=fake_test): - response = client.post( - "/api/provider-actions", - headers=headers, - json={ - "action": "test_model", - "provider": { - "id": "lab-grok", - "name": "Lab Grok", - "adapter": "grok", - "base_url": "", - "api_key": "xai-secret", - "enabled": True, - "models": [], - }, - "model": { - "id": "grok", - "name": "Grok", - "model_id": "grok-4", - }, - }, - ) - - assert response.status_code == 200 - assert response.json() == {"ok": True, "latency_ms": 123, "response": "OK"} - - -def test_provider_actions_maps_provider_failures_to_bad_gateway(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - - async def fake_discover(_profile): - raise ProviderOperationError("Provider returned HTTP 401") - - with patch( - "EvoScientist.langgraph_dev.http.discover_provider_models", - new=fake_discover, - ): - response = client.post( - "/api/provider-actions", - headers={"X-EvoScientist-Admin-Token": "admin-secret"}, - json={ - "action": "list_models", - "provider": { - "id": "lab-openai", - "name": "Lab OpenAI", - "adapter": "openai-compatible", - "base_url": "https://llm.example.test/v1", - "api_key": "bad-key", - "enabled": True, - "models": [], - }, - }, - ) - - assert response.status_code == 502 - assert response.json() == {"error": "Provider returned HTTP 401"} - - -def test_llm_config_action_discovers_models_with_effective_builtin_secret( - monkeypatch, -): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - monkeypatch.setenv("OPENAI_API_KEY", "environment-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - - async def fake_discover(profile): - assert profile.id == "openai" - assert profile.adapter == "openai" - assert profile.api_key == "environment-secret" - return [DiscoveredProviderModel("gpt-discovered", "GPT Discovered")] - - with patch( - "EvoScientist.langgraph_dev.http.discover_provider_models", - new=fake_discover, - ): - response = client.post( - "/api/config", - headers=headers, - json={"action": "list_models", "provider": {"id": "openai"}}, - ) - - assert response.status_code == 200 - assert response.json() == { - "models": [{"model_id": "gpt-discovered", "name": "GPT Discovered"}] - } - - -def test_llm_config_action_tests_builtin_model_with_draft_connection(monkeypatch): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - - async def fake_test(profile, model): - assert profile.id == "custom-openai" - assert profile.adapter == "openai-compatible" - assert profile.base_url == "https://proxy.example.test/v1" - assert profile.api_key == "draft-secret" - assert model.model_id == "vendor/model" - return ProviderModelTestResult(latency_ms=42, response="OK") - - with patch("EvoScientist.langgraph_dev.http.test_provider_model", new=fake_test): - response = client.post( - "/api/config", - headers=headers, - json={ - "action": "test_model", - "provider": { - "id": "custom-openai", - "base_url": "https://proxy.example.test/v1", - "api_key": "draft-secret", - }, - "model": { - "id": "chat-main", - "name": "Chat Main", - "model_id": "vendor/model", - }, - }, - ) - - assert response.status_code == 200 - assert response.json() == {"ok": True, "latency_ms": 42, "response": "OK"} - - -def test_default_model_api_requires_admin_token_header(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False) - response = client.put( - "/api/default-model", - json={"model": "gpt-5.4", "provider": "openai"}, - ) - assert response.status_code == 403 - - -def test_default_model_api_persists_pair_and_preserves_config(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - save_config(EvoScientistConfig(default_workdir="/tmp/research")) - - response = client.put( - "/api/default-model", - headers={"X-EvoScientist-Admin-Token": "admin-secret"}, - json={"model": "gpt-5.4", "provider": "openai"}, - ) - - assert response.status_code == 200 - assert response.json() == {"default": {"name": "gpt-5.4", "provider": "openai"}} - saved = load_config() - assert saved.model == "gpt-5.4" - assert saved.provider == "openai" - assert saved.default_workdir == "/tmp/research" - - -def test_default_model_api_accepts_configured_dynamic_model(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - headers = {"X-EvoScientist-Admin-Token": "admin-secret"} - provider_payload = { - "providers": [ - { - "id": "lab-openai", - "name": "Lab OpenAI", - "adapter": "openai-compatible", - "base_url": "https://llm.example.test/v1", - "api_key": "provider-secret", - "enabled": True, - "models": [ - { - "id": "lab-model", - "name": "Lab Model", - "model_id": "vendor/model", - "enabled": True, - } - ], - } - ] - } - assert ( - client.put( - "/api/provider-profiles", headers=headers, json=provider_payload - ).status_code - == 200 - ) - - response = client.put( - "/api/default-model", - headers=headers, - json={"model": "lab-model", "provider": "lab-openai"}, - ) - - assert response.status_code == 200 - saved = load_config() - assert (saved.model, saved.provider) == ("lab-model", "lab-openai") - - -def test_default_model_api_rejects_unconfigured_pair(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - - response = client.put( - "/api/default-model", - headers={"X-EvoScientist-Admin-Token": "admin-secret"}, - json={"model": "missing-model", "provider": "missing-provider"}, - ) - - assert response.status_code == 400 - assert "is not configured" in response.json()["error"] - - -def test_default_model_api_rejects_invalid_json(monkeypatch): - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "admin-secret") - response = client.put( - "/api/default-model", - headers={ - "X-EvoScientist-Admin-Token": "admin-secret", - "Content-Type": "application/json", - }, - content="{invalid", - ) - assert response.status_code == 400 - assert response.json() == {"error": "Request body must be valid JSON."} + response = client.request(method, path, headers=headers, json={}) + assert response.status_code == 404 def test_final_answer_extracts_latest_ai_text_blocks(): diff --git a/tests/test_legacy_artifacts.py b/tests/test_legacy_artifacts.py new file mode 100644 index 0000000..a4ca592 --- /dev/null +++ b/tests/test_legacy_artifacts.py @@ -0,0 +1,97 @@ +"""Tests for ``EvoScientist.config.legacy_artifacts`` (design doc §10 step 4). + +Startup must refuse — with an explicit reset guide — when any pre-Registry +model configuration artifact remains: an old ``providers.yaml``, an old +``run-runtime-snapshots.sqlite3``, or leftover LLM fields in +``config.yaml``. Platform fields in ``config.yaml`` are fine. +""" + +from __future__ import annotations + +import pytest +import yaml + +from EvoScientist.config.legacy_artifacts import ( + LEGACY_CONFIG_YAML_KEYS, + LegacyArtifactsError, + assert_no_legacy_artifacts, + find_legacy_artifacts, +) + + +def test_clean_config_dir_passes(tmp_path): + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"workspace_dir": "/tmp/ws", "langgraph_port": 2024}), + encoding="utf-8", + ) + assert find_legacy_artifacts(tmp_path) == [] + assert_no_legacy_artifacts(tmp_path) # must not raise + + +def test_missing_config_yaml_passes(tmp_path): + assert find_legacy_artifacts(tmp_path) == [] + assert_no_legacy_artifacts(tmp_path) + + +def test_legacy_providers_yaml_refused(tmp_path): + (tmp_path / "providers.yaml").write_text("providers: []\n", encoding="utf-8") + found = find_legacy_artifacts(tmp_path) + assert any("providers.yaml" in item for item in found) + with pytest.raises(LegacyArtifactsError) as excinfo: + assert_no_legacy_artifacts(tmp_path) + message = str(excinfo.value) + assert "providers.yaml" in message + # The reset guide is part of the refusal (section 10 step 4). + assert "Delete" in message + assert "Model Registry" in message + + +def test_legacy_snapshot_database_refused(tmp_path): + (tmp_path / "run-runtime-snapshots.sqlite3").touch() + found = find_legacy_artifacts(tmp_path) + assert any("run-runtime-snapshots.sqlite3" in item for item in found) + with pytest.raises(LegacyArtifactsError, match="run-runtime-snapshots"): + assert_no_legacy_artifacts(tmp_path) + + +def test_leftover_llm_fields_in_config_yaml_refused(tmp_path): + (tmp_path / "config.yaml").write_text( + yaml.safe_dump( + { + "workspace_dir": "/tmp/ws", + "model": "glm-5.2", + "zhipu_api_key": "sk-old", + "model_fallbacks": ["gpt-5"], + } + ), + encoding="utf-8", + ) + found = find_legacy_artifacts(tmp_path) + assert len(found) == 1 + assert "model" in found[0] + assert "zhipu_api_key" in found[0] + with pytest.raises(LegacyArtifactsError) as excinfo: + assert_no_legacy_artifacts(tmp_path) + assert "zhipu_api_key" in str(excinfo.value) + + +def test_all_artifacts_reported_together(tmp_path): + (tmp_path / "providers.yaml").touch() + (tmp_path / "run-runtime-snapshots.sqlite3").touch() + (tmp_path / "config.yaml").write_text( + yaml.safe_dump({"provider": "zhipu"}), encoding="utf-8" + ) + found = find_legacy_artifacts(tmp_path) + assert len(found) == 3 + + +def test_invalid_config_yaml_not_misread_as_legacy(tmp_path): + (tmp_path / "config.yaml").write_text("{{{{ not yaml", encoding="utf-8") + # Unparseable YAML yields no key evidence — other layers report it. + assert find_legacy_artifacts(tmp_path) == [] + + +def test_legacy_key_set_covers_cutover_fields(): + # Spot-check the section-10 cutover list stays comprehensive. + for key in ("provider", "model", "auxiliary_model", "anthropic_api_key"): + assert key in LEGACY_CONFIG_YAML_KEYS diff --git a/tests/test_llm.py b/tests/test_llm.py index 25048fc..eb5aec4 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -1,6 +1,9 @@ -"""Tests for EvoScientist LLM module.""" +"""Tests for EvoScientist LLM provider patches (``EvoScientist.llm.patches``). -from unittest.mock import patch +The static model catalog / free-string ``get_chat_model`` factory was removed +in the unified model-configuration refactor; its tests were dropped with it. +Chat-model construction coverage now lives with ``model_registry``. +""" import pytest @@ -8,790 +11,6 @@ import pytest # _patch_openai_capture_reasoning_content) before tests reference patched # functions from langchain_openai. import EvoScientist.llm.patches # noqa: F401 -from EvoScientist.llm import ( - DEFAULT_MODEL, - MODELS, - get_chat_model, - get_model_info, - get_models_for_provider, - list_models, -) -from EvoScientist.llm.models import _MODEL_ENTRIES - -# ============================================================================= -# Test MODELS registry -# ============================================================================= - - -class TestModelsRegistry: - def test_models_is_dict(self): - """Test that MODELS is a dictionary.""" - assert isinstance(MODELS, dict) - - def test_entries_has_all_providers(self): - """Test that _MODEL_ENTRIES covers all registered providers.""" - providers = {p for _, _, p in _MODEL_ENTRIES} - assert "anthropic" in providers - assert "openai" in providers - assert "google-genai" in providers - assert "minimax" in providers - assert "nvidia" in providers - assert "siliconflow" in providers - assert "openrouter" in providers - assert "zhipu" in providers - assert "zhipu-code" in providers - assert "volcengine" in providers - assert "dashscope" in providers - assert "dashscope-code" in providers - assert "deepseek" in providers - assert "moonshot" in providers - assert "kimi-coding" in providers - - def test_entries_are_valid_tuples(self): - """Test that _MODEL_ENTRIES contains valid (name, model_id, provider) tuples.""" - valid_providers = { - "anthropic", - "openai", - "google-genai", - "minimax", - "nvidia", - "siliconflow", - "openrouter", - "zhipu", - "zhipu-code", - "volcengine", - "dashscope", - "dashscope-code", - "custom-openai", - "custom-anthropic", - "deepseek", - "moonshot", - "kimi-coding", - } - for entry in _MODEL_ENTRIES: - assert len(entry) == 3, f"Entry {entry} doesn't have 3 elements" - name, model_id, provider = entry - assert isinstance(name, str) - assert isinstance(model_id, str) - assert provider in valid_providers, ( - f"Unknown provider '{provider}' for '{name}'" - ) - - def test_get_models_for_provider(self): - """Test that get_models_for_provider returns correct models.""" - anthropic_models = get_models_for_provider("anthropic") - assert len(anthropic_models) > 0 - for name, model_id in anthropic_models: - assert isinstance(name, str) - assert isinstance(model_id, str) - - # Third-party providers now have registered models - openrouter_models = get_models_for_provider("openrouter") - assert len(openrouter_models) > 0 - siliconflow_models = get_models_for_provider("siliconflow") - assert len(siliconflow_models) > 0 - - -# ============================================================================= -# Test DEFAULT_MODEL -# ============================================================================= - - -class TestDefaultModel: - def test_default_model_exists_in_registry(self): - """Test that DEFAULT_MODEL is a valid model in MODELS.""" - assert DEFAULT_MODEL in MODELS - - def test_default_model_is_anthropic(self): - """Test that default model uses Anthropic.""" - _, provider = MODELS[DEFAULT_MODEL] - assert provider == "anthropic" - - -# ============================================================================= -# Test list_models -# ============================================================================= - - -class TestListModels: - def test_returns_list(self): - """Test that list_models returns a list.""" - result = list_models() - assert isinstance(result, list) - - def test_returns_all_model_names(self): - """Test that list_models returns all model names.""" - result = list_models() - assert set(result) == set(MODELS.keys()) - - def test_list_is_not_empty(self): - """Test that the list is not empty.""" - assert len(list_models()) > 0 - - -# ============================================================================= -# Test get_model_info -# ============================================================================= - - -class TestGetModelInfo: - def test_returns_tuple_for_valid_model(self): - """Test that get_model_info returns tuple for valid model.""" - result = get_model_info("claude-sonnet-4-6") - assert result is not None - assert isinstance(result, tuple) - assert len(result) == 2 - - def test_returns_none_for_invalid_model(self): - """Test that get_model_info returns None for invalid model.""" - result = get_model_info("nonexistent-model") - assert result is None - - def test_returns_correct_info(self): - """Test that get_model_info returns correct info.""" - model_id, provider = get_model_info("gpt-5-nano") - assert model_id == "gpt-5-nano" - assert provider == "openai" - - -# ============================================================================= -# Test get_chat_model -# ============================================================================= - - -class TestGetChatModel: - @patch("EvoScientist.llm.models.init_chat_model") - def test_uses_default_model_when_none(self, mock_init): - """Test that get_chat_model uses default model when model=None.""" - mock_init.return_value = "mock_model" - - get_chat_model() - - mock_init.assert_called_once() - call_kwargs = mock_init.call_args[1] - # Default model should be resolved from MODELS - expected_model_id, expected_provider = MODELS[DEFAULT_MODEL] - assert call_kwargs["model"] == expected_model_id - assert call_kwargs["model_provider"] == expected_provider - - @patch("EvoScientist.llm.models.init_chat_model") - def test_resolves_short_name(self, mock_init): - """Test that get_chat_model resolves short names correctly.""" - mock_init.return_value = "mock_model" - - get_chat_model("claude-opus-4-8") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "claude-opus-4-8" - assert call_kwargs["model_provider"] == "anthropic" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_resolves_openai_short_name(self, mock_init): - """Test that get_chat_model resolves OpenAI short names.""" - mock_init.return_value = "mock_model" - - get_chat_model("gpt-5-mini") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "gpt-5-mini" - assert call_kwargs["model_provider"] == "openai" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_uses_full_model_id(self, mock_init): - """Test that get_chat_model accepts full model IDs.""" - mock_init.return_value = "mock_model" - - get_chat_model("claude-3-opus-20240229") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "claude-3-opus-20240229" - # Should infer anthropic from the model prefix - assert call_kwargs["model_provider"] == "anthropic" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_provider_override(self, mock_init): - """Test that provider can be overridden.""" - mock_init.return_value = "mock_model" - - get_chat_model("claude-sonnet-4-6", provider="custom_provider") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "custom_provider" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_glm_provider_alias_routes_to_zhipu(self, mock_init): - """The historic ``glm`` shorthand uses the Zhipu compatible endpoint.""" - mock_init.return_value = "mock_model" - - get_chat_model("glm-5.2", provider="glm") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "glm-5.2" - assert call_kwargs["model_provider"] == "openai" - assert call_kwargs["base_url"] == "https://open.bigmodel.cn/api/paas/v4" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_zhipu_does_not_fall_back_to_openai_api_key(self, mock_init, monkeypatch): - """A missing Zhipu key must not be sent from OPENAI_API_KEY instead.""" - monkeypatch.setenv("OPENAI_API_KEY", "unrelated-openai-key") - monkeypatch.delenv("ZHIPU_API_KEY", raising=False) - mock_init.return_value = "mock_model" - - get_chat_model("glm-5.2", provider="zhipu") - - assert mock_init.call_args.kwargs["api_key"] is None - - @patch("EvoScientist.llm.models.init_chat_model") - def test_passes_kwargs(self, mock_init): - """Test that additional kwargs are passed through.""" - mock_init.return_value = "mock_model" - - get_chat_model("gpt-5-nano", temperature=0.7, max_tokens=1000) - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["temperature"] == 0.7 - assert call_kwargs["max_tokens"] == 1000 - - @patch("EvoScientist.llm.models.init_chat_model") - def test_infers_openai_from_gpt_prefix(self, mock_init): - """Test that OpenAI is inferred from gpt- prefix.""" - mock_init.return_value = "mock_model" - - get_chat_model("gpt-4-turbo-preview") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_infers_openai_from_o1_prefix(self, mock_init): - """Test that OpenAI is inferred from o1 prefix.""" - mock_init.return_value = "mock_model" - - get_chat_model("o1-preview") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_infers_google_from_gemini_prefix(self, mock_init): - """Test that google-genai is inferred from gemini prefix.""" - mock_init.return_value = "mock_model" - - get_chat_model("gemini-2.0-flash") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "google-genai" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_defaults_to_anthropic_for_unknown(self, mock_init): - """Test that anthropic is default for unknown model prefixes.""" - mock_init.return_value = "mock_model" - - get_chat_model("some-unknown-model") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "anthropic" - - -# ============================================================================= -# Test Ollama provider -# ============================================================================= - - -class TestOllamaProvider: - """Ollama models are not in the static registry (detected dynamically). - All tests use explicit provider or ollama: prefix.""" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_explicit_provider(self, mock_init): - """Test that explicit provider='ollama' routes correctly.""" - mock_init.return_value = "mock_model" - - get_chat_model("llama3.1:8b", provider="ollama") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "llama3.1:8b" - assert call_kwargs["model_provider"] == "ollama" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_ollama_base_url_passthrough(self, mock_init, monkeypatch): - """Test that OLLAMA_BASE_URL env var is passed to kwargs.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OLLAMA_BASE_URL", "http://gpu-cluster:11434") - - get_chat_model("llama3.1:8b", provider="ollama") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["base_url"] == "http://gpu-cluster:11434" - assert call_kwargs["model_provider"] == "ollama" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_ollama_no_base_url_when_unset(self, mock_init, monkeypatch): - """Test that base_url is not set when OLLAMA_BASE_URL is empty.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - - get_chat_model("llama3.1:8b", provider="ollama") - - call_kwargs = mock_init.call_args[1] - assert "base_url" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_reasoning_auto_enabled_for_ollama(self, mock_init, monkeypatch): - """Test that reasoning is auto-enabled for Ollama models.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - - get_chat_model("llama3.1:8b", provider="ollama") - - call_kwargs = mock_init.call_args[1] - assert "thinking" not in call_kwargs - assert call_kwargs["reasoning"] is True - - @patch("EvoScientist.llm.models.init_chat_model") - def test_reasoning_not_overridden_for_ollama(self, mock_init, monkeypatch): - """Test that explicit reasoning=False is not overridden for Ollama.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - - get_chat_model("llama3.1:8b", provider="ollama", reasoning=False) - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["reasoning"] is False - - def test_no_static_registry_entries(self): - """Test that Ollama has no static registry entries (models detected dynamically).""" - ollama_models = get_models_for_provider("ollama") - assert len(ollama_models) == 0 - - @patch("EvoScientist.llm.models.init_chat_model") - def test_ollama_prefix_inference(self, mock_init, monkeypatch): - """Test that ollama: prefix infers ollama provider.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) - - get_chat_model("ollama:phi3:mini") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "phi3:mini" - assert call_kwargs["model_provider"] == "ollama" - - -# ============================================================================= -# Test slash model ID no longer routes to nvidia -# ============================================================================= - - -class TestSlashModelIdFallback: - @patch("EvoScientist.llm.models.init_chat_model") - def test_slash_model_id_defaults_to_anthropic(self, mock_init): - """Unregistered model IDs containing '/' should NOT route to nvidia. - - They fall through to the default 'anthropic' provider, consistent - with how all other unknown model IDs are handled. - """ - mock_init.return_value = "mock_model" - - get_chat_model("some-org/some-model") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "some-org/some-model" - assert call_kwargs["model_provider"] == "anthropic" - - -# ============================================================================= -# Test third-party provider routing -# ============================================================================= - - -class TestThirdPartyRouting: - @patch("EvoScientist.llm.models.init_chat_model") - def test_siliconflow_routes_through_openai(self, mock_init, monkeypatch): - """SiliconFlow provider should route through OpenAI with correct base_url.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("SILICONFLOW_API_KEY", "sf-key-123") - - get_chat_model("Pro/zai-org/GLM-5", provider="siliconflow") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert call_kwargs["base_url"] == "https://api.siliconflow.cn/v1" - assert call_kwargs["api_key"] == "sf-key-123" - # SiliconFlow should disable thinking - assert call_kwargs["extra_body"]["enable_thinking"] is False - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_uses_native_provider(self, mock_init, monkeypatch): - """OpenRouter should use native 'openrouter' provider via init_chat_model.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key-456") - # Assert the DEFAULT effort, so isolate from any leaked env override. - monkeypatch.delenv("EVOSCIENTIST_REASONING_EFFORT", raising=False) - - get_chat_model("x-ai/grok-4.3", provider="openrouter") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openrouter" - assert call_kwargs["api_key"] == "or-key-456" - assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_reasoning_user_override(self, mock_init, monkeypatch): - """User-supplied reasoning config should not be overridden.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - - get_chat_model( - "x-ai/grok-4.3", - provider="openrouter", - reasoning={"effort": "low"}, - ) - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["reasoning"] == {"effort": "low"} - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_reasoning_effort_from_env(self, mock_init, monkeypatch): - """Reasoning effort should be configurable via env var.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("EVOSCIENTIST_REASONING_EFFORT", "medium") - - get_chat_model("x-ai/grok-4.3", provider="openrouter") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["reasoning"] == {"effort": "medium", "summary": "auto"} - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_anthropic_prompt_cache_enabled_by_default( - self, mock_init, monkeypatch - ): - """OpenRouter Anthropic prompt caching should be opt-out.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.delenv( - "EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", raising=False - ) - - get_chat_model("claude-sonnet-4.6", provider="openrouter") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openrouter" - assert call_kwargs["model"] == "anthropic/claude-sonnet-4.6" - assert call_kwargs["model_kwargs"]["cache_control"] == {"type": "ephemeral"} - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_anthropic_prompt_cache_opt_out(self, mock_init, monkeypatch): - """The opt-out flag should skip caching for OpenRouter Claude models.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "false") - - get_chat_model("claude-sonnet-4.6", provider="openrouter") - - call_kwargs = mock_init.call_args[1] - assert "cache_control" not in call_kwargs - assert "cache_control" not in call_kwargs.get("model_kwargs", {}) - - @patch("EvoScientist.llm.models.init_chat_model") - def test_prompt_cache_default_skips_non_anthropic_openrouter( - self, mock_init, monkeypatch - ): - """OpenRouter models with implicit caching should be left alone.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") - - get_chat_model("x-ai/grok-4.3", provider="openrouter") - - call_kwargs = mock_init.call_args[1] - assert "cache_control" not in call_kwargs - assert "cache_control" not in call_kwargs.get("model_kwargs", {}) - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_anthropic_prompt_cache_preserves_top_level_override( - self, mock_init, monkeypatch - ): - """The default should not duplicate a caller's cache_control kwarg.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") - override = {"type": "ephemeral", "ttl": "1h"} - - get_chat_model( - "claude-sonnet-4.6", - provider="openrouter", - cache_control=override, - ) - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["cache_control"] == override - assert "cache_control" not in call_kwargs.get("model_kwargs", {}) - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_anthropic_prompt_cache_preserves_model_kwargs_override( - self, mock_init, monkeypatch - ): - """The default should not duplicate model_kwargs cache_control.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") - override = {"type": "ephemeral", "ttl": "1h"} - - get_chat_model( - "claude-sonnet-4.6", - provider="openrouter", - model_kwargs={"cache_control": override}, - ) - - call_kwargs = mock_init.call_args[1] - assert "cache_control" not in call_kwargs - assert call_kwargs["model_kwargs"]["cache_control"] == override - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openrouter_anthropic_prompt_cache_warns_on_invalid_model_kwargs( - self, mock_init, monkeypatch - ): - """Invalid model_kwargs shape should warn and skip cache injection.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") - monkeypatch.setenv("EVOSCIENTIST_OPENROUTER_ANTHROPIC_PROMPT_CACHE", "true") - - with pytest.warns(UserWarning, match="model_kwargs` is not a dict"): - get_chat_model( - "claude-sonnet-4.6", - provider="openrouter", - model_kwargs="bad", - ) - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_kwargs"] == "bad" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_custom_routes_through_openai(self, mock_init, monkeypatch): - """Custom provider should route through OpenAI with env-configured base_url.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("CUSTOM_OPENAI_BASE_URL", "https://my-llm.example.com/v1") - monkeypatch.setenv("CUSTOM_OPENAI_API_KEY", "custom-key-789") - - get_chat_model("my-custom-model", provider="custom-openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert call_kwargs["base_url"] == "https://my-llm.example.com/v1" - assert call_kwargs["api_key"] == "custom-key-789" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_base_url_override(self, mock_init, monkeypatch): - """Anthropic provider should support base_url override (e.g. ccproxy).""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://localhost:8000/api/v1") - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-dummy") - - get_chat_model("claude-sonnet-4-6", provider="anthropic") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "anthropic" - assert call_kwargs["base_url"] == "http://localhost:8000/api/v1" - assert call_kwargs["api_key"] == "sk-dummy" - # Proxy mode: thinking skipped (history round-trip causes 422) - assert "thinking" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_no_base_url_when_unset(self, mock_init, monkeypatch): - """Anthropic provider should not set base_url when env var is empty.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-real") - - get_chat_model("claude-sonnet-4-6", provider="anthropic") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "anthropic" - assert "base_url" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_third_party_no_reasoning(self, mock_init, monkeypatch): - """Third-party providers routed through OpenAI should NOT get auto-reasoning.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("SILICONFLOW_API_KEY", "sf-key") - - get_chat_model("deepseek-v3", provider="siliconflow") - - call_kwargs = mock_init.call_args[1] - assert "reasoning" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_volcengine_routes_through_openai(self, mock_init, monkeypatch): - """Volcengine provider should route through OpenAI with correct base_url.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("VOLCENGINE_API_KEY", "ve-key-123") - - get_chat_model("doubao-seed-1.6", provider="volcengine") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert call_kwargs["base_url"] == "https://ark.cn-beijing.volces.com/api/v3" - assert call_kwargs["api_key"] == "ve-key-123" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_dashscope_routes_through_openai(self, mock_init, monkeypatch): - """DashScope provider should route through OpenAI with correct base_url.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("DASHSCOPE_API_KEY", "ds-key-456") - - get_chat_model("qwen-max", provider="dashscope") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert ( - call_kwargs["base_url"] - == "https://dashscope.aliyuncs.com/compatible-mode/v1" - ) - assert call_kwargs["api_key"] == "ds-key-456" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_dashscope_code_routes_through_openai(self, mock_init, monkeypatch): - """DashScope-Code (sk-sp-* subscription keys) routes through OpenAI - with the coding.dashscope.aliyuncs.com base URL, reusing DASHSCOPE_API_KEY. - """ - mock_init.return_value = "mock_model" - monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-sp-key-789") - - get_chat_model("qwen3-coder", provider="dashscope-code") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert call_kwargs["base_url"] == "https://coding.dashscope.aliyuncs.com/v1" - assert call_kwargs["api_key"] == "sk-sp-key-789" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_minimax_routes_through_anthropic(self, mock_init, monkeypatch): - """MiniMax provider should route through Anthropic with correct base_url.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("MINIMAX_API_KEY", "mm-key-123") - - get_chat_model("MiniMax-M2.5", provider="minimax") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "anthropic" - assert call_kwargs["base_url"] == "https://api.minimaxi.com/anthropic" - assert call_kwargs["api_key"] == "mm-key-123" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_minimax_base_url_env_override(self, mock_init, monkeypatch): - """MINIMAX_BASE_URL env var should override the default base URL.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("MINIMAX_API_KEY", "mm-key-123") - monkeypatch.setenv("MINIMAX_BASE_URL", "https://api.minimax.io/anthropic") - - get_chat_model("MiniMax-M2.5", provider="minimax") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["base_url"] == "https://api.minimax.io/anthropic" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_minimax_gets_thinking(self, mock_init, monkeypatch): - """MiniMax provider should get auto-thinking (thinking-capable via Anthropic).""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") - - get_chat_model("MiniMax-M2.5", provider="minimax") - - call_kwargs = mock_init.call_args[1] - assert "thinking" in call_kwargs - assert "reasoning" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_minimax_short_name_resolution(self, mock_init, monkeypatch): - """MiniMax short names should resolve to correct model IDs.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") - - get_chat_model("minimax-m2.5", provider="minimax") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "MiniMax-M2.5" - assert call_kwargs["model_provider"] == "anthropic" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_minimax_highspeed_model(self, mock_init, monkeypatch): - """MiniMax M2.5-highspeed model should resolve correctly.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("MINIMAX_API_KEY", "mm-key") - - get_chat_model("minimax-m2.5-highspeed", provider="minimax") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model"] == "MiniMax-M2.5-highspeed" - assert call_kwargs["model_provider"] == "anthropic" - assert call_kwargs["base_url"] == "https://api.minimaxi.com/anthropic" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_custom_anthropic_via_routed_dict(self, mock_init, monkeypatch): - """custom-anthropic should work via _ANTHROPIC_ROUTED_PROVIDERS dict.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("CUSTOM_ANTHROPIC_BASE_URL", "https://my-claude.example.com") - monkeypatch.setenv("CUSTOM_ANTHROPIC_API_KEY", "ca-key-789") - - get_chat_model("claude-sonnet-4-6", provider="custom-anthropic") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "anthropic" - assert call_kwargs["base_url"] == "https://my-claude.example.com" - assert call_kwargs["api_key"] == "ca-key-789" - # custom-anthropic is NOT thinking-capable → thinking skipped - assert "thinking" not in call_kwargs - - -# ============================================================================= -# Test MiniMax provider -# ============================================================================= - - -class TestMiniMaxProvider: - def test_minimax_in_anthropic_routed_providers(self): - """MiniMax should be registered in _ANTHROPIC_ROUTED_PROVIDERS.""" - from EvoScientist.llm.models import _ANTHROPIC_ROUTED_PROVIDERS - - assert "minimax" in _ANTHROPIC_ROUTED_PROVIDERS - base_url, api_key_env = _ANTHROPIC_ROUTED_PROVIDERS["minimax"] - assert base_url == "https://api.minimaxi.com/anthropic" - assert api_key_env == "MINIMAX_API_KEY" - - def test_minimax_not_in_openai_routed_providers(self): - """MiniMax should NOT be in _OPENAI_ROUTED_PROVIDERS (moved to Anthropic).""" - from EvoScientist.llm.models import _OPENAI_ROUTED_PROVIDERS - - assert "minimax" not in _OPENAI_ROUTED_PROVIDERS - - def test_minimax_models_registered(self): - """MiniMax should have 5 direct model entries in _MODEL_ENTRIES.""" - minimax_models = get_models_for_provider("minimax") - assert len(minimax_models) == 5 - model_names = {name for name, _ in minimax_models} - assert "minimax-m3" in model_names - assert "minimax-m2.7" in model_names - assert "minimax-m2.7-highspeed" in model_names - assert "minimax-m2.5" in model_names - assert "minimax-m2.5-highspeed" in model_names - - def test_minimax_model_ids_correct(self): - """MiniMax model IDs should match the official API model names.""" - minimax_models = get_models_for_provider("minimax") - model_dict = dict(minimax_models) - assert model_dict["minimax-m2.7"] == "MiniMax-M2.7" - assert model_dict["minimax-m2.5"] == "MiniMax-M2.5" - assert model_dict["minimax-m2.5-highspeed"] == "MiniMax-M2.5-highspeed" - - def test_minimax_short_name_in_models_dict(self): - """MiniMax short names should be accessible via the MODELS dict.""" - # Note: MODELS dict uses last-entry-wins, so direct minimax entries - # may be overridden by nvidia/siliconflow/openrouter entries. - # Use get_models_for_provider() for provider-specific lookups. - minimax_models = get_models_for_provider("minimax") - assert len(minimax_models) > 0 - # ============================================================================= # Test _flatten_message_content @@ -2253,292 +1472,3 @@ class TestPatchOpenrouterStripResponsesReasoning: assert result["reasoning_details"] == ["opaque"] finally: self._restore(patches, mod, orig, orig_flag) - - -# ============================================================================= -# Test _apply_auto_config -# ============================================================================= - - -class TestAutoConfig: - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_4_5_thinking(self, mock_init, monkeypatch): - """Anthropic 4-5 models get enabled thinking with budget.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) - - get_chat_model("claude-haiku-4-5") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["thinking"] == {"type": "enabled", "budget_tokens": 10000} - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_4_6_adaptive_thinking(self, mock_init, monkeypatch): - """Anthropic 4-6 models get adaptive thinking with max effort.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) - - get_chat_model("claude-sonnet-4-6") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["thinking"] == {"type": "adaptive", "display": "summarized"} - assert call_kwargs["effort"] == "max" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_4_8_adaptive_thinking(self, mock_init, monkeypatch): - """Anthropic 4-8 models get adaptive thinking with max effort.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("ANTHROPIC_BASE_URL", raising=False) - - get_chat_model("claude-opus-4-8") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["thinking"] == {"type": "adaptive", "display": "summarized"} - assert call_kwargs["effort"] == "max" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_4_6_proxy_no_thinking(self, mock_init, monkeypatch): - """Anthropic 4-6 models via proxy skip thinking (history round-trip 422).""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8000") - monkeypatch.setenv("ANTHROPIC_API_KEY", "ccproxy-oauth") - - get_chat_model("claude-sonnet-4-6") - - call_kwargs = mock_init.call_args[1] - assert "thinking" not in call_kwargs - assert "effort" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_4_5_proxy_no_thinking(self, mock_init, monkeypatch): - """Anthropic 4-5 models via proxy also skip thinking (history round-trip 422).""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8000") - monkeypatch.setenv("ANTHROPIC_API_KEY", "ccproxy-oauth") - - get_chat_model("claude-haiku-4-5") - - call_kwargs = mock_init.call_args[1] - assert "thinking" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_4_6_no_proxy_no_downgrade(self, mock_init, monkeypatch): - """Anthropic 4-6 models without proxy still get adaptive thinking.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://api.anthropic.com") - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-real") - - get_chat_model("claude-sonnet-4-6") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["thinking"] == {"type": "adaptive", "display": "summarized"} - assert call_kwargs["effort"] == "max" - - @patch("EvoScientist.llm.models.init_chat_model") - def test_anthropic_thinking_not_overridden(self, mock_init): - """User-supplied thinking config should not be overridden.""" - mock_init.return_value = "mock_model" - custom_thinking = {"type": "enabled", "budget_tokens": 500} - - get_chat_model("claude-sonnet-4-6", thinking=custom_thinking) - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["thinking"] == custom_thinking - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openai_reasoning_xhigh(self, mock_init, monkeypatch): - """gpt-5.4+ and codex models get xhigh reasoning.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - - get_chat_model("gpt-5.4", provider="openai") - assert mock_init.call_args[1]["reasoning"] == { - "effort": "xhigh", - "summary": "auto", - } - - get_chat_model("gpt-5.3-codex", provider="openai") - assert mock_init.call_args[1]["reasoning"] == { - "effort": "xhigh", - "summary": "auto", - } - - get_chat_model("gpt-5.5", provider="openai") - assert mock_init.call_args[1]["reasoning"] == { - "effort": "xhigh", - "summary": "auto", - } - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openai_reasoning_high_fallback(self, mock_init, monkeypatch): - """Other OpenAI models get high reasoning effort.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - - get_chat_model("gpt-5-nano") - assert mock_init.call_args[1]["reasoning"] == { - "effort": "high", - "summary": "auto", - } - - get_chat_model("gpt-5.2", provider="openai") - assert mock_init.call_args[1]["reasoning"] == { - "effort": "high", - "summary": "auto", - } - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openai_base_url_override(self, mock_init, monkeypatch): - """OpenAI provider should support base_url override (e.g. ccproxy Codex).""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/codex/v1") - monkeypatch.setenv("OPENAI_API_KEY", "ccproxy-oauth") - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert call_kwargs["base_url"] == "http://127.0.0.1:8000/codex/v1" - assert call_kwargs["api_key"] == "ccproxy-oauth" - # Proxy mode: reasoning skipped (ccproxy untested) - assert "reasoning" not in call_kwargs - # Proxy mode: Responses API (bypasses format chain), streaming ON - assert call_kwargs["use_responses_api"] is True - assert "streaming" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openai_localhost_non_ccproxy_not_downgraded(self, mock_init, monkeypatch): - """Local OpenAI-compatible endpoints (vLLM, etc.) are not affected by ccproxy workarounds.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8080/v1") - monkeypatch.setenv("OPENAI_API_KEY", "sk-local-key") - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["base_url"] == "http://127.0.0.1:8080/v1" - # NOT ccproxy: reasoning should be applied, no forced Chat Completions - assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} - assert "use_responses_api" not in call_kwargs - assert "streaming" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openai_codex_path_but_wrong_key_not_ccproxy(self, mock_init, monkeypatch): - """ccproxy detection requires both /codex/ path AND ccproxy-oauth key.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/codex/v1") - monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key") - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} - assert "use_responses_api" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openai_ccproxy_key_but_wrong_path_not_ccproxy( - self, mock_init, monkeypatch - ): - """ccproxy detection requires both /codex/ path AND ccproxy-oauth key.""" - mock_init.return_value = "mock_model" - monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/v1") - monkeypatch.setenv("OPENAI_API_KEY", "ccproxy-oauth") - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} - assert "use_responses_api" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_openai_no_base_url_when_unset(self, mock_init, monkeypatch): - """OpenAI provider should not set base_url when env var is empty.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.setenv("OPENAI_API_KEY", "sk-real") - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["model_provider"] == "openai" - assert "base_url" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_google_thoughts(self, mock_init): - """Google GenAI models get include_thoughts=True by default.""" - mock_init.return_value = "mock_model" - - get_chat_model("gemini-2.5-flash") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["include_thoughts"] is True - - @patch("EvoScientist.llm.models.init_chat_model") - def test_use_responses_api_false(self, mock_init, monkeypatch): - """use_responses_api=false forces Chat Completions and drops reasoning.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.setenv("EVOSCIENTIST_USE_RESPONSES_API", "false") - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["use_responses_api"] is False - assert "reasoning" not in call_kwargs - - @patch("EvoScientist.llm.models.init_chat_model") - def test_use_responses_api_true(self, mock_init, monkeypatch): - """use_responses_api=true explicitly enables the Responses API.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.setenv("EVOSCIENTIST_USE_RESPONSES_API", "true") - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["use_responses_api"] is True - assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} - - @patch("EvoScientist.llm.models.init_chat_model") - def test_use_responses_api_default_unchanged(self, mock_init, monkeypatch): - """Empty use_responses_api preserves default behavior (no kwarg set).""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.delenv("EVOSCIENTIST_USE_RESPONSES_API", raising=False) - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert "use_responses_api" not in call_kwargs - assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"} - - @pytest.mark.parametrize("env_value", ["FALSE", " false ", "False"]) - @patch("EvoScientist.llm.models.init_chat_model") - def test_use_responses_api_false_normalization( - self, mock_init, monkeypatch, env_value - ): - """Case/whitespace variants of 'false' are normalized correctly.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.setenv("EVOSCIENTIST_USE_RESPONSES_API", env_value) - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["use_responses_api"] is False - assert "reasoning" not in call_kwargs - - @pytest.mark.parametrize("env_value", ["TRUE", " true ", "True"]) - @patch("EvoScientist.llm.models.init_chat_model") - def test_use_responses_api_true_normalization( - self, mock_init, monkeypatch, env_value - ): - """Case/whitespace variants of 'true' are normalized correctly.""" - mock_init.return_value = "mock_model" - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - monkeypatch.setenv("EVOSCIENTIST_USE_RESPONSES_API", env_value) - - get_chat_model("gpt-5-nano", provider="openai") - - call_kwargs = mock_init.call_args[1] - assert call_kwargs["use_responses_api"] is True diff --git a/tests/test_message_budget.py b/tests/test_message_budget.py index 84a7da0..0f12587 100644 --- a/tests/test_message_budget.py +++ b/tests/test_message_budget.py @@ -1,13 +1,11 @@ -"""Tests for message-only context budgeting (design doc 6.5, 8.3). +"""Tests for message-only context budgeting (design doc 6.5, 8.1, 8.3). -Covers both budget modes: - -- Snapshot mode: input limit and fixed reserves come from the frozen - ``ResolvedModelConfig.budget``; the per-call budget is recomputed on every - invocation with the current has_tools/has_attachments mode; the - summarizer model resolves through the snapshot's ``summary`` role. -- Interim local mode (no snapshot ID): limits from the compile-time model - profile with the default reserve policy. +Every budget comes from the frozen ``ResolvedModelConfig.budget`` of the +run snapshot — the middleware's own ``snapshot_role`` selects which frozen +configuration sizes the budget (section 6.1 role mapping). Runs without an +explicit ``runtime_snapshot_id`` lazily create a local snapshot bound to +their thread (section 8.1); a bootstrap registry fails closed with +``MODEL_REGISTRY_NOT_READY``. There is no interim 32K/profile fallback. """ from __future__ import annotations @@ -21,14 +19,27 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_ollama import ChatOllama from EvoScientist.middleware.message_budget import ( - ContextBudgetUnsatisfiableError, - MessageReservePolicy, _snapshot_message_budget, count_message_text_tokens, create_message_budget_middleware, ) +from EvoScientist.model_registry.errors import ( + MODEL_CONFIG_OUTSIDE_SNAPSHOT, + MODEL_REGISTRY_NOT_READY, + SNAPSHOT_NOT_FOUND, + ModelRegistryError, +) from EvoScientist.model_registry.runtime import SnapshotRuntime -from tests.registry_fixtures import make_active_store, make_snapshot +from EvoScientist.model_registry.schemas import CredentialWrite, RegistryV4 +from EvoScientist.model_registry.store import ModelRuntimeStore +from tests.registry_fixtures import ( + ZHIPU_SECRET, + _verify_model, + make_active_store, + make_snapshot, + ollama_provider_payload, + zhipu_provider_payload, +) @contextmanager @@ -75,6 +86,39 @@ def _configurable_for(snapshot, **overrides): return configurable +def _make_split_limit_store(config_dir): + """Active store whose auxiliary model has a smaller input limit. + + primary (zhipu-glm/glm-5.2): context 1048576 → resolved_input_limit 1015808 + auxiliary (local-ollama/qwen3): context 65536 → resolved_input_limit 32768 + """ + store = ModelRuntimeStore(config_dir=config_dir) + payload = { + "version": 4, + "revision": 1, + "state": "bootstrap", + "defaults": { + "primary": {"provider_id": "zhipu-glm", "model_key": "glm-5.2"}, + "auxiliary": {"provider_id": "local-ollama", "model_key": "qwen3"}, + }, + "providers": [ + zhipu_provider_payload(), + ollama_provider_payload(context_window_tokens=65536), + ], + } + registry = store.save_registry( + expected_revision=1, + registry=RegistryV4.model_validate(payload), + credential_writes=[ + CredentialWrite(credential_id="zhipu-primary", secret_value=ZHIPU_SECRET) + ], + ) + assert registry.state == "active" + _verify_model(store, registry, "zhipu-glm", "glm-5.2") + _verify_model(store, registry, "local-ollama", "qwen3") + return store + + def test_text_counter_excludes_attachment_payloads_and_counts_tool_results(): messages = [ HumanMessage( @@ -105,7 +149,7 @@ class TestSnapshotMessageBudget: def test_base_mode_deducts_only_system_reserve(self, store): snapshot = make_snapshot(store) budget = _snapshot_message_budget( - snapshot, has_tools=False, has_attachments=False + snapshot, "primary", has_tools=False, has_attachments=False ) message_budget = 1015808 - 4096 assert budget.input_limit == 1015808 @@ -116,12 +160,32 @@ class TestSnapshotMessageBudget: def test_tools_and_attachments_deduct_their_reserves(self, store): snapshot = make_snapshot(store) tools_only = _snapshot_message_budget( - snapshot, has_tools=True, has_attachments=False + snapshot, "primary", has_tools=True, has_attachments=False + ) + full = _snapshot_message_budget( + snapshot, "primary", has_tools=True, has_attachments=True ) - full = _snapshot_message_budget(snapshot, has_tools=True, has_attachments=True) assert tools_only.hard_tokens == int((1015808 - 4096 - 8192) * 0.90) assert full.hard_tokens == int((1015808 - 4096 - 8192 - 4096) * 0.90) + def test_auxiliary_role_uses_auxiliary_frozen_limits(self, tmp_path): + """T6 review fix: the budget role is threaded through, not hardcoded + to ``primary`` — a scheduler (auxiliary role) must size its budget + from the auxiliary model's frozen configuration.""" + store = _make_split_limit_store(tmp_path / "model-runtime") + snapshot = make_snapshot(store) + + primary_budget = _snapshot_message_budget( + snapshot, "primary", has_tools=False, has_attachments=False + ) + auxiliary_budget = _snapshot_message_budget( + snapshot, "auxiliary", has_tools=False, has_attachments=False + ) + + assert primary_budget.input_limit == 1015808 + assert auxiliary_budget.input_limit == 32768 + assert auxiliary_budget.hard_tokens == int((32768 - 4096) * 0.90) + class TestSnapshotModeMiddleware: def test_budget_uses_frozen_reserves_not_model_profile(self, store, runtime): @@ -190,12 +254,33 @@ class TestSnapshotModeMiddleware: assert budget.has_tools is False assert budget.hard_tokens == int((1015808 - 4096) * 0.90) - def test_snapshot_binding_is_verified(self, store, runtime): - from EvoScientist.model_registry.errors import ( - SNAPSHOT_NOT_FOUND, - ModelRegistryError, + def test_auxiliary_snapshot_role_sizes_budget_from_auxiliary(self, tmp_path): + """T6 review fix: ``snapshot_role="auxiliary"`` (scheduler) sizes the + budget from the snapshot's auxiliary frozen configuration.""" + store = _make_split_limit_store(tmp_path / "model-runtime") + runtime = SnapshotRuntime(store) + middleware = create_message_budget_middleware( + MagicMock(), + MagicMock(), + has_tools=False, + snapshot_role="auxiliary", + runtime=runtime, ) + snapshot = make_snapshot(store) + with _patched_config(_configurable_for(snapshot)): + budget = middleware._budget_for_request(_request([HumanMessage("hi")])) + + assert budget.input_limit == 32768 + assert budget.hard_tokens == int((32768 - 4096) * 0.90) + + def test_unknown_snapshot_role_rejected(self, runtime): + with pytest.raises(ValueError, match="Unknown model role"): + create_message_budget_middleware( + MagicMock(), MagicMock(), snapshot_role="bogus", runtime=runtime + ) + + def test_snapshot_binding_is_verified(self, store, runtime): middleware = create_message_budget_middleware( MagicMock(), MagicMock(), runtime=runtime ) @@ -232,59 +317,70 @@ class TestSnapshotModeMiddleware: # ============================================================================= -# Interim local mode (no snapshot ID in configurable) +# Local snapshot entry (section 8.1): no explicit snapshot ID # ============================================================================= -class TestInterimMode: - def test_budget_from_compile_time_profile(self, runtime): - model = MagicMock() - model.profile = {"max_input_tokens": 32_768} +class TestLocalSnapshotEntry: + def test_missing_snapshot_lazily_creates_one_bound_to_the_thread( + self, store, runtime + ): middleware = create_message_budget_middleware( - model, MagicMock(), has_tools=False, runtime=runtime + MagicMock(), MagicMock(), has_tools=False, runtime=runtime ) - with _patched_config({}): - budget = middleware._budget_for_request(_request([HumanMessage("hi")])) - policy = MessageReservePolicy() - assert budget.hard_tokens == policy.hard_budget(32_768, has_tools=False) - def test_rejects_when_reserves_exceed_model_limit(self, runtime): - model = MagicMock() - model.profile = { - "max_input_tokens": 28_672, - "min_effective_input_tokens": 4_096, - } - middleware = create_message_budget_middleware( - model, MagicMock(), runtime=runtime + with _patched_config({"thread_id": "cron-thread-1"}): + budget = middleware._budget_for_request(_request([HumanMessage("hi")])) + + assert budget.input_limit == 1015808 + # The lazy snapshot is persisted, bound to the run's own thread. + row = store.find_active_run_snapshot( + deployment_id="local", + thread_id="cron-thread-1", + run_request_id="auto:cron-thread-1", ) - request = _request( - [HumanMessage(content=[{"type": "image", "url": "https://x.test/a"}])] + assert row is not None + + def test_lazy_creation_is_shared_across_middleware_calls(self, store, runtime): + """The same thread converges on one snapshot (idempotent create).""" + middleware = create_message_budget_middleware( + MagicMock(), MagicMock(), runtime=runtime + ) + + with _patched_config({"thread_id": "cron-thread-2"}): + first = middleware._snapshot() + middleware._budget_for_request(_request([HumanMessage("hi")])) + second = middleware._snapshot() + + assert first.snapshot_id == second.snapshot_id + + def test_missing_snapshot_and_thread_id_fails_closed(self, runtime): + middleware = create_message_budget_middleware( + MagicMock(), MagicMock(), runtime=runtime ) with ( _patched_config({}), - pytest.raises( - ContextBudgetUnsatisfiableError, match="CONTEXT_BUDGET_UNSATISFIABLE" - ), + pytest.raises(ModelRegistryError) as excinfo, ): - middleware._budget_for_request(request) + middleware._budget_for_request(_request([HumanMessage("hi")])) + assert excinfo.value.code == MODEL_CONFIG_OUTSIDE_SNAPSHOT - def test_summarizer_falls_back_to_compile_time_model(self, runtime): - fallback = MagicMock() - middleware = create_message_budget_middleware( - fallback, MagicMock(), runtime=runtime + def test_bootstrap_registry_fails_with_not_ready(self, tmp_path): + """No 32K fallback: bootstrap registry → MODEL_REGISTRY_NOT_READY.""" + bootstrap_runtime = SnapshotRuntime( + ModelRuntimeStore(config_dir=tmp_path / "model-runtime") + ) + middleware = create_message_budget_middleware( + MagicMock(), MagicMock(), runtime=bootstrap_runtime ) - with _patched_config({}): - assert middleware.model is fallback - def test_outside_runnable_context_uses_interim_mode(self, runtime): - fallback = MagicMock() - middleware = create_message_budget_middleware( - fallback, MagicMock(), has_tools=False, runtime=runtime - ) - with _patched_config(None): - budget = middleware._budget_for_request(_request([HumanMessage("hi")])) - assert budget.input_limit == 32_768 + with ( + _patched_config({"thread_id": "cli-thread"}), + pytest.raises(ModelRegistryError) as excinfo, + ): + middleware._budget_for_request(_request([HumanMessage("hi")])) + assert excinfo.value.code == MODEL_REGISTRY_NOT_READY # ============================================================================= @@ -292,12 +388,22 @@ class TestInterimMode: # ============================================================================= -def test_budget_middleware_uses_message_threshold_and_safe_tool_cutoff(runtime): - model = MagicMock() - model.profile = {"max_input_tokens": 32_768} - middleware = create_message_budget_middleware( - model, MagicMock(), has_tools=False, runtime=runtime +def test_budget_middleware_uses_message_threshold_and_safe_tool_cutoff(tmp_path): + # A small context window keeps the trigger thresholds reachable in a test. + store = make_active_store( + tmp_path / "model-runtime-small", + context_window_tokens=32768, + max_output_tokens=8192, + min_effective_input_tokens=1024, + fixed_system_reserve_tokens=1024, + fixed_tools_reserve_tokens=2048, + fixed_attachments_reserve_tokens=1024, ) + runtime = SnapshotRuntime(store) + middleware = create_message_budget_middleware( + MagicMock(), MagicMock(), has_tools=False, runtime=runtime + ) + snapshot = make_snapshot(store) messages = [ HumanMessage(content="a" * 30_000), AIMessage( @@ -311,7 +417,7 @@ def test_budget_middleware_uses_message_threshold_and_safe_tool_cutoff(runtime): from EvoScientist.middleware.message_budget import _ACTIVE_BUDGET - with _patched_config({}): + with _patched_config(_configurable_for(snapshot)): # Simulate the per-call budget activation that wrap_model_call sets. token = _ACTIVE_BUDGET.set(middleware._budget_for_request(_request(messages))) try: @@ -323,3 +429,11 @@ def test_budget_middleware_uses_message_threshold_and_safe_tool_cutoff(runtime): # A cutoff never leaves the tool response without its matching AI tool call. if cutoff == 1: assert isinstance(messages[cutoff], AIMessage) + + +def test_active_budget_outside_model_call_fails_loudly(runtime): + middleware = create_message_budget_middleware( + MagicMock(), MagicMock(), runtime=runtime + ) + with pytest.raises(RuntimeError, match="outside a model call"): + middleware._active_budget() diff --git a/tests/test_minimax_integration.py b/tests/test_minimax_integration.py deleted file mode 100644 index a41bfbd..0000000 --- a/tests/test_minimax_integration.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Integration tests for MiniMax direct provider. - -These tests validate that the MiniMax provider can connect to the real -MiniMax API (api.minimaxi.com/anthropic by default) and produce chat completions. - -Requires MINIMAX_API_KEY environment variable to be set. -Optionally set MINIMAX_BASE_URL to https://api.minimax.io/anthropic for Global keys. -""" - -import os - -import pytest - -from EvoScientist.llm import get_chat_model - -pytestmark = pytest.mark.skipif( - not os.environ.get("MINIMAX_API_KEY"), - reason="MINIMAX_API_KEY not set", -) - - -class TestMiniMaxIntegration: - def test_minimax_m25_chat_completion(self): - """Test that MiniMax M2.5 can produce a chat completion.""" - model = get_chat_model("minimax-m2.5", provider="minimax", temperature=0) - response = model.invoke("Reply with exactly: hello") - assert response.content - assert len(response.content) > 0 - - def test_minimax_m25_highspeed_chat_completion(self): - """Test that MiniMax M2.5-highspeed can produce a chat completion.""" - model = get_chat_model( - "minimax-m2.5-highspeed", provider="minimax", temperature=0 - ) - response = model.invoke("Reply with exactly: world") - assert response.content - assert len(response.content) > 0 - - def test_minimax_with_full_model_id(self): - """Test using the full model ID directly.""" - model = get_chat_model("MiniMax-M2.5", provider="minimax", temperature=0) - response = model.invoke("What is 2+2? Answer with just the number.") - assert response.content - assert "4" in response.content - - def test_minimax_m27_chat_completion(self): - """Test that MiniMax M2.7 can produce a chat completion.""" - model = get_chat_model("minimax-m2.7", provider="minimax", temperature=0) - response = model.invoke("Reply with exactly: test") - assert response.content - assert len(response.content) > 0 diff --git a/tests/test_model_command.py b/tests/test_model_command.py deleted file mode 100644 index 02c21b6..0000000 --- a/tests/test_model_command.py +++ /dev/null @@ -1,941 +0,0 @@ -"""Tests for the /model command and extract_model_and_provider helper.""" - -from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - - -class TestExtractModelAndProvider: - """Unit tests for the argument parser helper.""" - - def test_known_model_no_provider(self): - from EvoScientist.commands.implementation.model import ( - extract_model_and_provider, - ) - - name, prov = extract_model_and_provider(["claude-sonnet-4-6"]) - assert name == "claude-sonnet-4-6" - assert prov == "anthropic" - - def test_known_model_with_provider_override(self): - from EvoScientist.commands.implementation.model import ( - extract_model_and_provider, - ) - - name, prov = extract_model_and_provider(["claude-sonnet-4-6", "openrouter"]) - assert name == "claude-sonnet-4-6" - assert prov == "openrouter" - - def test_unknown_model_no_provider_raises(self): - from EvoScientist.commands.implementation.model import ( - extract_model_and_provider, - ) - - with pytest.raises(ValueError, match="Unknown model"): - extract_model_and_provider(["nonexistent-model-xyz"]) - - def test_unknown_model_with_provider_still_raises(self): - from EvoScientist.commands.implementation.model import ( - extract_model_and_provider, - ) - - # Unknown models are always rejected, even with an explicit provider - with pytest.raises(ValueError, match="Unknown model"): - extract_model_and_provider(["my-custom-model", "custom-openai"]) - - def test_provider_override_on_known_model(self): - from EvoScientist.commands.implementation.model import ( - extract_model_and_provider, - ) - - # Known model with explicit provider override uses the override - name, prov = extract_model_and_provider(["claude-sonnet-4-6", "openrouter"]) - assert name == "claude-sonnet-4-6" - assert prov == "openrouter" - - def test_ollama_provider_accepts_arbitrary_name(self): - """Ollama models are locally-installed — the registry doesn't know - them. The ``ollama`` provider must pass any name through verbatim.""" - from EvoScientist.commands.implementation.model import ( - extract_model_and_provider, - ) - - name, prov = extract_model_and_provider(["llama3.3:8b", "ollama"]) - assert name == "llama3.3:8b" - assert prov == "ollama" - - def test_ollama_provider_accepts_dotted_tag(self): - from EvoScientist.commands.implementation.model import ( - extract_model_and_provider, - ) - - name, prov = extract_model_and_provider(["qwen3-coder-next:latest", "ollama"]) - assert name == "qwen3-coder-next:latest" - assert prov == "ollama" - - -class TestModelCommandUnknownModel: - """Verify error message for unknown models.""" - - async def test_unknown_model_shows_error(self): - from EvoScientist.commands.implementation.model import ModelCommand - - cmd = ModelCommand() - ui = MagicMock() - ui.supports_interactive = True - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - - ctx = MagicMock() - ctx.ui = ui - - with patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ): - await cmd.execute(ctx, ["nonexistent-model-xyz"]) - - ui.append_system.assert_called_once() - call_args = ui.append_system.call_args - assert "Unknown model" in call_args[0][0] - assert call_args[1]["style"] == "red" - - -class TestModelCommandPickerCancelled: - """Verify no-op when the interactive picker is cancelled.""" - - async def test_picker_returns_none(self): - from EvoScientist.commands.implementation.model import ModelCommand - - cmd = ModelCommand() - ui = MagicMock() - ui.supports_interactive = True - ui.wait_for_model_pick = AsyncMock(return_value=None) - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - - ctx = MagicMock() - ctx.ui = ui - - with patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ): - await cmd.execute(ctx, []) - - # No model switch should have happened - ui.append_system.assert_not_called() - - -class TestModelCommandSwitch: - """Verify a successful model switch updates config and rebuilds agent.""" - - async def test_switch_known_model(self): - from EvoScientist.commands.implementation.model import ModelCommand - - cmd = ModelCommand() - ui = MagicMock() - ui.supports_interactive = True - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - new_agent = MagicMock() - - ctx = MagicMock() - ctx.ui = ui - ctx.workspace_dir = "/tmp/test" - ctx.checkpointer = MagicMock() - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch("EvoScientist.EvoScientist._build_chat_model"), - patch("EvoScientist.EvoScientist.set_active_config") as set_cfg, - patch("EvoScientist.EvoScientist.set_chat_model_instance"), - patch( - "EvoScientist.cli.agent._load_agent", - return_value=new_agent, - ), - ): - await cmd.execute(ctx, ["claude-opus-4-8"]) - - # The switch is committed via set_active_config(temp_cfg), not by - # mutating the original cfg object in place. - set_cfg.assert_called_once() - committed = set_cfg.call_args[0][0] - assert committed.model == "claude-opus-4-8" - assert committed.provider == "anthropic" - - # Agent should be replaced on context - assert ctx.agent == new_agent - - # Success message shown - ui.append_system.assert_called_once() - msg = ui.append_system.call_args[0][0] - assert "claude-opus-4-8" in msg - assert "anthropic" in msg - - async def test_switch_with_save_flag(self): - from EvoScientist.commands.implementation.model import ModelCommand - - cmd = ModelCommand() - ui = MagicMock() - ui.supports_interactive = True - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - - ctx = MagicMock() - ctx.ui = ui - ctx.workspace_dir = "/tmp/test" - ctx.checkpointer = MagicMock() - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch("EvoScientist.EvoScientist._build_chat_model"), - patch("EvoScientist.EvoScientist.set_active_config"), - patch("EvoScientist.EvoScientist.set_chat_model_instance"), - patch( - "EvoScientist.cli.agent._load_agent", - return_value=MagicMock(), - ), - patch("EvoScientist.config.settings.set_config_value") as mock_save, - ): - await cmd.execute(ctx, ["claude-opus-4-8", "--save"]) - - # Config file should be updated - mock_save.assert_any_call("model", "claude-opus-4-8") - mock_save.assert_any_call("provider", "anthropic") - - # Success message should mention save - msg = ui.append_system.call_args[0][0] - assert "saved to config" in msg - - async def test_switch_without_save_flag_does_not_persist(self): - from EvoScientist.commands.implementation.model import ModelCommand - - cmd = ModelCommand() - ui = MagicMock() - ui.supports_interactive = True - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - - ctx = MagicMock() - ctx.ui = ui - ctx.workspace_dir = "/tmp/test" - ctx.checkpointer = MagicMock() - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch("EvoScientist.EvoScientist._build_chat_model"), - patch("EvoScientist.EvoScientist.set_active_config"), - patch("EvoScientist.EvoScientist.set_chat_model_instance"), - patch( - "EvoScientist.cli.agent._load_agent", - return_value=MagicMock(), - ), - patch("EvoScientist.config.settings.set_config_value") as mock_save, - ): - await cmd.execute(ctx, ["claude-opus-4-8"]) - - # Config file should NOT be updated - mock_save.assert_not_called() - - # Message should not mention save - msg = ui.append_system.call_args[0][0] - assert "saved to config" not in msg - - -class TestModelCommandFailure: - """Verify error handling when chat-model construction raises.""" - - async def test_build_chat_model_error(self): - from EvoScientist.commands.implementation.model import ModelCommand - - cmd = ModelCommand() - ui = MagicMock() - ui.supports_interactive = True - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - - ctx = MagicMock() - ctx.ui = ui - ctx.workspace_dir = "/tmp/test" - ctx.checkpointer = MagicMock() - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch( - "EvoScientist.EvoScientist._build_chat_model", - side_effect=RuntimeError("API key missing"), - ) as mock_build, - ): - await cmd.execute(ctx, ["claude-opus-4-8"]) - - mock_build.assert_called_once() - ui.append_system.assert_called_once() - call_args = ui.append_system.call_args - assert "Failed to switch model" in call_args[0][0] - assert call_args[1]["style"] == "red" - - -@pytest.fixture -def evo_module_state(): - """Snapshot and restore ``EvoScientist.EvoScientist`` module globals. - - The chat-model cache tests mutate ``_chat_model`` / ``_chat_model_key`` - / ``_config`` / ``_EvoScientist_agent`` directly. This fixture - guarantees all four are restored — even if a test body grows an early - return — so later tests in the suite see a clean module state. - """ - import EvoScientist.EvoScientist as mod - - snapshot = ( - mod._chat_model, - mod._chat_model_key, - mod._config, - mod._EvoScientist_agent, - ) - try: - yield mod - finally: - ( - mod._chat_model, - mod._chat_model_key, - mod._config, - mod._EvoScientist_agent, - ) = snapshot - - -class TestEnsureChatModelCacheInvalidation: - """Regression tests for issue #179: /model switch lagged by one step. - - Root cause: ``_ensure_chat_model()`` returned a cached ``_chat_model`` - without checking whether ``cfg.model`` / ``cfg.provider`` had changed - since the cache was populated. ``ModelCommand._apply_model`` builds - a new agent *before* ``set_chat_model`` runs, so the new agent was - bound to the *previous* cached model. - - Fix: track a ``(model, provider)`` key alongside ``_chat_model`` and - rebuild on mismatch. - """ - - def test_cache_rebuilds_when_config_model_changes(self, evo_module_state): - """After cfg.model changes, _ensure_chat_model must return a new instance.""" - mod = evo_module_state - - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - m1 = MagicMock(name="model-1") - m2 = MagicMock(name="model-2") - - mod._chat_model = None - mod._chat_model_key = None - mod._config = cfg - mod._EvoScientist_agent = None - - with patch("EvoScientist.llm.get_chat_model", side_effect=[m1, m2]) as gm: - first = mod._ensure_chat_model() - assert first is m1 - # Same config → cache hit, no rebuild. - again = mod._ensure_chat_model() - assert again is m1 - assert gm.call_count == 1 - - # Simulate /model switch writing the new choice into cfg. - cfg.model = "minimax-m2.7" - cfg.provider = "openrouter" - - second = mod._ensure_chat_model() - # Must be the NEW model instance, not the cached one. - assert second is m2 - assert second is not first - assert gm.call_count == 2 - # Second call used the new model name + provider. - _, kwargs = gm.call_args - assert kwargs == {"model": "minimax-m2.7", "provider": "openrouter"} - - def test_cache_rebuilds_when_only_provider_changes(self, evo_module_state): - """Same model name, different provider (openrouter vs anthropic) must rebuild.""" - mod = evo_module_state - - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - m1 = MagicMock(name="anthropic-model") - m2 = MagicMock(name="openrouter-model") - - mod._chat_model = None - mod._chat_model_key = None - mod._config = cfg - mod._EvoScientist_agent = None - - with patch("EvoScientist.llm.get_chat_model", side_effect=[m1, m2]) as gm: - assert mod._ensure_chat_model() is m1 - cfg.provider = "openrouter" - assert mod._ensure_chat_model() is m2 - assert gm.call_count == 2 - - def test_set_chat_model_updates_key(self, evo_module_state): - """set_chat_model must keep _chat_model_key in sync to avoid - an extra rebuild on the very next _ensure_chat_model() call.""" - mod = evo_module_state - - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - m_set = MagicMock(name="explicit-set-model") - - mod._chat_model = None - mod._chat_model_key = None - mod._config = cfg - mod._EvoScientist_agent = None - - with patch("EvoScientist.llm.get_chat_model", return_value=m_set) as gm: - mod.set_chat_model("minimax-m2.7", provider="openrouter") - assert mod._chat_model is m_set - assert mod._chat_model_key == ("minimax-m2.7", "openrouter") - - # Align cfg to what set_chat_model was called with. - cfg.model = "minimax-m2.7" - cfg.provider = "openrouter" - # Now _ensure_chat_model should NOT rebuild (key matches cfg). - assert mod._ensure_chat_model() is m_set - assert gm.call_count == 1 - - def test_set_chat_model_is_no_op_when_key_already_matches(self, evo_module_state): - """set_chat_model must NOT rebuild when the cache already holds the - requested (model, provider). - - Under the /model flow, ``_load_agent`` already rebuilt ``_chat_model`` - via ``_ensure_chat_model`` before ``set_chat_model`` is reached, so - the subsequent set should be idempotent — reusing the same Python - instance (and thus the same underlying HTTP client) that ``ctx.agent`` - is already bound to. - """ - mod = evo_module_state - - existing = MagicMock(name="existing-model") - mod._chat_model = existing - mod._chat_model_key = ("minimax-m2.7", "openrouter") - mod._config = SimpleNamespace(model="minimax-m2.7", provider="openrouter") - mod._EvoScientist_agent = None - - with patch("EvoScientist.llm.get_chat_model") as gm: - returned = mod.set_chat_model("minimax-m2.7", provider="openrouter") - - # Fast path: returned the EXISTING instance, no rebuild. - assert returned is existing - assert mod._chat_model is existing - gm.assert_not_called() - - -class TestApplyModelIntegration: - """End-to-end regression for #179 + #183: `_apply_model` must produce an - agent bound to the NEW model, threaded in via the pure ``create_cli_agent`` - path. - - Exercises the real chain: - ``_apply_model → _build_chat_model → _load_agent(chat_model=...) → - set_active_config / set_chat_model_instance`` - - Only ``_load_agent`` is faked (to avoid spinning up deepagents, MCP tools, - middleware, and subagent YAML); it binds the ``chat_model`` it receives. - ``get_chat_model`` returns a distinct sentinel per ``(model, provider)`` - pair so we can assert on identity. - """ - - async def test_new_agent_is_bound_to_newly_selected_model(self, evo_module_state): - from EvoScientist.commands.implementation.model import ModelCommand - from EvoScientist.config.settings import EvoScientistConfig - - mod = evo_module_state - - sentinels: dict[tuple[str, str | None], MagicMock] = {} - - def _fake_get_chat_model(model, provider=None): - key = (model, provider) - if key not in sentinels: - sentinels[key] = MagicMock(name=f"chat_model[{model}|{provider}]") - sentinels[key]._bound_model = model - sentinels[key]._bound_provider = provider - return sentinels[key] - - def _fake_load_agent( - workspace_dir=None, - checkpointer=None, - config=None, - chat_model=None, - *, - on_mcp_progress=None, - ): - # The pure path threads the freshly built chat model in; bind it - # directly instead of re-deriving via _ensure_chat_model. - agent = MagicMock(name="fake-agent") - agent._bound_model = chat_model - return agent - - cfg = EvoScientistConfig(model="claude-sonnet-4-6", provider="anthropic") - ctx = MagicMock() - ctx.ui = MagicMock() - ctx.ui.supports_interactive = True - ctx.workspace_dir = "/tmp/test_integration" - ctx.checkpointer = None - - # Prime: _chat_model already holds the OLD (default) model — - # this is the state that caused the off-by-one in production. - mod._config = cfg - mod._chat_model = _fake_get_chat_model("claude-sonnet-4-6", "anthropic") - mod._chat_model_key = ("claude-sonnet-4-6", "anthropic") - mod._EvoScientist_agent = None - old_model = mod._chat_model - - with ( - patch( - "EvoScientist.llm.get_chat_model", - side_effect=_fake_get_chat_model, - ), - patch( - "EvoScientist.cli.agent._load_agent", - side_effect=_fake_load_agent, - ), - ): - cmd = ModelCommand() - await cmd._apply_model(ctx, "minimax-m2.7", "openrouter") - - # The agent produced by _apply_model must be bound to the - # NEWLY requested model, threaded in via chat_model=. - assert ctx.agent._bound_model is not old_model - assert ctx.agent._bound_model._bound_model == "minimax-m2.7" - assert ctx.agent._bound_model._bound_provider == "openrouter" - - # Global state reflects the switch end-to-end, committed via the setters. - assert mod._chat_model_key == ("minimax-m2.7", "openrouter") - assert mod._chat_model is sentinels[("minimax-m2.7", "openrouter")] - # The switch is applied to the LIVE cfg in place and ``_config`` stays - # bound to that same object, so callers holding the active config by - # reference (e.g. serve's ``agent_holder["config"]``) observe the swap. - assert mod._config is cfg - assert cfg.model == "minimax-m2.7" - assert cfg.provider == "openrouter" - - # User-visible success message. - msg = ctx.ui.append_system.call_args[0][0] - assert "minimax-m2.7" in msg - assert "openrouter" in msg - - -class TestApplyModelPreservesConfigByReference: - """Regression for the din0s review on #267: serve mode (and any long-lived - caller) holds the active config object by reference via - ``agent_holder["config"]``. The pure-path commit must apply the switch to - that LIVE object in place — not rebind ``_config`` to a fresh ``temp_cfg`` - copy — otherwise a later workspace-changing ``/resume`` reloads the agent - from the stale startup config and silently reverts the ``/model`` switch. - - Critically this must hold across *repeated* switches: the earlier - "also mutate cfg but still rebind to temp_cfg" remedy only survives one - switch (the held object stops being the active ``_config`` after the first). - """ - - async def test_held_config_reference_tracks_repeated_switches( - self, evo_module_state - ): - from EvoScientist.commands.implementation.model import ModelCommand - from EvoScientist.config.settings import EvoScientistConfig - - mod = evo_module_state - - def _fake_get_chat_model(model, provider=None): - m = MagicMock(name=f"chat_model[{model}|{provider}]") - m._bound_model = model - return m - - def _fake_load_agent( - workspace_dir=None, - checkpointer=None, - config=None, - chat_model=None, - *, - on_mcp_progress=None, - ): - return MagicMock(name="fake-agent") - - cfg = EvoScientistConfig(model="claude-sonnet-4-6", provider="anthropic") - mod._config = cfg - mod._chat_model = None - mod._chat_model_key = None - mod._EvoScientist_agent = None - - # Simulate serve capturing the startup config object once (commands.py: - # ``agent_holder = {... "config": config}``) and never re-reading it. - agent_holder = {"config": cfg} - - ctx = MagicMock() - ctx.ui = MagicMock() - ctx.ui.supports_interactive = True - ctx.workspace_dir = "/tmp/test_byref" - ctx.checkpointer = None - - cmd = ModelCommand() - with ( - patch( - "EvoScientist.llm.get_chat_model", - side_effect=_fake_get_chat_model, - ), - patch( - "EvoScientist.cli.agent._load_agent", - side_effect=_fake_load_agent, - ), - ): - for model, provider in [ - ("claude-opus-4-8", "anthropic"), - ("minimax-m2.7", "openrouter"), - ("claude-sonnet-4-6", "anthropic"), - ]: - await cmd._apply_model(ctx, model, provider) - # The held reference must reflect the LATEST switch on every - # iteration — not just the first — and stay the active config. - assert agent_holder["config"].model == model - assert agent_holder["config"].provider == provider - assert mod._config is agent_holder["config"] - - -class TestModelCommandLoadAgentFailure: - """Verify the transactional ordering: when ``_load_agent`` raises, - nothing downstream (``set_chat_model``, ``cfg`` mutation, - ``set_config_value``) should happen. - - This is the core guarantee of the refactor that established - "build agent first, commit state only on success". Without this test - the ordering could silently regress (e.g. if ``_apply_model`` were - reordered to call ``set_chat_model`` first).""" - - async def test_load_agent_error_is_transactional(self): - from EvoScientist.commands.implementation.model import ModelCommand - - cmd = ModelCommand() - ui = MagicMock() - ui.supports_interactive = True - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - - ctx = MagicMock() - ctx.ui = ui - ctx.workspace_dir = "/tmp/test" - ctx.checkpointer = MagicMock() - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch("EvoScientist.EvoScientist._build_chat_model"), - patch( - "EvoScientist.cli.agent._load_agent", - side_effect=RuntimeError("agent build failed"), - ) as mock_load, - patch( - "EvoScientist.EvoScientist.set_active_config", - ) as mock_set_cfg, - patch( - "EvoScientist.EvoScientist.set_chat_model_instance", - ) as mock_set_model, - patch( - "EvoScientist.config.settings.set_config_value", - ) as mock_save, - ): - # Pass ``--save`` to strengthen the assertion: if the ordering - # ever regresses, ``set_config_value`` would be called with - # stale data. - await cmd.execute(ctx, ["claude-opus-4-8", "--save"]) - - # _load_agent was attempted (transactional first step). - mock_load.assert_called_once() - # Commit setters and downstream side-effects must NOT have happened. - mock_set_cfg.assert_not_called() - mock_set_model.assert_not_called() - mock_save.assert_not_called() - # Config must be untouched. - assert cfg.model == "claude-sonnet-4-6" - assert cfg.provider == "anthropic" - # User sees a red error message. - ui.append_system.assert_called_once() - call_args = ui.append_system.call_args - assert "Failed to switch model" in call_args[0][0] - assert call_args[1]["style"] == "red" - - -class TestApplyModelLoadAgentFailureTransactional: - """Regression for #183: when agent construction fails, the session stays on - the original model with no snapshot/restore. - - Because ``create_cli_agent(config=..., chat_model=...)`` is now pure — it - writes none of the four config/model globals — and ``_apply_model`` commits - only after a successful build, a failing ``_load_agent`` leaves all four - globals untouched. This replaces the old snapshot/restore rollback test. - - Complements :class:`TestModelCommandLoadAgentFailure`, which asserts the - downstream setters never run on failure. - """ - - async def test_globals_unchanged_when_load_agent_raises(self, evo_module_state): - from EvoScientist.commands.implementation.model import ModelCommand - from EvoScientist.config.settings import EvoScientistConfig - - mod = evo_module_state - - def _fake_load_agent( - workspace_dir=None, - checkpointer=None, - config=None, - chat_model=None, - *, - on_mcp_progress=None, - ): - # The pure path writes no globals; mimic a failure partway through - # agent wiring (middleware build, deepagents, MCP reconnect, ...). - raise RuntimeError("middleware build failed") - - cfg = EvoScientistConfig(model="claude-sonnet-4-6", provider="anthropic") - old_model = MagicMock(name="old-model") - old_agent = MagicMock(name="old-default-agent") - - mod._config = cfg - mod._chat_model = old_model - mod._chat_model_key = ("claude-sonnet-4-6", "anthropic") - mod._EvoScientist_agent = old_agent - - ctx = MagicMock() - ctx.ui = MagicMock() - ctx.ui.supports_interactive = True - ctx.workspace_dir = "/tmp/test_rollback" - ctx.checkpointer = None - - with ( - patch( - "EvoScientist.EvoScientist._build_chat_model", - return_value=MagicMock(name="new-model"), - ), - patch( - "EvoScientist.cli.agent._load_agent", - side_effect=_fake_load_agent, - ), - ): - cmd = ModelCommand() - await cmd._apply_model(ctx, "minimax-m2.7", "openrouter") - - # All four globals are unchanged — nothing was committed. - assert mod._config is cfg - assert mod._chat_model is old_model - assert mod._chat_model_key == ("claude-sonnet-4-6", "anthropic") - assert mod._EvoScientist_agent is old_agent - # The ``cfg`` object itself was not mutated. - assert cfg.model == "claude-sonnet-4-6" - assert cfg.provider == "anthropic" - # User sees an error message. - msg = ctx.ui.append_system.call_args[0][0] - assert "Failed to switch model" in msg - - -class TestModelCommandOllamaPicker: - """Verify Ollama discovery augments the picker entries and the sentinel - is always present when Ollama is configured.""" - - @pytest.fixture(autouse=True) - def _isolate_provider_registry(self, tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - - def _make_ctx_and_cfg(self, *, ollama_base_url: str | None): - cfg = SimpleNamespace( - model="claude-sonnet-4-6", - provider="anthropic", - ollama_base_url=ollama_base_url, - ) - ui = MagicMock() - ui.supports_interactive = True - ui.wait_for_model_pick = AsyncMock(return_value=None) - ctx = MagicMock() - ctx.ui = ui - return ctx, cfg, ui - - async def test_picker_entries_include_detected_ollama_models(self): - """When Ollama is reachable, detected models appear in entries with - provider='ollama' and the Custom sentinel is appended.""" - from EvoScientist.commands.implementation.model import ModelCommand - - ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") - - async def fake_discover(base_url, *, timeout): - return ["llama3.3:latest", "qwen3:8b"] - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch( - "EvoScientist.llm.ollama_discovery.discover_ollama_models", - side_effect=fake_discover, - ), - ): - await ModelCommand().execute(ctx, []) - - entries = ui.wait_for_model_pick.call_args[0][0] - ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"] - assert ("llama3.3:latest", "llama3.3:latest", "ollama") in ollama_rows - assert ("qwen3:8b", "qwen3:8b", "ollama") in ollama_rows - assert ( - "Custom Ollama model...", - "__custom_ollama__", - "ollama", - ) in ollama_rows - - async def test_picker_entries_include_sentinel_when_discovery_empty(self): - """Daemon unreachable / no models pulled — sentinel is the user's - escape hatch and must always be present.""" - from EvoScientist.commands.implementation.model import ModelCommand - - ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") - - async def fake_discover(base_url, *, timeout): - return [] - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch( - "EvoScientist.llm.ollama_discovery.discover_ollama_models", - side_effect=fake_discover, - ), - ): - await ModelCommand().execute(ctx, []) - - entries = ui.wait_for_model_pick.call_args[0][0] - ollama_rows = [(n, mid, p) for (n, mid, p) in entries if p == "ollama"] - assert ollama_rows == [ - ("Custom Ollama model...", "__custom_ollama__", "ollama") - ] - - async def test_picker_skips_ollama_section_when_not_configured(self): - """ollama_base_url unset → no discovery call, no ollama entries, - no sentinel (issue non-goal: no implicit localhost detection).""" - from EvoScientist.commands.implementation.model import ModelCommand - - ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="") - - discovery = AsyncMock(return_value=["should-never-appear"]) - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch( - "EvoScientist.llm.ollama_discovery.discover_ollama_models", - discovery, - ), - ): - await ModelCommand().execute(ctx, []) - - discovery.assert_not_called() - entries = ui.wait_for_model_pick.call_args[0][0] - assert not any(p == "ollama" for (_, _, p) in entries) - - async def test_picker_handles_cfg_without_ollama_base_url_attr(self): - """getattr(cfg, 'ollama_base_url', None) fallback: old configs - (or SimpleNamespace test fixtures) may not carry the attribute - at all. Must not raise AttributeError, must not probe.""" - from EvoScientist.commands.implementation.model import ModelCommand - - # Deliberately omit ollama_base_url from the namespace. - cfg = SimpleNamespace(model="claude-sonnet-4-6", provider="anthropic") - ui = MagicMock() - ui.supports_interactive = True - ui.wait_for_model_pick = AsyncMock(return_value=None) - ctx = MagicMock() - ctx.ui = ui - - discovery = AsyncMock(return_value=["should-never-appear"]) - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch( - "EvoScientist.llm.ollama_discovery.discover_ollama_models", - discovery, - ), - ): - await ModelCommand().execute(ctx, []) - - discovery.assert_not_called() - entries = ui.wait_for_model_pick.call_args[0][0] - assert not any(p == "ollama" for (_, _, p) in entries) - - async def test_picker_sentinel_result_is_treated_as_cancel(self): - """Defense-in-depth: if the widget ever returns the sentinel name - itself (shouldn't happen — it should substitute the typed name), - dispatch treats it as a cancel and does NOT call _apply_model.""" - from EvoScientist.commands.implementation.model import ModelCommand - - ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") - ui.wait_for_model_pick = AsyncMock(return_value=("__custom_ollama__", "ollama")) - - async def fake_discover(base_url, *, timeout): - return [] - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch( - "EvoScientist.llm.ollama_discovery.discover_ollama_models", - side_effect=fake_discover, - ), - patch("EvoScientist.cli.agent._load_agent") as load_agent, - ): - await ModelCommand().execute(ctx, []) - - load_agent.assert_not_called() - assert cfg.model == "claude-sonnet-4-6" # unchanged - - async def test_picker_applies_detected_ollama_model(self): - """User picks a live-detected Ollama model → _apply_model is invoked - with (name, "ollama") and the agent is rebuilt.""" - from EvoScientist.commands.implementation.model import ModelCommand - - ctx, cfg, ui = self._make_ctx_and_cfg(ollama_base_url="http://localhost:11434") - ctx.workspace_dir = "/tmp/test" - ctx.checkpointer = MagicMock() - ui.wait_for_model_pick = AsyncMock(return_value=("llama3.3", "ollama")) - - async def fake_discover(base_url, *, timeout): - return ["llama3.3"] - - with ( - patch( - "EvoScientist.EvoScientist._ensure_config", - return_value=cfg, - ), - patch( - "EvoScientist.llm.ollama_discovery.discover_ollama_models", - side_effect=fake_discover, - ), - patch("EvoScientist.EvoScientist._build_chat_model"), - patch("EvoScientist.EvoScientist.set_active_config") as set_cfg, - patch("EvoScientist.EvoScientist.set_chat_model_instance"), - patch( - "EvoScientist.cli.agent._load_agent", - return_value=MagicMock(), - ), - ): - await ModelCommand().execute(ctx, []) - - # Committed via set_active_config(temp_cfg); original cfg untouched. - set_cfg.assert_called_once() - committed = set_cfg.call_args[0][0] - assert committed.model == "llama3.3" - assert committed.provider == "ollama" diff --git a/tests/test_model_passthrough_patch.py b/tests/test_model_passthrough_patch.py index ee9c74d..6209d58 100644 --- a/tests/test_model_passthrough_patch.py +++ b/tests/test_model_passthrough_patch.py @@ -21,6 +21,13 @@ import pytest from EvoScientist.llm import patches as patches_mod + +@pytest.fixture(autouse=True) +def _active_registry(active_snapshot_runtime): + """The patch freezes a local run snapshot per child thread; that needs an + active registry (bootstrap fails closed with MODEL_REGISTRY_NOT_READY).""" + return active_snapshot_runtime + # ============================================================================= # Helpers # ============================================================================= @@ -348,7 +355,13 @@ class TestNonInterceptedMethods: class TestNoContextNoConfig: - def test_no_scope_no_config_kwarg(self, restore_model_passthrough_patch): + def test_no_scope_only_snapshot_config(self, restore_model_passthrough_patch): + """No inherited scope → only the frozen child snapshot ID is added. + + Since the snapshot-driven migration (design doc 8.1/8.2), every + child-thread ``runs.create`` carries ``runtime_snapshot_id``; scope + keys are still only forwarded when present in the parent context. + """ try: from deepagents.middleware import async_subagents as ds_mod except ImportError: @@ -366,8 +379,17 @@ class TestNoContextNoConfig: runs_sync.create.assert_called_once() kwargs = runs_sync.create.call_args.kwargs - # No config kwarg should be added when there's nothing to inherit. - assert "config" not in kwargs + configurable = kwargs["config"]["configurable"] + assert "runtime_snapshot_id" in configurable + for key in ( + "workspace_scope_id", + "workspace_scope_owner_id", + "workspace_scope_revision", + "workspace_deployment_id", + ): + assert key not in configurable + assert "model" not in configurable + assert "model_provider" not in configurable # ============================================================================= @@ -412,6 +434,7 @@ class TestPreserveExistingConfig: {"thread_id": "t1", "config": _Sentinel()} ) assert merged["thread_id"] == "t1" - # Non-dict input was replaced with a fresh dict; nothing is injected - # when there is no runnable context to inherit from. - assert merged["config"] == {"configurable": {}} + # Non-dict input was replaced with a fresh dict; only the frozen + # child snapshot ID is injected when there is no context to inherit. + configurable = merged["config"]["configurable"] + assert set(configurable) == {"runtime_snapshot_id"} diff --git a/tests/test_onboard.py b/tests/test_onboard.py index 2d7c233..746b928 100644 --- a/tests/test_onboard.py +++ b/tests/test_onboard.py @@ -52,16 +52,12 @@ def _patch_all_questionary(mock_q): class TestConstants: - def test_steps_has_thirteen_items(self): - """Test that STEPS contains exactly 13 steps.""" - assert len(STEPS) == 13 + def test_steps_has_nine_items(self): + """Test that STEPS contains exactly 9 steps.""" + assert len(STEPS) == 9 assert STEPS == [ "UI", "LangGraph Port", - "Provider", - "API Key", - "Model", - "Auxiliary Model", "Tavily Key", "Workspace", "Thinking", @@ -92,44 +88,11 @@ class TestSharedConstantsAlignment: """Drift guard: the canonical valid-value sets in ``EvoScientist.config.onboard.constants`` must match the actual ``Choice(value=...)`` ids built by the interactive step functions in - ``steps.py``. Without this, adding a new provider to one file but not + ``steps.py``. Without this, adding a new value to one file but not the other would silently break either CLI flag validation or the interactive picker. """ - def test_provider_constants_match_step_choices(self): - """Every value in `_step_provider`'s Choice list must be in - ``VALID_PROVIDERS`` — and vice versa.""" - from unittest.mock import MagicMock, patch - - from EvoScientist.config.onboard.constants import VALID_PROVIDERS - from EvoScientist.config.onboard.steps import _step_provider - - # Intercept questionary.select to capture the choices list before - # any user prompt happens. - captured = {} - - def _capture(*args, **kwargs): - captured["choices"] = kwargs.get("choices") or ( - args[1] if len(args) > 1 else [] - ) - fake = MagicMock() - fake.ask.return_value = "anthropic" - return fake - - with patch( - "EvoScientist.config.onboard.steps.questionary.select", - side_effect=_capture, - ): - _step_provider(EvoScientistConfig()) - - actual_provider_ids = {c.value for c in captured["choices"]} - assert actual_provider_ids == set(VALID_PROVIDERS), ( - "VALID_PROVIDERS in constants.py drifted from _step_provider's " - f"choices. Only-in-constants: {set(VALID_PROVIDERS) - actual_provider_ids}; " - f"only-in-choices: {actual_provider_ids - set(VALID_PROVIDERS)}" - ) - def test_ui_constants_match_step_choices(self): from EvoScientist.config.onboard.constants import VALID_UI_BACKENDS @@ -142,79 +105,6 @@ class TestSharedConstantsAlignment: assert VALID_WORKSPACE_MODES == frozenset({"daemon", "run"}) - def test_valid_providers_aligns_with_provider_key_attr(self): - """Every provider in ``VALID_PROVIDERS`` (except ``openai`` and - ``ollama``, which the wizard handles via fallbacks) must appear in - ``_PROVIDER_KEY_ATTR`` — otherwise the auth-mode flow won't know - which config attribute to write the validated key to.""" - from EvoScientist.config.onboard.constants import VALID_PROVIDERS - from EvoScientist.config.onboard.wizard import _PROVIDER_KEY_ATTR - - expected = set(VALID_PROVIDERS) - {"openai", "ollama"} - missing = expected - set(_PROVIDER_KEY_ATTR.keys()) - assert not missing, ( - "VALID_PROVIDERS has providers missing from _PROVIDER_KEY_ATTR " - f"in wizard.py: {missing}" - ) - extra = set(_PROVIDER_KEY_ATTR.keys()) - expected - assert not extra, ( - f"_PROVIDER_KEY_ATTR in wizard.py has keys not in VALID_PROVIDERS: {extra}" - ) - - def test_valid_providers_aligns_with_provider_key_info(self): - """``_provider_key_info`` uses ``mapping.get(provider, )``, so an unknown provider silently behaves like OpenAI. - Verify every non-``openai`` provider in ``VALID_PROVIDERS`` has an - explicit entry — detected by checking the returned display name is - not the OpenAI fallback string.""" - from EvoScientist.config.onboard.constants import VALID_PROVIDERS - from EvoScientist.config.onboard.helpers import _provider_key_info - - cfg = EvoScientistConfig() - for provider in VALID_PROVIDERS: - display_name, _, _ = _provider_key_info(cfg, provider) - if provider == "openai": - assert display_name == "OpenAI" - else: - assert display_name != "OpenAI", ( - f"Provider {provider!r} is missing from _provider_key_info " - "in helpers.py — it falls through to the OpenAI default, " - "which would silently send the wrong validator and " - "current-key lookup." - ) - - -class TestOAuthModeReconcile: - def test_reconcile_preserves_auxiliary_openai_oauth(self): - from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes - - config = EvoScientistConfig( - provider="minimax", - auxiliary_provider="openai", - auxiliary_model="gpt-5.5", - openai_auth_mode="oauth", - anthropic_auth_mode="oauth", - ) - - _reconcile_oauth_modes(config) - - assert config.openai_auth_mode == "oauth" - assert config.anthropic_auth_mode == "api_key" - - def test_reconcile_preserves_auxiliary_provider_without_model(self): - from EvoScientist.config.onboard.wizard import _reconcile_oauth_modes - - config = EvoScientistConfig( - provider="minimax", - auxiliary_provider="openai", - auxiliary_model="", - openai_auth_mode="oauth", - ) - - _reconcile_oauth_modes(config) - - assert config.openai_auth_mode == "oauth" - # ============================================================================= # Test render_progress @@ -229,7 +119,7 @@ class TestRenderProgress: assert panel.title is not None # The renderable content should contain step names content_str = str(panel.renderable) - assert "Provider" in content_str + assert "Tavily Key" in content_str def test_renders_completed_steps(self): """Test that completed steps are marked differently.""" @@ -378,206 +268,6 @@ class TestChoiceValidator: # ============================================================================= -class TestStepProvider: - def test_returns_selected_provider(self): - """Test that _step_provider returns selected provider.""" - from EvoScientist.config.onboard.steps import _step_provider - - config = EvoScientistConfig() - - with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: - mock_q.select.return_value.ask.return_value = "anthropic" - result = _step_provider(config) - - assert result == "anthropic" - mock_q.select.assert_called_once() - - def test_default_value_and_label_override(self): - """default_value preselects a provider (re-run co-pilot default) and - label customizes the prompt text.""" - from EvoScientist.config.onboard.steps import _step_provider - - config = EvoScientistConfig(provider="anthropic") - with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: - mock_q.select.return_value.ask.return_value = "openai" - _step_provider(config, label="co-pilot", default_value="openrouter") - - call = mock_q.select.call_args - assert call.kwargs["default"] == "openrouter" # override, not config.provider - assert "co-pilot" in call.args[0] - - def test_raises_keyboard_interrupt_on_cancel(self): - """Test that _step_provider raises KeyboardInterrupt on cancel.""" - from EvoScientist.config.onboard.steps import _step_provider - - config = EvoScientistConfig() - - with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: - mock_q.select.return_value.ask.return_value = None - with pytest.raises(KeyboardInterrupt): - _step_provider(config) - - -class TestStepOAuthAuthMode: - @pytest.mark.parametrize( - ( - "step_name", - "config_attr", - "provider_label", - "oauth_choice_label", - "ccproxy_provider", - "status_label", - "question_label", - "login_prompt", - ), - [ - ( - "_step_anthropic_auth_mode", - "anthropic_auth_mode", - "Anthropic", - "Claude Code OAuth", - "claude_api", - "OAuth", - "Authentication mode", - "Log in to Claude now?", - ), - ( - "_step_openai_auth_mode", - "openai_auth_mode", - "OpenAI", - "Codex OAuth", - "codex", - "Codex OAuth", - "OpenAI authentication mode", - "Log in to Codex now?", - ), - ], - ) - def test_oauth_wrappers_use_provider_specific_ccproxy_flow( - self, - step_name, - config_attr, - provider_label, - oauth_choice_label, - ccproxy_provider, - status_label, - question_label, - login_prompt, - ): - """Anthropic/OpenAI wrappers share flow but keep provider-specific IDs.""" - from EvoScientist.config.onboard import steps as onboard_steps - - config = EvoScientistConfig(**{config_attr: "oauth"}) - select_question = MagicMock() - select_question.ask.return_value = "oauth" - confirm_question = MagicMock() - confirm_question.ask.return_value = True - - with ( - patch( - "EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True - ), - patch( - "EvoScientist.ccproxy_manager.check_ccproxy_auth", - return_value=(False, "not authenticated"), - ) as mock_check_auth, - patch( - "EvoScientist.config.onboard.prompter.install_navigation_keys" - ) as mock_nav, - patch( - "EvoScientist.config.onboard.steps.questionary.select", - return_value=select_question, - ) as mock_select, - patch( - "EvoScientist.config.onboard.steps.questionary.confirm", - return_value=confirm_question, - ) as mock_confirm, - patch( - "EvoScientist.config.onboard.steps._prompt_ccproxy_port" - ) as mock_port, - patch("EvoScientist.config.onboard.steps._run_ccproxy_login") as mock_login, - ): - result = getattr(onboard_steps, step_name)(config) - - assert result == "oauth" - mock_nav.assert_called_once_with(select_question, with_back=True) - mock_port.assert_called_once_with(config) - mock_check_auth.assert_called_once_with(ccproxy_provider) - mock_login.assert_called_once_with(ccproxy_provider, status_label) - - select_call = mock_select.call_args - assert select_call.args[0] == f"{question_label} [Esc/← to go back]:" - assert select_call.kwargs["default"] == "oauth" - choice_titles = [ - choice.title - for choice in select_call.kwargs["choices"] - if getattr(choice, "value", None) in {"api_key", "oauth"} - ] - assert choice_titles == [ - f"API Key (direct {provider_label} access)", - f"{oauth_choice_label} (via ccproxy — no API key needed)", - ] - mock_confirm.assert_called_once() - assert mock_confirm.call_args.args[0] == login_prompt - - -class TestStepModel: - def test_returns_selected_model(self): - """Test that _step_model returns selected model.""" - from EvoScientist.config.onboard.steps import _step_model - - config = EvoScientistConfig() - - with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: - mock_q.select.return_value.ask.return_value = "claude-sonnet-4-6" - result = _step_model(config, "anthropic") - - assert result == "claude-sonnet-4-6" - - def test_main_model_not_in_provider_list_defaults_to_first(self): - """Reset/main flow: a config.model that isn't in the chosen provider's - list (e.g. provider switched to google-genai) defaults to that - provider's first model, NOT the custom 'Type a model name...' entry.""" - from EvoScientist.config.onboard.steps import _step_model - from EvoScientist.llm.models import get_models_for_provider - - config = EvoScientistConfig(model="claude-sonnet-4-6") - entries = get_models_for_provider("google-genai") - with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: - mock_q.select.return_value.ask.return_value = entries[0][0] - _step_model(config, "google-genai") - - default = mock_q.select.call_args.kwargs["default"] - assert default == entries[0][0] - assert default != "__custom__" - - def test_custom_default_value_preselects_and_prefills(self): - """Co-pilot re-run: a saved custom (non-registry) model preselects and - prefills the 'Type a model name...' entry.""" - from EvoScientist.config.onboard.steps import _step_model - - config = EvoScientistConfig() - with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: - mock_q.select.return_value.ask.return_value = "__custom__" - mock_q.text.return_value.ask.return_value = "my-private/model" - result = _step_model(config, "openrouter", default_value="my-private/model") - - assert mock_q.select.call_args.kwargs["default"] == "__custom__" - assert mock_q.text.call_args.kwargs["default"] == "my-private/model" - assert result == "my-private/model" - - def test_raises_keyboard_interrupt_on_cancel(self): - """Test that _step_model raises KeyboardInterrupt on cancel.""" - from EvoScientist.config.onboard.steps import _step_model - - config = EvoScientistConfig() - - with patch("EvoScientist.config.onboard.steps.questionary") as mock_q: - mock_q.select.return_value.ask.return_value = None - with pytest.raises(KeyboardInterrupt): - _step_model(config, "anthropic") - - class TestStepWorkspace: def test_returns_daemon_mode(self): """Test workspace step returns selected mode.""" @@ -1338,26 +1028,22 @@ class TestRunOnboard: mock_load.return_value = EvoScientistConfig() # Mock all questionary calls — order matches the wizard's select - # sequence: UI → Provider → Anthropic auth_mode → Model → - # Workspace → Thinking → (channels skipped via empty checkbox). + # sequence: UI → Workspace → Thinking → (channels skipped via + # empty checkbox). No provider/model steps exist anymore: models + # are configured via the WebUI / model registry. mock_q.select.return_value.ask.side_effect = [ "tui", # UI backend - "anthropic", # Provider - "api_key", # Anthropic auth mode (API key, not OAuth) - "claude-sonnet-4-6", # Model - "skip", # Auxiliary: Skip (single driver) "daemon", # Workspace mode True, # Show thinking ] mock_q.password.return_value.ask.side_effect = [ - "", # Provider API key (keep current) "", # Tavily key (keep current) ] mock_q.confirm.return_value.ask.side_effect = [ True, # Save config ] mock_q.text.return_value.ask.side_effect = [ - "", # Workspace directory (empty = use cwd) + "", # LangGraph port (empty = keep current) ] mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip @@ -1370,281 +1056,9 @@ class TestRunOnboard: # against silent shift bugs where a missing side_effect entry causes # downstream prompts to consume the wrong values. final_config = mock_save.call_args_list[-1].args[0] - assert final_config.provider == "anthropic" - assert final_config.model == "claude-sonnet-4-6" - assert final_config.anthropic_auth_mode == "api_key" assert final_config.ui_backend == "tui" assert final_config.default_mode == "daemon" - def test_auxiliary_model_enabled_collects_provider_and_key(self): - """Enabling the auxiliary step stores its provider, model, and the - chosen provider's API key (a different company than the main agent).""" - from EvoScientist.config.onboard.wizard import run_onboard - - mock_q = MagicMock() - with ( - _patch_all_questionary(mock_q), - patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, - patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, - patch("EvoScientist.config.onboard.wizard.console"), - patch("EvoScientist.config.onboard.steps.console"), - patch("EvoScientist.config.onboard.channels.console"), - patch("EvoScientist.config.onboard.helpers.console"), - patch("EvoScientist.config.onboard.wizard._step_tinytex"), - ): - mock_load.return_value = EvoScientistConfig() - - mock_q.select.return_value.ask.side_effect = [ - "tui", # UI backend - "anthropic", # Provider - "api_key", # Anthropic auth mode - "claude-sonnet-4-6", # Model - "assemble", # Auxiliary: Assemble - "openai", # Auxiliary provider (a different company) - "api_key", # Auxiliary OpenAI auth mode - "gpt-5.5", # Auxiliary model - "daemon", # Workspace mode - True, # Show thinking - ] - mock_q.password.return_value.ask.side_effect = [ - "", # Main provider API key (keep current) - "sk-aux-openai", # Auxiliary provider API key - "", # Tavily key (keep current) - ] - mock_q.confirm.return_value.ask.side_effect = [ - True, # Save config - ] - mock_q.text.return_value.ask.side_effect = [ - "", # Workspace directory - ] - mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip - - result = run_onboard(skip_validation=True) - - assert result is True - final_config = mock_save.call_args_list[-1].args[0] - assert final_config.auxiliary_provider == "openai" - assert final_config.auxiliary_model == "gpt-5.5" - assert final_config.openai_auth_mode == "api_key" - # The auxiliary provider's key is stored in its per-provider field. - assert final_config.openai_api_key == "sk-aux-openai" - # Main agent is untouched. - assert final_config.provider == "anthropic" - assert final_config.model == "claude-sonnet-4-6" - - def test_auxiliary_same_provider_reuses_main_credentials(self): - """Same-provider co-pilot should not imply separate credentials exist.""" - from EvoScientist.config.onboard.wizard import run_onboard - - mock_q = MagicMock() - with ( - _patch_all_questionary(mock_q), - patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, - patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, - patch("EvoScientist.config.onboard.wizard.console"), - patch("EvoScientist.config.onboard.steps.console"), - ): - mock_load.return_value = EvoScientistConfig( - provider="openai", - model="gpt-5.5", - openai_api_key="sk-main-openai", - ) - mock_q.select.return_value.ask.side_effect = [ - "assemble", # Auxiliary: Assemble - "openai", # Same provider as the main model - "gpt-5.5", # Auxiliary model - ] - mock_q.confirm.return_value.ask.side_effect = [True] # Save config - - result = run_onboard( - skip_validation=True, only_sections={"auxiliary_model"} - ) - - assert result is True - final_config = mock_save.call_args_list[-1].args[0] - assert final_config.auxiliary_provider == "openai" - assert final_config.auxiliary_model == "gpt-5.5" - assert final_config.openai_api_key == "sk-main-openai" - mock_q.password.assert_not_called() - assert mock_q.select.return_value.ask.call_count == 3 - - def test_auxiliary_same_provider_prompts_when_shared_key_missing(self): - """Same-provider reuse should not hide a missing shared API key.""" - from EvoScientist.config.onboard.wizard import run_onboard - - mock_q = MagicMock() - with ( - _patch_all_questionary(mock_q), - patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, - patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, - patch("EvoScientist.config.onboard.wizard.console"), - patch("EvoScientist.config.onboard.steps.console"), - patch("EvoScientist.config.onboard.helpers.console"), - patch( - "EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True - ), - ): - mock_load.return_value = EvoScientistConfig( - provider="openai", - model="gpt-5.5", - openai_auth_mode="api_key", - openai_api_key="", - ) - mock_q.select.return_value.ask.side_effect = [ - "assemble", # Auxiliary: Assemble - "openai", # Same provider as the main model - "api_key", # Shared OpenAI auth mode - "gpt-5.5", # Auxiliary model - ] - mock_q.password.return_value.ask.side_effect = [ - "sk-shared-openai", - ] - mock_q.confirm.return_value.ask.side_effect = [True] # Save config - - result = run_onboard( - skip_validation=True, only_sections={"auxiliary_model"} - ) - - assert result is True - final_config = mock_save.call_args_list[-1].args[0] - assert final_config.auxiliary_provider == "openai" - assert final_config.auxiliary_model == "gpt-5.5" - assert final_config.openai_auth_mode == "api_key" - assert final_config.openai_api_key == "sk-shared-openai" - mock_q.password.assert_called_once() - assert mock_q.select.return_value.ask.call_count == 4 - - def test_auxiliary_openai_oauth_skips_api_key(self): - """Auxiliary OpenAI now uses the shared auth flow and skips keys on OAuth.""" - from EvoScientist.config.onboard.wizard import run_onboard - - mock_q = MagicMock() - with ( - _patch_all_questionary(mock_q), - patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, - patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, - patch("EvoScientist.config.onboard.wizard.console"), - patch("EvoScientist.config.onboard.steps.console"), - patch("EvoScientist.config.onboard.helpers.console"), - patch( - "EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True - ), - patch( - "EvoScientist.ccproxy_manager.check_ccproxy_auth", - return_value=(False, "not authenticated"), - ) as mock_auth, - ): - mock_load.return_value = EvoScientistConfig() - mock_q.select.return_value.ask.side_effect = [ - "assemble", # Auxiliary: Assemble - "openai", # Auxiliary provider - "oauth", # OpenAI auth mode - "gpt-5.5", # Auxiliary model - ] - mock_q.text.return_value.ask.side_effect = [ - "", # ccproxy port (keep default) - ] - mock_q.confirm.return_value.ask.side_effect = [ - False, # Do not log in to Codex now - True, # Save config - ] - - result = run_onboard( - skip_validation=True, only_sections={"auxiliary_model"} - ) - - assert result is True - final_config = mock_save.call_args_list[-1].args[0] - assert final_config.auxiliary_provider == "openai" - assert final_config.auxiliary_model == "gpt-5.5" - assert final_config.openai_auth_mode == "oauth" - assert final_config.openai_api_key == "" - mock_q.password.assert_not_called() - mock_auth.assert_called_once_with("codex") - - def test_auxiliary_reconfigure_clears_unused_openai_oauth(self): - """Switching co-pilot away from OpenAI clears stale OpenAI OAuth mode.""" - from EvoScientist.config.onboard.wizard import run_onboard - - mock_q = MagicMock() - with ( - _patch_all_questionary(mock_q), - patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, - patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, - patch("EvoScientist.config.onboard.wizard.console"), - patch("EvoScientist.config.onboard.steps.console"), - patch("EvoScientist.config.onboard.helpers.console"), - ): - mock_load.return_value = EvoScientistConfig( - provider="anthropic", - model="claude-sonnet-4-6", - anthropic_auth_mode="oauth", - auxiliary_provider="openai", - auxiliary_model="gpt-5.5", - openai_auth_mode="oauth", - ) - mock_q.select.return_value.ask.side_effect = [ - "assemble", # Auxiliary: Assemble - "minimax", # Auxiliary provider no longer uses OpenAI - "global", # MiniMax region - "minimax-m2", # Auxiliary model - ] - mock_q.password.return_value.ask.side_effect = [ - "sk-minimax", # MiniMax API key - ] - mock_q.confirm.return_value.ask.side_effect = [True] # Save config - - result = run_onboard( - skip_validation=True, only_sections={"auxiliary_model"} - ) - - assert result is True - final_config = mock_save.call_args_list[-1].args[0] - assert final_config.auxiliary_provider == "minimax" - assert final_config.openai_auth_mode == "api_key" - assert final_config.anthropic_auth_mode == "oauth" - - def test_auxiliary_custom_provider_collects_base_url(self): - """Regression for the custom-provider fix: a custom auxiliary provider - collects its base URL (provider -> base URL -> key -> model order).""" - from EvoScientist.config.onboard.wizard import run_onboard - - mock_q = MagicMock() - with ( - _patch_all_questionary(mock_q), - patch("EvoScientist.config.onboard.wizard.load_config") as mock_load, - patch("EvoScientist.config.onboard.wizard.save_config") as mock_save, - patch("EvoScientist.config.onboard.wizard.console"), - patch("EvoScientist.config.onboard.steps.console"), - patch("EvoScientist.config.onboard.channels.console"), - patch("EvoScientist.config.onboard.helpers.console"), - ): - mock_load.return_value = EvoScientistConfig() - mock_q.select.return_value.ask.side_effect = [ - "assemble", # Auxiliary: Assemble - "custom-openai", # Auxiliary provider - "gpt-5.5", # Auxiliary model (from the custom-openai registry) - ] - mock_q.text.return_value.ask.side_effect = [ - "https://my-endpoint/v1", # Auxiliary base URL (custom provider) - ] - mock_q.password.return_value.ask.side_effect = [ - "sk-aux-custom", # Auxiliary provider API key - ] - mock_q.confirm.return_value.ask.side_effect = [True] # Save - - result = run_onboard( - skip_validation=True, only_sections={"auxiliary_model"} - ) - - assert result is True - final_config = mock_save.call_args_list[-1].args[0] - assert final_config.auxiliary_provider == "custom-openai" - assert final_config.auxiliary_model == "gpt-5.5" - # Base URL must be collected for the custom auxiliary provider. - assert final_config.custom_openai_base_url == "https://my-endpoint/v1" - assert final_config.custom_openai_api_key == "sk-aux-custom" - def test_returns_false_on_cancel(self): """Test that run_onboard returns False when cancelled.""" from EvoScientist.config.onboard.wizard import run_onboard @@ -1687,19 +1101,15 @@ class TestRunOnboard: mock_q.select.return_value.ask.side_effect = [ "tui", # UI backend - "anthropic", # Provider - "api_key", # Anthropic auth mode - "claude-sonnet-4-6", # Model - "skip", # Auxiliary: Skip (single driver) "daemon", # Workspace mode True, # Show thinking ] - mock_q.password.return_value.ask.side_effect = ["", ""] + mock_q.password.return_value.ask.side_effect = [""] mock_q.confirm.return_value.ask.side_effect = [ False, # Save config - NO ] mock_q.text.return_value.ask.side_effect = [ - "", # Workspace directory (empty = use cwd) + "", # LangGraph port (empty = keep current) ] mock_q.checkbox.return_value.ask.return_value = [] # Skills: skip @@ -1724,10 +1134,9 @@ class TestRunOnboard: config_file = tmp_path / "config.yaml" existing = EvoScientistConfig( - provider="openai", - model="gpt-5", - openai_api_key="sk-existing", + tavily_api_key="tvly-existing", ui_backend="cli", + default_mode="run", ) mock_q = MagicMock() @@ -1758,14 +1167,10 @@ class TestRunOnboard: mock_q.select.return_value.ask.side_effect = [ "reset", # Keep/Modify/Reset → Reset "tui", - "anthropic", - "api_key", - "claude-sonnet-4-6", - "skip", # Auxiliary: Skip (single driver) "daemon", True, ] - mock_q.password.return_value.ask.side_effect = ["", ""] + mock_q.password.return_value.ask.side_effect = [""] mock_q.confirm.return_value.ask.side_effect = [ False, # Save? = No ] @@ -1808,14 +1213,10 @@ class TestRunOnboard: ): mock_q.select.return_value.ask.side_effect = [ "tui", - "anthropic", - "api_key", - "claude-sonnet-4-6", - "skip", # Auxiliary: Skip (single driver) "daemon", True, ] - mock_q.password.return_value.ask.side_effect = ["", ""] + mock_q.password.return_value.ask.side_effect = [""] mock_q.confirm.return_value.ask.side_effect = [ False, # Save? = No ] diff --git a/tests/test_provider_admin.py b/tests/test_provider_admin.py deleted file mode 100644 index c50d7d9..0000000 --- a/tests/test_provider_admin.py +++ /dev/null @@ -1,42 +0,0 @@ -from __future__ import annotations - -import os - -from EvoScientist.config.provider_admin import ( - get_provider_admin_token, - get_provider_admin_token_path, -) - - -def test_explicit_provider_admin_token_wins(monkeypatch, tmp_path): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.setenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", "explicit-token") - - assert get_provider_admin_token() == "explicit-token" - assert not get_provider_admin_token_path().exists() - - -def test_provider_admin_token_is_created_and_reused(monkeypatch, tmp_path): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False) - - first = get_provider_admin_token() - second = get_provider_admin_token() - path = get_provider_admin_token_path() - - assert first - assert first == second - assert path.read_text(encoding="utf-8").strip() == first - if os.name != "nt": - assert path.parent.stat().st_mode & 0o777 == 0o700 - assert path.stat().st_mode & 0o777 == 0o600 - - -def test_provider_admin_token_reads_existing_file(monkeypatch, tmp_path): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - monkeypatch.delenv("EVOSCIENTIST_PROVIDER_ADMIN_TOKEN", raising=False) - path = get_provider_admin_token_path() - path.parent.mkdir(parents=True) - path.write_text("shared-token\n", encoding="utf-8") - - assert get_provider_admin_token() == "shared-token" diff --git a/tests/test_provider_operations.py b/tests/test_provider_operations.py deleted file mode 100644 index 55b4b74..0000000 --- a/tests/test_provider_operations.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Tests for provider-specific model discovery and connectivity checks.""" - -from __future__ import annotations - -from types import SimpleNamespace -from unittest.mock import patch - -import httpx -import pytest - -from EvoScientist.config.provider_profiles import ProviderModel, ProviderProfile -from EvoScientist.llm.provider_operations import ( - ProviderOperationError, - discover_provider_models, -) -from EvoScientist.llm.provider_operations import ( - test_provider_model as run_provider_model_test, -) - - -def _profile( - adapter: str, - *, - base_url: str = "", - api_key: str = "provider-secret", -) -> ProviderProfile: - return ProviderProfile( - id="test-provider", - name="Test provider", - adapter=adapter, - base_url=base_url, - api_key=api_key, - enabled=True, - models=(), - ) - - -@pytest.mark.asyncio -async def test_openai_discovery_uses_bearer_token_and_models_endpoint(): - def handler(request: httpx.Request) -> httpx.Response: - assert request.url == "https://api.openai.com/v1/models" - assert request.headers["Authorization"] == "Bearer provider-secret" - return httpx.Response( - 200, - json={ - "data": [ - {"id": "gpt-z"}, - {"id": "gpt-a", "name": "GPT A"}, - ] - }, - ) - - models = await discover_provider_models( - _profile("openai"), transport=httpx.MockTransport(handler) - ) - - assert [(model.model_id, model.name) for model in models] == [ - ("gpt-a", "GPT A"), - ("gpt-z", "gpt-z"), - ] - - -@pytest.mark.asyncio -async def test_anthropic_discovery_uses_native_headers_and_pagination(): - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - assert request.url.path == "/v1/models" - assert request.headers["x-api-key"] == "provider-secret" - assert request.headers["anthropic-version"] == "2023-06-01" - if request.url.params.get("after_id"): - return httpx.Response( - 200, - json={ - "data": [{"id": "claude-opus", "display_name": "Claude Opus"}], - "has_more": False, - }, - ) - return httpx.Response( - 200, - json={ - "data": [{"id": "claude-sonnet"}], - "has_more": True, - "last_id": "claude-sonnet", - }, - ) - - models = await discover_provider_models( - _profile("anthropic"), transport=httpx.MockTransport(handler) - ) - - assert len(requests) == 2 - assert requests[1].url.params["after_id"] == "claude-sonnet" - assert [model.model_id for model in models] == ["claude-opus", "claude-sonnet"] - - -@pytest.mark.asyncio -async def test_google_discovery_filters_non_generation_models_and_paginates(): - def handler(request: httpx.Request) -> httpx.Response: - assert request.url.path == "/v1beta/models" - assert request.headers["x-goog-api-key"] == "provider-secret" - if request.url.params.get("pageToken"): - return httpx.Response( - 200, - json={ - "models": [ - { - "name": "models/gemini-2.5-pro", - "displayName": "Gemini 2.5 Pro", - "supportedGenerationMethods": ["generateContent"], - } - ] - }, - ) - return httpx.Response( - 200, - json={ - "models": [ - { - "name": "models/text-embedding-004", - "supportedGenerationMethods": ["embedContent"], - }, - { - "name": "models/gemini-2.5-flash", - "supportedGenerationMethods": ["generateContent"], - }, - ], - "nextPageToken": "next-page", - }, - ) - - models = await discover_provider_models( - _profile("google-genai"), transport=httpx.MockTransport(handler) - ) - - assert [(model.model_id, model.name) for model in models] == [ - ("gemini-2.5-flash", "models/gemini-2.5-flash"), - ("gemini-2.5-pro", "Gemini 2.5 Pro"), - ] - - -@pytest.mark.asyncio -async def test_grok_uses_xai_default_and_antigravity_uses_configured_proxy(): - requests: list[httpx.Request] = [] - - def handler(request: httpx.Request) -> httpx.Response: - requests.append(request) - return httpx.Response(200, json={"data": [{"id": "grok-4"}]}) - - transport = httpx.MockTransport(handler) - await discover_provider_models(_profile("grok"), transport=transport) - await discover_provider_models( - _profile("antigravity", base_url="http://127.0.0.1:8317/v1"), - transport=transport, - ) - - assert str(requests[0].url) == "https://api.x.ai/v1/models" - assert str(requests[1].url) == "http://127.0.0.1:8317/v1/models" - - -@pytest.mark.asyncio -async def test_ollama_discovery_uses_tags_without_api_key(): - def handler(request: httpx.Request) -> httpx.Response: - assert request.url == "http://127.0.0.1:11434/api/tags" - assert "Authorization" not in request.headers - return httpx.Response( - 200, - json={"models": [{"name": "qwen3:8b"}, {"model": "llama3.3:latest"}]}, - ) - - models = await discover_provider_models( - _profile("ollama", api_key=""), transport=httpx.MockTransport(handler) - ) - - assert [model.model_id for model in models] == ["llama3.3:latest", "qwen3:8b"] - - -@pytest.mark.asyncio -async def test_discovery_requires_native_provider_api_key(): - with pytest.raises(ProviderOperationError, match="API key is required"): - await discover_provider_models(_profile("openai", api_key="")) - - -@pytest.mark.asyncio -async def test_provider_http_error_surfaces_message_without_response_body_dump(): - def handler(_request: httpx.Request) -> httpx.Response: - return httpx.Response( - 401, - json={"error": {"message": "Invalid credential"}, "secret": "hidden"}, - ) - - with pytest.raises( - ProviderOperationError, match="Provider returned HTTP 401: Invalid credential" - ): - await discover_provider_models( - _profile("openai"), transport=httpx.MockTransport(handler) - ) - - -@pytest.mark.asyncio -async def test_bare_openai_origin_uses_v1_and_reports_non_json_content_type(): - def handler(request: httpx.Request) -> httpx.Response: - assert request.url == "https://api.example.test/v1/models" - return httpx.Response( - 200, - text="", - headers={"content-type": "text/html; charset=utf-8"}, - ) - - with pytest.raises(ProviderOperationError) as raised: - await discover_provider_models( - _profile("openai", base_url="https://api.example.test"), - transport=httpx.MockTransport(handler), - ) - - message = str(raised.value) - assert "https://api.example.test/v1/models" in message - assert "Content-Type: text/html" in message - assert "normally end in /v1" in message - - -@pytest.mark.asyncio -async def test_discovery_retries_a_transient_connection_failure(): - attempts = 0 - - def handler(request: httpx.Request) -> httpx.Response: - nonlocal attempts - attempts += 1 - if attempts == 1: - raise httpx.ConnectTimeout("temporary timeout", request=request) - return httpx.Response(200, json={"data": [{"id": "gpt-recovered"}]}) - - models = await discover_provider_models( - _profile("openai"), transport=httpx.MockTransport(handler) - ) - - assert attempts == 2 - assert [model.model_id for model in models] == ["gpt-recovered"] - - -@pytest.mark.asyncio -async def test_model_test_builds_draft_model_and_returns_text(): - class FakeChatModel: - async def ainvoke(self, prompt: str): - assert prompt == "Reply with exactly: OK" - return SimpleNamespace(content=[{"type": "text", "text": "OK"}]) - - profile = _profile("grok") - model = ProviderModel( - id="grok-test", name="Grok test", model_id="grok-4", enabled=True - ) - with patch( - "EvoScientist.llm.models.get_profile_chat_model", return_value=FakeChatModel() - ) as build_model: - result = await run_provider_model_test(profile, model) - - assert result.response == "OK" - assert result.latency_ms >= 1 - build_model.assert_called_once_with( - profile, - model, - max_tokens=64, - skip_auto_config=True, - ) - - -@pytest.mark.asyncio -async def test_model_test_redacts_api_key_from_errors(): - profile = _profile("openai", api_key="secret-value") - model = ProviderModel(id="test", name="Test", model_id="gpt-test") - with patch( - "EvoScientist.llm.models.get_profile_chat_model", - side_effect=ValueError("request rejected for secret-value"), - ): - with pytest.raises(ProviderOperationError) as raised: - await run_provider_model_test(profile, model) - - assert "secret-value" not in str(raised.value) - assert "[redacted]" in str(raised.value) diff --git a/tests/test_provider_profiles.py b/tests/test_provider_profiles.py deleted file mode 100644 index cafeaae..0000000 --- a/tests/test_provider_profiles.py +++ /dev/null @@ -1,485 +0,0 @@ -"""Tests for runtime-editable provider profiles.""" - -from __future__ import annotations - -import os -from unittest.mock import patch - -import pytest - -from EvoScientist.config import EvoScientistConfig, apply_config_to_env, save_config -from EvoScientist.config.provider_profiles import ( - ProviderProfileError, - get_builtin_provider_profile, - get_provider_profile_revision, - get_provider_profiles_path, - list_configured_model_entries, - load_provider_profiles, - provider_profiles_public, - replace_provider_profiles, - resolve_provider_profile_draft, -) -from EvoScientist.llm.models import get_chat_model, list_model_picker_entries - - -@pytest.fixture -def provider_config_dir(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - return tmp_path / "evoscientist" - - -def _document(api_key: str = "secret-key") -> dict: - return { - "version": 3, - "providers": [ - { - "id": "lab-openai", - "name": "Lab OpenAI", - "adapter": "openai-compatible", - "base_url": "https://llm.example.test/v1/", - "api_key": api_key, - "enabled": True, - "runtime": { - "timeout_seconds": 90, - "max_retries": 1, - "default_temperature": 0.3, - "default_top_p": None, - "default_reasoning_effort": "auto", - }, - "models": [ - { - "id": "research-model", - "name": "Research Model", - "model_id": "vendor/research-1", - "enabled": True, - "runtime": { - "limit_mode": "combined", - "context_window_tokens": 32768, - "max_output_tokens": 4096, - "min_effective_input_tokens": 4096, - "limits_status": "confirmed", - "limits_source": "user", - }, - }, - { - "id": "disabled-model", - "name": "Disabled Model", - "model_id": "vendor/disabled", - "enabled": False, - "runtime": { - "limit_mode": "combined", - "context_window_tokens": 32768, - "max_output_tokens": 4096, - "min_effective_input_tokens": 4096, - "limits_status": "confirmed", - "limits_source": "user", - }, - }, - ], - } - ] - } - - -def _builtin_document(api_key: str = "builtin-secret") -> dict: - return { - "version": 3, - "builtins": [ - { - "id": "openai", - "name": "OpenAI", - "adapter": "openai", - "base_url": "https://proxy.example.test/v1", - "api_key": api_key, - "auth_mode": "api_key", - "enabled": True, - "runtime": {}, - "models": [ - { - "id": "chat-main", - "name": "Chat Main", - "model_id": "gpt-upstream", - "enabled": True, - "runtime": { - "limit_mode": "combined", - "context_window_tokens": 32768, - "max_output_tokens": 4096, - "min_effective_input_tokens": 4096, - "limits_status": "confirmed", - "limits_source": "user", - }, - } - ], - } - ] - } - - -def test_replace_round_trip_and_redacts_api_key(provider_config_dir): - saved = replace_provider_profiles(_document()) - - assert saved.providers[0].base_url == "https://llm.example.test/v1" - assert load_provider_profiles() == saved - public = provider_profiles_public(saved) - assert "api_key" not in public["providers"][0] - assert public["providers"][0]["api_key_configured"] is True - assert public["providers"][0]["api_key_hint"] == "sec...-key" - assert "openai" in public["reserved_provider_ids"] - - path = get_provider_profiles_path() - assert path == provider_config_dir / "providers.yaml" - if os.name != "nt": - assert path.parent.stat().st_mode & 0o777 == 0o700 - assert path.stat().st_mode & 0o777 == 0o600 - - -def test_v1_document_requires_reset(provider_config_dir): - path = get_provider_profiles_path() - path.parent.mkdir(parents=True) - path.write_text( - "version: 1\nproviders:\n" - " - id: lab-openai\n" - " name: Lab OpenAI\n" - " adapter: openai-compatible\n" - " base_url: https://llm.example.test/v1\n" - " api_key: old-secret\n" - " enabled: true\n" - " models: []\n", - encoding="utf-8", - ) - - with pytest.raises(ProviderProfileError, match="PROVIDER_PROFILE_RESET_REQUIRED"): - load_provider_profiles() - - -def test_builtin_profiles_round_trip_and_redact_secret(provider_config_dir): - saved = replace_provider_profiles(_builtin_document()) - - assert saved.builtins[0].id == "openai" - assert get_builtin_provider_profile("openai") == saved.builtins[0] - public = provider_profiles_public(saved) - assert public["builtins"][0]["api_key_configured"] is True - assert "builtin-secret" not in str(public) - - -def test_builtin_private_revision_changes_with_connection_secret( - provider_config_dir, -): - replace_provider_profiles(_builtin_document("first-secret")) - first = get_provider_profile_revision("openai") - - replace_provider_profiles(_builtin_document("second-secret")) - second = get_provider_profile_revision("openai") - - assert first - assert second - assert first != second - - -def test_custom_update_preserves_builtin_profiles(provider_config_dir): - replace_provider_profiles(_builtin_document()) - - saved = replace_provider_profiles(_document()) - - assert saved.builtins[0].id == "openai" - assert saved.providers[0].id == "lab-openai" - - -def test_builtin_update_preserves_custom_profiles(provider_config_dir): - replace_provider_profiles(_document()) - - saved = replace_provider_profiles(_builtin_document()) - - assert saved.builtins[0].id == "openai" - assert saved.providers[0].id == "lab-openai" - - -def test_empty_api_key_keeps_existing_secret(provider_config_dir): - replace_provider_profiles(_document("first-secret")) - update = _document("") - update["providers"][0]["name"] = "Renamed" - - saved = replace_provider_profiles(update) - - assert saved.providers[0].name == "Renamed" - assert saved.providers[0].api_key == "first-secret" - - -def test_public_revision_does_not_fingerprint_api_key(provider_config_dir): - first = provider_profiles_public( - replace_provider_profiles(_document("abc-first-1234")) - )["revision"] - second = provider_profiles_public( - replace_provider_profiles(_document("abc-second-1234")) - )["revision"] - - assert first == second - - -def test_clear_api_key_is_explicit(provider_config_dir): - replace_provider_profiles(_document("first-secret")) - update = _document("") - update["providers"][0]["clear_api_key"] = True - - saved = replace_provider_profiles(update) - - assert saved.providers[0].api_key == "" - - -def test_draft_reuses_saved_api_key_without_exposing_it(provider_config_dir): - replace_provider_profiles(_document("saved-secret")) - draft = _document("")["providers"][0] - draft["models"] = [] - - resolved = resolve_provider_profile_draft(draft) - - assert resolved.api_key == "saved-secret" - assert resolved.models == () - - -def test_draft_can_explicitly_clear_saved_api_key(provider_config_dir): - replace_provider_profiles(_document("saved-secret")) - draft = _document("")["providers"][0] - draft.update({"models": [], "clear_api_key": True}) - - resolved = resolve_provider_profile_draft(draft) - - assert resolved.api_key == "" - - -def test_reserved_provider_id_is_rejected(provider_config_dir): - raw = _document() - raw["providers"][0]["id"] = "openai" - with pytest.raises(ProviderProfileError, match="openai-custom"): - replace_provider_profiles(raw) - - -@pytest.mark.parametrize( - ("adapter", "expected"), - [ - ("openai", "https://llm.example.test/v1"), - ("openai-compatible", "https://llm.example.test/v1"), - ("openrouter", "https://llm.example.test/api/v1"), - ("google-genai", "https://llm.example.test/v1beta"), - ("anthropic", "https://llm.example.test"), - ], -) -def test_bare_provider_origin_gets_adapter_api_path( - provider_config_dir, adapter, expected -): - raw = _document() - raw["providers"][0].update( - { - "id": "lab-provider", - "adapter": adapter, - "base_url": "https://llm.example.test", - } - ) - - saved = replace_provider_profiles(raw) - - assert saved.providers[0].base_url == expected - - -def test_dynamic_models_are_listed_and_disabled_models_are_hidden( - provider_config_dir, -): - replace_provider_profiles(_document()) - - assert list_configured_model_entries() == [ - ("research-model", "vendor/research-1", "lab-openai") - ] - - -@pytest.mark.asyncio -async def test_custom_registry_replaces_static_picker_entries(provider_config_dir): - replace_provider_profiles(_document()) - - entries = await list_model_picker_entries(None, include_custom_ollama=False) - - assert entries == [("research-model", "vendor/research-1", "lab-openai")] - - -@pytest.mark.asyncio -async def test_empty_registry_hides_static_picker_entries(provider_config_dir): - replace_provider_profiles({"providers": []}) - - entries = await list_model_picker_entries(None, include_custom_ollama=False) - - assert entries == [] - - -@pytest.mark.asyncio -async def test_managed_builtins_replace_static_picker_entries(provider_config_dir): - replace_provider_profiles(_builtin_document()) - - entries = await list_model_picker_entries(None, include_custom_ollama=False) - - assert entries == [("chat-main", "gpt-upstream", "openai")] - - -def test_dynamic_profile_routes_through_selected_adapter(provider_config_dir): - replace_provider_profiles(_document()) - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("research-model", provider="lab-openai") - - _, kwargs = init_model.call_args - assert kwargs["model"] == "vendor/research-1" - assert kwargs["model_provider"] == "openai" - assert kwargs["base_url"] == "https://llm.example.test/v1" - assert kwargs["api_key"] == "secret-key" - assert kwargs["timeout"] == 90 - assert kwargs["max_retries"] == 1 - assert kwargs["max_tokens"] == 4096 - assert kwargs["default_headers"]["User-Agent"] == "codex_cli_rs/0.0.0" - - -def test_dynamic_native_openai_adapter_uses_gateway_compatible_user_agent( - provider_config_dir, -): - raw = _document() - raw["providers"][0]["adapter"] = "openai" - replace_provider_profiles(raw) - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("research-model", provider="lab-openai") - - _, kwargs = init_model.call_args - assert kwargs["model_provider"] == "openai" - assert kwargs["default_headers"]["User-Agent"] == "codex_cli_rs/0.0.0" - - -def test_builtin_catalog_alias_routes_to_upstream_model(provider_config_dir): - save_config( - EvoScientistConfig( - provider="openai", - model="chat-main", - model_catalog=[ - { - "provider": "openai", - "id": "chat-main", - "name": "Chat Main", - "model_id": "gpt-upstream", - "enabled": True, - } - ], - ) - ) - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("chat-main", provider="openai") - - _, kwargs = init_model.call_args - assert kwargs["model"] == "gpt-upstream" - assert kwargs["model_provider"] == "openai" - - -def test_managed_builtin_routes_connection_without_restart( - provider_config_dir, monkeypatch -): - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - replace_provider_profiles(_builtin_document()) - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("chat-main", provider="openai") - - _, kwargs = init_model.call_args - assert kwargs["model"] == "gpt-upstream" - assert kwargs["model_provider"] == "openai" - assert kwargs["base_url"] == "https://proxy.example.test/v1" - assert kwargs["api_key"] == "builtin-secret" - - -def test_environment_overrides_managed_builtin_connection( - provider_config_dir, monkeypatch -): - replace_provider_profiles(_builtin_document()) - monkeypatch.setenv("OPENAI_API_KEY", "environment-secret") - monkeypatch.setenv("OPENAI_BASE_URL", "https://env.example.test/v1") - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("chat-main", provider="openai") - - _, kwargs = init_model.call_args - assert kwargs["base_url"] == "https://env.example.test/v1" - assert kwargs["api_key"] == "environment-secret" - - -def test_managed_builtin_overrides_legacy_config_injected_environment( - provider_config_dir, monkeypatch -): - monkeypatch.delenv("OPENAI_API_KEY", raising=False) - monkeypatch.delenv("OPENAI_BASE_URL", raising=False) - apply_config_to_env( - EvoScientistConfig( - openai_api_key="legacy-secret", - ) - ) - replace_provider_profiles(_builtin_document("registry-secret")) - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("chat-main", provider="openai") - - _, kwargs = init_model.call_args - assert kwargs["api_key"] == "registry-secret" - - -def test_dynamic_ollama_profile_does_not_pass_api_key(provider_config_dir): - raw = _document("unused-secret") - raw["providers"][0].update( - { - "id": "local-ollama", - "adapter": "ollama", - "base_url": "http://127.0.0.1:11434", - } - ) - replace_provider_profiles(raw) - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("research-model", provider="local-ollama") - - _, kwargs = init_model.call_args - assert kwargs["model_provider"] == "ollama" - assert kwargs["base_url"] == "http://127.0.0.1:11434" - assert "api_key" not in kwargs - - -@pytest.mark.parametrize( - ("adapter", "base_url"), - [ - ("grok", ""), - ("antigravity", "http://127.0.0.1:8317/v1"), - ], -) -def test_openai_protocol_adapters_route_through_openai( - provider_config_dir, adapter, base_url -): - raw = _document() - raw["providers"][0].update( - { - "id": f"lab-{adapter}", - "adapter": adapter, - "base_url": base_url, - } - ) - replace_provider_profiles(raw) - - with patch("EvoScientist.llm.models.init_chat_model") as init_model: - get_chat_model("research-model", provider=f"lab-{adapter}") - - _, kwargs = init_model.call_args - assert kwargs["model_provider"] == "openai" - assert kwargs["base_url"] == (base_url or "https://api.x.ai/v1") - - -def test_revision_changes_when_credentials_change(provider_config_dir): - replace_provider_profiles(_document("first-secret")) - first = get_provider_profile_revision("lab-openai") - replace_provider_profiles(_document("second-secret")) - second = get_provider_profile_revision("lab-openai") - - assert first - assert second - assert first != second diff --git a/tests/test_rich_command_ui.py b/tests/test_rich_command_ui.py index 09cfcf9..0313e98 100644 --- a/tests/test_rich_command_ui.py +++ b/tests/test_rich_command_ui.py @@ -16,7 +16,7 @@ def _make_ui(**kwargs): class TestBasicIO: - """Core CommandUI methods used by /model path.""" + """Core CommandUI console I/O methods.""" def test_supports_interactive_true(self): ui, _ = _make_ui() @@ -45,75 +45,6 @@ class TestBasicIO: console.print.assert_not_called() -class TestWaitForModelPick: - """CLI model picker fallback: print table + return None.""" - - async def test_returns_none(self): - ui, _ = _make_ui() - entries = [ - ("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"), - ("gpt-4o", "openai/gpt-4o", "openai"), - ] - result = await ui.wait_for_model_pick( - entries, - current_model="claude-sonnet-4-6", - current_provider="anthropic", - ) - assert result is None - - async def test_prints_table_with_current_model_marker(self): - ui, console = _make_ui() - entries = [ - ("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic"), - ("gpt-4o", "openai/gpt-4o", "openai"), - ] - await ui.wait_for_model_pick( - entries, - current_model="claude-sonnet-4-6", - current_provider="anthropic", - ) - # First call renders the Table (Rich renderable), second prints usage. - assert console.print.call_count == 2 - first_arg = console.print.call_args_list[0].args[0] - assert isinstance(first_arg, Table) - - usage_arg = console.print.call_args_list[1].args[0] - assert "Usage: /model" in usage_arg - assert "--save" in usage_arg - - async def test_no_current_model_no_marker(self): - ui, console = _make_ui() - entries = [("claude-sonnet-4-6", "anthropic/claude-sonnet", "anthropic")] - await ui.wait_for_model_pick( - entries, - current_model=None, - current_provider=None, - ) - # Just asserts the coroutine runs without marker-branch issues. - assert console.print.call_count == 2 - - async def test_empty_entries_still_prints_header_and_usage(self): - ui, console = _make_ui() - result = await ui.wait_for_model_pick( - [], - current_model=None, - current_provider=None, - ) - assert result is None - # Header table + usage hint should still be printed even with - # no entries. - assert console.print.call_count == 2 - - -class TestUpdateStatusHook: - """update_status_after_model_change is a deliberate no-op on CLI.""" - - def test_no_op(self): - ui, console = _make_ui() - ui.update_status_after_model_change("claude-opus-4-8", "anthropic") - console.print.assert_not_called() - - class TestPhaseAMigrated: """Phase A migration: quit, clear, thread-pick fallback, and compact status. diff --git a/tests/test_runtime_context_middleware.py b/tests/test_runtime_context_middleware.py index a3f7465..7a9b626 100644 --- a/tests/test_runtime_context_middleware.py +++ b/tests/test_runtime_context_middleware.py @@ -65,10 +65,11 @@ def test_runtime_context_injects_current_date_and_timezone(): "EvoScientist.middleware.create_tool_selector_middleware", return_value=[MagicMock(), MagicMock()], ) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") def test_default_middleware_includes_runtime_context( - mock_config, mock_model, mock_tool_selector + mock_config, mock_model, mock_aux_model, mock_tool_selector ): mock_config.return_value = _mock_config() mock_model.return_value = MagicMock(profile={"max_input_tokens": 200_000}) diff --git a/tests/test_runtime_snapshots.py b/tests/test_runtime_snapshots.py deleted file mode 100644 index 469bf15..0000000 --- a/tests/test_runtime_snapshots.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Tests for server-side run configuration snapshots.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -import pytest - -from EvoScientist.config.provider_profiles import ( - ProviderProfileError, - replace_provider_profiles, -) -from EvoScientist.llm.runtime_snapshots import ( - create_run_runtime_snapshot, - get_run_runtime_snapshot, - get_snapshot_chat_model, -) - - -@pytest.fixture -def provider_config_dir(tmp_path, monkeypatch): - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - return tmp_path / "evoscientist" - - -def _document(api_key: str = "first-secret", base_url: str = "https://one.test/v1"): - return { - "version": 3, - "providers": [ - { - "id": "lab-openai", - "name": "Lab OpenAI", - "adapter": "openai-compatible", - "base_url": base_url, - "api_key": api_key, - "enabled": True, - "runtime": {"timeout_seconds": 90, "max_retries": 1}, - "models": [ - { - "id": "research-model", - "name": "Research Model", - "model_id": "vendor/research-1", - "enabled": True, - "runtime": { - "limit_mode": "combined", - "context_window_tokens": 32_768, - "max_output_tokens": 4_096, - "min_effective_input_tokens": 4_096, - "limits_status": "confirmed", - "limits_source": "provider", - }, - } - ], - } - ], - } - - -def test_snapshot_keeps_connection_and_runtime_after_profile_changes(provider_config_dir): - replace_provider_profiles(_document()) - - created = create_run_runtime_snapshot( - "run-request-a", model="research-model", provider="lab-openai" - ) - assert created is not None - assert created.options.resolved_input_limit == 28_672 - assert created.profile.api_key == "first-secret" - assert "first-secret" not in str(created.public_payload()) - assert "first-secret" not in ( - provider_config_dir / "run-runtime-snapshots.sqlite3" - ).read_bytes().decode("utf-8", errors="ignore") - - replace_provider_profiles( - _document("second-secret", "https://two.test/v1") - ) - loaded = get_run_runtime_snapshot("run-request-a") - - assert loaded is not None - assert loaded.profile.api_key == "first-secret" - assert loaded.profile.base_url == "https://one.test/v1" - assert loaded.options.timeout_seconds == 90 - - -def test_snapshot_id_is_idempotent_and_cannot_change_model(provider_config_dir): - replace_provider_profiles(_document()) - - first = create_run_runtime_snapshot( - "run-request-a", model="research-model", provider="lab-openai" - ) - repeated = create_run_runtime_snapshot( - "run-request-a", model="research-model", provider="lab-openai" - ) - - assert repeated == first - with pytest.raises(ValueError, match="already bound"): - create_run_runtime_snapshot( - "run-request-a", model="another-model", provider="lab-openai" - ) - - -def test_static_provider_does_not_need_a_custom_snapshot(provider_config_dir): - assert ( - create_run_runtime_snapshot( - "run-request-a", model="gpt-5", provider="openai" - ) - is None - ) - - -def test_glm_alias_is_static_even_when_custom_registry_is_stale( - provider_config_dir, monkeypatch -): - monkeypatch.setenv("ZHIPU_API_KEY", "test-zhipu-key") - provider_config_dir.mkdir(parents=True) - (provider_config_dir / "providers.yaml").write_text( - "version: 2\nproviders: []\n", encoding="utf-8" - ) - - assert ( - create_run_runtime_snapshot( - "run-request-glm", model="glm-5.2", provider="glm" - ) - is None - ) - - -def test_zhipu_snapshot_requires_its_own_api_key(provider_config_dir, monkeypatch): - monkeypatch.delenv("ZHIPU_API_KEY", raising=False) - monkeypatch.setenv("OPENAI_API_KEY", "unrelated-openai-key") - - with pytest.raises(ProviderProfileError, match="ZHIPU_API_KEY_NOT_CONFIGURED"): - create_run_runtime_snapshot( - "run-request-zhipu", model="glm-5.2", provider="zhipu" - ) - - -def test_snapshot_model_builder_uses_frozen_profile(provider_config_dir): - replace_provider_profiles(_document()) - create_run_runtime_snapshot( - "run-request-a", model="research-model", provider="lab-openai" - ) - model = MagicMock() - - with patch( - "EvoScientist.llm.models.get_profile_chat_model", return_value=model - ) as factory: - resolved = get_snapshot_chat_model("run-request-a") - - assert resolved is model - profile, provider_model = factory.call_args.args - assert profile.api_key == "first-secret" - assert provider_model.model_id == "vendor/research-1" diff --git a/tests/test_snapshot_runtime.py b/tests/test_snapshot_runtime.py index 140807f..89efd7b 100644 --- a/tests/test_snapshot_runtime.py +++ b/tests/test_snapshot_runtime.py @@ -209,3 +209,34 @@ class TestEndpointPolicyWiring: runtime = SnapshotRuntime(store, endpoint_policy=policy) model = runtime.build_default_role_model("auxiliary") assert isinstance(model, ChatOllama) + + +class TestCreateLocalSnapshot: + """Section 8.1 local entry: fixed binding convention through SnapshotService.""" + + def test_bootstrap_registry_fails_closed(self, tmp_path): + runtime = SnapshotRuntime(ModelRuntimeStore(config_dir=tmp_path / "db")) + with pytest.raises(ModelRegistryError) as excinfo: + runtime.create_local_snapshot("thread-1") + assert excinfo.value.code == MODEL_REGISTRY_NOT_READY + + def test_binding_convention_is_fixed(self, store, runtime): + from EvoScientist.model_registry.snapshots import config_for_role + + snapshot = runtime.create_local_snapshot("cli-thread-1") + assert snapshot.thread_id == "cli-thread-1" + assert snapshot.deployment_id == runtime.local_deployment_id + assert snapshot.payload.model_selection_revision == 0 + # primary=None inherits — the registry defaults are frozen at creation. + assert config_for_role(snapshot, "primary").model_ref == ZHIPU_REF + assert config_for_role(snapshot, "auxiliary").model_ref == OLLAMA_REF + + def test_same_run_request_id_reuses_snapshot(self, store, runtime): + first = runtime.create_local_snapshot("t-1", run_request_id="req-1") + second = runtime.create_local_snapshot("t-1", run_request_id="req-1") + assert second.snapshot_id == first.snapshot_id + + def test_default_run_request_id_freezes_per_call(self, store, runtime): + first = runtime.create_local_snapshot("t-1") + second = runtime.create_local_snapshot("t-1") + assert second.snapshot_id != first.snapshot_id diff --git a/tests/test_tool_selector_middleware.py b/tests/test_tool_selector_middleware.py index 60687a3..fb43e4a 100644 --- a/tests/test_tool_selector_middleware.py +++ b/tests/test_tool_selector_middleware.py @@ -305,9 +305,10 @@ def test_tracker_captures_tools(): "EvoScientist.middleware.create_tool_selector_middleware", side_effect=lambda *a, **kw: _patched_create(), ) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") -def test_default_middleware_includes_tool_selector(mock_config, mock_model, mock_ts): +def test_default_middleware_includes_tool_selector(mock_config, mock_model, mock_aux, mock_ts): mock_model.return_value = _mock_model() cfg = MagicMock() cfg.enable_ask_user = False @@ -341,9 +342,10 @@ def test_subagent_no_tool_selector(mock_model): "EvoScientist.middleware.create_tool_selector_middleware", side_effect=lambda *a, **kw: _patched_create(), ) +@patch("EvoScientist.EvoScientist._ensure_auxiliary_chat_model") @patch("EvoScientist.EvoScientist._ensure_chat_model") @patch("EvoScientist.EvoScientist._ensure_config") -def test_tool_selector_ordering(mock_config, mock_model, mock_ts): +def test_tool_selector_ordering(mock_config, mock_model, mock_aux, mock_ts): """ToolSelector should come after ToolErrorHandler and before Memory.""" mock_model.return_value = _mock_model() cfg = MagicMock() diff --git a/tests/test_tui_banner_position.py b/tests/test_tui_banner_position.py index 969476c..ef6c9e4 100644 --- a/tests/test_tui_banner_position.py +++ b/tests/test_tui_banner_position.py @@ -25,6 +25,13 @@ import pytest pytest.importorskip("textual") +@pytest.fixture(autouse=True) +def _active_registry(active_snapshot_runtime): + """The TUI status bar resolves the compile-time model from the registry; + bootstrap fails closed, so activate it for these tests.""" + return active_snapshot_runtime + + # --------------------------------------------------------------------------- # Module access # diff --git a/tests/test_tui_command_sync.py b/tests/test_tui_command_sync.py index 43015cd..f52cc4c 100644 --- a/tests/test_tui_command_sync.py +++ b/tests/test_tui_command_sync.py @@ -42,9 +42,10 @@ class _StubApp: self.refresh_calls.append(reset_streaming_text) -async def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch): +async def test_sync_tui_command_completion_adopts_agent_swap( + monkeypatch, active_snapshot_runtime +): import EvoScientist.cli.tui_interactive as tui_mod - from EvoScientist import EvoScientist as evosci_mod app = _StubApp() ctx = CommandContext( @@ -54,18 +55,15 @@ async def test_sync_tui_command_completion_adopts_agent_swap(monkeypatch): ) cmd = SimpleNamespace(name="/model") - monkeypatch.setattr( - evosci_mod, - "_ensure_config", - lambda: SimpleNamespace(model="gpt-5.5", provider="openai"), - ) monkeypatch.setattr(tui_mod, "_channels_is_running", lambda: True) app._channel_runtime.bind("old-agent", "old-thread") await tui_mod._sync_tui_command_completion(app, ctx, "old-agent", cmd) assert app._agent_loader.adopt_calls == ["new-agent"] - assert app.model_updates == [("gpt-5.5", "openai")] + # The status label comes from the registry defaults (provider_id/model_key), + # not from config free strings. + assert app.model_updates == [("zhipu-glm/glm-5.2", None)] assert app.refresh_calls == [True] assert app._channel_runtime.agent == "new-agent" assert app._channel_runtime.thread_id == "thread-1" diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py index 9015968..2a9d166 100644 --- a/tests/test_tui_widgets.py +++ b/tests/test_tui_widgets.py @@ -1090,276 +1090,3 @@ class TestCompletionLogic(unittest.TestCase): assert handled is False assert app._comp_index == 0 # unchanged - -@unittest.skipUnless(_has_textual, "textual not installed") -class TestModelPickerWidgetOllama(unittest.TestCase): - """ModelPickerWidget Ollama fallback: sentinel row renders under the - ollama group, selecting it enters free-text input mode, Enter confirms - with ``Picked(typed, "ollama")``, Esc returns to list, and filtering - never hides the sentinel.""" - - def _make_widget(self, entries=None, *, current_model=None): - """Build a widget with a mix of providers + the sentinel row. - - When ``entries`` is ``None``, a default mix (anthropic + one ollama - model + sentinel) is used. When provided, it fully replaces the - default — callers that need extra rows should pass the complete list. - """ - from unittest.mock import MagicMock - - from EvoScientist.cli.widgets.model_picker import ( - _CUSTOM_OLLAMA_ID, - ModelPickerWidget, - ) - - if entries is None: - entries = [ - ("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"), - ("llama3.3", "llama3.3", "ollama"), - ("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"), - ] - w = ModelPickerWidget(entries, current_model=current_model) - # Stub out Textual-dependent side effects so we can drive actions - # directly (follows the module's "no pilot" test pattern). - w.post_message = MagicMock() - w.focus = MagicMock() - # Fake the Input child — real one requires a mounted app. - custom_input = MagicMock() - custom_input.value = "" - custom_input.display = False - w._custom_input = custom_input - return w - - def _sentinel_index(self, widget): - from EvoScientist.cli.widgets.model_picker import _CUSTOM_OLLAMA_ID - - for i, item in enumerate(widget._items): - if item["type"] == "model" and item.get("model_id") == _CUSTOM_OLLAMA_ID: - return i - raise AssertionError(f"sentinel not found in items: {widget._items}") - - def test_sentinel_rendered_under_ollama_group(self): - w = self._make_widget() - # The sentinel must be grouped under an "ollama" header. - headers = [i["label"] for i in w._items if i["type"] == "header"] - assert "ollama" in headers - - def test_selecting_regular_row_posts_picked(self): - """Baseline: non-sentinel selection still works.""" - from EvoScientist.cli.widgets.model_picker import ModelPickerWidget - - w = self._make_widget() - # Find the claude row - claude_idx = next( - i - for i, item in enumerate(w._items) - if item["type"] == "model" and item["name"] == "claude-sonnet-4-6" - ) - w._selected = claude_idx - w.action_select() - assert w._mode == "list" - msgs = [c.args[0] for c in w.post_message.call_args_list] - assert any( - isinstance(m, ModelPickerWidget.Picked) - and m.name == "claude-sonnet-4-6" - and m.provider == "anthropic" - for m in msgs - ) - - def test_selecting_sentinel_enters_input_mode(self): - w = self._make_widget() - w._selected = self._sentinel_index(w) - w.action_select() - - assert w._mode == "input" - assert w._custom_input.display is True - w._custom_input.focus.assert_called_once() - # Entering input mode must NOT post any message — the user hasn't - # submitted anything yet. - w.post_message.assert_not_called() - - def test_enter_with_typed_name_posts_picked(self): - from EvoScientist.cli.widgets.model_picker import ModelPickerWidget - - w = self._make_widget() - w._mode = "input" - w._custom_input.value = "qwen3-coder-next" - w._custom_input.display = True - - w.action_select() - - msgs = [c.args[0] for c in w.post_message.call_args_list] - picked = [m for m in msgs if isinstance(m, ModelPickerWidget.Picked)] - assert len(picked) == 1 - assert picked[0].name == "qwen3-coder-next" - assert picked[0].provider == "ollama" - - def test_enter_with_empty_input_is_noop(self): - w = self._make_widget() - w._mode = "input" - w._custom_input.value = "" - - w.action_select() - - w.post_message.assert_not_called() - # Still in input mode — user can keep typing or Esc out. - assert w._mode == "input" - - def test_enter_with_whitespace_only_input_is_noop(self): - w = self._make_widget() - w._mode = "input" - w._custom_input.value = " \t " - - w.action_select() - - w.post_message.assert_not_called() - assert w._mode == "input" - - def test_esc_in_input_mode_returns_to_list(self): - from EvoScientist.cli.widgets.model_picker import ModelPickerWidget - - w = self._make_widget() - w._mode = "input" - w._custom_input.value = "partial" - w._custom_input.display = True - - w.action_cancel() - - assert w._mode == "list" - assert w._custom_input.display is False - assert w._custom_input.value == "" - # No Cancelled message — Esc from input returns to list, not closes. - cancelled = [ - c.args[0] - for c in w.post_message.call_args_list - if isinstance(c.args[0], ModelPickerWidget.Cancelled) - ] - assert cancelled == [] - - def test_esc_in_list_mode_cancels(self): - from EvoScientist.cli.widgets.model_picker import ModelPickerWidget - - w = self._make_widget() - w._mode = "list" - w.action_cancel() - msgs = [c.args[0] for c in w.post_message.call_args_list] - assert any(isinstance(m, ModelPickerWidget.Cancelled) for m in msgs) - - def test_up_in_input_mode_exits_to_list(self): - w = self._make_widget() - w._mode = "input" - w._custom_input.display = True - - w.action_move_up() - - assert w._mode == "list" - assert w._custom_input.display is False - - def test_down_in_input_mode_absorbed(self): - w = self._make_widget() - w._mode = "input" - before_selected = w._selected - w._custom_input.display = True - - w.action_move_down() - - # State unchanged — key was absorbed. - assert w._mode == "input" - assert w._custom_input.display is True - assert w._selected == before_selected - - def test_backspace_in_input_mode_no_filter_change(self): - w = self._make_widget() - w._mode = "input" - w._filter_text = "foo" - - w.action_backspace() - - # Input widget handles its own backspace — filter unchanged. - assert w._filter_text == "foo" - - def test_printable_key_in_input_mode_does_not_filter(self): - from unittest.mock import MagicMock - - w = self._make_widget() - w._mode = "input" - w._filter_text = "" - - event = MagicMock() - event.key = "a" - event.character = "a" - - w.on_key(event) - - assert w._filter_text == "" - - def test_duplicate_sentinels_collapsed(self): - """Defense-in-depth: even if callers pass two sentinel rows (state - reuse, stale merges), only one "Custom Ollama model..." renders.""" - from EvoScientist.cli.widgets.model_picker import ( - _CUSTOM_OLLAMA_ID, - _build_items, - ) - - entries = [ - ("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"), - ("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"), - ("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"), - ("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"), - ] - items = _build_items(entries) - sentinel_rows = [ - i - for i in items - if i["type"] == "model" and i.get("model_id") == _CUSTOM_OLLAMA_ID - ] - assert len(sentinel_rows) == 1, f"duplicate sentinels rendered: {items}" - - def test_sentinel_survives_filter(self): - """The Custom Ollama row is the user's escape hatch — filtering - must never hide it.""" - from EvoScientist.cli.widgets.model_picker import ( - _CUSTOM_OLLAMA_ID, - _build_items, - ) - - entries = [ - ("claude-sonnet-4-6", "claude-sonnet-4-6", "anthropic"), - ("llama3.3", "llama3.3", "ollama"), - ("Custom Ollama model...", _CUSTOM_OLLAMA_ID, "ollama"), - ] - # A filter that matches NOTHING in the normal entries. - items = _build_items(entries, filter_text="zzzzzz") - sentinel_rows = [ - i - for i in items - if i["type"] == "model" and i.get("model_id") == _CUSTOM_OLLAMA_ID - ] - assert len(sentinel_rows) == 1, f"sentinel hidden by filter: {items}" - - def test_on_input_submitted_routes_to_submit(self): - """Belt-and-suspenders: Enter fired inside the Input widget should - be handled the same way as action_select in input mode.""" - from unittest.mock import MagicMock - - from EvoScientist.cli.widgets.model_picker import ModelPickerWidget - - w = self._make_widget() - w._mode = "input" - w._custom_input.value = "mymodel" - - event = MagicMock() - event.input = w._custom_input # the Input child we stubbed - - w.on_input_submitted(event) - - event.stop.assert_called_once() - msgs = [c.args[0] for c in w.post_message.call_args_list] - picked = [m for m in msgs if isinstance(m, ModelPickerWidget.Picked)] - assert len(picked) == 1 - assert picked[0].name == "mymodel" - assert picked[0].provider == "ollama" - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_workspace_scope_http.py b/tests/test_workspace_scope_http.py index 990fe57..b2fbfc0 100644 --- a/tests/test_workspace_scope_http.py +++ b/tests/test_workspace_scope_http.py @@ -14,6 +14,13 @@ from EvoScientist.langgraph_dev.http import app from EvoScientist.workspace_scope import DeferredScopedBackend, create_workspace_backend +@pytest.fixture(autouse=True) +def _active_registry(active_snapshot_runtime): + """Run entry points freeze a local snapshot per run; that fails closed + against the default bootstrap registry, so activate it for these tests.""" + return active_snapshot_runtime + + def test_scope_routes_require_service_token(monkeypatch): monkeypatch.delenv("EVOSCIENTIST_BACKEND_SERVICE_TOKEN", raising=False) with TestClient(app) as client: diff --git a/uv.lock b/uv.lock index 3e463a7..ff843c1 100644 --- a/uv.lock +++ b/uv.lock @@ -966,6 +966,7 @@ dependencies = [ { name = "prompt-toolkit" }, { name = "psutil" }, { name = "pydantic" }, + { name = "pyjwt" }, { name = "python-dotenv" }, { name = "pyyaml" }, { name = "questionary" }, @@ -1078,6 +1079,7 @@ requires-dist = [ { name = "pycryptodome", marker = "extra == 'all-channels'", specifier = ">=3.20" }, { name = "pycryptodome", marker = "extra == 'wechat'", specifier = ">=3.20" }, { name = "pydantic", specifier = ">=2.10" }, + { name = "pyjwt", specifier = ">=2.8" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, diff --git a/湘江实验室科研项目指南建议征集表.docx b/湘江实验室科研项目指南建议征集表.docx new file mode 100644 index 0000000000000000000000000000000000000000..c6b629682383616e00f3f23a3489aef30d53d8d8 GIT binary patch literal 298960 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 z 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

r8Anm78(Fa;!%3lhl($q1i> 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

L?EUhN3nQd5UT3N<|E6Z1gS=VciGhbl? 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 literal 0 HcmV?d00001