From 65f03bf7c04208e899bb4f2e08c95860164ac1cf Mon Sep 17 00:00:00 2001 From: doramirdor Date: Wed, 4 Nov 2026 20:23:28 -0500 Subject: [PATCH] fix(nadir): scope NADIR_API_KEY on endpoint-inferred calls A call with no provider prefix and api_base set to the hosted Nadir API (e.g. model="bucket") was inferred as nadir by get_llm_provider's endpoint matching, which loaded NADIR_API_KEY unconditionally. With NADIR_API_BASE pointing at a self-hosted Nadir, that internal key was sent to the hosted API, bypassing the base scoping the explicit nadir branch and NadirDecisionsConfig.resolve_connection apply. The inference branch now loads the env key only when the configured base (NADIR_API_BASE, else the hosted default) is the hosted API. This covers both the chat and Decisions paths, which share the inference. Adds an asystemone integration test for both inferred cases. Co-Authored-By: Claude Opus 5.5 --- .../get_llm_provider_logic.py | 8 ++++++- .../llms/nadir/decisions/transformation.py | 5 +++-- tests/unit/decisions/test_main.py | 22 +++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 1ffb5e2f8e5..9c98a12b776 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -315,8 +315,14 @@ def get_llm_provider( dynamic_api_key = get_secret_str("CEREBRAS_API_KEY") elif endpoint == NADIR_DEFAULT_API_BASE: custom_llm_provider = "nadir" # rebind-ok: mirrors sibling endpoint branches + # NADIR_API_KEY belongs to NADIR_API_BASE, the same scoping as the explicit nadir branch: + # it reaches the hosted API only when that is the configured base. dynamic_api_key = ( - get_secret_str("NADIR_API_KEY") if api_base.lower().startswith("https://") else None + get_secret_str("NADIR_API_KEY") + if api_base.lower().startswith("https://") + and (get_secret_str("NADIR_API_BASE") or NADIR_DEFAULT_API_BASE).rstrip("/") + == NADIR_DEFAULT_API_BASE + else None ) elif endpoint == "https://inference.baseten.co/v1": custom_llm_provider = "baseten" diff --git a/litellm/llms/nadir/decisions/transformation.py b/litellm/llms/nadir/decisions/transformation.py index 3c485069fec..41f52338bd0 100644 --- a/litellm/llms/nadir/decisions/transformation.py +++ b/litellm/llms/nadir/decisions/transformation.py @@ -142,8 +142,9 @@ class NadirDecisionsConfig(BaseDecisionsConfig): """Nadir as a Decisions provider: ``/v1/bucket`` answers a single simple/medium/complex choice question. Nadir is a tier classifier, not a general judge, so any other question set is rejected before a call. - ``NADIR_API_KEY`` is only sent to ``NADIR_API_BASE`` or the hosted API, the same scoping as the ``nadir`` - chat provider; with no key the call is anonymous, which Nadir serves on a per-IP rate-limited tier. + ``NADIR_API_KEY`` is only sent to ``NADIR_API_BASE`` (the hosted API when unset), whether the provider is named + or inferred from the base, the same scoping as the ``nadir`` chat provider; with no key the call is anonymous, + which Nadir serves on a per-IP rate-limited tier. """ path = "/v1/bucket" diff --git a/tests/unit/decisions/test_main.py b/tests/unit/decisions/test_main.py index d242c16fc1a..831468828fe 100644 --- a/tests/unit/decisions/test_main.py +++ b/tests/unit/decisions/test_main.py @@ -727,6 +727,28 @@ async def test_nadir_answers_a_tier_question_from_its_bucket_verdict_and_keeps_i assert "authorization" not in elsewhere.calls.last.request.headers +@pytest.mark.asyncio +async def test_nadir_inferred_from_the_hosted_base_sends_only_a_hosted_key( + monkeypatch: pytest.MonkeyPatch, respx_mock: respx.MockRouter +) -> None: + monkeypatch.setenv("NADIR_API_KEY", "ndr-env") + verdict: Final = {"routing_tier": "simple", "confidence": 0.8} + hosted: Final = respx_mock.post("https://api.getnadir.com/v1/bucket").respond(json=verdict) + questions: Final = {"tier": {"type": "choice", "criteria": {"simple": None, "medium": None, "complex": None}}} + + monkeypatch.delenv("NADIR_API_BASE", raising=False) + _ = await litellm.asystemone( + model="bucket", state="hi", questions=questions, api_base="https://api.getnadir.com/v1" + ) + monkeypatch.setenv("NADIR_API_BASE", "https://nadir.internal/v1") + _ = await litellm.asystemone( + model="bucket", state="hi", questions=questions, api_base="https://api.getnadir.com/v1" + ) + + assert hosted.calls[0].request.headers["authorization"] == "Bearer ndr-env" + assert "authorization" not in hosted.calls[1].request.headers + + @pytest.mark.asyncio async def test_nadir_refuses_a_question_it_cannot_answer_before_any_call(respx_mock: respx.MockRouter) -> None: upstream: Final = respx_mock.post("https://api.getnadir.com/v1/bucket")