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
- 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
Open PRs0
Fixes on record
-
#3321 feat(gateway): surface session config on /new, /reset, and auto-reset
files:
gateway/run.py,tests/gateway/test_session_info.pyShow 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
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.pyShow 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
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.00means 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