Gateway config and model resolution fixes

NousResearch/hermes-agent · cluster 2708 · gateway

Data as of

Nothing open on GitHub. Fix merged in #3321. Check it covers your case.

The short answer

0open issues
0open PRs
5items grouped
Known?
Yes. 5 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #3321. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #5983. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #3321 feat(gateway): surface session config on /new, /reset, and auto-reset

    merged to main

    files: gateway/run.py, tests/gateway/test_session_info.py

    Show the diff
    • gateway/run.py
      @@ -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),
      @@ -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
      @@ -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
      @@ -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

    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

    Show the diff
    • agent/auxiliary_client.py
      @@ -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",
      @@ -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:
      @@ -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 = {}
      @@ -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 = {}
      @@ -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 ""
      @@ -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":
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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
      @@ -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:,})")
      @@ -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
      @@ -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")
      @@ -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
      @@ -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
      @@ -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

2 PRs closed without merging

From the close comments: superseded by another PR 2

  • #2720 fix(gateway): use configured context_length and base_url for local/Ollama models · superseded by another PR → #3321
  • #2826 fix(gateway): hygiene compression misses local/aliased models · superseded by another PR → #3321, #2708
Closed issues · 1
  • Closed#2708Gateway hygiene compression never triggers for local/Ollama models
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.40: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#3321, #5983
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
8 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.