diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 336cd6174a..b48064ff4c 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -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 ( diff --git a/tests/run_agent/test_anthropic_prompt_cache_policy.py b/tests/run_agent/test_anthropic_prompt_cache_policy.py index a4d8a2d35d..abe95d18b7 100644 --- a/tests/run_agent/test_anthropic_prompt_cache_policy.py +++ b/tests/run_agent/test_anthropic_prompt_cache_policy.py @@ -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)