From 2983723339b0220d3e2b6624db4b6ca8a7af5061 Mon Sep 17 00:00:00 2001 From: saitsuki Date: Sun, 19 Apr 2026 11:07:33 +0800 Subject: [PATCH] fix: pass config_context_length to /model display and gateway fallback paths When switching models via /model command or processing context references in the gateway, get_model_context_length() was called without the config_context_length parameter. For custom providers whose /models endpoint does not return a context_length field, this silently fell back to the hardcoded default (128K), ignoring the user's explicit custom_providers[].models..context_length in config.yaml. Changes: - Add read_config_context_length() helper to agent/model_metadata.py - Fix /model display fallback in cli.py (2 locations) - Fix /model display fallback in gateway/run.py - Fix context references call in gateway/run.py - Add 8 tests for read_config_context_length() Fixes #5089 Fixes #8785 --- agent/model_metadata.py | 63 +++++++++++++++++ cli.py | 14 +++- gateway/run.py | 14 +++- tests/agent/test_model_metadata.py | 106 +++++++++++++++++++++++++++++ 4 files changed, 193 insertions(+), 4 deletions(-) diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 81bac6c92f247..65217e4f66b4f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -938,6 +938,69 @@ def _resolve_nous_context_length(model: str) -> Optional[int]: return None +def read_config_context_length( + model: str, + base_url: str = "", +) -> int | None: + """Read config_context_length from config.yaml for the given model. + + Checks model.context_length first, then custom_providers[].models..context_length. + Returns None if not configured. + """ + try: + from hermes_cli.config import load_config, get_compatible_custom_providers + except ImportError: + return None + + try: + cfg = load_config() + except Exception: + return None + + # 1. model.context_length (top-level override) + model_cfg = cfg.get("model", {}) + if isinstance(model_cfg, dict): + raw = model_cfg.get("context_length") + if raw is not None: + try: + val = int(raw) + if val > 0: + return val + except (TypeError, ValueError): + pass + + # 2. custom_providers[].models..context_length + if base_url: + try: + custom_providers = get_compatible_custom_providers(cfg) + except Exception: + custom_providers = cfg.get("custom_providers", []) + if not isinstance(custom_providers, list): + custom_providers = [] + + normalized_url = base_url.rstrip("/") + for cp_entry in custom_providers: + if not isinstance(cp_entry, dict): + continue + cp_url = (cp_entry.get("base_url") or "").rstrip("/") + if cp_url and cp_url == normalized_url: + cp_models = cp_entry.get("models", {}) + if isinstance(cp_models, dict): + cp_model_cfg = cp_models.get(model, {}) + if isinstance(cp_model_cfg, dict): + cp_ctx = cp_model_cfg.get("context_length") + if cp_ctx is not None: + try: + val = int(cp_ctx) + if val > 0: + return val + except (TypeError, ValueError): + pass + break + + return None + + def get_model_context_length( model: str, base_url: str = "", diff --git a/cli.py b/cli.py index 02c1a4f7ef62a..32bcfcf47f730 100644 --- a/cli.py +++ b/cli.py @@ -4712,11 +4712,16 @@ def _apply_model_switch_result(self, result, persist_global: bool) -> None: _cprint(f" Capabilities: {mi.format_capabilities()}") else: try: - from agent.model_metadata import get_model_context_length + from agent.model_metadata import get_model_context_length, read_config_context_length + _config_ctx = read_config_context_length( + result.new_model, + base_url=result.base_url or self.base_url or "", + ) ctx = get_model_context_length( result.new_model, base_url=result.base_url or self.base_url, api_key=result.api_key or self.api_key, + config_context_length=_config_ctx, provider=result.target_provider, ) _cprint(f" Context: {ctx:,} tokens") @@ -4939,11 +4944,16 @@ def _handle_model_switch(self, cmd_original: str): else: # Fallback to old context length lookup try: - from agent.model_metadata import get_model_context_length + from agent.model_metadata import get_model_context_length, read_config_context_length + _config_ctx = read_config_context_length( + result.new_model, + base_url=result.base_url or self.base_url or "", + ) ctx = get_model_context_length( result.new_model, base_url=result.base_url or self.base_url, api_key=result.api_key or self.api_key, + config_context_length=_config_ctx, provider=result.target_provider, ) _cprint(f" Context: {ctx:,} tokens") diff --git a/gateway/run.py b/gateway/run.py index af3946d4afcc1..323596793c808 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -3645,12 +3645,17 @@ async def _prepare_inbound_message_text( if "@" in message_text: try: from agent.context_references import preprocess_context_references_async - from agent.model_metadata import get_model_context_length + from agent.model_metadata import get_model_context_length, read_config_context_length _msg_cwd = os.environ.get("TERMINAL_CWD", os.path.expanduser("~")) + _config_ctx = read_config_context_length( + self._model, + base_url=self._base_url or "", + ) _msg_ctx_len = get_model_context_length( self._model, base_url=self._base_url or "", + config_context_length=_config_ctx, ) _ctx_result = await preprocess_context_references_async( message_text, @@ -5349,11 +5354,16 @@ async def _on_model_selected( lines.append(f"Capabilities: {mi.format_capabilities()}") else: try: - from agent.model_metadata import get_model_context_length + from agent.model_metadata import get_model_context_length, read_config_context_length + _config_ctx = read_config_context_length( + result.new_model, + base_url=result.base_url or current_base_url or "", + ) ctx = get_model_context_length( result.new_model, base_url=result.base_url or current_base_url, api_key=result.api_key or current_api_key, + config_context_length=_config_ctx, provider=result.target_provider, ) lines.append(f"Context: {ctx:,} tokens") diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 6a0eab1512575..ce0ce0fccc632 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -28,6 +28,7 @@ get_model_context_length, get_next_probe_tier, get_cached_context_length, + read_config_context_length, parse_context_limit_from_error, save_context_length, fetch_model_metadata, @@ -711,3 +712,108 @@ def test_special_chars_in_model_name(self, tmp_path): with patch("agent.model_metadata._get_context_cache_path", return_value=cache_file): save_context_length(model, url, 200000) assert get_cached_context_length(model, url) == 200000 + + +# ========================================================================= +# read_config_context_length — config.yaml context_length resolution +# ========================================================================= + +class TestReadConfigContextLength: + """Tests for read_config_context_length() helper.""" + + def test_model_context_length_from_config(self): + """model.context_length in config is returned.""" + mock_cfg = {"model": {"context_length": 500000}} + with patch("hermes_cli.config.load_config", return_value=mock_cfg): + result = read_config_context_length("any-model") + assert result == 500000 + + def test_custom_providers_context_length(self): + """custom_providers[].models..context_length is returned.""" + mock_cfg = { + "custom_providers": [{ + "name": "my-provider", + "base_url": "https://api.example.com/v1", + "models": { + "gpt-5.4": {"context_length": 1000000}, + }, + }], + } + with patch("hermes_cli.config.load_config", return_value=mock_cfg), \ + patch("hermes_cli.config.get_compatible_custom_providers", return_value=mock_cfg["custom_providers"]): + result = read_config_context_length( + "gpt-5.4", + base_url="https://api.example.com/v1", + ) + assert result == 1000000 + + def test_model_context_length_takes_priority(self): + """model.context_length overrides custom_providers per-model.""" + mock_cfg = { + "model": {"context_length": 256000}, + "custom_providers": [{ + "name": "my-provider", + "base_url": "https://api.example.com/v1", + "models": {"gpt-5.4": {"context_length": 1000000}}, + }], + } + with patch("hermes_cli.config.load_config", return_value=mock_cfg): + result = read_config_context_length( + "gpt-5.4", + base_url="https://api.example.com/v1", + ) + assert result == 256000 + + def test_no_config_returns_none(self): + """No config data returns None.""" + with patch("hermes_cli.config.load_config", return_value={}): + result = read_config_context_length("some-model") + assert result is None + + def test_invalid_context_length_ignored(self): + """Non-integer context_length is ignored.""" + mock_cfg = {"model": {"context_length": "not-a-number"}} + with patch("hermes_cli.config.load_config", return_value=mock_cfg): + result = read_config_context_length("some-model") + assert result is None + + def test_zero_context_length_ignored(self): + """context_length=0 is treated as unset.""" + mock_cfg = {"model": {"context_length": 0}} + with patch("hermes_cli.config.load_config", return_value=mock_cfg): + result = read_config_context_length("some-model") + assert result is None + + def test_base_url_mismatch_skips_custom_providers(self): + """Non-matching base_url skips custom_providers lookup.""" + mock_cfg = { + "custom_providers": [{ + "name": "my-provider", + "base_url": "https://other.example.com/v1", + "models": {"gpt-5.4": {"context_length": 1000000}}, + }], + } + with patch("hermes_cli.config.load_config", return_value=mock_cfg), \ + patch("hermes_cli.config.get_compatible_custom_providers", return_value=mock_cfg["custom_providers"]): + result = read_config_context_length( + "gpt-5.4", + base_url="https://api.example.com/v1", + ) + assert result is None + + def test_trailing_slash_normalized(self): + """Trailing slashes in base_url are normalized for matching.""" + mock_cfg = { + "custom_providers": [{ + "name": "my-provider", + "base_url": "https://api.example.com/v1", + "models": {"gpt-5.4": {"context_length": 1000000}}, + }], + } + with patch("hermes_cli.config.load_config", return_value=mock_cfg), \ + patch("hermes_cli.config.get_compatible_custom_providers", return_value=mock_cfg["custom_providers"]): + result = read_config_context_length( + "gpt-5.4", + base_url="https://api.example.com/v1/", + ) + assert result == 1000000