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..b015cb8898c --- /dev/null +++ b/python/packages/edenai/AGENTS.md @@ -0,0 +1,31 @@ +# 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 import Message +from agent_framework.edenai import EdenAIChatClient + +client = EdenAIChatClient(model="openai/gpt-4o-mini") +response = await client.get_response([Message(role="user", contents=["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..9c7524ea3a7 --- /dev/null +++ b/python/packages/edenai/README.md @@ -0,0 +1,20 @@ +# Get Started with Microsoft Agent Framework Eden AI + +Please install this package: + +```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 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([Message(role="user", contents=["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())