fix(agent): honor prompt caching capabilities for aliases
This commit is contained in:
@@ -2231,6 +2231,28 @@ def anthropic_prompt_cache_policy(
|
||||
and (eff_provider == "anthropic" or base_url_hostname(eff_base_url) == "api.anthropic.com")
|
||||
)
|
||||
|
||||
# A custom Anthropic-compatible route may use a bare model alias that is
|
||||
# canonicalized only after Hermes sends the request. In that case model
|
||||
# spelling cannot prove cache support. Honor an exact route+model
|
||||
# capability declaration instead; explicit false is authoritative too.
|
||||
# This preserves the runtime model id (and therefore request/cache keys)
|
||||
# while avoiding unsafe alias-name guesses.
|
||||
custom_prompt_caching = None
|
||||
if is_anthropic_wire:
|
||||
try:
|
||||
from hermes_cli.config import get_custom_provider_model_capability
|
||||
|
||||
custom_prompt_caching = get_custom_provider_model_capability(
|
||||
model=eff_model,
|
||||
base_url=eff_base_url,
|
||||
capability="prompt_caching",
|
||||
custom_providers=getattr(agent, "_custom_providers", None),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
if custom_prompt_caching is not None:
|
||||
return custom_prompt_caching, custom_prompt_caching
|
||||
|
||||
# MiniMax-M3 rides MiniMax's server-side automatic prefix cache on the
|
||||
# Anthropic wire (content-keyed, no marker needed); explicit cache_control
|
||||
# is documented for M2.7/M2.5/M2.1/M2 only, so markers on M3 are dead
|
||||
|
||||
@@ -1782,6 +1782,51 @@ def get_custom_provider_context_length(
|
||||
return None
|
||||
|
||||
|
||||
def get_custom_provider_model_capability(
|
||||
model: str,
|
||||
base_url: str,
|
||||
capability: str,
|
||||
custom_providers: Optional[List[Dict[str, Any]]] = None,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[bool]:
|
||||
"""Return an explicit boolean capability for one custom-provider model.
|
||||
|
||||
Matching is scoped to the normalized route and exact runtime model id so
|
||||
aliases can declare capabilities without changing the id sent upstream.
|
||||
Missing or non-boolean declarations return ``None``.
|
||||
"""
|
||||
if not model or not base_url or not capability:
|
||||
return None
|
||||
if custom_providers is None:
|
||||
try:
|
||||
custom_providers = get_compatible_custom_providers(config)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(custom_providers, list):
|
||||
return None
|
||||
|
||||
target_url = normalize_route_base_url(base_url)
|
||||
if not target_url:
|
||||
return None
|
||||
|
||||
for entry in custom_providers:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_url = normalize_route_base_url(entry.get("base_url"))
|
||||
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
|
||||
value = model_cfg.get(capability)
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_config_version(value: Any) -> int:
|
||||
"""Return a safe integer config version, treating invalid values as legacy."""
|
||||
if isinstance(value, bool):
|
||||
|
||||
@@ -8,7 +8,10 @@ from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.config import get_custom_provider_context_length
|
||||
from hermes_cli.config import (
|
||||
get_custom_provider_context_length,
|
||||
get_custom_provider_model_capability,
|
||||
)
|
||||
|
||||
|
||||
class TestGetCustomProviderContextLength:
|
||||
@@ -49,6 +52,53 @@ class TestGetCustomProviderContextLength:
|
||||
assert get_custom_provider_context_length("m", "http://x", []) is None
|
||||
|
||||
|
||||
class TestGetCustomProviderModelCapability:
|
||||
def test_matches_exact_model_on_normalized_route(self):
|
||||
custom = [
|
||||
{
|
||||
"base_url": "https://example.invalid/anthropic/",
|
||||
"models": {"fable": {"prompt_caching": True}},
|
||||
}
|
||||
]
|
||||
|
||||
assert get_custom_provider_model_capability(
|
||||
"fable",
|
||||
"https://example.invalid/anthropic",
|
||||
"prompt_caching",
|
||||
custom,
|
||||
) is True
|
||||
assert get_custom_provider_model_capability(
|
||||
"opus",
|
||||
"https://example.invalid/anthropic",
|
||||
"prompt_caching",
|
||||
custom,
|
||||
) is None
|
||||
|
||||
def test_false_is_preserved_and_non_boolean_is_ignored(self):
|
||||
custom = [
|
||||
{
|
||||
"base_url": "https://example.invalid/anthropic",
|
||||
"models": {
|
||||
"disabled": {"prompt_caching": False},
|
||||
"invalid": {"prompt_caching": "true"},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
assert get_custom_provider_model_capability(
|
||||
"disabled",
|
||||
"https://example.invalid/anthropic",
|
||||
"prompt_caching",
|
||||
custom,
|
||||
) is False
|
||||
assert get_custom_provider_model_capability(
|
||||
"invalid",
|
||||
"https://example.invalid/anthropic",
|
||||
"prompt_caching",
|
||||
custom,
|
||||
) is None
|
||||
|
||||
|
||||
|
||||
class TestGetModelContextLengthHonorsOverride:
|
||||
"""agent.model_metadata.get_model_context_length must honor the
|
||||
|
||||
@@ -161,6 +161,58 @@ class TestThirdPartyAnthropicGateway:
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
|
||||
def test_bare_alias_with_explicit_prompt_caching_capability_caches(self):
|
||||
agent = _make_agent(
|
||||
provider="custom:anthropic-proxy",
|
||||
base_url="https://gateway.example.com/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="fable",
|
||||
)
|
||||
agent._custom_providers = [
|
||||
{
|
||||
"name": "anthropic-proxy",
|
||||
"base_url": "https://gateway.example.com/anthropic",
|
||||
"models": {"fable": {"prompt_caching": True}},
|
||||
}
|
||||
]
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, True)
|
||||
|
||||
def test_explicit_prompt_caching_false_is_authoritative(self):
|
||||
agent = _make_agent(
|
||||
provider="custom:anthropic-proxy",
|
||||
base_url="https://gateway.example.com/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="claude-fable-5",
|
||||
)
|
||||
agent._custom_providers = [
|
||||
{
|
||||
"name": "anthropic-proxy",
|
||||
"base_url": "https://gateway.example.com/anthropic",
|
||||
"models": {"claude-fable-5": {"prompt_caching": False}},
|
||||
}
|
||||
]
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_bare_alias_without_capability_stays_conservative(self):
|
||||
agent = _make_agent(
|
||||
provider="custom:anthropic-proxy",
|
||||
base_url="https://gateway.example.com/anthropic",
|
||||
api_mode="anthropic_messages",
|
||||
model="fable",
|
||||
)
|
||||
agent._custom_providers = [
|
||||
{
|
||||
"name": "anthropic-proxy",
|
||||
"base_url": "https://gateway.example.com/anthropic",
|
||||
"models": {"fable": {"context_length": 1_000_000}},
|
||||
}
|
||||
]
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
|
||||
class TestMiniMaxAnthropicWire:
|
||||
"""MiniMax's own model family on its Anthropic-compatible endpoint.
|
||||
|
||||
|
||||
@@ -185,6 +185,26 @@ providers:
|
||||
|
||||
With discovery off, the model picker (`hermes model`, `/model`) shows the configured list instead of a live probe.
|
||||
|
||||
For an Anthropic-compatible gateway that resolves a bare model alias only
|
||||
after receiving the request, opt the alias into native prompt-cache markers
|
||||
with the per-model `prompt_caching` capability:
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
anthropic-proxy:
|
||||
api: https://gateway.example.com/anthropic
|
||||
transport: anthropic_messages
|
||||
models:
|
||||
fable:
|
||||
context_length: 1000000
|
||||
prompt_caching: true
|
||||
```
|
||||
|
||||
Hermes matches this declaration to the exact provider route and runtime model
|
||||
id, without rewriting the alias. Set `prompt_caching: false` to explicitly
|
||||
disable cache markers for a model; when omitted, Hermes keeps its normal
|
||||
provider and model capability detection.
|
||||
|
||||
:::note Legacy format
|
||||
Older configs used a top-level `custom_providers:` list (with `base_url` instead of `api`). It still works and is auto-migrated to the `providers:` dict on `hermes update` (config v12).
|
||||
:::
|
||||
|
||||
Reference in New Issue
Block a user