mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
feat(providers): add Nadir intelligent-router provider (nadir/auto)
Nadir (https://getnadir.com) is an OpenAI-compatible intelligent router. A single virtual model, nadir/auto, is classified server-side and routed to the cheapest model that clears the quality bar. The response reports the routed model in the model field, so LiteLLM cost tracking prices the real underlying model. - litellm/llms/nadir/chat/transformation.py: NadirConfig(OpenAIGPTConfig) - register nadir across enum, provider lists, get_llm_provider, __init__, lazy imports, utils, get_supported_openai_params - add https://api.getnadir.com/v1 to openai_compatible_endpoints so base_url only usage reverse-maps to the provider - provider_endpoints_support.json entry (chat_completions only) - docs page + unit tests (11 passing) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
b3882d8e43
commit
f3e389af0e
12 changed files with 574 additions and 0 deletions
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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": (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
133
litellm/llms/nadir/chat/transformation.py
Normal file
133
litellm/llms/nadir/chat/transformation.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -4623,6 +4623,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,
|
||||
|
|
@ -6540,6 +6547,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
|
||||
|
|
@ -8184,6 +8196,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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
318
tests/test_litellm/llms/nadir/test_nadir.py
Normal file
318
tests/test_litellm/llms/nadir/test_nadir.py
Normal file
|
|
@ -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", {})
|
||||
Loading…
Add table
Reference in a new issue