Merge pull request #41101 from hMED22/litellm_add_edenai_provider

feat(edenai): add Eden AI provider across chat, Responses, Messages, embeddings, audio, images and video
This commit is contained in:
Yassin Kortam 2026-09-21 16:16:28 -05:00 committed by GitHub
commit f6c69af427
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 3208 additions and 9 deletions

View file

@ -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) | ✅ | ✅ | ✅ | | ✅ | | | | | |

View file

@ -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,
)

View file

@ -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",

View file

@ -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",

View file

@ -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}")

View file

@ -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

View file

@ -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,

View file

@ -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
)

View file

@ -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)

View file

@ -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

View file

@ -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})

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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
):

View file

@ -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",

View file

@ -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",

View file

@ -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"

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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)

View file

@ -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",
}

View file

@ -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()

View file

@ -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")

View file

@ -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")

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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")

View file

@ -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"

View file

@ -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.

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2.82 0 28 28">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.02 9.56C18.05 9.27 18.06 8.98 18.06 8.69C18.06 8.08 18 7.48 17.88 6.9C17.04 2.96 13.5 0 9.26 0C4.39 0 0.45 3.89 0.45 8.69C0.45 10.23 0.86 11.68 1.57 12.94C1.69 13.14 1.81 13.35 1.95 13.54C3.48 12.39 5.39 11.71 7.45 11.71C8.67 11.71 9.84 11.95 10.91 12.38C12.23 10.6 14.36 9.45 16.75 9.45C17.19 9.45 17.61 9.48 18.02 9.56ZM17.71 11.14C17.4 11.09 17.08 11.06 16.75 11.06C14.98 11.06 13.39 11.87 12.37 13.14C12.03 13.56 11.75 14.02 11.55 14.53C11.29 15.16 11.15 15.84 11.15 16.56C11.15 16.77 11.16 16.97 11.19 17.18C14.32 16.49 16.82 14.15 17.71 11.14ZM9.59 17.38C9.55 17.11 9.54 16.83 9.54 16.56C9.54 15.58 9.74 14.64 10.1 13.8C9.33 13.51 8.5 13.35 7.64 13.32C7.58 13.32 7.52 13.32 7.45 13.32C5.77 13.32 4.23 13.87 2.98 14.79C4.58 16.39 6.8 17.39 9.26 17.39C9.37 17.39 9.48 17.39 9.59 17.38Z" fill="#000410"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.1 13.8C9.33 13.51 8.5 13.35 7.64 13.32C7.58 13.32 7.52 13.32 7.45 13.32C5.77 13.32 4.23 13.87 2.98 14.79C2.54 15.11 2.15 15.48 1.79 15.89C0.68 17.17 0 18.84 0 20.66C0 22.06 0.39 23.36 1.08 24.47C2.39 26.59 4.75 28 7.45 28C10.63 28 13.34 26.04 14.41 23.28C12.36 22.59 10.71 21 9.97 18.98C9.78 18.47 9.65 17.94 9.59 17.38C9.55 17.11 9.54 16.83 9.54 16.55C9.54 15.58 9.74 14.64 10.1 13.8ZM14.83 21.71C14.88 21.37 14.9 21.02 14.9 20.66C14.9 18.1 13.57 15.84 11.55 14.53C11.29 15.16 11.15 15.84 11.15 16.56C11.15 16.77 11.16 16.97 11.19 17.18C11.25 17.73 11.4 18.25 11.61 18.74C12.22 20.11 13.38 21.2 14.83 21.71Z" fill="#000410"/>
<path d="M22.35 16.56C22.35 19.59 19.85 22.05 16.75 22.05C16.71 22.05 16.66 22.05 16.61 22.05C16.54 22.05 16.48 22.04 16.41 22.04C15.86 22.01 15.33 21.89 14.83 21.71C13.38 21.2 12.22 20.11 11.61 18.74C11.4 18.25 11.25 17.73 11.19 17.18C11.16 16.97 11.15 16.77 11.15 16.56C11.15 15.84 11.29 15.16 11.55 14.53C11.75 14.02 12.03 13.56 12.37 13.14C13.39 11.87 14.98 11.06 16.75 11.06C17.08 11.06 17.4 11.09 17.71 11.14C18.25 11.23 18.77 11.4 19.25 11.64C21.09 12.54 22.35 14.4 22.35 16.56Z" fill="#000410"/>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -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");
});

View file

@ -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<string, string> = {
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<Record<Providers, string>> = {
[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<Record<Providers, string>> = {
[Providers.Cognition]: "cognition/swe-1.7",
[Providers.Cursor]: "cursor/claude-4-sonnet",
[Providers.DeepInfra]: "deepinfra/<any-model-on-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/",

View file

@ -7,6 +7,7 @@ const BUNDLED_LOGO_PATH = /(?:\/assets\/logos\/|\/_next\/static\/media\/)/;
const TREATMENT_BY_ASSET: Readonly<Record<string, LogoTreatment>> = {
"baseten.svg": "invert",
"cursor.svg": "invert",
"edenai.svg": "invert",
"enkrypt_ai.avif": "invert",
"friendli.svg": "invert",
"github.svg": "invert",