fix: align cache-policy pre-gate identity with the capability matcher
Follow-ups on top of the salvaged #92785 commit: - Pre-gate now matches base URLs via normalize_route_base_url and provider ids via custom_provider_aliases, mirroring the semantics of get_custom_provider_model_capability. The raw string comparison silently dropped declarations whose config spelling differed only by host case or trailing slash (proven empirically: …/v1/ vs …/v1 with a non-matching provider name returned (False, False) despite an explicit prompt_caching: true). - get_provider(..., allow_network=False) in the early-init/stub branch: the policy runs per request destination (MoA aggregator, auxiliary replans via blank_cache_policy_stub, early agent init) and a cold models.dev cache triggered a measured ~450 ms foreground registry fetch from the send path. A catalog miss degrades to the conservative side. - Debug-log the previously silent provider-lookup exception fallback. - Tests: _make_agent defaults _custom_providers=[] (post-init reality; keeps built-in-route tests off the catalog/config fallback), the two early-init tests delete the attr explicitly, and three regression tests pin the URL-drift, spaced-legacy-name, and no-network contracts (all three fail on the unfixed commit).
This commit is contained in:
@@ -2322,46 +2322,68 @@ def anthropic_prompt_cache_policy(
|
||||
)
|
||||
_custom_providers = getattr(agent, "_custom_providers", None)
|
||||
_route_may_be_custom = False
|
||||
if _custom_providers:
|
||||
if not _supports_anthropic_cache_markers:
|
||||
# Responses/Bedrock never consume the declaration — skip the
|
||||
# identity probe entirely for those transports.
|
||||
pass
|
||||
elif _custom_providers:
|
||||
# The normalized list is already attached after agent initialization.
|
||||
# Use cheap runtime identity signals before calling the capability
|
||||
# helper so an unrelated configured provider does not put every
|
||||
# built-in chat-completions request on the route-normalization path.
|
||||
#
|
||||
# Identity must match the authoritative helper's semantics:
|
||||
# get_custom_provider_model_capability compares base URLs via
|
||||
# normalize_route_base_url, and runtime provider ids go through
|
||||
# custom_provider_aliases (space→hyphen, custom: prefix variants).
|
||||
# A raw-string gate here would silently drop declarations whose
|
||||
# config spelling differs only in host case / trailing slash.
|
||||
from hermes_cli.providers import custom_provider_aliases
|
||||
from hermes_cli.route_identity import normalize_route_base_url
|
||||
|
||||
_provider_ids = {provider_lower}
|
||||
if provider_lower.startswith("custom:"):
|
||||
_provider_ids.add(provider_lower.removeprefix("custom:"))
|
||||
from hermes_cli.route_identity import normalize_route_base_url
|
||||
|
||||
_runtime_route_url = normalize_route_base_url(eff_base_url)
|
||||
_eff_url_normalized = normalize_route_base_url(eff_base_url)
|
||||
for _entry in _custom_providers:
|
||||
if not isinstance(_entry, dict):
|
||||
continue
|
||||
_entry_ids = {
|
||||
str(_entry.get("name") or "").strip().lower(),
|
||||
str(_entry.get("provider_key") or "").strip().lower(),
|
||||
}
|
||||
if _provider_ids & (_entry_ids - {""}) or (
|
||||
_runtime_route_url
|
||||
_entry_ids = custom_provider_aliases(
|
||||
str(_entry.get("name") or ""),
|
||||
str(_entry.get("provider_key") or ""),
|
||||
)
|
||||
if _provider_ids & _entry_ids or (
|
||||
_eff_url_normalized
|
||||
and normalize_route_base_url(_entry.get("base_url"))
|
||||
== _runtime_route_url
|
||||
== _eff_url_normalized
|
||||
):
|
||||
_route_may_be_custom = True
|
||||
break
|
||||
elif _custom_providers is None:
|
||||
# During early agent initialization the normalized custom-provider list
|
||||
# is not attached yet. Avoid rebuilding it for ordinary built-in routes,
|
||||
# None = the list is not attached yet (early agent initialization or
|
||||
# a blank_cache_policy_stub destination); an attached empty list means
|
||||
# the agent initialized with no custom providers and correctly never
|
||||
# matches. Avoid rebuilding the list for ordinary built-in routes,
|
||||
# while still recognizing arbitrary config keys and built-in-name
|
||||
# overrides that point at a different endpoint.
|
||||
try:
|
||||
from hermes_cli.providers import get_provider
|
||||
|
||||
_provider_def = get_provider(eff_provider)
|
||||
# allow_network=False: this runs per request destination; a cold
|
||||
# models.dev cache must not trigger a foreground registry fetch
|
||||
# from the send path. A catalog miss (None) degrades to the
|
||||
# conservative side (route may be custom → capability lookup).
|
||||
_provider_def = get_provider(eff_provider, allow_network=False)
|
||||
_route_may_be_custom = _provider_def is None or (
|
||||
bool(_provider_def.base_url)
|
||||
and base_url_hostname(_provider_def.base_url)
|
||||
!= base_url_hostname(eff_base_url)
|
||||
)
|
||||
except Exception:
|
||||
except Exception as _pd_exc:
|
||||
logger.debug(
|
||||
"provider lookup failed during cache-policy pre-gate: %s",
|
||||
_pd_exc,
|
||||
)
|
||||
_route_may_be_custom = provider_lower.startswith("custom:")
|
||||
|
||||
if _supports_anthropic_cache_markers and (
|
||||
|
||||
@@ -28,6 +28,11 @@ def _make_agent(
|
||||
agent.api_mode = api_mode
|
||||
agent.model = model
|
||||
agent._base_url_lower = (base_url or "").lower()
|
||||
# Post-init reality for agents without custom providers: an attached
|
||||
# empty list. Keeps built-in-route tests hermetic — the policy's
|
||||
# None-branch would otherwise consult the models.dev catalog and, on a
|
||||
# miss, fall back to reading the developer's real config.yaml.
|
||||
agent._custom_providers = []
|
||||
agent.client = MagicMock()
|
||||
agent.quiet_mode = True
|
||||
return agent
|
||||
@@ -288,6 +293,7 @@ class TestThirdPartyAnthropicGateway:
|
||||
)
|
||||
# No agent._custom_providers — exercises the config fallback the
|
||||
# init-time call (agent_init before the snapshot assignment) hits.
|
||||
del agent._custom_providers
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, True)
|
||||
|
||||
agent.model = "opus"
|
||||
@@ -363,6 +369,69 @@ class TestCustomProviderOpenAIWireCapability:
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (False, False)
|
||||
|
||||
def test_gate_matches_declaration_despite_url_spelling_drift(self):
|
||||
"""The pre-gate must use the same URL identity semantics as the
|
||||
authoritative capability matcher (normalize_route_base_url): a
|
||||
declaration whose config spelling differs only by host case or
|
||||
trailing slash must still be honored, even when the provider name
|
||||
gives the gate no help."""
|
||||
agent = self._configured_agent(enabled=True)
|
||||
agent.provider = "some-unrelated-alias"
|
||||
agent.base_url = "https://Models.Example.NET/v1/"
|
||||
agent._base_url_lower = agent.base_url.lower()
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
|
||||
def test_gate_matches_declaration_via_spaced_legacy_name(self):
|
||||
"""Legacy entries with spaced display names ('My Gateway') must match
|
||||
the runtime 'custom:my-gateway' identity (custom_provider_aliases
|
||||
semantics). Combined with URL spelling drift, the raw pre-gate
|
||||
dropped this declaration on both legs."""
|
||||
agent = _make_agent(
|
||||
provider="custom:my-gateway",
|
||||
base_url="https://GW.example.net/v1/",
|
||||
api_mode="chat_completions",
|
||||
model="alias-model",
|
||||
)
|
||||
agent._custom_providers = [
|
||||
{
|
||||
"name": "My Gateway",
|
||||
"base_url": "https://gw.example.net/v1",
|
||||
"models": {"alias-model": {"prompt_caching": True}},
|
||||
}
|
||||
]
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
|
||||
def test_early_init_probe_never_fetches_catalog_from_network(
|
||||
self, monkeypatch
|
||||
):
|
||||
"""The None-branch provider probe runs per request destination and
|
||||
must stay off the network: get_provider must be called with
|
||||
allow_network=False so a cold models.dev cache cannot trigger a
|
||||
foreground registry download from the send path."""
|
||||
import hermes_cli.providers as _providers
|
||||
|
||||
seen: list = []
|
||||
real_get_provider = _providers.get_provider
|
||||
|
||||
def recording_get_provider(name, **kwargs):
|
||||
seen.append(kwargs.get("allow_network"))
|
||||
return real_get_provider(name, **kwargs)
|
||||
|
||||
monkeypatch.setattr(_providers, "get_provider", recording_get_provider)
|
||||
agent = _make_agent(
|
||||
provider="openrouter",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
api_mode="chat_completions",
|
||||
model="anthropic/claude-sonnet-4.6",
|
||||
)
|
||||
del agent._custom_providers # early-init shape: attr not attached yet
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
assert seen, "None-branch did not consult the provider catalog"
|
||||
assert all(v is False for v in seen)
|
||||
|
||||
def test_modern_providers_yaml_is_honored_during_early_init(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
@@ -390,6 +459,9 @@ class TestCustomProviderOpenAIWireCapability:
|
||||
api_mode="chat_completions",
|
||||
model="vendor-agnostic-model",
|
||||
)
|
||||
# Early init: the normalized custom-provider list is not attached
|
||||
# yet, so the policy must recognize the route from config itself.
|
||||
del agent._custom_providers
|
||||
|
||||
assert agent._anthropic_prompt_cache_policy() == (True, False)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user