diff --git a/litellm/constants.py b/litellm/constants.py index 6343c0675e2..7ff11e36802 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -965,6 +965,7 @@ openai_compatible_endpoints: Final[list] = [ "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://api.cortecs.ai/v1", @@ -1042,6 +1043,7 @@ openai_compatible_providers: Final[list] = [ "pinstripes", # Pinstripes - JSON-configured provider "darkbloom", "meta", # Meta Model API (Muse Spark) - JSON-configured provider + "bitdeer-ai", # Bitdeer AI - JSON-configured provider "cognition", "cortecs", "scx-ai", diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py index 5a498ead6bc..93fbdacd2c6 100644 --- a/litellm/litellm_core_utils/get_llm_provider_logic.py +++ b/litellm/litellm_core_utils/get_llm_provider_logic.py @@ -371,6 +371,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 b8517a4257c..9e1ad01b189 100644 --- a/litellm/llms/openai_like/dynamic_config.py +++ b/litellm/llms/openai_like/dynamic_config.py @@ -66,6 +66,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/providers.json b/litellm/llms/openai_like/providers.json index f3aa7768928..88e24da763e 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"] + }, "publicai": { "base_url": "https://api.publicai.co/v1", "api_key_env": "PUBLICAI_API_KEY", diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 22b75127d03..d476ca26eee 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -61225,6 +61225,52 @@ ], "supports_audio_output": true }, + "bitdeer-ai/moonshotai/Kimi-K3": { + "max_tokens": 131072, + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": true, + "supports_prompt_caching": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/zai-org/GLM-5.2": { + "max_tokens": 131072, + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "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/deepseek-ai/DeepSeek-V4-Flash": { + "max_tokens": 384000, + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "input_cost_per_token": 4e-08, + "output_cost_per_token": 8e-08, + "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" + }, "fallback_generalizations": { "rules": [ { diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 0e325bb61fe..77d6e553fbc 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": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "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 2dd9f50bec8..1c8aae91fde 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -748,6 +748,34 @@ ], "default_model_placeholder": "gpt-3.5-turbo" }, + { + "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/moonshotai/Kimi-K3" + }, { "provider": "BYTEZ", "provider_display_name": "Bytez", diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 3ec5e91af14..a2d23aac5d1 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -4200,6 +4200,7 @@ class LlmProviders(str, Enum): XIAOMI_MIMO = "xiaomi_mimo" TENSORMESH = "tensormesh" LIBERTAI = "libertai" + BITDEER_AI = "bitdeer-ai" PINSTRIPES = "pinstripes" COGNITION = "cognition" CORTECS = "cortecs" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 22b75127d03..d476ca26eee 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -61225,6 +61225,52 @@ ], "supports_audio_output": true }, + "bitdeer-ai/moonshotai/Kimi-K3": { + "max_tokens": 131072, + "max_input_tokens": 1048576, + "max_output_tokens": 131072, + "input_cost_per_token": 3e-06, + "output_cost_per_token": 1.5e-05, + "cache_read_input_token_cost": 3e-07, + "litellm_provider": "bitdeer-ai", + "mode": "chat", + "supports_function_calling": true, + "supports_tool_choice": true, + "supports_reasoning": true, + "supports_vision": true, + "supports_prompt_caching": true, + "source": "https://www.bitdeer.ai/en/pricing/ai-models" + }, + "bitdeer-ai/zai-org/GLM-5.2": { + "max_tokens": 131072, + "max_input_tokens": 1000000, + "max_output_tokens": 131072, + "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/deepseek-ai/DeepSeek-V4-Flash": { + "max_tokens": 384000, + "max_input_tokens": 1000000, + "max_output_tokens": 384000, + "input_cost_per_token": 4e-08, + "output_cost_per_token": 8e-08, + "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" + }, "fallback_generalizations": { "rules": [ { diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 00904219b81..1a901243506 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": false, + "image_generations": false, + "audio_transcriptions": false, + "audio_speech": false, + "moderations": false, + "batches": false, + "rerank": false, + "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..9927e74917f --- /dev/null +++ b/tests/unit/llms/openai_like/test_bitdeer_provider.py @@ -0,0 +1,164 @@ +import json +from pathlib import Path +from typing import Final + +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 = "moonshotai/Kimi-K3" +MODELS: Final = (MODEL, "zai-org/GLM-5.2", "deepseek-ai/DeepSeek-V4-Flash") + + +@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 _unique_entries(pairs: list[tuple[str, object]]) -> dict[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: + name: Final = f"bitdeer-ai/{model}" + entry: Final = prices[name] + assert entry == backup[name] == litellm.model_cost[name] + assert entry["max_tokens"] == entry["max_output_tokens"] <= entry["max_input_tokens"] + 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"])