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.
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -167,7 +167,7 @@ When `fallback_chain` is absent, `auto` uses the top-level `fallback_providers`
|
||||
|
||||
## Per-provider request options
|
||||
|
||||
Provider entries (`providers.<name>` 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.<name>` 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:
|
||||
|
||||
Reference in New Issue
Block a user