From a4a992645b405d00530ddac8500cc89257b4dc03 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:00:01 -0700 Subject: [PATCH] refactor(providers): one memoized endpoint->profile lookup; drop duplicated api_mode helpers - providers.profile_for_host is the single host->profile lookup (declared hostnames, exact or subdomain), memoized per (hermes_home_key, host, metadata) and invalidated by a registry generation counter bumped on every registration (process-wide or per-home layer). - agent/provider_route_hooks.route_profiles = named profile, then profile_for_host(base_url); replaces L1's every-profile scan, L2's conversation_loop._credential_refresh_profile host scan and L3's agent/endpoint_profile.py + ProviderProfile.serves_endpoint (deleted). - Copilot hostnames gain models.github.ai (the L3 Responses/reasoning gate host); per-site differences (carriers, editor headers, initiator) stay in the plugin hooks keyed on base_url. - Pure per-request hooks (request_headers, max_tokens_param) are memoized per argument set so a plugins.isolation: host proxy pays one IPC call per request shape. - hermes_cli/models.py: delete _should_use_copilot_responses_api / copilot_model_api_mode; runtime_provider routes per-model providers through api_mode_for_model generically. - credential_refresh_notice seam restores main's Copilot refresh status lines. - auth._env_key_auto_detected: ambient-credential check reads list_providers once instead of a per-pid get_provider_profile miss that walked the provider ladder (welcome_trial regression). --- agent/chat_completion_helpers.py | 4 +- agent/codex_responses_adapter.py | 6 +- agent/conversation_loop.py | 21 ++----- agent/endpoint_profile.py | 30 ---------- agent/provider_route_hooks.py | 48 ++++++++++++---- agent/reasoning_carriers.py | 6 +- agent/reasoning_params.py | 10 ++-- agent/transports/chat_completions.py | 2 +- agent/turn_api_error.py | 7 +-- agent/turn_recovery.py | 4 +- hermes_cli/auth.py | 7 ++- hermes_cli/models.py | 21 ------- hermes_cli/runtime_provider.py | 56 +++++++++++-------- plugins/model-providers/copilot/catalog.py | 11 ++-- .../model-providers/copilot/credentials.py | 5 ++ plugins/model-providers/copilot/responses.py | 4 -- plugins/model-providers/copilot/setup.py | 7 ++- providers/__init__.py | 47 +++++++++++++--- providers/base.py | 14 ++--- tests/agent/test_endpoint_profile_seams.py | 20 ++++--- tests/agent/test_provider_credential_seams.py | 5 +- tests/agent/test_provider_wire_seams.py | 2 +- .../hermes_cli/test_copilot_model_api_mode.py | 9 ++- .../test_copilot_runtime_api_mode.py | 13 +++-- .../test_copilot_runtime_api_mode_stale.py | 8 ++- tests/hermes_cli/test_model_validation.py | 6 +- .../test_copilot_pool_exchange.py | 3 +- .../test_copilot_refresh_notice.py | 17 ++++++ tests/providers/test_profile_host_seams.py | 48 ++++++++++++++++ 29 files changed, 271 insertions(+), 170 deletions(-) delete mode 100644 agent/endpoint_profile.py create mode 100644 tests/plugins/model_providers/test_copilot_refresh_notice.py diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index cd49c341bf..0788f37bd4 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1442,8 +1442,8 @@ def _build_chat_completions_kwargs(agent, api_messages, tools_for_api, reasoning _is_or = agent._is_openrouter_url() _host = agent._base_url_lower _is_lmstudio = (agent.provider or "").strip().lower() == "lmstudio" - from agent.endpoint_profile import endpoint_profile - _profile_endpoint = endpoint_profile(agent.base_url) is not None + from providers import profile_for_host + _profile_endpoint = profile_for_host(str(agent.base_url or "")) is not None # _fixed_temperature_for_model may return the OMIT_TEMPERATURE sentinel # (temperature omitted entirely), a numeric override, or None. diff --git a/agent/codex_responses_adapter.py b/agent/codex_responses_adapter.py index c944a1e004..fa3452d74b 100644 --- a/agent/codex_responses_adapter.py +++ b/agent/codex_responses_adapter.py @@ -22,7 +22,7 @@ logger = logging.getLogger(__name__) class ResponsesEndpointQuirks(NamedTuple): """Responses wire quirks of the endpoint, from the ``responses_*`` fields of the profile claiming it - (``ProviderProfile.serves_endpoint``). Defaults are the generic wire.""" + (``providers.profile_for_host``). Defaults are the generic wire.""" replay_item_ids: bool = True prompt_cache_key: bool = True issuer_kind: str = "" @@ -38,9 +38,9 @@ _GENERIC_ENDPOINT = ResponsesEndpointQuirks() def responses_endpoint_quirks(base_url: Any) -> ResponsesEndpointQuirks: """Quirks for *base_url*; the generic wire when no registered profile claims it (or none is installed). Pure function of the URL and the registry, so every turn replays the same bytes for the same history.""" - from agent.endpoint_profile import endpoint_profile + from providers import profile_for_host - profile = endpoint_profile(base_url) + profile = profile_for_host(str(base_url or "")) if profile is None: return _GENERIC_ENDPOINT return ResponsesEndpointQuirks( diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 93edbc9047..a5f9952168 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -364,22 +364,13 @@ def _apply_active_turn_redirect(agent: Any, messages: list[dict[str, Any]], text def _credential_refresh_profile(agent: Any) -> Any: """The profile owning the live credential's runtime refresh: the agent's provider by name, else - (a ``custom`` route at a provider's host) the registered profile whose hostname the base URL - targets. Only profiles that override ``refresh_runtime_credentials`` qualify.""" - from providers import get_provider_profile, list_providers - from providers.base import ProviderProfile + (a ``custom`` route at a provider's host) the profile serving the base URL's host. Only profiles + that override ``refresh_runtime_credentials`` qualify.""" + from agent.provider_route_hooks import route_profiles - def _refreshes(profile: Any) -> bool: - return type(profile).refresh_runtime_credentials is not ProviderProfile.refresh_runtime_credentials - - named = get_provider_profile((getattr(agent, "provider", "") or "").strip().lower()) - if named is not None and _refreshes(named): - return named - base_url = str(getattr(agent, "base_url", "") or "") - if not base_url: - return None - return next((p for p in list_providers() - if _refreshes(p) and p.get_hostname() and base_url_host_matches(base_url, p.get_hostname())), None) + profiles = route_profiles("refresh_runtime_credentials", (getattr(agent, "provider", "") or "").strip().lower(), + str(getattr(agent, "base_url", "") or "")) + return profiles[0] if profiles else None def _pressure_with_real_floor(compressor: Any, rough_tokens: int) -> int: diff --git a/agent/endpoint_profile.py b/agent/endpoint_profile.py deleted file mode 100644 index 2d97c966c7..0000000000 --- a/agent/endpoint_profile.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Resolve the provider profile that claims an endpoint URL (``ProviderProfile.serves_endpoint``). - -Endpoint wire quirks belong to the backend, not to the configured provider name: a ``custom`` entry -pointed at a provider's host gets that host's quirks, and a provider entry pointed at a relay does not. -""" - -from __future__ import annotations - -import logging -from typing import Any - -logger = logging.getLogger(__name__) - - -def endpoint_profile(base_url: Any) -> Any: - """The registered profile whose ``serves_endpoint`` claims *base_url*, else None (generic wire). - - Lazy and per-home: it walks ``providers.list_providers()`` on each call, so a user plugin - discovered later in the process (or bound to another profile home) is honoured. Fails open. - """ - url = str(base_url or "").strip() - if not url: - return None - try: - from providers import list_providers - - return next((p for p in list_providers() if p.serves_endpoint(url)), None) - except Exception: # plugin-owned hook (possibly hosted over IPC): a broken plugin degrades to the generic wire - logger.debug("endpoint profile lookup failed for %s", url, exc_info=True) - return None diff --git a/agent/provider_route_hooks.py b/agent/provider_route_hooks.py index 02d8acd254..d3b27f9874 100644 --- a/agent/provider_route_hooks.py +++ b/agent/provider_route_hooks.py @@ -1,10 +1,12 @@ -"""Ask provider profiles about an endpoint: the named provider first, then any profile serving the host. +"""Ask provider profiles about an endpoint: the named provider first, then the profile serving the host. -Endpoint-keyed ProviderProfile hooks (``client_headers``, ``request_headers``, ``max_tokens_param``) -receive the request's ``base_url`` and answer None / ``{}`` for endpoints that are not theirs, so a -``custom`` route pointed at a plugin provider's host still gets that provider's wire quirks. Only -profiles that override the hook are consulted: the base implementations are no-ops, and hosted -(``plugins.isolation: host``) proxies define only the hooks their plugin overrides. +Endpoint-keyed ProviderProfile hooks (``client_headers``, ``request_headers``, ``max_tokens_param``, +``refresh_runtime_credentials``) belong to the provider by name AND to the backend by host: a +``custom`` route pointed at a plugin provider's host still gets that provider's quirks. The host +side is ``providers.profile_for_host`` (the profile's declared ``hostnames``), core's one +endpoint->profile lookup. Only profiles that override the hook are consulted: the base +implementations are no-ops, and hosted (``plugins.isolation: host``) proxies define only the hooks +their plugin overrides. """ from __future__ import annotations @@ -16,26 +18,48 @@ from providers.base import ProviderProfile logger = logging.getLogger(__name__) +# Per-request hooks documented pure in providers/base.py: one answer per argument set, so a hosted +# plugin pays one IPC round trip per distinct request shape instead of one per API call. +_PURE_HOOKS = frozenset({"request_headers", "max_tokens_param"}) +_PURE_MEMO: dict[tuple, Any] = {} +_PURE_MEMO_MAX = 512 + def _overrides(profile: Any, hook: str) -> bool: return getattr(type(profile), hook, None) is not getattr(ProviderProfile, hook) -def route_profiles(hook: str, provider: str = "") -> list[Any]: - """Profiles overriding *hook*: the one registered for *provider* first, then every other.""" - from providers import get_provider_profile, list_providers +def route_profiles(hook: str, provider: str = "", base_url: str = "") -> list[Any]: + """Profiles overriding *hook*: the one registered for *provider*, then the one serving *base_url*'s host.""" + from providers import get_provider_profile, profile_for_host named = get_provider_profile(provider) if provider else None ordered = [named] if named is not None and _overrides(named, hook) else [] - ordered.extend(p for p in list_providers() if p is not named and _overrides(p, hook)) + hosted = profile_for_host(base_url) if base_url else None + if hosted is not None and hosted is not named and _overrides(hosted, hook): + ordered.append(hosted) return ordered +def _ask(profile: Any, hook: str, kwargs: dict[str, Any]) -> Any: + if hook not in _PURE_HOOKS: + return getattr(profile, hook)(**kwargs) + from providers import registry_generation + + key = (registry_generation(), id(profile), type(profile), hook, tuple(sorted(kwargs.items()))) + if key not in _PURE_MEMO: + if len(_PURE_MEMO) >= _PURE_MEMO_MAX: + _PURE_MEMO.clear() + _PURE_MEMO[key] = getattr(profile, hook)(**kwargs) + answer = _PURE_MEMO[key] + return dict(answer) if isinstance(answer, dict) else answer + + def route_answer(hook: str, *, provider: str = "", **kwargs: Any) -> Any: """First truthy answer of *hook* across ``route_profiles``; None when no profile claims the endpoint.""" - for profile in route_profiles(hook, provider): + for profile in route_profiles(hook, provider, str(kwargs.get("base_url") or "")): try: - answer = getattr(profile, hook)(**kwargs) + answer = _ask(profile, hook, kwargs) except Exception: # plugin code: a broken hook degrades to generic behaviour, never fails the request logger.debug("provider %s %s hook failed", getattr(profile, "name", "?"), hook, exc_info=True) continue diff --git a/agent/reasoning_carriers.py b/agent/reasoning_carriers.py index 5f7fefe53a..18cdd63352 100644 --- a/agent/reasoning_carriers.py +++ b/agent/reasoning_carriers.py @@ -112,17 +112,17 @@ class _WireRoute(NamedTuple): model: Any base_url: str gemini: bool # the route consumes the Gemini text-turn signature - profile: Any # endpoint profile (``ProviderProfile.serves_endpoint``) or None + profile: Any # profile serving the host (``providers.profile_for_host``) or None keep_fn_sig: bool # replayed tool calls keep ``function.thought_signature`` keys: tuple[str, ...] # every message-level carrier key core knows about def _wire_route(model: Any, base_url: Any) -> _WireRoute: - from agent.endpoint_profile import endpoint_profile + from providers import profile_for_host from agent.transports.chat_completions import _model_consumes_thought_signature url = str(base_url or "") - profile = endpoint_profile(url) + profile = profile_for_host(url) keep_fn_sig = profile is not None and profile.replays_function_thought_signature(model=model, base_url=url) # Same gate as tool-call extra_content, so the two signature carriers can never disagree. return _WireRoute(model, url, _model_consumes_thought_signature(model), profile, keep_fn_sig, _message_keys()) diff --git a/agent/reasoning_params.py b/agent/reasoning_params.py index 2c094613fb..73abe897e4 100644 --- a/agent/reasoning_params.py +++ b/agent/reasoning_params.py @@ -92,9 +92,9 @@ class ReasoningParamsMixin: url = self._base_url_lower if base_url_host_matches(url, "nousresearch.com") or base_url_host_matches(url, "ai-gateway.vercel.sh"): return True - from agent.endpoint_profile import endpoint_profile + from providers import profile_for_host - if (profile := endpoint_profile(getattr(self, "base_url", None))) is not None: + if (profile := profile_for_host(str(getattr(self, "base_url", None) or ""))) is not None: # A claimed endpoint gates on its own catalog: reasoning goes out iff it declares a payload. try: return profile.reasoning_payload(self.model, None, base_url=self.base_url) is not None @@ -140,11 +140,11 @@ class ReasoningParamsMixin: def _endpoint_reasoning_payload(self) -> dict | None: """``reasoning_payload`` of the profile claiming this endpoint for the agent's model and - ``reasoning_config`` (``ProviderProfile.serves_endpoint``), or None when no profile claims it.""" - from agent.endpoint_profile import endpoint_profile + ``reasoning_config`` (``providers.profile_for_host``), or None when no profile claims it.""" + from providers import profile_for_host base_url = getattr(self, "base_url", None) - profile = endpoint_profile(base_url) + profile = profile_for_host(str(base_url or "")) if profile is None: return None return profile.reasoning_payload(self.model, self.reasoning_config, base_url=str(base_url)) diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index 2e288d0a9f..6c97ca4f89 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -616,7 +616,7 @@ class ChatCompletionsTransport(ProviderTransport): extra_body["thinking"] = {"type": "disabled" if thinking_off else "enabled"} if supports_reasoning: - # An endpoint claimed by a profile (ProviderProfile.serves_endpoint) takes its verbatim payload. + # An endpoint claimed by a profile (providers.profile_for_host) takes its verbatim payload. if params.get("profile_endpoint", False): if params.get("endpoint_reasoning") is not None: extra_body["reasoning"] = params["endpoint_reasoning"] diff --git a/agent/turn_api_error.py b/agent/turn_api_error.py index 583936941d..7306b2cab5 100644 --- a/agent/turn_api_error.py +++ b/agent/turn_api_error.py @@ -331,10 +331,9 @@ def settle_unrecovered_error( ): _retry.provider_stale_refresh_attempted = True if agent._try_refresh_provider_runtime_credentials(reason="stale"): - agent._buffer_vprint( - f"🔐 {profile.display_name or profile.name} credential renewed after a " - "stale-credential error. Retrying request..." - ) + agent._buffer_vprint("🔐 " + (profile.credential_refresh_notice(reason="stale") or ( + f"{profile.display_name or profile.name} credential renewed after a " + "stale-credential error. Retrying request..."))) retry_count = 0 return _verdict("continue") # ``should_fallback=False`` marks a deterministic failure no other provider can fix (the diff --git a/agent/turn_recovery.py b/agent/turn_recovery.py index 52673f5012..33e4dfce77 100644 --- a/agent/turn_recovery.py +++ b/agent/turn_recovery.py @@ -436,8 +436,8 @@ def _refresh_credentials_after_401( if not _retry.provider_auth_refresh_attempted and (profile := _credential_refresh_profile(agent)) is not None: _retry.provider_auth_refresh_attempted = True if agent._try_refresh_provider_runtime_credentials(reason="unauthorized"): - agent._buffer_vprint( - f"🔐 {profile.display_name or profile.name} credentials refreshed after 401. Retrying request...") + agent._buffer_vprint("🔐 " + (profile.credential_refresh_notice(reason="unauthorized") or ( + f"{profile.display_name or profile.name} credentials refreshed after 401. Retrying request..."))) return True if ( agent.api_mode == "anthropic_messages" diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index d1bda80c96..c5273aa357 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -1604,10 +1604,11 @@ def _env_key_auto_detected( scoped_key_env: Callable[[str], str], oauth_active: Optional[str]) -> Optional[str]: """First registry api_key provider (registry order) with a usable env key, warning when it preempts a logged-in OAuth provider so a stale key in ~/.hermes/.env never switches silently.""" + from providers import list_providers + + ambient = {p.name for p in list_providers() if p.ambient_credentials} # never per-pid lookups: a miss walks the ladder for pid, pconfig in PROVIDER_REGISTRY.items(): - if pconfig.auth_type != "api_key" or pid in _NO_AUTO_DETECT_PROVIDERS: - continue - if getattr(_credential_profile(pid), "ambient_credentials", False): + if pconfig.auth_type != "api_key" or pid in _NO_AUTO_DETECT_PROVIDERS or pid in ambient: continue for env_var in pconfig.api_key_env_vars: if has_usable_secret(scoped_key_env(env_var)): diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 6f77831eac..57a3503ee4 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -2475,27 +2475,6 @@ def _github_reasoning_efforts_for_model_id(model_id: str) -> list[str]: return [] -def _should_use_copilot_responses_api(model_id: str) -> bool: - """opencode's ``shouldUseCopilotResponsesApi``: GPT-5+ uses the Responses API except - ``gpt-5-mini``; non-GPT models (Claude, Gemini, ...) use Chat Completions.""" - match = re.match(r"^gpt-(\d+)", model_id) - return bool(match) and int(match.group(1)) >= 5 and not model_id.startswith("gpt-5-mini") - - -def copilot_model_api_mode( - model_id: Optional[str], *, catalog: Optional[list[dict[str, Any]]] = None, - api_key: Optional[str] = None) -> str: - """API mode for a Copilot model from the id pattern (opencode's approach). Copilot's Claude models - go through its OpenAI-compatible chat endpoint, not the native Anthropic adapter: the catalog may - advertise /v1/messages but the Copilot token/header scheme lives in the OpenAI client path.""" - if catalog is None and api_key: # fetch once so normalize + endpoint check share it - catalog = fetch_github_model_catalog(api_key=api_key) - normalized = normalize_copilot_model_id(model_id, catalog=catalog, api_key=api_key) - if normalized and _should_use_copilot_responses_api(normalized): - return "codex_responses" - return "chat_completions" - - def azure_foundry_model_api_mode(model_name: Optional[str]) -> Optional[str]: """``"codex_responses"`` for families that only accept the Responses API on Azure Foundry (GPT-5.x incl. gpt-5-mini, codex, o1/o3/o4), else None. Any ``vendor/`` prefix is stripped first.""" diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index d425e2e7a1..eb6c83e36d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -260,27 +260,38 @@ def _effective_model(model_cfg: dict[str, Any], target_model: Optional[str]) -> return target_model or model_cfg.get("default") or "" -def _copilot_runtime_api_mode(model_cfg: dict[str, Any], api_key: str, *, target_model: Optional[str] = None) -> str: - configured_mode = _configured_api_mode("copilot", model_cfg) - if configured_mode: - # A stale/incompatible explicit ``codex_responses`` must not override Copilot's hard - # per-model requirement. Copilot serves ``gpt-5-mini`` on chat completions only; a - # Responses-API request there silently succeeds but returns no reasoning/thinking - # content (#46527). The stale value commonly survives an ordinary flow: the Copilot - # OAuth setup writes ``api_mode: codex_responses`` (correct for the gpt-5.4-mini it - # initially selects), then the user switches to gpt-5-mini without the mode being - # recomputed. The check is the pure-regex Copilot exception — no network call; a - # legitimate ``codex_responses`` for GPT-5 variants like gpt-5.4-mini is still honored. - model_name = str(_effective_model(model_cfg, target_model)).strip() - if (configured_mode == "codex_responses" and model_name - and not _models._should_use_copilot_responses_api(model_name)): - return "chat_completions" - return configured_mode +def _per_model_profile(provider: str) -> Any: + """The provider's profile when it routes ``api_mode`` per model (``ProviderProfile.api_mode_for_model``).""" + from providers import get_provider_profile + from providers.base import ProviderProfile + + profile = get_provider_profile(provider) if provider else None + if profile is None or type(profile).api_mode_for_model is ProviderProfile.api_mode_for_model: + return None + return profile + + +def _profile_runtime_api_mode(provider: str, model_cfg: dict[str, Any], api_key: str, *, + target_model: Optional[str] = None) -> str: + """api_mode for a provider that routes per model: its hard per-model wire wins over a stale persisted + ``codex_responses`` (pure check, no network); otherwise the profile decides with the live key.""" + profile = _per_model_profile(provider) + configured_mode = _configured_api_mode(provider, model_cfg) # Use the model being resolved, not the persisted default: a Claude MoA slot inheriting # codex_responses from a GPT-5 default fails with "model ... does not support Responses API". model_name = str(_effective_model(model_cfg, target_model)).strip() + if configured_mode: + # A stale explicit ``codex_responses`` (written by setup for the model first picked, kept after a + # switch to a chat-only model) must not override the provider's per-model requirement: the + # Responses request silently succeeds but returns no reasoning content (#46527). + if (configured_mode == "codex_responses" and model_name and profile is not None + and profile.api_mode_for_model(model_name) == "chat_completions"): + return "chat_completions" + return configured_mode + if not model_name or profile is None: + return "chat_completions" try: - return _models.copilot_model_api_mode(model_name, api_key=api_key) if model_name else "chat_completions" + return profile.api_mode_for_model(model_name, api_key=api_key) or "chat_completions" except Exception: return "chat_completions" @@ -314,8 +325,8 @@ def _configured_or_fallback_api_mode(provider: str, model_cfg: dict[str, Any], b def _api_key_provider_api_mode(provider: str, model_cfg: dict[str, Any], api_key: str, base_url: str, effective_model: Any, *, opencode_by_model: bool) -> str: """api_mode for a registry ``api_key`` provider (explicit and env/config paths).""" - if provider == "copilot": - return _copilot_runtime_api_mode(model_cfg, api_key, target_model=effective_model) + if _per_model_profile(provider) is not None: + return _profile_runtime_api_mode(provider, model_cfg, api_key, target_model=effective_model) if provider == "xai": # Ramp Router: Responses-native host — /v1/chat/completions is only a minimal compatibility shim, # while reasoning and caching support live on /v1/responses (docs.router.com/api/endpoint). Mirrors @@ -600,9 +611,10 @@ def _pool_entry_mode_and_url(provider, entry, model_cfg, effective_model, base_u return "anthropic_messages", _anthropic_cfg_base_url(model_cfg) or base_url or _ANTHROPIC_DEFAULT_BASE_URL if provider == "nous": return nous_api_mode(effective_model), (_nous_inference_env_override() or "") or base_url - if provider == "copilot": - api_mode = _copilot_runtime_api_mode(model_cfg, getattr(entry, "runtime_api_key", ""), target_model=effective_model) - return api_mode, base_url or PROVIDER_REGISTRY["copilot"].inference_base_url + if _per_model_profile(provider) is not None: + api_mode = _profile_runtime_api_mode(provider, model_cfg, getattr(entry, "runtime_api_key", ""), + target_model=effective_model) + return api_mode, base_url or PROVIDER_REGISTRY[provider].inference_base_url if provider == "azure-foundry": api_mode = "chat_completions" if _cfg_provider(model_cfg) == "azure-foundry": diff --git a/plugins/model-providers/copilot/catalog.py b/plugins/model-providers/copilot/catalog.py index 538a498f4b..d90bfd10ce 100644 --- a/plugins/model-providers/copilot/catalog.py +++ b/plugins/model-providers/copilot/catalog.py @@ -2,11 +2,12 @@ from __future__ import annotations -# Copilot business/enterprise accounts are served from ``api..githubcopilot.com``. -HOSTNAMES = ("githubcopilot.com",) -# GitHub Models (models.github.ai, and the ~8K-capped free tier at models.inference.ai.azure.com) -# share the Copilot catalog but take plain GitHub tokens. -METADATA_HOSTNAMES = ("models.github.ai", "models.inference.ai.azure.com") +# Copilot business/enterprise accounts are served from ``api..githubcopilot.com``; GitHub Models +# (models.github.ai) speaks the same wire (Responses quirks, reasoning payloads, x-initiator billing). +# Per-site differences (carriers, editor headers: githubcopilot.com only) live in the hooks, keyed on base_url. +HOSTNAMES = ("githubcopilot.com", "models.github.ai") +# The ~8K-capped GitHub Models free tier shares the Copilot catalog only (model metadata lookups). +METADATA_HOSTNAMES = ("models.inference.ai.azure.com",) MODELS_DEV_ID = "github-copilot" diff --git a/plugins/model-providers/copilot/credentials.py b/plugins/model-providers/copilot/credentials.py index 0a68f06ad3..f9689242e1 100644 --- a/plugins/model-providers/copilot/credentials.py +++ b/plugins/model-providers/copilot/credentials.py @@ -719,6 +719,11 @@ class CopilotCredentialsMixin: refresher = _RUNTIME_REFRESHERS.get(reason) return refresher() if refresher else None + def credential_refresh_notice(self, *, reason: str) -> str: + if reason == "stale": + return "Copilot credential re-exchanged after model_not_available 400. Retrying request..." + return "Copilot credentials refreshed after 401. Retrying request..." if reason == "unauthorized" else "" + def is_stale_credential_error(self, *, status_code: Optional[int], message: str) -> bool: """A 400 that is really a STALE / DEGRADED credential (status 400 AND an integrator / model-not-supported marker, so a wrong model name never triggers the re-exchange).""" diff --git a/plugins/model-providers/copilot/responses.py b/plugins/model-providers/copilot/responses.py index d817d63057..1db6338389 100644 --- a/plugins/model-providers/copilot/responses.py +++ b/plugins/model-providers/copilot/responses.py @@ -11,7 +11,6 @@ from typing import Any from utils import base_url_host_matches _COPILOT_HOST = "githubcopilot.com" # api.githubcopilot.com plus the api..githubcopilot.com variants -_GITHUB_MODELS_HOST = "models.github.ai" # Constructor kwargs for the profile's L3 fields. ``github_responses`` is persisted on encrypted reasoning # items in session history: renaming it orphans every stored blob (one cold cache + dropped reasoning on resume). @@ -38,9 +37,6 @@ def _is_copilot_host(base_url: str) -> bool: class CopilotResponsesMixin: """Endpoint ownership, catalog-clamped reasoning payloads and the ``/chat/completions`` carriers.""" - def serves_endpoint(self, base_url: str) -> bool: - return _is_copilot_host(base_url) or base_url_host_matches(str(base_url or ""), _GITHUB_MODELS_HOST) - def reasoning_payload(self, model: str | None, reasoning_config: dict | None, *, base_url: str) -> dict | None: from hermes_cli.models import clamp_github_reasoning_effort, github_model_reasoning_efforts diff --git a/plugins/model-providers/copilot/setup.py b/plugins/model-providers/copilot/setup.py index 930252f8a2..877dbf64dd 100644 --- a/plugins/model-providers/copilot/setup.py +++ b/plugins/model-providers/copilot/setup.py @@ -100,7 +100,10 @@ def model_flow_copilot(config, current_model="", args=None): comes from ``setup_reasoning_efforts`` there).""" from hermes_cli.auth import PROVIDER_REGISTRY, resolve_api_key_provider_credentials from hermes_cli.model_setup_flows_common import _persist_model, _pick_model_or_prompt, _say - from hermes_cli.models import fetch_api_models, copilot_model_api_mode + from hermes_cli.models import fetch_api_models + + from .wire import model_api_mode + del config, args provider_id = "copilot" pconfig = PROVIDER_REGISTRY[provider_id] @@ -132,7 +135,7 @@ def model_flow_copilot(config, current_model="", args=None): return selected = _normalize(selected) _persist_model(selected, provider_id, base_url=effective_base, - api_mode=copilot_model_api_mode(selected, catalog=catalog, api_key=api_key)) + api_mode=model_api_mode(selected, catalog=catalog, api_key=api_key)) print(f"Default model set to: {selected} (via {pconfig.name})") diff --git a/providers/__init__.py b/providers/__init__.py index 61a3ea925c..32ccc5be50 100644 --- a/providers/__init__.py +++ b/providers/__init__.py @@ -59,6 +59,8 @@ _ALIASES: dict[str, str] = {} _SOURCES: dict[str, str] = {} _current_source: str | None = None _PROVIDER_LIST_CACHE: list[ProviderProfile] | None = None +# Bumped by every registration (process-wide or per-home layer): memos over the registry key on it. +_REGISTRY_GENERATION = 0 _discovered = False _discovering = False _PLUGIN_DIR_STAMP_TTL_SECONDS = 1.0 @@ -127,7 +129,8 @@ def register_provider(profile: ProviderProfile) -> None: bundled profiles without editing repo code. A registration made while a ``$HERMES_HOME`` plugin is being imported lands in that home's layer. """ - global _PROVIDER_LIST_CACHE + global _PROVIDER_LIST_CACHE, _REGISTRY_GENERATION + _REGISTRY_GENERATION += 1 layer = _REGISTRATION_TARGET.get() if layer is not None: layer.registry[profile.name] = profile @@ -257,21 +260,49 @@ def routed_model_rejects_vision_tool_messages(provider: str, model: str) -> bool return target_profile is not None and target_profile.supports_vision_tool_messages is False +_HOST_PROFILE_MEMO: dict[tuple, tuple[tuple, ProviderProfile | None]] = {} + + +def registry_generation() -> int: + """Counter bumped by every provider registration; key memos of profile answers on it.""" + return _REGISTRY_GENERATION + + +def _host_claims(host: str, claimed: str) -> bool: + claimed = str(claimed or "").strip().lower().rstrip(".") + return bool(claimed) and (host == claimed or host.endswith("." + claimed)) + + def profile_for_host(url: str, *, metadata: bool = False) -> ProviderProfile | None: """The registered profile whose declared ``hostnames`` match *url*'s host (exact or subdomain). - *metadata* also matches ``metadata_hostnames``. Resolved per call against the bound home's - providers, so a ``$HERMES_HOME`` plugin installed after import (or in another profile) is seen. + *metadata* also matches ``metadata_hostnames``. Resolved against the bound home's providers, so a + ``$HERMES_HOME`` plugin installed after import (or in another profile) is seen; memoized per + (home, host) until the registry changes, because wire paths ask on every request. """ - from utils import base_url_host_matches + from agent.safe_worker_policy import safe_worker_enabled + from utils import base_url_hostname - if not url: + host = base_url_hostname(url) if url else "" + if not host or safe_worker_enabled(): return None + if not _discovered: + _discover_providers() + layer, home, key = _bound_home_layer() + _refresh_home_layer(layer, home, key) + token = (_REGISTRY_GENERATION, id(_REGISTRY), len(_REGISTRY), len(layer.registry)) + memo_key = (key, host, metadata) + cached = _HOST_PROFILE_MEMO.get(memo_key) + if cached is not None and cached[0] == token: + return cached[1] + found = None for profile in list_providers(): hosts = (*profile.hostnames, *profile.metadata_hostnames) if metadata else profile.hostnames - if any(base_url_host_matches(url, host) for host in hosts): - return profile - return None + if any(_host_claims(host, claimed) for claimed in hosts): + found = profile + break + _HOST_PROFILE_MEMO[memo_key] = (token, found) + return found def list_providers() -> list[ProviderProfile]: diff --git a/providers/base.py b/providers/base.py index 73e58fc01f..da1f3bca24 100644 --- a/providers/base.py +++ b/providers/base.py @@ -467,12 +467,12 @@ class ProviderProfile: def request_headers(self, *, base_url: str, model: str = "", user_initiated: bool = False) -> dict[str, str]: """Per-request headers (``extra_headers``) for *base_url*; ``user_initiated`` is True only on the - first API call of a user turn (attribution). Hot path: pure and cheap.""" + first API call of a user turn (attribution). Pure: core memoizes the answer per argument set.""" return {} def max_tokens_param(self, *, base_url: str, model: str = "") -> str | None: """``"max_completion_tokens"`` / ``"max_tokens"`` for requests to *base_url*, or None for the - core heuristic (direct OpenAI / Azure host or newer OpenAI model families).""" + core heuristic (direct OpenAI / Azure host or newer OpenAI model families). Pure: memoized per arguments.""" return None def listed(self) -> bool: @@ -652,6 +652,10 @@ class ProviderProfile: """True when a non-auth error really means the credential went stale (pure, no I/O).""" return False + def credential_refresh_notice(self, *, reason: str) -> str: + """Status line after ``refresh_runtime_credentials(reason=...)`` succeeded; "" = generic wording.""" + return "" + def request_rejection_hint(self, *, status_code: int | None, base_url: str) -> tuple[str, ...]: """User-facing lines explaining an endpoint's hard rejection of *any* route (pure); default none.""" return () @@ -661,7 +665,7 @@ class ProviderProfile: return None # ── Lane L3: Responses-route quirks and message-level reasoning carriers ─── - # Every field and hook below applies only on endpoints the profile claims via ``serves_endpoint``; + # Every field and hook below applies only on hosts the profile declares in ``hostnames``; # the defaults are the generic wire. Hot path: answers must be pure and cheap (no I/O). responses_replay_item_ids: bool = True # replayed Responses ``message`` items keep their ``id`` responses_prompt_cache_key: bool = True # the Responses body carries ``prompt_cache_key`` @@ -673,10 +677,6 @@ class ProviderProfile: message_carrier_text: str = "" # top-level assistant key of readable reasoning text (streamed parts concatenate) replays_tool_call_signatures: bool = False # tool calls keep Gemini ``extra_content`` signatures (by provider name) - def serves_endpoint(self, base_url: str) -> bool: - """True when *base_url* is this provider's own backend, so the L3 fields and hooks apply to it.""" - return False - def reasoning_payload(self, model: str | None, reasoning_config: dict | None, *, base_url: str) -> dict | None: """Verbatim ``reasoning`` object the endpoint takes for *model* (catalog-clamped), or None for none.""" return None diff --git a/tests/agent/test_endpoint_profile_seams.py b/tests/agent/test_endpoint_profile_seams.py index 16fd183dc9..749ab0f010 100644 --- a/tests/agent/test_endpoint_profile_seams.py +++ b/tests/agent/test_endpoint_profile_seams.py @@ -1,4 +1,4 @@ -"""Generic endpoint seams (``ProviderProfile.serves_endpoint`` + the L3 fields/hooks) through a fake relay profile. +"""Generic endpoint seams (``hostnames`` via ``providers.profile_for_host`` + the L3 fields/hooks) through a fake relay profile. The relay is not any shipped provider: the contract is that a profile claiming an endpoint controls its Responses item-id replay, cache-key routing, issuer stamp, reasoning shape and message-level reasoning @@ -10,16 +10,12 @@ from types import SimpleNamespace import pytest from providers.base import ProviderProfile -from utils import base_url_host_matches RELAY = "https://relay.example/v1" OTHER = "https://other.example/v1" class _RelayProfile(ProviderProfile): - def serves_endpoint(self, base_url: str) -> bool: - return base_url_host_matches(base_url, "relay.example") - def reasoning_payload(self, model, reasoning_config, *, base_url): return {"effort": "low"} @@ -28,16 +24,22 @@ class _RelayProfile(ProviderProfile): @pytest.fixture -def relay(monkeypatch): +def relay(request): import providers profile = _RelayProfile( - name="relay-fake", responses_replay_item_ids=False, responses_prompt_cache_key=False, + name="relay-fake", hostnames=("relay.example",), responses_replay_item_ids=False, responses_prompt_cache_key=False, responses_issuer_kind="relay_kind", responses_trusts_completed_status=False, responses_profile_reasoning=True, message_carrier_opaque="blob", message_carrier_text="blob_text", ) - real = providers.list_providers - monkeypatch.setattr(providers, "list_providers", lambda: [profile, *real()]) + providers.list_providers() # discovery first, so the fixture registration is not shadowed + providers.register_provider(profile) + + def _cleanup(): + providers._REGISTRY.pop(profile.name, None) + providers._PROVIDER_LIST_CACHE = None + + request.addfinalizer(_cleanup) return profile diff --git a/tests/agent/test_provider_credential_seams.py b/tests/agent/test_provider_credential_seams.py index 527a8291aa..4037b3e5c7 100644 --- a/tests/agent/test_provider_credential_seams.py +++ b/tests/agent/test_provider_credential_seams.py @@ -7,6 +7,7 @@ from types import SimpleNamespace import providers from providers.base import ProviderProfile +from utils import base_url_host_matches class _OwnCreds(ProviderProfile): @@ -24,7 +25,7 @@ class _OwnCreds(ProviderProfile): return ("capped",) if status_code == 413 and "acme.example" in base_url else () -_ACME = _OwnCreds(name="acme", base_url="https://api.acme.example/v1") +_ACME = _OwnCreds(name="acme", base_url="https://api.acme.example/v1", hostnames=("acme.example",)) _PLAIN = ProviderProfile(name="plain", base_url="https://api.plain.example/v1") @@ -32,6 +33,8 @@ def _registry(monkeypatch): profiles = {"acme": _ACME, "plain": _PLAIN} monkeypatch.setattr(providers, "get_provider_profile", lambda name: profiles.get(name)) monkeypatch.setattr(providers, "list_providers", lambda: list(profiles.values())) + monkeypatch.setattr(providers, "profile_for_host", lambda url, metadata=False: next( + (p for p in profiles.values() if any(base_url_host_matches(url, h) for h in p.hostnames)), None)) def test_profile_seeded_rows_land_in_the_pool_and_skip_the_generic_env_loop(monkeypatch): diff --git a/tests/agent/test_provider_wire_seams.py b/tests/agent/test_provider_wire_seams.py index 9f896f56cb..cc41dd31c1 100644 --- a/tests/agent/test_provider_wire_seams.py +++ b/tests/agent/test_provider_wire_seams.py @@ -37,7 +37,7 @@ def wire_profile(): return "max_completion_tokens" if base_url_host_matches(base_url, "wirefake.test") else None providers.list_providers() # discovery first, so the fake survives it - profile = WireFakeProfile(name="wirefake", base_url=HOST) + profile = WireFakeProfile(name="wirefake", base_url=HOST, hostnames=("wirefake.test",)) providers.register_provider(profile) yield profile, seen_keys import hermes_cli.auth diff --git a/tests/hermes_cli/test_copilot_model_api_mode.py b/tests/hermes_cli/test_copilot_model_api_mode.py index dfd3af4611..8170fafe93 100644 --- a/tests/hermes_cli/test_copilot_model_api_mode.py +++ b/tests/hermes_cli/test_copilot_model_api_mode.py @@ -2,9 +2,14 @@ from __future__ import annotations +import providers as _providers + +_providers.get_provider_profile("copilot") # imports the copilot plugin package (wire module) +from plugins.model_providers.copilot import wire as copilot_wire + def test_copilot_claude_stays_on_chat_completions_even_if_catalog_lists_messages(): - from hermes_cli.models import copilot_model_api_mode + copilot_model_api_mode = copilot_wire.model_api_mode catalog = [ { @@ -17,7 +22,7 @@ def test_copilot_claude_stays_on_chat_completions_even_if_catalog_lists_messages def test_copilot_gpt5_still_uses_responses_api(): - from hermes_cli.models import copilot_model_api_mode + copilot_model_api_mode = copilot_wire.model_api_mode assert copilot_model_api_mode("gpt-5.5", catalog=[]) == "codex_responses" assert copilot_model_api_mode("gpt-5-mini", catalog=[]) == "chat_completions" diff --git a/tests/hermes_cli/test_copilot_runtime_api_mode.py b/tests/hermes_cli/test_copilot_runtime_api_mode.py index 138e4dff96..2800e03971 100644 --- a/tests/hermes_cli/test_copilot_runtime_api_mode.py +++ b/tests/hermes_cli/test_copilot_runtime_api_mode.py @@ -6,17 +6,22 @@ from types import SimpleNamespace import pytest +import providers as _providers + +_providers.get_provider_profile("copilot") # imports the copilot plugin package (wire module) +from plugins.model_providers.copilot import wire as copilot_wire + def test_copilot_runtime_api_mode_still_uses_default_without_target(monkeypatch): from hermes_cli import runtime_provider as rp monkeypatch.setattr( - "hermes_cli.models.copilot_model_api_mode", + copilot_wire, "model_api_mode", lambda model, api_key=None: "codex_responses" if str(model).startswith("gpt-5") else "chat_completions", ) - assert rp._copilot_runtime_api_mode( - {"provider": "copilot", "default": "gpt-5.5"}, + assert rp._profile_runtime_api_mode( + "copilot", {"provider": "copilot", "default": "gpt-5.5"}, "token", ) == "codex_responses" @@ -44,7 +49,7 @@ def test_resolver_routes_copilot_by_target_model_for_every_credential_path( model_cfg = {"provider": "copilot", "default": configured_model} monkeypatch.setattr(rp, "_get_model_config", lambda: model_cfg) monkeypatch.setattr(rp, "resolve_provider", lambda *_args, **_kwargs: "copilot") - # Keep this a real copilot_model_api_mode decision without making a live + # Keep this a real Copilot model_api_mode decision without making a live # catalog request. The API-mode contract is determined by the model family. monkeypatch.setattr(models, "fetch_github_model_catalog", lambda **_kwargs: []) diff --git a/tests/hermes_cli/test_copilot_runtime_api_mode_stale.py b/tests/hermes_cli/test_copilot_runtime_api_mode_stale.py index 9302c65693..3610e4ef54 100644 --- a/tests/hermes_cli/test_copilot_runtime_api_mode_stale.py +++ b/tests/hermes_cli/test_copilot_runtime_api_mode_stale.py @@ -2,7 +2,7 @@ honor a stale/incompatible explicit ``api_mode``. The gateway resolves and snapshots the primary provider/model at startup via -``resolve_runtime_provider`` -> ``_copilot_runtime_api_mode``. When the config +``resolve_runtime_provider`` -> ``_profile_runtime_api_mode``. When the config carries a stale ``api_mode: codex_responses`` (e.g. the Copilot OAuth setup wrote it for the ``gpt-5.4-mini`` it initially selects, then the user switched to ``gpt-5-mini``) the old code honored the stale ``codex_responses`` verbatim. @@ -10,7 +10,11 @@ That dispatches gpt-5-mini through the Responses API — which Copilot serves on chat completions only — and the response comes back with no reasoning/thinking content (the symptom in #46527). """ -from hermes_cli.runtime_provider import _copilot_runtime_api_mode +from functools import partial + +from hermes_cli.runtime_provider import _profile_runtime_api_mode + +_copilot_runtime_api_mode = partial(_profile_runtime_api_mode, "copilot") def test_stale_codex_responses_vetoed_for_gpt5_mini(): diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 37f7b30f13..3fc0e25089 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -3,7 +3,11 @@ import pytest from unittest.mock import MagicMock, patch -from hermes_cli.models import azure_foundry_model_api_mode, copilot_model_api_mode, curated_models_for_provider, fetch_api_models, normalize_provider, opencode_model_api_mode, parse_model_input, probe_api_models, provider_model_ids +from hermes_cli.models import azure_foundry_model_api_mode, curated_models_for_provider, fetch_api_models, normalize_provider, opencode_model_api_mode, parse_model_input, probe_api_models, provider_model_ids +import providers as _providers + +_providers.get_provider_profile("copilot") # imports the copilot plugin package (wire module) +from plugins.model_providers.copilot.wire import model_api_mode as copilot_model_api_mode from hermes_cli.models_local import fetch_lmstudio_models from hermes_cli.models_validate import validate_requested_model diff --git a/tests/plugins/model_providers/test_copilot_pool_exchange.py b/tests/plugins/model_providers/test_copilot_pool_exchange.py index 5f59ba82d0..c76585127d 100644 --- a/tests/plugins/model_providers/test_copilot_pool_exchange.py +++ b/tests/plugins/model_providers/test_copilot_pool_exchange.py @@ -10,6 +10,7 @@ from hermes_cli import runtime_provider as rp _providers.get_provider_profile("copilot") # imports the copilot plugin package (credentials module) from plugins.model_providers.copilot import credentials as copilot_credentials +from plugins.model_providers.copilot import wire as copilot_wire def test_runtime_selected_copilot_exchanges_ambient_pool_token(tmp_path, monkeypatch): @@ -28,7 +29,7 @@ def test_runtime_selected_copilot_exchanges_ambient_pool_token(tmp_path, monkeyp monkeypatch.setattr(copilot_credentials, "resolve_copilot_token", lambda: ("ghu_raw_gh_token", "gh auth token")) monkeypatch.setattr(copilot_credentials, "get_copilot_api_token", lambda tok: ("tid=exchanged;exp=1", "https://api.enterprise.ghe.example")) - monkeypatch.setattr(rp._models, "copilot_model_api_mode", lambda *a, **k: "chat_completions") + monkeypatch.setattr(copilot_wire, "model_api_mode", lambda *a, **k: "chat_completions") monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "copilot") resolved = rp.resolve_runtime_provider(requested="copilot", target_model="gpt-4.1") diff --git a/tests/plugins/model_providers/test_copilot_refresh_notice.py b/tests/plugins/model_providers/test_copilot_refresh_notice.py new file mode 100644 index 0000000000..6cd49a6760 --- /dev/null +++ b/tests/plugins/model_providers/test_copilot_refresh_notice.py @@ -0,0 +1,17 @@ +"""Copilot's credential-refresh status lines keep main's wording (``ProviderProfile.credential_refresh_notice``).""" + +import providers + + +def test_copilot_refresh_notices_match_the_pre_seam_text(): + profile = providers.get_provider_profile("copilot") + assert profile.credential_refresh_notice(reason="unauthorized") == ( + "Copilot credentials refreshed after 401. Retrying request...") + assert profile.credential_refresh_notice(reason="stale") == ( + "Copilot credential re-exchanged after model_not_available 400. Retrying request...") + + +def test_profiles_without_a_notice_get_the_generic_wording(): + from providers.base import ProviderProfile + + assert ProviderProfile(name="plain").credential_refresh_notice(reason="unauthorized") == "" diff --git a/tests/providers/test_profile_host_seams.py b/tests/providers/test_profile_host_seams.py index 8800d7c37c..fb3c931f7e 100644 --- a/tests/providers/test_profile_host_seams.py +++ b/tests/providers/test_profile_host_seams.py @@ -68,3 +68,51 @@ def test_live_context_length_answers_after_cache_with_credentials(fake_profile): ctx = _resolve_provider_aware_context_length("m-1", "https://api.fake-llm.test/v1", "sk-x", NAME, NAME) assert ctx == 777_000 assert fake_profile.calls == [("m-1", "sk-x", "https://api.fake-llm.test/v1")] + + +def test_profile_for_host_answers_follow_registry_changes(request): + """Host answers are memoized for the wire hot path, but a profile registered later is seen at once.""" + host = "https://api.late-claim.test/v1" + providers.list_providers() + assert providers.profile_for_host(host) is None + late = ProviderProfile(name="l4-late-claim", hostnames=("late-claim.test",)) + providers.register_provider(late) + + def _cleanup(): + providers._REGISTRY.pop(late.name, None) + providers._PROVIDER_LIST_CACHE = None + + request.addfinalizer(_cleanup) + assert providers.profile_for_host(host) is late + _cleanup() + assert providers.profile_for_host(host) is None + + +class _CountingWire(ProviderProfile): + def request_headers(self, *, base_url, model="", user_initiated=False): + self.calls.append((base_url, model, user_initiated)) + return {"X-Count": "user"} if user_initiated else {} + + +def test_pure_per_request_hook_is_asked_once_per_argument_set(request): + """``request_headers`` is pure: a hosted plugin answers once per distinct request shape, not per call.""" + from agent.provider_route_hooks import route_answer + + profile = _CountingWire(name="l4-counting-wire", hostnames=("counting-wire.test",)) + profile.calls = [] + providers.list_providers() + providers.register_provider(profile) + + def _cleanup(): + providers._REGISTRY.pop(profile.name, None) + providers._PROVIDER_LIST_CACHE = None + + request.addfinalizer(_cleanup) + url = "https://api.counting-wire.test/v1" + for _ in range(3): + answer = route_answer("request_headers", provider="custom", base_url=url, model="m", user_initiated=True) + assert answer == {"X-Count": "user"} + answer["mutated"] = "1" # callers own their copy; the memo is never poisoned + assert len(profile.calls) == 1 + route_answer("request_headers", provider="custom", base_url=url, model="m", user_initiated=False) + assert len(profile.calls) == 2