mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
Merge f3e389af0e into 252c71c0b2
This commit is contained in:
commit
60bd2a4166
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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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