From 531b0c7758020cbf292a83c6795e3dc232aed3ac Mon Sep 17 00:00:00 2001 From: "Victor M. SMITH" <72023257+MVS-source@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:39:55 +0200 Subject: [PATCH 1/2] feat(providers): add Eden AI provider Add Eden AI (an EU-based, OpenAI-compatible gateway) as a provider by subclassing BaseOpenAIProvider with a fixed base URL. Include a models list converter (Eden AI's /v3/models omits fields the OpenAI Model schema requires), the enum entry, the pyproject extra, conftest model maps and a unit test. Verified live against the real API: completion, streaming, embeddings, list_models, response_format and moderation. --- pyproject.toml | 3 +- src/any_llm/constants.py | 1 + src/any_llm/providers/edenai/__init__.py | 3 + src/any_llm/providers/edenai/edenai.py | 32 +++++ src/any_llm/providers/edenai/utils.py | 34 +++++ tests/conftest.py | 2 + tests/integration/test_moderation.py | 1 + tests/unit/providers/test_edenai_provider.py | 142 +++++++++++++++++++ 8 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 src/any_llm/providers/edenai/__init__.py create mode 100644 src/any_llm/providers/edenai/edenai.py create mode 100644 src/any_llm/providers/edenai/utils.py create mode 100644 tests/unit/providers/test_edenai_provider.py diff --git a/pyproject.toml b/pyproject.toml index e088c0414..d97277b54 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ @@ -139,6 +139,7 @@ vllm = [] zai = [] dashscope = [] deepinfra = [] +edenai = [] telnyx = [] [project.urls] diff --git a/src/any_llm/constants.py b/src/any_llm/constants.py index ac74a47ce..5db45e4f0 100644 --- a/src/any_llm/constants.py +++ b/src/any_llm/constants.py @@ -63,6 +63,7 @@ class LLMProvider(StrEnum): MINIMAX = "minimax" DASHSCOPE = "dashscope" DEEPINFRA = "deepinfra" + EDENAI = "edenai" ZAI = "zai" TELNYX = "telnyx" diff --git a/src/any_llm/providers/edenai/__init__.py b/src/any_llm/providers/edenai/__init__.py new file mode 100644 index 000000000..51139943b --- /dev/null +++ b/src/any_llm/providers/edenai/__init__.py @@ -0,0 +1,3 @@ +from .edenai import EdenaiProvider + +__all__ = ["EdenaiProvider"] diff --git a/src/any_llm/providers/edenai/edenai.py b/src/any_llm/providers/edenai/edenai.py new file mode 100644 index 000000000..beab16567 --- /dev/null +++ b/src/any_llm/providers/edenai/edenai.py @@ -0,0 +1,32 @@ +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 + 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) diff --git a/src/any_llm/providers/edenai/utils.py b/src/any_llm/providers/edenai/utils.py new file mode 100644 index 000000000..ade195837 --- /dev/null +++ b/src/any_llm/providers/edenai/utils.py @@ -0,0 +1,34 @@ +"""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`` endpoint omits the ``object``, ``owned_by`` and + ``created`` fields that the OpenAI ``Model`` schema requires. The OpenAI SDK + accepts the missing fields via ``model_construct()``, but the resulting + objects fail round-trip serialization. This fills the 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 diff --git a/tests/conftest.py b/tests/conftest.py index 0760aa85f..ee7057b27 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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", } @@ -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", } diff --git a/tests/integration/test_moderation.py b/tests/integration/test_moderation.py index dd5099064..47f5a769f 100644 --- a/tests/integration/test_moderation.py +++ b/tests/integration/test_moderation.py @@ -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", } diff --git a/tests/unit/providers/test_edenai_provider.py b/tests/unit/providers/test_edenai_provider.py new file mode 100644 index 000000000..13eee3e91 --- /dev/null +++ b/tests/unit/providers/test_edenai_provider.py @@ -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" From 47f8f671448f3a861707e6a03ec1df99a68a39a8 Mon Sep 17 00:00:00 2001 From: njbrake Date: Fri, 17 Jul 2026 19:20:49 +0000 Subject: [PATCH 2/2] docs(edenai): clarify Responses and models-conversion comments Note that Eden AI exposes /v3/responses but any-llm's OpenResponses shape is unverified against it, so Responses stays unsupported for now. Correct the models-list docstring: created is the field reliably absent, object/owned_by may be present. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/any_llm/providers/edenai/edenai.py | 3 +++ src/any_llm/providers/edenai/utils.py | 11 ++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/any_llm/providers/edenai/edenai.py b/src/any_llm/providers/edenai/edenai.py index beab16567..85e306002 100644 --- a/src/any_llm/providers/edenai/edenai.py +++ b/src/any_llm/providers/edenai/edenai.py @@ -17,6 +17,9 @@ class EdenaiProvider(BaseOpenAIProvider): 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 diff --git a/src/any_llm/providers/edenai/utils.py b/src/any_llm/providers/edenai/utils.py index ade195837..011cb2ac2 100644 --- a/src/any_llm/providers/edenai/utils.py +++ b/src/any_llm/providers/edenai/utils.py @@ -13,11 +13,12 @@ def _convert_models_list(response: Any) -> Sequence[Model]: """Convert the Eden AI /v3/models response to valid Model objects. - Eden AI's ``/v3/models`` endpoint omits the ``object``, ``owned_by`` and - ``created`` fields that the OpenAI ``Model`` schema requires. The OpenAI SDK - accepts the missing fields via ``model_construct()``, but the resulting - objects fail round-trip serialization. This fills the required fields while - preserving Eden AI's extra attributes (``model_name``, ``context_length``, ...). + 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] = []