{"repo":"NousResearch/hermes-agent","public_id":"2708","display_label":"Gateway config and model resolution fixes","summary":"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.","subsystem":"gateway","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":3321},"resolution":{"state":"resolved","by":"pr-5983"},"freshness":{"state":"stale","last_verified_at":"2026-04-08T05:23:28+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":2708,"pr_number":3321,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":2708,"pr_number":5983,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":5781,"pr_number":5983,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":5910,"pr_number":5983,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2708,"title":"Gateway hygiene compression never triggers for local/Ollama models","url":null,"state":"closed"},{"kind":"pr","number":2720,"title":"fix(gateway): use configured context_length and base_url for local/Ollama models","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3321","number":3321},{"kind":"pr","value":"#3321","number":3321}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":2826,"title":"fix(gateway): hygiene compression misses local/aliased models","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#3321","number":3321},{"kind":"pr","value":"#3321","number":3321},{"kind":"pr","value":"#2708","number":2708}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":3321,"title":"feat(gateway): surface session config on /new, /reset, and auto-reset","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/run.py","tests/gateway/test_session_info.py"],"fix_hunks":[{"file":"gateway/run.py","hunks":["@@ -1982,6 +1982,12 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n                             f\"Use /resume to browse and restore a previous session.\\n\"\n                             f\"Adjust reset timing in config.yaml under session_reset.\"\n                         )\n+                        try:\n+                            session_info = self._format_session_info()\n+                            if session_info:\n+                                notice = f\"{notice}\\n\\n{session_info}\"\n+                        except Exception:\n+                            pass\n                         await adapter.send(\n                             source.chat_id, notice,\n                             metadata=getattr(event, 'metadata', None),","@@ -2749,6 +2755,85 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):\n             # Clear session env\n             self._clear_session_env()\n     \n+    def _format_session_info(self) -> str:\n+        \"\"\"Resolve current model config and return a formatted info block.\n+\n+        Surfaces model, provider, context length, and endpoint so gateway\n+        users can immediately see if context detection went wrong (e.g.\n+        local models falling to the 128K default).\n+        \"\"\"\n+        from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT\n+\n+        model = _resolve_gateway_model()\n+        config_context_length = None\n+        provider = None\n+        base_url = None\n+        api_key = None\n+\n+        try:\n+            cfg_path = _hermes_home / \"config.yaml\"\n+            if cfg_path.exists():\n+                import yaml as _info_yaml\n+                with open(cfg_path, encoding=\"utf-8\") as f:\n+                    data = _info_yaml.safe_load(f) or {}\n+                model_cfg = data.get(\"model\", {})\n+                if isinstance(model_cfg, dict):\n+                    raw_ctx = model_cfg.get(\"context_length\")\n+                    if raw_ctx is not None:\n+                        try:\n+                            config_context_length = int(raw_ctx)\n+                        except (TypeError, ValueError):\n+                            pass\n+                    provider = model_cfg.get(\"provider\") or None\n+                    base_url = model_cfg.get(\"base_url\") or None\n+        except Exception:\n+            pass\n+\n+        # Resolve runtime credentials for probing\n+        try:\n+            runtime = _resolve_runtime_agent_kwargs()\n+            provider = provider or runtime.get(\"provider\")\n+            base_url = base_url or runtime.get(\"base_url\")\n+            api_key = [redacted:secret-assignment]\"api_key\")\n+        except Exception:\n+            pass\n+\n+        context_length = get_model_context_length(\n+            model,\n+            base_url=base_url or \"\",\n+            api_key=[redacted:secret-assignment] or \"\",\n+            config_context_length=config_context_length,\n+            provider=provider or \"\",\n+        )\n+\n+        # Format context source hint\n+        if config_context_length is not None:\n+            ctx_source = \"config\"\n+        elif context_length == DEFAULT_FALLBACK_CONTEXT:\n+            ctx_source = \"default — set model.context_length in config to override\"\n+        else:\n+            ctx_source = \"detected\"\n+\n+        # Format context length for display\n+        if context_length >= 1_000_000:\n+            ctx_display = f\"{context_length / 1_000_000:.1f}M\"\n+        elif context_length >= 1_000:\n+            ctx_display = f\"{context_length // 1_000}K\"\n+        else:\n+            ctx_display = str(context_length)\n+\n+        lines = [\n+            f\"◆ Model: `{model}`\",\n+            f\"◆ Provider: {provider or 'openrouter'}\",\n+            f\"◆ Context: {ctx_display} tokens ({ctx_source})\",\n+        ]\n+\n+        # Show endpoint for local/custom setups\n+        if base_url and (\"localhost\" in base_url or \"127.0.0.1\" in base_url or \"0.0.0.0\" in base_url):\n+            lines.append(f\"◆ Endpoint: {base_url}\")\n+\n+        return \"\\n\".join(lines)\n+\n     async def _handle_reset_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /new or /reset command.\"\"\"\n         source = event.source","@@ -2789,12 +2874,22 @@ async def _handle_reset_command(self, event: MessageEvent) -> str:\n             \"session_key\": session_key,\n         })\n         \n+        # Resolve session config info to surface to the user\n+        try:\n+            session_info = self._format_session_info()\n+        except Exception:\n+            session_info = \"\"\n+\n         if new_entry:\n-            return \"✨ Session reset! I've started fresh with no memory of our previous conversation.\"\n+            header = \"✨ Session reset! Starting fresh.\"\n         else:\n             # No existing session, just create one\n             self.session_store.get_or_create_session(source, force_new=True)\n-            return \"✨ New session started!\"\n+            header = \"✨ New session started!\"\n+\n+        if session_info:\n+            return f\"{header}\\n\\n{session_info}\"\n+        return header\n     \n     async def _handle_status_command(self, event: MessageEvent) -> str:\n         \"\"\"Handle /status command.\"\"\""]},{"file":"tests/gateway/test_session_info.py","hunks":["@@ -0,0 +1,110 @@\n+\"\"\"Tests for GatewayRunner._format_session_info — session config surfacing.\"\"\"\n+\n+import pytest\n+from unittest.mock import patch, MagicMock\n+from pathlib import Path\n+\n+from gateway.run import GatewayRunner\n+\n+\n[redacted:email]()\n+def runner():\n+    \"\"\"Create a bare GatewayRunner without __init__.\"\"\"\n+    return GatewayRunner.__new__(GatewayRunner)\n+\n+\n+def _patch_info(tmp_path, config_yaml, model, runtime):\n+    \"\"\"Return a context-manager stack that patches _format_session_info deps.\"\"\"\n+    cfg_path = tmp_path / \"config.yaml\"\n+    if config_yaml is not None:\n+        cfg_path.write_text(config_yaml)\n+    return (\n+        patch(\"gateway.run._hermes_home\", tmp_path),\n+        patch(\"gateway.run._resolve_gateway_model\", return_value=model),\n+        patch(\"gateway.run._resolve_runtime_agent_kwargs\", return_value=runtime),\n+    )\n+\n+\n+class TestFormatSessionInfo:\n+\n+    def test_includes_model_name(self, runner, tmp_path):\n+        p1, p2, p3 = _patch_info(tmp_path, \"model:\\n  default: anthropic/claude-opus-4.6\\n  provider: openrouter\\n\",\n+                                  \"anthropic/claude-opus-4.6\",\n+                                  {\"provider\": \"openrouter\", \"base_url\": \"https://openrouter.ai/api/v1\", \"api_key\": \"k\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"claude-opus-4.6\" in info\n+\n+    def test_includes_provider(self, runner, tmp_path):\n+        p1, p2, p3 = _patch_info(tmp_path, \"model:\\n  default: test-model\\n  provider: openrouter\\n\",\n+                                  \"test-model\",\n+                                  {\"provider\": \"openrouter\", \"base_url\": \"\", \"api_key\": \"\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"openrouter\" in info\n+\n+    def test_config_context_length(self, runner, tmp_path):\n+        p1, p2, p3 = _patch_info(tmp_path, \"model:\\n  default: test-model\\n  context_length: 32768\\n\",\n+                                  \"test-model\",\n+                                  {\"provider\": \"custom\", \"base_url\": \"\", \"api_key\": \"\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"32K\" in info\n+        assert \"config\" in info\n+\n+    def test_default_fallback_hint(self, runner, tmp_path):\n+        p1, p2, p3 = _patch_info(tmp_path, \"model:\\n  default: unknown-model-xyz\\n\",\n+                                  \"unknown-model-xyz\",\n+                                  {\"provider\": \"\", \"base_url\": \"\", \"api_key\": \"\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"128K\" in info\n+        assert \"model.context_length\" in info\n+\n+    def test_local_endpoint_shown(self, runner, tmp_path):\n+        p1, p2, p3 = _patch_info(\n+            tmp_path,\n+            \"model:\\n  default: qwen3:8b\\n  provider: custom\\n  base_url: http://localhost:11434/v1\\n  context_length: 8192\\n\",\n+            \"qwen3:8b\",\n+            {\"provider\": \"custom\", \"base_url\": \"http://localhost:11434/v1\", \"api_key\": \"\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"localhost:11434\" in info\n+        assert \"8K\" in info\n+\n+    def test_cloud_endpoint_hidden(self, runner, tmp_path):\n+        p1, p2, p3 = _patch_info(tmp_path, \"model:\\n  default: test-model\\n  provider: openrouter\\n\",\n+                                  \"test-model\",\n+                                  {\"provider\": \"openrouter\", \"base_url\": \"https://openrouter.ai/api/v1\", \"api_key\": \"k\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"Endpoint\" not in info\n+\n+    def test_million_context_format(self, runner, tmp_path):\n+        p1, p2, p3 = _patch_info(tmp_path, \"model:\\n  default: test-model\\n  context_length: 1000000\\n\",\n+                                  \"test-model\",\n+                                  {\"provider\": \"\", \"base_url\": \"\", \"api_key\": \"\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"1.0M\" in info\n+\n+    def test_missing_config(self, runner, tmp_path):\n+        \"\"\"No config.yaml should not crash.\"\"\"\n+        p1, p2, p3 = _patch_info(tmp_path, None,  # don't create config\n+                                  \"anthropic/claude-sonnet-4.6\",\n+                                  {\"provider\": \"openrouter\", \"base_url\": \"\", \"api_key\": \"\"})\n+        with p1, p2, p3:\n+            info = runner._format_session_info()\n+        assert \"Model\" in info\n+        assert \"Context\" in info\n+\n+    def test_runtime_resolution_failure_doesnt_crash(self, runner, tmp_path):\n+        \"\"\"If runtime resolution raises, should still produce output.\"\"\"\n+        cfg_path = tmp_path / \"config.yaml\"\n+        cfg_path.write_text(\"model:\\n  default: test-model\\n  context_length: 4096\\n\")\n+        with patch(\"gateway.run._hermes_home\", tmp_path), \\\n+             patch(\"gateway.run._resolve_gateway_model\", return_value=\"test-model\"), \\\n+             patch(\"gateway.run._resolve_runtime_agent_kwargs\", side_effect=RuntimeError(\"no creds\")):\n+            info = runner._format_session_info()\n+        assert \"4K\" in info\n+        assert \"config\" in info\n"]}]},{"kind":"pr","number":5983,"title":"fix: provider/model resolution — salvage 4 community PRs + MiniMax aux URL fix","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"agent/auxiliary_client.py","hunks":["@@ -59,6 +59,41 @@\n \n logger = logging.getLogger(__name__)\n \n+_PROVIDER_ALIASES = {\n+    \"google\": \"gemini\",\n+    \"google-gemini\": \"gemini\",\n+    \"google-ai-studio\": \"gemini\",\n+    \"glm\": \"zai\",\n+    \"z-ai\": \"zai\",\n+    \"z.ai\": \"zai\",\n+    \"zhipu\": \"zai\",\n+    \"kimi\": \"kimi-coding\",\n+    \"moonshot\": \"kimi-coding\",\n+    \"minimax-china\": \"minimax-cn\",\n+    \"minimax_cn\": \"minimax-cn\",\n+    \"claude\": \"anthropic\",\n+    \"claude-code\": \"anthropic\",\n+}\n+\n+\n+def _normalize_aux_provider(provider: Optional[str], *, for_vision: bool = False) -> str:\n+    normalized = (provider or \"auto\").strip().lower()\n+    if normalized.startswith(\"custom:\"):\n+        suffix = normalized.split(\":\", 1)[1].strip()\n+        if not suffix:\n+            return \"custom\"\n+        normalized = suffix if not for_vision else \"custom\"\n+    if normalized == \"codex\":\n+        return \"openai-codex\"\n+    if normalized == \"main\":\n+        # Resolve to the user's actual main provider so named custom providers\n+        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.\n+        main_prov = _read_main_provider()\n+        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n+            return main_prov\n+        return \"custom\"\n+    return _PROVIDER_ALIASES.get(normalized, normalized)\n+\n # Default auxiliary models for direct API-key providers (cheap/fast for side tasks)\n _API_KEY_PROVIDER_AUX_MODELS: [redacted:secret-assignment] str] = {\n     \"gemini\": \"gemini-3-flash-preview\",","@@ -106,6 +141,23 @@\n _CODEX_AUX_BASE_URL = \"https://chatgpt.com/backend-api/codex\"\n \n \n+def _to_openai_base_url(base_url: str) -> str:\n+    \"\"\"Normalize an Anthropic-style base URL to OpenAI-compatible format.\n+\n+    Some providers (MiniMax, MiniMax-CN) expose an ``/anthropic`` endpoint for\n+    the Anthropic Messages API and a separate ``/v1`` endpoint for OpenAI chat\n+    completions.  The auxiliary client uses the OpenAI SDK, so it must hit the\n+    ``/v1`` surface.  Passing the raw ``inference_base_url`` causes requests to\n+    land on ``/anthropic/chat/completions`` — a 404.\n+    \"\"\"\n+    url = str(base_url or \"\").strip().rstrip(\"/\")\n+    if url.endswith(\"/anthropic\"):\n+        rewritten = url[: -len(\"/anthropic\")] + \"/v1\"\n+        logger.debug(\"Auxiliary client: rewrote base URL %s → %s\", url, rewritten)\n+        return rewritten\n+    return url\n+\n+\n def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]:\n     \"\"\"Return (pool_exists_for_provider, selected_entry).\"\"\"\n     try:","@@ -635,7 +687,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n             if not api_key:\n                 continue\n \n-            base_url = _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url\n+            base_url = _to_openai_base_url(\n+                _pool_runtime_base_url(entry, pconfig.inference_base_url) or pconfig.inference_base_url\n+            )\n             model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, \"default\")\n             logger.debug(\"Auxiliary text client: %s (%s) via pool\", pconfig.name, model)\n             extra = {}","@@ -652,7 +706,9 @@ def _resolve_api_key_provider() -> Tuple[Optional[OpenAI], Optional[str]]:\n         if not api_key:\n             continue\n \n-        base_url = str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        base_url = _to_openai_base_url(\n+            str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        )\n         model = _API_KEY_PROVIDER_AUX_MODELS.get(provider_id, \"default\")\n         logger.debug(\"Auxiliary text client: %s (%s)\", pconfig.name, model)\n         extra = {}","@@ -778,7 +834,7 @@ def _read_main_provider() -> str:\n         if isinstance(model_cfg, dict):\n             provider = model_cfg.get(\"provider\", \"\")\n             if isinstance(provider, str) and provider.strip():\n-                return provider.strip().lower()\n+                return _normalize_aux_provider(provider)\n     except Exception:\n         pass\n     return \"\"","@@ -1140,17 +1196,7 @@ def resolve_provider_client(\n         (client, resolved_model) or (None, None) if auth is unavailable.\n     \"\"\"\n     # Normalise aliases\n-    provider = (provider or \"auto\").strip().lower()\n-    if provider == \"codex\":\n-        provider = \"openai-codex\"\n-    if provider == \"main\":\n-        # Resolve to the user's actual main provider so named custom providers\n-        # and non-aggregator providers (DeepSeek, Alibaba, etc.) work correctly.\n-        main_prov = _read_main_provider()\n-        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n-            provider = main_prov\n-        else:\n-            provider = \"custom\"\n+    provider = _normalize_aux_provider(provider)\n \n     # ── Auto: try all providers in priority order ────────────────────\n     if provider == \"auto\":","@@ -1300,7 +1346,9 @@ def resolve_provider_client(\n                          provider, \", \".join(tried_sources))\n             return None, None\n \n-        base_url = str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        base_url = _to_openai_base_url(\n+            str(creds.get(\"base_url\", \"\")).strip().rstrip(\"/\") or pconfig.inference_base_url\n+        )\n \n         default_model = _API_KEY_PROVIDER_AUX_MODELS.get(provider, \"\")\n         final_model = model or default_model","@@ -1384,17 +1432,7 @@ def get_async_text_auxiliary_client(task: str = \"\"):\n \n \n def _normalize_vision_provider(provider: Optional[str]) -> str:\n-    provider = (provider or \"auto\").strip().lower()\n-    if provider == \"codex\":\n-        return \"openai-codex\"\n-    if provider == \"main\":\n-        # Resolve to actual main provider — named custom providers and\n-        # non-aggregator providers need to pass through as their real name.\n-        main_prov = _read_main_provider()\n-        if main_prov and main_prov not in (\"auto\", \"main\", \"\"):\n-            return main_prov\n-        return \"custom\"\n-    return provider\n+    return _normalize_aux_provider(provider, for_vision=True)\n \n \n def _resolve_strict_vision_backend(provider: str) -> Tuple[Optional[Any], Optional[str]]:"]},{"file":"agent/model_metadata.py","hunks":["@@ -611,6 +611,59 @@ def _model_id_matches(candidate_id: str, lookup_model: str) -> bool:\n     return False\n \n \n+def query_ollama_num_ctx(model: str, base_url: str) -> Optional[int]:\n+    \"\"\"Query an Ollama server for the model's context length.\n+\n+    Returns the model's maximum context from GGUF metadata via ``/api/show``,\n+    or the explicit ``num_ctx`` from the Modelfile if set.  Returns None if\n+    the server is unreachable or not Ollama.\n+\n+    This is the value that should be passed as ``num_ctx`` in Ollama chat\n+    requests to override the default 2048.\n+    \"\"\"\n+    import httpx\n+\n+    bare_model = _strip_provider_prefix(model)\n+    server_url = base_url.rstrip(\"/\")\n+    if server_url.endswith(\"/v1\"):\n+        server_url = server_url[:-3]\n+\n+    try:\n+        server_type = detect_local_server_type(base_url)\n+    except Exception:\n+        return None\n+    if server_type != \"ollama\":\n+        return None\n+\n+    try:\n+        with httpx.Client(timeout=3.0) as client:\n+            resp = client.post(f\"{server_url}/api/show\", json={\"name\": bare_model})\n+            if resp.status_code != 200:\n+                return None\n+            data = resp.json()\n+\n+            # Prefer explicit num_ctx from Modelfile parameters (user override)\n+            params = data.get(\"parameters\", \"\")\n+            if \"num_ctx\" in params:\n+                for line in params.split(\"\\n\"):\n+                    if \"num_ctx\" in line:\n+                        parts = line.strip().split()\n+                        if len(parts) >= 2:\n+                            try:\n+                                return int(parts[-1])\n+                            except ValueError:\n+                                pass\n+\n+            # Fall back to GGUF model_info context_length (training max)\n+            model_info = data.get(\"model_info\", {})\n+            for key, value in model_info.items():\n+                if \"context_length\" in key and isinstance(value, (int, float)):\n+                    return int(value)\n+    except Exception:\n+        pass\n+    return None\n+\n+\n def _query_local_context_length(model: str, base_url: str) -> Optional[int]:\n     \"\"\"Query a local server for the model's context length.\"\"\"\n     import httpx"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -791,12 +791,12 @@ def list_authenticated_providers(\n         if overlay.auth_type in (\"oauth_device_code\", \"oauth_external\", \"external_process\"):\n             # These use auth stores, not env vars — check for auth.json entries\n             try:\n-                from hermes_cli.auth import _read_auth_store\n-                store = _read_auth_store()\n-                if store and pid in store:\n+                from hermes_cli.auth import _load_auth_store\n+                store = _load_auth_store()\n+                if store and (pid in store.get(\"providers\", {}) or pid in store.get(\"credential_pool\", {})):\n                     has_creds = True\n-            except Exception:\n-                pass\n+            except Exception as exc:\n+                logger.debug(\"Auth store check failed for %s: %s\", pid, exc)\n         if not has_creds:\n             continue\n "]},{"file":"run_agent.py","hunks":["@@ -85,6 +85,7 @@\n     estimate_tokens_rough, estimate_messages_tokens_rough, estimate_request_tokens_rough,\n     get_next_probe_tier, parse_context_limit_from_error,\n     save_context_length, is_local_endpoint,\n+    query_ollama_num_ctx,\n )\n from agent.context_compressor import ContextCompressor\n from agent.subdirectory_hints import SubdirectoryHintTracker","@@ -1216,6 +1217,33 @@ def __init__(\n         self.session_cost_status = \"unknown\"\n         self.session_cost_source = \"none\"\n         \n+        # ── Ollama num_ctx injection ──\n+        # Ollama defaults to 2048 context regardless of the model's capabilities.\n+        # When running against an Ollama server, detect the model's max context\n+        # and pass num_ctx on every chat request so the full window is used.\n+        # User override: set model.ollama_num_ctx in config.yaml to cap VRAM use.\n+        self._ollama_num_ctx: int | None = None\n+        _ollama_num_ctx_override = None\n+        if isinstance(_model_cfg, dict):\n+            _ollama_num_ctx_override = _model_cfg.get(\"ollama_num_ctx\")\n+        if _ollama_num_ctx_override is not None:\n+            try:\n+                self._ollama_num_ctx = int(_ollama_num_ctx_override)\n+            except (TypeError, ValueError):\n+                logger.debug(\"Invalid ollama_num_ctx config value: %r\", _ollama_num_ctx_override)\n+        if self._ollama_num_ctx is None and self.base_url and is_local_endpoint(self.base_url):\n+            try:\n+                _detected = query_ollama_num_ctx(self.model, self.base_url)\n+                if _detected and _detected > 0:\n+                    self._ollama_num_ctx = _detected\n+            except Exception as exc:\n+                logger.debug(\"Ollama num_ctx detection failed: %s\", exc)\n+        if self._ollama_num_ctx and not self.quiet_mode:\n+            logger.info(\n+                \"Ollama num_ctx: will request %d tokens (model max from /api/show)\",\n+                self._ollama_num_ctx,\n+            )\n+\n         if not self.quiet_mode:\n             if compression_enabled:\n                 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:\n         if _is_nous:\n             extra_body[\"tags\"] = [\"product=hermes-agent\"]\n \n+        # Ollama num_ctx: override the 2048 default so the model actually\n+        # uses the context window it was trained for.  Passed via the OpenAI\n+        # SDK's extra_body → options.num_ctx, which Ollama's OpenAI-compat\n+        # endpoint forwards to the runner as --ctx-size.\n+        if self._ollama_num_ctx:\n+            options = extra_body.get(\"options\", {})\n+            options[\"num_ctx\"] = self._ollama_num_ctx\n+            extra_body[\"options\"] = options\n+\n         if extra_body:\n             api_kwargs[\"extra_body\"] = extra_body\n "]},{"file":"tests/agent/test_auxiliary_client.py","hunks":["@@ -471,6 +471,23 @@ def test_explicit_zai(self, monkeypatch):\n             client, model = resolve_provider_client(\"zai\")\n             assert client is not None\n \n+    def test_explicit_google_alias_uses_gemini_credentials(self):\n+        \"\"\"provider='google' should route through the gemini API-key provider.\"\"\"\n+        with (\n+            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\n+                \"api_key\": \"gemini-key\",\n+                \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n+            }),\n+            patch(\"agent.auxiliary_client.OpenAI\") as mock_openai,\n+        ):\n+            mock_openai.return_value = MagicMock()\n+            client, model = resolve_provider_client(\"google\", model=\"gemini-3.1-pro-preview\")\n+\n+        assert client is not None\n+        assert model == \"gemini-3.1-pro-preview\"\n+        assert mock_openai.call_args.kwargs[\"api_key\"] == \"gemini-key\"\n+        assert mock_openai.call_args.kwargs[\"base_url\"] == \"https://generativelanguage.googleapis.com/v1beta/openai\"\n+\n     def test_explicit_unknown_returns_none(self, monkeypatch):\n         \"\"\"Unknown provider should return None.\"\"\"\n         client, model = resolve_provider_client(\"nonexistent-provider\")","@@ -822,6 +839,31 @@ def test_vision_uses_nous_when_available(self, monkeypatch):\n         assert model == \"google/gemini-3-flash-preview\"\n         assert client is not None\n \n+    def test_vision_config_google_provider_uses_gemini_credentials(self, monkeypatch):\n+        config = {\n+            \"auxiliary\": {\n+                \"vision\": {\n+                    \"provider\": \"google\",\n+                    \"model\": \"gemini-3.1-pro-preview\",\n+                }\n+            }\n+        }\n+        monkeypatch.setattr(\"hermes_cli.config.load_config\", lambda: config)\n+        with (\n+            patch(\"hermes_cli.auth.resolve_api_key_provider_credentials\", return_value={\n+                \"api_key\": \"gemini-key\",\n+                \"base_url\": \"https://generativelanguage.googleapis.com/v1beta/openai\",\n+            }),\n+            patch(\"agent.auxiliary_client.OpenAI\") as mock_openai,\n+        ):\n+            resolved_provider, client, model = resolve_vision_provider_client()\n+\n+        assert resolved_provider == \"gemini\"\n+        assert client is not None\n+        assert model == \"gemini-3.1-pro-preview\"\n+        assert mock_openai.call_args.kwargs[\"api_key\"] == \"gemini-key\"\n+        assert mock_openai.call_args.kwargs[\"base_url\"] == \"https://generativelanguage.googleapis.com/v1beta/openai\"\n+\n     def test_vision_forced_main_uses_custom_endpoint(self, monkeypatch):\n         \"\"\"When explicitly forced to 'main', vision CAN use custom endpoint.\"\"\"\n         config = {"]},{"file":"tests/agent/test_minimax_auxiliary_url.py","hunks":["@@ -0,0 +1,42 @@\n+\"\"\"Tests for MiniMax auxiliary client URL normalization.\n+\n+MiniMax and MiniMax-CN set inference_base_url to the /anthropic path.\n+The auxiliary client uses the OpenAI SDK, which needs /v1 instead.\n+\"\"\"\n+\n+import sys\n+import os\n+\n+sys.path.insert(0, os.path.join(os.path.dirname(__file__), \"..\", \"..\"))\n+\n+from agent.auxiliary_client import _to_openai_base_url\n+\n+\n+class TestToOpenaiBaseUrl:\n+    def test_minimax_global_anthropic_suffix_replaced(self):\n+        assert _to_openai_base_url(\"https://api.minimax.io/anthropic\") == \"https://api.minimax.io/v1\"\n+\n+    def test_minimax_cn_anthropic_suffix_replaced(self):\n+        assert _to_openai_base_url(\"https://api.minimaxi.com/anthropic\") == \"https://api.minimaxi.com/v1\"\n+\n+    def test_trailing_slash_stripped_before_replace(self):\n+        assert _to_openai_base_url(\"https://api.minimax.io/anthropic/\") == \"https://api.minimax.io/v1\"\n+\n+    def test_v1_url_unchanged(self):\n+        assert _to_openai_base_url(\"https://api.openai.com/v1\") == \"https://api.openai.com/v1\"\n+\n+    def test_openrouter_url_unchanged(self):\n+        assert _to_openai_base_url(\"https://openrouter.ai/api/v1\") == \"https://openrouter.ai/api/v1\"\n+\n+    def test_anthropic_domain_unchanged(self):\n+        \"\"\"api.anthropic.com doesn't end with /anthropic — should be untouched.\"\"\"\n+        assert _to_openai_base_url(\"https://api.anthropic.com\") == \"https://api.anthropic.com\"\n+\n+    def test_anthropic_in_subpath_unchanged(self):\n+        assert _to_openai_base_url(\"https://example.com/anthropic/extra\") == \"https://example.com/anthropic/extra\"\n+\n+    def test_empty_string(self):\n+        assert _to_openai_base_url(\"\") == \"\"\n+\n+    def test_none(self):\n+        assert _to_openai_base_url(None) == \"\""]},{"file":"tests/test_ollama_num_ctx.py","hunks":["@@ -0,0 +1,135 @@\n+\"\"\"Tests for Ollama num_ctx context length detection and injection.\n+\n+Covers:\n+  agent/model_metadata.py — query_ollama_num_ctx()\n+  run_agent.py — _ollama_num_ctx detection + extra_body injection\n+\"\"\"\n+\n+from unittest.mock import patch, MagicMock\n+\n+import pytest\n+\n+from agent.model_metadata import query_ollama_num_ctx\n+\n+\n+# ═══════════════════════════════════════════════════════════════════════\n+# Level 1: query_ollama_num_ctx — Ollama API interaction\n+# ═══════════════════════════════════════════════════════════════════════\n+\n+\n+def _mock_httpx_client(show_response_data, status_code=200):\n+    \"\"\"Create a mock httpx.Client context manager that returns given /api/show data.\"\"\"\n+    mock_resp = MagicMock(status_code=status_code)\n+    mock_resp.json.return_value = show_response_data\n+    mock_client = MagicMock()\n+    mock_client.post.return_value = mock_resp\n+    mock_ctx = MagicMock()\n+    mock_ctx.__enter__ = MagicMock(return_value=mock_client)\n+    mock_ctx.__exit__ = MagicMock(return_value=False)\n+    return mock_ctx, mock_client\n+\n+\n+class TestQueryOllamaNumCtx:\n+    \"\"\"Test the Ollama /api/show context length query.\"\"\"\n+\n+    def test_returns_context_from_model_info(self):\n+        \"\"\"Should extract context_length from GGUF model_info metadata.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"llama.context_length\": 131072},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            # httpx is imported inside the function — patch the module import\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"llama3.1:8b\", \"http://localhost:11434/v1\")\n+\n+        assert result == 131072\n+\n+    def test_prefers_explicit_num_ctx_from_modelfile(self):\n+        \"\"\"If the Modelfile sets num_ctx explicitly, that should take priority.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"llama.context_length\": 131072},\n+            \"parameters\": \"num_ctx 32768\\ntemperature 0.7\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"custom-model\", \"http://localhost:11434\")\n+\n+        assert result == 32768\n+\n+    def test_returns_none_for_non_ollama_server(self):\n+        \"\"\"Should return None if the server is not Ollama.\"\"\"\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"lm-studio\"):\n+            result = query_ollama_num_ctx(\"model\", \"http://localhost:1234\")\n+        assert result is None\n+\n+    def test_returns_none_on_connection_error(self):\n+        \"\"\"Should return None if the server is unreachable.\"\"\"\n+        with patch(\"agent.model_metadata.detect_local_server_type\", side_effect=Exception(\"timeout\")):\n+            result = query_ollama_num_ctx(\"model\", \"http://localhost:11434\")\n+        assert result is None\n+\n+    def test_returns_none_on_404(self):\n+        \"\"\"Should return None if the model is not found.\"\"\"\n+        mock_ctx, _ = _mock_httpx_client({}, status_code=404)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"nonexistent\", \"http://localhost:11434\")\n+\n+        assert result is None\n+\n+    def test_strips_provider_prefix(self):\n+        \"\"\"Should strip 'local:' prefix from model name before querying.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"qwen2.context_length\": 32768},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, mock_client = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"local:qwen2.5:7b\", \"http://localhost:11434/v1\")\n+\n+        # Verify the post was called with stripped name (no \"local:\" prefix)\n+        call_args = mock_client.post.call_args\n+        assert call_args[1][\"json\"][\"name\"] == \"qwen2.5:7b\" or call_args[0][1] is not None\n+        assert result == 32768\n+\n+    def test_handles_qwen2_architecture_key(self):\n+        \"\"\"Different model architectures use different key prefixes in model_info.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"qwen2.context_length\": 65536},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"qwen2.5:32b\", \"http://localhost:11434\")\n+\n+        assert result == 65536\n+\n+    def test_returns_none_when_model_info_empty(self):\n+        \"\"\"Should return None if model_info has no context_length key.\"\"\"\n+        show_data = {\n+            \"model_info\": {\"llama.embedding_length\": 4096},\n+            \"parameters\": \"\",\n+        }\n+        mock_ctx, _ = _mock_httpx_client(show_data)\n+\n+        with patch(\"agent.model_metadata.detect_local_server_type\", return_value=\"ollama\"):\n+            import httpx\n+            with patch.object(httpx, \"Client\", return_value=mock_ctx):\n+                result = query_ollama_num_ctx(\"model\", \"http://localhost:11434\")\n+\n+        assert result is None\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}