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).
This commit is contained in:
Teknium committed 2026-10-25 19:00:01 -07:00
1 parent ce7b41572c
commit a4a992645b
29 files changed
+271 -170

No files matched your search

+2 -2
View File
@@ -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.
+3 -3
View File
@@ -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(
+6 -15
View File
@@ -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:
-30
View File
@@ -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
+36 -12
View File
@@ -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
+3 -3
View File
@@ -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())
+5 -5
View File
@@ -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))
+1 -1
View File
@@ -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"]
+3 -4
View File
@@ -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
+2 -2
View File
@@ -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"
+4 -3
View File
@@ -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)):
-21
View File
@@ -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."""
+34 -22
View File
@@ -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":
+6 -5
View File
@@ -2,11 +2,12 @@
from __future__ import annotations
# Copilot business/enterprise accounts are served from ``api.<plan>.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.<plan>.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"
@@ -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)."""
@@ -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.<plan>.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
+5 -2
View File
@@ -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})")
+39 -8
View File
@@ -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]:
+7 -7
View File
@@ -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
+11 -9
View File
@@ -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
@@ -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):
+1 -1
View File
@@ -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
@@ -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"
@@ -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: [])
@@ -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():
+5 -1
View File
@@ -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
@@ -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")
@@ -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") == ""
@@ -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