# NousResearch/hermes-agent · Gateway config and model resolution fixes

## Open now

- Nothing open on GitHub.

These items address bugs and improvements in the gateway layer, specifically around hygiene compression not triggering for local/Ollama models, correct use of configured context_length and base_url, session config surfacing, and provider/model resolution fixes.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #3321. Verify it addresses your case.
- **Recommended:** read PR #3321 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-5983`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #2708: Gateway hygiene compression never triggers for local/Ollama models

## Prior & competing PRs

- **#3321: feat(gateway): surface session config on /new, /reset, and auto-reset**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `gateway/run.py`, `tests/gateway/test_session_info.py`
  - fix hunks:
  - `gateway/run.py`

    ```diff
    @@ -1982,6 +1982,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
                                 f"Use /resume to browse and restore a previous session.\n"
                                 f"Adjust reset timing in config.yaml under session_reset."
                             )
    +                        try:
    +                            session_info = self._format_session_info()
    +                            if session_info:
    +                                notice = f"{notice}\n\n{session_info}"
    +                        except Exception:
    +                            pass
                             await adapter.send(
                                 source.chat_id, notice,
                                 metadata=getattr(event, 'metadata', None),
    ```

    ```diff
    @@ -2749,6 +2755,85 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
                 # Clear session env
                 self._clear_session_env()
         
    +    def _format_session_info(self) -> str:
    +        """Resolve current model config and return a formatted info block.
    +
    +        Surfaces model, provider, context length, and endpoint so gateway
    +        users can immediately see if context detection went wrong (e.g.
    +        local models falling to the 128K default).
    +        """
    +        from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT
    +
    +        model = _resolve_gateway_model()
    +        config_context_length = None
    +        provider = None
    +        base_url = None
    +        api_key = None
    +
    +        try:
    +            cfg_path = _hermes_home / "config.yaml"
    +            if cfg_path.exists():
    +                import yaml as _info_yaml
    +                with open(cfg_path, encoding="utf-8") as f:
    +                    data = _info_yaml.safe_load(f) or {}
    +                model_cfg = data.get("model", {})
    +                if isinstance(model_cfg, dict):
    +                    raw_ctx = model_cfg.get("context_length")
    +                    if raw_ctx is not None:
    +                        try:
    +                            config_context_length = int(raw_ctx)
    +                        except (TypeError, ValueError):
    +                            pass
    +                    provider = model_cfg.get("provider") or None
    +                    base_url = model_cfg.get("base_url") or None
    +        except Exception:
    +            pass
    +
    +        # Resolve runtime credentials for probing
    +        try:
    +            runtime = _resolve_runtime_agent_kwargs()
    +            provider = provider or runtime.get("provider")
    +            base_url = base_url or runtime.get("base_url")
    +            api_key = [redacted:secret-assignment]"api_key")
    +        except Exception:
    +            pass
    +
    +        context_length = get_model_context_length(
    +            model,
    +            base_url=base_url or "",
    +            api_key=[redacted:secret-assignment] or "",
    +            config_context_length=config_context_length,
    +            provider=provider or "",
    +        )
    +
    +        # Format context source hint
    +        if config_context_length is not None:
    +            ctx_source = "config"
    +        elif context_length == DEFAULT_FALLBACK_CONTEXT:
    +            ctx_source = "default — set model.context_length in config to override"
    +        else:
    +            ctx_source = "detected"
    +
    +        # Format context length for display
    +        if context_length >= 1_000_000:
    +            ctx_display = f"{context_length / 1_000_000:.1f}M"
    +        elif context_length >= 1_000:
    +            ctx_display = f"{context_length // 1_000}K"
    +        else:
    +            ctx_display = str(context_length)
    +
    +        lines = [
    +            f"◆ Model: `{model}`",
    +            f"◆ Provider: {provider or 'openrouter'}",
    +            f"◆ Context: {ctx_display} tokens ({ctx_source})",
    +        ]
    +
    +        # Show endpoint for local/custom setups
    +        if base_url and ("localhost" in base_url or "127.0.0.1" in base_url or "0.0.0.0" in base_url):
    +            lines.append(f"◆ Endpoint: {base_url}")
    +
    +        return "\n".join(lines)
    +
         async def _handle_reset_command(self, event: MessageEvent) -> str:
             """Handle /new or /reset command."""
             source = event.source
    ```

    ```diff
    @@ -2789,12 +2874,22 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:
                 "session_key": session_key,
             })
             
    +        # Resolve session config info to surface to the user
    +        try:
    +            session_info = self._format_session_info()
    +        except Exception:
    +            session_info = ""
    +
             if new_entry:
    -            return "✨ Session reset! I've started fresh with no memory of our previous conversation."
    +            header = "✨ Session reset! Starting fresh."
             else:
                 # No existing session, just create one
                 self.session_store.get_or_create_session(source, force_new=True)
    -            return "✨ New session started!"
    +            header = "✨ New session started!"
    +
    +        if session_info:
    +            return f"{header}\n\n{session_info}"
    +        return header
         
         async def _handle_status_command(self, event: MessageEvent) -> str:
             """Handle /status command."""
    ```
  - `tests/gateway/test_session_info.py`

    ```diff
    @@ -0,0 +1,110 @@
    +"""Tests for GatewayRunner._format_session_info — session config surfacing."""
    +
    +import pytest
    +from unittest.mock import patch, MagicMock
    +from pathlib import Path
    +
    +from gateway.run import GatewayRunner
    +
    +
    [redacted:email]()
    +def runner():
    +    """Create a bare GatewayRunner without __init__."""
    +    return GatewayRunner.__new__(GatewayRunner)
    +
    +
    +def _patch_info(tmp_path, config_yaml, model, runtime):
    +    """Return a context-manager stack that patches _format_session_info deps."""
    +    cfg_path = tmp_path / "config.yaml"
    +    if config_yaml is not None:
    +        cfg_path.write_text(config_yaml)
    +    return (
    +        patch("gateway.run._hermes_home", tmp_path),
    +        patch("gateway.run._resolve_gateway_model", return_value=model),
    +        patch("gateway.run._resolve_runtime_agent_kwargs", return_value=runtime),
    +    )
    +
    +
    +class TestFormatSessionInfo:
    +
    +    def test_includes_model_name(self, runner, tmp_path):
    +        p1, p2, p3 = _patch_info(tmp_path, "model:\n  default: anthropic/claude-opus-4.6\n  provider: openrouter\n",
    +                                  "anthropic/claude-opus-4.6",
    +                                  {"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1", "api_key": "k"})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "claude-opus-4.6" in info
    +
    +    def test_includes_provider(self, runner, tmp_path):
    +        p1, p2, p3 = _patch_info(tmp_path, "model:\n  default: test-model\n  provider: openrouter\n",
    +                                  "test-model",
    +                                  {"provider": "openrouter", "base_url": "", "api_key": ""})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "openrouter" in info
    +
    +    def test_config_context_length(self, runner, tmp_path):
    +        p1, p2, p3 = _patch_info(tmp_path, "model:\n  default: test-model\n  context_length: 32768\n",
    +                                  "test-model",
    +                                  {"provider": "custom", "base_url": "", "api_key": ""})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "32K" in info
    +        assert "config" in info
    +
    +    def test_default_fallback_hint(self, runner, tmp_path):
    +        p1, p2, p3 = _patch_info(tmp_path, "model:\n  default: unknown-model-xyz\n",
    +                                  "unknown-model-xyz",
    +                                  {"provider": "", "base_url": "", "api_key": ""})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "128K" in info
    +        assert "model.context_length" in info
    +
    +    def test_local_endpoint_shown(self, runner, tmp_path):
    +        p1, p2, p3 = _patch_info(
    +            tmp_path,
    +            "model:\n  default: qwen3:8b\n  provider: custom\n  base_url: http://localhost:11434/v1\n  context_length: 8192\n",
    +            "qwen3:8b",
    +            {"provider": "custom", "base_url": "http://localhost:11434/v1", "api_key": ""})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "localhost:11434" in info
    +        assert "8K" in info
    +
    +    def test_cloud_endpoint_hidden(self, runner, tmp_path):
    +        p1, p2, p3 = _patch_info(tmp_path, "model:\n  default: test-model\n  provider: openrouter\n",
    +                                  "test-model",
    +                                  {"provider": "openrouter", "base_url": "https://openrouter.ai/api/v1", "api_key": "k"})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "Endpoint" not in info
    +
    +    def test_million_context_format(self, runner, tmp_path):
    +        p1, p2, p3 = _patch_info(tmp_path, "model:\n  default: test-model\n  context_length: 1000000\n",
    +                                  "test-model",
    +                                  {"provider": "", "base_url": "", "api_key": ""})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "1.0M" in info
    +
    +    def test_missing_config(self, runner, tmp_path):
    +        """No config.yaml should not crash."""
    +        p1, p2, p3 = _patch_info(tmp_path, None,  # don't create config
    +                                  "anthropic/claude-sonnet-4.6",
    +                                  {"provider": "openrouter", "base_url": "", "api_key": ""})
    +        with p1, p2, p3:
    +            info = runner._format_session_info()
    +        assert "Model" in info
    +        assert "Context" in info
    +
    +    def test_runtime_resolution_failure_doesnt_crash(self, runner, tmp_path):
    +        """If runtime resolution raises, should still produce output."""
    +        cfg_path = tmp_path / "config.yaml"
    +        cfg_path.write_text("model:\n  default: test-model\n  context_length: 4096\n")
    +        with patch("gateway.run._hermes_home", tmp_path), \
    +             patch("gateway.run._resolve_gateway_model", return_value="test-model"), \
    +             patch("gateway.run._resolve_runtime_agent_kwargs", side_effect=RuntimeError("no creds")):
    +            info = runner._format_session_info()
    +        assert "4K" in info
    +        assert "config" in info
    ```
- **#5983: fix: provider/model resolution — salvage 4 community PRs + MiniMax aux URL fix**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/auxiliary_client.py`, `agent/model_metadata.py`, `hermes_cli/model_switch.py`, `run_agent.py`, `tests/agent/test_auxiliary_client.py`, `tests/agent/test_minimax_auxiliary_url.py`, `tests/test_ollama_num_ctx.py`
  - fix hunks:
  - `agent/auxiliary_client.py`

    ```diff
    @@ -59,6 +59,41 @@
     
     logger = logging.getLogger(__name__)
     
    +_PROVIDER_ALIASES = {
    +    "google": "gemini",
    +    "google-gemini": "gemini",
    +    "google-ai-studio": "gemini",
    +    "glm": "zai",
    +    "z-ai": "zai",
    +    "z.ai": "zai",
    +    "zhipu": "zai",
    +    "kimi": "kimi-coding",
    +    "moonshot": "kimi-coding",
    +    "minimax-china": "minimax-cn",
    +    "minimax_cn": "minimax-cn",
    +    "claude": "anthropic",
    +    "claude-code": "anthropic",
    +}
    +
    +
    +def _normalize_aux_provider(provider: Optional[str], *, for_vision: bool = False) -> str:
    +    normalized = (provider or "auto").strip().lower()
    +    if normalized.startswith("custom:"):
    +        suffix = normalized.split(":", 1)[1].strip()
    +        if not suffix:
    +            return "custom"
    +        normalized = suffix if not for_vision else "custom"
    +    if normalized == "codex":
    +        return "openai-codex"
    +    if normalized == "main":
    +        # Resolve to the user's actual main provider so named custom providers
    +        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.
    +        main_prov = _read_main_provider()
    +        if main_prov and main_prov not in ("auto", "main", ""):
    +            return main_prov
    +        return "custom"
    +    return _PROVIDER_ALIASES.get(normalized, normalized)
    +
     # Default auxiliary models for direct API-key providers (cheap/fast for side tasks)
     _API_KEY_PROVIDER_AUX_MODELS: [redacted:secret-assignment] str] = {
         "gemini": "gemini-3-flash-preview",
    ```

    ```diff
    @@ -106,6 +141,23 @@
     _CODEX_AUX_BASE_URL = "https://chatgpt.com/backend-api/codex"
     
     
    +def _to_openai_base_url(base_url: str) -> str:
    +    """Normalize an Anthropic-style base URL to OpenAI-compatible format.
    +
    +    Some providers (MiniMax, MiniMax-CN) expose an ``/anthropic`` endpoint for
    +    the Anthropic Messages API and a separate ``/v1`` endpoint for OpenAI chat
    +    completions.  The auxiliary client uses the OpenAI SDK, so it must hit the
    +    ``/v1`` surface.  Passing the raw ``inference_base_url`` causes requests to
    +    land on ``/anthropic/chat/completions`` — a 404.
    +    """
    +    url = str(base_url or "").strip().rstrip("/")
    +    if url.endswith("/anthropic"):
    +        rewritten = url[: -len("/anthropic")] + "/v1"
    +        logger.debug("Auxiliary client: rewrote base URL %s → %s", url, rewritten)
    +        return rewritten
    +    return url
    +
    +
     def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:
         """Return (pool_exists_for_provider, selected_entry)."""
         try:
    ```

    ```diff
    @@ -635,7 +687,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
                 if not api_key:
                     continue
     
    -            base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
    +            base_url = _to_openai_base_url(
    +                _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url
    +            )
                 model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
                 logger.debug("Auxiliary text client: %s (%s) via pool", pconfig.name, model)
                 extra = {}
    ```

    ```diff
    @@ -652,7 +706,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:
             if not api_key:
                 continue
     
    -        base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
    +        base_url = _to_openai_base_url(
    +            str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
    +        )
             model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, "default")
             logger.debug("Auxiliary text client: %s (%s)", pconfig.name, model)
             extra = {}
    ```

    ```diff
    @@ -778,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 provider.strip().lower()
    +                return _normalize_aux_provider(provider)
         except Exception:
             pass
         return ""
    ```

    ```diff
    @@ -1140,17 +1196,7 @@ def resolve_provider_client(
             (client, resolved_model) or (None, None) if auth is unavailable.
         """
         # Normalise aliases
    -    provider = (provider or "auto").strip().lower()
    -    if provider == "codex":
    -        provider = "openai-codex"
    -    if provider == "main":
    -        # Resolve to the user's actual main provider so named custom providers
    -        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.
    -        main_prov = _read_main_provider()
    -        if main_prov and main_prov not in ("auto", "main", ""):
    -            provider = main_prov
    -        else:
    -            provider = "custom"
    +    provider = _normalize_aux_provider(provider)
     
         # ── Auto: try all providers in priority order ────────────────────
         if provider == "auto":
    ```

    ```diff
    @@ -1300,7 +1346,9 @@ def resolve_provider_client(
                              provider, ", ".join(tried_sources))
                 return None, None
     
    -        base_url = str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
    +        base_url = _to_openai_base_url(
    +            str(creds.get("base_url", "")).strip().rstrip("/") or pconfig.inference_base_url
    +        )
     
             default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, "")
             final_model = model or default_model
    ```

    ```diff
    @@ -1384,17 +1432,7 @@ def get_async_text_auxiliary_client(task: str = ""):
     
     
     def _normalize_vision_provider(provider: Optional[str]) -> str:
    -    provider = (provider or "auto").strip().lower()
    -    if provider == "codex":
    -        return "openai-codex"
    -    if provider == "main":
    -        # Resolve to actual main provider — named custom providers and
    -        # non-aggregator providers need to pass through as their real name.
    -        main_prov = _read_main_provider()
    -        if main_prov and main_prov not in ("auto", "main", ""):
    -            return main_prov
    -        return "custom"
    -    return provider
    +    return _normalize_aux_provider(provider, for_vision=True)
     
     
     def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Optional[str]]:
    ```
  - `agent/model_metadata.py`

    ```diff
    @@ -611,6 +611,59 @@ def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:
         return False
     
     
    +def query_ollama_num_ctx(model: str, base_url: str) -> Optional[int]:
    +    """Query an Ollama server for the model's context length.
    +
    +    Returns the model's maximum context from GGUF metadata via ``/api/show``,
    +    or the explicit ``num_ctx`` from the Modelfile if set.  Returns None if
    +    the server is unreachable or not Ollama.
    +
    +    This is the value that should be passed as ``num_ctx`` in Ollama chat
    +    requests to override the default 2048.
    +    """
    +    import httpx
    +
    +    bare_model = _strip_provider_prefix(model)
    +    server_url = base_url.rstrip("/")
    +    if server_url.endswith("/v1"):
    +        server_url = server_url[:-3]
    +
    +    try:
    +        server_type = detect_local_server_type(base_url)
    +    except Exception:
    +        return None
    +    if server_type != "ollama":
    +        return None
    +
    +    try:
    +        with httpx.Client(timeout=3.0) as client:
    +            resp = client.post(f"{server_url}/api/show", json={"name": bare_model})
    +            if resp.status_code != 200:
    +                return None
    +            data = resp.json()
    +
    +            # Prefer explicit num_ctx from Modelfile parameters (user override)
    +            params = data.get("parameters", "")
    +            if "num_ctx" in params:
    +                for line in params.split("\n"):
    +                    if "num_ctx" in line:
    +                        parts = line.strip().split()
    +                        if len(parts) >= 2:
    +                            try:
    +                                return int(parts[-1])
    +                            except ValueError:
    +                                pass
    +
    +            # Fall back to GGUF model_info context_length (training max)
    +            model_info = data.get("model_info", {})
    +            for key, value in model_info.items():
    +                if "context_length" in key and isinstance(value, (int, float)):
    +                    return int(value)
    +    except Exception:
    +        pass
    +    return None
    +
    +
     def _query_local_context_length(model: str, base_url: str) -> Optional[int]:
         """Query a local server for the model's context length."""
         import httpx
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -791,12 +791,12 @@ def list_authenticated_providers(
             if overlay.auth_type in ("oauth_device_code", "oauth_external", "external_process"):
                 # These use auth stores, not env vars — check for auth.json entries
                 try:
    -                from hermes_cli.auth import _read_auth_store
    -                store = _read_auth_store()
    -                if store and pid in store:
    +                from hermes_cli.auth import _load_auth_store
    +                store = _load_auth_store()
    +                if store and (pid in store.get("providers", {}) or pid in store.get("credential_pool", {})):
                         has_creds = True
    -            except Exception:
    -                pass
    +            except Exception as exc:
    +                logger.debug("Auth store check failed for %s: %s", pid, exc)
             if not has_creds:
                 continue
     
    ```
  - `run_agent.py`

    ```diff
    @@ -85,6 +85,7 @@
         estimate_tokens_rough, estimate_messages_tokens_rough, estimate_request_tokens_rough,
         get_next_probe_tier, parse_context_limit_from_error,
         save_context_length, is_local_endpoint,
    +    query_ollama_num_ctx,
     )
     from agent.context_compressor import ContextCompressor
     from agent.subdirectory_hints import SubdirectoryHintTracker
    ```

    ```diff
    @@ -1216,6 +1217,33 @@ def __init__(
             self.session_cost_status = "unknown"
             self.session_cost_source = "none"
             
    +        # ── Ollama num_ctx injection ──
    +        # Ollama defaults to 2048 context regardless of the model's capabilities.
    +        # When running against an Ollama server, detect the model's max context
    +        # and pass num_ctx on every chat request so the full window is used.
    +        # User override: set model.ollama_num_ctx in config.yaml to cap VRAM use.
    +        self._ollama_num_ctx: int | None = None
    +        _ollama_num_ctx_override = None
    +        if isinstance(_model_cfg, dict):
    +            _ollama_num_ctx_override = _model_cfg.get("ollama_num_ctx")
    +        if _ollama_num_ctx_override is not None:
    +            try:
    +                self._ollama_num_ctx = int(_ollama_num_ctx_override)
    +            except (TypeError, ValueError):
    +                logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override)
    +        if self._ollama_num_ctx is None and self.base_url and is_local_endpoint(self.base_url):
    +            try:
    +                _detected = query_ollama_num_ctx(self.model, self.base_url)
    +                if _detected and _detected > 0:
    +                    self._ollama_num_ctx = _detected
    +            except Exception as exc:
    +                logger.debug("Ollama num_ctx detection failed: %s", exc)
    +        if self._ollama_num_ctx and not self.quiet_mode:
    +            logger.info(
    +                "Ollama num_ctx: will request %d tokens (model max from /api/show)",
    +                self._ollama_num_ctx,
    +            )
    +
             if not self.quiet_mode:
                 if compression_enabled:
                     print(f"📊 Context limit: {self.context_compressor.context_length:,} tokens (compress at {int(compression_threshold*100)}% = {self.context_compressor.threshold_tokens:,})")
    ```

    ```diff
    @@ -5456,6 +5484,15 @@ def _build_api_kwargs(self, api_messages: list) -> dict:
             if _is_nous:
                 extra_body["tags"] = ["product=hermes-agent"]
     
    +        # Ollama num_ctx: override the 2048 default so the model actually
    +        # uses the context window it was trained for.  Passed via the OpenAI
    +        # SDK's extra_body → options.num_ctx, which Ollama's OpenAI-compat
    +        # endpoint forwards to the runner as --ctx-size.
    +        if self._ollama_num_ctx:
    +            options = extra_body.get("options", {})
    +            options["num_ctx"] = self._ollama_num_ctx
    +            extra_body["options"] = options
    +
             if extra_body:
                 api_kwargs["extra_body"] = extra_body
     
    ```
  - `tests/agent/test_auxiliary_client.py`

    ```diff
    @@ -471,6 +471,23 @@ def test_explicit_zai(self, monkeypatch):
                 client, model = resolve_provider_client("zai")
                 assert client is not None
     
    +    def test_explicit_google_alias_uses_gemini_credentials(self):
    +        """provider='google' should route through the gemini API-key provider."""
    +        with (
    +            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
    +                "api_key": "gemini-key",
    +                "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
    +            }),
    +            patch("agent.auxiliary_client.OpenAI") as mock_openai,
    +        ):
    +            mock_openai.return_value = MagicMock()
    +            client, model = resolve_provider_client("google", model="gemini-3.1-pro-preview")
    +
    +        assert client is not None
    +        assert model == "gemini-3.1-pro-preview"
    +        assert mock_openai.call_args.kwargs["api_key"] == "gemini-key"
    +        assert mock_openai.call_args.kwargs["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
    +
         def test_explicit_unknown_returns_none(self, monkeypatch):
             """Unknown provider should return None."""
             client, model = resolve_provider_client("nonexistent-provider")
    ```

    ```diff
    @@ -822,6 +839,31 @@ def test_vision_uses_nous_when_available(self, monkeypatch):
             assert model == "google/gemini-3-flash-preview"
             assert client is not None
     
    +    def test_vision_config_google_provider_uses_gemini_credentials(self, monkeypatch):
    +        config = {
    +            "auxiliary": {
    +                "vision": {
    +                    "provider": "google",
    +                    "model": "gemini-3.1-pro-preview",
    +                }
    +            }
    +        }
    +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: config)
    +        with (
    +            patch("hermes_cli.auth.resolve_api_key_provider_credentials", return_value={
    +                "api_key": "gemini-key",
    +                "base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
    +            }),
    +            patch("agent.auxiliary_client.OpenAI") as mock_openai,
    +        ):
    +            resolved_provider, client, model = resolve_vision_provider_client()
    +
    +        assert resolved_provider == "gemini"
    +        assert client is not None
    +        assert model == "gemini-3.1-pro-preview"
    +        assert mock_openai.call_args.kwargs["api_key"] == "gemini-key"
    +        assert mock_openai.call_args.kwargs["base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
    +
         def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch):
             """When explicitly forced to 'main', vision CAN use custom endpoint."""
             config = {
    ```
  - `tests/agent/test_minimax_auxiliary_url.py`

    ```diff
    @@ -0,0 +1,42 @@
    +"""Tests for MiniMax auxiliary client URL normalization.
    +
    +MiniMax and MiniMax-CN set inference_base_url to the /anthropic path.
    +The auxiliary client uses the OpenAI SDK, which needs /v1 instead.
    +"""
    +
    +import sys
    +import os
    +
    +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
    +
    +from agent.auxiliary_client import _to_openai_base_url
    +
    +
    +class TestToOpenaiBaseUrl:
    +    def test_minimax_global_anthropic_suffix_replaced(self):
    +        assert _to_openai_base_url("https://api.minimax.io/anthropic") == "https://api.minimax.io/v1"
    +
    +    def test_minimax_cn_anthropic_suffix_replaced(self):
    +        assert _to_openai_base_url("https://api.minimaxi.com/anthropic") == "https://api.minimaxi.com/v1"
    +
    +    def test_trailing_slash_stripped_before_replace(self):
    +        assert _to_openai_base_url("https://api.minimax.io/anthropic/") == "https://api.minimax.io/v1"
    +
    +    def test_v1_url_unchanged(self):
    +        assert _to_openai_base_url("https://api.openai.com/v1") == "https://api.openai.com/v1"
    +
    +    def test_openrouter_url_unchanged(self):
    +        assert _to_openai_base_url("https://openrouter.ai/api/v1") == "https://openrouter.ai/api/v1"
    +
    +    def test_anthropic_domain_unchanged(self):
    +        """api.anthropic.com doesn't end with /anthropic — should be untouched."""
    +        assert _to_openai_base_url("https://api.anthropic.com") == "https://api.anthropic.com"
    +
    +    def test_anthropic_in_subpath_unchanged(self):
    +        assert _to_openai_base_url("https://example.com/anthropic/extra") == "https://example.com/anthropic/extra"
    +
    +    def test_empty_string(self):
    +        assert _to_openai_base_url("") == ""
    +
    +    def test_none(self):
    +        assert _to_openai_base_url(None) == ""
    ```
  - `tests/test_ollama_num_ctx.py`

    ```diff
    @@ -0,0 +1,135 @@
    +"""Tests for Ollama num_ctx context length detection and injection.
    +
    +Covers:
    +  agent/model_metadata.py — query_ollama_num_ctx()
    +  run_agent.py — _ollama_num_ctx detection + extra_body injection
    +"""
    +
    +from unittest.mock import patch, MagicMock
    +
    +import pytest
    +
    +from agent.model_metadata import query_ollama_num_ctx
    +
    +
    +# ═══════════════════════════════════════════════════════════════════════
    +# Level 1: query_ollama_num_ctx — Ollama API interaction
    +# ═══════════════════════════════════════════════════════════════════════
    +
    +
    +def _mock_httpx_client(show_response_data, status_code=200):
    +    """Create a mock httpx.Client context manager that returns given /api/show data."""
    +    mock_resp = MagicMock(status_code=status_code)
    +    mock_resp.json.return_value = show_response_data
    +    mock_client = MagicMock()
    +    mock_client.post.return_value = mock_resp
    +    mock_ctx = MagicMock()
    +    mock_ctx.__enter__ = MagicMock(return_value=mock_client)
    +    mock_ctx.__exit__ = MagicMock(return_value=False)
    +    return mock_ctx, mock_client
    +
    +
    +class TestQueryOllamaNumCtx:
    +    """Test the Ollama /api/show context length query."""
    +
    +    def test_returns_context_from_model_info(self):
    +        """Should extract context_length from GGUF model_info metadata."""
    +        show_data = {
    +            "model_info": {"llama.context_length": 131072},
    +            "parameters": "",
    +        }
    +        mock_ctx, _ = _mock_httpx_client(show_data)
    +
    +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
    +            # httpx is imported inside the function — patch the module import
    +            import httpx
    +            with patch.object(httpx, "Client", return_value=mock_ctx):
    +                result = query_ollama_num_ctx("llama3.1:8b", "http://localhost:11434/v1")
    +
    +        assert result == 131072
    +
    +    def test_prefers_explicit_num_ctx_from_modelfile(self):
    +        """If the Modelfile sets num_ctx explicitly, that should take priority."""
    +        show_data = {
    +            "model_info": {"llama.context_length": 131072},
    +            "parameters": "num_ctx 32768\ntemperature 0.7",
    +        }
    +        mock_ctx, _ = _mock_httpx_client(show_data)
    +
    +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
    +            import httpx
    +            with patch.object(httpx, "Client", return_value=mock_ctx):
    +                result = query_ollama_num_ctx("custom-model", "http://localhost:11434")
    +
    +        assert result == 32768
    +
    +    def test_returns_none_for_non_ollama_server(self):
    +        """Should return None if the server is not Ollama."""
    +        with patch("agent.model_metadata.detect_local_server_type", return_value="lm-studio"):
    +            result = query_ollama_num_ctx("model", "http://localhost:1234")
    +        assert result is None
    +
    +    def test_returns_none_on_connection_error(self):
    +        """Should return None if the server is unreachable."""
    +        with patch("agent.model_metadata.detect_local_server_type", side_effect=Exception("timeout")):
    +            result = query_ollama_num_ctx("model", "http://localhost:11434")
    +        assert result is None
    +
    +    def test_returns_none_on_404(self):
    +        """Should return None if the model is not found."""
    +        mock_ctx, _ = _mock_httpx_client({}, status_code=404)
    +
    +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
    +            import httpx
    +            with patch.object(httpx, "Client", return_value=mock_ctx):
    +                result = query_ollama_num_ctx("nonexistent", "http://localhost:11434")
    +
    +        assert result is None
    +
    +    def test_strips_provider_prefix(self):
    +        """Should strip 'local:' prefix from model name before querying."""
    +        show_data = {
    +            "model_info": {"qwen2.context_length": 32768},
    +            "parameters": "",
    +        }
    +        mock_ctx, mock_client = _mock_httpx_client(show_data)
    +
    +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
    +            import httpx
    +            with patch.object(httpx, "Client", return_value=mock_ctx):
    +                result = query_ollama_num_ctx("local:qwen2.5:7b", "http://localhost:11434/v1")
    +
    +        # Verify the post was called with stripped name (no "local:" prefix)
    +        call_args = mock_client.post.call_args
    +        assert call_args[1]["json"]["name"] == "qwen2.5:7b" or call_args[0][1] is not None
    +        assert result == 32768
    +
    +    def test_handles_qwen2_architecture_key(self):
    +        """Different model architectures use different key prefixes in model_info."""
    +        show_data = {
    +            "model_info": {"qwen2.context_length": 65536},
    +            "parameters": "",
    +        }
    +        mock_ctx, _ = _mock_httpx_client(show_data)
    +
    +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
    +            import httpx
    +            with patch.object(httpx, "Client", return_value=mock_ctx):
    +                result = query_ollama_num_ctx("qwen2.5:32b", "http://localhost:11434")
    +
    +        assert result == 65536
    +
    +    def test_returns_none_when_model_info_empty(self):
    +        """Should return None if model_info has no context_length key."""
    +        show_data = {
    +            "model_info": {"llama.embedding_length": 4096},
    +            "parameters": "",
    +        }
    +        mock_ctx, _ = _mock_httpx_client(show_data)
    +
    +        with patch("agent.model_metadata.detect_local_server_type", return_value="ollama"):
    +            import httpx
    +            with patch.object(httpx, "Client", return_value=mock_ctx):
    +                result = query_ollama_num_ctx("model", "http://localhost:11434")
    +
    +        assert result is None
    ```
- **#2720: fix(gateway): use configured context_length and base_url for local/Ollama models**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#3321`, `#3321`
- **#2826: fix(gateway): hygiene compression misses local/aliased models**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#3321`, `#3321`, `#2708`

## Fix status

- Merged to main in PR #3321 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-04-08`. This confirmation is old and may be out of date (a revert/reopen would not have invalidated it here); re-verify.
- Subsystem: `gateway`
