diff --git a/litellm/__init__.py b/litellm/__init__.py index ccfbf80369f..ca1aea4d144 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -657,6 +657,7 @@ azure_anthropic_models: Set = set() azure_text_models: Set = set() anyscale_models: Set = set() cerebras_models: Set = set() +nadir_models: Set = set() # mutable-ok: provider registry, filled from model_cost at import like every sibling provider galadriel_models: Set = set() nvidia_nim_models: Set = set() nvidia_riva_models: Set = set() @@ -889,6 +890,8 @@ def _populate_provider_model_sets(model_cost_map: Dict) -> None: anyscale_models.add(key) elif value.get("litellm_provider") == "cerebras": cerebras_models.add(key) + elif value.get("litellm_provider") == "nadir": + nadir_models.add(key) elif value.get("litellm_provider") == "galadriel": galadriel_models.add(key) elif value.get("litellm_provider") == "nvidia_nim": @@ -1077,6 +1080,7 @@ model_list = list( | azure_anthropic_models | anyscale_models | cerebras_models + | nadir_models | galadriel_models | nvidia_nim_models | nvidia_riva_models @@ -1183,6 +1187,7 @@ def _build_models_by_provider() -> dict: "azure_text": azure_text_models, "anyscale": anyscale_models, "cerebras": cerebras_models, + "nadir": nadir_models, "galadriel": galadriel_models, "nvidia_nim": nvidia_nim_models, "nvidia_riva": nvidia_riva_models, @@ -1956,6 +1961,7 @@ if TYPE_CHECKING: FeatherlessAIConfig as FeatherlessAIConfig, ) from .llms.cerebras.chat import CerebrasConfig as CerebrasConfig + from .llms.nadir.chat.transformation import NadirConfig as NadirConfig from .llms.baseten.chat import BasetenConfig as BasetenConfig from .llms.sambanova.chat import SambanovaConfig as SambanovaConfig from .llms.sambanova.embedding.transformation import ( diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index dc323c8cc15..4126cc81d95 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -260,6 +260,7 @@ LLM_CONFIG_NAMES: Final = ( "NvidiaNimEmbeddingConfig", "FeatherlessAIConfig", "CerebrasConfig", + "NadirConfig", "BasetenConfig", "SambanovaConfig", "SambaNovaEmbeddingConfig", @@ -1033,6 +1034,7 @@ _LLM_CONFIGS_IMPORT_MAP: Final = { "FeatherlessAIConfig", ), "CerebrasConfig": (".llms.cerebras.chat", "CerebrasConfig"), + "NadirConfig": (".llms.nadir.chat.transformation", "NadirConfig"), "BasetenConfig": (".llms.baseten.chat", "BasetenConfig"), "SambanovaConfig": (".llms.sambanova.chat", "SambanovaConfig"), "SambaNovaEmbeddingConfig": ( diff --git a/litellm/constants.py b/litellm/constants.py index 5f8fa203b37..345a4692e9c 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -667,6 +667,7 @@ LITELLM_CHAT_PROVIDERS: Final = [ "gigachat", "nvidia_nim", "cerebras", + "nadir", "baseten", "ai21_chat", "volcengine", @@ -859,6 +860,7 @@ openai_compatible_endpoints: Final[list] = [ "codestral.mistral.ai/v1/fim/completions", "api.groq.com/openai/v1", "https://integrate.api.nvidia.com/v1", + "https://api.getnadir.com/v1", "api.deepseek.com/v1", "api.together.ai/v1", "api.together.xyz/v1", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 02681d8b499..9a5c3191df1 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -263,6 +263,9 @@ def get_llm_provider( elif endpoint == "https://api.cerebras.ai/v1": custom_llm_provider = "cerebras" dynamic_api_key = get_secret_str("CEREBRAS_API_KEY") + elif endpoint == "https://api.getnadir.com/v1": + custom_llm_provider = "nadir" # rebind-ok: mirrors sibling endpoint branches + dynamic_api_key = get_secret_str("NADIR_API_KEY") elif endpoint == "https://inference.baseten.co/v1": custom_llm_provider = "baseten" dynamic_api_key = get_secret_str("BASETEN_API_KEY") @@ -628,6 +631,18 @@ def _get_openai_compatible_provider_info( elif custom_llm_provider == "cerebras": api_base = api_base or get_secret("CEREBRAS_API_BASE") or "https://api.cerebras.ai/v1" dynamic_api_key = api_key or get_secret_str("CEREBRAS_API_KEY") + elif custom_llm_provider == "nadir": + # Bind the server-side NADIR_API_KEY to the trusted Nadir endpoint. If a + # caller directs the request at a custom api_base, do NOT fall back to + # the env key: forwarding the server's key as a Bearer token to a + # caller-controlled host is a credential-exfiltration risk. Such + # overrides must supply their own key. + default_nadir_base: Final = get_secret_str("NADIR_API_BASE") or "https://api.getnadir.com/v1" + caller_base: Final = api_base + api_base = api_base or default_nadir_base # rebind-ok: mirrors sibling provider branches + trusted_base: Final = caller_base is None or caller_base.rstrip("/") == default_nadir_base.rstrip("/") + env_key: Final = get_secret_str("NADIR_API_KEY") if trusted_base else None + dynamic_api_key = api_key or env_key # rebind-ok: mirrors sibling provider branches elif custom_llm_provider == "baseten": # Use BasetenConfig to determine the appropriate API base URL if api_base is None: diff --git a/litellm/litellm_core_utils/get_supported_openai_params.py b/litellm/litellm_core_utils/get_supported_openai_params.py index 915a03025d9..10be83a4e55 100644 --- a/litellm/litellm_core_utils/get_supported_openai_params.py +++ b/litellm/litellm_core_utils/get_supported_openai_params.py @@ -93,6 +93,8 @@ def get_supported_openai_params( return litellm.nvidiaNimEmbeddingConfig.get_supported_openai_params() elif custom_llm_provider == "cerebras": return litellm.CerebrasConfig().get_supported_openai_params(model=model) + elif custom_llm_provider == "nadir": + return litellm.NadirConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "baseten": return litellm.BasetenConfig().get_supported_openai_params(model=model) elif custom_llm_provider == "xai": diff --git a/litellm/llms/nadir/chat/transformation.py b/litellm/llms/nadir/chat/transformation.py new file mode 100644 index 00000000000..7dbc7f55872 --- /dev/null +++ b/litellm/llms/nadir/chat/transformation.py @@ -0,0 +1,133 @@ +""" +Nadir Chat Completions API + +Nadir (https://getnadir.com) is an intelligent LLM router. A single virtual +model, ``nadir/auto``, classifies each request by complexity and routes it to +the cheapest model that clears the quality bar (e.g. Haiku for simple prompts, +Sonnet for mid, Opus for complex), then returns an OpenAI-compatible response. + +The endpoint speaks the OpenAI ``/v1/chat/completions`` dialect, so no request +translation is required. Nadir accepts the key as a Bearer token, so the +standard OpenAI-compatible transport works unchanged. + +Cost attribution: the routed model name belongs to the underlying vendor, so it +does not resolve against a ``nadir/*`` pricing entry. Nadir returns the +authoritative cost it computed for the call, and ``transform_response`` below +surfaces it the same way the OpenRouter provider does. This is also why Nadir +has its own dispatch branch in ``main.py`` instead of riding the generic +OpenAI-compatible path, which never calls ``transform_response``. +""" + +from typing import Final + +import httpx + +from litellm.llms.openai.chat.gpt_transformation import OpenAIGPTConfig +from litellm.types.llms.openai import AllMessageValues +from litellm.types.utils import ModelResponse + +# The OpenAI params Nadir's request schema actually accepts. Anything outside +# this set is dropped by the router rather than forwarded to the chosen model, +# so advertising more would be advertising a silent no-op. ``extra_headers`` +# and ``max_retries`` are handled by the LiteLLM transport, not sent in the body. +_SUPPORTED_OPENAI_PARAMS: Final = ( + "extra_headers", + "frequency_penalty", + "max_retries", + "max_tokens", + "presence_penalty", + "response_format", + "stream", + "temperature", + "top_p", +) + + +class NadirConfig(OpenAIGPTConfig): + """ + Reference: https://getnadir.com/docs + + Nadir is OpenAI-compatible, so parameter mapping is inherited from + ``OpenAIGPTConfig`` unchanged. ``model`` is a virtual router alias + (``auto``); the concrete model is chosen server-side per request. + """ + + @classmethod + def get_config(cls) -> dict: # mutable-ok: return type fixed by the base interface + return super().get_config() + + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: return type fixed by the base interface + """ + Only the params Nadir's request schema actually accepts. + + Nadir speaks the OpenAI dialect but validates into its own request + model, and anything outside that model is dropped rather than + forwarded. Inheriting the full OpenAI param set would therefore + advertise support that silently does nothing, so the list below is + restricted to what the endpoint honors. ``extra_headers`` and + ``max_retries`` are handled by the LiteLLM transport rather than sent + in the body, so they stay. + + Notably absent: ``tools`` / ``tool_choice`` / ``functions``. Function + calling is not part of the router's request schema today. + """ + return list(_SUPPORTED_OPENAI_PARAMS) # mutable-ok: the base interface returns a list + + def _get_openai_compatible_provider_info( + self, api_base: "str | None", api_key: "str | None" + ) -> "tuple[str | None, str | None]": + resolved_base: Final = api_base or "https://api.getnadir.com/v1" + return resolved_base, api_key + + def transform_response( + self, + model: str, + raw_response: httpx.Response, + model_response: ModelResponse, + logging_obj: object, + request_data: dict, # mutable-ok: signature fixed by the base interface + messages: list[AllMessageValues], # mutable-ok: signature fixed by the base interface + optional_params: dict, # mutable-ok: signature fixed by the base interface + litellm_params: dict, # mutable-ok: signature fixed by the base interface + encoding: object, + api_key: str | None = None, + json_mode: bool | None = None, + ) -> ModelResponse: + """ + Standard OpenAI response handling, plus Nadir's own cost. + + The routed model (``claude-haiku-4-5``, say) belongs to the underlying + vendor, so it has no ``nadir/*`` pricing entry and the shared cost + calculator cannot price it. Nadir already computes the cost of the call + it actually made, so pass that through as the provider-reported cost + rather than mirroring every vendor's price list under this provider. + Same mechanism the OpenRouter provider uses. + """ + transformed: Final = super().transform_response( + model=model, + raw_response=raw_response, + model_response=model_response, + logging_obj=logging_obj, + request_data=request_data, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + api_key=api_key, + json_mode=json_mode, + ) + + try: + cost: Final = raw_response.json()["nadir_metadata"]["cost"]["total_cost_usd"] + if cost is not None: + hidden: Final = transformed._hidden_params # pyright: ignore[reportPrivateUsage] # sole hidden-params channel + if "additional_headers" not in hidden: + hidden["additional_headers"] = {} # mutable-ok: the header bag the cost calculator reads + hidden["additional_headers"]["llm_provider-x-litellm-response-cost"] = float(cost) + except (ValueError, KeyError, TypeError): + # Best-effort: a body that is not JSON, or is missing the cost + # keys, is still a valid completion. Narrow rather than blind so a + # genuine bug in here is not swallowed. + pass + + return transformed diff --git a/litellm/main.py b/litellm/main.py index 17edafcdfca..7f797a60c09 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -3465,6 +3465,50 @@ def _complete_openrouter(ctx: _CompletionDispatchContext) -> _CompletionDispatch return response +def _complete_nadir(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult: + """ + Nadir has its own dispatch branch rather than riding the generic + OpenAI-compatible path, for the same reason OpenRouter does: the shared + OpenAI SDK handler never calls ``provider_config.transform_response``, and + Nadir needs it to report the cost of the model it actually routed to. The + routed model is a vendor name with no ``nadir/*`` pricing entry, so without + that hook every call records 0.0 spend. + + ``api_key`` deliberately does NOT fall back to the environment here. + ``get_llm_provider`` already binds ``NADIR_API_KEY`` to the trusted Nadir + endpoint and withholds it when the caller points at their own ``api_base``; + re-reading the env var at this layer would undo that. + """ + api_base: Final = ( + ctx.api_base or litellm.api_base or get_secret_str("NADIR_API_BASE") or "https://api.getnadir.com/v1" + ) + api_key: Final = ctx.api_key or litellm.api_key + + ## COMPLETION CALL + response: Final = base_llm_http_handler.completion( + model=ctx.model, + stream=ctx.stream, + messages=ctx.messages, + acompletion=ctx.acompletion, + api_base=api_base, + model_response=ctx.model_response, + optional_params=ctx.optional_params, + litellm_params=ctx.litellm_params, + shared_session=ctx.shared_session, + custom_llm_provider="nadir", + timeout=ctx.timeout, + headers=ctx.headers or litellm.headers, + encoding=_get_encoding(), + api_key=api_key, + logging_obj=ctx.logging, + client=ctx.client, + ) + ## LOGGING + ctx.logging.post_call(input=ctx.messages, api_key=api_key, original_response=response) + + return response + + def _complete_vercel_ai_gateway( ctx: _CompletionDispatchContext, ) -> _CompletionDispatchResult: @@ -5810,6 +5854,8 @@ def completion( response = _complete_datarobot(_dispatch_ctx) elif custom_llm_provider == "openrouter": response = _complete_openrouter(_dispatch_ctx) + elif custom_llm_provider == "nadir": + response = _complete_nadir(_dispatch_ctx) # rebind-ok: mirrors sibling provider branches elif custom_llm_provider == "vercel_ai_gateway": response = _complete_vercel_ai_gateway(_dispatch_ctx) elif custom_llm_provider == "palm": diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index cd781abee26..2ad1a02947b 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2505,6 +2505,24 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "provider": "Nadir", + "provider_display_name": "Nadir", + "litellm_provider": "nadir", + "credential_fields": [ + { + "key": "api_key", + "label": "API Key", + "placeholder": null, + "tooltip": null, + "required": true, + "field_type": "password", + "options": null, + "default_value": null + } + ], + "default_model_placeholder": "nadir/auto" + }, { "provider": "Oracle", "provider_display_name": "Oracle Cloud Infrastructure (OCI)", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 00c55b35182..8902122322a 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3949,6 +3949,7 @@ class LlmProviders(str, Enum): NVIDIA_RIVA = "nvidia_riva" SONIOX = "soniox" CEREBRAS = "cerebras" + NADIR = "nadir" AI21_CHAT = "ai21_chat" VOLCENGINE = "volcengine" CODESTRAL = "codestral" diff --git a/litellm/utils.py b/litellm/utils.py index 8ef55758ef1..a656e7fa201 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -4614,6 +4614,13 @@ def get_optional_params( model=model, drop_params=bool(drop_params), ) + elif custom_llm_provider == "nadir": + optional_params = litellm.NadirConfig().map_openai_params( # rebind-ok: same optional_params rebinding every sibling provider branch does + non_default_params=non_default_params, + optional_params=optional_params, + model=model, + drop_params=(drop_params if drop_params is not None and isinstance(drop_params, bool) else False), + ) elif custom_llm_provider == "xai": optional_params = litellm.XAIChatConfig().map_openai_params( model=model, @@ -6531,6 +6538,11 @@ def validate_environment( keys_in_environment = True else: missing_keys.append("CEREBRAS_API_KEY") + elif custom_llm_provider == "nadir": + if "NADIR_API_KEY" in os.environ: + keys_in_environment = True # rebind-ok: same flag rebinding every sibling provider branch does + else: + missing_keys.append("NADIR_API_KEY") elif custom_llm_provider == "baseten": if "BASETEN_API_KEY" in os.environ: keys_in_environment = True @@ -8175,6 +8187,7 @@ class ProviderConfigManager: LlmProviders.HUGGINGFACE: (lambda: litellm.HuggingFaceChatConfig(), False), LlmProviders.TOGETHER_AI: (lambda: litellm.TogetherAIChatConfig(), False), LlmProviders.OPENROUTER: (lambda: litellm.OpenrouterConfig(), False), + LlmProviders.NADIR: (lambda: litellm.NadirConfig(), False), LlmProviders.VERCEL_AI_GATEWAY: ( lambda: litellm.VercelAIGatewayConfig(), False, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index c71f4a82a4a..f181cb5a666 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -457,6 +457,24 @@ "interactions": true } }, + "nadir": { + "display_name": "Nadir (`nadir`)", + "url": "https://docs.litellm.ai/docs/providers/nadir", + "endpoints": { + "chat_completions": true, + "messages": false, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false, + "interactions": false + } + }, "cerebras": { "display_name": "Cerebras (`cerebras`)", "url": "https://docs.litellm.ai/docs/providers/cerebras", diff --git a/tests/test_litellm/llms/nadir/test_nadir.py b/tests/test_litellm/llms/nadir/test_nadir.py new file mode 100644 index 00000000000..9f7b346abb2 --- /dev/null +++ b/tests/test_litellm/llms/nadir/test_nadir.py @@ -0,0 +1,318 @@ +""" +Unit tests for the Nadir provider (https://getnadir.com). + +Nadir is an OpenAI-compatible intelligent router: the virtual model +``nadir/auto`` is classified server-side and routed to the cheapest model that +clears the quality bar. These tests cover provider resolution, credential +handling, and config wiring without making a live API call. +""" + +from unittest.mock import MagicMock, patch + +import litellm +from litellm import get_llm_provider +from litellm.types.utils import LlmProviders + + +class TestNadirProviderResolution: + def test_model_prefix_resolves_to_nadir(self): + model, provider, dynamic_api_key, api_base = get_llm_provider(model="nadir/auto", api_key="sk-test") + assert provider == "nadir" + # The nadir/ prefix is stripped; the virtual router alias is sent upstream. + assert model == "auto" + + def test_default_api_base(self): + _, _, _, api_base = get_llm_provider(model="nadir/auto", api_key="sk-test") + assert api_base == "https://api.getnadir.com/v1" + + def test_api_base_override(self): + _, _, _, api_base = get_llm_provider( + model="nadir/auto", + api_key="sk-test", + api_base="https://gateway.internal/v1", + ) + assert api_base == "https://gateway.internal/v1" + + def test_api_key_from_env(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-live-env") + _, _, dynamic_api_key, _ = get_llm_provider(model="nadir/auto") + assert dynamic_api_key == "sk-live-env" + + def test_endpoint_reverse_maps_to_nadir(self): + # A caller passing only the Nadir base_url (no nadir/ prefix) is still + # identified as the nadir provider. + _, provider, _, _ = get_llm_provider( + model="auto", + api_base="https://api.getnadir.com/v1", + api_key="sk-test", + ) + assert provider == "nadir" + + +class TestNadirCredentialScoping: + """The server NADIR_API_KEY must never be forwarded to a caller-supplied host.""" + + def test_env_key_used_for_default_endpoint(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-server-secret") + _, _, dynamic_api_key, _ = get_llm_provider(model="nadir/auto") + assert dynamic_api_key == "sk-server-secret" + + def test_env_key_used_when_base_matches_default(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-server-secret") + _, _, dynamic_api_key, _ = get_llm_provider(model="nadir/auto", api_base="https://api.getnadir.com/v1/") + assert dynamic_api_key == "sk-server-secret" + + def test_env_key_NOT_leaked_to_custom_base(self, monkeypatch): + # A caller-controlled api_base without a caller key must NOT receive + # the server's env credential. + monkeypatch.setenv("NADIR_API_KEY", "sk-server-secret") + _, _, dynamic_api_key, _ = get_llm_provider(model="nadir/auto", api_base="https://attacker.example/v1") + assert dynamic_api_key is None + + def test_caller_key_used_for_custom_base(self, monkeypatch): + # A caller directing at a custom base may still supply their own key. + monkeypatch.setenv("NADIR_API_KEY", "sk-server-secret") + _, _, dynamic_api_key, _ = get_llm_provider( + model="nadir/auto", + api_base="https://self-hosted.internal/v1", + api_key="sk-caller-own", + ) + assert dynamic_api_key == "sk-caller-own" + + def test_env_key_used_for_operator_configured_base(self, monkeypatch): + # An operator-configured NADIR_API_BASE is trusted; passing that same + # base explicitly still uses the env key. + monkeypatch.setenv("NADIR_API_KEY", "sk-server-secret") + monkeypatch.setenv("NADIR_API_BASE", "https://nadir.mycorp.internal/v1") + _, _, dynamic_api_key, _ = get_llm_provider(model="nadir/auto", api_base="https://nadir.mycorp.internal/v1") + assert dynamic_api_key == "sk-server-secret" + + +class TestNadirRegistration: + def test_enum_member(self): + assert LlmProviders.NADIR.value == "nadir" + + def test_config_loads(self): + assert litellm.NadirConfig().__class__.__name__ == "NadirConfig" + + def test_supported_params_nonempty(self): + params = litellm.NadirConfig().get_supported_openai_params(model="auto") + assert isinstance(params, list) and len(params) > 0 + # Streaming is advertised; real token-by-token requires the SSE-enabled + # Nadir backend, otherwise stream=False returns a single completion. + assert "stream" in params + + def test_unsupported_params_are_not_advertised(self): + # Nadir validates into its own request schema and drops anything + # outside it, so the provider must not inherit OpenAI's full param + # list. Advertising these would silently no-op at request time. + params = litellm.NadirConfig().get_supported_openai_params(model="auto") + for unsupported in ( + "tools", + "tool_choice", + "functions", + "function_call", + "parallel_tool_calls", + "stop", + "seed", + "n", + "logprobs", + "stream_options", + "user", + ): + assert unsupported not in params, f"{unsupported} is not honored by Nadir" + + +class TestNadirParamMapping: + def test_get_optional_params_maps_nadir(self): + # Exercises the nadir branch in litellm.utils.get_optional_params. + params = litellm.get_optional_params( + model="auto", + custom_llm_provider="nadir", + temperature=0.5, + max_tokens=64, + ) + assert params["temperature"] == 0.5 + assert params["max_tokens"] == 64 + + def test_get_supported_openai_params_dispatcher(self): + # Exercises the nadir branch in the top-level get_supported_openai_params. + params = litellm.get_supported_openai_params(model="auto", custom_llm_provider="nadir") + assert isinstance(params, list) and "stream" in params + + +class TestNadirEnvValidation: + def test_validate_environment_detects_key(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-live-xyz") + result = litellm.validate_environment(model="nadir/auto") + assert result["keys_in_environment"] is True + + def test_validate_environment_flags_missing_key(self, monkeypatch): + monkeypatch.delenv("NADIR_API_KEY", raising=False) + result = litellm.validate_environment(model="nadir/auto") + assert "NADIR_API_KEY" in result["missing_keys"] + + +class TestNadirDispatch: + """Nadir must not ride the generic OpenAI-compatible path. + + That path never calls ``provider_config.transform_response``, so the cost + Nadir reports would be dropped and every call would record 0.0 spend. + """ + + def test_not_in_openai_compatible_providers(self): + assert "nadir" not in litellm.openai_compatible_providers + + def test_provider_config_resolves(self): + from litellm.types.utils import LlmProviders + from litellm.utils import ProviderConfigManager + + config = ProviderConfigManager.get_provider_chat_config(model="auto", provider=LlmProviders.NADIR) + assert config.__class__.__name__ == "NadirConfig" + + +class TestNadirCostAttribution: + """The routed model is a vendor name with no nadir/* pricing entry, so the + cost Nadir reports is what must reach the cost calculator.""" + + def _transform(self, payload): + import httpx + + from litellm.types.utils import ModelResponse + + raw = httpx.Response( + 200, + json=payload, + request=httpx.Request("POST", "https://api.getnadir.com/v1/chat/completions"), + ) + return litellm.NadirConfig().transform_response( + model="auto", + raw_response=raw, + model_response=ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + def _payload(self, **extra): + base = { + "id": "req-1", + "object": "chat.completion", + "created": 0, + "model": "claude-haiku-4-5", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + } + base.update(extra) + return base + + def test_reported_cost_reaches_the_cost_calculator(self): + from litellm.cost_calculator import get_response_cost_from_hidden_params + + res = self._transform(self._payload(nadir_metadata={"cost": {"total_cost_usd": 0.00123}})) + assert get_response_cost_from_hidden_params(res._hidden_params) == 0.00123 + + def test_routed_model_is_preserved(self): + # Overwriting this with "auto" would misattribute every request. + res = self._transform(self._payload(nadir_metadata={"cost": {"total_cost_usd": 0.001}})) + assert res.model == "claude-haiku-4-5" + + def test_missing_cost_does_not_fail_the_response(self): + res = self._transform(self._payload(nadir_metadata={})) + assert res.choices[0].message.content == "hi" + assert "llm_provider-x-litellm-response-cost" not in res._hidden_params.get("additional_headers", {}) + + +class TestNadirCompletionDispatch: + """`_complete_nadir` is the branch that routes Nadir through the httpx + handler instead of the OpenAI SDK path. These pin its contract without a + network call.""" + + def _call(self, **kwargs): + from litellm.types.utils import ModelResponse + + captured = {} + + def fake_completion(**call_kwargs): + captured.update(call_kwargs) + return ModelResponse() + + with patch( # test-quality-ok: these tests assert the dispatch wiring itself (nadir must reach base_llm_http_handler, and which credentials it is handed); faking HTTP would not observe that + "litellm.main.base_llm_http_handler.completion", side_effect=fake_completion + ): + litellm.completion( + model="nadir/auto", + messages=[{"role": "user", "content": "hi"}], + **kwargs, + ) + return captured + + def test_routes_through_the_http_handler_as_nadir(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-env") + captured = self._call() + assert captured["custom_llm_provider"] == "nadir" + assert captured["api_base"] == "https://api.getnadir.com/v1" + + def test_env_key_is_used_for_the_default_endpoint(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-env") + assert self._call()["api_key"] == "sk-env" + + def test_caller_key_wins(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-env") + assert self._call(api_key="sk-caller")["api_key"] == "sk-caller" + + def test_env_key_is_not_forwarded_to_a_caller_supplied_host(self, monkeypatch): + # The scoping lives in get_llm_provider; _complete_nadir must not undo + # it by re-reading NADIR_API_KEY at dispatch time. + monkeypatch.setenv("NADIR_API_KEY", "sk-env") + captured = self._call(api_base="https://attacker.example/v1") + assert captured["api_key"] != "sk-env" + assert captured["api_base"] == "https://attacker.example/v1" + + def test_custom_api_base_is_honoured(self, monkeypatch): + monkeypatch.setenv("NADIR_API_KEY", "sk-env") + captured = self._call(api_base="https://nadir.internal/v1", api_key="sk-own") + assert captured["api_base"] == "https://nadir.internal/v1" + assert captured["api_key"] == "sk-own" + + +class TestNadirConfigSurface: + def test_get_config_returns_a_mapping(self): + assert isinstance(litellm.NadirConfig.get_config(), dict) + + def test_provider_info_defaults_the_base(self): + base, key = litellm.NadirConfig()._get_openai_compatible_provider_info(None, "sk-x") + assert base == "https://api.getnadir.com/v1" + assert key == "sk-x" + + def test_provider_info_honours_an_explicit_base(self): + base, _ = litellm.NadirConfig()._get_openai_compatible_provider_info("https://nadir.internal/v1", "sk-x") + assert base == "https://nadir.internal/v1" + + def test_non_json_body_does_not_fail_the_response(self): + # Exercises the narrow except: a body that is not JSON at all. + import httpx + + from litellm.types.utils import ModelResponse + + raw = httpx.Response( + 200, + text="not json", + request=httpx.Request("POST", "https://api.getnadir.com/v1/chat/completions"), + ) + with patch.object(litellm.NadirConfig.__bases__[0], "transform_response", return_value=ModelResponse()): + res = litellm.NadirConfig().transform_response( + model="auto", + raw_response=raw, + model_response=ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + assert "llm_provider-x-litellm-response-cost" not in res._hidden_params.get("additional_headers", {})