From c6d47b021438af031ddb48778ff7ca4768993bc9 Mon Sep 17 00:00:00 2001 From: Turgut Kural <58116817+TurgutKural@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:21:44 +0300 Subject: [PATCH 1/2] fix(context): thread custom_providers to all context-length resolution call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit custom_providers[].models..context_length overrides were only honored at agent startup (agent_init) and /model switch (model_switch). Several sibling call paths called get_model_context_length() without passing custom_providers, causing them to fall through to endpoint probing and the 256K/131K hardcoded defaults — even when the user had an explicit per-model override configured. Affected call sites: - ContextCompressor._resolve_context_length (deferred first-access probe) - auxiliary_client._candidate_context_window (fallback chain screening) - moa_loop._trim_messages_for_reference (MoA reference model trimming) - web_server.get_model_info (WebUI model info endpoint) Upstream added the custom_providers parameter to get_model_context_length (f981d47cb0, #15844); this PR threads it through the remaining call sites, which upstream has not covered yet (verified against current upstream/main: ContextCompressor construction, _candidate_context_window, moa_loop, and web_server.get_model_info all still omit it). Tests: 20 tests covering all four call sites, precedence rules, graceful degradation, and extended helper coverage. --- agent/agent_init.py | 1 + agent/auxiliary_client.py | 10 + agent/context_compressor.py | 7 + agent/moa_loop.py | 16 + hermes_cli/web_server.py | 3 + .../test_custom_provider_context_threading.py | 437 ++++++++++++++++++ 6 files changed, 474 insertions(+) create mode 100644 tests/agent/test_custom_provider_context_threading.py diff --git a/agent/agent_init.py b/agent/agent_init.py index 9b77d5227f515..c57d660f76d29 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -2756,6 +2756,7 @@ def _parse_prune_int(raw, default): proactive_prune_min_reclaim_tokens=compression_proactive_prune_min_reclaim, min_tail_user_messages=compression_min_tail_users, tail_mode=compression_tail_mode, + custom_providers=_custom_providers, ) _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) if callable(_bind_session_state): diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index b5aeae274f09b..f6735257418cd 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -5592,12 +5592,22 @@ def _candidate_context_window( """ if not model: return None + # Load custom_providers from config so per-model context_length + # overrides (custom_providers[].models..context_length) are + # honored for fallback candidates too — not just the main model. + _custom_providers: list | None = None + try: + from hermes_cli.config import get_compatible_custom_providers, load_config_readonly + _custom_providers = get_compatible_custom_providers(load_config_readonly()) + except Exception: + pass try: ctx = get_model_context_length( model, base_url=base_url, api_key=api_key, provider=provider, + custom_providers=_custom_providers, ) except Exception as exc: logger.debug( diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 2a0b5dc4a4497..52647f44f72eb 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -2171,6 +2171,7 @@ def _resolve_context_length(self) -> int: api_key=self.api_key, config_context_length=self._config_context_length, provider=self.provider, + custom_providers=self._custom_providers, ) # Small-context threshold floor: models under 512K trigger at # >=75% so compaction doesn't fire with half the window still @@ -2996,6 +2997,7 @@ def __init__( proactive_prune_min_reclaim_tokens: int = 4096, min_tail_user_messages: int = 1, tail_mode: str = "legacy", + custom_providers: list | None = None, ): self.model = model self.base_url = base_url @@ -3006,6 +3008,11 @@ def __init__( # tail + verbatim-user-message summary section + recovery pointers; # "legacy" = 0.20*window tail (shipping behavior). self.tail_mode = tail_mode if tail_mode in ("legacy", "lean") else "legacy" + # Per-model context_length overrides from custom_providers config. + # Threaded to get_model_context_length() in _resolve_context_length() + # so deferred resolution (first context_length property access) honors + # the same per-model overrides that startup resolution does (#15779). + self._custom_providers = custom_providers # Per-model threshold overrides (longest substring match wins). # Stored as a plain dict; resolved in _resolve_threshold(), then the # small-context floor is applied on top. diff --git a/agent/moa_loop.py b/agent/moa_loop.py index d783afd99b625..a8fd1622ff0f8 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -658,6 +658,21 @@ def _run_reference( _REFERENCE_TRIM_SAFETY_FRACTION = 0.10 +def _load_custom_providers() -> list | None: + """Best-effort load of custom_providers from config. + + Used by _trim_messages_for_reference to honor per-model context_length + overrides (custom_providers[].models..context_length) when resolving + reference model context windows. Returns None on any failure so the + resolver falls through to probing — never breaks a MoA turn. + """ + try: + from hermes_cli.config import get_compatible_custom_providers, load_config_readonly + return get_compatible_custom_providers(load_config_readonly()) + except Exception: + return None + + def _trim_messages_for_reference( messages: list[dict[str, Any]], slot: dict[str, str], @@ -723,6 +738,7 @@ def _trim_messages_for_reference( base_url=str(runtime.get("base_url") or ""), api_key=str(runtime.get("api_key") or ""), provider=provider, + custom_providers=_load_custom_providers(), ) except Exception: logger.debug( diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index a7dd5fe4b0ca7..6d55164f91781 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -6922,11 +6922,14 @@ def get_model_info(profile: Optional[str] = None): # purely auto-detected value, then separately report the override) try: from agent.model_metadata import get_model_context_length + from hermes_cli.config import get_compatible_custom_providers + _cp = get_compatible_custom_providers(cfg) auto_ctx = get_model_context_length( model=model_name, base_url=base_url, provider=provider, config_context_length=None, # ignore override — we want auto value + custom_providers=_cp, ) except Exception: auto_ctx = 0 diff --git a/tests/agent/test_custom_provider_context_threading.py b/tests/agent/test_custom_provider_context_threading.py new file mode 100644 index 0000000000000..a27ecac1912fe --- /dev/null +++ b/tests/agent/test_custom_provider_context_threading.py @@ -0,0 +1,437 @@ +"""Tests for custom_providers context_length threading across all call sites. + +Regression tests ensuring that ``custom_providers[].models..context_length`` +overrides are honored not just at agent startup (agent_init) and /model switch +(model_switch), but also at every deferred resolution point: + + * ContextCompressor._resolve_context_length (deferred first-access probe) + * auxiliary_client._candidate_context_window (fallback chain screening) + * moa_loop._trim_messages_for_reference (MoA reference model trimming) + * web_server.get_model_info (WebUI model info endpoint) + +See #15779 for the original /model switch fix; this extends the same contract +to sibling call paths that were missed. +""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from hermes_cli.config import get_custom_provider_context_length + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + +CUSTOM_PROVIDERS = [ + { + "name": "test-provider", + "base_url": "https://custom.example.com/v1", + "models": { + "my-model": {"context_length": 1_000_000}, + }, + }, +] + +BASE_URL = "https://custom.example.com/v1" +MODEL = "my-model" +EXPECTED_CTX = 1_000_000 + + +def _mock_all_probes(): + """Disable every downstream resolution step so only the + custom_providers override (step 0b) can produce a result.""" + 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), + ] + + +# --------------------------------------------------------------------------- +# 1. ContextCompressor._resolve_context_length +# --------------------------------------------------------------------------- + +class TestContextCompressorCustomProviders: + """ContextCompressor must honor custom_providers per-model overrides + when it lazily resolves context_length on first property access.""" + + def test_resolve_context_length_uses_custom_providers(self): + from agent.context_compressor import ContextCompressor + + compressor = ContextCompressor( + model=MODEL, + base_url=BASE_URL, + provider="custom", + custom_providers=CUSTOM_PROVIDERS, + ) + patches = _mock_all_probes() + for p in patches: + p.start() + try: + ctx = compressor._resolve_context_length() + finally: + for p in patches: + p.stop() + + assert ctx == EXPECTED_CTX, ( + f"Expected {EXPECTED_CTX} from custom_providers override, got {ctx}" + ) + + def test_resolve_context_length_without_custom_providers_falls_through(self): + """Without custom_providers, resolver falls through to default.""" + from agent.context_compressor import ContextCompressor + from agent.model_metadata import DEFAULT_FALLBACK_CONTEXT + + compressor = ContextCompressor( + model="unknown-model", + base_url=BASE_URL, + provider="custom", + custom_providers=None, + ) + patches = _mock_all_probes() + for p in patches: + p.start() + try: + ctx = compressor._resolve_context_length() + finally: + for p in patches: + p.stop() + + assert ctx == DEFAULT_FALLBACK_CONTEXT + + def test_config_context_length_still_wins_over_custom_providers(self): + """Explicit config_context_length (step 0) outranks custom_providers (step 0b).""" + from agent.context_compressor import ContextCompressor + + compressor = ContextCompressor( + model=MODEL, + base_url=BASE_URL, + provider="custom", + config_context_length=500_000, + custom_providers=CUSTOM_PROVIDERS, + ) + ctx = compressor._resolve_context_length() + assert ctx == 500_000 + + def test_custom_providers_stored_on_instance(self): + """The custom_providers list is stored for deferred resolution.""" + from agent.context_compressor import ContextCompressor + + compressor = ContextCompressor( + model=MODEL, + base_url=BASE_URL, + provider="custom", + custom_providers=CUSTOM_PROVIDERS, + ) + assert compressor._custom_providers is CUSTOM_PROVIDERS + + def test_default_custom_providers_is_none(self): + """Omitting custom_providers defaults to None (backward compat).""" + from agent.context_compressor import ContextCompressor + + compressor = ContextCompressor( + model=MODEL, + base_url=BASE_URL, + provider="custom", + ) + assert compressor._custom_providers is None + + +# --------------------------------------------------------------------------- +# 2. auxiliary_client._candidate_context_window +# --------------------------------------------------------------------------- + +class TestCandidateContextWindowCustomProviders: + """_candidate_context_window must load custom_providers from config + and pass them to get_model_context_length.""" + + def test_honors_custom_providers_override(self): + from agent.auxiliary_client import _candidate_context_window + + mock_config = {"custom_providers": CUSTOM_PROVIDERS} + with ( + patch( + "hermes_cli.config.load_config_readonly", + return_value=mock_config, + ), + patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=CUSTOM_PROVIDERS, + ), + ): + patches = _mock_all_probes() + for p in patches: + p.start() + try: + ctx = _candidate_context_window( + "custom", MODEL, base_url=BASE_URL, + ) + finally: + for p in patches: + p.stop() + + assert ctx == EXPECTED_CTX + + def test_config_load_failure_falls_through_gracefully(self): + """If config loading fails, resolver still works (returns default).""" + from agent.auxiliary_client import _candidate_context_window + from agent.model_metadata import DEFAULT_FALLBACK_CONTEXT + + with patch( + "hermes_cli.config.load_config_readonly", + side_effect=RuntimeError("config unavailable"), + ): + patches = _mock_all_probes() + for p in patches: + p.start() + try: + ctx = _candidate_context_window( + "custom", "unknown-model", base_url=BASE_URL, + ) + finally: + for p in patches: + p.stop() + + assert ctx == DEFAULT_FALLBACK_CONTEXT + + def test_empty_model_returns_none(self): + from agent.auxiliary_client import _candidate_context_window + + assert _candidate_context_window("custom", "", base_url=BASE_URL) is None + + +# --------------------------------------------------------------------------- +# 3. moa_loop._load_custom_providers + _trim_messages_for_reference +# --------------------------------------------------------------------------- + +class TestMoACustomProviders: + """MoA reference trimming must honor custom_providers overrides.""" + + def test_load_custom_providers_returns_list(self): + from agent.moa_loop import _load_custom_providers + + mock_config = {"custom_providers": CUSTOM_PROVIDERS} + with ( + patch( + "hermes_cli.config.load_config_readonly", + return_value=mock_config, + ), + patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=CUSTOM_PROVIDERS, + ), + ): + result = _load_custom_providers() + + assert result == CUSTOM_PROVIDERS + + def test_load_custom_providers_returns_none_on_failure(self): + from agent.moa_loop import _load_custom_providers + + with patch( + "hermes_cli.config.load_config_readonly", + side_effect=RuntimeError("no config"), + ): + result = _load_custom_providers() + + assert result is None + + def test_trim_messages_uses_custom_providers_context(self): + """_trim_messages_for_reference resolves context via custom_providers.""" + from agent.moa_loop import _trim_messages_for_reference + + slot = {"model": MODEL, "provider": "custom"} + runtime = {"base_url": BASE_URL, "api_key": "test-key", "provider": "custom"} + + # Build messages that would fit in 1M but not in 256K + # ~300K tokens worth of text (chars/4 heuristic) + big_content = "x" * 1_200_000 # ~300K tokens + messages = [ + {"role": "system", "content": "You are a helper."}, + {"role": "user", "content": big_content}, + {"role": "assistant", "content": "OK"}, + ] + + mock_config = {"custom_providers": CUSTOM_PROVIDERS} + with ( + patch( + "hermes_cli.config.load_config_readonly", + return_value=mock_config, + ), + patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=CUSTOM_PROVIDERS, + ), + ): + patches = _mock_all_probes() + for p in patches: + p.start() + try: + result = _trim_messages_for_reference( + messages, slot, runtime, + ) + finally: + for p in patches: + p.stop() + + # With 1M context, messages should NOT be trimmed (they fit) + assert len(result) == len(messages), ( + f"Messages should not be trimmed with 1M context, " + f"got {len(result)} of {len(messages)}" + ) + + +# --------------------------------------------------------------------------- +# 4. web_server.get_model_info +# --------------------------------------------------------------------------- + +class TestWebServerModelInfoCustomProviders: + """WebUI /api/model/info must pass custom_providers to the resolver.""" + + def test_get_model_info_passes_custom_providers(self): + """Verify get_model_context_length receives custom_providers kwarg.""" + from hermes_cli.web_server import get_model_info + + mock_config = { + "model": { + "default": MODEL, + "provider": "custom", + "base_url": BASE_URL, + }, + "custom_providers": CUSTOM_PROVIDERS, + } + + captured_kwargs = {} + + def _capture_get_model_context_length(**kwargs): + captured_kwargs.update(kwargs) + return 256_000 # default + + with ( + patch("hermes_cli.web_server.load_config", return_value=mock_config), + patch( + "agent.model_metadata.get_model_context_length", + side_effect=_capture_get_model_context_length, + ), + patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=CUSTOM_PROVIDERS, + ), + ): + try: + get_model_info() + except Exception: + pass # endpoint may need app context; we only care about kwargs + + assert "custom_providers" in captured_kwargs, ( + "get_model_context_length was not called with custom_providers" + ) + assert captured_kwargs["custom_providers"] == CUSTOM_PROVIDERS + + +# --------------------------------------------------------------------------- +# 5. get_custom_provider_context_length (existing helper — extended coverage) +# --------------------------------------------------------------------------- + +class TestGetCustomProviderContextLengthExtended: + """Extended coverage for the lookup helper used by all call sites.""" + + def test_model_not_in_entry_returns_none(self): + assert ( + get_custom_provider_context_length( + "other-model", BASE_URL, CUSTOM_PROVIDERS, + ) + is None + ) + + def test_base_url_mismatch_returns_none(self): + assert ( + get_custom_provider_context_length( + MODEL, "https://wrong.example.com/v1", CUSTOM_PROVIDERS, + ) + is None + ) + + def test_models_as_list_returns_none(self): + """List-format models (no per-model config) must return None.""" + providers = [ + { + "base_url": BASE_URL, + "models": [MODEL, "other-model"], + } + ] + assert ( + get_custom_provider_context_length(MODEL, BASE_URL, providers) + is None + ) + + def test_zero_context_length_returns_none(self): + providers = [ + { + "base_url": BASE_URL, + "models": {MODEL: {"context_length": 0}}, + } + ] + assert ( + get_custom_provider_context_length(MODEL, BASE_URL, providers) + is None + ) + + def test_negative_context_length_returns_none(self): + providers = [ + { + "base_url": BASE_URL, + "models": {MODEL: {"context_length": -100}}, + } + ] + assert ( + get_custom_provider_context_length(MODEL, BASE_URL, providers) + is None + ) + + def test_string_context_length_coerced(self): + """String integers are coerced (config YAML may parse as str).""" + providers = [ + { + "base_url": BASE_URL, + "models": {MODEL: {"context_length": "1000000"}}, + } + ] + assert ( + get_custom_provider_context_length(MODEL, BASE_URL, providers) + == 1_000_000 + ) + + def test_multiple_entries_first_match_wins(self): + providers = [ + { + "base_url": BASE_URL, + "models": {MODEL: {"context_length": 500_000}}, + }, + { + "base_url": BASE_URL, + "models": {MODEL: {"context_length": 1_000_000}}, + }, + ] + assert ( + get_custom_provider_context_length(MODEL, BASE_URL, providers) + == 500_000 + ) + + def test_model_cfg_not_dict_returns_none(self): + """models. must be a dict; a bare string is invalid.""" + providers = [ + { + "base_url": BASE_URL, + "models": {MODEL: "1000000"}, + } + ] + assert ( + get_custom_provider_context_length(MODEL, BASE_URL, providers) + is None + ) From d6528b73f6a71654288cda651e9ca6f95b278bc3 Mon Sep 17 00:00:00 2001 From: Turgut Kural <58116817+TurgutKural@users.noreply.github.com> Date: Thu, 13 Aug 2026 07:14:55 +0300 Subject: [PATCH 2/2] fix(context): honor custom_providers in tool-search gate + /context fallback --- gateway/slash_commands.py | 29 ++- model_tools.py | 17 ++ .../test_custom_provider_context_threading.py | 224 +++++++++++++++++- .../test_tool_search_context_provider.py | 8 +- 4 files changed, 271 insertions(+), 7 deletions(-) diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index cd54164bd0d9b..d41261d830bee 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -864,8 +864,35 @@ def _resolve_nonresident_context(): try: from agent.model_metadata import get_model_context_length + # Inactive-agent fallback: resolve the configured route + # identity + compatible custom_providers so per-model + # context_length overrides (custom_providers[].models.) + # are honored — passing only model_name falls through to + # generic metadata for custom endpoints (#15779). + _provider = "" + _custom_providers = None + try: + from hermes_cli.config import ( + get_compatible_custom_providers, + load_config_readonly, + ) + + _cfg = load_config_readonly() or {} + _model_cfg = _cfg.get("model") + if isinstance(_model_cfg, dict): + _provider = str(_model_cfg.get("provider") or "").strip() + _custom_providers = get_compatible_custom_providers(_cfg) + except Exception: + _provider = "" + _custom_providers = None + context_length = _int_value( - await asyncio.to_thread(get_model_context_length, model_name) + await asyncio.to_thread( + get_model_context_length, + model_name, + provider=_provider, + custom_providers=_custom_providers, + ) ) except Exception: context_length = 0 diff --git a/model_tools.py b/model_tools.py index 0a5216bbb8ece..129257e7f89b9 100644 --- a/model_tools.py +++ b/model_tools.py @@ -720,12 +720,29 @@ def _resolve_active_context_length() -> int: return cached_ctx except Exception: pass + # Per-model context_length overrides from custom_providers config + # (custom_providers[].models..context_length) must be honored by + # the tool-search gate too — otherwise the gate sizes against generic + # metadata for custom endpoints (#15779). Best-effort load: config + # failure degrades to None so resolution falls through to probing. + custom_providers = None + try: + from hermes_cli.config import ( + get_compatible_custom_providers, + load_config_readonly, + ) + custom_providers = get_compatible_custom_providers( + load_config_readonly() + ) + except Exception: + custom_providers = None return int(get_model_context_length( model_id, base_url=base_url, api_key=api_key, config_context_length=config_ctx, provider=provider, + custom_providers=custom_providers, ) or 0) except Exception as e: logger.debug("Could not resolve active context length: %s", e) diff --git a/tests/agent/test_custom_provider_context_threading.py b/tests/agent/test_custom_provider_context_threading.py index a27ecac1912fe..8d02ef735825b 100644 --- a/tests/agent/test_custom_provider_context_threading.py +++ b/tests/agent/test_custom_provider_context_threading.py @@ -14,7 +14,7 @@ """ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -335,9 +335,229 @@ def _capture_get_model_context_length(**kwargs): # --------------------------------------------------------------------------- -# 5. get_custom_provider_context_length (existing helper — extended coverage) +# 5. model_tools._resolve_active_context_length (tool-search gate) # --------------------------------------------------------------------------- +class TestToolSearchGateCustomProviders: + """The tool-search context gate must honor custom_providers per-model + context_length overrides instead of sizing against generic metadata.""" + + def test_gate_passes_custom_providers_to_resolver(self): + from model_tools import _resolve_active_context_length + + mock_config = { + "model": { + "model": MODEL, + "provider": "custom", + "base_url": BASE_URL, + }, + "custom_providers": CUSTOM_PROVIDERS, + } + captured_kwargs = {} + + def _capture(model, **kwargs): + captured_kwargs.update(kwargs) + return EXPECTED_CTX + + with ( + patch("hermes_cli.config.load_config", return_value=mock_config), + patch( + "hermes_cli.config.load_config_readonly", + return_value=mock_config, + ), + patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=CUSTOM_PROVIDERS, + ), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={}, + ), + patch( + "agent.model_metadata.get_cached_context_length", + return_value=None, + ), + patch( + "agent.model_metadata.get_model_context_length", + side_effect=_capture, + ), + ): + result = _resolve_active_context_length() + + assert result == EXPECTED_CTX + assert captured_kwargs.get("custom_providers") == CUSTOM_PROVIDERS, ( + "get_model_context_length was not called with custom_providers" + ) + assert captured_kwargs.get("provider") == "custom" + + def test_gate_falls_through_on_config_load_failure(self): + """Config-load failure must not break the gate — resolver still runs + with custom_providers=None (generic metadata).""" + from model_tools import _resolve_active_context_length + + mock_config = { + "model": { + "model": MODEL, + "provider": "custom", + "base_url": BASE_URL, + }, + } + with ( + patch("hermes_cli.config.load_config", return_value=mock_config), + patch( + "hermes_cli.config.load_config_readonly", + side_effect=RuntimeError("config unavailable"), + ), + patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value={}, + ), + patch( + "agent.model_metadata.get_cached_context_length", + return_value=None, + ), + patch( + "agent.model_metadata.get_model_context_length", + return_value=EXPECTED_CTX, + ), + ): + result = _resolve_active_context_length() + + assert result == EXPECTED_CTX + + +# --------------------------------------------------------------------------- +# 6. gateway /context inactive-agent fallback +# --------------------------------------------------------------------------- + +class TestGatewayContextFallbackCustomProviders: + """The /context no-resident-agent fallback must resolve the configured + route identity + compatible custom_providers before calling the + resolver — passing only model_name falls through to generic metadata.""" + + def _make_mixin(self, session_db_row): + from gateway.slash_commands import GatewaySlashCommandsMixin + + mixin = GatewaySlashCommandsMixin.__new__(GatewaySlashCommandsMixin) + + class _SessionEntry: + session_id = "sess-test" + last_prompt_tokens = 0 + + session_store = MagicMock() + session_store.get_or_create_session = AsyncMock( + return_value=_SessionEntry() + ) + session_store.load_transcript = AsyncMock(return_value=[]) + mixin.async_session_store = session_store + mixin._running_agents = {} + mixin._agent_cache_lock = None + mixin._agent_cache = None + + session_db = MagicMock() + if session_db_row is not None: + session_db.get_session = AsyncMock(return_value=session_db_row) + mixin._session_db = session_db + + return mixin + + def _make_event(self): + event = MagicMock() + event.source = "test-source" + event.get_command_args.return_value = "" + return event + + def test_fallback_passes_provider_and_custom_providers(self): + mixin = self._make_mixin({"model": MODEL}) + mixin._session_key_for_source = lambda source: "sess-test" + captured = {} + + def _capture(model, **kwargs): + captured["model"] = model + captured.update(kwargs) + return EXPECTED_CTX + + mock_config = { + "model": { + "provider": "custom", + "base_url": BASE_URL, + }, + "custom_providers": CUSTOM_PROVIDERS, + } + with ( + patch( + "hermes_cli.config.load_config_readonly", + return_value=mock_config, + ), + patch( + "hermes_cli.config.get_compatible_custom_providers", + return_value=CUSTOM_PROVIDERS, + ), + # Force the shared non-resident resolver (upstream's + # _resolve_gateway_model_context) to fail so the PR's + # last-resort fallback block — the code under test — runs. + patch( + "gateway.run._resolve_gateway_model_context", + side_effect=RuntimeError("boom"), + ), + patch( + "agent.model_metadata.get_model_context_length", + side_effect=_capture, + ), + ): + result = asyncio_run(mixin._handle_context_command(self._make_event())) + + assert isinstance(result, str) + assert captured.get("model") == MODEL + assert captured.get("provider") == "custom" + assert captured.get("custom_providers") == CUSTOM_PROVIDERS + + def test_fallback_fails_open_when_config_loading_raises(self): + mixin = self._make_mixin({"model": MODEL}) + mixin._session_key_for_source = lambda source: "sess-test" + captured = {} + + def _capture(model, **kwargs): + captured["model"] = model + captured.update(kwargs) + return EXPECTED_CTX + + with ( + patch( + "hermes_cli.config.load_config_readonly", + side_effect=RuntimeError("config unavailable"), + ), + patch( + "hermes_cli.config.get_compatible_custom_providers", + side_effect=RuntimeError("config unavailable"), + ), + # Force the shared non-resident resolver to fail too, so the + # fallback block under test runs with a broken config path. + patch( + "gateway.run._resolve_gateway_model_context", + side_effect=RuntimeError("boom"), + ), + patch( + "agent.model_metadata.get_model_context_length", + side_effect=_capture, + ), + ): + result = asyncio_run(mixin._handle_context_command(self._make_event())) + + # Config failure degrades to provider="" / custom_providers=None — + # the resolver still runs and /context still renders. + assert isinstance(result, str) + assert captured.get("model") == MODEL + assert captured.get("provider") == "" + assert captured.get("custom_providers") is None + + +def asyncio_run(coro): + import asyncio + + return asyncio.new_event_loop().run_until_complete(coro) + + class TestGetCustomProviderContextLengthExtended: """Extended coverage for the lookup helper used by all call sites.""" diff --git a/tests/tools/test_tool_search_context_provider.py b/tests/tools/test_tool_search_context_provider.py index 19b516ac02adf..29785ae2d9993 100644 --- a/tests/tools/test_tool_search_context_provider.py +++ b/tests/tools/test_tool_search_context_provider.py @@ -30,7 +30,7 @@ def test_passes_provider_base_url_and_key_from_runtime(self): captured = {} - def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider=""): + def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider="", custom_providers=None): captured.update( model=model_id, base_url=base_url, api_key=api_key, config_ctx=config_context_length, provider=provider, @@ -60,7 +60,7 @@ def test_offline_credential_failure_degrades_to_config_values(self): captured = {} - def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider=""): + def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider="", custom_providers=None): captured.update(base_url=base_url, api_key=api_key, provider=provider) return 272_000 @@ -83,7 +83,7 @@ def test_no_provider_configured_skips_runtime_resolution(self): captured = {} - def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider=""): + def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider="", custom_providers=None): captured.update(base_url=base_url, provider=provider) return 200_000 @@ -103,7 +103,7 @@ def test_config_context_length_still_short_circuits(self): captured = {} - def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider=""): + def fake_get_ctx(model_id, base_url="", api_key="", config_context_length=None, provider="", custom_providers=None): captured["config_ctx"] = config_context_length return config_context_length or 0