fix(agent): honor prompt caching capabilities for aliases

This commit is contained in:
fangliquanflq
2026-08-14 01:55:51 +08:00
committed by kshitij
parent e505ff9777
commit 316f31c9d5
5 changed files with 190 additions and 1 deletions

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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.

View File

@@ -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).
:::