From c7f680e8020944273784abf8f851580c9f8a682d Mon Sep 17 00:00:00 2001 From: MVS-source <72023257+MVS-source@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:55:25 +0200 Subject: [PATCH 1/2] Add Eden AI as a chat client provider Eden AI is a gateway to many model providers behind one OpenAI compatible API and a single key. This adds an agent-framework-edenai package with an EdenAIChatClient, following the same pattern as the Foundry Local client (it subclasses the OpenAI chat completion client and points it at the Eden AI base URL). Models use the provider/model format, for example openai/gpt-4o-mini or anthropic/claude-sonnet-4-5, so the catalog is not hardcoded. - New package python/packages/edenai (client, settings, options, tests, README, AGENTS) - Namespace shim agent_framework.edenai in core, plus the workspace source entry - EDENAI_API_KEY, EDENAI_MODEL and optional EDENAI_BASE_URL settings - Provider sample under samples/02-agents/providers/edenai and a row in the providers overview - cspell dictionary entry Verified end to end against the live Eden AI API (openai and anthropic models). ruff, mypy and pyright are clean and the unit tests pass. --- python/.cspell.json | 1 + .../core/agent_framework/edenai/__init__.py | 38 +++ .../core/agent_framework/edenai/__init__.pyi | 13 + python/packages/edenai/AGENTS.md | 30 ++ python/packages/edenai/LICENSE | 21 ++ python/packages/edenai/README.md | 19 ++ .../edenai/agent_framework_edenai/__init__.py | 23 ++ .../agent_framework_edenai/_chat_client.py | 278 ++++++++++++++++++ .../edenai/agent_framework_edenai/py.typed | 0 python/packages/edenai/pyproject.toml | 97 ++++++ python/packages/edenai/tests/conftest.py | 41 +++ .../edenai/tests/test_edenai_client.py | 119 ++++++++ python/pyproject.toml | 1 + python/samples/02-agents/providers/README.md | 1 + .../02-agents/providers/edenai/README.md | 23 ++ .../providers/edenai/edenai_chat_client.py | 56 ++++ 16 files changed, 761 insertions(+) create mode 100644 python/packages/core/agent_framework/edenai/__init__.py create mode 100644 python/packages/core/agent_framework/edenai/__init__.pyi create mode 100644 python/packages/edenai/AGENTS.md create mode 100644 python/packages/edenai/LICENSE create mode 100644 python/packages/edenai/README.md create mode 100644 python/packages/edenai/agent_framework_edenai/__init__.py create mode 100644 python/packages/edenai/agent_framework_edenai/_chat_client.py create mode 100644 python/packages/edenai/agent_framework_edenai/py.typed create mode 100644 python/packages/edenai/pyproject.toml create mode 100644 python/packages/edenai/tests/conftest.py create mode 100644 python/packages/edenai/tests/test_edenai_client.py create mode 100644 python/samples/02-agents/providers/edenai/README.md create mode 100644 python/samples/02-agents/providers/edenai/edenai_chat_client.py diff --git a/python/.cspell.json b/python/.cspell.json index d53d7103529..b481718dff4 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -37,6 +37,7 @@ "datamodel", "desync", "dotenv", + "edenai", "endregion", "entra", "faiss", diff --git a/python/packages/core/agent_framework/edenai/__init__.py b/python/packages/core/agent_framework/edenai/__init__.py new file mode 100644 index 00000000000..109b95ecb2e --- /dev/null +++ b/python/packages/core/agent_framework/edenai/__init__.py @@ -0,0 +1,38 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Eden AI integration namespace for optional Agent Framework connectors. + +This module lazily re-exports objects from: +- ``agent-framework-edenai`` + +Supported classes: +- EdenAIChatClient +- EdenAIChatOptions +- EdenAISettings +""" + +import importlib +from typing import Any + +IMPORT_PATH = "agent_framework_edenai" +PACKAGE_NAME = "agent-framework-edenai" +_IMPORTS = [ + "EdenAIChatClient", + "EdenAIChatOptions", + "EdenAISettings", +] + + +def __getattr__(name: str) -> Any: + if name in _IMPORTS: + try: + return getattr(importlib.import_module(IMPORT_PATH), name) + except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + f"The '{PACKAGE_NAME}' package is not installed, please do `pip install {PACKAGE_NAME}`" + ) from exc + raise AttributeError(f"Module {IMPORT_PATH} has no attribute {name}.") + + +def __dir__() -> list[str]: + return _IMPORTS diff --git a/python/packages/core/agent_framework/edenai/__init__.pyi b/python/packages/core/agent_framework/edenai/__init__.pyi new file mode 100644 index 00000000000..068c8b0d583 --- /dev/null +++ b/python/packages/core/agent_framework/edenai/__init__.pyi @@ -0,0 +1,13 @@ +# Copyright (c) Microsoft. All rights reserved. + +from agent_framework_edenai import ( + EdenAIChatClient, + EdenAIChatOptions, + EdenAISettings, +) + +__all__ = [ + "EdenAIChatClient", + "EdenAIChatOptions", + "EdenAISettings", +] diff --git a/python/packages/edenai/AGENTS.md b/python/packages/edenai/AGENTS.md new file mode 100644 index 00000000000..77d2092103f --- /dev/null +++ b/python/packages/edenai/AGENTS.md @@ -0,0 +1,30 @@ +# Eden AI Package (agent-framework-edenai) + +Integration with Eden AI, a gateway to many model providers behind one OpenAI compatible API. + +## Main Classes + +- **`EdenAIChatClient`** - Chat client for Eden AI models (OpenAI compatible) +- **`EdenAIChatOptions`** - Options TypedDict for Eden AI chat parameters +- **`EdenAISettings`** - Settings for configuration + +## Usage + +```python +from agent_framework.edenai import EdenAIChatClient + +client = EdenAIChatClient(model="openai/gpt-4o-mini") +response = await client.get_response("Hello") +``` + +## Import Path + +```python +from agent_framework.edenai import EdenAIChatClient +``` + +## Configuration + +- `EDENAI_API_KEY`: the Eden AI API key. +- `EDENAI_MODEL`: the `provider/model` to use, for example `openai/gpt-4o-mini`. +- `EDENAI_BASE_URL`: optional, defaults to `https://api.edenai.run/v3`. diff --git a/python/packages/edenai/LICENSE b/python/packages/edenai/LICENSE new file mode 100644 index 00000000000..9e841e7a26e --- /dev/null +++ b/python/packages/edenai/LICENSE @@ -0,0 +1,21 @@ + MIT License + + Copyright (c) Microsoft Corporation. + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE diff --git a/python/packages/edenai/README.md b/python/packages/edenai/README.md new file mode 100644 index 00000000000..9a62bc29dae --- /dev/null +++ b/python/packages/edenai/README.md @@ -0,0 +1,19 @@ +# Get Started with Microsoft Agent Framework Eden AI + +Please install this package as the extra for `agent-framework`: + +```bash +pip install agent-framework-edenai --pre +``` + +Eden AI is a gateway to many model providers behind one OpenAI compatible API and a single key. Models use the `provider/model` format, for example `openai/gpt-4o-mini` or `anthropic/claude-sonnet-4-5`. + +```python +from agent_framework.edenai import EdenAIChatClient + +# Reads EDENAI_API_KEY from the environment, or pass api_key=... +client = EdenAIChatClient(model="openai/gpt-4o-mini") +response = await client.get_response("Hello") +``` + +See the [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information. diff --git a/python/packages/edenai/agent_framework_edenai/__init__.py b/python/packages/edenai/agent_framework_edenai/__init__.py new file mode 100644 index 00000000000..20e8a97212d --- /dev/null +++ b/python/packages/edenai/agent_framework_edenai/__init__.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Eden AI integration for Microsoft Agent Framework. + +Eden AI is a gateway that exposes many model providers behind a single +OpenAI compatible API and one key. +""" + +import importlib.metadata + +from ._chat_client import EdenAIChatClient, EdenAIChatOptions, EdenAISettings + +try: + __version__ = importlib.metadata.version("agent-framework-edenai") +except importlib.metadata.PackageNotFoundError: + __version__ = "0.0.0" # Fallback for development mode + +__all__ = [ + "EdenAIChatClient", + "EdenAIChatOptions", + "EdenAISettings", + "__version__", +] diff --git a/python/packages/edenai/agent_framework_edenai/_chat_client.py b/python/packages/edenai/agent_framework_edenai/_chat_client.py new file mode 100644 index 00000000000..a66d2ebe00b --- /dev/null +++ b/python/packages/edenai/agent_framework_edenai/_chat_client.py @@ -0,0 +1,278 @@ +# Copyright (c) Microsoft. All rights reserved. + +from __future__ import annotations + +import sys +from collections.abc import Awaitable, Callable, Mapping, Sequence +from typing import Any, ClassVar, Generic, Literal, cast, overload + +from agent_framework import ( + ChatAndFunctionMiddlewareTypes, + ChatMiddlewareLayer, + ChatOptions, + ChatResponse, + ChatResponseUpdate, + CompactionStrategy, + FunctionInvocationConfiguration, + FunctionInvocationLayer, + Message, + ResponseStream, + TokenizerProtocol, +) +from agent_framework._settings import SecretString, load_settings +from agent_framework.observability import ChatTelemetryLayer +from agent_framework_openai._chat_completion_client import RawOpenAIChatCompletionClient +from openai import AsyncOpenAI +from pydantic import BaseModel + +if sys.version_info >= (3, 13): + from typing import TypeVar # pragma: no cover +else: + from typing_extensions import TypeVar # pragma: no cover +if sys.version_info >= (3, 11): + from typing import TypedDict # pragma: no cover +else: + from typing_extensions import TypedDict # pragma: no cover + + +__all__ = [ + "EdenAIChatClient", + "EdenAIChatOptions", + "EdenAISettings", +] + +# Eden AI is OpenAI compatible, so this base URL is used with the OpenAI SDK. +DEFAULT_EDENAI_BASE_URL = "https://api.edenai.run/v3" + +ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) + + +# region Eden AI Chat Options TypedDict + + +class EdenAIChatOptions(ChatOptions[ResponseModelT], Generic[ResponseModelT], total=False): + """Eden AI chat options dict. + + Eden AI is a gateway that exposes many providers behind one OpenAI compatible + API, so the standard OpenAI Chat Completions options are supported. The model + uses the ``provider/model`` format, for example ``openai/gpt-4o-mini`` or + ``anthropic/claude-sonnet-4-5``. + + See: https://docs.edenai.co + + Keys: + # Inherited from ChatOptions (supported via the OpenAI compatible API): + model: The ``provider/model`` identifier, for example ``openai/gpt-4o-mini``. + temperature: Sampling temperature (0-2). + top_p: Nucleus sampling parameter. + max_tokens: Maximum tokens to generate. + stop: Stop sequences. + tools: List of tools available to the model. + tool_choice: How the model should use tools. + frequency_penalty: Frequency penalty (-2.0 to 2.0). + presence_penalty: Presence penalty (-2.0 to 2.0). + seed: Random seed for reproducibility. + response_format: Structured output schema. + + Note: + The options that are honored depend on the underlying provider the model + routes to. Options a provider does not support are typically ignored. + """ + + # Eden AI specific options + extra_body: dict[str, Any] + """Additional request body parameters passed through to the underlying provider.""" + + +EdenAIChatOptionsT = TypeVar( + "EdenAIChatOptionsT", + bound=TypedDict, # type: ignore[valid-type] + default="EdenAIChatOptions", + covariant=True, +) + + +# endregion + + +class EdenAISettings(TypedDict, total=False): + """Eden AI settings. + + Settings are resolved in this order: explicit keyword arguments, values from an + explicitly provided .env file, then environment variables with the prefix + 'EDENAI_'. + + Keys: + api_key: The Eden AI API key. (Env var EDENAI_API_KEY) + base_url: The base URL for the Eden AI OpenAI compatible API. + Defaults to https://api.edenai.run/v3. (Env var EDENAI_BASE_URL) + model: The ``provider/model`` to use, for example ``openai/gpt-4o-mini``. + (Env var EDENAI_MODEL) + """ + + api_key: SecretString | None + base_url: str | None + model: str | None + + +class EdenAIChatClient( + FunctionInvocationLayer[EdenAIChatOptionsT], + ChatMiddlewareLayer[EdenAIChatOptionsT], + ChatTelemetryLayer[EdenAIChatOptionsT], + RawOpenAIChatCompletionClient[EdenAIChatOptionsT], + Generic[EdenAIChatOptionsT], +): + """Eden AI Chat completion class with middleware, telemetry, and function invocation support.""" + + OTEL_PROVIDER_NAME: ClassVar[str] = "edenai" + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: ChatOptions[ResponseModelT], + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[ResponseModelT]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[False] = ..., + options: EdenAIChatOptionsT | ChatOptions[None] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[Any]]: ... + + @overload + def get_response( + self, + messages: Sequence[Message], + *, + stream: Literal[True], + options: EdenAIChatOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: ... + + def get_response( + self, + messages: Sequence[Message], + *, + stream: bool = False, + options: EdenAIChatOptionsT | ChatOptions[Any] | None = None, + compaction_strategy: CompactionStrategy | None = None, + tokenizer: TokenizerProtocol | None = None, + function_invocation_kwargs: Mapping[str, Any] | None = None, + client_kwargs: Mapping[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + ) -> Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]: + """Get a response from the Eden AI chat client with all standard layers enabled.""" + super_get_response = cast( + "Callable[..., Awaitable[ChatResponse[Any]] | ResponseStream[ChatResponseUpdate, ChatResponse[Any]]]", + super().get_response, + ) + effective_client_kwargs = dict(client_kwargs) if client_kwargs is not None else {} + if middleware is not None: + effective_client_kwargs["middleware"] = middleware + return super_get_response( + messages=messages, + stream=stream, + options=options, + compaction_strategy=compaction_strategy, + tokenizer=tokenizer, + function_invocation_kwargs=function_invocation_kwargs, + client_kwargs=effective_client_kwargs, + ) + + def __init__( + self, + model: str | None = None, + *, + api_key: str | None = None, + base_url: str | None = None, + additional_properties: dict[str, Any] | None = None, + middleware: Sequence[ChatAndFunctionMiddlewareTypes] | None = None, + function_invocation_configuration: FunctionInvocationConfiguration | None = None, + env_file_path: str | None = None, + env_file_encoding: str = "utf-8", + ) -> None: + """Initialize an EdenAIChatClient. + + Eden AI is a gateway to many model providers behind one OpenAI compatible API + and a single key. Models use the ``provider/model`` format, for example + ``openai/gpt-4o-mini`` or ``anthropic/claude-sonnet-4-5``. The full list is + available from the Eden AI catalog, so this client does not hardcode it. + + Keyword Args: + model: The ``provider/model`` to use. If not provided, it is loaded from + the EDENAI_MODEL environment variable. + api_key: The Eden AI API key. If not provided, it is loaded from the + EDENAI_API_KEY environment variable. + base_url: The base URL for the Eden AI OpenAI compatible API. If not + provided, it is loaded from EDENAI_BASE_URL and otherwise defaults to + https://api.edenai.run/v3. + additional_properties: Additional properties stored on the client instance. + middleware: Optional sequence of ChatAndFunctionMiddlewareTypes to apply to requests. + function_invocation_configuration: Optional configuration for function invocation support. + env_file_path: If provided, the .env settings are read from this file path location. + env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. + + Examples: + + .. code-block:: python + + # Create an EdenAIChatClient with a specific model: + from agent_framework.edenai import EdenAIChatClient + + client = EdenAIChatClient(model="openai/gpt-4o-mini", api_key="...") + + agent = client.as_agent( + name="EdenAgent", + instructions="You are a helpful agent.", + tools=get_weather, + ) + response = await agent.run("What's the weather like in Paris?") + + # Or set the values in the environment: + # EDENAI_API_KEY=... + # EDENAI_MODEL=openai/gpt-4o-mini + client = EdenAIChatClient() + + Raises: + SettingNotFoundError: If the api key or model could not be resolved. + """ + settings = load_settings( + EdenAISettings, + env_prefix="EDENAI_", + required_fields=["api_key", "model"], + api_key=api_key, + base_url=base_url, + model=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + resolved_api_key: str = str(settings["api_key"]) # type: ignore[typeddict-item] + resolved_model: str = settings["model"] # type: ignore[assignment,typeddict-item] + resolved_base_url = settings.get("base_url") or DEFAULT_EDENAI_BASE_URL + + super().__init__( + model=resolved_model, + async_client=AsyncOpenAI(base_url=resolved_base_url, api_key=resolved_api_key), + additional_properties=additional_properties, + middleware=middleware, + function_invocation_configuration=function_invocation_configuration, + ) diff --git a/python/packages/edenai/agent_framework_edenai/py.typed b/python/packages/edenai/agent_framework_edenai/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/python/packages/edenai/pyproject.toml b/python/packages/edenai/pyproject.toml new file mode 100644 index 00000000000..8dd61964c20 --- /dev/null +++ b/python/packages/edenai/pyproject.toml @@ -0,0 +1,97 @@ +[project] +name = "agent-framework-edenai" +description = "Eden AI integration for Microsoft Agent Framework." +authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}] +readme = "README.md" +requires-python = ">=3.10" +version = "1.0.0b260724" +license-files = ["LICENSE"] +urls.homepage = "https://aka.ms/agent-framework" +urls.source = "https://github.com/microsoft/agent-framework/tree/main/python" +urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=tag%3Apython-1&expanded=true" +urls.issues = "https://github.com/microsoft/agent-framework/issues" +classifiers = [ + "License :: OSI Approved :: MIT License", + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "agent-framework-core>=1.6.0,<2", + "agent-framework-openai>=1.6.0,<2", + "openai>=2.25.0,<3", +] + +[tool.uv] +prerelease = "if-necessary-or-explicit" +environments = [ + "sys_platform == 'darwin'", + "sys_platform == 'linux'", + "sys_platform == 'win32'" +] + +[tool.uv-dynamic-versioning] +fallback-version = "0.0.0" + +[tool.pytest.ini_options] +testpaths = 'tests' +addopts = "-ra -q -r fEX" +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +filterwarnings = [] +timeout = 120 +markers = [ + "integration: marks tests as integration tests that require external services", +] + +[tool.ruff] +extend = "../../pyproject.toml" + +[tool.coverage.run] +omit = [ + "**/__init__.py" +] + +[tool.pyright] +extends = "../../pyproject.toml" +exclude = ['tests'] + +[tool.mypy] +plugins = ['pydantic.mypy'] +strict = true +python_version = "3.10" +ignore_missing_imports = true +disallow_untyped_defs = true +no_implicit_optional = true +check_untyped_defs = true +warn_return_any = true +show_error_codes = true +warn_unused_ignores = false +disallow_incomplete_defs = true +disallow_untyped_decorators = true + +[tool.bandit] +targets = ["agent_framework_edenai"] +exclude_dirs = ["tests"] + +[tool.poe] +executor.type = "uv" +include = "../../shared_tasks.toml" + +[tool.poe.tasks.mypy] +help = "Run MyPy for this package." +cmd = "mypy --config-file $POE_ROOT/pyproject.toml agent_framework_edenai" + +[tool.poe.tasks.test] +help = "Run the default unit test suite for this package." +cmd = 'pytest -m "not integration" --cov=agent_framework_edenai --cov-report=term-missing:skip-covered -n auto --dist worksteal tests' + +[build-system] +requires = ["flit-core >= 3.11,<4.0"] +build-backend = "flit_core.buildapi" diff --git a/python/packages/edenai/tests/conftest.py b/python/packages/edenai/tests/conftest.py new file mode 100644 index 00000000000..f841e851a26 --- /dev/null +++ b/python/packages/edenai/tests/conftest.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft. All rights reserved. +from typing import Any + +from pytest import fixture + + +@fixture +def exclude_list(request: Any) -> list[str]: + """Fixture that returns a list of environment variables to exclude.""" + return request.param if hasattr(request, "param") else [] + + +@fixture +def override_env_param_dict(request: Any) -> dict[str, str]: + """Fixture that returns a dict of environment variables to override.""" + return request.param if hasattr(request, "param") else {} + + +@fixture() +def edenai_unit_test_env(monkeypatch: Any, exclude_list: list[str], override_env_param_dict: dict[str, str]): + """Fixture to set environment variables for EdenAISettings.""" + if exclude_list is None: + exclude_list = [] + + if override_env_param_dict is None: + override_env_param_dict = {} + + env_vars = { + "EDENAI_API_KEY": "test-api-key", + "EDENAI_MODEL": "openai/gpt-4o-mini", + } + + env_vars.update(override_env_param_dict) + + for key, value in env_vars.items(): + if key in exclude_list: + monkeypatch.delenv(key, raising=False) + continue + monkeypatch.setenv(key, value) + + return env_vars diff --git a/python/packages/edenai/tests/test_edenai_client.py b/python/packages/edenai/tests/test_edenai_client.py new file mode 100644 index 00000000000..9b9a7f41836 --- /dev/null +++ b/python/packages/edenai/tests/test_edenai_client.py @@ -0,0 +1,119 @@ +# Copyright (c) Microsoft. All rights reserved. + +import inspect + +import pytest +from agent_framework import Agent, SupportsChatGetResponse +from agent_framework._settings import load_settings +from agent_framework.edenai import EdenAIChatClient +from agent_framework.exceptions import SettingNotFoundError + +from agent_framework_edenai._chat_client import DEFAULT_EDENAI_BASE_URL, EdenAISettings + +# Settings Tests + + +def test_edenai_settings_init_from_env(edenai_unit_test_env: dict[str, str]) -> None: + """Test EdenAISettings initialization from environment variables.""" + settings = load_settings(EdenAISettings, env_prefix="EDENAI_") + + assert settings["api_key"] == edenai_unit_test_env["EDENAI_API_KEY"] + assert settings["model"] == edenai_unit_test_env["EDENAI_MODEL"] + + +def test_edenai_settings_init_with_explicit_values() -> None: + """Test EdenAISettings initialization with explicit values.""" + settings = load_settings( + EdenAISettings, + env_prefix="EDENAI_", + api_key="explicit-key", + model="anthropic/claude-sonnet-4-5", + ) + + assert settings["api_key"] == "explicit-key" + assert settings["model"] == "anthropic/claude-sonnet-4-5" + + +@pytest.mark.parametrize("exclude_list", [["EDENAI_API_KEY"]], indirect=True) +def test_edenai_settings_missing_api_key(edenai_unit_test_env: dict[str, str]) -> None: + """Test EdenAISettings when api_key is missing raises error.""" + with pytest.raises(SettingNotFoundError, match="Required setting 'api_key'"): + load_settings( + EdenAISettings, + env_prefix="EDENAI_", + required_fields=["api_key"], + ) + + +@pytest.mark.parametrize("exclude_list", [["EDENAI_MODEL"]], indirect=True) +def test_edenai_settings_missing_model(edenai_unit_test_env: dict[str, str]) -> None: + """Test EdenAISettings when model is missing raises error.""" + with pytest.raises(SettingNotFoundError, match="Required setting 'model'"): + load_settings( + EdenAISettings, + env_prefix="EDENAI_", + required_fields=["model"], + ) + + +def test_edenai_settings_explicit_overrides_env(edenai_unit_test_env: dict[str, str]) -> None: + """Test that explicit values override environment variables.""" + settings = load_settings(EdenAISettings, env_prefix="EDENAI_", model="google/gemini-2.5-flash") + + assert settings["model"] == "google/gemini-2.5-flash" + assert settings["model"] != edenai_unit_test_env["EDENAI_MODEL"] + + +# Client Initialization Tests + + +def test_edenai_client_init(edenai_unit_test_env: dict[str, str]) -> None: + """Test EdenAIChatClient initialization from environment variables.""" + client = EdenAIChatClient() + + assert client.model == edenai_unit_test_env["EDENAI_MODEL"] + assert isinstance(client, SupportsChatGetResponse) + + +def test_edenai_client_init_with_explicit_values() -> None: + """Test EdenAIChatClient initialization with explicit api_key and model.""" + client = EdenAIChatClient(model="openai/gpt-4o-mini", api_key="explicit-key") + + assert client.model == "openai/gpt-4o-mini" + + +def test_edenai_client_default_base_url(edenai_unit_test_env: dict[str, str]) -> None: + """Test EdenAIChatClient uses the Eden AI base URL by default.""" + client = EdenAIChatClient() + + assert str(client.client.base_url).rstrip("/") == DEFAULT_EDENAI_BASE_URL + + +def test_edenai_client_base_url_override(edenai_unit_test_env: dict[str, str]) -> None: + """Test EdenAIChatClient honors a base_url override.""" + client = EdenAIChatClient(base_url="https://example.test/v3") + + assert str(client.client.base_url).rstrip("/") == "https://example.test/v3" + + +def test_edenai_client_missing_api_key(edenai_unit_test_env: dict[str, str], monkeypatch: pytest.MonkeyPatch) -> None: + """Test EdenAIChatClient raises when the api key is missing.""" + monkeypatch.delenv("EDENAI_API_KEY", raising=False) + with pytest.raises(SettingNotFoundError, match="Required setting 'api_key'"): + EdenAIChatClient(model="openai/gpt-4o-mini") + + +def test_agent_accepts_edenai_client(edenai_unit_test_env: dict[str, str]) -> None: + """Test that an Agent accepts an EdenAIChatClient.""" + client = EdenAIChatClient() + agent = Agent(client=client, instructions="test agent") + assert agent.client is client + + +def test_edenai_client_get_response_uses_explicit_runtime_buckets() -> None: + """Eden AI should expose explicit runtime buckets instead of raw kwargs.""" + signature = inspect.signature(EdenAIChatClient.get_response) + + assert "client_kwargs" in signature.parameters + assert "function_invocation_kwargs" in signature.parameters + assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values()) diff --git a/python/pyproject.toml b/python/pyproject.toml index 040005cbae0..3309c3466bd 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -86,6 +86,7 @@ agent-framework-copilotstudio = { workspace = true } agent-framework-declarative = { workspace = true } agent-framework-devui = { workspace = true } agent-framework-durabletask = { workspace = true } +agent-framework-edenai = { workspace = true } agent-framework-foundry = { workspace = true } agent-framework-foundry-hosting = { workspace = true } agent-framework-foundry-local = { workspace = true } diff --git a/python/samples/02-agents/providers/README.md b/python/samples/02-agents/providers/README.md index 4f0946a2864..6fa4be55199 100644 --- a/python/samples/02-agents/providers/README.md +++ b/python/samples/02-agents/providers/README.md @@ -9,6 +9,7 @@ This directory groups provider-specific samples for Agent Framework. | [`azure/`](azure/) | Azure OpenAI chat completion samples using `OpenAIChatCompletionClient`, including basic usage, explicit configuration, tools, and sessions. | | [`copilotstudio/`](copilotstudio/) | Microsoft Copilot Studio agent samples, including required environment/app registration setup and explicit authentication patterns. | | [`custom/`](custom/) | Framework extensibility samples for building custom `BaseAgent` and `BaseChatClient` implementations, including layer-composition guidance. | +| [`edenai/`](edenai/) | Eden AI samples using `EdenAIChatClient`, an OpenAI-compatible gateway to many providers with one key (models in `provider/model` format). | | [`foundry/`](foundry/) | Microsoft Foundry and Foundry Local samples using `FoundryChatClient`, `FoundryAgent`, `RawFoundryAgentChatClient`, and `FoundryLocalClient` for hosted agents, Responses API, local inference, tools, MCP, and sessions. | | [`gemini/`](gemini/) | Google Gemini samples covering tool calling, streaming, extended thinking, grounding, and built-in code execution. | | [`github_copilot/`](github_copilot/) | `GitHubCopilotAgent` samples showing basic usage, session handling, permission-scoped shell/file/url access, and MCP integration. | diff --git a/python/samples/02-agents/providers/edenai/README.md b/python/samples/02-agents/providers/edenai/README.md new file mode 100644 index 00000000000..ee00ffc5f65 --- /dev/null +++ b/python/samples/02-agents/providers/edenai/README.md @@ -0,0 +1,23 @@ +# Eden AI Samples + +Samples for the Eden AI provider using `EdenAIChatClient`. + +Eden AI is a gateway to many model providers behind one OpenAI compatible API and a single key. Models use the `provider/model` format, for example `openai/gpt-4o-mini` or `anthropic/claude-sonnet-4-5`. + +## Setup + +```bash +pip install agent-framework-edenai --pre +``` + +Set the following environment variables (a `.env` file is supported): + +- `EDENAI_API_KEY`: your Eden AI API key (get one at https://www.edenai.co). +- `EDENAI_MODEL`: the `provider/model` to use, for example `openai/gpt-4o-mini`. +- `EDENAI_BASE_URL`: optional, defaults to `https://api.edenai.run/v3`. + +## Files + +| File | What it shows | +| --- | --- | +| [`edenai_chat_client.py`](edenai_chat_client.py) | Basic `EdenAIChatClient` usage with a function tool. | diff --git a/python/samples/02-agents/providers/edenai/edenai_chat_client.py b/python/samples/02-agents/providers/edenai/edenai_chat_client.py new file mode 100644 index 00000000000..091254fce0d --- /dev/null +++ b/python/samples/02-agents/providers/edenai/edenai_chat_client.py @@ -0,0 +1,56 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +from datetime import datetime + +from agent_framework import Message, tool +from agent_framework.edenai import EdenAIChatClient +from dotenv import load_dotenv + +# Load environment variables from .env file +load_dotenv() + +""" +Eden AI Chat Client Example + +This sample demonstrates using the Eden AI chat client directly. + +Eden AI is a gateway to many model providers behind one OpenAI compatible API and a +single key. Models use the provider/model format, for example openai/gpt-4o-mini or +anthropic/claude-sonnet-4-5. + +Set EDENAI_API_KEY and EDENAI_MODEL in your environment (or pass them to the client), +then run the sample. See https://www.edenai.co to get a key. +""" + + +# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production; +# see samples/02-agents/tools/function_tool_with_approval.py +# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py. +@tool(approval_mode="never_require") +def get_time(): + """Get the current time.""" + return f"The current time is {datetime.now().strftime('%I:%M %p')}." + + +async def main() -> None: + # Reads EDENAI_API_KEY and EDENAI_MODEL from the environment, or pass them here: + # client = EdenAIChatClient(model="openai/gpt-4o-mini", api_key="...") + client = EdenAIChatClient() + message = "What time is it? Use a tool call" + messages = [Message(role="user", contents=[message])] + stream = False + print(f"User: {message}") + if stream: + print("Assistant: ", end="") + async for chunk in client.get_response(messages, options={"tools": [get_time]}, stream=True): + if str(chunk): + print(str(chunk), end="") + print("") + else: + response = await client.get_response(messages, options={"tools": [get_time]}) + print(f"Assistant: {response}") + + +if __name__ == "__main__": + asyncio.run(main()) From f2d05a537336a25541712d4cac5ef602ab520d89 Mon Sep 17 00:00:00 2001 From: MVS-source <72023257+MVS-source@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:26:24 +0200 Subject: [PATCH 2/2] docs(edenai): fix get_response usage example and install wording The README and AGENTS.md examples passed a raw string to get_response, which expects a list of Message objects and would fail at runtime. Also aligned the install line wording with the other provider packages. --- python/packages/edenai/AGENTS.md | 3 ++- python/packages/edenai/README.md | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/python/packages/edenai/AGENTS.md b/python/packages/edenai/AGENTS.md index 77d2092103f..b015cb8898c 100644 --- a/python/packages/edenai/AGENTS.md +++ b/python/packages/edenai/AGENTS.md @@ -11,10 +11,11 @@ Integration with Eden AI, a gateway to many model providers behind one OpenAI co ## Usage ```python +from agent_framework import Message from agent_framework.edenai import EdenAIChatClient client = EdenAIChatClient(model="openai/gpt-4o-mini") -response = await client.get_response("Hello") +response = await client.get_response([Message(role="user", contents=["Hello"])]) ``` ## Import Path diff --git a/python/packages/edenai/README.md b/python/packages/edenai/README.md index 9a62bc29dae..9c7524ea3a7 100644 --- a/python/packages/edenai/README.md +++ b/python/packages/edenai/README.md @@ -1,6 +1,6 @@ # Get Started with Microsoft Agent Framework Eden AI -Please install this package as the extra for `agent-framework`: +Please install this package: ```bash pip install agent-framework-edenai --pre @@ -9,11 +9,12 @@ pip install agent-framework-edenai --pre Eden AI is a gateway to many model providers behind one OpenAI compatible API and a single key. Models use the `provider/model` format, for example `openai/gpt-4o-mini` or `anthropic/claude-sonnet-4-5`. ```python +from agent_framework import Message from agent_framework.edenai import EdenAIChatClient # Reads EDENAI_API_KEY from the environment, or pass api_key=... client = EdenAIChatClient(model="openai/gpt-4o-mini") -response = await client.get_response("Hello") +response = await client.get_response([Message(role="user", contents=["Hello"])]) ``` See the [README](https://github.com/microsoft/agent-framework/tree/main/python/README.md) for more information.