diff --git a/litellm/constants.py b/litellm/constants.py index c0fe0491775..e4c3b75954c 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1051,6 +1051,7 @@ openai_compatible_endpoints: Final[list[str]] = [ "https://api.libertai.io/v1", "https://pinstripes.io/v1", "https://api.meta.ai/v1", + "https://api-inference.bitdeer.ai/v1", "https://api.sailresearch.com/v1", "https://api.cognition.ai/v1", "https://inference.coralbricks.ai/v1", @@ -1131,6 +1132,7 @@ openai_compatible_providers: Final[list[str]] = [ "openinfer", "darkbloom", "meta", # Meta Model API (Muse Spark) - JSON-configured provider + "bitdeer-ai", # Bitdeer AI - JSON-configured provider "cognition", "coralbricks", "cortecs", diff --git a/litellm/images/main.py b/litellm/images/main.py index 7f94835d59b..0360151ae60 100644 --- a/litellm/images/main.py +++ b/litellm/images/main.py @@ -489,6 +489,12 @@ def image_generation( or custom_llm_provider == LlmProviders.LITELLM_PROXY.value or custom_llm_provider in litellm.openai_compatible_providers ): + if custom_llm_provider == "bitdeer-ai" and not (api_key or dynamic_api_key): + raise litellm.AuthenticationError( + message="Bitdeer AI requires api_key or BITDEER_API_KEY", + llm_provider=custom_llm_provider, + model=model, + ) if extra_headers is not None: optional_params["extra_headers"] = extra_headers # Forward OpenAI organization if present (set by proxy pre-call utils) diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index a67c1bb7df8..17117f68b58 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -383,6 +383,14 @@ def get_llm_provider( elif endpoint == "https://llm.chutes.ai/v1/": custom_llm_provider = "chutes" dynamic_api_key = get_secret_str("CHUTES_API_KEY") + elif endpoint == "https://api-inference.bitdeer.ai/v1": + return _get_openai_compatible_provider_info( + model=f"bitdeer-ai/{model}", + api_base=api_base, + api_key=api_key, + dynamic_api_key=dynamic_api_key, + litellm_params=litellm_params, + ) elif endpoint == "https://api.v0.dev/v1": custom_llm_provider = "v0" dynamic_api_key = get_secret_str("V0_API_KEY") diff --git a/litellm/llms/openai_like/dynamic_config.py b/litellm/llms/openai_like/dynamic_config.py index 685561f55a5..b6aa7ad9856 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -67,6 +67,13 @@ def create_config_class(provider: SimpleProviderConfig): if not resolved_base: resolved_base = provider.base_url + if ( + provider.special_handling.get("require_api_key_for_custom_base") + and resolved_base.rstrip("/") != provider.base_url.rstrip("/") + and not api_key + ): + raise ValueError(f"An explicit api_key is required for {provider.slug} when overriding api_base") + # Resolve API key resolved_key: Final = api_key or get_secret_str(provider.api_key_env) diff --git a/litellm/llms/openai_like/json_loader.py b/litellm/llms/openai_like/json_loader.py index 23b63177137..11f753252cc 100644 --- a/litellm/llms/openai_like/json_loader.py +++ b/litellm/llms/openai_like/json_loader.py @@ -3,6 +3,7 @@ JSON-based provider configuration loader for OpenAI-compatible providers. """ import json +from collections.abc import Mapping, Sequence from pathlib import Path from typing import Final @@ -20,13 +21,13 @@ class SimpleProviderConfig: def __init__(self, slug: str, data: dict): self.slug = slug self.base_url = data["base_url"] - self.api_key_env = data["api_key_env"] - self.api_base_env = data.get("api_base_env") - self.base_class = data.get("base_class", "openai_gpt") - self.param_mappings = data.get("param_mappings", {}) + self.api_key_env: str = data["api_key_env"] + self.api_base_env: str | None = data.get("api_base_env") + self.base_class: str = data.get("base_class", "openai_gpt") + self.param_mappings: Mapping[str, str] = data.get("param_mappings", {}) self.constraints = data.get("constraints", {}) - self.special_handling = data.get("special_handling", {}) - self.supported_endpoints = data.get("supported_endpoints", []) + self.special_handling: Mapping[str, bool] = data.get("special_handling", {}) + self.supported_endpoints: Sequence[str] = data.get("supported_endpoints", []) self.provider_info: ProviderSpecificModelInfo = ( _PROVIDER_INFO_ADAPTER.validate_python(data["provider_info"]) if "provider_info" in data else {} ) @@ -86,6 +87,11 @@ class JSONProviderRegistry: return False return "/v1/responses" in provider.supported_endpoints + @classmethod + def supports_embeddings(cls, slug: str) -> bool: + provider: Final = cls._providers.get(slug) + return provider is not None and "/v1/embeddings" in provider.supported_endpoints + @classmethod def list_providers(cls) -> list: """List all registered provider slugs""" diff --git a/litellm/llms/openai_like/providers.json b/litellm/llms/openai_like/providers.json index 36337fddbdc..d397e53d559 100644 --- a/litellm/llms/openai_like/providers.json +++ b/litellm/llms/openai_like/providers.json @@ -1,4 +1,15 @@ { + "bitdeer-ai": { + "base_url": "https://api-inference.bitdeer.ai/v1", + "api_key_env": "BITDEER_API_KEY", + "param_mappings": { + "max_completion_tokens": "max_tokens" + }, + "special_handling": { + "require_api_key_for_custom_base": true + }, + "supported_endpoints": ["/v1/chat/completions", "/v1/embeddings", "/v1/rerank", "/v1/images/generations"] + }, "publicai": { "base_url": "https://api.publicai.co/v1", "api_key_env": "PUBLICAI_API_KEY", diff --git a/litellm/llms/openai_like/rerank/bitdeer_transformation.py b/litellm/llms/openai_like/rerank/bitdeer_transformation.py new file mode 100644 index 00000000000..991c4fc5e52 --- /dev/null +++ b/litellm/llms/openai_like/rerank/bitdeer_transformation.py @@ -0,0 +1,22 @@ +from typing import Final + +from litellm.llms.hosted_vllm.rerank.transformation import HostedVLLMRerankConfig +from litellm.types.rerank import RerankBilledUnits +from litellm.types.utils import ModelInfo + + +class BitdeerRerankConfig(HostedVLLMRerankConfig): + def calculate_rerank_cost( + self, + model: str, + custom_llm_provider: str | None = None, + billed_units: RerankBilledUnits | None = None, + model_info: ModelInfo | None = None, + ) -> tuple[float, float]: + if model_info is None or billed_units is None: + return 0.0, 0.0 + cost_per_token: Final = model_info.get("input_cost_per_token") + total_tokens: Final = billed_units.get("total_tokens") + if cost_per_token is None or total_tokens is None: + return 0.0, 0.0 + return cost_per_token * total_tokens, 0.0 diff --git a/litellm/main.py b/litellm/main.py index 27700cf9def..73ac5ffed92 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -2641,6 +2641,12 @@ def _complete_custom_openai( or None # default - https://github.com/openai/openai-python/blob/284c1799070c723c6a553337134148a7ab088dd8/openai/util.py#L105 ) openai.organization = organization + if custom_llm_provider == "bitdeer-ai" and not api_key: + raise litellm.AuthenticationError( + message="Bitdeer AI requires api_key or BITDEER_API_KEY", + llm_provider=custom_llm_provider, + model=model, + ) # set API KEY api_key = ( api_key @@ -7646,6 +7652,27 @@ def embedding( aembedding=aembedding, litellm_params={}, ) + elif JSONProviderRegistry.supports_embeddings(custom_llm_provider): + if not api_key: + raise litellm.AuthenticationError( + message=f"{custom_llm_provider} embeddings need an api_key", + llm_provider=custom_llm_provider, + model=model, + ) + response = openai_chat_completions.embedding( # rebind-ok: one assignment per provider branch + model=model, + input=input, + api_base=api_base, + api_key=api_key, + logging_obj=logging, + timeout=timeout, + model_response=EmbeddingResponse(), + optional_params=optional_params, + client=client, + aembedding=aembedding, + max_retries=max_retries, + litellm_params=litellm_params_dict, + ) else: raise LiteLLMUnknownProvider(model=model, custom_llm_provider=custom_llm_provider) if response is not None and hasattr(response, "_hidden_params") and isinstance(response, EmbeddingResponse): diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index cb9bfa6ffe4..9782e0149f8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -63476,6 +63476,114 @@ ], "supports_audio_output": true }, + "bitdeer-ai/deepseek-ai/DeepSeek-V4.1-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 6e-09, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/zai-org/GLM-5.3": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "cache_read_input_token_cost": 2.6e-07, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/zai-org/GLM-5.3-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 5e-07, + "cache_read_input_token_cost": 3e-08, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/Qwen/Qwen3.8-27B": { + "max_input_tokens": 262144, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "cache_read_input_token_cost": 1e-07, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/deepseek-ai/DeepSeek-V4-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "cache_read_input_token_cost": 1e-08, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/deepseek-ai/DeepSeek-V4.1-Flash-1017": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 6e-09, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/BAAI/bge-m3": { + "max_input_tokens": 8192, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "litellm_provider": "bitdeer-ai", + "mode": "embedding", + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/BAAI/bge-reranker-v2-m3": { + "max_input_tokens": 8192, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "bitdeer-ai", + "mode": "rerank", + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/seedream-5.0-lite": { + "litellm_provider": "bitdeer-ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "supported_endpoints": [ + "/v1/images/generations" + ], + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, "fallback_generalizations": { "rules": [ { diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index f95b553c2b4..ac8a2e0cc24 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -32,6 +32,23 @@ } }, "providers": { + "bitdeer-ai": { + "display_name": "Bitdeer AI (`bitdeer-ai`)", + "url": "https://docs.litellm.ai/docs/providers/bitdeer", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": true, + "image_generations": true, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": true, + "a2a": false + } + }, "a2a": { "display_name": "A2A (Agent-to-Agent) (`a2a`)", "url": "https://docs.litellm.ai/docs/providers/a2a", diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index 4bec42a7868..48bbca18610 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -794,6 +794,34 @@ ], "default_model_placeholder": "bespoke/nimble" }, + { + "provider": "BITDEER_AI", + "provider_display_name": "Bitdeer AI", + "litellm_provider": "bitdeer-ai", + "credential_fields": [ + { + "key": "api_base", + "label": "API Base", + "placeholder": "https://api-inference.bitdeer.ai/v1", + "tooltip": null, + "required": false, + "field_type": "text", + "options": null, + "default_value": null + }, + { + "key": "api_key", + "label": "API Key", + "placeholder": null, + "tooltip": null, + "required": true, + "field_type": "password", + "options": null, + "default_value": null + } + ], + "default_model_placeholder": "bitdeer-ai/deepseek-ai/DeepSeek-V4-Flash" + }, { "provider": "BYTEZ", "provider_display_name": "Bytez", diff --git a/litellm/rerank_api/main.py b/litellm/rerank_api/main.py index f7e2616324f..5dc4e391a7d 100644 --- a/litellm/rerank_api/main.py +++ b/litellm/rerank_api/main.py @@ -412,6 +412,28 @@ def rerank( litellm_params=rerank_litellm_params, ) + elif _custom_llm_provider == litellm.LlmProviders.BITDEER_AI: + if not dynamic_api_key: + raise litellm.AuthenticationError( + message="bitdeer-ai rerank needs an api_key or BITDEER_API_KEY", + llm_provider="bitdeer-ai", + model=model, + ) + response = base_llm_http_handler.rerank( # rebind-ok: one assignment per provider branch + model=model, + custom_llm_provider=_custom_llm_provider, + provider_config=rerank_provider_config, + optional_rerank_params=optional_rerank_params, + logging_obj=litellm_logging_obj, + timeout=optional_params.timeout, + api_key=dynamic_api_key, + api_base=dynamic_api_base, + _is_async=_is_async, + headers=headers or litellm.headers or {}, + client=client, + model_response=model_response, + litellm_params=rerank_litellm_params, + ) elif _custom_llm_provider == litellm.LlmProviders.DEEPINFRA: api_key = dynamic_api_key or optional_params.api_key or get_secret_str("DEEPINFRA_API_KEY") diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5939a350970..03ad9d423f4 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4541,6 +4541,7 @@ class LlmProviders(str, Enum): XIAOMI_MIMO = "xiaomi_mimo" TENSORMESH = "tensormesh" LIBERTAI = "libertai" + BITDEER_AI = "bitdeer-ai" PINSTRIPES = "pinstripes" COGNITION = "cognition" CORALBRICKS = "coralbricks" diff --git a/litellm/utils.py b/litellm/utils.py index 7de7ce6188d..5f8fe0f72f4 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9561,7 +9561,7 @@ class ProviderConfigManager: return None @staticmethod - def get_provider_rerank_config( + def get_provider_rerank_config( # noqa: C901 # flat one-branch-per-provider dispatch model: str, provider: LlmProviders, api_base: str | None, @@ -9584,6 +9584,12 @@ class ProviderConfigManager: ) elif litellm.LlmProviders.HOSTED_VLLM == provider: return litellm.HostedVLLMRerankConfig() + elif litellm.LlmProviders.BITDEER_AI == provider: + from litellm.llms.openai_like.rerank.bitdeer_transformation import ( + BitdeerRerankConfig, + ) + + return BitdeerRerankConfig() elif litellm.LlmProviders.HUGGINGFACE == provider: return litellm.HuggingFaceRerankConfig() elif litellm.LlmProviders.DEEPINFRA == provider: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index cb9bfa6ffe4..9782e0149f8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -63476,6 +63476,114 @@ ], "supports_audio_output": true }, + "bitdeer-ai/deepseek-ai/DeepSeek-V4.1-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 6e-09, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/zai-org/GLM-5.3": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.4e-06, + "output_cost_per_token": 4.4e-06, + "cache_read_input_token_cost": 2.6e-07, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/zai-org/GLM-5.3-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.5e-07, + "output_cost_per_token": 5e-07, + "cache_read_input_token_cost": 3e-08, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/Qwen/Qwen3.8-27B": { + "max_input_tokens": 262144, + "input_cost_per_token": 5e-07, + "output_cost_per_token": 3e-06, + "cache_read_input_token_cost": 1e-07, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/deepseek-ai/DeepSeek-V4-Flash": { + "max_input_tokens": 1048576, + "input_cost_per_token": 1.4e-07, + "output_cost_per_token": 2.8e-07, + "cache_read_input_token_cost": 1e-08, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/deepseek-ai/DeepSeek-V4.1-Flash-1017": { + "max_input_tokens": 1048576, + "input_cost_per_token": 3e-07, + "output_cost_per_token": 1.2e-06, + "cache_read_input_token_cost": 6e-09, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_prompt_caching": true, + "supports_vision": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/BAAI/bge-m3": { + "max_input_tokens": 8192, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "output_vector_size": 1024, + "litellm_provider": "bitdeer-ai", + "mode": "embedding", + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/BAAI/bge-reranker-v2-m3": { + "max_input_tokens": 8192, + "input_cost_per_token": 1e-08, + "output_cost_per_token": 0.0, + "litellm_provider": "bitdeer-ai", + "mode": "rerank", + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/seedream-5.0-lite": { + "litellm_provider": "bitdeer-ai", + "mode": "image_generation", + "output_cost_per_image": 0.035, + "supported_endpoints": [ + "/v1/images/generations" + ], + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, "fallback_generalizations": { "rules": [ { diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index d65da3f41fd..fcfe71e89ec 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -33,6 +33,23 @@ } }, "providers": { + "bitdeer-ai": { + "display_name": "Bitdeer AI (`bitdeer-ai`)", + "url": "https://docs.litellm.ai/docs/providers/bitdeer", + "endpoints": { + "chat_completions": true, + "messages": true, + "responses": true, + "embeddings": true, + "image_generations": true, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": true, + "a2a": false + } + }, "a2a": { "display_name": "A2A (Agent-to-Agent) (`a2a`)", "url": "https://docs.litellm.ai/docs/providers/a2a", diff --git a/tests/unit/llms/openai_like/test_bitdeer_provider.py b/tests/unit/llms/openai_like/test_bitdeer_provider.py new file mode 100644 index 00000000000..a311a89df25 --- /dev/null +++ b/tests/unit/llms/openai_like/test_bitdeer_provider.py @@ -0,0 +1,352 @@ +import json +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import Final, Literal + +import pytest +import respx + +import litellm +from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.llms.openai_like.dynamic_config import create_config_class +from litellm.llms.openai_like.json_loader import JSONProviderRegistry + +BASE_URL: Final = "https://api-inference.bitdeer.ai/v1" +MODEL: Final = "deepseek-ai/DeepSeek-V4-Flash" +EMBEDDING_MODEL: Final = "BAAI/bge-m3" +RERANK_MODEL: Final = "BAAI/bge-reranker-v2-m3" +IMAGE_MODEL: Final = "seedream-5.0-lite" +MODELS: Final = ( + "deepseek-ai/DeepSeek-V4.1-Flash", + "deepseek-ai/DeepSeek-V4.1-Flash-1017", + "zai-org/GLM-5.3", + "zai-org/GLM-5.3-Flash", + "Qwen/Qwen3.8-27B", + MODEL, +) + + +@pytest.mark.parametrize("api_key", (None, "explicit-key")) +@pytest.mark.parametrize("api_base", (BASE_URL, BASE_URL + "/")) +@pytest.mark.parametrize("routing", ("prefix", "provider", "url")) +def test_bitdeer_credentials(monkeypatch: pytest.MonkeyPatch, api_key: str | None, api_base: str, routing: str) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "environment-key") + + result: Final = get_llm_provider( + model=f"bitdeer-ai/{MODEL}" if routing == "prefix" else MODEL, + custom_llm_provider="bitdeer-ai" if routing == "provider" else None, + api_base=api_base, + api_key=api_key, + ) + + assert result == (MODEL, "bitdeer-ai", api_key or "environment-key", api_base) + + +def test_bitdeer_default_base_and_missing_credentials(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BITDEER_API_KEY", raising=False) + assert get_llm_provider(model=f"bitdeer-ai/{MODEL}") == (MODEL, "bitdeer-ai", None, BASE_URL) + monkeypatch.setenv("BITDEER_API_KEY", "environment-key") + assert get_llm_provider(model=f"bitdeer-ai/{MODEL}") == (MODEL, "bitdeer-ai", "environment-key", BASE_URL) + + +@pytest.mark.parametrize("api_key", (None, "")) +@pytest.mark.parametrize( + "api_base", + ( + "https://custom.example/v1", + "http://api-inference.bitdeer.ai/v1", + "https://api-inference.bitdeer.ai:8443/v1", + "https://api-inference.bitdeer.ai.evil.example/v1", + "https://api-inference.bitdeer.ai@evil.example/v1", + "https://evil.example/https://api-inference.bitdeer.ai/v1", + BASE_URL + "/other", + BASE_URL + "?redirect=https://evil.example", + ), +) +def test_bitdeer_custom_base_requires_explicit_key( + monkeypatch: pytest.MonkeyPatch, api_base: str, api_key: str | None +) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "server-secret") + + with pytest.raises(litellm.BadRequestError, match="explicit api_key is required") as error: + get_llm_provider(model=f"bitdeer-ai/{MODEL}", api_base=api_base, api_key=api_key) + + assert "server-secret" not in str(error.value) + + +@pytest.mark.parametrize( + "api_base", ("http://api-inference.bitdeer.ai/v1", "https://api-inference.bitdeer.ai:8443/v1", BASE_URL + "/other") +) +def test_bitdeer_url_autodetection_rejects_unsafe_key_fallback(monkeypatch: pytest.MonkeyPatch, api_base: str) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "server-secret") + with pytest.raises(litellm.BadRequestError, match="explicit api_key is required"): + get_llm_provider(model=MODEL, api_base=api_base) + + +def test_bitdeer_dynamic_config_checks_custom_base(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "server-secret") + provider: Final = JSONProviderRegistry.get("bitdeer-ai") + assert provider is not None + config: Final = create_config_class(provider)() + + with pytest.raises(ValueError, match="explicit api_key is required"): + config._get_openai_compatible_provider_info("https://custom.example/v1", None) + + assert config._get_openai_compatible_provider_info("https://custom.example/v1", "explicit-key") == ( + "https://custom.example/v1", + "explicit-key", + ) + + +@pytest.mark.parametrize("model", (MODEL, f"bitdeer-ai/{MODEL}")) +@pytest.mark.parametrize("api_key", (None, "explicit-key")) +def test_bitdeer_completion_sends_selected_key( + monkeypatch: pytest.MonkeyPatch, model: str, api_key: str | None +) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "environment-key") + with respx.mock() as upstream: + route: Final = upstream.post(BASE_URL + "/chat/completions").respond( + 200, + json={ + "id": "chatcmpl-bitdeer", + "object": "chat.completion", + "created": 0, + "model": MODEL, + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "Hello"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}, + }, + ) + response: Final = litellm.completion( + model=model, + api_base=BASE_URL, + api_key=api_key, + messages=[{"role": "user", "content": "Hi"}], + max_completion_tokens=32, + ) + + request: Final = route.calls.last.request + payload: Final = json.loads(request.content) + assert route.call_count == 1 + assert request.headers["authorization"] == f"Bearer {api_key or 'environment-key'}" + assert payload["model"] == MODEL + assert payload["max_tokens"] == 32 + assert "max_completion_tokens" not in payload + assert response.choices[0].message.content == "Hello" + assert response._hidden_params["response_cost"] == pytest.approx( + 10 * litellm.model_cost[f"bitdeer-ai/{MODEL}"]["input_cost_per_token"] + + 2 * litellm.model_cost[f"bitdeer-ai/{MODEL}"]["output_cost_per_token"] + ) + + +def test_bitdeer_completion_rejects_custom_base_before_sending(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "server-secret") + with respx.mock() as upstream: + with pytest.raises(litellm.BadRequestError, match="explicit api_key is required"): + litellm.completion( + model=f"bitdeer-ai/{MODEL}", + api_base="https://custom.example/v1", + messages=[{"role": "user", "content": "Hi"}], + ) + assert len(upstream.calls) == 0 + + +def _request( + endpoint: Literal["chat", "image"], model: str, custom_llm_provider: str | None = None, api_base: str | None = None +) -> object: + if endpoint == "chat": + return litellm.completion( + model=model, + custom_llm_provider=custom_llm_provider, + api_base=api_base, + messages=[{"role": "user", "content": "Hi"}], + ) + return litellm.image_generation( + model=model, custom_llm_provider=custom_llm_provider, api_base=api_base, prompt="A tree" + ) + + +async def _arequest(endpoint: Literal["chat", "image"], model: str) -> object: + if endpoint == "chat": + return await litellm.acompletion(model=model, messages=[{"role": "user", "content": "Hi"}]) + return await litellm.aimage_generation(model=model, prompt="A tree") + + +@pytest.mark.parametrize("routing", ("prefix", "provider", "url")) +@pytest.mark.parametrize("endpoint", ("chat", "image")) +def test_bitdeer_missing_credentials_never_send_openai_key( + monkeypatch: pytest.MonkeyPatch, + routing: Literal["prefix", "provider", "url"], + endpoint: Literal["chat", "image"], +) -> None: + monkeypatch.delenv("BITDEER_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "openai-secret") + monkeypatch.setattr(litellm, "api_key", "global-secret") + monkeypatch.setattr(litellm, "openai_key", "global-openai-secret") + model: Final = f"bitdeer-ai/{MODEL}" if routing == "prefix" else MODEL + provider: Final = "bitdeer-ai" if routing == "provider" else None + api_base: Final = BASE_URL if routing == "url" else None + + with respx.mock() as upstream: + with pytest.raises(litellm.AuthenticationError, match="BITDEER_API_KEY") as error: + _request(endpoint, model=model, custom_llm_provider=provider, api_base=api_base) + assert len(upstream.calls) == 0 + assert "secret" not in str(error.value) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("endpoint", ("chat", "image")) +async def test_bitdeer_async_missing_credentials_never_send_openai_key( + monkeypatch: pytest.MonkeyPatch, endpoint: Literal["chat", "image"] +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.delenv("BITDEER_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "openai-secret") + monkeypatch.setattr(litellm, "api_key", "global-secret") + monkeypatch.setattr(litellm, "openai_key", "global-openai-secret") + + with respx.mock() as upstream: + with pytest.raises(litellm.AuthenticationError, match="BITDEER_API_KEY") as error: + await _arequest(endpoint, model=f"bitdeer-ai/{MODEL}") + assert len(upstream.calls) == 0 + assert "secret" not in str(error.value) + + +def _unique_entries(pairs: Sequence[tuple[str, object]]) -> Mapping[str, object]: + keys: Final = tuple(key for key, _ in pairs) + assert len(keys) == len(set(keys)), "Duplicate JSON key" + return dict(pairs) + + +def test_bitdeer_pricing_and_packaged_registry() -> None: + root: Final = Path(litellm.__file__).parent.parent + prices: Final = json.loads( + (root / "model_prices_and_context_window.json").read_text(), object_pairs_hook=_unique_entries + ) + backup: Final = json.loads((root / "litellm/model_prices_and_context_window_backup.json").read_text()) + + for model in (*MODELS, EMBEDDING_MODEL, RERANK_MODEL, IMAGE_MODEL): + extra: Final = f"bitdeer-ai/{model}" + assert prices[extra] == backup[extra] == litellm.model_cost[extra] + assert prices[extra]["litellm_provider"] == "bitdeer-ai" + + for model in MODELS: + name: Final = f"bitdeer-ai/{model}" + entry: Final = prices[name] + assert entry["mode"] == "chat" + assert entry["max_input_tokens"] > 0 + assert entry["litellm_provider"] == "bitdeer-ai" + prompt_cost, output_cost = litellm.cost_per_token(model=name, prompt_tokens=100, completion_tokens=10) + assert prompt_cost == pytest.approx(100 * entry["input_cost_per_token"]) + assert output_cost == pytest.approx(10 * entry["output_cost_per_token"]) + + +def test_bitdeer_embedding_sends_selected_key_and_charges_input_tokens(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "environment-key") + with respx.mock() as upstream: + route: Final = upstream.post(BASE_URL + "/embeddings").respond( + 200, + json={ + "object": "list", + "model": EMBEDDING_MODEL, + "data": [{"object": "embedding", "index": 0, "embedding": [0.25, -0.5]}], + "usage": {"prompt_tokens": 7, "total_tokens": 7}, + }, + ) + response: Final = litellm.embedding(model=f"bitdeer-ai/{EMBEDDING_MODEL}", input=["hello"], api_base=BASE_URL) + + request: Final = route.calls.last.request + assert json.loads(request.content) == {"model": EMBEDDING_MODEL, "input": ["hello"]} + assert request.headers["authorization"] == "Bearer environment-key" + assert response.data[0]["embedding"] == [0.25, -0.5] + assert response._hidden_params["response_cost"] == pytest.approx( + 7 * litellm.model_cost[f"bitdeer-ai/{EMBEDDING_MODEL}"]["input_cost_per_token"] + ) + + +def test_bitdeer_embedding_without_bitdeer_key_never_sends_the_openai_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BITDEER_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "openai-secret") + with respx.mock() as upstream: + with pytest.raises(litellm.AuthenticationError, match="api_key"): + litellm.embedding(model=f"bitdeer-ai/{EMBEDDING_MODEL}", input=["hello"], api_base=BASE_URL) + assert len(upstream.calls) == 0 + + +def test_bitdeer_rerank_orders_results_and_sends_selected_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "environment-key") + with respx.mock() as upstream: + route: Final = upstream.post(BASE_URL + "/rerank").respond( + 200, + json={ + "id": "rerank-bitdeer", + "model": RERANK_MODEL, + "usage": {"total_tokens": 9}, + "results": [ + {"index": 1, "relevance_score": 0.91, "document": {"text": "paris"}}, + {"index": 0, "relevance_score": 0.02, "document": {"text": "berlin"}}, + ], + }, + ) + response: Final = litellm.rerank( + model=f"bitdeer-ai/{RERANK_MODEL}", + query="capital of france", + documents=["berlin", "paris"], + api_key="explicit-key", + ) + + request: Final = route.calls.last.request + payload: Final = json.loads(request.content) + assert payload["model"] == RERANK_MODEL + assert payload["query"] == "capital of france" + assert payload["documents"] == ["berlin", "paris"] + assert request.headers["authorization"] == "Bearer explicit-key" + assert [(result["index"], result["relevance_score"]) for result in response.results] == [(1, 0.91), (0, 0.02)] + assert response._hidden_params["response_cost"] == pytest.approx( + 9 * litellm.model_cost[f"bitdeer-ai/{RERANK_MODEL}"]["input_cost_per_token"] + ) + + +def test_bitdeer_rerank_without_bitdeer_key_never_sends_the_hosted_vllm_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BITDEER_API_KEY", raising=False) + monkeypatch.setenv("HOSTED_VLLM_API_KEY", "vllm-secret") + with respx.mock() as upstream: + with pytest.raises(litellm.AuthenticationError, match="BITDEER_API_KEY"): + litellm.rerank(model=f"bitdeer-ai/{RERANK_MODEL}", query="q", documents=["d"]) + assert len(upstream.calls) == 0 + + +def test_bitdeer_rerank_rejects_custom_base_before_sending(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "server-secret") + with respx.mock() as upstream: + with pytest.raises(litellm.BadRequestError, match="explicit api_key is required"): + litellm.rerank( + model=f"bitdeer-ai/{RERANK_MODEL}", + query="q", + documents=["d"], + api_base="https://custom.example/v1", + ) + assert len(upstream.calls) == 0 + + +def test_bitdeer_image_generation_sends_selected_key_and_charges_per_image(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BITDEER_API_KEY", "environment-key") + with respx.mock() as upstream: + route: Final = upstream.post(BASE_URL + "/images/generations").respond( + 200, + json={"created": 0, "data": [{"url": "https://images.example/out.jpeg", "size": "2048x2048"}]}, + ) + response: Final = litellm.image_generation( + model=f"bitdeer-ai/{IMAGE_MODEL}", prompt="a red circle", api_base=BASE_URL, api_key="explicit-key" + ) + + request: Final = route.calls.last.request + payload: Final = json.loads(request.content) + assert payload["model"] == IMAGE_MODEL + assert payload["prompt"] == "a red circle" + assert request.headers["authorization"] == "Bearer explicit-key" + assert response.data[0].url == "https://images.example/out.jpeg" + assert response._hidden_params["response_cost"] == pytest.approx( + litellm.model_cost[f"bitdeer-ai/{IMAGE_MODEL}"]["output_cost_per_image"] + )