# NousResearch/hermes-agent · Custom provider context_length not respected in compression paths

## Open now

- 2 issues, 3 PRs open on GitHub.
- #10157: Feature: Warn on context_length mismatch after /model --global switch
- #15563: get_model_context_length step 2 short-circuits to 128K default; resolve_display_context_length skips config_context_length
- PR #10366: fix(model): warn on stale context_length after /model --global
- PR #15948: Fix config-driven context length resolution
- PR #36852: fix(custom_providers): resolve per-model context_length for publisher/slug model ids

A cluster of bug reports and fixes centered on custom provider context_length values being ignored across compression feasibility checks, auxiliary compression, context hygiene, and related gateway/agent paths. Work involves propagating custom_providers configuration through the relevant code paths so context-length detection honors user-defined values.

## TL;DR

- 61 member(s): 2 open issue(s), 21 closed issue(s), 3 open PR(s), 7 merged PR(s), 28 PR(s) closed without merging.
- A fix was merged to main in PR #8700. Verify it addresses your case.
- **Recommended:** read PR #8700 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-8700,pr-15844,pr-20218,pr-59522,pr-73578`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.03`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Open related issues

- #10157: Feature: Warn on context_length mismatch after /model --global switch
- #15563: get_model_context_length step 2 short-circuits to 128K default; resolve_display_context_length skips config_context_length

## Closed / duplicate issues

- #5089: Bug: Status banner fails to detect context_length from custom_providers
- #8499: \[Bug\] \`auxiliary.compression.context_length\` config ignored in compression model feasibility check
- #8548: Bug: compression model context length not read from custom_providers
- #8550: Bug: compression model context length not read from custom_providers
- #8785: Bug: compression runtime context_length overrides are not applied consistently
- #12156: Telegram gateway ignores context_length from custom_providers entries
- #12977: \[Bug\]: custom_providers.models.context_length not propagated to auxiliary compression feasibility check
- #13807: auxiliary compression model does not read context_length from providers config
- #15779: Bug: /model switch to named custom provider ignores custom_providers model context_length
- #17690: but the main model's compression threshold was
- #19539: Compression feasibility check ignores custom provider context_length for auxiliary compression
- #20608: _check_compression_model_feasibility doesn't pass custom_providers to get_model_context_length
- #21919: \[Bug\]: Auxiliary compression model does not inherit context_length from custom_providers
- #21947: \[Bug\]: Auxiliary compression model does not inherit context_length from custom_providers
- #23779: compression feasibility check ignores custom_providers context_length, causes false 256K warning
- #24072: # Bug Report: \`model.context_length\` persists across \`/model\` provider switches
- #26548: \[Bug\]: \`auxiliary.*\` context_length ignores \`custom_providers\[\].models\[\].context_length\`
- #30178: \[Bug\]: LM Studio custom_providers per-model context_length broken in 0.14.0 — regressed to 64K
- #39736: \[Bug\]: Compression model context length does not respect custom provider context length
- #59314: fix: resolve_display_context_length ignores custom_providers per-model context_length
- #63032: \[Bug\] /model switch does not rebase context_length — uses old model's budget

## Prior & competing PRs

- **#8700: fix(compression): pass configured context_length to feasibility check**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `tests/run_agent/test_compression_feasibility.py`
- **#8721: fix(agent): honor custom provider context in compression feasibility check**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#17460`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#13813: fix(run_agent): read context_length from providers/custom_providers for compression model**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#14119: fix(agent): honor provider context for aux compression**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `d682f320b35a13084371a541a835e1d988c982b8`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#15732: fix(context-length): fall through to catalog when endpoint probe misses; pass config_context_length to display resolver**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#15844: fix(context): honor custom_providers context_length on /model switch + bump probe tier to 256K**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `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:
  - `agent/model_metadata.py`

    ```diff
    @@ -106,9 +106,11 @@ def _strip_provider_prefix(model: str) -> str:
     _ENDPOINT_MODEL_CACHE_TTL = 300
     
     # Descending tiers for context length probing when the model is unknown.
    -# We start at 128K (a safe default for most modern models) and step down
    -# on context-length errors until one works.
    +# We start at 256K (covers GPT-5.x, many current large-context models) and
    +# step down on context-length errors until one works.  Tier[0] is also the
    +# default fallback when no detection method succeeds.
     CONTEXT_PROBE_TIERS = [
    +    256_000,
         128_000,
         64_000,
         32_000,
    ```

    ```diff
    @@ -1193,6 +1195,7 @@ def get_model_context_length(
         api_key: str = "",
         config_context_length: int | None = None,
         provider: str = "",
    +    custom_providers: list | None = None,
     ) -> int:
         """Get the context length for a model.
     
    ```

    ```diff
    @@ -1213,6 +1216,23 @@ def get_model_context_length(
         if config_context_length is not None and isinstance(config_context_length, int) and config_context_length > 0:
             return config_context_length
     
    +    # 0b. custom_providers per-model override — check before any probe.
    +    # This closes the gap where /model switch and display paths used to fall
    +    # back to 128K despite the user having a per-model context_length set.
    +    # See #15779.
    +    if custom_providers and base_url and model:
    +        try:
    +            from hermes_cli.config import get_custom_provider_context_length
    +            cp_ctx = get_custom_provider_context_length(
    +                model=model,
    +                base_url=base_url,
    +                custom_providers=custom_providers,
    +            )
    +            if cp_ctx:
    +                return cp_ctx
    +        except Exception:
    +            pass  # fall through to probing
    +
         # Normalise provider-prefixed model names (e.g. "local:model-name" →
         # "model-name") so cache lookups and server queries use the bare ID that
         # local servers actually know about.  Ollama "model:tag" colons are preserved.
    ```

    ```diff
    @@ -1352,7 +1372,7 @@ def get_model_context_length(
         # 6. OpenRouter live API metadata (provider-unaware fallback)
         metadata = fetch_model_metadata()
         if model in metadata:
    -        return metadata[model].get("context_length", 128000)
    +        return metadata[model].get("context_length", DEFAULT_FALLBACK_CONTEXT)
     
         # 8. Hardcoded defaults (fuzzy match — longest key first for specificity)
         # Only check `default_model in model` (is the key a substring of the input).
    ```
  - `gateway/run.py`

    ```diff
    @@ -4891,6 +4891,7 @@ def _format_session_info(self) -> str:
             provider = None
             base_url = None
             api_key = None
    +        custom_provs = None
     
             try:
                 cfg_path = _hermes_home / "config.yaml"
    ```

    ```diff
    @@ -4908,6 +4909,11 @@ def _format_session_info(self) -> str:
                                 pass
                         provider = model_cfg.get("provider") or None
                         base_url = model_cfg.get("base_url") or None
    +                try:
    +                    from hermes_cli.config import get_compatible_custom_providers
    +                    custom_provs = get_compatible_custom_providers(data)
    +                except Exception:
    +                    custom_provs = data.get("custom_providers")
             except Exception:
                 pass
     
    ```

    ```diff
    @@ -4926,6 +4932,7 @@ def _format_session_info(self) -> str:
                 api_key=[redacted:secret-assignment] or "",
                 config_context_length=config_context_length,
                 provider=provider or "",
    +            custom_providers=custom_provs,
             )
     
             # Format context source hint
    ```

    ```diff
    @@ -5601,6 +5608,7 @@ async def _on_model_selected(
                                 base_url=result.base_url or current_base_url or "",
                                 api_key=[redacted:secret-assignment] or current_api_key or "",
                                 model_info=mi,
    +                            custom_providers=custom_provs,
                             )
                             if ctx:
                                 lines.append(f"Context: {ctx:,} tokens")
    ```

    ```diff
    @@ -5748,6 +5756,7 @@ async def _on_model_selected(
                 base_url=result.base_url or current_base_url or "",
                 api_key=[redacted:secret-assignment] or current_api_key or "",
                 model_info=mi,
    +            custom_providers=custom_provs,
             )
             if ctx:
                 lines.append(f"Context: {ctx:,} tokens")
    ```
  - `hermes_cli/config.py`

    ```diff
    @@ -2206,6 +2206,71 @@ def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:
         return compatible
     
     
    +def get_custom_provider_context_length(
    +    model: str,
    +    base_url: str,
    +    custom_providers: Optional[List[Dict[str, Any]]] = None,
    +    config: Optional[Dict[str, Any]] = None,
    +) -> Optional[int]:
    +    """Look up a per-model ``context_length`` override from ``custom_providers``.
    +
    +    Matches any entry whose ``base_url`` equals ``base_url`` (trailing-slash
    +    insensitive) and returns ``custom_providers[i].models.<model>.context_length``
    +    if present and valid.  Returns ``None`` when no override applies.
    +
    +    This is the single source of truth for custom-provider context overrides,
    +    used by:
    +      * ``AIAgent.__init__`` (startup resolution)
    +      * ``AIAgent.switch_model`` (mid-session ``/model`` switch)
    +      * ``hermes_cli.model_switch.resolve_display_context_length`` (``/model`` confirmation display)
    +      * ``gateway.run._format_session_info`` (``/info`` display)
    +      * ``agent.model_metadata.get_model_context_length`` (when custom_providers is threaded through)
    +
    +    Before this helper existed, the lookup was duplicated in ``run_agent.py``'s
    +    startup path only; every other path (notably ``/model`` switch) fell back
    +    to the 128K default.  See #15779.
    +    """
    +    if not model or not base_url:
    +        return None
    +    if custom_providers is None:
    +        try:
    +            custom_providers = get_compatible_custom_providers(config)
    +        except Exception:
    +            if config is None:
    +                return None
    +            raw = config.get("custom_providers")
    +            custom_providers = raw if isinstance(raw, list) else []
    +    if not isinstance(custom_providers, list):
    +        return None
    +
    +    target_url = (base_url or "").rstrip("/")
    +    if not target_url:
    +        return None
    +
    +    for entry in custom_providers:
    +        if not isinstance(entry, dict):
    +            continue
    +        entry_url = (entry.get("base_url") or "").rstrip("/")
    +        if not entry_url or entry_url != target_url:
    +            continue
    +        models = entry.get("models")
    +        if not isinstance(models, dict):
    +            continue
    +        model_cfg = models.get(model)
    +        if not isinstance(model_cfg, dict):
    +            continue
    +        raw_ctx = model_cfg.get("context_length")
    +        if raw_ctx is None:
    +            continue
    +        try:
    +            ctx = int(raw_ctx)
    +        except (TypeError, ValueError):
    +            continue
    +        if ctx > 0:
    +            return ctx
    +    return None
    +
    +
     def check_config_version() -> Tuple[int, int]:
         """
         Check config version.
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -533,6 +533,7 @@ def resolve_display_context_length(
         base_url: str = "",
         api_key: str = "",
         model_info: Optional[ModelInfo] = None,
    +    custom_providers: list | None = None,
     ) -> Optional[int]:
         """Resolve the context length to show in /model output.
     
    ```

    ```diff
    @@ -543,6 +544,11 @@ def resolve_display_context_length(
         about Codex OAuth, Copilot, Nous, and falls back to models.dev for the
         rest.
     
    +    When ``custom_providers`` is provided, per-model ``context_length``
    +    overrides from ``custom_providers[].models.<id>.context_length`` are
    +    honored — this closes #15779 where ``/model`` switch ignored user-set
    +    overrides.
    +
         Prefer the provider-aware value; fall back to ``model_info.context_window``
         only if the resolver returns nothing.
         """
    ```

    ```diff
    @@ -553,6 +559,7 @@ def resolve_display_context_length(
                 base_url=base_url or "",
                 api_key=[redacted:secret-assignment] or "",
                 provider=provider or None,
    +            custom_providers=custom_providers,
             )
             if ctx:
                 return int(ctx)
    ```
  - `run_agent.py`

    ```diff
    @@ -1765,43 +1765,64 @@ def __init__(
             # Store for reuse in switch_model (so config override persists across model switches)
             self._config_context_length = _config_context_length
     
    +        # Resolve custom_providers list once for reuse below (startup
    +        # context-length override and plugin context-engine init).
    +        try:
    +            from hermes_cli.config import get_compatible_custom_providers
    +            _custom_providers = get_compatible_custom_providers(_agent_cfg)
    +        except Exception:
    +            _custom_providers = _agent_cfg.get("custom_providers")
    +            if not isinstance(_custom_providers, list):
    +                _custom_providers = []
    +
             # Check custom_providers per-model context_length
    -        if _config_context_length is None:
    +        if _config_context_length is None and _custom_providers:
                 try:
    -                from hermes_cli.config import get_compatible_custom_providers
    -                _custom_providers = get_compatible_custom_providers(_agent_cfg)
    +                from hermes_cli.config import get_custom_provider_context_length
    +                _cp_ctx_resolved = get_custom_provider_context_length(
    +                    model=self.model,
    +                    base_url=self.base_url,
    +                    custom_providers=_custom_providers,
    +                )
    +                if _cp_ctx_resolved:
    +                    _config_context_length = int(_cp_ctx_resolved)
                 except Exception:
    -                _custom_providers = _agent_cfg.get("custom_providers")
    -                if not isinstance(_custom_providers, list):
    -                    _custom_providers = []
    -            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 == self.base_url.rstrip("/"):
    -                    _cp_models = _cp_entry.get("models", {})
    -                    if isinstance(_cp_models, dict):
    -                        _cp_model_cfg = _cp_models.get(self.model, {})
    -                        if isinstance(_cp_model_cfg, dict):
    -                            _cp_ctx = _cp_model_cfg.get("context_length")
    -                            if _cp_ctx is not None:
    -                                try:
    -                                    _config_context_length = int(_cp_ctx)
    -                                except (TypeError, ValueError):
    -                                    logger.warning(
    -                                        "Invalid context_length for model %r in "
    -                                        "custom_providers: %r — must be a plain "
    -                                        "integer (e.g. 256000, not '256K'). "
    -                                        "Falling back to auto-detection.",
    -                                        self.model, _cp_ctx,
    -                                    )
    -                                    print(
    -                                        f"\n⚠ Invalid context_length for model {self.model!r} in custom_providers: {_cp_ctx!r}\n"
    -                                        f"  Must be a plain integer (e.g. 256000, not '256K').\n"
    -                                        f"  Falling back to auto-detected context window.\n",
    -                                        file=sys.stderr,
    -                                    )
    -                    break
    +                _cp_ctx_resolved = None
    +
    +            # Surface a clear warning if the user set a context_length but it
    +            # wasn't a valid positive int — the helper silently skips those.
    +            if _config_context_length is None:
    +                _target = self.base_url.rstrip("/") if self.base_url else ""
    +                for _cp_entry in _custom_providers:
    +                    if not isinstance(_cp_entry, dict):
    +                        continue
    +                    _cp_url = (_cp_entry.get("base_url") or "").rstrip("/")
    +                    if _target and _cp_url == _target:
    +                        _cp_models = _cp_entry.get("models", {})
    +                        if isinstance(_cp_models, dict):
    +                            _cp_model_cfg = _cp_models.get(self.model, {})
    +                            if isinstance(_cp_model_cfg, dict):
    +                                _cp_ctx = _cp_model_cfg.get("context_length")
    +                                if _cp_ctx is not None:
    +                                    try:
    +                                        _parsed = int(_cp_ctx)
    +                                        if _parsed <= 0:
    +                                            raise ValueError
    +                                    except (TypeError, ValueError):
    +                                        logger.warning(
    +                                            "Invalid context_length for model %r in "
    +                                            "custom_providers: %r — must be a positive "
    +                                            "integer (e.g. 256000, not '256K'). "
    +                                            "Falling back to auto-detection.",
    +                                            self.model, _cp_ctx,
    +                                        )
    +                                        print(
    +                                            f"\n⚠ Invalid context_length for model {self.model!r} in custom_providers: {_cp_ctx!r}\n"
    +                                            f"  Must be a positive integer (e.g. 256000, not '256K').\n"
    +                                            f"  Falling back to auto-detected context window.\n",
    +                                            file=sys.stderr,
    +                                        )
    +                        break
             
             # Select context engine: config-driven (like memory providers).
             # 1. Check config.yaml context.engine setting
    ```

    ```diff
    @@ -1851,6 +1872,7 @@ def __init__(
                     api_key=[redacted:secret-assignment] "api_key", ""),
                     config_context_length=_config_context_length,
                     provider=self.provider,
    +                custom_providers=_custom_providers,
                 )
                 self.context_compressor.update_model(
                     model=self.model,
    ```

    ```diff
    @@ -2141,12 +2163,23 @@ def switch_model(self, new_model, new_provider, api_key='', base_url='', api_mod
             # ── Update context compressor ──
             if hasattr(self, "context_compressor") and self.context_compressor:
                 from agent.model_metadata import get_model_context_length
    +            # Re-read custom_providers from live config so per-model
    +            # context_length overrides are honored when switching to a
    +            # custom provider mid-session (closes #15779).
    +            _sm_custom_providers = None
    +            try:
    +                from hermes_cli.config import load_config, get_compatible_custom_providers
    +                _sm_cfg = load_config()
    +                _sm_custom_providers = get_compatible_custom_providers(_sm_cfg)
    +            except Exception:
    +                _sm_custom_providers = None
                 new_context_length = get_model_context_length(
                     self.model,
                     base_url=self.base_url,
                     api_key=[redacted:secret-assignment]
                     provider=self.provider,
                     config_context_length=getattr(self, "_config_context_length", None),
    +                custom_providers=_sm_custom_providers,
                 )
                 self.context_compressor.update_model(
                     model=self.model,
    ```
  - `tests/agent/test_model_metadata.py`

    ```diff
    @@ -459,9 +459,10 @@ def test_qwen_generic_context_length(self, mock_fetch):
     
         @patch("agent.model_metadata.fetch_model_metadata")
         def test_api_missing_context_length_key(self, mock_fetch):
    -        """Model in API but without context_length → defaults to 128000."""
    +        """Model in API but without context_length → defaults to the top
    +        probe tier (currently 256K)."""
             mock_fetch.return_value = {"test/model": {"name": "Test"}}
    -        assert get_model_context_length("test/model") == 128000
    +        assert get_model_context_length("test/model") == CONTEXT_PROBE_TIERS[0]
     
         @patch("agent.model_metadata.fetch_model_metadata")
         def test_cache_takes_priority_over_api(self, mock_fetch, tmp_path):
    ```

    ```diff
    @@ -814,14 +815,17 @@ def test_tiers_descending(self):
             for i in range(len(CONTEXT_PROBE_TIERS) - 1):
                 assert CONTEXT_PROBE_TIERS[i] > CONTEXT_PROBE_TIERS[i + 1]
     
    -    def test_first_tier_is_128k(self):
    -        assert CONTEXT_PROBE_TIERS[0] == 128_000
    +    def test_first_tier_is_256k(self):
    +        assert CONTEXT_PROBE_TIERS[0] == 256_000
     
         def test_last_tier_is_8k(self):
             assert CONTEXT_PROBE_TIERS[-1] == 8_000
     
     
     class TestGetNextProbeTier:
    +    def test_from_256k(self):
    +        assert get_next_probe_tier(256_000) == 128_000
    +
         def test_from_128k(self):
             assert get_next_probe_tier(128_000) == 64_000
     
    ```

    ```diff
    @@ -841,8 +845,8 @@ def test_from_arbitrary_value(self):
             assert get_next_probe_tier(100_000) == 64_000
     
         def test_above_max_tier(self):
    -        """Value above 128K should return 128K."""
    -        assert get_next_probe_tier(500_000) == 128_000
    +        """Value above 256K should return 256K."""
    +        assert get_next_probe_tier(500_000) == 256_000
     
         def test_zero_returns_none(self):
             assert get_next_probe_tier(0) is None
    ```
  - `tests/gateway/test_session_info.py`

    ```diff
    @@ -58,7 +58,7 @@ def test_default_fallback_hint(self, runner, tmp_path):
                                       {"provider": "", "base_url": "", "api_key": ""})
             with p1, p2, p3:
                 info = runner._format_session_info()
    -        assert "128K" in info
    +        assert "256K" in info
             assert "model.context_length" in info
     
         def test_local_endpoint_shown(self, runner, tmp_path):
    ```
  - `tests/hermes_cli/test_custom_provider_context_length.py`

    ```diff
    @@ -0,0 +1,240 @@
    +"""Regression tests for custom_providers per-model context_length resolution.
    +
    +Covers the fix for #15779 — mid-session /model switch to a named custom
    +provider must honor ``custom_providers[].models.<id>.context_length`` the
    +same way startup already does.
    +"""
    +from __future__ import annotations
    +
    +from unittest.mock import patch
    +
    +from hermes_cli.config import get_custom_provider_context_length
    +
    +
    +class TestGetCustomProviderContextLength:
    +    def test_returns_override_for_matching_entry(self):
    +        custom = [
    +            {
    +                "name": "my-endpoint",
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"gpt-5.5": {"context_length": 1_050_000}},
    +            }
    +        ]
    +        assert (
    +            get_custom_provider_context_length(
    +                "gpt-5.5", "https://example.invalid/v1", custom
    +            )
    +            == 1_050_000
    +        )
    +
    +    def test_trailing_slash_insensitive(self):
    +        custom = [
    +            {
    +                "base_url": "https://example.invalid/v1/",
    +                "models": {"m": {"context_length": 500_000}},
    +            }
    +        ]
    +        # config has trailing slash, runtime doesn't — must match
    +        assert (
    +            get_custom_provider_context_length(
    +                "m", "https://example.invalid/v1", custom
    +            )
    +            == 500_000
    +        )
    +        # and the reverse
    +        custom2 = [
    +            {
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"m": {"context_length": 500_000}},
    +            }
    +        ]
    +        assert (
    +            get_custom_provider_context_length(
    +                "m", "https://example.invalid/v1/", custom2
    +            )
    +            == 500_000
    +        )
    +
    +    def test_returns_none_when_url_does_not_match(self):
    +        custom = [
    +            {
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"m": {"context_length": 400_000}},
    +            }
    +        ]
    +        assert (
    +            get_custom_provider_context_length(
    +                "m", "https://other.invalid/v1", custom
    +            )
    +            is None
    +        )
    +
    +    def test_returns_none_when_model_does_not_match(self):
    +        custom = [
    +            {
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"gpt-5.5": {"context_length": 400_000}},
    +            }
    +        ]
    +        assert (
    +            get_custom_provider_context_length(
    +                "different-model", "https://example.invalid/v1", custom
    +            )
    +            is None
    +        )
    +
    +    def test_returns_none_for_string_value(self):
    +        """'256K' string is not a valid int — skip silently.
    +
    +        (The inline startup path still emits a user-visible warning; the
    +        helper itself returns None so downstream fallbacks can run.)
    +        """
    +        custom = [
    +            {
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"m": {"context_length": "256K"}},
    +            }
    +        ]
    +        assert (
    +            get_custom_provider_context_length(
    +                "m", "https://example.invalid/v1", custom
    +            )
    +            is None
    +        )
    +
    +    def test_returns_none_for_zero_or_negative(self):
    +        for bad in (0, -1, -100):
    +            custom = [
    +                {
    +                    "base_url": "https://example.invalid/v1",
    +                    "models": {"m": {"context_length": bad}},
    +                }
    +            ]
    +            assert (
    +                get_custom_provider_context_length(
    +                    "m", "https://example.invalid/v1", custom
    +                )
    +                is None
    +            ), f"value {bad!r} should be rejected"
    +
    +    def test_empty_inputs_return_none(self):
    +        assert get_custom_provider_context_length("", "http://x", [{"base_url": "http://x", "models": {"": {"context_length": 1}}}]) is None
    +        assert get_custom_provider_context_length("m", "", [{"base_url": "", "models": {"m": {"context_length": 1}}}]) is None
    +        assert get_custom_provider_context_length("m", "http://x", None) is None
    +        assert get_custom_provider_context_length("m", "http://x", []) is None
    +
    +    def test_ignores_non_dict_entries(self):
    +        """Malformed entries must not crash the lookup."""
    +        custom = [
    +            "not a dict",
    +            None,
    +            {"base_url": "https://example.invalid/v1", "models": "not a dict"},
    +            {"base_url": "https://example.invalid/v1", "models": {"m": "not a dict"}},
    +            {
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"m": {"context_length": 400_000}},
    +            },
    +        ]
    +        assert (
    +            get_custom_provider_context_length(
    +                "m", "https://example.invalid/v1", custom
    +            )
    +            == 400_000
    +        )
    +
    +
    +class TestGetModelContextLengthHonorsOverride:
    +    """agent.model_metadata.get_model_context_length must honor the
    +    custom_providers override at step 0b — before any probe, cache hit,
    +    or models.dev lookup can override it.
    +    """
    +
    +    def _mock_all_probes(self):
    +        """Context manager that disables every downstream resolution step."""
    +        from agent import model_metadata as _mm
    +        return [
    +            patch.object(_mm, "get_cached_context_length", return_value=None),
    +            patch.object(_mm, "fetch_endpoint_model_metadata", return_value={}),
    +            patch.object(_mm, "fetch_model_metadata", return_value={}),
    +            patch.object(_mm, "is_local_endpoint", return_value=False),
    +            patch.object(_mm, "_is_known_provider_base_url", return_value=False),
    +        ]
    +
    +    def test_custom_providers_override_wins_over_default_fallback(self):
    +        from agent.model_metadata import get_model_context_length
    +        custom = [
    +            {
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"gpt-5.5": {"context_length": 1_050_000}},
    +            }
    +        ]
    +        patches = self._mock_all_probes()
    +        for p in patches:
    +            p.start()
    +        try:
    +            ctx = get_model_context_length(
    +                "gpt-5.5",
    +                base_url="https://example.invalid/v1",
    +                provider="custom",
    +                custom_providers=custom,
    +            )
    +        finally:
    +            for p in patches:
    +                p.stop()
    +        assert ctx == 1_050_000
    +
    +    def test_explicit_config_context_length_still_wins(self):
    +        """Top-level model.context_length (step 0) outranks custom_providers (step 0b).
    +
    +        Users who set both should see the top-level value — that's the
    +        documented precedence and matches the long-standing step-0 behavior.
    +        """
    +        from agent.model_metadata import get_model_context_length
    +        custom = [
    +            {
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"m": {"context_length": 1_050_000}},
    +            }
    +        ]
    +        ctx = get_model_context_length(
    +            "m",
    +            base_url="https://example.invalid/v1",
    +            provider="custom",
    +            config_context_length=500_000,  # explicit top-level wins
    +            custom_providers=custom,
    +        )
    +        assert ctx == 500_000
    +
    +    def test_no_override_falls_through_to_default(self):
    +        """With custom_providers=None and all probes disabled, resolver
    +        returns DEFAULT_FALLBACK_CONTEXT (256K after the stepdown bump).
    +        """
    +        from agent.model_metadata import get_model_context_length, DEFAULT_FALLBACK_CONTEXT
    +        patches = self._mock_all_probes()
    +        for p in patches:
    +            p.start()
    +        try:
    +            ctx = get_model_context_length(
    +                "unknown-model",
    +                base_url="https://example.invalid/v1",
    +                provider="custom",
    +                custom_providers=None,
    +            )
    +        finally:
    +            for p in patches:
    +                p.stop()
    +        assert ctx == DEFAULT_FALLBACK_CONTEXT
    +
    +
    +class TestContextProbeTiers:
    +    def test_256k_is_top_tier_and_default(self):
    +        """The stepdown probe starts at 256K and 256K is the new default."""
    +        from agent.model_metadata import CONTEXT_PROBE_TIERS, DEFAULT_FALLBACK_CONTEXT
    +
    +        assert CONTEXT_PROBE_TIERS[0] == 256_000
    +        assert DEFAULT_FALLBACK_CONTEXT == 256_000
    +        # Tiers still descend monotonically
    +        for a, b in zip(CONTEXT_PROBE_TIERS, CONTEXT_PROBE_TIERS[1:]):
    +            assert a > b, f"tiers must strictly descend, got {a} then {b}"
    +        # 128K is still a tier (users relying on it probe-down get there)
    +        assert 128_000 in CONTEXT_PROBE_TIERS
    ```
  - `tests/hermes_cli/test_model_switch_context_display.py`

    ```diff
    @@ -88,3 +88,61 @@ def test_prefers_resolver_even_when_model_info_has_larger_value(self):
                     model_info=fake_mi,
                 )
             assert ctx == 128_000
    +
    +    def test_custom_providers_override_honored(self):
    +        """Regression for #15779: /model switch onto a custom provider must
    +        surface the configured per-model context_length, not the 128K/256K
    +        fallback.
    +        """
    +        custom_provs = [
    +            {
    +                "name": "my-custom-endpoint",
    +                "base_url": "https://example.invalid/v1",
    +                "models": {"gpt-5.5": {"context_length": 1_050_000}},
    +            }
    +        ]
    +        # Real resolver call — no mock — so the override path is exercised
    +        # through agent.model_metadata.get_model_context_length.
    +        from unittest.mock import patch as _p
    +        from agent import model_metadata as _mm
    +        with _p.object(_mm, "get_cached_context_length", return_value=None), \
    +             _p.object(_mm, "fetch_endpoint_model_metadata", return_value={}), \
    +             _p.object(_mm, "fetch_model_metadata", return_value={}), \
    +             _p.object(_mm, "is_local_endpoint", return_value=False), \
    +             _p.object(_mm, "_is_known_provider_base_url", return_value=False):
    +            ctx = resolve_display_context_length(
    +                "gpt-5.5",
    +                "custom",
    +                base_url="https://example.invalid/v1",
    +                api_key="k",
    +                custom_providers=custom_provs,
    +            )
    +        assert ctx == 1_050_000, (
    +            "custom_providers[].models.gpt-5.5.context_length=1.05M must win "
    +            "over probe-down fallback"
    +        )
    +
    +    def test_custom_providers_trailing_slash_insensitive(self):
    +        """Base URL comparison must tolerate trailing-slash differences
    +        between config.yaml and the runtime value.
    +        """
    +        custom_provs = [
    +            {
    +                "base_url": "https://example.invalid/v1/",
    +                "models": {"m": {"context_length": 400_000}},
    +            }
    +        ]
    +        from unittest.mock import patch as _p
    +        from agent import model_metadata as _mm
    +        with _p.object(_mm, "get_cached_context_length", return_value=None), \
    +             _p.object(_mm, "fetch_endpoint_model_metadata", return_value={}), \
    +             _p.object(_mm, "fetch_model_metadata", return_value={}), \
    +             _p.object(_mm, "is_local_endpoint", return_value=False), \
    +             _p.object(_mm, "_is_known_provider_base_url", return_value=False):
    +            ctx = resolve_display_context_length(
    +                "m",
    +                "custom",
    +                base_url="https://example.invalid/v1",  # no trailing slash
    +                custom_providers=custom_provs,
    +            )
    +        assert ctx == 400_000
    ```
- **#17460: fix(model_metadata): auto-load global custom_providers in get_model_context_length (closes #12977) (closes #13807)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#18877: fix(context): pass custom_providers in aux compression and fallback paths**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`, `21078ebcea6dd870835080fdc76a40284418c921`, `b5bcffe1674fa9ab3ba7a754c07ab77bedde83a8`, `563b4d9e51a46cc421e327b351cb7efe1ccb151b`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#20218: fix(run_agent): use aux provider for compression context length lookup (salvage #16918)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`, `scripts/release.py`
- **#20724: fix(run_agent): pass custom_providers to compression feasibility check (#20608)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`, `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`, `#20608`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#23788: fix(compression): pass custom providers to feasibility check**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`, `7becb19ea`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#23789: fix: pass custom_providers to get_model_context_length in compression feasibility check**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#25494: fix(auxiliary): forward custom_providers to compression model context-length detection (#25359)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `run_agent.py`
  - fix hunks:
  - `run_agent.py`

    ```diff
    @@ -2204,6 +2204,10 @@ def __init__(
                 if not isinstance(_custom_providers, list):
                     _custom_providers = []
     
    +        # Store for reuse by _check_compression_model_feasibility (auxiliary
    +        # compression model context-length detection needs the same list).
    +        self._custom_providers = _custom_providers
    +
             # Check custom_providers per-model context_length
             if _config_context_length is None and _custom_providers:
                 try:
    ```

    ```diff
    @@ -3246,6 +3250,7 @@ def _check_compression_model_feasibility(self) -> None:
                     # provider-specific paths (e.g. Bedrock static table, OpenRouter API)
                     # are invoked for the correct client, not inherited from the main model.
                     provider=(_aux_cfg_provider if _aux_cfg_provider and _aux_cfg_provider != "auto" else getattr(self, "provider", "")),
    +                custom_providers=self._custom_providers,
                 )
     
                 # Hard floor: the auxiliary compression model must have at least
    ```
- **#59522: fix(cli): pass custom_providers to resolve_display_context_length (#59314)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `cli.py`, `tests/hermes_cli/test_model_switch_context_display.py`
- **#73578: fix(lmstudio): respect applied runtime context (salvage of #52188)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `agent/agent_init.py`, `agent/agent_runtime_helpers.py`, `contributors/emails/[redacted:email]`, `hermes_cli/models.py`, `run_agent.py`, `tests/hermes_cli/test_lmstudio_context_policy.py`, `tests/hermes_cli/test_urllib_security.py`, `tests/run_agent/test_lmstudio_load_mode.py`, `tests/run_agent/test_switch_model_context.py`, `website/docs/integrations/providers.md`
- **#133822: Mem0 installs from Mem0's own catalog plugin; core no longer bundles it**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `CONTRIBUTING.es.md`, `CONTRIBUTING.md`, `hermes_cli/config_defaults.py`, `hermes_cli/doctor_state.py`, `hermes_cli/subcommands/memory.py`, `locales/af.yaml`, `locales/ar.yaml`, `locales/de.yaml`, `locales/en.yaml`, `locales/es.yaml`, `locales/fr.yaml`, `locales/ga.yaml`, `locales/hu.yaml`, `locales/it.yaml`, `locales/ja.yaml`, `locales/ko.yaml`, `locales/pt.yaml`, `locales/ru.yaml`, `locales/tr.yaml`, `locales/uk.yaml` (+33 more)
- **#8240: fix(gateway): read custom provider context length in status**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3ccf723bf999d02698e44a30e1d6a9a90d7713f7`, `#5089`, `#15844`
- **#8511: fix(compression): pass configured context_length to feasibility check**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#8700`
- **#8667: fix(compression): honor auxiliary context_length override**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#8700`, `#8511`
- **#8786: fix: unify context length overrides for compression runtimes**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `125de02056eab84362fc91f57bd7041a19860b22`, `46451528a50ad1adff2591650e9f0ccda561cd26`
- **#10366: fix(model): warn on stale context_length after /model --global**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `cli.py`, `gateway/run.py`, `hermes_cli/model_switch.py`, `tests/cli/test_model_switch_context_warning.py`, `tests/gateway/test_model_command_custom_providers.py`, `tests/hermes_cli/test_model_switch_context_length_warning.py`
- **#13540: fix(agent): propagate custom provider context_length to compression feasibility check**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`
- **#14152: fix(agent): propagate custom provider context to compression checks**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `7becb19ea00c13bdff6f78b71aa3ddfb0bdb5378`, `#25494`
- **#14382: fix(gateway): read nested per-model context_length in session info banner**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `125de02056eab84362fc91f57bd7041a19860b22`, `#15844`
- **#15397: fix: compression model should read context_length from custom_providers**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#13813`, `#13807`, `#12977`
- **#15787: fix(model-switch): honor custom_providers per-model context_length on /model switch (#15779)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `125de0205`, `#15844`, `#15779`, `#15844`, `#15844`
- **#15948: Fix config-driven context length resolution**. open
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#15907`, `#15907`, `#15907`
  - files: `cli.py`, `gateway/run.py`, `hermes_cli/model_switch.py`, `run_agent.py`, `tests/gateway/test_gateway_model_context_display.py`, `tests/hermes_cli/test_model_switch_context_display.py`, `tests/run_agent/test_compression_feasibility.py`, `tests/run_agent/test_switch_model_context.py`
- **#16918: fix(run_agent): use aux provider for compression context length lookup**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`
- **#18844: refactor(gateway): use get_custom_provider_context_length() helper + pass custom_providers to @context path**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `8206e4249749`, `265ac7d81219`, `781334eea4`, `#133822`
- **#19034: fix(cli): honor custom context lengths in model switch display**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#15844`
- **#19553: fix(compression): pass custom_providers to feasibility check context lookup**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#21953: fix(compression): honor custom provider context lengths**. closed without merge
  - maintainer direction: category `stale_abandoned`; implication `active`
- **#23563: fix(agent): pass custom providers to aux context checks**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#13540`, `#18877`, `#20724`
- **#24079: fix: clear stale _config_context_length when switching providers**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `b6d48ebfde63f325143bc601cbc622d89d428f1a`, `#24724`, `#22387`
- **#25554: fix(agent): forward custom_providers to context-length probe on fallback activation**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `b5bcffe1674fa9ab3ba7a754c07ab77bedde83a8`
- **#26843: fix(compression): pass custom providers to context resolver**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `125de02056eab84362fc91f57bd7041a19860b22`, `#15844`
- **#36852: fix(custom_providers): resolve per-model context_length for publisher/slug model ids**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `265ac7d812192d3b6f11888811512604681d3a32`
  - files: `agent/agent_init.py`, `gateway/run.py`, `hermes_cli/config.py`, `tests/gateway/test_context_expansion_custom_provider.py`, `tests/gateway/test_session_hygiene.py`, `tests/gateway/test_session_info.py`, `tests/hermes_cli/test_custom_provider_context_length.py`, `tests/run_agent/test_compression_feasibility.py`, `tests/run_agent/test_invalid_context_length_warning.py`
- **#52188: fix(lmstudio): respect applied runtime context**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`

## Fix status

- Merged to main in PR #8700 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-10-06`.
- Subsystem: `agent`
