diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 2b99ac0708..f743a64eeb 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -834,7 +834,7 @@ def _read_main_provider() -> str: if isinstance(model_cfg, dict): provider = model_cfg.get("provider", "") if isinstance(provider, str) and provider.strip(): - return _normalize_aux_provider(provider) + return provider.strip().lower() except Exception: pass return "" @@ -1470,19 +1470,25 @@ def _preferred_main_vision_provider() -> Optional[str]: def get_available_vision_backends() -> List[str]: """Return the currently available vision backends in auto-selection order. - Order: OpenRouter → Nous → active provider. This is the single source - of truth for setup, tool gating, and runtime auto-routing of vision tasks. + Order: active provider → OpenRouter → Nous → stop. This is the single + source of truth for setup, tool gating, and runtime auto-routing of + vision tasks. """ - available = [p for p in _VISION_AUTO_PROVIDER_ORDER - if _strict_vision_backend_available(p)] - # Also check the user's active provider (may be DeepSeek, Alibaba, named - # custom, etc.) — resolve_provider_client handles all provider types. + available: List[str] = [] + # 1. Active provider — if the user configured a provider, try it first. main_provider = _read_main_provider() - if (main_provider and main_provider not in ("auto", "") - and main_provider not in available): - client, _ = resolve_provider_client(main_provider, _read_main_model()) - if client is not None: - available.append(main_provider) + if main_provider and main_provider not in ("auto", ""): + if main_provider in _VISION_AUTO_PROVIDER_ORDER: + if _strict_vision_backend_available(main_provider): + available.append(main_provider) + else: + client, _ = resolve_provider_client(main_provider, _read_main_model()) + if client is not None: + available.append(main_provider) + # 2. OpenRouter, 3. Nous — skip if already covered by main provider. + for p in _VISION_AUTO_PROVIDER_ORDER: + if p not in available and _strict_vision_backend_available(p): + available.append(p) return available @@ -1529,28 +1535,37 @@ def resolve_vision_provider_client( if requested == "auto": # Vision auto-detection order: - # 1. OpenRouter (known vision-capable default model) - # 2. Nous Portal (known vision-capable default model) - # 3. Active provider + model (user's main chat config) + # 1. Active provider + model (user's main chat config) + # 2. OpenRouter (known vision-capable default model) + # 3. Nous Portal (known vision-capable default model) # 4. Stop - for candidate in _VISION_AUTO_PROVIDER_ORDER: - sync_client, default_model = _resolve_strict_vision_backend(candidate) - if sync_client is not None: - return _finalize(candidate, sync_client, default_model) - - # Fall back to the user's active provider + model. main_provider = _read_main_provider() main_model = _read_main_model() if main_provider and main_provider not in ("auto", ""): - sync_client, resolved_model = resolve_provider_client( - main_provider, main_model) + if main_provider in _VISION_AUTO_PROVIDER_ORDER: + # Known strict backend — use its defaults. + sync_client, default_model = _resolve_strict_vision_backend(main_provider) + if sync_client is not None: + return _finalize(main_provider, sync_client, default_model) + else: + # Exotic provider (DeepSeek, Alibaba, named custom, etc.) + rpc_client, rpc_model = resolve_provider_client( + main_provider, main_model) + if rpc_client is not None: + logger.info( + "Vision auto-detect: using active provider %s (%s)", + main_provider, rpc_model or main_model, + ) + return _finalize( + main_provider, rpc_client, rpc_model or main_model) + + # Fall back through aggregators. + for candidate in _VISION_AUTO_PROVIDER_ORDER: + if candidate == main_provider: + continue # already tried above + sync_client, default_model = _resolve_strict_vision_backend(candidate) if sync_client is not None: - logger.info( - "Vision auto-detect: using active provider %s (%s)", - main_provider, resolved_model or main_model, - ) - return _finalize( - main_provider, sync_client, resolved_model or main_model) + return _finalize(candidate, sync_client, default_model) logger.debug("Auxiliary vision client: none available") return None, None, None diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 14364a1e91..5b1d3376af 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -136,7 +136,7 @@ DEFAULT_CONTEXT_LENGTHS = { "deepseek-ai/DeepSeek-V3.2": 65536, "moonshotai/Kimi-K2.5": 262144, "moonshotai/Kimi-K2-Thinking": 262144, - "minimaxai/minimax-m2.5": 1048576, + "MiniMaxAI/MiniMax-M2.5": 1048576, "XiaomiMiMo/MiMo-V2-Flash": 32768, "mimo-v2-pro": 1048576, "mimo-v2-omni": 1048576, diff --git a/cli.py b/cli.py index f00e6b7fea..f0edf67ee2 100644 --- a/cli.py +++ b/cli.py @@ -4668,13 +4668,13 @@ class HermesCLI: if output: self.console.print(_rich_text_from_ansi(output)) else: - ChatConsole().print("[dim]Command returned no output[/]") + self.console.print("[dim]Command returned no output[/]") except subprocess.TimeoutExpired: - ChatConsole().print("[bold red]Quick command timed out (30s)[/]") + self.console.print("[bold red]Quick command timed out (30s)[/]") except Exception as e: - ChatConsole().print(f"[bold red]Quick command error: {e}[/]") + self.console.print(f"[bold red]Quick command error: {e}[/]") else: - ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]") + self.console.print(f"[bold red]Quick command '{base_cmd}' has no command defined[/]") elif qcmd.get("type") == "alias": target = qcmd.get("target", "").strip() if target: @@ -4683,9 +4683,9 @@ class HermesCLI: aliased_command = f"{target} {user_args}".strip() return self.process_command(aliased_command) else: - ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]") + self.console.print(f"[bold red]Quick command '{base_cmd}' has no target defined[/]") else: - ChatConsole().print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") + self.console.print(f"[bold red]Quick command '{base_cmd}' has unsupported type (supported: 'exec', 'alias')[/]") # Check for plugin-registered slash commands elif base_cmd.lstrip("/") in _get_plugin_cmd_handler_names(): from hermes_cli.plugins import get_plugin_command_handler diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index c7cd12ae71..dd02ad23ab 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -737,8 +737,8 @@ class TestAuxiliaryPoolAwareness: assert client is not None assert client.__class__.__name__ == "AnthropicAuxiliaryClient" - def test_vision_auto_prefers_openrouter_over_active_provider(self, monkeypatch): - """OpenRouter is tried before the active provider in vision auto.""" + def test_vision_auto_prefers_active_provider_over_openrouter(self, monkeypatch): + """Active provider is tried before OpenRouter in vision auto.""" monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") monkeypatch.setenv("ANTHROPIC_API_KEY", "***") @@ -746,12 +746,13 @@ class TestAuxiliaryPoolAwareness: patch("agent.auxiliary_client._read_nous_auth", return_value=None), patch("agent.auxiliary_client._read_main_provider", return_value="anthropic"), patch("agent.auxiliary_client._read_main_model", return_value="claude-sonnet-4"), - patch("agent.auxiliary_client.OpenAI") as mock_openai, + patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()), + patch("agent.anthropic_adapter.resolve_anthropic_token", return_value="***"), ): provider, client, model = resolve_vision_provider_client() - # OpenRouter should win over anthropic active provider - assert provider == "openrouter" + # Active provider should win over OpenRouter + assert provider == "anthropic" def test_vision_auto_uses_named_custom_as_active_provider(self, monkeypatch): """Named custom provider works as active provider fallback in vision auto.""" diff --git a/tools/environments/docker.py b/tools/environments/docker.py index 4d3b6f50d1..b97040d4e0 100644 --- a/tools/environments/docker.py +++ b/tools/environments/docker.py @@ -505,14 +505,17 @@ class DockerEnvironment(BaseEnvironment): # (dynamic from host process). Forward values take precedence. exec_env: dict[str, str] = dict(self._env) - forward_keys = set(self._forward_env) + explicit_forward_keys = set(self._forward_env) + passthrough_keys: set[str] = set() try: from tools.env_passthrough import get_all_passthrough - forward_keys |= get_all_passthrough() + passthrough_keys = set(get_all_passthrough()) except Exception: pass - # Strip Hermes-managed secrets so they never leak into the container. - forward_keys -= _HERMES_PROVIDER_ENV_BLOCKLIST + # Explicit docker_forward_env entries are an intentional opt-in and must + # win over the generic Hermes secret blocklist. Only implicit passthrough + # keys are filtered. + forward_keys = explicit_forward_keys | (passthrough_keys - _HERMES_PROVIDER_ENV_BLOCKLIST) hermes_env = _load_hermes_env_vars() if forward_keys else {} for key in sorted(forward_keys): value = os.getenv(key)