From 80044bf385d5e3344bed1e13d830a689d333b834 Mon Sep 17 00:00:00 2001 From: Stephen Chin <1290231+steveonjava@users.noreply.github.com> Date: Sat, 29 Aug 2026 05:54:41 -0700 Subject: [PATCH] fix(gateway): propagate trusted proxy capabilities Forward normalized custom-provider capabilities on the default gateway path so native compaction does not depend on session rehydration. Document the content trust boundary and cover both lookup and gateway resolution. --- gateway/run.py | 12 ++++++ hermes_cli/runtime_provider.py | 37 ++++++++++--------- tests/gateway/test_agent_cache.py | 26 +++++++++++++ .../test_runtime_provider_resolution.py | 33 +++++++++++++++++ website/docs/user-guide/configuring-models.md | 12 +++++- 5 files changed, 102 insertions(+), 18 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 6fc419de60..bf61487592 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2960,6 +2960,17 @@ def _resolve_runtime_agent_kwargs() -> dict: if isinstance(_runtime_mot, int) and _runtime_mot > 0: max_tokens = _runtime_mot + capabilities = runtime.get("capabilities") + capabilities = ( + { + key: value + for key, value in capabilities.items() + if isinstance(key, str) and isinstance(value, bool) + } + if isinstance(capabilities, dict) + else {} + ) + return { "api_key": runtime.get("api_key"), "base_url": runtime.get("base_url"), @@ -2976,6 +2987,7 @@ def _resolve_runtime_agent_kwargs() -> dict: # Must flow through to the per-turn route or the provider's configured # request body never reaches the model on the gateway path. "request_overrides": runtime.get("request_overrides"), + "capabilities": capabilities, } diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 97241255f3..03bb777e90 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -707,25 +707,26 @@ def _try_resolve_from_custom_pool( return None +def _filter_capabilities(value: Any) -> Dict[str, bool]: + """Return the string-keyed boolean capabilities accepted at runtime.""" + if not isinstance(value, dict): + return {} + return { + key: enabled + for key, enabled in value.items() + if isinstance(key, str) and isinstance(enabled, bool) + } + + def _lift_model_capabilities( entry: Dict[str, Any], model: Optional[str], result: Dict[str, Any] ) -> None: """Copy explicit boolean per-model capabilities into the runtime.""" - capabilities = { - key: value - for key, value in (entry.get("capabilities") or {}).items() - if isinstance(key, str) and isinstance(value, bool) - } + capabilities = _filter_capabilities(entry.get("capabilities")) models = entry.get("models") model_config = models.get(model) if isinstance(models, dict) and model else None if isinstance(model_config, dict): - capabilities.update( - { - key: value - for key, value in model_config.items() - if isinstance(key, str) and isinstance(value, bool) - } - ) + capabilities.update(_filter_capabilities(model_config)) if capabilities: result["capabilities"] = capabilities @@ -827,7 +828,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An # Found match by provider key base_url = entry.get("api") or entry.get("url") or entry.get("base_url") or "" if base_url: - result = { + result: Dict[str, Any] = { "name": entry.get("name", ep_name), "base_url": base_url.strip(), "api_key": resolved_api_key, @@ -855,8 +856,9 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An if api_mode: result["api_mode"] = api_mode _lift_max_output_tokens(entry, result) - if isinstance(entry.get("capabilities"), dict): - result["capabilities"] = dict(entry["capabilities"]) + capabilities = _filter_capabilities(entry.get("capabilities")) + if capabilities: + result["capabilities"] = capabilities return result # Fall back to custom_providers: list (legacy format) @@ -904,8 +906,9 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An if model_name: result["model"] = model_name _lift_max_output_tokens(entry, result) - if isinstance(entry.get("capabilities"), dict): - result["capabilities"] = dict(entry["capabilities"]) + capabilities = _filter_capabilities(entry.get("capabilities")) + if capabilities: + result["capabilities"] = capabilities return result return None diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 84e6e018ee..cd60911ffd 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -80,6 +80,32 @@ class TestAgentConfigSignature: ) + def test_default_gateway_runtime_forwards_filtered_capabilities(self, monkeypatch): + """Configured provider capabilities must reach a newly created gateway agent.""" + from gateway.run import _resolve_runtime_agent_kwargs + from hermes_cli import runtime_provider + + monkeypatch.setattr( + runtime_provider, + "resolve_runtime_provider", + lambda: { + "api_key": "test-key", + "base_url": "https://trusted-proxy.example/v1", + "provider": "custom", + "requested_provider": "custom:trusted-proxy", + "api_mode": "responses", + "capabilities": { + "openai_native_compaction": True, + "ignore-me": "not-a-bool", + }, + }, + ) + monkeypatch.setattr(runtime_provider, "_get_model_config", lambda: {}) + + runtime = _resolve_runtime_agent_kwargs() + + assert runtime["capabilities"] == {"openai_native_compaction": True} + # --------------------------------------------------------------- # cache_keys (compression/context config cache-busting) # --------------------------------------------------------------- diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 422e2c3d9a..d20c258345 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -659,6 +659,39 @@ def test_named_custom_provider_uses_saved_credentials(monkeypatch): assert resolved["source"] == "custom_provider:Local" +def test_named_custom_provider_filters_capabilities_at_lookup_boundary(monkeypatch): + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "local": { + "name": "Local", + "base_url": "http://1.2.3.4:1234/v1", + "capabilities": { + "openai_native_compaction": True, + "invalid-value": "yes", + 42: True, + }, + } + } + }, + ) + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider should not be called for named custom providers" + ) + ), + ) + + provider = rp._get_named_custom_provider("local") + + assert provider["capabilities"] == {"openai_native_compaction": True} + + def test_bare_custom_resolves_providers_dict_entry_named_custom(monkeypatch): """A request for bare ``provider="custom"`` must resolve a literal ``providers.custom`` entry (e.g. a cliproxy endpoint) instead of falling diff --git a/website/docs/user-guide/configuring-models.md b/website/docs/user-guide/configuring-models.md index 2ede0dba3c..3e8db68c31 100644 --- a/website/docs/user-guide/configuring-models.md +++ b/website/docs/user-guide/configuring-models.md @@ -167,7 +167,7 @@ When `fallback_chain` is absent, `auto` uses the top-level `fallback_providers` ## Per-provider request options -Provider entries (`providers.` in the `providers:` dict, or items in the legacy `custom_providers` list) accept two knobs that shape how Hermes talks to the endpoint: +Provider entries (`providers.` in the `providers:` dict, or items in the legacy `custom_providers` list) accept knobs that shape how Hermes talks to the endpoint: **`extra_headers`** — a mapping of extra HTTP headers attached to every LLM request routed to that provider's base URL. They are applied last, after URL/profile defaults and user header overrides, so they survive credential swaps and client rebuilds. Useful for Cloudflare Access service tokens, proxy auth, or custom bearer schemes: @@ -197,6 +197,16 @@ providers: With discovery off, the model picker (`hermes model`, `/model`) shows the configured list instead of a live probe. +**`openai_native_compaction`** — set this capability to `true` only for an OpenAI-compatible endpoint that you trust with conversation content. Native compaction sends its payload to that provider's configured `base_url`: + +```yaml +providers: + trusted-proxy: + api: https://llm.internal.example.com/v1 + capabilities: + openai_native_compaction: true +``` + For a gateway that resolves a bare model alias only after receiving the request, opt the alias into prompt-cache markers with the per-model `prompt_caching` capability: