Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ dependencies = [
[project.optional-dependencies]

all = [
"any-llm-sdk[mistral,anthropic,huggingface,gemini,vertexai,vertexaianthropic,cohere,cerebras,fireworks,gmi,groq,bedrock,azure,azureanthropic,azureopenai,cascadia,watsonx,together,sambanova,ollama,moonshot,neosantara,nebius,xai,databricks,deepseek,inception,openai,otari,openrouter,portkey,qiniu,requesty,lmstudio,llama,voyage,perplexity,platform,llamafile,llamacpp,sagemaker,github,zai,minimax,mzai,vllm,dashscope,deepinfra,atlascloud,telnyx]"
"any-llm-sdk[mistral,anthropic,huggingface,gemini,vertexai,vertexaianthropic,cohere,cerebras,fireworks,gmi,groq,bedrock,azure,azureanthropic,azureopenai,cascadia,watsonx,together,sambanova,ollama,moonshot,neosantara,nebius,xai,databricks,deepseek,inception,openai,otari,openrouter,portkey,qiniu,requesty,lmstudio,llama,voyage,perplexity,platform,llamafile,llamacpp,sagemaker,github,zai,minimax,mzai,vllm,dashscope,deepinfra,atlascloud,telnyx,edenai]"
]

platform = [
Expand Down Expand Up @@ -139,6 +139,7 @@ vllm = []
zai = []
dashscope = []
deepinfra = []
edenai = []
telnyx = []

[project.urls]
Expand Down
1 change: 1 addition & 0 deletions src/any_llm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ class LLMProvider(StrEnum):
MINIMAX = "minimax"
DASHSCOPE = "dashscope"
DEEPINFRA = "deepinfra"
EDENAI = "edenai"
ZAI = "zai"
TELNYX = "telnyx"

Expand Down
3 changes: 3 additions & 0 deletions src/any_llm/providers/edenai/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .edenai import EdenaiProvider

__all__ = ["EdenaiProvider"]
35 changes: 35 additions & 0 deletions src/any_llm/providers/edenai/edenai.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from collections.abc import Sequence
from typing import Any

from typing_extensions import override

from any_llm.providers.edenai.utils import _convert_models_list
from any_llm.providers.openai.base import BaseOpenAIProvider
from any_llm.types.model import Model


class EdenaiProvider(BaseOpenAIProvider):
API_BASE = "https://api.edenai.run/v3"
ENV_API_KEY_NAME = "EDENAI_API_KEY"
ENV_API_BASE_NAME = "EDENAI_API_BASE"
PROVIDER_NAME = "edenai"
PROVIDER_DOCUMENTATION_URL = "https://www.edenai.co/docs"

SUPPORTS_COMPLETION_STREAMING = True
SUPPORTS_COMPLETION = True
# Eden AI exposes a /v3/responses endpoint, but any-llm's Responses support
# targets the OpenResponses-compatible shape, which Eden AI's endpoint has
# not been verified against, so the Responses API is left unsupported here.
SUPPORTS_RESPONSES = False
# Eden AI's OpenAI-compatible endpoint does not surface structured reasoning
# content in a separate field (it is inlined in the message content), so
# reasoning is reported as unsupported here.
SUPPORTS_COMPLETION_REASONING = False
SUPPORTS_EMBEDDING = True
SUPPORTS_MODERATION = True

@staticmethod
@override
def _convert_list_models_response(response: Any) -> Sequence[Model]:
"""Convert the Eden AI /v3/models response to valid Model objects."""
return _convert_models_list(response)
35 changes: 35 additions & 0 deletions src/any_llm/providers/edenai/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Eden AI provider utilities."""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from any_llm.types.model import Model

if TYPE_CHECKING:
from collections.abc import Sequence


def _convert_models_list(response: Any) -> Sequence[Model]:
"""Convert the Eden AI /v3/models response to valid Model objects.

Eden AI's ``/v3/models`` response does not reliably populate every field the
OpenAI ``Model`` schema requires (``created`` in particular is absent, and
``object``/``owned_by`` may be missing). The OpenAI SDK accepts the missing
fields via ``model_construct()``, but the resulting objects fail round-trip
serialization. This fills any missing required fields while preserving Eden
AI's extra attributes (``model_name``, ``context_length``, ...).
"""
raw_models = response.data if hasattr(response, "data") else response
result: list[Model] = []
for model in raw_models:
data: dict[str, Any] = model.model_dump() if hasattr(model, "model_dump") else dict(vars(model))
if data.get("object") is None:
data["object"] = "model"
if data.get("owned_by") is None:
model_id = data.get("id") or ""
data["owned_by"] = model_id.split("/", 1)[0] if "/" in model_id else "edenai"
if data.get("created") is None:
data["created"] = 0
result.append(Model.model_validate(data))
return result
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ def provider_model_map() -> dict[LLMProvider, str]:
LLMProvider.MINIMAX: "MiniMax-M2",
LLMProvider.ZAI: "glm-4-32b-0414-128k",
LLMProvider.DEEPINFRA: "meta-llama/Meta-Llama-3.1-8B-Instruct",
LLMProvider.EDENAI: "openai/gpt-4o-mini",
LLMProvider.TELNYX: "meta-llama/Meta-Llama-3.1-8B-Instruct",
}

Expand Down Expand Up @@ -169,6 +170,7 @@ def embedding_provider_model_map() -> dict[LLMProvider, str]:
LLMProvider.AZUREOPENAI: "gpt-4.1-nano", # Not an embedding model but it's the only one we have deployed in Azure OpenAI
LLMProvider.OPENROUTER: "qwen/qwen3-embedding-8b",
LLMProvider.DEEPINFRA: "BAAI/bge-base-en-v1.5",
LLMProvider.EDENAI: "openai/text-embedding-3-small",
}


Expand Down
1 change: 1 addition & 0 deletions tests/integration/test_moderation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def moderation_provider_model_map() -> dict[LLMProvider, str]:
return {
LLMProvider.OPENAI: "omni-moderation-latest",
LLMProvider.MISTRAL: "mistral-moderation-latest",
LLMProvider.EDENAI: "openai/omni-moderation-latest",
}


Expand Down
142 changes: 142 additions & 0 deletions tests/unit/providers/test_edenai_provider.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from any_llm.exceptions import MissingApiKeyError
from any_llm.providers.edenai import EdenaiProvider
from any_llm.providers.edenai.utils import _convert_models_list
from any_llm.types.completion import CompletionParams
from any_llm.types.model import Model


def test_edenai_provider_attributes() -> None:
"""Eden AI provider declares the expected config and capability flags."""
assert EdenaiProvider.PROVIDER_NAME == "edenai"
assert EdenaiProvider.API_BASE == "https://api.edenai.run/v3"
assert EdenaiProvider.ENV_API_KEY_NAME == "EDENAI_API_KEY"
assert EdenaiProvider.SUPPORTS_COMPLETION is True
assert EdenaiProvider.SUPPORTS_RESPONSES is False


def test_edenai_provider_raises_missing_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Instantiating without a key (and no env var) raises MissingApiKeyError."""
monkeypatch.delenv("EDENAI_API_KEY", raising=False)
with pytest.raises(MissingApiKeyError):
EdenaiProvider()


def test_edenai_remaps_max_tokens_to_max_completion_tokens() -> None:
"""Base OpenAI-compatible behavior: max_tokens is remapped for the chat API."""
params = CompletionParams(
model_id="openai/gpt-4o-mini",
messages=[{"role": "user", "content": "Hello"}],
max_tokens=8192,
)
result = EdenaiProvider._convert_completion_params(params)
assert "max_tokens" not in result
assert result["max_completion_tokens"] == 8192


def _make_edenai_model(**overrides: object) -> Model:
"""Build a fake model resembling what the OpenAI SDK constructs from Eden AI's response."""
defaults: dict[str, object] = {
"id": "openai/gpt-4o-mini",
"created": None,
"object": None,
"owned_by": None,
}
defaults.update(overrides)
return Model.model_construct(**defaults) # type: ignore[arg-type]


def test_convert_models_list_fills_missing_fields() -> None:
"""Models with None object/created are rebuilt with valid defaults."""
response = SimpleNamespace(data=[_make_edenai_model(created=1779376861)])
result = _convert_models_list(response)

assert len(result) == 1
model = result[0]
assert model.id == "openai/gpt-4o-mini"
assert model.object == "model"
assert model.created == 1779376861


def test_convert_models_list_owned_by_from_vendor_prefix() -> None:
"""owned_by is derived from the vendor prefix of Eden AI's id."""
response = SimpleNamespace(data=[_make_edenai_model(id="anthropic/claude-sonnet-4-5")])
result = _convert_models_list(response)

assert result[0].owned_by == "anthropic"


def test_convert_models_list_owned_by_fallback_when_no_prefix() -> None:
"""An id without a vendor prefix falls back to 'edenai'."""
response = SimpleNamespace(data=[_make_edenai_model(id="some-model")])
result = _convert_models_list(response)

assert result[0].owned_by == "edenai"


def test_convert_models_list_defaults_created_to_zero_when_none() -> None:
"""A None created timestamp falls back to 0."""
response = SimpleNamespace(data=[_make_edenai_model(created=None)])
result = _convert_models_list(response)

assert result[0].created == 0


def test_convert_models_list_handles_empty_response() -> None:
"""An empty response returns an empty list."""
response = SimpleNamespace(data=[])
result = _convert_models_list(response)

assert result == []


def test_convert_models_list_round_trip_serialization() -> None:
"""Converted models must survive model_dump_json -> model_validate_json."""
response = SimpleNamespace(data=[_make_edenai_model()])
models = _convert_models_list(response)

for model in models:
json_data = model.model_dump_json()
restored = Model.model_validate_json(json_data)
assert restored.id == model.id
assert restored.object == "model"
assert restored.owned_by == "openai"


def test_convert_models_list_preserves_extra_edenai_fields() -> None:
"""Extra Eden AI attributes (model_name, context_length) survive conversion."""
model = _make_edenai_model(model_name="gpt-4o-mini", context_length=128000)
response = SimpleNamespace(data=[model])
result = _convert_models_list(response)

converted = result[0]
assert converted.model_extra is not None
assert converted.model_extra["model_name"] == "gpt-4o-mini"
assert converted.model_extra["context_length"] == 128000


@patch("any_llm.providers.openai.base.AsyncOpenAI")
def test_list_models_returns_valid_model_objects(mock_openai_class: MagicMock) -> None:
"""End-to-end: EdenaiProvider.list_models() returns spec-compliant Model objects."""
raw_models = [
_make_edenai_model(id="openai/gpt-4o-mini"),
_make_edenai_model(id="mistral/mistral-small-latest"),
]
mock_client = AsyncMock()
mock_client.models.list.return_value = SimpleNamespace(data=raw_models)
mock_openai_class.return_value = mock_client

provider = EdenaiProvider(api_key="sk-test")
result = provider.list_models()

assert len(result) == 2
for model in result:
assert isinstance(model, Model)
assert model.object == "model"
assert model.owned_by is not None
assert result[0].owned_by == "openai"
assert result[1].owned_by == "mistral"
Loading