{"repo":"NousResearch/hermes-agent","public_id":"43462","display_label":"Context length config not respected in compression/fill-rate logic","summary":"Multiple issues and fixes address a bug where context fill rate calculations and compression feasibility checks use a hardcoded value instead of reading context length from user configuration, custom providers, or per-model overrides. Work spans detection, propagation, and fallback resolution of configured context limits.","subsystem":"agent","counts":{"members":9,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":0,"merged_prs":2,"attempt_prs":6},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":15844,"merged_at":"2026-04-26T01:47:53+00:00"},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-04-26T01:47:53+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.2222222222222222},"fix_edges":[{"issue_number":5089,"pr_number":15844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-26T01:47:53+00:00"},{"issue_number":8499,"pr_number":8700,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-13T00:52:34+00:00"},{"issue_number":15779,"pr_number":15844,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-26T01:47:53+00:00"},{"issue_number":26548,"pr_number":15844,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-04-26T01:47:53+00:00"}],"members":[{"kind":"issue","number":43462,"title":"Context fill rate calculation uses hardcoded 256k instead of config limits","url":null,"state":"open"},{"kind":"pr","number":8700,"title":"fix(compression): pass configured context_length to feasibility check","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_compression_feasibility.py"]},{"kind":"pr","number":13540,"title":"fix(agent): propagate custom provider context_length to compression feasibility check","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":13813,"title":"fix(run_agent): read context_length from providers/custom_providers for compression model","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":15844,"title":"fix(context): honor custom_providers context_length on /model switch + bump probe tier to 256K","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/model_metadata.py","gateway/run.py","hermes_cli/config.py","hermes_cli/model_switch.py","run_agent.py","tests/agent/test_model_metadata.py","tests/gateway/test_session_info.py","tests/hermes_cli/test_custom_provider_context_length.py","tests/hermes_cli/test_model_switch_context_display.py"],"fix_hunks":[{"file":"agent/model_metadata.py","hunks":["@@ -106,9 +106,11 @@ def _strip_provider_prefix(model: str) -> str:\n _ENDPOINT_MODEL_CACHE_TTL = 300\n \n # Descending tiers for context length probing when the model is unknown.\n-# We start at 128K (a safe default for most modern models) and step down\n-# on context-length errors until one works.\n+# We start at 256K (covers GPT-5.x, many current large-context models) and\n+# step down on context-length errors until one works.  Tier[0] is also the\n+# default fallback when no detection method succeeds.\n CONTEXT_PROBE_TIERS = [\n+    256_000,\n     128_000,\n     64_000,\n     32_000,","@@ -1193,6 +1195,7 @@ def get_model_context_length(\n     api_key: str = \"\",\n     config_context_length: int | None = None,\n     provider: str = \"\",\n+    custom_providers: list | None = None,\n ) -> int:\n     \"\"\"Get the context length for a model.\n ","@@ -1213,6 +1216,23 @@ def get_model_context_length(\n     if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:\n         return config_context_length\n \n+    # 0b. custom_providers per-model override — check before any probe.\n+    # This closes the gap where /model switch and display paths used to fall\n+    # back to 128K despite the user having a per-model context_length set.\n+    # See #15779.\n+    if custom_providers and base_url and model:\n+        try:\n+            from hermes_cli.config import get_custom_provider_context_length\n+            cp_ctx = get_custom_provider_context_length(\n+                model=model,\n+                base_url=base_url,\n+                custom_providers=custom_providers,\n+            )\n+            if cp_ctx:\n+                return cp_ctx\n+        except Exception:\n+            pass  # fall through to probing\n+\n     # Normalise provider-prefixed model names (e.g. \"local:model-name\" →\n     # \"model-name\") so cache lookups and server queries use the bare ID that\n     # local servers actually know about.  Ollama \"model:tag\" colons are preserved.","@@ -1352,7 +1372,7 @@ def get_model_context_length(\n     # 6. OpenRouter live API metadata (provider-unaware fallback)\n     metadata = fetch_model_metadata()\n     if model in metadata:\n-        return metadata[model].get(\"context_length\", 128000)\n+        return metadata[model].get(\"context_length\", DEFAULT_FALLBACK_CONTEXT)\n \n     # 8. Hardcoded defaults (fuzzy match — longest key first for specificity)\n     # Only check `default_model in model` (is the key a substring of the input)."]},{"file":"gateway/run.py","hunks":["@@ -4891,6 +4891,7 @@ def _format_session_info(self) -> str:\n         provider = None\n         base_url = None\n         api_key = None\n+        custom_provs = None\n \n         try:\n             cfg_path = _hermes_home / \"config.yaml\"","@@ -4908,6 +4909,11 @@ def _format_session_info(self) -> str:\n                             pass\n                     provider = model_cfg.get(\"provider\") or None\n                     base_url = model_cfg.get(\"base_url\") or None\n+                try:\n+                    from hermes_cli.config import get_compatible_custom_providers\n+                    custom_provs = get_compatible_custom_providers(data)\n+                except Exception:\n+                    custom_provs = data.get(\"custom_providers\")\n         except Exception:\n             pass\n ","@@ -4926,6 +4932,7 @@ def _format_session_info(self) -> str:\n             api_key=[redacted:secret-assignment] or \"\",\n             config_context_length=config_context_length,\n             provider=provider or \"\",\n+            custom_providers=custom_provs,\n         )\n \n         # Format context source hint","@@ -5601,6 +5608,7 @@ async def _on_model_selected(\n                             base_url=result.base_url or current_base_url or \"\",\n                             api_key=[redacted:secret-assignment] or current_api_key or \"\",\n                             model_info=mi,\n+                            custom_providers=custom_provs,\n                         )\n                         if ctx:\n                             lines.append(f\"Context: {ctx:,} tokens\")","@@ -5748,6 +5756,7 @@ async def _on_model_selected(\n             base_url=result.base_url or current_base_url or \"\",\n             api_key=[redacted:secret-assignment] or current_api_key or \"\",\n             model_info=mi,\n+            custom_providers=custom_provs,\n         )\n         if ctx:\n             lines.append(f\"Context: {ctx:,} tokens\")"]},{"file":"hermes_cli/config.py","hunks":["@@ -2206,6 +2206,71 @@ def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:\n     return compatible\n \n \n+def get_custom_provider_context_length(\n+    model: str,\n+    base_url: str,\n+    custom_providers: Optional[List[Dict[str, Any]]] = None,\n+    config: Optional[Dict[str, Any]] = None,\n+) -> Optional[int]:\n+    \"\"\"Look up a per-model ``context_length`` override from ``custom_providers``.\n+\n+    Matches any entry whose ``base_url`` equals ``base_url`` (trailing-slash\n+    insensitive) and returns ``custom_providers[i].models.<model>.context_length``\n+    if present and valid.  Returns ``None`` when no override applies.\n+\n+    This is the single source of truth for custom-provider context overrides,\n+    used by:\n+      * ``AIAgent.__init__`` (startup resolution)\n+      * ``AIAgent.switch_model`` (mid-session ``/model`` switch)\n+      * ``hermes_cli.model_switch.resolve_display_context_length`` (``/model`` confirmation display)\n+      * ``gateway.run._format_session_info`` (``/info`` display)\n+      * ``agent.model_metadata.get_model_context_length`` (when custom_providers is threaded through)\n+\n+    Before this helper existed, the lookup was duplicated in ``run_agent.py``'s\n+    startup path only; every other path (notably ``/model`` switch) fell back\n+    to the 128K default.  See #15779.\n+    \"\"\"\n+    if not model or not base_url:\n+        return None\n+    if custom_providers is None:\n+        try:\n+            custom_providers = get_compatible_custom_providers(config)\n+        except Exception:\n+            if config is None:\n+                return None\n+            raw = config.get(\"custom_providers\")\n+            custom_providers = raw if isinstance(raw, list) else []\n+    if not isinstance(custom_providers, list):\n+        return None\n+\n+    target_url = (base_url or \"\").rstrip(\"/\")\n+    if not target_url:\n+        return None\n+\n+    for entry in custom_providers:\n+        if not isinstance(entry, dict):\n+            continue\n+        entry_url = (entry.get(\"base_url\") or \"\").rstrip(\"/\")\n+        if not entry_url or entry_url != target_url:\n+            continue\n+        models = entry.get(\"models\")\n+        if not isinstance(models, dict):\n+            continue\n+        model_cfg = models.get(model)\n+        if not isinstance(model_cfg, dict):\n+            continue\n+        raw_ctx = model_cfg.get(\"context_length\")\n+        if raw_ctx is None:\n+            continue\n+        try:\n+            ctx = int(raw_ctx)\n+        except (TypeError, ValueError):\n+            continue\n+        if ctx > 0:\n+            return ctx\n+    return None\n+\n+\n def check_config_version() -> Tuple[int, int]:\n     \"\"\"\n     Check config version."]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -533,6 +533,7 @@ def resolve_display_context_length(\n     base_url: str = \"\",\n     api_key: str = \"\",\n     model_info: Optional[ModelInfo] = None,\n+    custom_providers: list | None = None,\n ) -> Optional[int]:\n     \"\"\"Resolve the context length to show in /model output.\n ","@@ -543,6 +544,11 @@ def resolve_display_context_length(\n     about Codex OAuth, Copilot, Nous, and falls back to models.dev for the\n     rest.\n \n+    When ``custom_providers`` is provided, per-model ``context_length``\n+    overrides from ``custom_providers[].models.<id>.context_length`` are\n+    honored — this closes #15779 where ``/model`` switch ignored user-set\n+    overrides.\n+\n     Prefer the provider-aware value; fall back to ``model_info.context_window``\n     only if the resolver returns nothing.\n     \"\"\"","@@ -553,6 +559,7 @@ def resolve_display_context_length(\n             base_url=base_url or \"\",\n             api_key=[redacted:secret-assignment] or \"\",\n             provider=provider or None,\n+            custom_providers=custom_providers,\n         )\n         if ctx:\n             return int(ctx)"]},{"file":"run_agent.py","hunks":["@@ -1765,43 +1765,64 @@ def __init__(\n         # Store for reuse in switch_model (so config override persists across model switches)\n         self._config_context_length = _config_context_length\n \n+        # Resolve custom_providers list once for reuse below (startup\n+        # context-length override and plugin context-engine init).\n+        try:\n+            from hermes_cli.config import get_compatible_custom_providers\n+            _custom_providers = get_compatible_custom_providers(_agent_cfg)\n+        except Exception:\n+            _custom_providers = _agent_cfg.get(\"custom_providers\")\n+            if not isinstance(_custom_providers, list):\n+                _custom_providers = []\n+\n         # Check custom_providers per-model context_length\n-        if _config_context_length is None:\n+        if _config_context_length is None and _custom_providers:\n             try:\n-                from hermes_cli.config import get_compatible_custom_providers\n-                _custom_providers = get_compatible_custom_providers(_agent_cfg)\n+                from hermes_cli.config import get_custom_provider_context_length\n+                _cp_ctx_resolved = get_custom_provider_context_length(\n+                    model=self.model,\n+                    base_url=self.base_url,\n+                    custom_providers=_custom_providers,\n+                )\n+                if _cp_ctx_resolved:\n+                    _config_context_length = int(_cp_ctx_resolved)\n             except Exception:\n-                _custom_providers = _agent_cfg.get(\"custom_providers\")\n-                if not isinstance(_custom_providers, list):\n-                    _custom_providers = []\n-            for _cp_entry in _custom_providers:\n-                if not isinstance(_cp_entry, dict):\n-                    continue\n-                _cp_url = (_cp_entry.get(\"base_url\") or \"\").rstrip(\"/\")\n-                if _cp_url and _cp_url == self.base_url.rstrip(\"/\"):\n-                    _cp_models = _cp_entry.get(\"models\", {})\n-                    if isinstance(_cp_models, dict):\n-                        _cp_model_cfg = _cp_models.get(self.model, {})\n-                        if isinstance(_cp_model_cfg, dict):\n-                            _cp_ctx = _cp_model_cfg.get(\"context_length\")\n-                            if _cp_ctx is not None:\n-                                try:\n-                                    _config_context_length = int(_cp_ctx)\n-                                except (TypeError, ValueError):\n-                                    logger.warning(\n-                                        \"Invalid context_length for model %r in \"\n-                                        \"custom_providers: %r — must be a plain \"\n-                                        \"integer (e.g. 256000, not '256K'). \"\n-                                        \"Falling back to auto-detection.\",\n-                                        self.model, _cp_ctx,\n-                                    )\n-                                    print(\n-                                        f\"\\n⚠ Invalid context_length for model {self.model!r} in custom_providers: {_cp_ctx!r}\\n\"\n-                                        f\"  Must be a plain integer (e.g. 256000, not '256K').\\n\"\n-                                        f\"  Falling back to auto-detected context window.\\n\",\n-                                        file=sys.stderr,\n-                                    )\n-                    break\n+                _cp_ctx_resolved = None\n+\n+            # Surface a clear warning if the user set a context_length but it\n+            # wasn't a valid positive int — the helper silently skips those.\n+            if _config_context_length is None:\n+                _target = self.base_url.rstrip(\"/\") if self.base_url else \"\"\n+                for _cp_entry in _custom_providers:\n+                    if not isinstance(_cp_entry, dict):\n+                        continue\n+                    _cp_url = (_cp_entry.get(\"base_url\") or \"\").rstrip(\"/\")\n+                    if _target and _cp_url == _target:\n+                        _cp_models = _cp_entry.get(\"models\", {})\n+                        if isinstance(_cp_models, dict):\n+                            _cp_model_cfg = _cp_models.get(self.model, {})\n+                            if isinstance(_cp_model_cfg, dict):\n+                                _cp_ctx = _cp_model_cfg.get(\"context_length\")\n+                                if _cp_ctx is not None:\n+                                    try:\n+                                        _parsed = int(_cp_ctx)\n+                                        if _parsed <= 0:\n+                                            raise ValueError\n+                                    except (TypeError, ValueError):\n+                                        logger.warning(\n+                                            \"Invalid context_length for model %r in \"\n+                                            \"custom_providers: %r — must be a positive \"\n+                                            \"integer (e.g. 256000, not '256K'). \"\n+                                            \"Falling back to auto-detection.\",\n+                                            self.model, _cp_ctx,\n+                                        )\n+                                        print(\n+                                            f\"\\n⚠ Invalid context_length for model {self.model!r} in custom_providers: {_cp_ctx!r}\\n\"\n+                                            f\"  Must be a positive integer (e.g. 256000, not '256K').\\n\"\n+                                            f\"  Falling back to auto-detected context window.\\n\",\n+                                            file=sys.stderr,\n+                                        )\n+                        break\n         \n         # Select context engine: config-driven (like memory providers).\n         # 1. Check config.yaml context.engine setting","@@ -1851,6 +1872,7 @@ def __init__(\n                 api_key=[redacted:secret-assignment] \"api_key\", \"\"),\n                 config_context_length=_config_context_length,\n                 provider=self.provider,\n+                custom_providers=_custom_providers,\n             )\n             self.context_compressor.update_model(\n                 model=self.model,","@@ -2141,12 +2163,23 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod\n         # ── Update context compressor ──\n         if hasattr(self, \"context_compressor\") and self.context_compressor:\n             from agent.model_metadata import get_model_context_length\n+            # Re-read custom_providers from live config so per-model\n+            # context_length overrides are honored when switching to a\n+            # custom provider mid-session (closes #15779).\n+            _sm_custom_providers = None\n+            try:\n+                from hermes_cli.config import load_config, get_compatible_custom_providers\n+                _sm_cfg = load_config()\n+                _sm_custom_providers = get_compatible_custom_providers(_sm_cfg)\n+            except Exception:\n+                _sm_custom_providers = None\n             new_context_length = get_model_context_length(\n                 self.model,\n                 base_url=self.base_url,\n                 api_key=self.api_key,\n                 provider=self.provider,\n                 config_context_length=getattr(self, \"_config_context_length\", None),\n+                custom_providers=_sm_custom_providers,\n             )\n             self.context_compressor.update_model(\n                 model=self.model,"]},{"file":"tests/agent/test_model_metadata.py","hunks":["@@ -459,9 +459,10 @@ def test_qwen_generic_context_length(self, mock_fetch):\n \n     @patch(\"agent.model_metadata.fetch_model_metadata\")\n     def test_api_missing_context_length_key(self, mock_fetch):\n-        \"\"\"Model in API but without context_length → defaults to 128000.\"\"\"\n+        \"\"\"Model in API but without context_length → defaults to the top\n+        probe tier (currently 256K).\"\"\"\n         mock_fetch.return_value = {\"test/model\": {\"name\": \"Test\"}}\n-        assert get_model_context_length(\"test/model\") == 128000\n+        assert get_model_context_length(\"test/model\") == CONTEXT_PROBE_TIERS[0]\n \n     @patch(\"agent.model_metadata.fetch_model_metadata\")\n     def test_cache_takes_priority_over_api(self, mock_fetch, tmp_path):","@@ -814,14 +815,17 @@ def test_tiers_descending(self):\n         for i in range(len(CONTEXT_PROBE_TIERS) - 1):\n             assert CONTEXT_PROBE_TIERS[i] > CONTEXT_PROBE_TIERS[i + 1]\n \n-    def test_first_tier_is_128k(self):\n-        assert CONTEXT_PROBE_TIERS[0] == 128_000\n+    def test_first_tier_is_256k(self):\n+        assert CONTEXT_PROBE_TIERS[0] == 256_000\n \n     def test_last_tier_is_8k(self):\n         assert CONTEXT_PROBE_TIERS[-1] == 8_000\n \n \n class TestGetNextProbeTier:\n+    def test_from_256k(self):\n+        assert get_next_probe_tier(256_000) == 128_000\n+\n     def test_from_128k(self):\n         assert get_next_probe_tier(128_000) == 64_000\n ","@@ -841,8 +845,8 @@ def test_from_arbitrary_value(self):\n         assert get_next_probe_tier(100_000) == 64_000\n \n     def test_above_max_tier(self):\n-        \"\"\"Value above 128K should return 128K.\"\"\"\n-        assert get_next_probe_tier(500_000) == 128_000\n+        \"\"\"Value above 256K should return 256K.\"\"\"\n+        assert get_next_probe_tier(500_000) == 256_000\n \n     def test_zero_returns_none(self):\n         assert get_next_probe_tier(0) is None"]},{"file":"tests/gateway/test_session_info.py","hunks":["@@ -58,7 +58,7 @@ def test_default_fallback_hint(self, runner, tmp_path):\n                                   {\"provider\": \"\", \"base_url\": \"\", \"api_key\": \"\"})\n         with p1, p2, p3:\n             info = runner._format_session_info()\n-        assert \"128K\" in info\n+        assert \"256K\" in info\n         assert \"model.context_length\" in info\n \n     def test_local_endpoint_shown(self, runner, tmp_path):"]},{"file":"tests/hermes_cli/test_custom_provider_context_length.py","hunks":["@@ -0,0 +1,240 @@\n+\"\"\"Regression tests for custom_providers per-model context_length resolution.\n+\n+Covers the fix for #15779 — mid-session /model switch to a named custom\n+provider must honor ``custom_providers[].models.<id>.context_length`` the\n+same way startup already does.\n+\"\"\"\n+from __future__ import annotations\n+\n+from unittest.mock import patch\n+\n+from hermes_cli.config import get_custom_provider_context_length\n+\n+\n+class TestGetCustomProviderContextLength:\n+    def test_returns_override_for_matching_entry(self):\n+        custom = [\n+            {\n+                \"name\": \"my-endpoint\",\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"gpt-5.5\": {\"context_length\": 1_050_000}},\n+            }\n+        ]\n+        assert (\n+            get_custom_provider_context_length(\n+                \"gpt-5.5\", \"https://example.invalid/v1\", custom\n+            )\n+            == 1_050_000\n+        )\n+\n+    def test_trailing_slash_insensitive(self):\n+        custom = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1/\",\n+                \"models\": {\"m\": {\"context_length\": 500_000}},\n+            }\n+        ]\n+        # config has trailing slash, runtime doesn't — must match\n+        assert (\n+            get_custom_provider_context_length(\n+                \"m\", \"https://example.invalid/v1\", custom\n+            )\n+            == 500_000\n+        )\n+        # and the reverse\n+        custom2 = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"m\": {\"context_length\": 500_000}},\n+            }\n+        ]\n+        assert (\n+            get_custom_provider_context_length(\n+                \"m\", \"https://example.invalid/v1/\", custom2\n+            )\n+            == 500_000\n+        )\n+\n+    def test_returns_none_when_url_does_not_match(self):\n+        custom = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"m\": {\"context_length\": 400_000}},\n+            }\n+        ]\n+        assert (\n+            get_custom_provider_context_length(\n+                \"m\", \"https://other.invalid/v1\", custom\n+            )\n+            is None\n+        )\n+\n+    def test_returns_none_when_model_does_not_match(self):\n+        custom = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"gpt-5.5\": {\"context_length\": 400_000}},\n+            }\n+        ]\n+        assert (\n+            get_custom_provider_context_length(\n+                \"different-model\", \"https://example.invalid/v1\", custom\n+            )\n+            is None\n+        )\n+\n+    def test_returns_none_for_string_value(self):\n+        \"\"\"'256K' string is not a valid int — skip silently.\n+\n+        (The inline startup path still emits a user-visible warning; the\n+        helper itself returns None so downstream fallbacks can run.)\n+        \"\"\"\n+        custom = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"m\": {\"context_length\": \"256K\"}},\n+            }\n+        ]\n+        assert (\n+            get_custom_provider_context_length(\n+                \"m\", \"https://example.invalid/v1\", custom\n+            )\n+            is None\n+        )\n+\n+    def test_returns_none_for_zero_or_negative(self):\n+        for bad in (0, -1, -100):\n+            custom = [\n+                {\n+                    \"base_url\": \"https://example.invalid/v1\",\n+                    \"models\": {\"m\": {\"context_length\": bad}},\n+                }\n+            ]\n+            assert (\n+                get_custom_provider_context_length(\n+                    \"m\", \"https://example.invalid/v1\", custom\n+                )\n+                is None\n+            ), f\"value {bad!r} should be rejected\"\n+\n+    def test_empty_inputs_return_none(self):\n+        assert get_custom_provider_context_length(\"\", \"http://x\", [{\"base_url\": \"http://x\", \"models\": {\"\": {\"context_length\": 1}}}]) is None\n+        assert get_custom_provider_context_length(\"m\", \"\", [{\"base_url\": \"\", \"models\": {\"m\": {\"context_length\": 1}}}]) is None\n+        assert get_custom_provider_context_length(\"m\", \"http://x\", None) is None\n+        assert get_custom_provider_context_length(\"m\", \"http://x\", []) is None\n+\n+    def test_ignores_non_dict_entries(self):\n+        \"\"\"Malformed entries must not crash the lookup.\"\"\"\n+        custom = [\n+            \"not a dict\",\n+            None,\n+            {\"base_url\": \"https://example.invalid/v1\", \"models\": \"not a dict\"},\n+            {\"base_url\": \"https://example.invalid/v1\", \"models\": {\"m\": \"not a dict\"}},\n+            {\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"m\": {\"context_length\": 400_000}},\n+            },\n+        ]\n+        assert (\n+            get_custom_provider_context_length(\n+                \"m\", \"https://example.invalid/v1\", custom\n+            )\n+            == 400_000\n+        )\n+\n+\n+class TestGetModelContextLengthHonorsOverride:\n+    \"\"\"agent.model_metadata.get_model_context_length must honor the\n+    custom_providers override at step 0b — before any probe, cache hit,\n+    or models.dev lookup can override it.\n+    \"\"\"\n+\n+    def _mock_all_probes(self):\n+        \"\"\"Context manager that disables every downstream resolution step.\"\"\"\n+        from agent import model_metadata as _mm\n+        return [\n+            patch.object(_mm, \"get_cached_context_length\", return_value=None),\n+            patch.object(_mm, \"fetch_endpoint_model_metadata\", return_value={}),\n+            patch.object(_mm, \"fetch_model_metadata\", return_value={}),\n+            patch.object(_mm, \"is_local_endpoint\", return_value=False),\n+            patch.object(_mm, \"_is_known_provider_base_url\", return_value=False),\n+        ]\n+\n+    def test_custom_providers_override_wins_over_default_fallback(self):\n+        from agent.model_metadata import get_model_context_length\n+        custom = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"gpt-5.5\": {\"context_length\": 1_050_000}},\n+            }\n+        ]\n+        patches = self._mock_all_probes()\n+        for p in patches:\n+            p.start()\n+        try:\n+            ctx = get_model_context_length(\n+                \"gpt-5.5\",\n+                base_url=\"https://example.invalid/v1\",\n+                provider=\"custom\",\n+                custom_providers=custom,\n+            )\n+        finally:\n+            for p in patches:\n+                p.stop()\n+        assert ctx == 1_050_000\n+\n+    def test_explicit_config_context_length_still_wins(self):\n+        \"\"\"Top-level model.context_length (step 0) outranks custom_providers (step 0b).\n+\n+        Users who set both should see the top-level value — that's the\n+        documented precedence and matches the long-standing step-0 behavior.\n+        \"\"\"\n+        from agent.model_metadata import get_model_context_length\n+        custom = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"m\": {\"context_length\": 1_050_000}},\n+            }\n+        ]\n+        ctx = get_model_context_length(\n+            \"m\",\n+            base_url=\"https://example.invalid/v1\",\n+            provider=\"custom\",\n+            config_context_length=500_000,  # explicit top-level wins\n+            custom_providers=custom,\n+        )\n+        assert ctx == 500_000\n+\n+    def test_no_override_falls_through_to_default(self):\n+        \"\"\"With custom_providers=None and all probes disabled, resolver\n+        returns DEFAULT_FALLBACK_CONTEXT (256K after the stepdown bump).\n+        \"\"\"\n+        from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT\n+        patches = self._mock_all_probes()\n+        for p in patches:\n+            p.start()\n+        try:\n+            ctx = get_model_context_length(\n+                \"unknown-model\",\n+                base_url=\"https://example.invalid/v1\",\n+                provider=\"custom\",\n+                custom_providers=None,\n+            )\n+        finally:\n+            for p in patches:\n+                p.stop()\n+        assert ctx == DEFAULT_FALLBACK_CONTEXT\n+\n+\n+class TestContextProbeTiers:\n+    def test_256k_is_top_tier_and_default(self):\n+        \"\"\"The stepdown probe starts at 256K and 256K is the new default.\"\"\"\n+        from agent.model_metadata import CONTEXT_PROBE_TIERS, DEFAULT_FALLBACK_CONTEXT\n+\n+        assert CONTEXT_PROBE_TIERS[0] == 256_000\n+        assert DEFAULT_FALLBACK_CONTEXT == 256_000\n+        # Tiers still descend monotonically\n+        for a, b in zip(CONTEXT_PROBE_TIERS, CONTEXT_PROBE_TIERS[1:]):\n+            assert a > b, f\"tiers must strictly descend, got {a} then {b}\"\n+        # 128K is still a tier (users relying on it probe-down get there)\n+        assert 128_000 in CONTEXT_PROBE_TIERS"]},{"file":"tests/hermes_cli/test_model_switch_context_display.py","hunks":["@@ -88,3 +88,61 @@ def test_prefers_resolver_even_when_model_info_has_larger_value(self):\n                 model_info=fake_mi,\n             )\n         assert ctx == 128_000\n+\n+    def test_custom_providers_override_honored(self):\n+        \"\"\"Regression for #15779: /model switch onto a custom provider must\n+        surface the configured per-model context_length, not the 128K/256K\n+        fallback.\n+        \"\"\"\n+        custom_provs = [\n+            {\n+                \"name\": \"my-custom-endpoint\",\n+                \"base_url\": \"https://example.invalid/v1\",\n+                \"models\": {\"gpt-5.5\": {\"context_length\": 1_050_000}},\n+            }\n+        ]\n+        # Real resolver call — no mock — so the override path is exercised\n+        # through agent.model_metadata.get_model_context_length.\n+        from unittest.mock import patch as _p\n+        from agent import model_metadata as _mm\n+        with _p.object(_mm, \"get_cached_context_length\", return_value=None), \\\n+             _p.object(_mm, \"fetch_endpoint_model_metadata\", return_value={}), \\\n+             _p.object(_mm, \"fetch_model_metadata\", return_value={}), \\\n+             _p.object(_mm, \"is_local_endpoint\", return_value=False), \\\n+             _p.object(_mm, \"_is_known_provider_base_url\", return_value=False):\n+            ctx = resolve_display_context_length(\n+                \"gpt-5.5\",\n+                \"custom\",\n+                base_url=\"https://example.invalid/v1\",\n+                api_key=\"k\",\n+                custom_providers=custom_provs,\n+            )\n+        assert ctx == 1_050_000, (\n+            \"custom_providers[].models.gpt-5.5.context_length=1.05M must win \"\n+            \"over probe-down fallback\"\n+        )\n+\n+    def test_custom_providers_trailing_slash_insensitive(self):\n+        \"\"\"Base URL comparison must tolerate trailing-slash differences\n+        between config.yaml and the runtime value.\n+        \"\"\"\n+        custom_provs = [\n+            {\n+                \"base_url\": \"https://example.invalid/v1/\",\n+                \"models\": {\"m\": {\"context_length\": 400_000}},\n+            }\n+        ]\n+        from unittest.mock import patch as _p\n+        from agent import model_metadata as _mm\n+        with _p.object(_mm, \"get_cached_context_length\", return_value=None), \\\n+             _p.object(_mm, \"fetch_endpoint_model_metadata\", return_value={}), \\\n+             _p.object(_mm, \"fetch_model_metadata\", return_value={}), \\\n+             _p.object(_mm, \"is_local_endpoint\", return_value=False), \\\n+             _p.object(_mm, \"_is_known_provider_base_url\", return_value=False):\n+            ctx = resolve_display_context_length(\n+                \"m\",\n+                \"custom\",\n+                base_url=\"https://example.invalid/v1\",  # no trailing slash\n+                custom_providers=custom_provs,\n+            )\n+        assert ctx == 400_000\n"]}]},{"kind":"pr","number":19553,"title":"fix(compression): pass custom_providers to feasibility check context lookup","url":null,"state":"closed","close_reason":{"category":"stale_abandoned","implication":"active"},"merged":false},{"kind":"pr","number":24460,"title":"fix(context): respect custom provider per-model context_length overrides","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#11438","number":11438},{"kind":"pr","value":"#13052","number":13052},{"kind":"pr","value":"#7998","number":7998},{"kind":"pr","value":"#15779","number":15779}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":25554,"title":"fix(agent): forward custom_providers to context-length probe on fallback activation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b5bcffe1674fa9ab3ba7a754c07ab77bedde83a8","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":43509,"title":"fix(config): fall back to provider-level context_length for fill rate calculation","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}