Merge pull request #121614 from NousResearch/austin/fix/121347-base-url-resolution
fix(providers): honor a configured base_url instead of the vendor host across runtime, fallback, listing and validation
This commit is contained in:
@@ -2296,7 +2296,8 @@ def _warn_paid_lane_once(model: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _try_openrouter(explicit_api_key: Optional[Union[str, Callable[[], str]]] = None, model: str = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
def _try_openrouter(explicit_api_key: Optional[Union[str, Callable[[], str]]] = None, model: str = None,
|
||||
explicit_base_url: Optional[str] = None) -> Tuple[Optional[OpenAI], Optional[str]]:
|
||||
free_only, cfg_model = _aux_openrouter_settings()
|
||||
or_model = model or cfg_model
|
||||
if free_only and not _is_free_model(or_model):
|
||||
@@ -2309,11 +2310,14 @@ def _try_openrouter(explicit_api_key: Optional[Union[str, Callable[[], str]]] =
|
||||
return None, None
|
||||
if not _is_free_model(or_model):
|
||||
_warn_paid_lane_once(or_model)
|
||||
# A caller-supplied endpoint (fallback_providers entry, custom_providers entry) is
|
||||
# authoritative over both the pool row and the canonical host (#121359).
|
||||
override_url = (explicit_base_url or "").strip().rstrip("/")
|
||||
pool_present, entry = _select_pool_entry("openrouter")
|
||||
if pool_present:
|
||||
or_key = explicit_api_key or _pool_runtime_api_key(entry)
|
||||
if or_key:
|
||||
base_url = _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
|
||||
base_url = override_url or _pool_runtime_base_url(entry, OPENROUTER_BASE_URL) or OPENROUTER_BASE_URL
|
||||
logger.debug("Auxiliary client: OpenRouter via pool")
|
||||
return _create_openai_client(
|
||||
api_key=or_key, base_url=base_url, default_headers=build_or_headers()
|
||||
@@ -2327,7 +2331,7 @@ def _try_openrouter(explicit_api_key: Optional[Union[str, Callable[[], str]]] =
|
||||
return None, None
|
||||
logger.debug("Auxiliary client: OpenRouter")
|
||||
return _create_openai_client(
|
||||
api_key=or_key, base_url=OPENROUTER_BASE_URL, default_headers=build_or_headers()
|
||||
api_key=or_key, base_url=override_url or OPENROUTER_BASE_URL, default_headers=build_or_headers()
|
||||
), or_model
|
||||
|
||||
|
||||
@@ -3009,7 +3013,8 @@ def _try_azure_foundry(
|
||||
return client, final_model
|
||||
|
||||
|
||||
def _try_anthropic(explicit_api_key: Optional[Union[str, Callable[[], str]]] = None) -> Tuple[Optional[Any], Optional[str]]:
|
||||
def _try_anthropic(explicit_api_key: Optional[Union[str, Callable[[], str]]] = None,
|
||||
explicit_base_url: Optional[str] = None) -> Tuple[Optional[Any], Optional[str]]:
|
||||
try:
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
from agent.anthropic_credentials import resolve_anthropic_token
|
||||
@@ -3037,6 +3042,21 @@ def _try_anthropic(explicit_api_key: Optional[Union[str, Callable[[], str]]] = N
|
||||
cfg_base_url = (model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
if cfg_base_url and _is_anthropic_compatible_host(cfg_base_url):
|
||||
base_url = cfg_base_url
|
||||
# A caller-supplied endpoint (fallback_providers entry, custom_providers entry) wins over
|
||||
# both the pool row and config.yaml, under the same Anthropic-compatible host rule the
|
||||
# primary path applies. A foreign host is REFUSED outright rather than silently demoted to
|
||||
# the canonical host: continuing would send the explicit credential to a target the caller
|
||||
# did not ask for (#121359).
|
||||
override_url = (explicit_base_url or "").strip().rstrip("/")
|
||||
if override_url:
|
||||
if not _is_anthropic_compatible_host(override_url):
|
||||
logger.warning(
|
||||
"Auxiliary client: refusing anthropic explicit base_url %r — not an "
|
||||
"Anthropic-compatible host; no client built and no request sent.",
|
||||
override_url,
|
||||
)
|
||||
return None, None
|
||||
base_url = override_url
|
||||
from agent.anthropic_credentials import _is_oauth_token
|
||||
is_oauth = _is_oauth_token(token)
|
||||
model = _get_aux_model_for_provider("anthropic") or "claude-haiku-4-5-20251001"
|
||||
@@ -4968,7 +4988,8 @@ def _resolve_auto_branch(req: _ResolveRequest) -> _ResolveResult:
|
||||
|
||||
def _resolve_openrouter_branch(req: _ResolveRequest) -> _ResolveResult:
|
||||
"""OpenRouter."""
|
||||
client, default = _try_openrouter(explicit_api_key=req.explicit_api_key, model=req.model)
|
||||
client, default = _try_openrouter(explicit_api_key=req.explicit_api_key, model=req.model,
|
||||
explicit_base_url=req.explicit_base_url)
|
||||
if client is None:
|
||||
logger.warning("resolve_provider_client: openrouter requested but %s",
|
||||
_describe_openrouter_unavailable(model=req.model))
|
||||
@@ -5230,7 +5251,8 @@ def _resolve_api_key_branch(req: _ResolveRequest, pconfig: Any, resolve_creds: C
|
||||
"""PROVIDER_REGISTRY ``api_key`` providers (Anthropic via its own resolver), honouring explicit overrides."""
|
||||
provider = req.provider
|
||||
if provider == "anthropic":
|
||||
client, default_model = _try_anthropic(explicit_api_key=req.explicit_api_key)
|
||||
client, default_model = _try_anthropic(explicit_api_key=req.explicit_api_key,
|
||||
explicit_base_url=req.explicit_base_url)
|
||||
return _route_or_warn(req, client, default_model,
|
||||
"resolve_provider_client: anthropic requested but no Anthropic credentials found")
|
||||
creds = resolve_creds(provider)
|
||||
|
||||
2
contributors/emails/ahisblessed@users.noreply.github.com
Normal file
2
contributors/emails/ahisblessed@users.noreply.github.com
Normal file
@@ -0,0 +1,2 @@
|
||||
ahisblessed
|
||||
# PR #121591 salvage (/model picker probes model.base_url)
|
||||
@@ -1602,26 +1602,66 @@ def _chat_catalog_rows(models):
|
||||
return without_generation_models(models)
|
||||
|
||||
|
||||
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
|
||||
"""Best known model catalog for a provider: per-provider live fetchers, then the generic profile
|
||||
fetch, then the static list (merged with models.dev for ``_MODELS_DEV_PREFERRED`` providers)."""
|
||||
requested = str(provider or "").strip().lower()
|
||||
if requested == "ollama":
|
||||
return _ollama_local_catalog(force_refresh)
|
||||
def _configured_relay_base_url(provider: str) -> str:
|
||||
"""``model.base_url`` when it points the *configured* provider at a relay/proxy, else "".
|
||||
|
||||
normalized = normalize_provider(provider)
|
||||
fetcher = _PROVIDER_CATALOG_FETCHERS.get(normalized)
|
||||
if fetcher is not None:
|
||||
models = fetcher(normalized, force_refresh)
|
||||
if models is not None:
|
||||
return _chat_catalog_rows(models)
|
||||
Discovery must probe the same endpoint inference uses (#121387): with ``model.base_url``
|
||||
set for the configured provider, the vendor's canonical host is NOT the catalog to list.
|
||||
Mirrors the ``$OPENAI_BASE_URL`` -> ``model.base_url`` -> canonical precedence of
|
||||
``_openai_discovery_base_url`` for every built-in provider, not just OpenAI.
|
||||
"""
|
||||
try:
|
||||
models = _profile_live_catalog(normalized)
|
||||
model_cfg = _get_model_config_dict()
|
||||
except Exception:
|
||||
models = None
|
||||
if models is not None:
|
||||
return _chat_catalog_rows(models)
|
||||
return ""
|
||||
cfg_provider = str(model_cfg.get("provider") or "").strip().lower()
|
||||
if not cfg_provider or not provider:
|
||||
return ""
|
||||
try:
|
||||
if normalize_provider(provider) != normalize_provider(cfg_provider):
|
||||
return ""
|
||||
except Exception:
|
||||
return ""
|
||||
return str(model_cfg.get("base_url") or "").strip().rstrip("/")
|
||||
|
||||
|
||||
def _relay_model_catalog(normalized: str, relay: str) -> Optional[list[str]]:
|
||||
"""Live catalog probed at a configured ``model.base_url`` relay, or None to fall through.
|
||||
|
||||
Returns only the relay's live ids (no curated merge): a relay user must see the relay's
|
||||
catalog, and a failed/empty probe degrades to the canonical fetchers untouched.
|
||||
"""
|
||||
try:
|
||||
from providers import get_provider_profile
|
||||
|
||||
profile = get_provider_profile(normalized)
|
||||
if profile is None or getattr(profile, "auth_type", "") != "api_key":
|
||||
return None
|
||||
api_key, _ = _api_key_credentials(normalized)
|
||||
live = profile.fetch_models(api_key=api_key, base_url=relay)
|
||||
return [str(m) for m in (live or []) if m] or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
|
||||
# Canonical fetchers that already resolve `model.base_url` themselves for the configured
|
||||
# provider AND degrade to their curated list when that relay fails — `_anthropic_catalog`,
|
||||
# `_custom_catalog`, `_openai_catalog` (via `_openai_discovery_base_url`) and the simple
|
||||
# api-key fetchers (via `resolve_api_key_provider_credentials`). They already satisfy the
|
||||
# "no vendor egress when a relay is configured" invariant, so intercepting them would only
|
||||
# override correct, better-merged behaviour. Everything else is vendor-pinned (#121387).
|
||||
_RELAY_AWARE_CATALOG_FETCHERS = frozenset(
|
||||
{"anthropic", "custom", "openai", "openai-api", "stepfun", "gmi"}
|
||||
)
|
||||
|
||||
|
||||
def _static_catalog(normalized: str, fetcher: Any) -> list[str]:
|
||||
"""The local, no-egress catalog tail: curated static list (+ models.dev merge where preferred).
|
||||
|
||||
Shared by the normal path's final fallback and by the configured-relay degrade path, which
|
||||
must never reach a live vendor fetcher (#121387).
|
||||
"""
|
||||
# Merge static curated list with live API results so models that the live endpoint omits (stale cache,
|
||||
# partial rollout) still appear in the picker. Single providers (kimi, zai) use curated-first (commit
|
||||
# 658ac1d86) to surface newest models even when live API lags (#46309). OpenCode Zen / Go are different:
|
||||
@@ -1640,6 +1680,40 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
|
||||
return _chat_catalog_rows(_xai_finalize_catalog(merged) if normalized in {"xai", "xai-oauth"} else merged)
|
||||
|
||||
|
||||
def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) -> list[str]:
|
||||
"""Best known model catalog for a provider: per-provider live fetchers, then the generic profile
|
||||
fetch, then the static list (merged with models.dev for ``_MODELS_DEV_PREFERRED`` providers)."""
|
||||
requested = str(provider or "").strip().lower()
|
||||
if requested == "ollama":
|
||||
return _ollama_local_catalog(force_refresh)
|
||||
|
||||
normalized = normalize_provider(provider)
|
||||
# A configured `model.base_url` relay is TERMINAL for live catalog egress: the picker must
|
||||
# list what the configured endpoint serves and must never touch the vendor host (#121387).
|
||||
# A failed or empty probe degrades to the local curated list — falling through to the
|
||||
# canonical fetchers would send the provider credential to exactly the host the user
|
||||
# deliberately routed away from, recreating the bug on the failure path.
|
||||
relay = _configured_relay_base_url(provider or "")
|
||||
if relay and normalized not in _RELAY_AWARE_CATALOG_FETCHERS:
|
||||
relayed = _relay_model_catalog(normalized, relay)
|
||||
if relayed:
|
||||
return _chat_catalog_rows(relayed)
|
||||
return _static_catalog(normalized, _PROVIDER_CATALOG_FETCHERS.get(normalized))
|
||||
fetcher = _PROVIDER_CATALOG_FETCHERS.get(normalized)
|
||||
if fetcher is not None:
|
||||
models = fetcher(normalized, force_refresh)
|
||||
if models is not None:
|
||||
return _chat_catalog_rows(models)
|
||||
try:
|
||||
models = _profile_live_catalog(normalized)
|
||||
except Exception:
|
||||
models = None
|
||||
if models is not None:
|
||||
return _chat_catalog_rows(models)
|
||||
|
||||
return _static_catalog(normalized, fetcher)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Disk cache for provider_model_ids() — keeps /model picker fast (otherwise every open re-fetches
|
||||
# every authed provider's /v1/models). One JSON file at $HERMES_HOME/provider_models_cache.json;
|
||||
|
||||
@@ -589,7 +589,7 @@ def _validate_managed_local(req: _Request) -> Optional[dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def _profile_catalog(normalized: str) -> tuple[list[str], bool]:
|
||||
def _profile_catalog(normalized: str, base_url: Optional[str] = None) -> tuple[list[str], bool]:
|
||||
"""``(catalog, authoritative)`` for a profile whose catalog is not the generic
|
||||
``{base_url}/models`` listing — it overrides ``fetch_models`` or points ``models_url``
|
||||
elsewhere — so that listing is not authoritative for it (a relay may 200 with a different
|
||||
@@ -604,6 +604,10 @@ def _profile_catalog(normalized: str) -> tuple[list[str], bool]:
|
||||
profile = get_provider_profile(normalized)
|
||||
if profile is None:
|
||||
return [], False
|
||||
# A non-default runtime base_url is a user-configured relay/proxy; its own /models listing
|
||||
# decides validation, not the provider profile's canonical catalog endpoint.
|
||||
if base_url and (base_url.rstrip("/") != (profile.base_url or "").rstrip("/")):
|
||||
return [], False
|
||||
generic = (profile.base_url or "").rstrip("/") + "/models"
|
||||
own_endpoint = bool(profile.models_url) and profile.models_url.rstrip("/") != generic
|
||||
if not own_endpoint and type(profile).fetch_models is ProviderProfile.fetch_models:
|
||||
@@ -618,7 +622,7 @@ def _validate_live_listing(req: _Request) -> Optional[dict[str, Any]]:
|
||||
against that catalog (``provider_model_ids`` — the picker's list) before the generic listing."""
|
||||
from hermes_cli import models as _m
|
||||
|
||||
catalog, authoritative = _profile_catalog(req.normalized)
|
||||
catalog, authoritative = _profile_catalog(req.normalized, req.base_url)
|
||||
if catalog:
|
||||
match = _match_in_catalog(req.lookup, catalog, suggest_query=req.requested)
|
||||
if match.exact:
|
||||
|
||||
@@ -534,6 +534,13 @@ def _pool_entry_mode_and_url(provider, entry, model_cfg, effective_model, base_u
|
||||
# only when the pool row still carries the canonical URL).
|
||||
if base_url in ("", default_url):
|
||||
base_url = _config_base_url_for_provider(model_cfg, provider) or base_url
|
||||
if provider == "xai":
|
||||
# Env-seeded rows keep the registry host. model.base_url is the relay
|
||||
# override, and only while the row is still that host — an explicit
|
||||
# per-credential endpoint stays authoritative (#121347).
|
||||
canonical = (PROVIDER_REGISTRY["xai"].inference_base_url or "").rstrip("/")
|
||||
if base_url.rstrip("/") in ("", canonical):
|
||||
base_url = _config_base_url_for_provider(model_cfg, provider) or base_url
|
||||
return api_mode, base_url or (default_url() if callable(default_url) else default_url)
|
||||
if provider == "anthropic":
|
||||
return "anthropic_messages", _anthropic_cfg_base_url(model_cfg) or base_url or _ANTHROPIC_DEFAULT_BASE_URL
|
||||
|
||||
96
tests/agent/test_fallback_entry_base_url.py
Normal file
96
tests/agent/test_fallback_entry_base_url.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""A ``fallback_providers`` entry's ``base_url`` must reach the client (#121359).
|
||||
|
||||
``try_activate_fallback`` forwards the entry's ``base_url`` to
|
||||
``resolve_provider_client(explicit_base_url=...)``. Every registry API-key provider honours
|
||||
that override, but the two providers with their own resolver branches — ``anthropic`` and
|
||||
``openrouter`` — dropped it and built the client on the vendor's canonical host, sending the
|
||||
fallback turn (and the key) somewhere the user never configured.
|
||||
|
||||
These assert the RELATIONSHIP: given an explicit endpoint, the resolved client's base URL is
|
||||
that endpoint. No live request is made; all hosts here are loopback/``.invalid``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_env(monkeypatch):
|
||||
for key in ("OPENAI_API_KEY", "OPENAI_BASE_URL", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN",
|
||||
"ANTHROPIC_BASE_URL", "OPENROUTER_API_KEY", "OPENROUTER_BASE_URL"):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
_RELAY_ANTHROPIC = "http://127.0.0.1:9002/anthropic"
|
||||
_RELAY_OPENROUTER = "http://127.0.0.1:9002/openrouter/v1"
|
||||
|
||||
|
||||
def _client_base_url(client) -> str:
|
||||
for chain in (("base_url",), ("_real_client", "base_url"), ("_client", "base_url")):
|
||||
obj = client
|
||||
try:
|
||||
for attr in chain:
|
||||
obj = getattr(obj, attr)
|
||||
return str(obj)
|
||||
except AttributeError:
|
||||
continue
|
||||
return ""
|
||||
|
||||
|
||||
def test_anthropic_fallback_entry_base_url_is_the_resolved_endpoint():
|
||||
"""A fallback entry pointing anthropic at an Anthropic-protocol relay must be honoured."""
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
fake_anthropic = MagicMock(name="anthropic_sdk_client")
|
||||
with patch("agent.anthropic_adapter.build_anthropic_client", return_value=fake_anthropic) as mock_build:
|
||||
client, _model = resolve_provider_client(
|
||||
"anthropic", model="claude-haiku-4-5-20251001", raw_codex=True,
|
||||
explicit_base_url=_RELAY_ANTHROPIC, explicit_api_key="sk-test-not-a-real-key")
|
||||
|
||||
assert client is not None
|
||||
assert mock_build.call_args[0][1] == _RELAY_ANTHROPIC
|
||||
assert client.base_url == _RELAY_ANTHROPIC
|
||||
|
||||
|
||||
def test_anthropic_fallback_entry_refuses_a_non_anthropic_endpoint():
|
||||
"""An explicit target mismatch is REFUSED — never silently demoted to the canonical host.
|
||||
|
||||
Continuing with `base_url` reset to api.anthropic.com would send the caller's explicit
|
||||
credential to a host the caller did not ask for. No client is produced and the SDK
|
||||
builder is never reached.
|
||||
"""
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
with patch("agent.anthropic_adapter.build_anthropic_client", return_value=MagicMock()) as mock_build:
|
||||
client, _model = resolve_provider_client(
|
||||
"anthropic", model="claude-haiku-4-5-20251001", raw_codex=True,
|
||||
explicit_base_url="http://127.0.0.1:9002/openai/v1", explicit_api_key="sk-test-not-a-real-key")
|
||||
|
||||
assert client is None, "an incompatible explicit endpoint must not yield a client"
|
||||
assert mock_build.call_count == 0, "no canonical client may be constructed on refusal"
|
||||
|
||||
|
||||
def test_openrouter_fallback_entry_base_url_is_the_resolved_endpoint():
|
||||
"""A fallback entry pointing openrouter at a relay must not resolve to openrouter.ai."""
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
client, _model = resolve_provider_client(
|
||||
"openrouter", model="some/model", raw_codex=True,
|
||||
explicit_base_url=_RELAY_OPENROUTER, explicit_api_key="sk-test-not-a-real-key")
|
||||
|
||||
assert client is not None
|
||||
assert _client_base_url(client).rstrip("/") == _RELAY_OPENROUTER
|
||||
|
||||
|
||||
def test_openrouter_without_an_entry_base_url_keeps_the_configured_default():
|
||||
"""No override: resolution is unchanged (no relay invented)."""
|
||||
from agent.auxiliary_client import OPENROUTER_BASE_URL, resolve_provider_client
|
||||
|
||||
client, _model = resolve_provider_client(
|
||||
"openrouter", model="some/model", raw_codex=True, explicit_api_key="sk-test-not-a-real-key")
|
||||
|
||||
assert client is not None
|
||||
assert _client_base_url(client).rstrip("/") == OPENROUTER_BASE_URL.rstrip("/")
|
||||
@@ -833,6 +833,25 @@ class TestProfileCatalogAuthoritative:
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is True
|
||||
|
||||
def test_nebius_relay_base_url_validates_against_relay_listing(self, monkeypatch):
|
||||
"""A configured relay base URL must decide Nebius Token Factory validation (#121388)."""
|
||||
relay_base_url = "https://relay.example.invalid/v1"
|
||||
calls = []
|
||||
|
||||
def fetch_relay_models(_api_key, base_url, **_kwargs):
|
||||
calls.append(base_url)
|
||||
return ["relay-only/model"] if base_url == relay_base_url else ["canonical-only/model"]
|
||||
|
||||
monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda _provider: ["canonical-only/model"])
|
||||
monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch_relay_models)
|
||||
|
||||
result = validate_requested_model(
|
||||
"relay-only/model", "nebius-token-factory", api_key="k", base_url=relay_base_url)
|
||||
|
||||
assert result["accepted"] is True
|
||||
assert result["recognized"] is True
|
||||
assert calls == [relay_base_url]
|
||||
|
||||
|
||||
# -- validate — whitespace in self-hosted / user-configured ids --------------
|
||||
|
||||
|
||||
113
tests/hermes_cli/test_models_relay_base_url.py
Normal file
113
tests/hermes_cli/test_models_relay_base_url.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""`model.base_url` relays must be probed instead of the vendor's canonical host (#121387)."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.models as models
|
||||
|
||||
|
||||
class _RecordingProfile:
|
||||
auth_type = "api_key"
|
||||
fallback_models = []
|
||||
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def fetch_models(self, api_key=None, base_url=None):
|
||||
self.calls.append((api_key, base_url))
|
||||
return ["relay-only-model"]
|
||||
|
||||
|
||||
def test_relay_base_url_is_probed_for_configured_provider(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"_get_model_config_dict",
|
||||
lambda: {
|
||||
"provider": "deepseek",
|
||||
"base_url": "http://127.0.0.1:9001/deepseek/v1",
|
||||
},
|
||||
)
|
||||
profile = _RecordingProfile()
|
||||
import providers
|
||||
|
||||
monkeypatch.setattr(providers, "get_provider_profile", lambda name: profile)
|
||||
monkeypatch.setattr(models, "_api_key_credentials", lambda name: (None, None))
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"_PROVIDER_CATALOG_FETCHERS",
|
||||
{
|
||||
"deepseek": lambda n, f: (_ for _ in ()).throw(
|
||||
AssertionError("canonical host touched")
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
assert models.provider_model_ids("deepseek", force_refresh=True) == [
|
||||
"relay-only-model"
|
||||
]
|
||||
assert profile.calls == [(None, "http://127.0.0.1:9001/deepseek/v1")]
|
||||
|
||||
|
||||
def test_base_url_for_a_different_provider_is_ignored(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"_get_model_config_dict",
|
||||
lambda: {
|
||||
"provider": "deepseek",
|
||||
"base_url": "http://127.0.0.1:9001/deepseek/v1",
|
||||
},
|
||||
)
|
||||
assert models._configured_relay_base_url("openai") == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"probe_result",
|
||||
[
|
||||
pytest.param("raise", id="relay-hangs-or-errors"),
|
||||
pytest.param([], id="relay-404-empty-catalog"),
|
||||
],
|
||||
)
|
||||
def test_a_failed_relay_probe_never_falls_back_to_the_vendor_host(monkeypatch, probe_result):
|
||||
"""Egress sentinel: a configured relay is TERMINAL for live catalog egress.
|
||||
|
||||
A relay that 404s, hangs or returns nothing must degrade to the LOCAL curated list.
|
||||
Falling through to the canonical fetcher would send the provider credential to exactly
|
||||
the vendor host the user routed away from, recreating #121387 on the failure path.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
models,
|
||||
"_get_model_config_dict",
|
||||
lambda: {
|
||||
"provider": "deepseek",
|
||||
"base_url": "http://127.0.0.1:9001/deepseek/v1",
|
||||
},
|
||||
)
|
||||
|
||||
def _probe(**kwargs):
|
||||
if probe_result == "raise":
|
||||
raise RuntimeError("relay down")
|
||||
return probe_result
|
||||
|
||||
import providers
|
||||
|
||||
monkeypatch.setattr(
|
||||
providers,
|
||||
"get_provider_profile",
|
||||
lambda name: SimpleNamespace(
|
||||
auth_type="api_key", fetch_models=_probe, fallback_models=[]
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(models, "_api_key_credentials", lambda name: (None, None))
|
||||
|
||||
# Sentinels on EVERY live-egress seam below the relay probe.
|
||||
def _egress(*a, **k):
|
||||
raise AssertionError("vendor host touched after a failed relay probe")
|
||||
|
||||
monkeypatch.setattr(models, "_PROVIDER_CATALOG_FETCHERS", {"deepseek": _egress})
|
||||
monkeypatch.setattr(models, "_profile_live_catalog", _egress)
|
||||
monkeypatch.setattr(models, "_merge_with_models_dev", _egress)
|
||||
monkeypatch.setattr(models, "_PROVIDER_MODELS", {"deepseek": ["curated-local"]})
|
||||
|
||||
# Degrades locally to the curated list, with no vendor egress at all.
|
||||
assert models.provider_model_ids("deepseek") == ["curated-local"]
|
||||
@@ -163,6 +163,66 @@ def test_codex_pool_honors_model_base_url(monkeypatch):
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
|
||||
|
||||
def _xai_pool(url):
|
||||
class _Entry:
|
||||
access_token = "pool-token"
|
||||
source = "env:XAI_API_KEY"
|
||||
base_url = url
|
||||
|
||||
class _Pool:
|
||||
def has_credentials(self):
|
||||
return True
|
||||
|
||||
def select(self, **_kwargs):
|
||||
return _Entry()
|
||||
|
||||
return _Pool()
|
||||
|
||||
|
||||
def test_xai_pool_honors_model_base_url_when_row_is_registry_host(monkeypatch):
|
||||
"""#121347: an env-seeded xAI row keeps https://api.x.ai/v1. model.base_url is the
|
||||
relay override, same as the other API-key providers, and must not be shadowed."""
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "xai")
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: _xai_pool("https://api.x.ai/v1"))
|
||||
monkeypatch.delenv("XAI_BASE_URL", raising=False)
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {
|
||||
"provider": "xai", "default": "grok-4", "base_url": "http://127.0.0.1:8765/v1/"})
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="xai")
|
||||
|
||||
assert resolved["provider"] == "xai"
|
||||
assert resolved["api_key"] == "pool-token"
|
||||
assert resolved["base_url"] == "http://127.0.0.1:8765/v1"
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
|
||||
|
||||
def test_xai_pool_keeps_explicit_credential_endpoint(monkeypatch):
|
||||
"""A pool row that is not the registry host is an explicit endpoint and wins over model.base_url."""
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "xai")
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: _xai_pool("https://relay.example/v1"))
|
||||
monkeypatch.delenv("XAI_BASE_URL", raising=False)
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {
|
||||
"provider": "xai", "default": "grok-4", "base_url": "http://127.0.0.1:8765/v1"})
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="xai")
|
||||
|
||||
assert resolved["base_url"] == "https://relay.example/v1"
|
||||
assert resolved["api_mode"] == "codex_responses"
|
||||
|
||||
|
||||
def test_xai_pool_ignores_another_providers_base_url(monkeypatch):
|
||||
"""A stale model.base_url saved for a different provider must not receive the xAI key."""
|
||||
monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "xai")
|
||||
monkeypatch.setattr(rp, "load_pool", lambda provider: _xai_pool("https://api.x.ai/v1"))
|
||||
monkeypatch.delenv("XAI_BASE_URL", raising=False)
|
||||
monkeypatch.setattr(rp, "_get_model_config", lambda: {
|
||||
"provider": "deepseek", "default": "deepseek-v4-pro", "base_url": "http://127.0.0.1:8765/v1"})
|
||||
|
||||
resolved = rp.resolve_runtime_provider(requested="xai")
|
||||
|
||||
assert resolved["base_url"] == "https://api.x.ai/v1"
|
||||
|
||||
|
||||
class TestCustomProviderPoolLoopbackNoKeyExemption:
|
||||
"""Regression for issue #86864: legacy custom_providers configs often
|
||||
used short/placeholder api_keys ('123', 'm') for local no-auth
|
||||
|
||||
Reference in New Issue
Block a user