diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index ab6d440c66..867bc6879d 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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) diff --git a/contributors/emails/ahisblessed@users.noreply.github.com b/contributors/emails/ahisblessed@users.noreply.github.com new file mode 100644 index 0000000000..f8dac5ea2d --- /dev/null +++ b/contributors/emails/ahisblessed@users.noreply.github.com @@ -0,0 +1,2 @@ +ahisblessed +# PR #121591 salvage (/model picker probes model.base_url) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 74c4189fe0..eeeeebb181 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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; diff --git a/hermes_cli/models_validate.py b/hermes_cli/models_validate.py index 662a860f57..6dbcf4a424 100644 --- a/hermes_cli/models_validate.py +++ b/hermes_cli/models_validate.py @@ -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: diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index cf149c3b7f..0bf71e302d 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -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 diff --git a/tests/agent/test_fallback_entry_base_url.py b/tests/agent/test_fallback_entry_base_url.py new file mode 100644 index 0000000000..41a8825a3d --- /dev/null +++ b/tests/agent/test_fallback_entry_base_url.py @@ -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("/") diff --git a/tests/hermes_cli/test_model_validation.py b/tests/hermes_cli/test_model_validation.py index 1c31d8aae2..1cb8120232 100644 --- a/tests/hermes_cli/test_model_validation.py +++ b/tests/hermes_cli/test_model_validation.py @@ -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 -------------- diff --git a/tests/hermes_cli/test_models_relay_base_url.py b/tests/hermes_cli/test_models_relay_base_url.py new file mode 100644 index 0000000000..8c14b35c9a --- /dev/null +++ b/tests/hermes_cli/test_models_relay_base_url.py @@ -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"] diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index caae54a521..396defd46f 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -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