mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
commit
f6c69af427
39 changed files with 3208 additions and 9 deletions
|
|
@ -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) | ✅ | ✅ | ✅ | | ✅ | | | | | |
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
91
litellm/llms/edenai/audio_transcription/transformation.py
Normal file
91
litellm/llms/edenai/audio_transcription/transformation.py
Normal 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)
|
||||
145
litellm/llms/edenai/chat/transformation.py
Normal file
145
litellm/llms/edenai/chat/transformation.py
Normal 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
|
||||
80
litellm/llms/edenai/common_utils.py
Normal file
80
litellm/llms/edenai/common_utils.py
Normal 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})
|
||||
97
litellm/llms/edenai/embedding/transformation.py
Normal file
97
litellm/llms/edenai/embedding/transformation.py
Normal 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)
|
||||
115
litellm/llms/edenai/image_generation/transformation.py
Normal file
115
litellm/llms/edenai/image_generation/transformation.py
Normal 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)
|
||||
79
litellm/llms/edenai/messages/transformation.py
Normal file
79
litellm/llms/edenai/messages/transformation.py
Normal 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)
|
||||
80
litellm/llms/edenai/responses/transformation.py
Normal file
80
litellm/llms/edenai/responses/transformation.py
Normal 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
|
||||
85
litellm/llms/edenai/text_to_speech/transformation.py
Normal file
85
litellm/llms/edenai/text_to_speech/transformation.py
Normal 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)
|
||||
146
litellm/llms/edenai/videos/transformation.py
Normal file
146
litellm/llms/edenai/videos/transformation.py
Normal 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)
|
||||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
}
|
||||
61
tests/test_litellm/llms/edenai/conftest.py
Normal file
61
tests/test_litellm/llms/edenai/conftest.py
Normal 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()
|
||||
|
|
@ -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")
|
||||
|
|
@ -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")
|
||||
|
|
@ -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)
|
||||
|
|
@ -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")
|
||||
63
tests/test_litellm/llms/edenai/test_edenai_common_utils.py
Normal file
63
tests/test_litellm/llms/edenai/test_edenai_common_utils.py
Normal 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
|
||||
|
|
@ -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")
|
||||
|
|
@ -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"
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
5
ui/litellm-dashboard/public/assets/logos/edenai.svg
Normal file
5
ui/litellm-dashboard/public/assets/logos/edenai.svg
Normal 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 |
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue