diff --git a/litellm/__init__.py b/litellm/__init__.py index e0c7d56361c..0467a118ee5 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -644,6 +644,7 @@ dashscope_models: Set = set() moonshot_models: Set = set() publicai_models: Set = set() darkbloom_models: Set = set() +sference_models: Set = set() v0_models: Set = set() morph_models: Set = set() lambda_ai_models: Set = set() @@ -897,6 +898,8 @@ def _populate_provider_model_sets(model_cost_map: Dict) -> None: publicai_models.add(key) elif value.get("litellm_provider") == "darkbloom": darkbloom_models.add(key) + elif value.get("litellm_provider") == "sference": + sference_models.add(key) elif value.get("litellm_provider") == "v0": v0_models.add(key) elif value.get("litellm_provider") == "morph": @@ -1055,6 +1058,7 @@ model_list = list( | moonshot_models | publicai_models | darkbloom_models + | sference_models | v0_models | morph_models | lambda_ai_models @@ -1162,6 +1166,7 @@ def _build_models_by_provider() -> dict: "moonshot": moonshot_models, "publicai": publicai_models, "darkbloom": darkbloom_models, + "sference": sference_models, "v0": v0_models, "morph": morph_models, "lambda_ai": lambda_ai_models, diff --git a/litellm/constants.py b/litellm/constants.py index 3b91f23fe39..be531efc8a1 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -752,6 +752,7 @@ openai_compatible_endpoints: Final[list] = [ "https://api.libertai.io/v1", "https://pinstripes.io/v1", "https://api.meta.ai/v1", + "https://api.sference.com/v1", ] @@ -819,6 +820,7 @@ openai_compatible_providers: Final[list] = [ "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", "meta", # Meta Model API (Muse Spark) - JSON-configured provider + "sference", # sference - JSON-configured provider ] openai_text_completion_compatible_providers: Final[list] = [ # providers that support `/v1/completions` "together_ai", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index dbb40913e14..2e2dea7a297 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -349,6 +349,9 @@ def get_llm_provider( elif endpoint == "https://api.meta.ai/v1": custom_llm_provider = "meta" dynamic_api_key = get_secret_str("META_API_KEY") + elif endpoint == "https://api.sference.com/v1": + custom_llm_provider = "sference" + dynamic_api_key = api_key or get_secret_str("SFERENCE_API_KEY") if api_base is not None and not isinstance(api_base, str): raise Exception(f"api base needs to be a string. api_base={api_base}") diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 19e29bcdcb2..c1b93739e74 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -89,6 +89,11 @@ def create_config_class(provider: SimpleProviderConfig): return api_base + def _model_in_cost_map(self, model: str) -> bool: + import litellm + + return model in litellm.model_cost or f"{provider.slug}/{model}" in litellm.model_cost + def get_supported_openai_params(self, model: str) -> list: """Get supported OpenAI params, excluding tool-related params for models that don't support function calling.""" @@ -96,7 +101,9 @@ def create_config_class(provider: SimpleProviderConfig): supported_params: Final = super().get_supported_openai_params(model=model) - _supports_fc: Final = supports_function_calling(model=model, custom_llm_provider=provider.slug) + _supports_fc: Final = supports_function_calling(model=model, custom_llm_provider=provider.slug) or ( + provider.default_capabilities.get("function_calling") is True and not self._model_in_cost_map(model) + ) if not _supports_fc: tool_params: Final = [ diff --git a/litellm/llms/openai_like/json_loader.py b/litellm/llms/openai_like/json_loader.py index 38f3866cfc3..66080711fc1 100644 --- a/litellm/llms/openai_like/json_loader.py +++ b/litellm/llms/openai_like/json_loader.py @@ -22,6 +22,7 @@ class SimpleProviderConfig: self.constraints = data.get("constraints", {}) self.special_handling = data.get("special_handling", {}) self.supported_endpoints = data.get("supported_endpoints", []) + self.default_capabilities = data.get("default_capabilities", {}) class JSONProviderRegistry: diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index 164100d4194..b04d38c666d 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -37,6 +37,16 @@ "max_completion_tokens": "max_tokens" } }, + "sference": { + "base_url": "https://api.sference.com/v1", + "api_key_env": "SFERENCE_API_KEY", + "api_base_env": "SFERENCE_API_BASE", + "base_class": "openai_gpt", + "supported_endpoints": ["/v1/chat/completions", "/v1/messages"], + "default_capabilities": { + "function_calling": true + } + }, "apertis": { "base_url": "https://api.stima.tech/v1", "api_key_env": "STIMA_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d954e33da9c..3aa11342582 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -33733,6 +33733,181 @@ "supports_vision": true, "source": "https://cloud.sambanova.ai/plans/pricing" }, + "sference/Qwen/Qwen3.6-35B-A3B": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "sference", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/Qwen/Qwen3-VL-30B-A3B-Instruct": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "litellm_provider": "sference", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "sference/bottlecapai/ThinkingCap-Qwen3.6-27B": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 4e-07, + "litellm_provider": "sference", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2.6e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/deepseek-ai/DeepSeek-V4-Flash": { + "cache_read_input_token_cost": 7e-08, + "input_cost_per_token": 1.4e-07, + "litellm_provider": "sference", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/moonshotai/Kimi-K3": { + "cache_read_input_token_cost": 2.25e-07, + "input_cost_per_token": 2.25e-06, + "litellm_provider": "sference", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 1.125e-05, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/zai-org/GLM-5.2": { + "cache_read_input_token_cost": 2.6e-07, + "input_cost_per_token": 1.2e-06, + "litellm_provider": "sference", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 4.2e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, "snowflake/claude-3-5-sonnet": { "litellm_provider": "snowflake", "max_input_tokens": 200000, diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index dd7712aabca..e8c684d26fc 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -2010,6 +2010,23 @@ "interactions": true } }, + "sference": { + "display_name": "Sference (`sference`)", + "url": "https://docs.litellm.ai/docs/providers/sference", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "snowflake": { "display_name": "Snowflake (`snowflake`)", "url": "https://docs.litellm.ai/docs/providers/snowflake", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 18cf9461648..004a883537b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3706,6 +3706,7 @@ class LlmProviders(str, Enum): PINSTRIPES = "pinstripes" DARKBLOOM = "darkbloom" META = "meta" + SFERENCE = "sference" LITELLM_AGENT = "litellm_agent" CURSOR = "cursor" BEDROCK_MANTLE = "bedrock_mantle" diff --git a/litellm/utils.py b/litellm/utils.py index 911de83b785..85d8c076e71 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -6151,6 +6151,11 @@ def validate_environment( keys_in_environment = True else: missing_keys.append("TENCENT_API_KEY") + elif custom_llm_provider == "sference": + if "SFERENCE_API_KEY" in os.environ: + keys_in_environment = True + else: + missing_keys.append("SFERENCE_API_KEY") elif custom_llm_provider == "mistral": if "MISTRAL_API_KEY" in os.environ: keys_in_environment = True diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 8982b4f2565..10d4f35d863 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -33824,6 +33824,181 @@ "supports_vision": true, "source": "https://cloud.sambanova.ai/plans/pricing" }, + "sference/Qwen/Qwen3.6-35B-A3B": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 2e-07, + "litellm_provider": "sference", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 1.25e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/Qwen/Qwen3-VL-30B-A3B-Instruct": { + "cache_read_input_token_cost": 1e-07, + "input_cost_per_token": 4e-07, + "litellm_provider": "sference", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": false, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "sference/bottlecapai/ThinkingCap-Qwen3.6-27B": { + "cache_read_input_token_cost": 5e-08, + "input_cost_per_token": 4e-07, + "litellm_provider": "sference", + "max_input_tokens": 262144, + "max_output_tokens": 262144, + "max_tokens": 262144, + "mode": "chat", + "output_cost_per_token": 2.6e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/deepseek-ai/DeepSeek-V4-Flash": { + "cache_read_input_token_cost": 7e-08, + "input_cost_per_token": 1.4e-07, + "litellm_provider": "sference", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 2.8e-07, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/moonshotai/Kimi-K3": { + "cache_read_input_token_cost": 2.25e-07, + "input_cost_per_token": 2.25e-06, + "litellm_provider": "sference", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 1.125e-05, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, + "sference/zai-org/GLM-5.2": { + "cache_read_input_token_cost": 2.6e-07, + "input_cost_per_token": 1.2e-06, + "litellm_provider": "sference", + "max_input_tokens": 1048576, + "max_output_tokens": 1048576, + "max_tokens": 1048576, + "mode": "chat", + "output_cost_per_token": 4.2e-06, + "source": "https://api.sference.com/v1/models", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/messages" + ], + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": false + }, "snowflake/claude-3-5-sonnet": { "litellm_provider": "snowflake", "max_input_tokens": 200000, diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 65db63dc045..cd333bba3bf 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2244,6 +2244,23 @@ "interactions": true } }, + "sference": { + "display_name": "Sference (`sference`)", + "url": "https://docs.litellm.ai/docs/providers/sference", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": false, + "embeddings": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "a2a": false + } + }, "snowflake": { "display_name": "Snowflake (`snowflake`)", "url": "https://docs.litellm.ai/docs/providers/snowflake", diff --git a/tests/test_litellm/llms/openai_like/test_sference_provider.py b/tests/test_litellm/llms/openai_like/test_sference_provider.py new file mode 100644 index 00000000000..d9173ca85fd --- /dev/null +++ b/tests/test_litellm/llms/openai_like/test_sference_provider.py @@ -0,0 +1,393 @@ +""" +Tests for the sference provider configuration and integration. +""" + +import json +import os +from pathlib import Path + +import pytest + +import litellm + + +@pytest.fixture(scope="module", autouse=True) +def local_model_cost_map(): + """Force the bundled cost map so catalog-dependent assertions see this branch's + sference entries, then restore the prior global state.""" + original_model_cost = litellm.model_cost + previous_env = os.environ.get("LITELLM_LOCAL_MODEL_COST_MAP") + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True" + litellm.model_cost = litellm.get_model_cost_map(url="") + try: + yield + finally: + litellm.model_cost = original_model_cost + if previous_env is None: + os.environ.pop("LITELLM_LOCAL_MODEL_COST_MAP", None) + else: + os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = previous_env + + +@pytest.fixture(autouse=True) +def clear_sference_env(monkeypatch): + """Keep tests hermetic when the host exports sference env vars.""" + monkeypatch.delenv("SFERENCE_API_BASE", raising=False) + monkeypatch.delenv("SFERENCE_API_KEY", raising=False) + + +class TestSferenceProviderConfig: + def test_sference_in_provider_list(self): + from litellm import LlmProviders + + assert hasattr(LlmProviders, "SFERENCE") + assert LlmProviders.SFERENCE.value == "sference" + assert "sference" in litellm.provider_list + + def test_sference_json_config_exists(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + assert JSONProviderRegistry.exists("sference") + + sference = JSONProviderRegistry.get("sference") + assert sference is not None + assert sference.base_url == "https://api.sference.com/v1" + assert sference.api_key_env == "SFERENCE_API_KEY" + assert sference.api_base_env == "SFERENCE_API_BASE" + + def test_sference_in_openai_compatible_providers(self): + from litellm.constants import openai_compatible_providers + + assert "sference" in openai_compatible_providers + + def test_sference_provider_resolution(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="sference/Qwen/Qwen3.6-35B-A3B", + custom_llm_provider=None, + api_base=None, + api_key="sk-test", + ) + + assert model == "Qwen/Qwen3.6-35B-A3B" + assert provider == "sference" + assert api_base == "https://api.sference.com/v1" + assert api_key == "sk-test" + + def test_sference_api_base_override(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="sference/Qwen/Qwen3.6-35B-A3B", + custom_llm_provider=None, + api_base="https://custom.sference.example/v1", + api_key="sk-test", + ) + + assert provider == "sference" + assert api_base == "https://custom.sference.example/v1" + assert api_key == "sk-test" + + def test_sference_url_autodetection(self): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + model, provider, api_key, api_base = get_llm_provider( + model="Qwen/Qwen3.6-35B-A3B", + custom_llm_provider=None, + api_base="https://api.sference.com/v1", + api_key=None, + ) + assert provider == "sference" + assert api_base == "https://api.sference.com/v1" + + def test_explicit_api_key_wins_over_env(self, monkeypatch): + """In the api_base autodetection branch an explicit per-request key must + beat the host-exported SFERENCE_API_KEY, otherwise requests bill the wrong account.""" + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("SFERENCE_API_KEY", "sk-env-key") + model, provider, api_key, api_base = get_llm_provider( + model="Qwen/Qwen3.6-35B-A3B", + custom_llm_provider=None, + api_base="https://api.sference.com/v1", + api_key="sk-explicit", + ) + assert provider == "sference" + assert api_key == "sk-explicit" + + def test_sference_api_key_resolved_from_env(self, monkeypatch): + from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + monkeypatch.setenv("SFERENCE_API_KEY", "sk-env-key") + model, provider, api_key, api_base = get_llm_provider( + model="sference/Qwen/Qwen3.6-35B-A3B", + custom_llm_provider=None, + api_base=None, + api_key=None, + ) + assert provider == "sference" + assert api_key == "sk-env-key" + + def test_sference_complete_url(self): + from litellm.llms.openai_like.dynamic_config import create_config_class + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + + provider = JSONProviderRegistry.get("sference") + assert provider is not None + cfg = create_config_class(provider)() + + url = cfg.get_complete_url( + api_base=None, + api_key="sk-test", + model="Qwen/Qwen3.6-35B-A3B", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.sference.com/v1/chat/completions" + + +class TestSferenceSupportedParams: + def test_reasoning_model_supports_reasoning_effort(self): + params = litellm.get_supported_openai_params(model="Qwen/Qwen3.6-35B-A3B", custom_llm_provider="sference") + assert params is not None + assert "reasoning_effort" in params + + def test_non_reasoning_model_excludes_reasoning_effort(self): + """Qwen3-VL is listed with thinking unsupported in the sference catalog, + so it must not advertise reasoning_effort.""" + params = litellm.get_supported_openai_params( + model="Qwen/Qwen3-VL-30B-A3B-Instruct", custom_llm_provider="sference" + ) + assert params is not None + assert "reasoning_effort" not in params + + def test_tool_params_supported(self): + params = litellm.get_supported_openai_params(model="Qwen/Qwen3.6-35B-A3B", custom_llm_provider="sference") + assert params is not None + assert "tools" in params + assert "tool_choice" in params + + def test_service_tier_and_prompt_cache_key_supported(self): + params = litellm.get_supported_openai_params(model="Qwen/Qwen3.6-35B-A3B", custom_llm_provider="sference") + assert params is not None + assert "service_tier" in params + assert "prompt_cache_key" in params + + def test_reasoning_effort_and_service_tier_mapped_through(self): + cfg = litellm.ProviderConfigManager.get_provider_chat_config( + model="Qwen/Qwen3.6-35B-A3B", provider=litellm.LlmProviders.SFERENCE + ) + assert cfg is not None + mapped = cfg.map_openai_params( + non_default_params={"reasoning_effort": "high", "service_tier": "flex"}, + optional_params={}, + model="Qwen/Qwen3.6-35B-A3B", + drop_params=False, + ) + assert mapped["reasoning_effort"] == "high" + assert mapped["service_tier"] == "flex" + + +class TestSferenceCompletion: + @pytest.mark.respx() + def test_completion_request_routing(self, respx_mock, monkeypatch): + """A completion call must hit the sference chat completions endpoint with the + catalog model id (provider prefix stripped), bearer auth, and pass-through params.""" + monkeypatch.setenv("SFERENCE_API_KEY", "sk-env-key") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + + respx_mock.post("https://api.sference.com/v1/chat/completions").respond( + json={ + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1786206392, + "model": "Qwen/Qwen3.6-35B-A3B", + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": "Hello!"}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 5, "completion_tokens": 3, "total_tokens": 8}, + }, + status_code=200, + ) + + response = litellm.completion( + model="sference/Qwen/Qwen3.6-35B-A3B", + messages=[{"role": "user", "content": "hi"}], + reasoning_effort="high", + service_tier="flex", + ) + + assert len(respx_mock.calls) == 1 + request = respx_mock.calls[0].request + assert request.headers["Authorization"] == "Bearer sk-env-key" + + body = json.loads(request.content) + assert body["model"] == "Qwen/Qwen3.6-35B-A3B" + assert body["reasoning_effort"] == "high" + assert body["service_tier"] == "flex" + assert response.choices[0].message.content == "Hello!" + + +class TestSferenceValidateEnvironment: + def test_missing_api_key_reported(self): + result = litellm.validate_environment(model="sference/Qwen/Qwen3.6-35B-A3B") + assert result["keys_in_environment"] is False + assert "SFERENCE_API_KEY" in result["missing_keys"] + + def test_present_api_key_accepted(self, monkeypatch): + monkeypatch.setenv("SFERENCE_API_KEY", "sk-env-key") + result = litellm.validate_environment(model="sference/Qwen/Qwen3.6-35B-A3B") + assert result["keys_in_environment"] is True + assert result["missing_keys"] == [] + + +class TestSferenceModelsByProvider: + def test_models_by_provider_contains_catalog(self): + """Wildcard sference/* proxy deployments expand via models_by_provider.""" + litellm.add_known_models(litellm.model_cost) + + sference_models = litellm.models_by_provider.get("sference") + assert sference_models is not None + assert len(sference_models) == 6 + assert "sference/moonshotai/Kimi-K3" in sference_models + + +class TestSferenceRuntimeEndpointsMatrix: + def test_sference_in_runtime_supported_endpoints_matrix(self): + """The proxy serves litellm/provider_endpoints_support_backup.json at + GET /public/supported_endpoints, so sference must be listed there too.""" + matrix = json.loads((Path(litellm.__file__).parent / "provider_endpoints_support_backup.json").read_text()) + + assert "sference" in matrix["providers"] + endpoints = matrix["providers"]["sference"]["endpoints"] + assert endpoints["chat_completions"] is True + assert endpoints["messages"] is True + + +class TestSferenceUncatalogedModels: + def test_uncataloged_model_keeps_tool_params(self): + """BYOM/custom sference models absent from the cost map fall back to the + provider-level default capability instead of losing tool calling.""" + params = litellm.get_supported_openai_params(model="my-org/my-fine-tune", custom_llm_provider="sference") + assert params is not None + assert "tools" in params + assert "tool_choice" in params + + def test_uncataloged_model_has_no_reasoning_effort(self): + params = litellm.get_supported_openai_params(model="my-org/my-fine-tune", custom_llm_provider="sference") + assert params is not None + assert "reasoning_effort" not in params + + +class TestSferenceAnthropicMessages: + def test_messages_config_resolves(self): + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + cfg = litellm.ProviderConfigManager.get_provider_anthropic_messages_config( + model="Qwen/Qwen3.6-35B-A3B", provider=litellm.LlmProviders.SFERENCE + ) + assert isinstance(cfg, JSONProviderAnthropicMessagesConfig) + + def test_messages_complete_url(self): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + provider = JSONProviderRegistry.get("sference") + assert provider is not None + cfg = JSONProviderAnthropicMessagesConfig(provider) + + url = cfg.get_complete_url( + api_base=None, + api_key="sk-test", + model="Qwen/Qwen3.6-35B-A3B", + optional_params={}, + litellm_params={}, + ) + assert url == "https://api.sference.com/v1/messages" + + def test_messages_api_key_resolved_from_env(self, monkeypatch): + from litellm.llms.openai_like.json_loader import JSONProviderRegistry + from litellm.llms.openai_like.messages.transformation import ( + JSONProviderAnthropicMessagesConfig, + ) + + monkeypatch.setenv("SFERENCE_API_KEY", "sk-env-key") + provider = JSONProviderRegistry.get("sference") + assert provider is not None + cfg = JSONProviderAnthropicMessagesConfig(provider) + + headers, _ = cfg.validate_anthropic_messages_environment( + headers={}, + model="Qwen/Qwen3.6-35B-A3B", + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + assert headers["authorization"] == "Bearer sk-env-key" + assert headers["anthropic-version"] == "2023-06-01" + + +class TestSferenceModelInfoAndCost: + def test_model_info_from_catalog(self): + """get_model_info resolves the catalog entry; the values themselves are + pinned in test_sference_model_metadata.py.""" + info = litellm.get_model_info("sference/deepseek-ai/DeepSeek-V4-Flash") + + assert info["litellm_provider"] == "sference" + assert info["max_input_tokens"] == info["max_output_tokens"] + assert info["supports_reasoning"] is True + assert info["supports_function_calling"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_vision"] is False + + def test_get_max_tokens_from_catalog(self): + """get_max_tokens must not raise for cataloged sference models; the pinned + value is the published context ceiling.""" + assert litellm.get_max_tokens("sference/moonshotai/Kimi-K3") == 1048576 + assert litellm.get_max_tokens("sference/Qwen/Qwen3.6-35B-A3B") == 262144 + + def test_vision_model_info_from_catalog(self): + info = litellm.get_model_info("sference/Qwen/Qwen3-VL-30B-A3B-Instruct") + + assert info["litellm_provider"] == "sference" + assert info["supports_vision"] is True + assert info["supports_reasoning"] is False + + def test_cost_calculation_with_cached_tokens(self): + """Cost math must combine uncached input at input price, cached input at + cache-read price, and output at output price, all from the catalog entry.""" + from litellm import completion_cost + from litellm.types.utils import ModelResponse, PromptTokensDetails, Usage + + entry = litellm.model_cost["sference/deepseek-ai/DeepSeek-V4-Flash"] + response = ModelResponse( + model="deepseek-ai/DeepSeek-V4-Flash", + usage=Usage( + prompt_tokens=1000, + completion_tokens=500, + total_tokens=1500, + prompt_tokens_details=PromptTokensDetails(cached_tokens=200), + ), + ) + cost = completion_cost( + completion_response=response, + model="sference/deepseek-ai/DeepSeek-V4-Flash", + custom_llm_provider="sference", + ) + expected = ( + 800 * entry["input_cost_per_token"] + + 200 * entry["cache_read_input_token_cost"] + + 500 * entry["output_cost_per_token"] + ) + assert abs(cost - expected) < 1e-12 diff --git a/tests/test_litellm/test_sference_model_metadata.py b/tests/test_litellm/test_sference_model_metadata.py new file mode 100644 index 00000000000..a88df8b1b5e --- /dev/null +++ b/tests/test_litellm/test_sference_model_metadata.py @@ -0,0 +1,134 @@ +""" +Validates the sference catalog entries in model_prices_and_context_window.json +against the sference /v1/models source data, and that the bundled backup stays +in sync. +""" + +import json +from pathlib import Path + +import pytest + +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider + + +@pytest.fixture(autouse=True) +def clear_sference_env(monkeypatch): + """Keep routing assertions hermetic when the host exports sference env vars.""" + monkeypatch.delenv("SFERENCE_API_BASE", raising=False) + monkeypatch.delenv("SFERENCE_API_KEY", raising=False) + + +SFERENCE_SOURCE = "https://api.sference.com/v1/models" + +SFERENCE_MODELS = { + "sference/Qwen/Qwen3.6-35B-A3B": { + "input_cost_per_token": 2e-07, + "output_cost_per_token": 1.25e-06, + "cache_read_input_token_cost": 5e-08, + "max_input_tokens": 262144, + "supports_reasoning": True, + "supports_vision": False, + "supported_modalities": ["text"], + }, + "sference/Qwen/Qwen3-VL-30B-A3B-Instruct": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2e-06, + "cache_read_input_token_cost": 1e-07, + "max_input_tokens": 262144, + "supports_reasoning": False, + "supports_vision": True, + "supported_modalities": ["text", "image"], + }, + "sference/bottlecapai/ThinkingCap-Qwen3.6-27B": { + "input_cost_per_token": 4e-07, + "output_cost_per_token": 2.6e-06, + "cache_read_input_token_cost": 5e-08, + "max_input_tokens": 262144, + "supports_reasoning": True, + "supports_vision": False, + "supported_modalities": ["text"], + }, + "sference/deepseek-ai/DeepSeek-V4-Flash": { + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "cache_read_input_token_cost": 7e-08, + "max_input_tokens": 1048576, + "supports_reasoning": True, + "supports_vision": False, + "supported_modalities": ["text"], + }, + "sference/moonshotai/Kimi-K3": { + "input_cost_per_token": 2.25e-06, + "output_cost_per_token": 1.125e-05, + "cache_read_input_token_cost": 2.25e-07, + "max_input_tokens": 1048576, + "supports_reasoning": True, + "supports_vision": False, + "supported_modalities": ["text"], + }, + "sference/zai-org/GLM-5.2": { + "input_cost_per_token": 1.2e-06, + "output_cost_per_token": 4.2e-06, + "cache_read_input_token_cost": 2.6e-07, + "max_input_tokens": 1048576, + "supports_reasoning": True, + "supports_vision": False, + "supported_modalities": ["text"], + }, +} + + +def _load(path: Path) -> dict: + with open(path) as f: + return json.load(f) + + +@pytest.mark.parametrize("model", sorted(SFERENCE_MODELS)) +def test_sference_catalog_entry(model: str): + model_cost = _load(Path(__file__).parents[2] / "model_prices_and_context_window.json") + + info = model_cost.get(model) + assert info is not None, f"{model} not found in model_prices_and_context_window.json" + + expected = SFERENCE_MODELS[model] + assert info["litellm_provider"] == "sference" + assert info["mode"] == "chat" + assert info["source"] == SFERENCE_SOURCE + + assert info["input_cost_per_token"] == expected["input_cost_per_token"] + assert info["output_cost_per_token"] == expected["output_cost_per_token"] + assert info["cache_read_input_token_cost"] == expected["cache_read_input_token_cost"] + assert info["max_input_tokens"] == expected["max_input_tokens"] + assert info["max_output_tokens"] == expected["max_input_tokens"] + assert info["max_tokens"] == expected["max_input_tokens"] + + assert info["supports_reasoning"] is expected["supports_reasoning"] + assert info["supports_vision"] is expected["supports_vision"] + assert info["supported_modalities"] == expected["supported_modalities"] + assert info["supported_output_modalities"] == ["text"] + assert info["supports_function_calling"] is True + assert info["supports_tool_choice"] is True + assert info["supports_prompt_caching"] is True + assert info["supports_response_schema"] is True + assert info["supports_system_messages"] is True + assert info["supports_native_streaming"] is True + assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/messages"] + + +@pytest.mark.parametrize("model", sorted(SFERENCE_MODELS)) +def test_sference_backup_matches_main(model: str): + repo_root = Path(__file__).parents[2] + main_cost = _load(repo_root / "model_prices_and_context_window.json") + backup_cost = _load(repo_root / "litellm" / "model_prices_and_context_window_backup.json") + + assert backup_cost.get(model) == main_cost.get(model), f"{model} differs between main and backup model cost maps" + + +@pytest.mark.parametrize("model", sorted(SFERENCE_MODELS)) +def test_sference_model_routing(model: str): + routed_model, provider, _, api_base = get_llm_provider(model=model, api_key="sk-test") + + assert routed_model == model.split("/", 1)[1] + assert provider == "sference" + assert api_base == "https://api.sference.com/v1"