mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
ce7b41572c
commit
a4a992645b
29 files changed
+271
-170
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)):
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user