diff --git a/README.md b/README.md
index 3eb475f121e..e927c80b8b4 100644
--- a/README.md
+++ b/README.md
@@ -307,6 +307,7 @@ For MCP OAuth, an upstream may advertise dynamic client registration but refuse
| [Deepgram (`deepgram`)](https://docs.litellm.ai/docs/providers/deepgram) | ✅ | ✅ | ✅ | | | ✅ | | | | |
| [DeepInfra (`deepinfra`)](https://docs.litellm.ai/docs/providers/deepinfra) | ✅ | ✅ | ✅ | | | | | | | |
| [Deepseek (`deepseek`)](https://docs.litellm.ai/docs/providers/deepseek) | ✅ | ✅ | ✅ | | | | | | | |
+| [Eden AI (`edenai`)](https://docs.litellm.ai/docs/providers/edenai) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | |
| [ElevenLabs (`elevenlabs`)](https://docs.litellm.ai/docs/providers/elevenlabs) | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | |
| [Empower (`empower`)](https://docs.litellm.ai/docs/providers/empower) | ✅ | ✅ | ✅ | | | | | | | |
| [Fal AI (`fal_ai`)](https://docs.litellm.ai/docs/providers/fal_ai) | ✅ | ✅ | ✅ | | ✅ | | | | | |
diff --git a/litellm/__init__.py b/litellm/__init__.py
index d202bd41cfe..44515472648 100644
--- a/litellm/__init__.py
+++ b/litellm/__init__.py
@@ -689,6 +689,7 @@ recraft_models: Set = set()
cometapi_models: Set = set()
oci_models: Set = set()
vercel_ai_gateway_models: Set = set()
+edenai_models: Set = set() # mutable-ok: filled from the price map at import, like the sibling provider sets
volcengine_models: Set = set()
wandb_models: Set = set(WANDB_MODELS)
ovhcloud_models: Set = set()
@@ -763,6 +764,8 @@ def _populate_provider_model_sets(model_cost_map: Dict) -> None:
openrouter_models.add(key)
elif value.get("litellm_provider") == "vercel_ai_gateway":
vercel_ai_gateway_models.add(key)
+ elif value.get("litellm_provider") == "edenai":
+ edenai_models.add(key)
elif value.get("litellm_provider") == "datarobot":
datarobot_models.add(key)
elif value.get("litellm_provider") == "vertex_ai-text-models":
@@ -1111,6 +1114,7 @@ model_list = list(
| oci_models
| heroku_models
| vercel_ai_gateway_models
+ | edenai_models
| volcengine_models
| wandb_models
| ovhcloud_models
@@ -1139,6 +1143,7 @@ def _build_models_by_provider() -> dict:
"baseten": baseten_models,
"openrouter": openrouter_models,
"vercel_ai_gateway": vercel_ai_gateway_models,
+ "edenai": edenai_models,
"datarobot": datarobot_models,
"vertex_ai": vertex_chat_models
| vertex_text_models
@@ -2117,6 +2122,30 @@ if TYPE_CHECKING:
from .llms.vercel_ai_gateway.chat.transformation import (
VercelAIGatewayConfig as VercelAIGatewayConfig,
)
+ from .llms.edenai.chat.transformation import (
+ EdenAIChatConfig as EdenAIChatConfig,
+ )
+ from .llms.edenai.responses.transformation import (
+ EdenAIResponsesAPIConfig as EdenAIResponsesAPIConfig,
+ )
+ from .llms.edenai.messages.transformation import (
+ EdenAIAnthropicMessagesConfig as EdenAIAnthropicMessagesConfig,
+ )
+ from .llms.edenai.embedding.transformation import (
+ EdenAIEmbeddingConfig as EdenAIEmbeddingConfig,
+ )
+ from .llms.edenai.audio_transcription.transformation import (
+ EdenAIAudioTranscriptionConfig as EdenAIAudioTranscriptionConfig,
+ )
+ from .llms.edenai.text_to_speech.transformation import (
+ EdenAITextToSpeechConfig as EdenAITextToSpeechConfig,
+ )
+ from .llms.edenai.image_generation.transformation import (
+ EdenAIImageGenerationConfig as EdenAIImageGenerationConfig,
+ )
+ from .llms.edenai.videos.transformation import (
+ EdenAIVideoConfig as EdenAIVideoConfig,
+ )
from .llms.ovhcloud.chat.transformation import (
OVHCloudChatConfig as OVHCloudChatConfig,
)
diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py
index bca04a17250..db4eb8bdb33 100644
--- a/litellm/_lazy_imports_registry.py
+++ b/litellm/_lazy_imports_registry.py
@@ -327,6 +327,14 @@ LLM_CONFIG_NAMES: Final = (
"InceptionChatConfig",
"HyperbolicChatConfig",
"VercelAIGatewayConfig",
+ "EdenAIChatConfig",
+ "EdenAIResponsesAPIConfig",
+ "EdenAIAnthropicMessagesConfig",
+ "EdenAIEmbeddingConfig",
+ "EdenAIAudioTranscriptionConfig",
+ "EdenAITextToSpeechConfig",
+ "EdenAIImageGenerationConfig",
+ "EdenAIVideoConfig",
"OVHCloudChatConfig",
"OVHCloudEmbeddingConfig",
"CometAPIEmbeddingConfig",
@@ -1232,6 +1240,17 @@ _LLM_CONFIGS_IMPORT_MAP: Final = {
".llms.vercel_ai_gateway.chat.transformation",
"VercelAIGatewayConfig",
),
+ "EdenAIChatConfig": (".llms.edenai.chat.transformation", "EdenAIChatConfig"),
+ "EdenAIResponsesAPIConfig": (".llms.edenai.responses.transformation", "EdenAIResponsesAPIConfig"),
+ "EdenAIAnthropicMessagesConfig": (".llms.edenai.messages.transformation", "EdenAIAnthropicMessagesConfig"),
+ "EdenAIEmbeddingConfig": (".llms.edenai.embedding.transformation", "EdenAIEmbeddingConfig"),
+ "EdenAIAudioTranscriptionConfig": (
+ ".llms.edenai.audio_transcription.transformation",
+ "EdenAIAudioTranscriptionConfig",
+ ),
+ "EdenAITextToSpeechConfig": (".llms.edenai.text_to_speech.transformation", "EdenAITextToSpeechConfig"),
+ "EdenAIImageGenerationConfig": (".llms.edenai.image_generation.transformation", "EdenAIImageGenerationConfig"),
+ "EdenAIVideoConfig": (".llms.edenai.videos.transformation", "EdenAIVideoConfig"),
"OVHCloudChatConfig": (".llms.ovhcloud.chat.transformation", "OVHCloudChatConfig"),
"OVHCloudEmbeddingConfig": (
".llms.ovhcloud.embedding.transformation",
diff --git a/litellm/constants.py b/litellm/constants.py
index 72495b389d7..88cc5b04743 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -750,6 +750,7 @@ LITELLM_CHAT_PROVIDERS: Final = [
"inception",
"vercel_ai_gateway",
"wandb",
+ "edenai",
"ovhcloud",
"lemonade",
"docker_model_runner",
@@ -925,6 +926,7 @@ openai_compatible_endpoints: Final[list] = [
"https://api.hyperbolic.xyz/v1",
"https://ai-gateway.helicone.ai/",
"https://ai-gateway.vercel.sh/v1",
+ "https://api.edenai.run/v3",
"https://api.inference.wandb.ai/v1",
"https://api.clarifai.com/v2/ext/openai/v1",
"https://api.libertai.io/v1",
@@ -994,6 +996,7 @@ openai_compatible_providers: Final[list] = [
"hyperbolic",
"vercel_ai_gateway",
"aiml",
+ "edenai",
"wandb",
"cometapi",
"clarifai",
diff --git a/litellm/images/main.py b/litellm/images/main.py
index 81547a153c3..1f722eb752a 100644
--- a/litellm/images/main.py
+++ b/litellm/images/main.py
@@ -388,6 +388,7 @@ def image_generation(
litellm.LlmProviders.DASHSCOPE,
litellm.LlmProviders.QWENCLOUD,
litellm.LlmProviders.QWEN_AI_PLATFORM,
+ litellm.LlmProviders.EDENAI,
):
if image_generation_config is None:
raise ValueError(f"image generation config is not supported for {custom_llm_provider}")
diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py
index d29b1fc74ef..ce6f77f78a0 100644
--- a/litellm/litellm_core_utils/core_helpers.py
+++ b/litellm/litellm_core_utils/core_helpers.py
@@ -4,7 +4,8 @@ import copy
import logging
import re
from collections.abc import Iterable, Mapping
-from typing import TYPE_CHECKING, Any, Final, Literal
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Any, Final, Literal, Protocol
import httpx
from pydantic import TypeAdapter, ValidationError
@@ -703,3 +704,24 @@ def redact_nested_match_and_regex_keys(
except Exception:
return payload
return redacted
+
+
+RESPONSE_COST_HEADER: Final = "llm_provider-x-litellm-response-cost"
+_NO_HEADERS: Final[Mapping[str, object]] = MappingProxyType({})
+
+
+class _CarriesHiddenParams(Protocol):
+ _hidden_params: dict[str, object] # mutable-ok: the responses billed here keep hidden params in a plain dict
+
+
+def set_response_cost_in_hidden_params(response: _CarriesHiddenParams, cost: float | None) -> None:
+ """Record a provider-reported cost where the cost calculator looks before the price map."""
+ if cost is None:
+ return
+ hidden_params: Final = response._hidden_params # pyright: ignore[reportPrivateUsage] # no public accessor
+ additional_headers: Final[object] = hidden_params.get("additional_headers")
+ merged: Final[dict[str, object]] = { # mutable-ok: assigned into the plain-dict hidden params
+ **(additional_headers if isinstance(additional_headers, Mapping) else _NO_HEADERS),
+ RESPONSE_COST_HEADER: cost,
+ }
+ hidden_params["additional_headers"] = merged # rebind-ok: the caller's record is the point
diff --git a/litellm/litellm_core_utils/get_llm_provider_logic.py b/litellm/litellm_core_utils/get_llm_provider_logic.py
index b7067a45117..5868e79323a 100644
--- a/litellm/litellm_core_utils/get_llm_provider_logic.py
+++ b/litellm/litellm_core_utils/get_llm_provider_logic.py
@@ -362,6 +362,9 @@ def get_llm_provider(
elif endpoint == "https://ai-gateway.vercel.sh/v1":
custom_llm_provider = "vercel_ai_gateway"
dynamic_api_key = get_secret_str("VERCEL_AI_GATEWAY_API_KEY")
+ elif endpoint == "https://api.edenai.run/v3":
+ custom_llm_provider = "edenai" # rebind-ok: api_base detection resolves the provider in place
+ dynamic_api_key = get_secret_str("EDENAI_API_KEY")
elif endpoint == "https://api.inference.wandb.ai/v1":
custom_llm_provider = "wandb"
dynamic_api_key = get_secret_str("WANDB_API_KEY")
@@ -853,6 +856,9 @@ def _get_openai_compatible_provider_info(
api_base,
dynamic_api_key,
) = litellm.VercelAIGatewayConfig()._get_openai_compatible_provider_info(api_base, api_key)
+ elif custom_llm_provider == "edenai":
+ api_base = litellm.EdenAIChatConfig.get_api_base(api_base) # rebind-ok: chain resolves in place
+ dynamic_api_key = litellm.EdenAIChatConfig.get_api_key(api_key) # rebind-ok: chain resolves in place
elif custom_llm_provider == "aiml":
(
api_base,
diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py
index ba8addbaaa0..b34f1b3aafd 100644
--- a/litellm/litellm_core_utils/litellm_logging.py
+++ b/litellm/litellm_core_utils/litellm_logging.py
@@ -69,7 +69,11 @@ from litellm.litellm_core_utils.classifier_logging import (
classifier_input_snapshot,
is_classifier_call,
)
-from litellm.litellm_core_utils.core_helpers import is_expected_client_error, reconstruct_model_name
+from litellm.litellm_core_utils.core_helpers import (
+ is_expected_client_error,
+ reconstruct_model_name,
+ set_response_cost_in_hidden_params,
+)
from litellm.litellm_core_utils.get_litellm_params import get_litellm_params
from litellm.litellm_core_utils.internal_call_metadata import (
MODEL_ACCESS_GROUP_METADATA_KEY,
@@ -3918,6 +3922,7 @@ class Logging(LiteLLMLoggingBaseClass):
):
## return unified Usage object
if isinstance(result.response.usage, ResponseAPIUsage):
+ set_response_cost_in_hidden_params(result.response, result.response.usage.cost)
transformed_usage: Final = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(
result.response.usage
)
diff --git a/litellm/llms/edenai/audio_transcription/transformation.py b/litellm/llms/edenai/audio_transcription/transformation.py
new file mode 100644
index 00000000000..fc8a13d5ccd
--- /dev/null
+++ b/litellm/llms/edenai/audio_transcription/transformation.py
@@ -0,0 +1,91 @@
+"""
+Support for OpenAI's `/v1/audio/transcriptions` endpoint on Eden AI, served at `/v3/audio/transcriptions`
+with the real per-request cost at the top level of the JSON body.
+
+Docs: https://www.edenai.co/docs/api-reference/audio/audio-transcriptions
+"""
+
+from collections.abc import Mapping
+from typing import Final
+
+import httpx
+
+from litellm.litellm_core_utils.audio_utils.utils import process_audio_file
+from litellm.litellm_core_utils.core_helpers import set_response_cost_in_hidden_params
+from litellm.llms.base_llm.audio_transcription.transformation import AudioTranscriptionRequestData
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.openai.transcriptions.whisper_transformation import OpenAIWhisperAudioTranscriptionConfig
+from litellm.types.llms.openai import AllMessageValues
+from litellm.types.utils import FileTypes, TranscriptionResponse
+from litellm.utils import convert_to_model_response_object
+
+from ..common_utils import EdenAIException, authorized_headers, endpoint_url, reported_cost
+
+
+def _form_fields(model: str, optional_params: Mapping[str, object]) -> dict[str, object]: # mutable-ok: httpx form data
+ """LiteLLM parks non-OpenAI params, `model` included, under `extra_body` for the OpenAI SDK; a
+ multipart body carries them as top-level text fields instead."""
+ extras: Final = optional_params.get("extra_body")
+ nested: Final = extras.items() if isinstance(extras, Mapping) else ()
+ fields: Final = (*optional_params.items(), *nested, ("model", model))
+ return {key: value for key, value in fields if key != "extra_body"} # mutable-ok: httpx form data
+
+
+class EdenAIAudioTranscriptionConfig(OpenAIWhisperAudioTranscriptionConfig):
+ @property
+ def has_native_transcription_endpoint(self) -> bool:
+ return True
+
+ def get_complete_url(
+ self,
+ api_base: str | None,
+ api_key: str | None,
+ model: str,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ stream: bool | None = None,
+ ) -> str:
+ return endpoint_url(api_base, "audio/transcriptions")
+
+ def validate_environment(
+ self,
+ headers: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ messages: list[AllMessageValues], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ api_key: str | None = None,
+ api_base: str | None = None,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return authorized_headers(headers, api_key, model)
+
+ def transform_audio_transcription_request(
+ self,
+ model: str,
+ audio_file: FileTypes,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ ) -> AudioTranscriptionRequestData:
+ """Eden reports `duration` and `cost` on every body, so the Whisper default of `verbose_json`,
+ which the gpt-4o-transcribe models reject, is not needed for cost tracking."""
+ audio: Final = process_audio_file(audio_file)
+ files: Final = {"file": (audio.filename, audio.file_content, audio.content_type)} # mutable-ok: httpx contract
+ return AudioTranscriptionRequestData(data=_form_fields(model, optional_params), files=files)
+
+ def transform_audio_transcription_response(self, raw_response: httpx.Response) -> TranscriptionResponse:
+ if "application/json" not in raw_response.headers.get("content-type", ""):
+ return TranscriptionResponse(text=raw_response.text)
+ body: Final = raw_response.json()
+ response: Final[TranscriptionResponse] = convert_to_model_response_object(
+ response_object=body, model_response_object=TranscriptionResponse(), response_type="audio_transcription"
+ )
+ set_response_cost_in_hidden_params(response, reported_cost(body))
+ return response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
diff --git a/litellm/llms/edenai/chat/transformation.py b/litellm/llms/edenai/chat/transformation.py
new file mode 100644
index 00000000000..4fd5a9d550b
--- /dev/null
+++ b/litellm/llms/edenai/chat/transformation.py
@@ -0,0 +1,145 @@
+"""
+Support for OpenAI's `/v1/chat/completions` endpoint on Eden AI.
+
+Eden AI is an OpenAI-compatible gateway (one key across 1000+ models), so requests go through the
+shared HTTP handler untouched. Every Eden response reports the real per-request cost at the top
+level of the body; the only translation here lifts that number into LiteLLM's cost tracking.
+
+Docs: https://www.edenai.co/docs
+"""
+
+from collections.abc import AsyncIterator, Iterator, Mapping
+from types import MappingProxyType
+from typing import TYPE_CHECKING, Final
+
+import httpx
+from pydantic import BaseModel, TypeAdapter
+
+import litellm
+from litellm.litellm_core_utils.core_helpers import set_response_cost_in_hidden_params
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.openai.chat.gpt_transformation import OpenAIChatCompletionStreamingHandler, OpenAIGPTConfig
+from litellm.types.llms.openai import AllMessageValues
+from litellm.types.utils import ModelResponse, ModelResponseStream, Usage
+
+from ..common_utils import EdenAIException, reported_cost, resolve_api_base, resolve_api_key
+
+if TYPE_CHECKING:
+ import tiktoken
+
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+_OPTIONAL_MAPPING: Final[TypeAdapter[Mapping[str, object] | None]] = TypeAdapter(Mapping[str, object] | None)
+
+
+class _EdenAIModel(BaseModel):
+ id: str
+
+
+class _EdenAIModelCatalog(BaseModel):
+ data: tuple[_EdenAIModel, ...]
+
+
+def _stream_options_with_usage(request: Mapping[str, object]) -> Mapping[str, object]:
+ current: Final = _OPTIONAL_MAPPING.validate_python(request.get("stream_options")) or MappingProxyType({})
+ return MappingProxyType({**current, "include_usage": True})
+
+
+class EdenAIChatCompletionStreamingHandler(OpenAIChatCompletionStreamingHandler):
+ def chunk_parser(self, chunk: dict[str, object]) -> ModelResponseStream: # mutable-ok: inherited contract
+ parsed: Final = super().chunk_parser(chunk)
+ cost: Final = reported_cost(chunk)
+ usage: Final[object] = getattr(parsed, "usage", None)
+ if cost is not None and isinstance(usage, Usage):
+ usage.cost = cost
+ return parsed
+
+
+class EdenAIChatConfig(OpenAIGPTConfig):
+ def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: inherited contract
+ reasoning: Final[tuple[str, ...]] = (
+ ("reasoning_effort",)
+ if litellm.supports_reasoning(model=model, custom_llm_provider=litellm.LlmProviders.EDENAI.value)
+ else ()
+ )
+ return [*super().get_supported_openai_params(model), *reasoning] # mutable-ok: inherited contract
+
+ @staticmethod
+ def get_api_key(api_key: str | None = None) -> str | None:
+ return resolve_api_key(api_key)
+
+ @staticmethod
+ def get_api_base(api_base: str | None = None) -> str:
+ return resolve_api_base(api_base)
+
+ def transform_request(
+ self,
+ model: str,
+ messages: list[AllMessageValues], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ headers: dict[str, object], # mutable-ok: inherited contract
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ request: Final[dict[str, object]] = super().transform_request( # mutable-ok: inherited contract
+ model, messages, optional_params, litellm_params, headers
+ )
+ if not request.get("stream"):
+ return request
+ return {**request, "stream_options": dict(_stream_options_with_usage(request))} # mutable-ok: JSON body
+
+ def transform_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ model_response: ModelResponse,
+ logging_obj: "LiteLLMLoggingObj",
+ request_data: dict[str, object], # mutable-ok: inherited contract
+ messages: list[AllMessageValues], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ encoding: "tiktoken.Encoding | None",
+ api_key: str | None = None,
+ json_mode: bool | None = None,
+ ) -> ModelResponse:
+ response: 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,
+ )
+ set_response_cost_in_hidden_params(response, reported_cost(raw_response.content))
+ return response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
+
+ def get_model_response_iterator(
+ self,
+ streaming_response: Iterator[str] | AsyncIterator[str] | ModelResponse,
+ sync_stream: bool,
+ json_mode: bool | None = False,
+ ) -> EdenAIChatCompletionStreamingHandler:
+ return EdenAIChatCompletionStreamingHandler(
+ streaming_response=streaming_response, sync_stream=sync_stream, json_mode=json_mode
+ )
+
+ def get_models(
+ self, api_key: str | None = None, api_base: str | None = None
+ ) -> list[str]: # mutable-ok: inherited contract
+ response: Final = litellm.module_level_client.get(url=f"{self.get_api_base(api_base)}/models")
+ if not response.is_success:
+ raise EdenAIException(status_code=response.status_code, message=response.text, headers=response.headers)
+ catalog: Final = _EdenAIModelCatalog.model_validate(response.json())
+ return [f"edenai/{model.id}" for model in catalog.data] # mutable-ok: inherited contract
diff --git a/litellm/llms/edenai/common_utils.py b/litellm/llms/edenai/common_utils.py
new file mode 100644
index 00000000000..a97354cc30b
--- /dev/null
+++ b/litellm/llms/edenai/common_utils.py
@@ -0,0 +1,80 @@
+"""
+Pieces shared by every Eden AI endpoint: credentials, the exception class, and the per-request
+`cost` Eden reports at the top level of each response body, or in a header when the body is binary.
+"""
+
+from collections.abc import Container, Mapping
+from types import MappingProxyType
+from typing import Final
+
+from pydantic import AliasChoices, BaseModel, Field, ValidationError
+
+import litellm
+from litellm.exceptions import AuthenticationError
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.secret_managers.main import get_secret_str
+from litellm.types.utils import LlmProviders
+
+EDENAI_API_BASE: Final = "https://api.edenai.run/v3"
+EDENAI_COST_HEADER: Final = "x-edenai-cost"
+
+
+class EdenAIException(BaseLLMException):
+ pass
+
+
+class _EdenAIExtras(BaseModel):
+ cost: float | None = Field(default=None, validation_alias=AliasChoices("cost", EDENAI_COST_HEADER))
+
+
+def resolve_api_base(api_base: str | None) -> str:
+ return api_base or get_secret_str("EDENAI_API_BASE") or EDENAI_API_BASE
+
+
+def resolve_api_key(api_key: str | None) -> str | None:
+ return api_key or get_secret_str("EDENAI_API_KEY")
+
+
+def require_api_key(api_key: str | None, model: str) -> str:
+ resolved: Final = resolve_api_key(api_key or litellm.api_key)
+ if resolved is None:
+ raise AuthenticationError(
+ message="Missing Eden AI API key: set EDENAI_API_KEY or pass api_key",
+ llm_provider=LlmProviders.EDENAI.value,
+ model=model,
+ )
+ return resolved
+
+
+def reported_cost(payload: object) -> float | None:
+ try:
+ extras: Final = (
+ _EdenAIExtras.model_validate_json(payload)
+ if isinstance(payload, bytes)
+ else _EdenAIExtras.model_validate(payload)
+ )
+ except ValidationError:
+ return None
+ return extras.cost
+
+
+def authorized_headers(
+ headers: Mapping[str, object], api_key: str | None, model: str
+) -> dict[str, object]: # mutable-ok: header contract
+ return {**headers, "Authorization": f"Bearer {require_api_key(api_key, model)}"} # mutable-ok: header contract
+
+
+def json_headers(
+ headers: Mapping[str, object], api_key: str | None, model: str
+) -> dict[str, object]: # mutable-ok: header contract
+ """The shared HTTP handler sends some JSON bodies as raw content, so the type must be set here."""
+ authorized: Final = authorized_headers(headers, api_key, model)
+ return {**authorized, "Content-Type": "application/json"} # mutable-ok: header contract
+
+
+def endpoint_url(api_base: str | None, path: str) -> str:
+ return f"{resolve_api_base(api_base).rstrip('/')}/{path}"
+
+
+def pick(params: Mapping[str, object], keys: Container[str]) -> Mapping[str, object]:
+ return MappingProxyType({key: value for key, value in params.items() if key in keys})
diff --git a/litellm/llms/edenai/embedding/transformation.py b/litellm/llms/edenai/embedding/transformation.py
new file mode 100644
index 00000000000..1c2cc937875
--- /dev/null
+++ b/litellm/llms/edenai/embedding/transformation.py
@@ -0,0 +1,97 @@
+"""
+Support for OpenAI's `/v1/embeddings` endpoint on Eden AI, served at `/v3/embeddings` with the real
+per-request cost at the top level of the body.
+
+Docs: https://www.edenai.co/docs/v3/llms/embeddings
+"""
+
+from typing import TYPE_CHECKING, Final
+
+import httpx
+
+from litellm.litellm_core_utils.core_helpers import set_response_cost_in_hidden_params
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.embedding.transformation import BaseEmbeddingConfig
+from litellm.types.llms.openai import AllEmbeddingInputValues, AllMessageValues
+from litellm.types.utils import EmbeddingResponse
+from litellm.utils import convert_to_model_response_object
+
+from ..common_utils import EdenAIException, endpoint_url, json_headers, pick, reported_cost
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+_SUPPORTED_PARAMS: Final = ("dimensions", "encoding_format", "user")
+
+
+class EdenAIEmbeddingConfig(BaseEmbeddingConfig):
+ def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: inherited contract
+ return list(_SUPPORTED_PARAMS) # mutable-ok: inherited contract
+
+ def map_openai_params(
+ self,
+ non_default_params: dict[str, object], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ drop_params: bool,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return {**optional_params, **pick(non_default_params, _SUPPORTED_PARAMS)} # mutable-ok: inherited contract
+
+ def validate_environment(
+ self,
+ headers: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ messages: list[AllMessageValues], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ api_key: str | None = None,
+ api_base: str | None = None,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return json_headers(headers, api_key, model)
+
+ def get_complete_url(
+ self,
+ api_base: str | None,
+ api_key: str | None,
+ model: str,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ stream: bool | None = None,
+ ) -> str:
+ return endpoint_url(api_base, "embeddings")
+
+ def transform_embedding_request(
+ self,
+ model: str,
+ input: AllEmbeddingInputValues,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ headers: dict[str, object], # mutable-ok: inherited contract
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return {"model": model, "input": input, **optional_params} # mutable-ok: inherited contract
+
+ def transform_embedding_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ model_response: EmbeddingResponse,
+ logging_obj: "LiteLLMLoggingObj",
+ api_key: str | None,
+ request_data: dict[str, object], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ ) -> EmbeddingResponse:
+ body: Final = raw_response.json()
+ logging_obj.post_call(original_response=body)
+ response: Final[EmbeddingResponse] = convert_to_model_response_object(
+ response_object=body, model_response_object=model_response, response_type="embedding"
+ )
+ set_response_cost_in_hidden_params(response, reported_cost(body))
+ return response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
diff --git a/litellm/llms/edenai/image_generation/transformation.py b/litellm/llms/edenai/image_generation/transformation.py
new file mode 100644
index 00000000000..2965c4041de
--- /dev/null
+++ b/litellm/llms/edenai/image_generation/transformation.py
@@ -0,0 +1,115 @@
+"""
+Support for OpenAI's `/v1/images/generations` endpoint on Eden AI, served at `/v3/images/generations`
+for every image model in the catalog with the real per-request cost at the top level of the body.
+
+Docs: https://www.edenai.co/docs/v3/llms/image-generation
+"""
+
+from typing import TYPE_CHECKING, Final
+
+import httpx
+
+from litellm.litellm_core_utils.core_helpers import set_response_cost_in_hidden_params
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.image_generation.transformation import BaseImageGenerationConfig
+from litellm.types.llms.openai import AllMessageValues, OpenAIImageGenerationOptionalParams
+from litellm.types.utils import ImageResponse
+from litellm.utils import convert_to_model_response_object
+
+from ..common_utils import EdenAIException, endpoint_url, json_headers, pick, reported_cost
+
+if TYPE_CHECKING:
+ import tiktoken
+
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+_SUPPORTED_PARAMS: Final[tuple[OpenAIImageGenerationOptionalParams, ...]] = (
+ "background",
+ "moderation",
+ "n",
+ "output_compression",
+ "output_format",
+ "quality",
+ "response_format",
+ "size",
+ "style",
+ "user",
+)
+
+
+class EdenAIImageGenerationConfig(BaseImageGenerationConfig):
+ def get_supported_openai_params(
+ self, model: str
+ ) -> list[OpenAIImageGenerationOptionalParams]: # mutable-ok: inherited contract
+ return list(_SUPPORTED_PARAMS) # mutable-ok: inherited contract
+
+ def map_openai_params(
+ self,
+ non_default_params: dict[str, object], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ drop_params: bool,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return {**optional_params, **pick(non_default_params, _SUPPORTED_PARAMS)} # mutable-ok: inherited contract
+
+ def get_complete_url(
+ self,
+ api_base: str | None,
+ api_key: str | None,
+ model: str,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ stream: bool | None = None,
+ ) -> str:
+ return endpoint_url(api_base, "images/generations")
+
+ def validate_environment(
+ self,
+ headers: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ messages: list[AllMessageValues], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ api_key: str | None = None,
+ api_base: str | None = None,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return json_headers(headers, api_key, model)
+
+ def transform_image_generation_request(
+ self,
+ model: str,
+ prompt: str,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ headers: dict[str, object], # mutable-ok: inherited contract
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return {"model": model, "prompt": prompt, **optional_params} # mutable-ok: inherited contract
+
+ def transform_image_generation_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ model_response: ImageResponse,
+ logging_obj: "LiteLLMLoggingObj",
+ request_data: dict[str, object], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ encoding: "tiktoken.Encoding | None",
+ api_key: str | None = None,
+ json_mode: bool | None = None,
+ ) -> ImageResponse:
+ body: Final = raw_response.json()
+ logging_obj.post_call(original_response=body)
+ response: Final[ImageResponse] = convert_to_model_response_object(
+ response_object=body, model_response_object=model_response, response_type="image_generation"
+ )
+ set_response_cost_in_hidden_params(response, reported_cost(body))
+ return response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
diff --git a/litellm/llms/edenai/messages/transformation.py b/litellm/llms/edenai/messages/transformation.py
new file mode 100644
index 00000000000..fbb3ae0c05a
--- /dev/null
+++ b/litellm/llms/edenai/messages/transformation.py
@@ -0,0 +1,79 @@
+"""
+Support for Anthropic's `/v1/messages` endpoint on Eden AI.
+
+Eden AI serves the Anthropic Messages API at `/v3/v1/messages` for every model in its catalog, so
+the Anthropic payload is forwarded untranslated and the answer comes back in Anthropic's shape with
+Eden's per-request `cost` beside it. Eden does not report a cost inside a Messages stream yet, so
+streams fall back to the price map.
+
+Docs: https://www.edenai.co/docs/api-reference/anthropic-messages/create-anthropic-message
+"""
+
+from typing import TYPE_CHECKING, Final
+
+import httpx
+
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.openai_like.json_loader import SimpleProviderConfig
+from litellm.llms.openai_like.messages.transformation import JSONProviderAnthropicMessagesConfig
+from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicMessagesResponse
+from litellm.types.utils import LlmProviders
+
+from ..common_utils import EDENAI_API_BASE, EdenAIException, reported_cost, require_api_key
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+_EDENAI_PROVIDER_SPEC: Final[dict[str, str]] = { # mutable-ok: SimpleProviderConfig takes a plain dict
+ "base_url": EDENAI_API_BASE,
+ "api_key_env": "EDENAI_API_KEY",
+ "api_base_env": "EDENAI_API_BASE",
+}
+_EDENAI_PROVIDER: Final = SimpleProviderConfig(LlmProviders.EDENAI.value, _EDENAI_PROVIDER_SPEC)
+
+
+class EdenAIAnthropicMessagesConfig(JSONProviderAnthropicMessagesConfig):
+ def __init__(self) -> None:
+ super().__init__(_EDENAI_PROVIDER)
+
+ def validate_anthropic_messages_environment(
+ self,
+ headers: dict[str, str], # mutable-ok: inherited contract
+ model: str,
+ messages: list[object], # mutable-ok: inherited contract
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ api_key: str | None = None,
+ api_base: str | None = None,
+ ) -> tuple[dict[str, str], str | None]: # mutable-ok: inherited contract
+ return super().validate_anthropic_messages_environment(
+ headers=headers,
+ model=model,
+ messages=messages,
+ optional_params=optional_params,
+ litellm_params=litellm_params,
+ api_key=require_api_key(api_key, model),
+ api_base=api_base,
+ )
+
+ def transform_anthropic_messages_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ ) -> AnthropicMessagesResponse:
+ response: Final = super().transform_anthropic_messages_response(
+ model=model, raw_response=raw_response, logging_obj=logging_obj
+ )
+ cost: Final = reported_cost(response)
+ if cost is not None:
+ logging_obj.model_call_details["response_cost"] = cost # rebind-ok: the per-call record spend logging reads
+ return response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
diff --git a/litellm/llms/edenai/responses/transformation.py b/litellm/llms/edenai/responses/transformation.py
new file mode 100644
index 00000000000..3274e70746c
--- /dev/null
+++ b/litellm/llms/edenai/responses/transformation.py
@@ -0,0 +1,80 @@
+"""
+Support for OpenAI's `/v1/responses` endpoint on Eden AI.
+
+Eden AI serves the Responses API at `/v3/responses` in OpenAI's wire format, so the OpenAI config
+does the work; this one points it at Eden and authenticates with the Eden key. Eden reports the
+per-request cost on `usage.cost` of every body, the final `response.completed` event included, so
+the shared usage-cost lift bills both modes.
+
+Docs: https://www.edenai.co/docs/v3/llms/responses
+"""
+
+from typing import TYPE_CHECKING, Final
+
+import httpx
+
+from litellm.litellm_core_utils.core_helpers import set_response_cost_in_hidden_params
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig
+from litellm.types.llms.openai import ResponsesAPIResponse
+from litellm.types.router import GenericLiteLLMParams
+from litellm.types.utils import LlmProviders
+
+from ..common_utils import EdenAIException, authorized_headers, resolve_api_base
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+
+class EdenAIResponsesAPIConfig(OpenAIResponsesAPIConfig):
+ @property
+ def custom_llm_provider(self) -> LlmProviders:
+ return LlmProviders.EDENAI
+
+ def validate_environment(
+ self,
+ headers: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ litellm_params: GenericLiteLLMParams | None,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return authorized_headers(headers, litellm_params.api_key if litellm_params else None, model)
+
+ def get_complete_url(
+ self,
+ api_base: str | None,
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ ) -> str:
+ return super().get_complete_url(api_base=resolve_api_base(api_base), litellm_params=litellm_params)
+
+ def transform_response_api_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ ) -> ResponsesAPIResponse:
+ response: Final = super().transform_response_api_response(
+ model=model, raw_response=raw_response, logging_obj=logging_obj
+ )
+ set_response_cost_in_hidden_params(response, response.usage.cost if response.usage else None)
+ return response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
+
+ def should_fake_stream(
+ self,
+ model: str | None,
+ stream: bool | None,
+ custom_llm_provider: str | None = None,
+ ) -> bool:
+ """Eden streams every catalog model natively; the base class would fake-stream any model the
+ price map does not know, which is all of them."""
+ return False
+
+ def supports_native_websocket(self) -> bool:
+ return False
diff --git a/litellm/llms/edenai/text_to_speech/transformation.py b/litellm/llms/edenai/text_to_speech/transformation.py
new file mode 100644
index 00000000000..50c7ed96725
--- /dev/null
+++ b/litellm/llms/edenai/text_to_speech/transformation.py
@@ -0,0 +1,85 @@
+"""
+Support for OpenAI's `/v1/audio/speech` endpoint on Eden AI, served at `/v3/audio/speech`. The answer
+is raw audio, so the real per-request cost travels in the `x-edenai-cost` response header.
+
+Docs: https://www.edenai.co/docs/api-reference/audio/audio-speech
+"""
+
+from typing import TYPE_CHECKING, Final
+
+import httpx
+
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.base_llm.text_to_speech.transformation import BaseTextToSpeechConfig, TextToSpeechRequestData
+from litellm.types.llms.openai import HttpxBinaryResponseContent
+
+from ..common_utils import EdenAIException, endpoint_url, json_headers, reported_cost
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+_SUPPORTED_PARAMS: Final = ("voice", "response_format", "speed", "instructions")
+
+
+class EdenAITextToSpeechConfig(BaseTextToSpeechConfig):
+ def get_supported_openai_params(self, model: str) -> list[str]: # mutable-ok: inherited contract
+ return list(_SUPPORTED_PARAMS) # mutable-ok: inherited contract
+
+ def map_openai_params(
+ self,
+ model: str,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ voice: str | dict[str, object] | None = None, # mutable-ok: inherited contract
+ drop_params: bool = False,
+ kwargs: dict[str, object] | None = None, # mutable-ok: inherited contract
+ ) -> tuple[str | None, dict[str, object]]: # mutable-ok: inherited contract
+ return (voice if isinstance(voice, str) else None), optional_params
+
+ def validate_environment(
+ self,
+ headers: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ api_key: str | None = None,
+ api_base: str | None = None,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return json_headers(headers, api_key, model)
+
+ def get_complete_url(
+ self,
+ model: str,
+ api_base: str | None,
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ ) -> str:
+ return endpoint_url(api_base, "audio/speech")
+
+ def transform_text_to_speech_request(
+ self,
+ model: str,
+ input: str,
+ voice: str | None,
+ optional_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ headers: dict[str, object], # mutable-ok: inherited contract
+ ) -> TextToSpeechRequestData:
+ fields: Final = (("model", model), ("input", input), ("voice", voice), *optional_params.items())
+ return TextToSpeechRequestData(
+ dict_body={key: value for key, value in fields if value is not None} # mutable-ok: TypedDict field
+ )
+
+ def transform_text_to_speech_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ ) -> HttpxBinaryResponseContent:
+ response: Final = HttpxBinaryResponseContent(response=raw_response)
+ response.set_response_cost(reported_cost(raw_response.headers))
+ return response
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
diff --git a/litellm/llms/edenai/videos/transformation.py b/litellm/llms/edenai/videos/transformation.py
new file mode 100644
index 00000000000..25c7bcf24ea
--- /dev/null
+++ b/litellm/llms/edenai/videos/transformation.py
@@ -0,0 +1,146 @@
+"""
+Support for OpenAI's `/v1/videos` API on Eden AI, served at `/v3/videos`. A job is created, polled and
+downloaded through the OpenAI routes; Eden reports `cost` as 0 on the create response and the settled
+amount on the status read once the job completes or fails.
+
+Docs: https://www.edenai.co/docs/v3/llms/video-generation
+"""
+
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Final
+
+import httpx
+from httpx._types import RequestFiles
+
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.openai.videos.transformation import OpenAIVideoConfig
+from litellm.types.router import GenericLiteLLMParams
+from litellm.types.videos.main import VideoObject
+
+from ..common_utils import EdenAIException, authorized_headers, endpoint_url, reported_cost
+
+if TYPE_CHECKING:
+ from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
+
+
+def _usage_with_reported_cost(
+ usage: Mapping[str, object] | None, body: bytes
+) -> dict[str, object]: # mutable-ok: VideoObject.usage is a plain dict field
+ cost: Final = reported_cost(body)
+ return { # mutable-ok: VideoObject.usage is a plain dict field
+ key: value
+ for key, value in (*(usage.items() if usage else ()), ("provider_reported_cost_usd", cost))
+ if value is not None
+ }
+
+
+class EdenAIVideoConfig(OpenAIVideoConfig):
+ def validate_environment(
+ self,
+ headers: dict[str, object], # mutable-ok: inherited contract
+ model: str,
+ api_key: str | None = None,
+ litellm_params: GenericLiteLLMParams | None = None,
+ ) -> dict[str, object]: # mutable-ok: inherited contract
+ return authorized_headers(headers, api_key or (litellm_params.api_key if litellm_params else None), model)
+
+ def get_complete_url(
+ self,
+ model: str,
+ api_base: str | None,
+ litellm_params: dict[str, object], # mutable-ok: inherited contract
+ ) -> str:
+ return endpoint_url(api_base, "videos")
+
+ def use_multipart_form_data(self) -> bool:
+ return False
+
+ def transform_video_create_request(
+ self,
+ model: str,
+ prompt: str,
+ api_base: str,
+ video_create_optional_request_params: dict[str, object], # mutable-ok: inherited contract
+ litellm_params: GenericLiteLLMParams,
+ headers: dict[str, object], # mutable-ok: inherited contract
+ ) -> tuple[dict[str, object], RequestFiles, str]: # mutable-ok: inherited contract
+ """A reference image is a multipart file part, or a JSON `{"file_id"}` / `{"image_url"}` object."""
+ reference: Final = video_create_optional_request_params.get("input_reference")
+ if not isinstance(reference, Mapping):
+ return super().transform_video_create_request(
+ model=model,
+ prompt=prompt,
+ api_base=api_base,
+ video_create_optional_request_params=video_create_optional_request_params,
+ litellm_params=litellm_params,
+ headers=headers,
+ )
+ data, files, url = super().transform_video_create_request(
+ model=model,
+ prompt=prompt,
+ api_base=api_base,
+ video_create_optional_request_params={ # mutable-ok: inherited contract
+ key: value for key, value in video_create_optional_request_params.items() if key != "input_reference"
+ },
+ litellm_params=litellm_params,
+ headers=headers,
+ )
+ return {**data, "input_reference": dict(reference)}, files, url # mutable-ok: JSON body
+
+ def transform_video_create_response(
+ self,
+ model: str,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ custom_llm_provider: str | None = None,
+ request_data: dict[str, object] | None = None, # mutable-ok: inherited contract
+ ) -> VideoObject:
+ video: Final = super().transform_video_create_response(
+ model=model,
+ raw_response=raw_response,
+ logging_obj=logging_obj,
+ custom_llm_provider=custom_llm_provider,
+ request_data=request_data,
+ )
+ video.usage = _usage_with_reported_cost(video.usage, raw_response.content)
+ return video
+
+ def transform_video_status_retrieve_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ custom_llm_provider: str | None = None,
+ ) -> VideoObject:
+ raw_response.raise_for_status() # the shared GET helpers return error bodies instead of raising
+ video: Final = super().transform_video_status_retrieve_response(
+ raw_response=raw_response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider
+ )
+ video.usage = _usage_with_reported_cost(video.usage, raw_response.content)
+ return video
+
+ def transform_video_content_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ ) -> bytes:
+ raw_response.raise_for_status() # the shared GET helpers return error bodies instead of raising
+ return raw_response.content
+
+ def transform_video_list_response(
+ self,
+ raw_response: httpx.Response,
+ logging_obj: "LiteLLMLoggingObj",
+ custom_llm_provider: str | None = None,
+ ) -> dict[str, str]: # mutable-ok: inherited contract
+ raw_response.raise_for_status() # the shared GET helpers return error bodies instead of raising
+ return super().transform_video_list_response(
+ raw_response=raw_response, logging_obj=logging_obj, custom_llm_provider=custom_llm_provider
+ )
+
+ def get_error_class(
+ self,
+ error_message: str,
+ status_code: int,
+ headers: dict[str, object] | httpx.Headers, # mutable-ok: inherited contract
+ ) -> BaseLLMException:
+ return EdenAIException(message=error_message, status_code=status_code, headers=headers)
diff --git a/litellm/main.py b/litellm/main.py
index 66466f01da4..6704358e3ea 100644
--- a/litellm/main.py
+++ b/litellm/main.py
@@ -3568,6 +3568,32 @@ def _complete_vercel_ai_gateway(
return response
+def _complete_edenai(ctx: _CompletionDispatchContext) -> _CompletionDispatchResult:
+ api_base: Final = litellm.EdenAIChatConfig.get_api_base(ctx.api_base)
+ api_key: Final = litellm.EdenAIChatConfig.get_api_key(ctx.api_key or litellm.api_key)
+ response: Final = base_llm_http_handler.completion(
+ model=ctx.model,
+ messages=ctx.messages,
+ api_base=api_base,
+ custom_llm_provider="edenai",
+ model_response=ctx.model_response,
+ encoding=_get_encoding(),
+ logging_obj=ctx.logging,
+ optional_params=ctx.optional_params,
+ timeout=ctx.timeout,
+ litellm_params=ctx.litellm_params,
+ shared_session=ctx.shared_session,
+ acompletion=ctx.acompletion,
+ stream=ctx.stream,
+ api_key=api_key,
+ headers=ctx.headers or litellm.headers,
+ client=_dispatch_client_http(ctx),
+ provider_config=ctx.provider_config,
+ )
+ ctx.logging.post_call(input=ctx.messages, api_key=api_key, original_response=response)
+ return response
+
+
def _complete_vertex_ai_beta(
ctx: _CompletionDispatchContext,
) -> _CompletionDispatchResult:
@@ -5771,6 +5797,8 @@ def completion(
response = _complete_minimax(_dispatch_ctx)
elif custom_llm_provider == "hosted_vllm":
response = _complete_hosted_vllm(_dispatch_ctx)
+ elif custom_llm_provider == "edenai":
+ response = _complete_edenai(_dispatch_ctx) # rebind-ok: dispatch chain binds response per branch
elif (
# A known OpenAI model name only decides the route when nothing else
# resolved a provider. get_llm_provider() already maps these names to
@@ -6440,6 +6468,22 @@ def embedding(
litellm_params=litellm_params_dict,
headers=headers or {},
)
+ elif custom_llm_provider == "edenai":
+ response = base_llm_http_handler.embedding(
+ model=model,
+ input=input,
+ custom_llm_provider=custom_llm_provider,
+ api_base=api_base,
+ api_key=api_key,
+ logging_obj=logging,
+ timeout=timeout,
+ model_response=EmbeddingResponse(),
+ optional_params=optional_params,
+ client=client,
+ aembedding=aembedding,
+ litellm_params=litellm_params_dict,
+ headers=headers,
+ )
elif (
custom_llm_provider == "openai_like"
or custom_llm_provider == "llamafile"
@@ -8142,7 +8186,23 @@ def speech(
custom_llm_provider=custom_llm_provider,
)
response: HttpxBinaryResponseContent | Coroutine[object, object, HttpxBinaryResponseContent] | None = None
- if custom_llm_provider == "openai" or (
+ if custom_llm_provider == "edenai":
+ litellm_params_dict["api_base"] = api_base
+ response = base_llm_http_handler.text_to_speech_handler(
+ model=model,
+ input=input,
+ voice=voice if isinstance(voice, str) else None,
+ text_to_speech_provider_config=text_to_speech_provider_config or litellm.EdenAITextToSpeechConfig(),
+ text_to_speech_optional_params=optional_params,
+ custom_llm_provider=custom_llm_provider,
+ litellm_params=litellm_params_dict,
+ logging_obj=logging_obj,
+ timeout=timeout,
+ extra_headers=extra_headers,
+ client=client,
+ _is_async=aspeech or False,
+ )
+ elif custom_llm_provider == "openai" or (
custom_llm_provider in litellm.openai_compatible_providers
and custom_llm_provider not in AZURE_OPENAI_AUDIO_PROVIDERS
):
diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json
index e87db5bf593..1fcb7600a5e 100644
--- a/litellm/provider_endpoints_support_backup.json
+++ b/litellm/provider_endpoints_support_backup.json
@@ -815,6 +815,24 @@
"interactions": true
}
},
+ "edenai": {
+ "display_name": "Eden AI (`edenai`)",
+ "url": "https://docs.litellm.ai/docs/providers/edenai",
+ "endpoints": {
+ "chat_completions": true,
+ "messages": true,
+ "responses": true,
+ "embeddings": true,
+ "image_generations": true,
+ "audio_transcriptions": true,
+ "audio_speech": true,
+ "moderations": false,
+ "batches": false,
+ "rerank": false,
+ "interactions": false,
+ "video_generations": true
+ }
+ },
"duckduckgo": {
"display_name": "DuckDuckGo (`duckduckgo`)",
"url": "https://docs.litellm.ai/docs/search/duckduckgo",
diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json
index cb2bc540b55..0ca08cb7992 100644
--- a/litellm/proxy/public_endpoints/provider_create_fields.json
+++ b/litellm/proxy/public_endpoints/provider_create_fields.json
@@ -1321,6 +1321,34 @@
],
"default_model_placeholder": "gpt-3.5-turbo"
},
+ {
+ "provider": "EDENAI",
+ "provider_display_name": "Eden AI",
+ "litellm_provider": "edenai",
+ "credential_fields": [
+ {
+ "key": "api_base",
+ "label": "API Base",
+ "placeholder": "https://api.edenai.run/v3",
+ "tooltip": "Set to https://api.eu.edenai.run/v3 for the EU endpoint",
+ "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": "edenai/openai/gpt-mini-latest"
+ },
{
"provider": "ElevenLabs",
"provider_display_name": "ElevenLabs",
diff --git a/litellm/types/utils.py b/litellm/types/utils.py
index 82cd0250857..142304223d0 100644
--- a/litellm/types/utils.py
+++ b/litellm/types/utils.py
@@ -4161,6 +4161,7 @@ class LlmProviders(str, Enum):
OCI = "oci"
AUTO_ROUTER = "auto_router"
VERCEL_AI_GATEWAY = "vercel_ai_gateway"
+ EDENAI = "edenai"
DOTPROMPT = "dotprompt"
MANUS = "manus"
WANDB = "wandb"
diff --git a/litellm/utils.py b/litellm/utils.py
index 0c3a4bdd577..709f3f6d1dd 100644
--- a/litellm/utils.py
+++ b/litellm/utils.py
@@ -3313,6 +3313,9 @@ def register_model(
elif value.get("litellm_provider") == "vercel_ai_gateway":
if key not in litellm.vercel_ai_gateway_models:
litellm.vercel_ai_gateway_models.add(key)
+ elif value.get("litellm_provider") == "edenai":
+ if key not in litellm.edenai_models:
+ litellm.edenai_models.add(key)
elif value.get("litellm_provider") == "vertex_ai-text-models":
if key not in litellm.vertex_text_models:
litellm.vertex_text_models.add(key)
@@ -4895,6 +4898,9 @@ def get_optional_params(
return optional_params
+EXTRA_BODY_ROUTING_KEYS: Final = frozenset({"model"})
+
+
def add_provider_specific_params_to_optional_params(
optional_params: dict,
passed_params: dict,
@@ -4920,10 +4926,8 @@ def add_provider_specific_params_to_optional_params(
**extra_body,
}
- if additional_drop_params is not None:
- processed_extra_body = {k: v for k, v in initial_extra_body.items() if k not in additional_drop_params}
- else:
- processed_extra_body = initial_extra_body
+ dropped_keys: Final = EXTRA_BODY_ROUTING_KEYS | frozenset(additional_drop_params or ())
+ processed_extra_body: Final = {k: v for k, v in initial_extra_body.items() if k not in dropped_keys}
_ensure_extra_body_is_safe: Final = getattr(sys.modules[__name__], "_ensure_extra_body_is_safe")
optional_params["extra_body"] = _ensure_extra_body_is_safe(extra_body=processed_extra_body)
@@ -6574,6 +6578,11 @@ def validate_environment(
keys_in_environment = True
else:
missing_keys.append("VERCEL_AI_GATEWAY_API_KEY")
+ elif custom_llm_provider == "edenai":
+ if "EDENAI_API_KEY" in os.environ:
+ keys_in_environment = True
+ else:
+ missing_keys.append("EDENAI_API_KEY")
elif custom_llm_provider == "datarobot":
if "DATAROBOT_API_TOKEN" in os.environ:
keys_in_environment = True
@@ -6824,6 +6833,12 @@ def validate_environment(
keys_in_environment = True
else:
missing_keys.append("VERCEL_AI_GATEWAY_API_KEY")
+ ## edenai
+ elif model in litellm.edenai_models:
+ if "EDENAI_API_KEY" in os.environ:
+ keys_in_environment = True
+ else:
+ missing_keys.append("EDENAI_API_KEY")
## datarobot
elif model in litellm.datarobot_models:
if "DATAROBOT_API_TOKEN" in os.environ:
@@ -8324,6 +8339,7 @@ class ProviderConfigManager:
lambda: litellm.VercelAIGatewayConfig(),
False,
),
+ LlmProviders.EDENAI: (litellm.EdenAIChatConfig, False),
LlmProviders.COMETAPI: (lambda: litellm.CometAPIConfig(), False),
LlmProviders.DATAROBOT: (lambda: litellm.DataRobotConfig(), False),
LlmProviders.GEMINI: (lambda: litellm.GoogleAIStudioGeminiConfig(), False),
@@ -8626,6 +8642,8 @@ class ProviderConfigManager:
return SagemakerEmbeddingConfig.get_model_config(model)
elif litellm.LlmProviders.PERPLEXITY == provider:
return litellm.PerplexityEmbeddingConfig()
+ elif litellm.LlmProviders.EDENAI == provider:
+ return litellm.EdenAIEmbeddingConfig()
return None
@staticmethod
@@ -8746,6 +8764,8 @@ class ProviderConfigManager:
)
return GithubCopilotAnthropicMessagesConfig()
+ elif litellm.LlmProviders.EDENAI == provider:
+ return litellm.EdenAIAnthropicMessagesConfig()
from litellm.llms.openai_like.json_loader import JSONProviderRegistry
@@ -8854,6 +8874,8 @@ class ProviderConfigManager:
)
return GeminiAudioTranscriptionConfig()
+ elif litellm.LlmProviders.EDENAI == provider:
+ return litellm.EdenAIAudioTranscriptionConfig()
return None
@staticmethod
@@ -8956,6 +8978,8 @@ class ProviderConfigManager:
return litellm.HostedVLLMResponsesAPIConfig()
elif litellm.LlmProviders.FIREWORKS_AI == provider:
return litellm.FireworksAIResponsesAPIConfig()
+ elif litellm.LlmProviders.EDENAI == provider:
+ return litellm.EdenAIResponsesAPIConfig()
elif litellm.LlmProviders.BEDROCK_MANTLE == provider:
# Both decisions are data-driven from the model's price-map entry, with
# no model-name logic. Capability (can it serve Responses?) comes from
@@ -9028,7 +9052,7 @@ class ProviderConfigManager:
return litellm.OpenAITextCompletionConfig()
@staticmethod
- def get_provider_model_info(
+ def get_provider_model_info( # noqa: C901 # provider dispatch table, one branch per provider
model: str | None,
provider: LlmProviders,
) -> BaseLLMModelInfo | None:
@@ -9065,6 +9089,8 @@ class ProviderConfigManager:
return litellm.LemonadeChatConfig()
elif LlmProviders.CLARIFAI == provider:
return litellm.ClarifaiConfig()
+ elif LlmProviders.EDENAI == provider:
+ return litellm.EdenAIChatConfig()
elif LlmProviders.BEDROCK == provider:
from litellm.llms.bedrock.common_utils import BedrockModelInfo
@@ -9413,6 +9439,8 @@ class ProviderConfigManager:
)
return get_modelscope_image_generation_config(model)
+ elif LlmProviders.EDENAI == provider:
+ return litellm.EdenAIImageGenerationConfig()
return None
@staticmethod
@@ -9448,6 +9476,8 @@ class ProviderConfigManager:
from litellm.llms.hosted_vllm.videos import get_hosted_vllm_video_config
return get_hosted_vllm_video_config(model)
+ elif LlmProviders.EDENAI == provider:
+ return litellm.EdenAIVideoConfig()
return None
@staticmethod
@@ -9768,6 +9798,8 @@ class ProviderConfigManager:
)
return AWSPollyTextToSpeechConfig()
+ elif litellm.LlmProviders.EDENAI == provider:
+ return litellm.EdenAITextToSpeechConfig()
return None
@staticmethod
diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json
index c208d4edaaa..b8d1621cde3 100644
--- a/provider_endpoints_support.json
+++ b/provider_endpoints_support.json
@@ -868,6 +868,24 @@
"interactions": true
}
},
+ "edenai": {
+ "display_name": "Eden AI (`edenai`)",
+ "url": "https://docs.litellm.ai/docs/providers/edenai",
+ "endpoints": {
+ "chat_completions": true,
+ "messages": true,
+ "responses": true,
+ "embeddings": true,
+ "image_generations": true,
+ "audio_transcriptions": true,
+ "audio_speech": true,
+ "moderations": false,
+ "batches": false,
+ "rerank": false,
+ "interactions": false,
+ "video_generations": true
+ }
+ },
"duckduckgo": {
"display_name": "DuckDuckGo (`duckduckgo`)",
"url": "https://docs.litellm.ai/docs/search/duckduckgo",
diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py
index 325052ebda9..277ae33a076 100644
--- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py
+++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py
@@ -25,7 +25,7 @@ from litellm.litellm_core_utils.litellm_logging import (
set_callbacks,
)
from litellm.llms.base_llm.ocr.transformation import OCRUsageInfo
-from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse
+from litellm.types.llms.openai import ResponseAPIUsage, ResponseCompletedEvent, ResponsesAPIResponse
from litellm.types.utils import (
CallTypes,
LiteLLMRealtimeStreamLoggingObject,
@@ -7415,3 +7415,55 @@ class TestAzurePTUSpilloverCost:
finally:
litellm.model_cost.pop(custom_model_id, None)
self._unregister_models()
+
+
+def _completed_responses_event(usage: ResponseAPIUsage) -> ResponseCompletedEvent:
+ return ResponseCompletedEvent(
+ type="response.completed",
+ response=ResponsesAPIResponse(
+ id="resp-1", created_at=1, object="response", status="completed", model="codex-mini-latest", output=[], usage=usage
+ ),
+ )
+
+
+def _responses_stream_logging_obj() -> LitellmLogging:
+ logging_obj = _make_logging_obj(stream=True)
+ logging_obj.update_environment_variables(
+ model="openai/codex-mini-latest", user="", optional_params={}, litellm_params={"api_base": ""}
+ )
+ return logging_obj
+
+
+def test_get_assembled_streaming_response_bills_a_provider_reported_usage_cost():
+ """A Responses stream whose completed event carries ``usage.cost`` is billed that number,
+ the way an assembled chat stream already is, instead of a price-map estimate."""
+ logging_obj = _responses_stream_logging_obj()
+ now = datetime.datetime.now()
+
+ assembled = logging_obj._get_assembled_streaming_response(
+ result=_completed_responses_event(ResponseAPIUsage(input_tokens=12, output_tokens=2, total_tokens=14, cost=0.0042)),
+ start_time=now,
+ end_time=now,
+ is_async=True,
+ streaming_chunks=[],
+ )
+
+ assert assembled._hidden_params["additional_headers"]["llm_provider-x-litellm-response-cost"] == 0.0042
+ assert logging_obj._response_cost_calculator(result=assembled) == 0.0042
+
+
+def test_get_assembled_streaming_response_without_usage_cost_leaves_pricing_to_the_price_map():
+ logging_obj = _responses_stream_logging_obj()
+ now = datetime.datetime.now()
+
+ assembled = logging_obj._get_assembled_streaming_response(
+ result=_completed_responses_event(ResponseAPIUsage(input_tokens=12, output_tokens=2, total_tokens=14)),
+ start_time=now,
+ end_time=now,
+ is_async=True,
+ streaming_chunks=[],
+ )
+
+ assert "additional_headers" not in assembled._hidden_params
+ price_map_cost = logging_obj._response_cost_calculator(result=assembled)
+ assert price_map_cost is not None and 0 < price_map_cost != 0.0042
diff --git a/tests/test_litellm/llms/edenai/audio_transcription/test_edenai_audio_transcription_transformation.py b/tests/test_litellm/llms/edenai/audio_transcription/test_edenai_audio_transcription_transformation.py
new file mode 100644
index 00000000000..3f6cba8a91b
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/audio_transcription/test_edenai_audio_transcription_transformation.py
@@ -0,0 +1,155 @@
+"""Eden AI `/v3/audio/transcriptions`: OpenAI's speech-to-text API served by Eden's gateway, which
+reports the real per-request cost at the top level of the JSON body."""
+
+import httpx
+import pytest
+
+import litellm
+from litellm.cost_calculator import get_response_cost_from_hidden_params
+from litellm.llms.edenai.audio_transcription.transformation import EdenAIAudioTranscriptionConfig
+from litellm.llms.edenai.common_utils import EdenAIException
+from litellm.types.utils import LlmProviders, TranscriptionResponse
+from litellm.utils import ProviderConfigManager
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_TRANSCRIPTIONS_URL = f"{EDEN_BASE}/audio/transcriptions"
+EDEN_REPORTED_COST = 0.0042
+MODEL = "edenai/openai/whisper-1"
+SELLER_MODEL = "openai/whisper-1"
+AUDIO_FILE = ("hello.mp3", b"ID3\x04\x00fake-mp3-bytes", "audio/mpeg")
+
+
+def _eden_transcription(cost: float | None = EDEN_REPORTED_COST) -> dict:
+ """Live `/v3/audio/transcriptions` body: Whisper's verbose shape plus Eden's top-level `cost`
+ and `provider`, with `duration` present whatever `response_format` was asked for."""
+ body = {
+ "text": "Hello there.",
+ "usage": {"type": "duration", "seconds": 1.0},
+ "language": "english",
+ "task": "transcribe",
+ "duration": 0.62,
+ "words": None,
+ "segments": [{"id": 0, "start": 0.0, "end": 0.8, "text": " Hello there."}],
+ "provider": "openai",
+ }
+ return body if cost is None else {**body, "cost": cost}
+
+
+def _multipart_body(respx_mock) -> str:
+ return respx_mock.calls.last.request.content.decode(errors="replace")
+
+
+class TestRegistration:
+ def test_eden_is_a_native_transcription_provider(self):
+ config = ProviderConfigManager.get_provider_audio_transcription_config(
+ model=SELLER_MODEL, provider=LlmProviders.EDENAI
+ )
+
+ assert isinstance(config, EdenAIAudioTranscriptionConfig)
+
+
+class TestRequestTransformation:
+ def test_sends_the_file_as_multipart_without_forcing_verbose_json(self):
+ request = EdenAIAudioTranscriptionConfig().transform_audio_transcription_request(
+ model=SELLER_MODEL, audio_file=AUDIO_FILE, optional_params={"language": "en"}, litellm_params={}
+ )
+
+ assert request.data == {"model": SELLER_MODEL, "language": "en"}
+ assert request.files == {"file": AUDIO_FILE}
+
+ def test_sdk_style_extra_body_is_flattened_into_form_fields(self):
+ """LiteLLM parks `model` and any non-OpenAI kwarg under `extra_body` for the OpenAI SDK, and a
+ nested dict cannot ride in a multipart form."""
+ request = EdenAIAudioTranscriptionConfig().transform_audio_transcription_request(
+ model=SELLER_MODEL,
+ audio_file=AUDIO_FILE,
+ optional_params={"language": "en", "extra_body": {"model": SELLER_MODEL, "user": "u-1"}},
+ litellm_params={},
+ )
+
+ assert request.data == {"model": SELLER_MODEL, "language": "en", "user": "u-1"}
+
+ def test_missing_key_is_an_authentication_error_before_any_request(self, no_eden_key, respx_mock):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ litellm.transcription(model=MODEL, file=AUDIO_FILE)
+ assert not respx_mock.calls
+
+
+class TestTranscription:
+ def test_posts_multipart_to_eden_with_the_bearer_key_and_the_seller_model_id(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_TRANSCRIPTIONS_URL).mock(return_value=httpx.Response(200, json=_eden_transcription()))
+
+ response = litellm.transcription(model=MODEL, file=AUDIO_FILE, language="en", temperature=0)
+
+ assert isinstance(response, TranscriptionResponse)
+ assert response.text == "Hello there."
+ request = respx_mock.calls.last.request
+ assert request.headers["Authorization"] == f"Bearer {eden_key}"
+ assert request.headers["Content-Type"].startswith("multipart/form-data")
+ body = _multipart_body(respx_mock)
+ assert f'name="model"\r\n\r\n{SELLER_MODEL}' in body
+ assert 'name="language"\r\n\r\nen' in body
+ assert 'name="temperature"\r\n\r\n0' in body
+ assert 'name="file"; filename="hello.mp3"' in body
+ assert "verbose_json" not in body
+
+ def test_eden_reported_cost_beats_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_TRANSCRIPTIONS_URL).mock(return_value=httpx.Response(200, json=_eden_transcription()))
+
+ response = litellm.transcription(model=MODEL, file=AUDIO_FILE)
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) == EDEN_REPORTED_COST
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+ def test_a_body_without_cost_leaves_pricing_to_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_TRANSCRIPTIONS_URL).mock(
+ return_value=httpx.Response(200, json=_eden_transcription(cost=None))
+ )
+
+ response = litellm.transcription(model=MODEL, file=AUDIO_FILE)
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) is None
+ assert response.duration == 0.62
+ assert response.usage is not None
+ assert response.usage.seconds == 1.0
+
+ def test_a_plain_text_answer_is_the_transcript(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_TRANSCRIPTIONS_URL).mock(
+ return_value=httpx.Response(200, text="Hello there.", headers={"content-type": "text/plain"})
+ )
+
+ response = litellm.transcription(model=MODEL, file=AUDIO_FILE, response_format="text")
+
+ assert response.text == "Hello there."
+ assert 'name="response_format"\r\n\r\ntext' in _multipart_body(respx_mock)
+
+ @pytest.mark.asyncio
+ async def test_async_call_tracks_the_same_cost(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.post(EDEN_TRANSCRIPTIONS_URL).mock(return_value=httpx.Response(200, json=_eden_transcription()))
+
+ response = await litellm.atranscription(model=MODEL, file=AUDIO_FILE)
+
+ assert response.text == "Hello there."
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+
+class TestErrors:
+ def test_sync_401_surfaces_as_an_eden_error_with_the_status_code(self, eden_key, respx_mock):
+ """`litellm.transcription` does not map provider errors onto the OpenAI exception classes the
+ way its async twin does, so the proxy relies on the status code the provider exception carries."""
+ respx_mock.post(EDEN_TRANSCRIPTIONS_URL).mock(
+ return_value=httpx.Response(401, json={"detail": "Invalid token."})
+ )
+
+ with pytest.raises(EdenAIException, match="Invalid token") as excinfo:
+ litellm.transcription(model=MODEL, file=AUDIO_FILE)
+ assert excinfo.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_async_401_maps_to_authentication_error(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.post(EDEN_TRANSCRIPTIONS_URL).mock(
+ return_value=httpx.Response(401, json={"detail": "Invalid token."})
+ )
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ await litellm.atranscription(model=MODEL, file=AUDIO_FILE)
diff --git a/tests/test_litellm/llms/edenai/chat/test_edenai_chat_transformation.py b/tests/test_litellm/llms/edenai/chat/test_edenai_chat_transformation.py
new file mode 100644
index 00000000000..4f1e2c11a51
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/chat/test_edenai_chat_transformation.py
@@ -0,0 +1,453 @@
+"""Eden AI (`edenai/...`) chat provider: an OpenAI-compatible gateway that reports the real
+per-request cost at the top level of every response instead of leaving it to the price map."""
+
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+
+import litellm
+from litellm.cost_calculator import get_response_cost_from_hidden_params, response_cost_calculator
+from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
+from litellm.llms.base_llm.chat.transformation import BaseLLMException
+from litellm.llms.edenai.chat.transformation import EdenAIChatCompletionStreamingHandler, EdenAIChatConfig
+from litellm.llms.edenai.common_utils import EdenAIException
+from litellm.proxy.auth.model_checks import get_provider_models
+from litellm.types.router import LiteLLM_Params
+from litellm.types.utils import LlmProviders
+from litellm.utils import ProviderConfigManager
+
+REPO_ROOT = Path(__file__).resolve().parents[5]
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_EU_BASE = "https://api.eu.edenai.run/v3"
+EDEN_CHAT_URL = f"{EDEN_BASE}/chat/completions"
+EDEN_REPORTED_COST = 0.0042
+EDEN_USAGE = {"completion_tokens": 1, "prompt_tokens": 9, "total_tokens": 10}
+MESSAGES = [{"role": "user", "content": "Say OK"}]
+
+
+def _eden_chat_completion(cost: float | None = EDEN_REPORTED_COST) -> dict:
+ """Live `/v3/chat/completions` body: OpenAI shape plus Eden's top-level `cost`, `provider`
+ and `status`, with `model` echoing the seller's bare model name."""
+ body = {
+ "status": "success",
+ "id": "chatcmpl-eden-1",
+ "created": 1788347376,
+ "model": "gpt-4.1-nano",
+ "object": "chat.completion",
+ "choices": [{"finish_reason": "stop", "index": 0, "message": {"content": "OK", "role": "assistant"}}],
+ "usage": EDEN_USAGE,
+ "provider": "openai",
+ }
+ return body if cost is None else {**body, "cost": cost}
+
+
+def _eden_stream_chunk(
+ delta: dict, finish_reason: str | None = None, usage: dict | None = None, cost: float | None = None
+) -> dict:
+ chunk = {
+ "id": "chatcmpl-eden-stream",
+ "created": 1788347377,
+ "model": "openai/gpt-4.1-nano",
+ "object": "chat.completion.chunk",
+ "choices": [{"finish_reason": finish_reason, "index": 0, "delta": delta, "logprobs": None}],
+ }
+ if usage is not None:
+ chunk["usage"] = usage
+ if cost is not None:
+ chunk["cost"] = cost
+ return chunk
+
+
+def _eden_stream_frames(cost: float | None = EDEN_REPORTED_COST) -> tuple[dict, ...]:
+ """Live stream with `stream_options.include_usage`: the usage frame comes after the
+ finish_reason frame, keeps one empty choice, and carries Eden's `cost` at the top level."""
+ return (
+ _eden_stream_chunk({"role": "assistant", "content": ""}),
+ _eden_stream_chunk({"content": "OK"}),
+ _eden_stream_chunk({"content": None}, finish_reason="stop"),
+ _eden_stream_chunk({"content": None, "role": None}, usage=EDEN_USAGE, cost=cost),
+ )
+
+
+def _sse(frames: tuple[dict, ...]) -> httpx.Response:
+ body = "".join(f"data: {json.dumps(frame)}\n\n" for frame in frames) + "data: [DONE]\n\n"
+ return httpx.Response(200, content=body.encode(), headers={"content-type": "text/event-stream"})
+
+
+def _request_body(respx_mock) -> dict:
+ return json.loads(respx_mock.calls.last.request.content)
+
+
+class TestProviderResolution:
+ @pytest.mark.parametrize(
+ "requested, sent_to_eden",
+ [
+ ("edenai/openai/gpt-4.1-nano", "openai/gpt-4.1-nano"),
+ ("edenai/gpt-4o", "gpt-4o"),
+ ("edenai/vertex/gemini-3.7-flash@eu", "vertex/gemini-3.7-flash@eu"),
+ ("edenai/fireworks_ai/accounts/fireworks/models/glm-5p3", "fireworks_ai/accounts/fireworks/models/glm-5p3"),
+ ("edenai/cloudflare/@cf/qwen/qwen3.8-27b", "cloudflare/@cf/qwen/qwen3.8-27b"),
+ ],
+ )
+ def test_strips_only_the_edenai_prefix(self, eden_key, requested, sent_to_eden):
+ model, provider, api_key, api_base = get_llm_provider(requested)
+
+ assert (model, provider, api_key, api_base) == (sent_to_eden, "edenai", eden_key, EDEN_BASE)
+
+ def test_env_api_base_moves_the_key_to_the_eu_endpoint(self, eden_key, monkeypatch):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+
+ _, provider, api_key, api_base = get_llm_provider("edenai/openai/gpt-4.1-nano")
+
+ assert (provider, api_key, api_base) == ("edenai", eden_key, EDEN_EU_BASE)
+
+ def test_explicit_credentials_win_over_env(self, eden_key):
+ _, _, api_key, api_base = get_llm_provider(
+ "edenai/openai/gpt-4.1-nano", api_key="explicit-key", api_base="https://eden.internal/v3"
+ )
+
+ assert (api_key, api_base) == ("explicit-key", "https://eden.internal/v3")
+
+ def test_eden_api_base_is_recognised_without_the_prefix(self, eden_key):
+ model, provider, api_key, api_base = get_llm_provider("gpt-4.1-nano", api_base=EDEN_BASE)
+
+ assert (model, provider, api_key, api_base) == ("gpt-4.1-nano", "edenai", eden_key, EDEN_BASE)
+
+
+class TestRegistration:
+ def test_provider_is_registered_everywhere_routing_looks(self):
+ assert LlmProviders.EDENAI.value == "edenai"
+ assert "edenai" in litellm.provider_list
+ assert "edenai" in litellm.openai_compatible_providers
+ assert EDEN_BASE in litellm.openai_compatible_endpoints
+ assert isinstance(
+ ProviderConfigManager.get_provider_chat_config(model="openai/gpt-4.1-nano", provider=LlmProviders.EDENAI),
+ EdenAIChatConfig,
+ )
+
+ def test_supported_params_are_the_openai_chat_params(self):
+ supported = litellm.get_supported_openai_params(model="openai/gpt-4.1-nano", custom_llm_provider="edenai")
+
+ assert supported is not None
+ assert {"tools", "tool_choice", "response_format", "stream_options", "max_completion_tokens"} <= set(supported)
+
+ def test_reasoning_effort_is_supported_only_for_models_the_price_map_flags_as_reasoning(self):
+ reasoning = litellm.get_supported_openai_params(model="openai/gpt-5-mini", custom_llm_provider="edenai")
+ plain = litellm.get_supported_openai_params(model="openai/gpt-4.1-nano", custom_llm_provider="edenai")
+
+ assert reasoning is not None and plain is not None
+ assert "reasoning_effort" in reasoning
+ assert "reasoning_effort" not in plain
+
+ def test_validate_environment_names_the_eden_key(self, monkeypatch):
+ monkeypatch.delenv("EDENAI_API_KEY", raising=False)
+ missing = litellm.validate_environment(model="edenai/openai/gpt-4.1-nano")
+ monkeypatch.setenv("EDENAI_API_KEY", "eden-test-key")
+ present = litellm.validate_environment(model="edenai/openai/gpt-4.1-nano")
+
+ assert (missing["keys_in_environment"], missing["missing_keys"]) == (False, ["EDENAI_API_KEY"])
+ assert (present["keys_in_environment"], present["missing_keys"]) == (True, [])
+
+ def test_a_model_registered_from_a_cost_map_still_asks_for_the_eden_key(self, monkeypatch):
+ """A cost map may name an Eden model without the `edenai/` prefix, leaving the provider
+ registry as the only way key validation can tell whose key the model needs."""
+ alias = "eden-cost-map-alias"
+ litellm.register_model(
+ {alias: {"litellm_provider": "edenai", "mode": "chat", "input_cost_per_token": 1e-06}},
+ persist_across_reloads=False,
+ )
+ try:
+ monkeypatch.delenv("EDENAI_API_KEY", raising=False)
+ missing = litellm.validate_environment(model=alias)
+ monkeypatch.setenv("EDENAI_API_KEY", "eden-test-key")
+ present = litellm.validate_environment(model=alias)
+ finally:
+ litellm.edenai_models.discard(alias)
+ litellm.model_cost.pop(alias, None)
+ litellm.add_known_models(model_cost_map={})
+
+ assert (missing["keys_in_environment"], missing["missing_keys"]) == (False, ["EDENAI_API_KEY"])
+ assert (present["keys_in_environment"], present["missing_keys"]) == (True, [])
+
+ def test_a_cost_map_reload_reaches_wildcard_expansion(self, eden_key):
+ """Wildcard expansion reads the provider registry, which a cost map reload rebuilds in
+ place, so models added after startup have to show up without a restart."""
+ alias = "edenai/openai/gpt-4.1-nano-from-cost-map"
+ wildcard = LiteLLM_Params(model="edenai/*", api_key="wildcard-key")
+ assert alias not in (get_provider_models("edenai", wildcard) or [])
+
+ litellm.add_known_models(model_cost_map={alias: {"litellm_provider": "edenai", "mode": "chat"}})
+ try:
+ expanded = get_provider_models("edenai", wildcard)
+ finally:
+ litellm.edenai_models.discard(alias)
+ litellm.add_known_models(model_cost_map={})
+
+ assert expanded is not None
+ assert alias in expanded
+ assert alias not in (get_provider_models("edenai", wildcard) or [])
+
+
+class TestRequestTransformation:
+ def _request(self, optional_params: dict) -> dict:
+ return EdenAIChatConfig().transform_request(
+ model="openai/gpt-4.1-nano",
+ messages=MESSAGES,
+ optional_params=optional_params,
+ litellm_params={},
+ headers={},
+ )
+
+ def test_streaming_request_asks_eden_for_the_usage_frame(self):
+ assert self._request({"stream": True})["stream_options"] == {"include_usage": True}
+
+ def test_streaming_request_overrides_a_caller_opt_out(self):
+ body = self._request({"stream": True, "stream_options": {"include_usage": False}})
+
+ assert body["stream_options"] == {"include_usage": True}
+
+ def test_non_streaming_request_carries_no_stream_options(self):
+ assert "stream_options" not in self._request({"max_tokens": 5})
+
+
+class TestCompletion:
+ def test_posts_to_eden_with_the_bearer_key_and_the_seller_model_id(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(200, json=_eden_chat_completion()))
+
+ response = litellm.completion(model="edenai/openai/gpt-4.1-nano", messages=MESSAGES, max_tokens=5)
+
+ assert response.choices[0].message.content == "OK"
+ assert respx_mock.calls.last.request.headers["Authorization"] == f"Bearer {eden_key}"
+ body = _request_body(respx_mock)
+ assert (body["model"], body["messages"], body["max_tokens"]) == ("openai/gpt-4.1-nano", MESSAGES, 5)
+
+ def test_reasoning_effort_reaches_eden_without_drop_params(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(200, json=_eden_chat_completion()))
+
+ litellm.completion(model="edenai/openai/gpt-5-mini", messages=MESSAGES, reasoning_effort="low")
+
+ assert _request_body(respx_mock)["reasoning_effort"] == "low"
+
+ def test_eden_reported_cost_beats_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(200, json=_eden_chat_completion()))
+
+ response = litellm.completion(model="edenai/openai/gpt-4.1-nano", messages=MESSAGES, max_tokens=5)
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) == EDEN_REPORTED_COST
+ assert (
+ response_cost_calculator(
+ response_object=response,
+ model="openai/gpt-4.1-nano",
+ custom_llm_provider="edenai",
+ call_type="completion",
+ optional_params={},
+ )
+ == EDEN_REPORTED_COST
+ )
+
+ def test_a_body_without_cost_leaves_pricing_to_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(200, json=_eden_chat_completion(cost=None)))
+
+ response = litellm.completion(model="edenai/openai/gpt-4.1-nano", messages=MESSAGES, max_tokens=5)
+
+ assert response.choices[0].message.content == "OK"
+ assert get_response_cost_from_hidden_params(response._hidden_params) is None
+
+ def test_extra_body_forwards_eden_only_fields(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(200, json=_eden_chat_completion()))
+
+ litellm.completion(
+ model="edenai/openai/gpt-4.1-nano",
+ messages=MESSAGES,
+ extra_body={"fallbacks": ["anthropic/claude-sonnet-latest"], "routing": {"sort": "latency"}},
+ )
+
+ body = _request_body(respx_mock)
+ assert body["fallbacks"] == ["anthropic/claude-sonnet-latest"]
+ assert body["routing"] == {"sort": "latency"}
+ assert "extra_body" not in body
+
+ def test_unknown_kwargs_ride_along_as_eden_fields(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(200, json=_eden_chat_completion()))
+
+ litellm.completion(model="edenai/openai/gpt-4.1-nano", messages=MESSAGES, routing={"sort": "latency"})
+
+ assert _request_body(respx_mock)["routing"] == {"sort": "latency"}
+
+
+class TestStreaming:
+ def test_include_usage_surfaces_eden_cost_on_the_usage_chunk(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=_sse(_eden_stream_frames()))
+
+ chunks = list(
+ litellm.completion(
+ model="edenai/openai/gpt-4.1-nano",
+ messages=MESSAGES,
+ stream=True,
+ stream_options={"include_usage": True},
+ )
+ )
+
+ assert _request_body(respx_mock)["stream_options"] == {"include_usage": True}
+ assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "OK"
+ usage_chunks = [chunk for chunk in chunks if getattr(chunk, "usage", None) is not None]
+ assert len(usage_chunks) == 1
+ assert (usage_chunks[0].usage.total_tokens, usage_chunks[0].usage.cost) == (10, EDEN_REPORTED_COST)
+
+ def test_without_include_usage_eden_cost_is_still_tracked_but_hidden(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=_sse(_eden_stream_frames()))
+
+ chunks = list(litellm.completion(model="edenai/openai/gpt-4.1-nano", messages=MESSAGES, stream=True))
+
+ assert _request_body(respx_mock)["stream_options"] == {"include_usage": True}
+ assert "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices) == "OK"
+ assert all(getattr(chunk, "usage", None) is None for chunk in chunks)
+ hidden_usage = chunks[-1]._hidden_params["usage"]
+ assert (hidden_usage.total_tokens, hidden_usage.cost) == (10, EDEN_REPORTED_COST)
+
+
+class TestStreamingHandler:
+ def _parse(self, chunk: dict):
+ return EdenAIChatCompletionStreamingHandler(streaming_response=None, sync_stream=True).chunk_parser(chunk)
+
+ def test_moves_top_level_cost_onto_the_usage_object(self):
+ parsed = self._parse(_eden_stream_chunk({"content": None}, usage=EDEN_USAGE, cost=EDEN_REPORTED_COST))
+
+ assert parsed.usage is not None
+ assert (parsed.usage.prompt_tokens, parsed.usage.cost) == (9, EDEN_REPORTED_COST)
+
+ def test_usage_without_cost_stays_unpriced(self):
+ parsed = self._parse(_eden_stream_chunk({"content": None}, usage=EDEN_USAGE))
+
+ assert parsed.usage is not None
+ assert getattr(parsed.usage, "cost", None) is None
+
+ def test_content_chunks_are_passed_through(self):
+ parsed = self._parse(_eden_stream_chunk({"content": "OK"}))
+
+ assert parsed.choices[0].delta.content == "OK"
+ assert getattr(parsed, "usage", None) is None
+
+
+class TestErrors:
+ def test_middleware_401_detail_body_maps_to_authentication_error(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token"}))
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ litellm.completion(model="edenai/openai/gpt-4.1-nano", messages=MESSAGES)
+
+ def test_unknown_model_envelope_maps_to_bad_request(self, eden_key, respx_mock):
+ envelope = {
+ "error": {
+ "message": "Model(s) not found or inactive: openai/does-not-exist",
+ "type": "invalid_request_error",
+ "param": None,
+ "code": "invalid_parameter",
+ }
+ }
+ respx_mock.post(EDEN_CHAT_URL).mock(return_value=httpx.Response(400, json=envelope))
+
+ with pytest.raises(litellm.BadRequestError, match="not found or inactive"):
+ litellm.completion(model="edenai/openai/does-not-exist", messages=MESSAGES)
+
+ def test_429_maps_to_rate_limit_error(self, eden_key, respx_mock):
+ envelope = {
+ "error": {"message": "Rate limit exceeded", "type": "rate_limit_error", "code": "rate_limit_exceeded"}
+ }
+ respx_mock.post(EDEN_CHAT_URL).mock(
+ return_value=httpx.Response(429, json=envelope, headers={"Retry-After": "7"})
+ )
+
+ with pytest.raises(litellm.RateLimitError, match="Rate limit exceeded"):
+ litellm.completion(model="edenai/openai/gpt-4.1-nano", messages=MESSAGES, num_retries=0)
+
+ def test_error_class_is_the_eden_exception(self):
+ error = EdenAIChatConfig().get_error_class("boom", 503, {"Content-Type": "application/json"})
+
+ assert isinstance(error, EdenAIException)
+ assert isinstance(error, BaseLLMException)
+ assert (error.message, error.status_code, error.headers) == ("boom", 503, {"Content-Type": "application/json"})
+
+
+class TestModelListing:
+ CATALOG = {"data": [{"id": "openai/gpt-4.1-nano", "object": "model"}, {"id": "anthropic/claude-sonnet-latest"}]}
+ ROUTABLE = ["edenai/openai/gpt-4.1-nano", "edenai/anthropic/claude-sonnet-latest"]
+
+ def test_lists_the_public_catalog_as_routable_model_names(self, eden_key, respx_mock):
+ respx_mock.get(f"{EDEN_BASE}/models").mock(return_value=httpx.Response(200, json=self.CATALOG))
+
+ assert EdenAIChatConfig().get_models() == self.ROUTABLE
+
+ def test_lists_from_the_configured_endpoint(self, eden_key, monkeypatch, respx_mock):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+ respx_mock.get(f"{EDEN_EU_BASE}/models").mock(return_value=httpx.Response(200, json=self.CATALOG))
+
+ assert EdenAIChatConfig().get_models() == self.ROUTABLE
+
+ def test_get_valid_models_reads_the_live_catalog(self, eden_key, respx_mock):
+ respx_mock.get(f"{EDEN_BASE}/models").mock(return_value=httpx.Response(200, json=self.CATALOG))
+
+ models = litellm.get_valid_models(
+ custom_llm_provider="edenai", check_provider_endpoint=True, api_key="listing-key"
+ )
+
+ assert models == self.ROUTABLE
+
+ def test_a_rejected_catalog_request_surfaces_edens_status_and_body(self, eden_key, respx_mock):
+ """A bad key has to reach the caller as an Eden error, not as a parse failure on the
+ rejection body that never held a catalog."""
+ respx_mock.get(f"{EDEN_BASE}/models").mock(return_value=httpx.Response(401, json={"detail": "Invalid token"}))
+
+ with pytest.raises(EdenAIException) as rejected:
+ EdenAIChatConfig().get_models()
+
+ assert rejected.value.status_code == 401
+ assert "Invalid token" in rejected.value.message
+
+ def test_proxy_wildcard_expands_to_the_live_catalog(self, eden_key, monkeypatch, respx_mock):
+ monkeypatch.setattr(litellm, "check_provider_endpoint", True)
+ respx_mock.get(f"{EDEN_BASE}/models").mock(return_value=httpx.Response(200, json=self.CATALOG))
+
+ models = get_provider_models("edenai", LiteLLM_Params(model="edenai/*", api_key="wildcard-key"))
+
+ assert models == self.ROUTABLE
+
+
+class TestDashboardRegistration:
+ def test_add_model_form_offers_eden_with_a_required_key_and_optional_base(self):
+ fields_path = REPO_ROOT / "litellm" / "proxy" / "public_endpoints" / "provider_create_fields.json"
+ entries = [e for e in json.loads(fields_path.read_text()) if e["litellm_provider"] == "edenai"]
+
+ assert len(entries) == 1
+ entry = entries[0]
+ assert (entry["provider"], entry["provider_display_name"]) == ("EDENAI", "Eden AI")
+ assert entry["default_model_placeholder"].startswith("edenai/")
+ fields = {f["key"]: f for f in entry["credential_fields"]}
+ assert (fields["api_key"]["required"], fields["api_key"]["field_type"]) == (True, "password")
+ assert (fields["api_base"]["required"], fields["api_base"]["placeholder"]) == (False, EDEN_BASE)
+
+ @pytest.mark.parametrize(
+ "matrix_path",
+ [
+ REPO_ROOT / "provider_endpoints_support.json",
+ REPO_ROOT / "litellm" / "provider_endpoints_support_backup.json",
+ ],
+ ids=["root", "backup"],
+ )
+ def test_endpoint_matrix_documents_every_served_surface(self, matrix_path):
+ entry = json.loads(matrix_path.read_text())["providers"]["edenai"]
+
+ assert entry["url"] == "https://docs.litellm.ai/docs/providers/edenai"
+ served = {name for name, flag in entry["endpoints"].items() if flag}
+ assert served == {
+ "chat_completions",
+ "messages",
+ "responses",
+ "embeddings",
+ "image_generations",
+ "audio_transcriptions",
+ "audio_speech",
+ "video_generations",
+ }
diff --git a/tests/test_litellm/llms/edenai/conftest.py b/tests/test_litellm/llms/edenai/conftest.py
new file mode 100644
index 00000000000..5ca5728354c
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/conftest.py
@@ -0,0 +1,61 @@
+import asyncio
+import uuid
+
+import pytest
+import pytest_asyncio
+
+import litellm
+from litellm.integrations.custom_logger import CustomLogger
+from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
+
+
+@pytest.fixture
+def eden_key(monkeypatch) -> str:
+ monkeypatch.delenv("EDENAI_API_BASE", raising=False)
+ monkeypatch.setenv("EDENAI_API_KEY", "eden-test-key")
+ monkeypatch.setattr(litellm, "api_key", None)
+ return "eden-test-key"
+
+
+@pytest.fixture
+def no_eden_key(monkeypatch) -> None:
+ monkeypatch.delenv("EDENAI_API_KEY", raising=False)
+ monkeypatch.setattr(litellm, "api_key", None)
+
+
+class SpendCapture(CustomLogger):
+ """Records the cost the spend logs would store for one call, matched by its call id."""
+
+ def __init__(self, call_id: str):
+ super().__init__()
+ self.call_id = call_id
+ self.costs: list[object] = []
+
+ async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
+ if kwargs.get("litellm_call_id") == self.call_id:
+ self.costs.append((kwargs.get("standard_logging_object") or {}).get("response_cost"))
+
+ async def settle(self) -> None:
+ await asyncio.sleep(0)
+ await asyncio.wait_for(GLOBAL_LOGGING_WORKER.flush(), timeout=10.0)
+
+
+@pytest_asyncio.fixture
+async def spend_capture(monkeypatch) -> SpendCapture:
+ GLOBAL_LOGGING_WORKER.start() # rebinds the worker's queue to this test's event loop
+ capture = SpendCapture(call_id=f"eden-{uuid.uuid4()}")
+ monkeypatch.setattr(litellm, "callbacks", [capture])
+ return capture
+
+
+@pytest.fixture
+def httpx_transport(monkeypatch):
+ """respx fakes httpx, so the async client must not sit on LiteLLM's default aiohttp transport."""
+ monkeypatch.setattr( # test-quality-ok: respx needs HTTPX enabled to fake the provider HTTP boundary.
+ litellm,
+ "disable_aiohttp_transport",
+ True,
+ )
+ litellm.in_memory_llm_clients_cache.flush_cache()
+ yield
+ litellm.in_memory_llm_clients_cache.flush_cache()
diff --git a/tests/test_litellm/llms/edenai/embedding/test_edenai_embedding_transformation.py b/tests/test_litellm/llms/edenai/embedding/test_edenai_embedding_transformation.py
new file mode 100644
index 00000000000..efdb428db32
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/embedding/test_edenai_embedding_transformation.py
@@ -0,0 +1,113 @@
+"""Eden AI `/v3/embeddings`: OpenAI's embeddings API served by Eden's gateway, which reports the
+real per-request cost at the top level of the body."""
+
+import json
+
+import httpx
+import pytest
+
+import litellm
+from litellm.cost_calculator import get_response_cost_from_hidden_params
+from litellm.llms.edenai.embedding.transformation import EdenAIEmbeddingConfig
+from litellm.types.utils import EmbeddingResponse, LlmProviders
+from litellm.utils import ProviderConfigManager
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_EMBEDDINGS_URL = f"{EDEN_BASE}/embeddings"
+EDEN_REPORTED_COST = 0.0042
+MODEL = "edenai/openai/text-embedding-3-small"
+SELLER_MODEL = "openai/text-embedding-3-small"
+VECTOR = [0.016754150390625, -0.055755615234375]
+
+
+def _eden_embedding(cost: float | None = EDEN_REPORTED_COST) -> dict:
+ """Live `/v3/embeddings` body: OpenAI shape plus Eden's top-level `cost`, `provider` and `status`."""
+ body = {
+ "status": "success",
+ "model": "text-embedding-3-small",
+ "data": [{"embedding": VECTOR, "index": 0, "object": "embedding"}],
+ "object": "list",
+ "usage": {"prompt_tokens": 1, "total_tokens": 1},
+ "provider": "openai",
+ }
+ return body if cost is None else {**body, "cost": cost}
+
+
+def _request_body(respx_mock) -> dict:
+ return json.loads(respx_mock.calls.last.request.content)
+
+
+class TestRegistration:
+ def test_eden_is_a_native_embedding_provider(self):
+ config = ProviderConfigManager.get_provider_embedding_config(model=SELLER_MODEL, provider=LlmProviders.EDENAI)
+
+ assert isinstance(config, EdenAIEmbeddingConfig)
+
+
+class TestAuthentication:
+ def test_missing_key_is_an_authentication_error_before_any_request(self, no_eden_key, respx_mock):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ litellm.embedding(model=MODEL, input="hello")
+ assert not respx_mock.calls
+
+
+class TestEmbedding:
+ def test_posts_to_eden_with_the_bearer_key_and_the_seller_model_id(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_EMBEDDINGS_URL).mock(return_value=httpx.Response(200, json=_eden_embedding()))
+
+ response = litellm.embedding(model=MODEL, input="hello", dimensions=2)
+
+ assert isinstance(response, EmbeddingResponse)
+ assert response.data[0]["embedding"] == VECTOR
+ request = respx_mock.calls.last.request
+ assert request.headers["Authorization"] == f"Bearer {eden_key}"
+ assert request.headers["Content-Type"] == "application/json"
+ body = _request_body(respx_mock)
+ assert (body["model"], body["input"], body["dimensions"]) == (SELLER_MODEL, "hello", 2)
+
+ def test_eden_reported_cost_beats_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_EMBEDDINGS_URL).mock(return_value=httpx.Response(200, json=_eden_embedding()))
+
+ response = litellm.embedding(model=MODEL, input="hello")
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) == EDEN_REPORTED_COST
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+ def test_a_body_without_cost_leaves_pricing_to_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_EMBEDDINGS_URL).mock(return_value=httpx.Response(200, json=_eden_embedding(cost=None)))
+
+ response = litellm.embedding(model=MODEL, input="hello")
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) is None
+
+ def test_extra_body_forwards_eden_only_fields(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_EMBEDDINGS_URL).mock(return_value=httpx.Response(200, json=_eden_embedding()))
+
+ litellm.embedding(model=MODEL, input="hello", extra_body={"metadata": {"trace": "abc"}})
+
+ assert _request_body(respx_mock)["metadata"] == {"trace": "abc"}
+
+ @pytest.mark.asyncio
+ async def test_async_call_tracks_the_same_cost(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.post(EDEN_EMBEDDINGS_URL).mock(return_value=httpx.Response(200, json=_eden_embedding()))
+
+ response = await litellm.aembedding(model=MODEL, input=["hello", "world"])
+
+ assert _request_body(respx_mock)["input"] == ["hello", "world"]
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+
+class TestErrors:
+ def test_middleware_401_maps_to_authentication_error(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_EMBEDDINGS_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token."}))
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ litellm.embedding(model=MODEL, input="hello")
+
+ def test_429_maps_to_rate_limit_error(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_EMBEDDINGS_URL).mock(
+ return_value=httpx.Response(429, json={"error": {"message": "Rate limit exceeded", "type": "rate_limit"}})
+ )
+
+ with pytest.raises(litellm.RateLimitError):
+ litellm.embedding(model=MODEL, input="hello")
diff --git a/tests/test_litellm/llms/edenai/image_generation/test_edenai_image_generation_transformation.py b/tests/test_litellm/llms/edenai/image_generation/test_edenai_image_generation_transformation.py
new file mode 100644
index 00000000000..d7b32affc70
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/image_generation/test_edenai_image_generation_transformation.py
@@ -0,0 +1,124 @@
+"""Eden AI `/v3/images/generations`: OpenAI's image generation API served by Eden's gateway, which
+reports the real per-request cost at the top level of the body."""
+
+import json
+
+import httpx
+import pytest
+
+import litellm
+from litellm.cost_calculator import get_response_cost_from_hidden_params
+from litellm.llms.edenai.image_generation.transformation import EdenAIImageGenerationConfig
+from litellm.types.utils import ImageResponse, LlmProviders
+from litellm.utils import ProviderConfigManager
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_IMAGES_URL = f"{EDEN_BASE}/images/generations"
+EDEN_REPORTED_COST = 0.0042
+MODEL = "edenai/openai/gpt-image-1-mini"
+SELLER_MODEL = "openai/gpt-image-1-mini"
+PNG_B64 = "iVBORw0KGgoAAAANSUhE"
+
+
+def _eden_image(cost: float | None = EDEN_REPORTED_COST) -> dict:
+ """Live `/v3/images/generations` body: OpenAI shape plus Eden's top-level `cost` and `provider`."""
+ body = {
+ "created": 1788818607,
+ "background": None,
+ "data": [{"b64_json": PNG_B64, "revised_prompt": None, "url": None}],
+ "output_format": "png",
+ "quality": "low",
+ "size": "1024x1024",
+ "usage": {
+ "total_tokens": 281,
+ "input_tokens": 9,
+ "input_tokens_details": {"image_tokens": 0, "text_tokens": 9},
+ "output_tokens": 272,
+ "output_tokens_details": {"image_tokens": 272, "text_tokens": 0},
+ },
+ "provider": "openai",
+ }
+ return body if cost is None else {**body, "cost": cost}
+
+
+def _request_body(respx_mock) -> dict:
+ return json.loads(respx_mock.calls.last.request.content)
+
+
+class TestRegistration:
+ def test_eden_is_a_native_image_generation_provider(self):
+ config = ProviderConfigManager.get_provider_image_generation_config(
+ model=SELLER_MODEL, provider=LlmProviders.EDENAI
+ )
+
+ assert isinstance(config, EdenAIImageGenerationConfig)
+
+
+class TestAuthentication:
+ def test_missing_key_is_an_authentication_error_before_any_request(self, no_eden_key, respx_mock):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ litellm.image_generation(model=MODEL, prompt="a red square")
+ assert not respx_mock.calls
+
+
+class TestImageGeneration:
+ def test_a_param_outside_the_openai_image_set_is_rejected_unless_dropped(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_IMAGES_URL).mock(return_value=httpx.Response(200, json=_eden_image()))
+
+ with pytest.raises(litellm.UnsupportedParamsError, match="imageConfig"):
+ litellm.image_generation(model=MODEL, prompt="a red square", imageConfig={"aspectRatio": "16:9"})
+ litellm.image_generation(
+ model=MODEL, prompt="a red square", imageConfig={"aspectRatio": "16:9"}, drop_params=True
+ )
+
+ assert "imageConfig" not in _request_body(respx_mock)
+
+ def test_posts_to_eden_with_the_bearer_key_and_the_seller_model_id(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_IMAGES_URL).mock(return_value=httpx.Response(200, json=_eden_image()))
+
+ response = litellm.image_generation(model=MODEL, prompt="a red square", size="1024x1024", quality="low", n=1)
+
+ assert isinstance(response, ImageResponse)
+ assert response.data[0].b64_json == PNG_B64
+ assert respx_mock.calls.last.request.headers["Authorization"] == f"Bearer {eden_key}"
+ assert _request_body(respx_mock) == {
+ "model": SELLER_MODEL,
+ "prompt": "a red square",
+ "size": "1024x1024",
+ "quality": "low",
+ "n": 1,
+ }
+
+ def test_eden_reported_cost_beats_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_IMAGES_URL).mock(return_value=httpx.Response(200, json=_eden_image()))
+
+ response = litellm.image_generation(model=MODEL, prompt="a red square")
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) == EDEN_REPORTED_COST
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+ def test_a_body_without_cost_leaves_pricing_to_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_IMAGES_URL).mock(return_value=httpx.Response(200, json=_eden_image(cost=None)))
+
+ response = litellm.image_generation(model=MODEL, prompt="a red square")
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) is None
+ assert response.usage is not None
+ assert response.usage.output_tokens == 272
+
+ @pytest.mark.asyncio
+ async def test_async_call_tracks_the_same_cost(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.post(EDEN_IMAGES_URL).mock(return_value=httpx.Response(200, json=_eden_image()))
+
+ response = await litellm.aimage_generation(model=MODEL, prompt="a red square")
+
+ assert response.data[0].b64_json == PNG_B64
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+
+class TestErrors:
+ def test_middleware_401_maps_to_authentication_error(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_IMAGES_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token."}))
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ litellm.image_generation(model=MODEL, prompt="a red square")
diff --git a/tests/test_litellm/llms/edenai/messages/test_edenai_anthropic_messages_transformation.py b/tests/test_litellm/llms/edenai/messages/test_edenai_anthropic_messages_transformation.py
new file mode 100644
index 00000000000..e795ba70fb4
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/messages/test_edenai_anthropic_messages_transformation.py
@@ -0,0 +1,247 @@
+"""Eden AI `/v3/v1/messages`: Anthropic's Messages API served by Eden's gateway for every model in
+its catalog. The Anthropic payload is forwarded untranslated, and Eden reports the real per-request
+cost at the top level of a non-streaming body."""
+
+import asyncio
+import json
+import time
+import uuid
+
+import httpx
+import pytest
+
+import litellm
+from litellm.integrations.custom_logger import CustomLogger
+from litellm.litellm_core_utils.litellm_logging import Logging
+from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
+from litellm.llms.edenai.messages.transformation import EdenAIAnthropicMessagesConfig
+from litellm.types.utils import LlmProviders
+from litellm.utils import ProviderConfigManager
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_EU_BASE = "https://api.eu.edenai.run/v3"
+EDEN_MESSAGES_URL = f"{EDEN_BASE}/v1/messages"
+EDEN_REPORTED_COST = 0.0042
+MODEL = "edenai/openai/gpt-4.1-nano"
+SELLER_MODEL = "openai/gpt-4.1-nano"
+MESSAGES = [{"role": "user", "content": "Say OK"}]
+BILLING_BLOCK = {"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.0; cc_entrypoint=cli"}
+SYSTEM_BLOCK = {"type": "text", "text": "Be terse", "cache_control": {"type": "ephemeral"}}
+
+
+def _eden_message(cost: float | None = EDEN_REPORTED_COST) -> dict:
+ """Live body: Anthropic shape with the id sent to Eden echoed in `model` and Eden's top-level `cost`."""
+ body = {
+ "id": "chatcmpl-eden-1",
+ "type": "message",
+ "role": "assistant",
+ "model": SELLER_MODEL,
+ "stop_sequence": None,
+ "stop_reason": "end_turn",
+ "usage": {"input_tokens": 12, "output_tokens": 1},
+ "content": [{"type": "text", "text": "OK"}],
+ }
+ return body if cost is None else {**body, "cost": cost}
+
+
+def _eden_stream() -> httpx.Response:
+ """Live stream: Anthropic events with token usage on `message_delta` and no cost anywhere."""
+ message = {
+ "id": "msg_eden_1",
+ "type": "message",
+ "role": "assistant",
+ "content": [],
+ "model": SELLER_MODEL,
+ "stop_reason": None,
+ "stop_sequence": None,
+ "usage": {"input_tokens": 0, "output_tokens": 0},
+ }
+ events = (
+ {"type": "message_start", "message": message},
+ {"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
+ {"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": "OK"}},
+ {"type": "content_block_stop", "index": 0},
+ {
+ "type": "message_delta",
+ "delta": {"stop_reason": "end_turn"},
+ "usage": {"input_tokens": 12, "output_tokens": 1},
+ },
+ {"type": "message_stop"},
+ )
+ body = "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events)
+ return httpx.Response(200, content=body.encode(), headers={"content-type": "text/event-stream"})
+
+
+def _request_body(respx_mock) -> dict:
+ return json.loads(respx_mock.calls.last.request.content)
+
+
+def _logging_obj() -> Logging:
+ return Logging(
+ model=SELLER_MODEL,
+ messages=MESSAGES,
+ stream=False,
+ call_type="anthropic_messages",
+ start_time=time.time(),
+ litellm_call_id="eden-messages-unit",
+ function_id="eden-messages-unit",
+ )
+
+
+class TestRegistration:
+ @pytest.mark.parametrize("model", [SELLER_MODEL, "anthropic/claude-sonnet-latest"])
+ def test_eden_serves_anthropic_messages_natively_for_every_catalog_model(self, model):
+ config = ProviderConfigManager.get_provider_anthropic_messages_config(model=model, provider=LlmProviders.EDENAI)
+
+ assert isinstance(config, EdenAIAnthropicMessagesConfig)
+ assert config.custom_llm_provider == "edenai"
+
+
+class TestEndpointResolution:
+ def _url(self, api_base: str | None) -> str:
+ return EdenAIAnthropicMessagesConfig().get_complete_url(
+ api_base=api_base, api_key=None, model=SELLER_MODEL, optional_params={}, litellm_params={}
+ )
+
+ def test_defaults_to_the_global_endpoint(self, eden_key):
+ assert self._url(None) == EDEN_MESSAGES_URL
+
+ def test_env_api_base_moves_to_the_eu_endpoint(self, eden_key, monkeypatch):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+
+ assert self._url(None) == f"{EDEN_EU_BASE}/v1/messages"
+
+ def test_explicit_api_base_wins_over_env(self, eden_key, monkeypatch):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+
+ assert self._url("https://eden.internal/v3/") == "https://eden.internal/v3/v1/messages"
+
+
+class TestAuthentication:
+ def _headers(self, headers: dict, api_key: str | None = None) -> dict:
+ resolved, _ = EdenAIAnthropicMessagesConfig().validate_anthropic_messages_environment(
+ headers=headers,
+ model=SELLER_MODEL,
+ messages=MESSAGES,
+ optional_params={},
+ litellm_params={},
+ api_key=api_key,
+ )
+ return resolved
+
+ def test_env_key_becomes_the_bearer_header_with_the_anthropic_version(self, eden_key):
+ headers = self._headers({})
+
+ assert headers == {
+ "authorization": f"Bearer {eden_key}",
+ "anthropic-version": "2023-06-01",
+ "content-type": "application/json",
+ }
+
+ def test_explicit_key_wins_over_env(self, eden_key):
+ assert self._headers({}, api_key="explicit-key")["authorization"] == "Bearer explicit-key"
+
+ def test_a_caller_supplied_authorization_header_is_kept(self, eden_key):
+ headers = self._headers({"Authorization": "Bearer caller-token"})
+
+ assert headers["Authorization"] == "Bearer caller-token"
+ assert "authorization" not in headers
+
+ def test_missing_key_is_an_authentication_error(self, no_eden_key):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ self._headers({})
+
+
+class TestResponseTransformation:
+ def test_eden_reported_cost_becomes_the_call_spend(self):
+ logging_obj = _logging_obj()
+
+ response = EdenAIAnthropicMessagesConfig().transform_anthropic_messages_response(
+ model=SELLER_MODEL, raw_response=httpx.Response(200, json=_eden_message()), logging_obj=logging_obj
+ )
+
+ assert response["content"] == [{"type": "text", "text": "OK"}]
+ assert response["cost"] == EDEN_REPORTED_COST
+ assert logging_obj.model_call_details["response_cost"] == EDEN_REPORTED_COST
+
+ def test_a_body_without_cost_leaves_pricing_to_the_price_map(self):
+ logging_obj = _logging_obj()
+
+ EdenAIAnthropicMessagesConfig().transform_anthropic_messages_response(
+ model=SELLER_MODEL, raw_response=httpx.Response(200, json=_eden_message(cost=None)), logging_obj=logging_obj
+ )
+
+ assert "response_cost" not in logging_obj.model_call_details
+
+
+class TestMessages:
+ @pytest.mark.asyncio
+ async def test_posts_the_anthropic_payload_untranslated_with_the_bearer_key(
+ self, eden_key, httpx_transport, respx_mock
+ ):
+ respx_mock.post(EDEN_MESSAGES_URL).mock(return_value=httpx.Response(200, json=_eden_message()))
+
+ response = await litellm.anthropic.messages.acreate(
+ model=MODEL,
+ max_tokens=16,
+ messages=MESSAGES,
+ system=[SYSTEM_BLOCK],
+ thinking={"type": "enabled", "budget_tokens": 1024},
+ )
+
+ assert response["content"] == [{"type": "text", "text": "OK"}]
+ assert response["cost"] == EDEN_REPORTED_COST
+ request = respx_mock.calls.last.request
+ assert request.headers["authorization"] == f"Bearer {eden_key}"
+ assert request.headers["anthropic-version"] == "2023-06-01"
+ body = _request_body(respx_mock)
+ assert (body["model"], body["messages"], body["max_tokens"]) == (SELLER_MODEL, MESSAGES, 16)
+ assert body["system"] == [SYSTEM_BLOCK]
+ assert body["thinking"] == {"type": "enabled", "budget_tokens": 1024}
+
+ @pytest.mark.asyncio
+ async def test_claude_code_billing_blocks_are_stripped_from_the_system_prompt(
+ self, eden_key, httpx_transport, respx_mock
+ ):
+ respx_mock.post(EDEN_MESSAGES_URL).mock(return_value=httpx.Response(200, json=_eden_message()))
+
+ await litellm.anthropic.messages.acreate(
+ model=MODEL, max_tokens=16, messages=MESSAGES, system=[BILLING_BLOCK, SYSTEM_BLOCK]
+ )
+
+ assert _request_body(respx_mock)["system"] == [SYSTEM_BLOCK]
+
+ @pytest.mark.asyncio
+ async def test_eden_reported_cost_is_logged_as_the_call_spend(
+ self, eden_key, httpx_transport, respx_mock, spend_capture
+ ):
+ respx_mock.post(EDEN_MESSAGES_URL).mock(return_value=httpx.Response(200, json=_eden_message()))
+ await litellm.anthropic.messages.acreate(
+ model=MODEL, max_tokens=16, messages=MESSAGES, litellm_call_id=spend_capture.call_id
+ )
+ await spend_capture.settle()
+
+ assert spend_capture.costs == [EDEN_REPORTED_COST]
+
+
+class TestStreaming:
+ @pytest.mark.asyncio
+ async def test_stream_forwards_eden_events_verbatim(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.post(EDEN_MESSAGES_URL).mock(return_value=_eden_stream())
+
+ stream = await litellm.anthropic.messages.acreate(model=MODEL, max_tokens=16, messages=MESSAGES, stream=True)
+ body = b"".join([chunk async for chunk in stream]).decode()
+
+ assert _request_body(respx_mock)["stream"] is True
+ assert "event: message_start" in body
+ assert '"text_delta", "text": "OK"' in body or '"text_delta","text":"OK"' in body
+ assert "event: message_stop" in body
+
+
+class TestErrors:
+ @pytest.mark.asyncio
+ async def test_401_detail_body_is_an_authentication_error(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.post(EDEN_MESSAGES_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token"}))
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ await litellm.anthropic.messages.acreate(model=MODEL, max_tokens=16, messages=MESSAGES)
diff --git a/tests/test_litellm/llms/edenai/responses/test_edenai_responses_transformation.py b/tests/test_litellm/llms/edenai/responses/test_edenai_responses_transformation.py
new file mode 100644
index 00000000000..3fe9e226da9
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/responses/test_edenai_responses_transformation.py
@@ -0,0 +1,268 @@
+"""Eden AI `/v3/responses`: OpenAI's Responses API served by Eden's gateway. Eden reports the real
+per-request cost at the top level of the body and, on streams, on the final usage frame."""
+
+import json
+
+import httpx
+import pytest
+
+import litellm
+from litellm.cost_calculator import get_response_cost_from_hidden_params
+from litellm.llms.edenai.responses.transformation import EdenAIResponsesAPIConfig
+from litellm.types.llms.openai import ResponsesAPIResponse, ResponsesAPIStreamEvents
+from litellm.types.router import GenericLiteLLMParams
+from litellm.types.utils import LlmProviders
+from litellm.utils import ProviderConfigManager
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_EU_BASE = "https://api.eu.edenai.run/v3"
+EDEN_RESPONSES_URL = f"{EDEN_BASE}/responses"
+EDEN_REPORTED_COST = 0.0042
+MODEL = "edenai/openai/gpt-4.1-nano"
+SELLER_MODEL = "openai/gpt-4.1-nano"
+
+
+def _usage(cost: float | None) -> dict:
+ usage = {"input_tokens": 12, "output_tokens": 2, "total_tokens": 14}
+ return usage if cost is None else {**usage, "cost": cost}
+
+
+def _output(text: str = "OK") -> list[dict]:
+ return [
+ {
+ "id": "msg_eden_1",
+ "type": "message",
+ "status": "completed",
+ "role": "assistant",
+ "content": [{"type": "output_text", "text": text, "annotations": []}],
+ }
+ ]
+
+
+def _eden_response(cost: float | None = EDEN_REPORTED_COST) -> dict:
+ """Live `/v3/responses` body: OpenAI shape plus Eden's top-level `cost` and `provider`."""
+ body = {
+ "id": "resp_eden_1",
+ "object": "response",
+ "created_at": 1788443790,
+ "status": "completed",
+ "model": "gpt-4.1-nano",
+ "provider": "openai",
+ "output": _output(),
+ "usage": _usage(cost),
+ }
+ return body if cost is None else {**body, "cost": cost}
+
+
+def _eden_stream_events(cost: float | None = EDEN_REPORTED_COST) -> tuple[dict, ...]:
+ """Live stream: the `response.completed` frame carries Eden's cost on `usage` only."""
+ in_progress = {
+ "id": "resp_eden_1",
+ "object": "response",
+ "created_at": 1788443790,
+ "status": "in_progress",
+ "model": SELLER_MODEL,
+ "output": [],
+ }
+ return (
+ {"type": "response.created", "sequence_number": 0, "response": in_progress},
+ {
+ "type": "response.output_item.added",
+ "sequence_number": 1,
+ "output_index": 0,
+ "item": {
+ "id": "msg_eden_1",
+ "type": "message",
+ "status": "in_progress",
+ "role": "assistant",
+ "content": [],
+ },
+ },
+ {
+ "type": "response.output_text.delta",
+ "sequence_number": 2,
+ "item_id": "msg_eden_1",
+ "output_index": 0,
+ "content_index": 0,
+ "delta": "OK",
+ },
+ {
+ "type": "response.completed",
+ "sequence_number": 3,
+ "response": {**in_progress, "status": "completed", "output": _output(), "usage": _usage(cost)},
+ },
+ )
+
+
+def _sse(events: tuple[dict, ...]) -> httpx.Response:
+ body = "".join(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n" for event in events)
+ return httpx.Response(200, content=body.encode(), headers={"content-type": "text/event-stream"})
+
+
+def _request_body(respx_mock) -> dict:
+ return json.loads(respx_mock.calls.last.request.content)
+
+
+class TestRegistration:
+ def test_eden_is_a_native_responses_provider(self):
+ config = ProviderConfigManager.get_provider_responses_api_config(
+ provider=LlmProviders.EDENAI, model=SELLER_MODEL
+ )
+
+ assert isinstance(config, EdenAIResponsesAPIConfig)
+ assert config.custom_llm_provider == LlmProviders.EDENAI
+
+ def test_the_provider_string_resolves_too(self):
+ assert isinstance(
+ ProviderConfigManager.get_provider_responses_api_config(provider="edenai"), EdenAIResponsesAPIConfig
+ )
+
+ def test_websocket_callers_get_the_managed_handler(self):
+ """Eden serves the Responses API over HTTP only, so a websocket client has to be bridged
+ rather than dialled straight through to a wss:// endpoint Eden does not have."""
+ assert EdenAIResponsesAPIConfig().supports_native_websocket() is False
+
+
+class TestEndpointResolution:
+ def test_defaults_to_the_global_endpoint(self, eden_key):
+ assert EdenAIResponsesAPIConfig().get_complete_url(api_base=None, litellm_params={}) == EDEN_RESPONSES_URL
+
+ def test_env_api_base_moves_to_the_eu_endpoint(self, eden_key, monkeypatch):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+
+ assert (
+ EdenAIResponsesAPIConfig().get_complete_url(api_base=None, litellm_params={}) == f"{EDEN_EU_BASE}/responses"
+ )
+
+ def test_explicit_api_base_wins_and_loses_its_trailing_slash(self, eden_key, monkeypatch):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+
+ url = EdenAIResponsesAPIConfig().get_complete_url(api_base="https://eden.internal/v3/", litellm_params={})
+
+ assert url == "https://eden.internal/v3/responses"
+
+
+class TestAuthentication:
+ def test_env_key_becomes_the_bearer_header(self, eden_key):
+ headers = EdenAIResponsesAPIConfig().validate_environment(
+ headers={"x-trace": "1"}, model=SELLER_MODEL, litellm_params=None
+ )
+
+ assert headers == {"x-trace": "1", "Authorization": f"Bearer {eden_key}"}
+
+ def test_explicit_key_wins_over_env(self, eden_key):
+ headers = EdenAIResponsesAPIConfig().validate_environment(
+ headers={}, model=SELLER_MODEL, litellm_params=GenericLiteLLMParams(api_key="explicit-key")
+ )
+
+ assert headers["Authorization"] == "Bearer explicit-key"
+
+ def test_missing_key_is_an_authentication_error(self, no_eden_key):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ EdenAIResponsesAPIConfig().validate_environment(headers={}, model=SELLER_MODEL, litellm_params=None)
+
+
+class TestResponses:
+ def test_posts_to_eden_with_the_bearer_key_and_the_seller_model_id(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(return_value=httpx.Response(200, json=_eden_response()))
+
+ response = litellm.responses(model=MODEL, input="Say OK", max_output_tokens=16)
+
+ assert isinstance(response, ResponsesAPIResponse)
+ assert response.output[0].content[0].text == "OK"
+ assert respx_mock.calls.last.request.headers["Authorization"] == f"Bearer {eden_key}"
+ body = _request_body(respx_mock)
+ assert (body["model"], body["input"], body["max_output_tokens"]) == (SELLER_MODEL, "Say OK", 16)
+
+ def test_eden_reported_cost_beats_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(return_value=httpx.Response(200, json=_eden_response()))
+
+ response = litellm.responses(model=MODEL, input="Say OK", max_output_tokens=16)
+
+ assert get_response_cost_from_hidden_params(response._hidden_params) == EDEN_REPORTED_COST
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+ def test_a_body_without_cost_leaves_pricing_to_the_price_map(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(return_value=httpx.Response(200, json=_eden_response(cost=None)))
+
+ response = litellm.responses(model=MODEL, input="Say OK", max_output_tokens=16)
+
+ assert response.output[0].content[0].text == "OK"
+ assert get_response_cost_from_hidden_params(response._hidden_params) is None
+
+ def test_stateful_params_pass_through_to_eden(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(return_value=httpx.Response(200, json=_eden_response()))
+
+ litellm.responses(
+ model=MODEL,
+ input="Say OK",
+ previous_response_id="resp_previous",
+ store=False,
+ reasoning={"effort": "low"},
+ )
+
+ body = _request_body(respx_mock)
+ assert (body["previous_response_id"], body["store"], body["reasoning"]) == (
+ "resp_previous",
+ False,
+ {"effort": "low"},
+ )
+
+ def test_extra_body_forwards_eden_only_fields(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(return_value=httpx.Response(200, json=_eden_response()))
+
+ litellm.responses(
+ model=MODEL,
+ input="Say OK",
+ extra_body={"fallbacks": ["anthropic/claude-sonnet-latest"], "routing": {"sort": "latency"}},
+ )
+
+ body = _request_body(respx_mock)
+ assert body["fallbacks"] == ["anthropic/claude-sonnet-latest"]
+ assert body["routing"] == {"sort": "latency"}
+ assert "extra_body" not in body
+
+
+class TestStreaming:
+ def test_stream_forwards_eden_events_and_bills_the_usage_cost(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(return_value=_sse(_eden_stream_events()))
+
+ stream = litellm.responses(model=MODEL, input="Say OK", stream=True)
+ events = list(stream)
+
+ assert _request_body(respx_mock)["stream"] is True
+ assert [event.type for event in events] == [
+ ResponsesAPIStreamEvents.RESPONSE_CREATED,
+ ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
+ ResponsesAPIStreamEvents.OUTPUT_TEXT_DELTA,
+ ResponsesAPIStreamEvents.RESPONSE_COMPLETED,
+ ]
+ assert events[2].delta == "OK"
+ assert events[-1].response.usage.cost == EDEN_REPORTED_COST
+ assert stream.logging_obj.model_call_details["response_cost"] == EDEN_REPORTED_COST
+
+
+class TestErrors:
+ def test_401_detail_body_is_an_authentication_error(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token"}))
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ litellm.responses(model=MODEL, input="Say OK")
+
+ def test_400_envelope_is_a_bad_request_error(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_RESPONSES_URL).mock(
+ return_value=httpx.Response(
+ 400,
+ json={
+ "error": {
+ "message": "Model(s) not found or inactive: openai/does-not-exist",
+ "type": "invalid_request_error",
+ "param": None,
+ "code": "invalid_parameter",
+ }
+ },
+ )
+ )
+
+ with pytest.raises(litellm.BadRequestError, match="not found or inactive"):
+ litellm.responses(model="edenai/openai/does-not-exist", input="Say OK")
diff --git a/tests/test_litellm/llms/edenai/test_edenai_common_utils.py b/tests/test_litellm/llms/edenai/test_edenai_common_utils.py
new file mode 100644
index 00000000000..01b7ef55f81
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/test_edenai_common_utils.py
@@ -0,0 +1,63 @@
+"""Credential, endpoint and cost helpers shared by every Eden AI config."""
+
+import httpx
+import pytest
+
+import litellm
+from litellm.llms.edenai.common_utils import authorized_headers, endpoint_url, json_headers, reported_cost
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_EU_BASE = "https://api.eu.edenai.run/v3"
+
+
+class TestEndpointUrl:
+ def test_defaults_to_the_global_endpoint(self, eden_key):
+ assert endpoint_url(None, "embeddings") == f"{EDEN_BASE}/embeddings"
+
+ def test_env_api_base_moves_to_the_eu_endpoint(self, eden_key, monkeypatch):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+
+ assert endpoint_url(None, "audio/speech") == f"{EDEN_EU_BASE}/audio/speech"
+
+ def test_explicit_api_base_wins_and_loses_its_trailing_slash(self, eden_key, monkeypatch):
+ monkeypatch.setenv("EDENAI_API_BASE", EDEN_EU_BASE)
+
+ assert (
+ endpoint_url("https://proxy.example/v3/", "images/generations")
+ == "https://proxy.example/v3/images/generations"
+ )
+
+
+class TestAuthorizedHeaders:
+ def test_env_key_becomes_the_bearer_header_and_caller_headers_are_kept(self, eden_key):
+ assert authorized_headers({"X-Trace": "abc"}, None, "openai/tts-1") == {
+ "X-Trace": "abc",
+ "Authorization": f"Bearer {eden_key}",
+ }
+
+ def test_explicit_key_wins_over_env(self, eden_key):
+ assert authorized_headers({}, "explicit-key", "openai/tts-1")["Authorization"] == "Bearer explicit-key"
+
+ def test_json_headers_add_the_content_type(self, eden_key):
+ assert json_headers({}, None, "openai/tts-1") == {
+ "Authorization": f"Bearer {eden_key}",
+ "Content-Type": "application/json",
+ }
+
+ def test_missing_key_is_an_authentication_error(self, no_eden_key):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ authorized_headers({}, None, "openai/tts-1")
+
+
+class TestReportedCost:
+ def test_reads_the_top_level_cost_of_a_body(self):
+ assert reported_cost({"cost": 0.0042, "provider": "openai"}) == 0.0042
+ assert reported_cost(b'{"cost": 0.0042, "text": "hi"}') == 0.0042
+
+ def test_reads_the_speech_cost_header(self):
+ assert reported_cost(httpx.Headers({"x-edenai-cost": "0.00015", "content-type": "audio/mpeg"})) == 0.00015
+
+ def test_no_cost_anywhere_is_none(self):
+ assert reported_cost({"provider": "openai"}) is None
+ assert reported_cost(httpx.Headers({"content-type": "audio/mpeg"})) is None
+ assert reported_cost(b"not json") is None
diff --git a/tests/test_litellm/llms/edenai/text_to_speech/test_edenai_text_to_speech_transformation.py b/tests/test_litellm/llms/edenai/text_to_speech/test_edenai_text_to_speech_transformation.py
new file mode 100644
index 00000000000..922713da63e
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/text_to_speech/test_edenai_text_to_speech_transformation.py
@@ -0,0 +1,139 @@
+"""Eden AI `/v3/audio/speech`: OpenAI's text-to-speech API served by Eden's gateway. The answer is
+raw audio, so Eden reports the real per-request cost in the `x-edenai-cost` response header."""
+
+import asyncio
+import json
+import uuid
+
+import httpx
+import pytest
+
+import litellm
+from litellm.integrations.custom_logger import CustomLogger
+from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
+from litellm.llms.edenai.common_utils import EdenAIException
+from litellm.llms.edenai.text_to_speech.transformation import EdenAITextToSpeechConfig
+from litellm.types.llms.openai import HttpxBinaryResponseContent
+from litellm.types.utils import LlmProviders
+from litellm.utils import ProviderConfigManager
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_SPEECH_URL = f"{EDEN_BASE}/audio/speech"
+EDEN_REPORTED_COST = 0.00015
+MODEL = "edenai/openai/tts-1"
+SELLER_MODEL = "openai/tts-1"
+AUDIO = b"ID3\x04\x00fake-mp3-bytes"
+
+
+def _eden_audio(cost: float | None = EDEN_REPORTED_COST) -> httpx.Response:
+ """Live `/v3/audio/speech` answer: audio bytes, with the cost and provider in `x-edenai-*` headers."""
+ headers = {"content-type": "audio/mpeg", "x-edenai-provider": "openai"}
+ return httpx.Response(
+ 200, content=AUDIO, headers=headers if cost is None else {**headers, "x-edenai-cost": str(cost)}
+ )
+
+
+def _request_body(respx_mock) -> dict:
+ return json.loads(respx_mock.calls.last.request.content)
+
+
+class TestRegistration:
+ def test_eden_is_a_native_text_to_speech_provider(self):
+ config = ProviderConfigManager.get_provider_text_to_speech_config(
+ model=SELLER_MODEL, provider=LlmProviders.EDENAI
+ )
+
+ assert isinstance(config, EdenAITextToSpeechConfig)
+
+
+class TestRequestTransformation:
+ def test_body_is_the_openai_speech_request_without_empty_fields(self):
+ request = EdenAITextToSpeechConfig().transform_text_to_speech_request(
+ model=SELLER_MODEL,
+ input="hello there",
+ voice="alloy",
+ optional_params={"response_format": "wav", "speed": None},
+ litellm_params={},
+ headers={},
+ )
+
+ assert request["dict_body"] == {
+ "model": SELLER_MODEL,
+ "input": "hello there",
+ "voice": "alloy",
+ "response_format": "wav",
+ }
+
+ def test_a_missing_voice_is_left_for_eden_to_reject(self):
+ request = EdenAITextToSpeechConfig().transform_text_to_speech_request(
+ model=SELLER_MODEL, input="hello", voice=None, optional_params={}, litellm_params={}, headers={}
+ )
+
+ assert "voice" not in request["dict_body"]
+
+ def test_missing_key_is_an_authentication_error_before_any_request(self, no_eden_key, respx_mock):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ litellm.speech(model=MODEL, input="hello", voice="alloy")
+ assert not respx_mock.calls
+
+
+class TestSpeech:
+ def test_posts_to_eden_with_the_bearer_key_and_returns_the_audio(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_SPEECH_URL).mock(return_value=_eden_audio())
+
+ response = litellm.speech(model=MODEL, input="hello there", voice="alloy", response_format="mp3", speed=1.2)
+
+ assert isinstance(response, HttpxBinaryResponseContent)
+ assert response.content == AUDIO
+ assert respx_mock.calls.last.request.headers["Authorization"] == f"Bearer {eden_key}"
+ assert _request_body(respx_mock) == {
+ "model": SELLER_MODEL,
+ "input": "hello there",
+ "voice": "alloy",
+ "response_format": "mp3",
+ "speed": 1.2,
+ }
+
+ def test_the_cost_header_becomes_the_response_cost(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_SPEECH_URL).mock(return_value=_eden_audio())
+
+ response = litellm.speech(model=MODEL, input="hello there", voice="alloy")
+
+ assert response._hidden_params["response_cost"] == EDEN_REPORTED_COST
+
+ def test_an_answer_without_the_cost_header_leaves_pricing_to_the_price_map(self):
+ response = EdenAITextToSpeechConfig().transform_text_to_speech_response(
+ model=SELLER_MODEL, raw_response=_eden_audio(cost=None), logging_obj=None
+ )
+
+ assert "response_cost" not in response._hidden_params
+
+ @pytest.mark.asyncio
+ async def test_async_call_logs_the_header_cost_as_spend(self, eden_key, httpx_transport, spend_capture, respx_mock):
+ respx_mock.post(EDEN_SPEECH_URL).mock(return_value=_eden_audio())
+
+ response = await litellm.aspeech(
+ model=MODEL, input="hello there", voice="alloy", litellm_call_id=spend_capture.call_id
+ )
+ await spend_capture.settle()
+
+ assert response.content == AUDIO
+ assert spend_capture.costs == [EDEN_REPORTED_COST]
+
+
+class TestErrors:
+ def test_middleware_401_surfaces_as_an_eden_error_with_the_status_code(self, eden_key, respx_mock):
+ """`litellm.speech` does not map provider errors onto the OpenAI exception classes the way
+ chat does, so the proxy relies on the status code the provider exception carries."""
+ respx_mock.post(EDEN_SPEECH_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token."}))
+
+ with pytest.raises(EdenAIException, match="Invalid token") as excinfo:
+ litellm.speech(model=MODEL, input="hello", voice="alloy")
+ assert excinfo.value.status_code == 401
+
+ @pytest.mark.asyncio
+ async def test_async_401_maps_to_authentication_error(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.post(EDEN_SPEECH_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token."}))
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ await litellm.aspeech(model=MODEL, input="hello", voice="alloy")
diff --git a/tests/test_litellm/llms/edenai/videos/test_edenai_video_transformation.py b/tests/test_litellm/llms/edenai/videos/test_edenai_video_transformation.py
new file mode 100644
index 00000000000..360e4d07f24
--- /dev/null
+++ b/tests/test_litellm/llms/edenai/videos/test_edenai_video_transformation.py
@@ -0,0 +1,308 @@
+"""Eden AI `/v3/videos`: OpenAI's video jobs API served by Eden's gateway, which reports `cost` as 0
+while a job is queued and the settled amount on the status read once it completes."""
+
+import json
+from io import BytesIO
+
+import httpx
+import pytest
+
+import litellm
+from litellm.llms.edenai.videos.transformation import EdenAIVideoConfig
+from litellm.types.utils import LlmProviders
+from litellm.types.videos.main import VideoObject
+from litellm.types.videos.utils import decode_video_id_with_provider, encode_video_id_with_provider
+from litellm.utils import ProviderConfigManager
+
+EDEN_BASE = "https://api.edenai.run/v3"
+EDEN_VIDEOS_URL = f"{EDEN_BASE}/videos"
+MODEL = "edenai/pruna/p-video"
+SELLER_MODEL = "pruna/p-video"
+JOB_ID = "fcd74ecd-23df-4eea-a372-478a1e842d42"
+SETTLED_COST = 0.08
+FILE_URL = "https://files.example.net/60b11f54/video.mp4"
+MP4_BYTES = b"\x00\x00\x00\x18ftypmp42"
+PROMPT = "a red ball rolling on a wooden table"
+
+
+def _eden_video(status: str = "queued", cost: float = 0.0, **overrides: object) -> dict:
+ """Live `/v3/videos` body: OpenAI's video object plus Eden's top-level `provider` and `cost`."""
+ return {
+ "id": JOB_ID,
+ "object": "video",
+ "status": status,
+ "progress": 100 if status == "completed" else 0,
+ "created_at": 1789067483,
+ "completed_at": 1789067493 if status == "completed" else None,
+ "expires_at": None,
+ "model": SELLER_MODEL,
+ "seconds": "4",
+ "size": "1280x720",
+ "remixed_from_video_id": None,
+ "error": None,
+ "provider": "pruna",
+ "cost": cost,
+ **overrides,
+ }
+
+
+def _encoded(job_id: str = JOB_ID) -> str:
+ return encode_video_id_with_provider(job_id, "edenai", SELLER_MODEL)
+
+
+def _request_body(respx_mock) -> dict:
+ return json.loads(respx_mock.calls.last.request.content)
+
+
+class TestRegistration:
+ def test_eden_is_a_native_video_provider(self):
+ config = ProviderConfigManager.get_provider_video_config(model=SELLER_MODEL, provider=LlmProviders.EDENAI)
+
+ assert isinstance(config, EdenAIVideoConfig)
+
+
+class TestAuthentication:
+ def test_missing_key_is_an_authentication_error_before_any_request(self, no_eden_key, respx_mock):
+ with pytest.raises(litellm.AuthenticationError, match="EDENAI_API_KEY"):
+ litellm.video_generation(model=MODEL, prompt=PROMPT)
+ assert not respx_mock.calls
+
+
+class TestCreate:
+ def test_posts_json_to_eden_with_the_bearer_key_and_the_seller_model_id(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))
+
+ response = litellm.video_generation(model=MODEL, prompt=PROMPT, seconds="4", size="1280x720")
+
+ assert isinstance(response, VideoObject)
+ assert response.status == "queued"
+ request = respx_mock.calls.last.request
+ assert request.headers["Authorization"] == f"Bearer {eden_key}"
+ assert request.headers["Content-Type"] == "application/json"
+ assert json.loads(request.content) == {
+ "model": SELLER_MODEL,
+ "prompt": PROMPT,
+ "seconds": "4",
+ "size": "1280x720",
+ }
+
+ def test_the_returned_id_routes_later_calls_back_to_eden(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))
+
+ response = litellm.video_generation(model=MODEL, prompt=PROMPT)
+
+ assert decode_video_id_with_provider(response.id) == {
+ "custom_llm_provider": "edenai",
+ "model_id": SELLER_MODEL,
+ "video_id": JOB_ID,
+ }
+
+ def test_eden_extensions_go_through_as_kwargs_and_extra_body(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))
+
+ litellm.video_generation(model=MODEL, prompt=PROMPT, seed=7, extra_body={"provider_params": {"guidance": 2}})
+
+ body = _request_body(respx_mock)
+ assert (body["seed"], body["provider_params"]) == (7, {"guidance": 2})
+
+ def test_a_reference_image_file_makes_the_request_multipart(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))
+ reference = BytesIO(b"\x89PNG\r\n\x1a\n" + b"\x00" * 16)
+
+ litellm.video_generation(model=MODEL, prompt="animate this", input_reference=reference, seconds="4")
+
+ request = respx_mock.calls.last.request
+ assert request.headers["Content-Type"].startswith("multipart/form-data")
+ assert b'name="input_reference"; filename="input_reference.png"' in request.content
+ assert b'name="model"\r\n\r\n' + SELLER_MODEL.encode() in request.content
+ assert b'name="seconds"\r\n\r\n4' in request.content
+
+ def test_a_reference_image_url_stays_in_the_json_body(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))
+
+ litellm.video_generation(
+ model=MODEL, prompt="animate this", input_reference={"image_url": "https://img.example.net/start.png"}
+ )
+
+ request = respx_mock.calls.last.request
+ assert request.headers["Content-Type"] == "application/json"
+ assert json.loads(request.content)["input_reference"] == {"image_url": "https://img.example.net/start.png"}
+
+ def test_a_queued_job_reports_edens_zero_cost_and_the_requested_duration(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))
+
+ response = litellm.video_generation(model=MODEL, prompt=PROMPT, seconds="4")
+
+ assert response.usage == {"duration_seconds": 4.0, "provider_reported_cost_usd": 0.0}
+
+ @pytest.mark.asyncio
+ async def test_a_queued_job_bills_nothing_until_it_settles(
+ self, eden_key, httpx_transport, respx_mock, spend_capture
+ ):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(200, json=_eden_video()))
+
+ await litellm.avideo_generation(model=MODEL, prompt=PROMPT, seconds="4", litellm_call_id=spend_capture.call_id)
+ await spend_capture.settle()
+
+ assert spend_capture.costs == [0.0]
+
+ @pytest.mark.asyncio
+ async def test_a_cost_settled_on_the_create_response_is_billed(
+ self, eden_key, httpx_transport, respx_mock, spend_capture
+ ):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(
+ return_value=httpx.Response(200, json=_eden_video(status="completed", cost=SETTLED_COST))
+ )
+
+ await litellm.avideo_generation(model=MODEL, prompt=PROMPT, seconds="4", litellm_call_id=spend_capture.call_id)
+ await spend_capture.settle()
+
+ assert spend_capture.costs == [SETTLED_COST]
+
+
+class TestStatus:
+ def test_reads_the_job_with_the_bearer_key_and_surfaces_the_settled_cost(self, eden_key, respx_mock):
+ respx_mock.get(f"{EDEN_VIDEOS_URL}/{JOB_ID}").mock(
+ return_value=httpx.Response(
+ 200, json=_eden_video(status="completed", cost=SETTLED_COST, seconds=None, size=None)
+ )
+ )
+
+ response = litellm.video_status(video_id=_encoded())
+
+ assert respx_mock.calls.last.request.headers["Authorization"] == f"Bearer {eden_key}"
+ assert (response.status, response.progress) == ("completed", 100)
+ assert response.usage == {"provider_reported_cost_usd": SETTLED_COST}
+ assert decode_video_id_with_provider(response.id)["video_id"] == JOB_ID
+
+ @pytest.mark.asyncio
+ async def test_polling_a_finished_job_does_not_bill_it_again(
+ self, eden_key, httpx_transport, respx_mock, spend_capture
+ ):
+ respx_mock.get(f"{EDEN_VIDEOS_URL}/{JOB_ID}").mock(
+ return_value=httpx.Response(200, json=_eden_video(status="completed", cost=SETTLED_COST))
+ )
+
+ await litellm.avideo_status(video_id=_encoded(), litellm_call_id=spend_capture.call_id)
+ await spend_capture.settle()
+
+ assert len(spend_capture.costs) == 1
+ assert not spend_capture.costs[0]
+
+ def test_an_unknown_job_is_a_not_found_error(self, eden_key, respx_mock):
+ respx_mock.get(f"{EDEN_VIDEOS_URL}/{JOB_ID}").mock(
+ return_value=httpx.Response(
+ 404,
+ json={
+ "error": {
+ "message": f"Video {JOB_ID} not found",
+ "type": "invalid_request_error",
+ "param": None,
+ "code": "model_not_found",
+ }
+ },
+ )
+ )
+
+ with pytest.raises(litellm.NotFoundError, match="not found"):
+ litellm.video_status(video_id=_encoded())
+
+
+class TestContent:
+ def test_follows_edens_redirect_to_the_file_without_forwarding_the_key(self, eden_key, respx_mock):
+ respx_mock.get(f"{EDEN_VIDEOS_URL}/{JOB_ID}/content").mock(
+ return_value=httpx.Response(302, headers={"location": FILE_URL})
+ )
+ respx_mock.get(FILE_URL).mock(
+ return_value=httpx.Response(200, content=MP4_BYTES, headers={"content-type": "binary/octet-stream"})
+ )
+
+ video = litellm.video_content(video_id=_encoded())
+
+ assert video == MP4_BYTES
+ eden_request, file_request = (call.request for call in respx_mock.calls)
+ assert eden_request.headers["Authorization"] == f"Bearer {eden_key}"
+ assert "Authorization" not in file_request.headers
+
+ @pytest.mark.asyncio
+ async def test_async_download_follows_the_same_redirect(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.get(f"{EDEN_VIDEOS_URL}/{JOB_ID}/content").mock(
+ return_value=httpx.Response(302, headers={"location": FILE_URL})
+ )
+ respx_mock.get(FILE_URL).mock(return_value=httpx.Response(200, content=MP4_BYTES))
+
+ assert await litellm.avideo_content(video_id=_encoded()) == MP4_BYTES
+
+
+class TestList:
+ def test_lists_jobs_newest_first_with_encoded_ids_and_their_costs(self, eden_key, httpx_transport, respx_mock):
+ """The sync entry point runs the async handler, so the client must sit on httpx for respx to see it."""
+ older = "d544c281-9099-487e-b537-5f2291b603c8"
+ respx_mock.get(host="api.edenai.run", path="/v3/videos").mock(
+ return_value=httpx.Response(
+ 200,
+ json={
+ "object": "list",
+ "data": [
+ _eden_video(status="completed", cost=0.02, seconds=None, size=None),
+ _eden_video(status="completed", cost=0.1, id=older, seconds=None, size=None),
+ ],
+ "first_id": JOB_ID,
+ "last_id": older,
+ "has_more": True,
+ },
+ )
+ )
+
+ page = litellm.video_list(custom_llm_provider="edenai", limit=2)
+
+ assert respx_mock.calls.last.request.url.params["limit"] == "2"
+ assert [decode_video_id_with_provider(video["id"])["video_id"] for video in page["data"]] == [JOB_ID, older]
+ assert [video["cost"] for video in page["data"]] == [0.02, 0.1]
+ assert decode_video_id_with_provider(page["last_id"]) == {
+ "custom_llm_provider": "edenai",
+ "model_id": SELLER_MODEL,
+ "video_id": older,
+ }
+
+
+class TestErrors:
+ def test_middleware_401_maps_to_authentication_error(self, eden_key, respx_mock):
+ respx_mock.post(EDEN_VIDEOS_URL).mock(return_value=httpx.Response(401, json={"detail": "Invalid token"}))
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ litellm.video_generation(model=MODEL, prompt=PROMPT)
+
+ def test_a_401_on_a_read_is_an_authentication_error_too(self, eden_key, httpx_transport, respx_mock):
+ respx_mock.get(host="api.edenai.run", path="/v3/videos").mock(
+ return_value=httpx.Response(401, json={"detail": "Invalid token"})
+ )
+ respx_mock.get(f"{EDEN_VIDEOS_URL}/{JOB_ID}/content").mock(
+ return_value=httpx.Response(401, json={"detail": "Invalid token"})
+ )
+
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ litellm.video_list(custom_llm_provider="edenai")
+ with pytest.raises(litellm.AuthenticationError, match="Invalid token"):
+ litellm.video_content(video_id=_encoded())
+
+ def test_an_openai_param_eden_does_not_accept_yet_is_forwarded_and_eden_answers(self, eden_key, respx_mock):
+ """OpenAI's full video param set goes through untouched, so Eden's own validation is what a caller
+ sees today and nothing here needs to change once Eden accepts these fields."""
+ respx_mock.post(EDEN_VIDEOS_URL).mock(
+ return_value=httpx.Response(
+ 422,
+ json={
+ "error": {
+ "message": "Extra inputs are not permitted",
+ "type": "invalid_request_error",
+ "param": "user",
+ "code": "invalid_parameter",
+ }
+ },
+ )
+ )
+
+ with pytest.raises(litellm.BadRequestError, match="Extra inputs"):
+ litellm.video_generation(model=MODEL, prompt=PROMPT, user="u1")
+ assert _request_body(respx_mock)["user"] == "u1"
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index 8e419b15044..3eba1bcfced 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -2998,6 +2998,35 @@ class TestAdditionalDropParamsForNonOpenAIProviders:
assert result.get("custom_param") == "value"
+class TestExtraBodyCannotOverrideModel:
+ @pytest.mark.parametrize("custom_llm_provider", ["edenai", "openai", "azure"])
+ def test_extra_body_model_is_dropped_for_openai_compatible_providers(self, custom_llm_provider: str) -> None:
+ from litellm.utils import add_provider_specific_params_to_optional_params
+
+ result = add_provider_specific_params_to_optional_params(
+ optional_params={"extra_body": {"model": "edenai/openai/gpt-4o", "provider_flag": True}},
+ passed_params={
+ "model": "edenai/openai/gpt-4o-mini",
+ "extra_body": {"model": "edenai/anthropic/claude-3-opus", "top_k": 5},
+ "custom_param": "kept",
+ },
+ custom_llm_provider=custom_llm_provider,
+ openai_params=["model", "temperature"],
+ additional_drop_params=None,
+ )
+
+ assert result == {"extra_body": {"provider_flag": True, "top_k": 5, "custom_param": "kept"}}, result
+
+ def test_get_optional_params_strips_extra_body_model_for_edenai(self) -> None:
+ result = litellm.get_optional_params(
+ model="openai/gpt-4o-mini",
+ custom_llm_provider="edenai",
+ extra_body={"model": "anthropic/claude-opus-4-1", "top_k": 5},
+ )
+
+ assert result["extra_body"] == {"top_k": 5}, result
+
+
class TestDropParamsWithPromptCacheKey:
"""
Test that drop_params: true correctly drops prompt_cache_key for non-OpenAI providers.
diff --git a/ui/litellm-dashboard/public/assets/logos/edenai.svg b/ui/litellm-dashboard/public/assets/logos/edenai.svg
new file mode 100644
index 00000000000..957bd800e00
--- /dev/null
+++ b/ui/litellm-dashboard/public/assets/logos/edenai.svg
@@ -0,0 +1,5 @@
+
diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
index 439a5958874..d6b8d00a023 100644
--- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
+++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
@@ -79,6 +79,17 @@ describe("provider_info_helpers", () => {
expect(result.logo).toBe(providerLogoMap[Providers.BedrockMantle]);
});
+ it("should map edenai slug and EDENAI enum key to the Eden AI display name and logo", () => {
+ const fromSlug = getProviderLogoAndName("edenai");
+ expect(fromSlug.displayName).toBe(Providers.EDENAI);
+ expect(fromSlug.logo).toBe(providerLogoMap[Providers.EDENAI]);
+ expect(fromSlug.logo).toBeTruthy();
+
+ const fromEnumKey = getProviderLogoAndName("EDENAI");
+ expect(fromEnumKey.displayName).toBe(Providers.EDENAI);
+ expect(fromEnumKey.logo).toBe(providerLogoMap[Providers.EDENAI]);
+ });
+
it("should resolve the BedrockMantle enum key to the Bedrock Mantle logo", () => {
// The Add Model dropdown passes the provider_map key ("BedrockMantle"),
// not the slug ("bedrock_mantle"). Unlike "Bedrock", the key does not
@@ -212,6 +223,10 @@ describe("provider_info_helpers", () => {
expect(getPlaceholder(Providers.SCX_AI)).toBe("scx-ai/GLM-5.2");
});
+ it("should return an edenai model placeholder for EDENAI provider", () => {
+ expect(getPlaceholder(Providers.EDENAI)).toBe("edenai/openai/gpt-mini-latest");
+ });
+
it("should return claude-3-opus placeholder for Anthropic provider", () => {
expect(getPlaceholder(Providers.Anthropic)).toBe("claude-3-opus");
});
diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
index 288c443a5ad..eab55503da8 100644
--- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
+++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
@@ -15,6 +15,7 @@ import databricksLogo from "../../public/assets/logos/databricks.svg";
import deepgramLogo from "../../public/assets/logos/deepgram.png";
import deepinfraLogo from "../../public/assets/logos/deepinfra.png";
import deepseekLogo from "../../public/assets/logos/deepseek.svg";
+import edenaiLogo from "../../public/assets/logos/edenai.svg";
import elevenlabsLogo from "../../public/assets/logos/elevenlabs.png";
import falAiLogo from "../../public/assets/logos/fal_ai.jpg";
import featherlessLogo from "../../public/assets/logos/featherless.svg";
@@ -103,6 +104,7 @@ export enum Providers {
Deepseek = "Deepseek",
DOCKER_MODEL_RUNNER = "Docker Model Runner",
DOTPROMPT = "Dotprompt",
+ EDENAI = "Eden AI",
ElevenLabs = "ElevenLabs",
EMPOWER = "Empower",
FalAI = "Fal AI",
@@ -219,6 +221,7 @@ export const provider_map: Record = {
Deepseek: "deepseek",
DOCKER_MODEL_RUNNER: "docker_model_runner",
DOTPROMPT: "dotprompt",
+ EDENAI: "edenai",
ElevenLabs: "elevenlabs",
EMPOWER: "empower",
FalAI: "fal_ai",
@@ -331,6 +334,7 @@ export const providerLogoMap: Partial> = {
[Providers.Deepseek]: deepseekLogo.src,
[Providers.Deepgram]: deepgramLogo.src,
[Providers.DeepInfra]: deepinfraLogo.src,
+ [Providers.EDENAI]: edenaiLogo.src,
[Providers.ElevenLabs]: elevenlabsLogo.src,
[Providers.FalAI]: falAiLogo.src,
[Providers.FEATHERLESS_AI]: featherlessLogo.src,
@@ -436,6 +440,7 @@ const providerPlaceholderMap: Partial> = {
[Providers.Cognition]: "cognition/swe-1.7",
[Providers.Cursor]: "cursor/claude-4-sonnet",
[Providers.DeepInfra]: "deepinfra/",
+ [Providers.EDENAI]: "edenai/openai/gpt-mini-latest",
[Providers.FalAI]: "fal_ai/fal-ai/flux-pro/v1.1-ultra",
[Providers.Google_AI_Studio]: "gemini-pro",
[Providers.JinaAI]: "jina_ai/",
diff --git a/ui/litellm-dashboard/src/lib/logoTreatments.ts b/ui/litellm-dashboard/src/lib/logoTreatments.ts
index 7a5a7892d17..1448dfa686d 100644
--- a/ui/litellm-dashboard/src/lib/logoTreatments.ts
+++ b/ui/litellm-dashboard/src/lib/logoTreatments.ts
@@ -7,6 +7,7 @@ const BUNDLED_LOGO_PATH = /(?:\/assets\/logos\/|\/_next\/static\/media\/)/;
const TREATMENT_BY_ASSET: Readonly> = {
"baseten.svg": "invert",
"cursor.svg": "invert",
+ "edenai.svg": "invert",
"enkrypt_ai.avif": "invert",
"friendli.svg": "invert",
"github.svg": "invert",