From 87f511738d1b35525c7876d96bdec0af5df8ea65 Mon Sep 17 00:00:00 2001 From: MVS-source <72023257+MVS-source@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:32:39 +0200 Subject: [PATCH] feat: add Eden AI as a first-class LLM provider and embedder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eden AI (https://www.edenai.co/) is an EU-headquartered gateway exposing 700+ models (Mistral, GPT, Claude, Gemini, Cohere, DeepSeek, Llama, ...) behind a single OpenAI-compatible endpoint. Because it is OpenAI-compatible, this reuses the existing `openai` dependency with a custom base_url — no new dependency. - llm/edenai.py: EdenAIProvider (generate + streaming chat + /models discovery), default mistral/mistral-small-latest, EDENAI_API_KEY, EDENAI_BASE_URL override (EU endpoint https://api.eu.edenai.run/v3 for data residency). reasoning maps to OpenAI reasoning_effort for OpenAI reasoning models routed via Eden. - embedding/edenai.py: EdenAIEmbedder (openai/text-embedding-3-small default, 1536/3072/1024 dims for the supported models). - Registered in the LLM and embedding registries, rate limiter defaults, server provider catalog, CLI provider selection / resolution / validation, embedder auto-detection, and the web settings UI. - Unit tests for the provider and embedder (mocked; no network). - Docs: CONFIG.md provider + embedder + env-var entries, .env.example. The API key is read only from EDENAI_API_KEY (or passed explicitly); it is never logged or hardcoded. Signed-off-by: MVS-source <72023257+MVS-source@users.noreply.github.com> --- .env.example | 5 + docs/CONFIG.md | 26 ++ packages/cli/src/repowise/cli/helpers.py | 15 + .../src/repowise/cli/providers/embedders.py | 2 + .../src/repowise/cli/ui/provider_selection.py | 3 + .../core/providers/embedding/edenai.py | 108 +++++ .../core/providers/embedding/registry.py | 3 + .../repowise/core/providers/llm/__init__.py | 1 + .../src/repowise/core/providers/llm/edenai.py | 439 ++++++++++++++++++ .../repowise/core/providers/llm/registry.py | 3 + .../core/src/repowise/core/rate_limiter.py | 1 + .../src/repowise/server/provider_config.py | 13 + .../components/settings/provider-section.tsx | 7 +- .../test_persistence/test_edenai_embedder.py | 131 ++++++ .../test_providers/test_edenai_provider.py | 293 ++++++++++++ 15 files changed, 1048 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/repowise/core/providers/embedding/edenai.py create mode 100644 packages/core/src/repowise/core/providers/llm/edenai.py create mode 100644 tests/unit/test_persistence/test_edenai_embedder.py create mode 100644 tests/unit/test_providers/test_edenai_provider.py diff --git a/.env.example b/.env.example index 4b00e975c..db464c4fd 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,11 @@ OPENAI_API_KEY=your_openai_api_key_here DEEPSEEK_API_KEY=your_deepseek_api_key_here # DEEPSEEK_BASE_URL=https://api.deepseek.com +# Eden AI (700+ models via one OpenAI-compatible key; EU-headquartered) +EDENAI_API_KEY=your_edenai_api_key_here +# Set the EU endpoint for data residency / GDPR-sensitive workloads: +# EDENAI_BASE_URL=https://api.eu.edenai.run/v3 + # Ollama — no API key needed, but set base URL if non-default # OLLAMA_BASE_URL=http://localhost:11434 diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 6f082e9a9..12ce9985b 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -216,6 +216,28 @@ export LITELLM_API_KEY="..." repowise init --provider litellm --model azure/gpt-4 ``` +### Eden AI (700+ models, one key) + +[Eden AI](https://www.edenai.co/) is an EU-headquartered gateway exposing 700+ +models (Mistral, GPT, Claude, Gemini, Cohere, DeepSeek, Llama, …) through a +single OpenAI-compatible endpoint. Models use `vendor/model` form. + +```bash +export EDENAI_API_KEY="..." +repowise init --provider edenai --model mistral/mistral-small-latest +repowise init --provider edenai --model openai/gpt-5-mini --reasoning low +``` + +For data residency / GDPR-sensitive workloads, point at the EU endpoint: + +```bash +export EDENAI_BASE_URL="https://api.eu.edenai.run/v3" +repowise init --provider edenai --model mistral/mistral-small-latest +``` + +`reasoning` is forwarded as OpenAI `reasoning_effort` for OpenAI reasoning models +routed through Eden (e.g. `openai/gpt-5*`); other models expose only `auto`. + ### Provider auto-detection If you don't pass `--provider`, repowise detects your provider by checking, in @@ -235,6 +257,8 @@ The embedder is separate from the LLM provider. |----------|---------|-------| | `gemini` | `GEMINI_API_KEY` | Default when key is present | | `openai` | `OPENAI_API_KEY` | OpenAI `text-embedding-3-small` | +| `openrouter` | `OPENROUTER_API_KEY` | OpenRouter-hosted embeddings | +| `edenai` | `EDENAI_API_KEY` | Eden AI gateway; `openai/text-embedding-3-small` (1536), `-3-large` (3072), `cohere/embed-multilingual-v3.0` (1024) | | `ollama` | `OLLAMA_EMBEDDING_MODEL` | Local Ollama embeddings, no API key | | `mock` | n/a | Dummy embeddings, no semantic search | @@ -264,6 +288,8 @@ The `.repowise/.env` file is gitignored automatically. | `ANTHROPIC_API_KEY` | Anthropic API key | | `OPENAI_API_KEY` | OpenAI API key | | `GEMINI_API_KEY` / `GOOGLE_API_KEY` | Google Gemini API key | +| `EDENAI_API_KEY` | Eden AI API key | +| `EDENAI_BASE_URL` | Eden AI endpoint override (default: `https://api.edenai.run/v3`; EU: `https://api.eu.edenai.run/v3`) | | `OLLAMA_BASE_URL` | Ollama server URL (default: `http://localhost:11434`) | | `OLLAMA_EMBEDDING_MODEL` | Ollama embedding model (also selects the `ollama` embedder) | | `OLLAMA_EMBEDDING_DIMS` | Ollama embedding output dimensions (optional; inferred from the model otherwise) | diff --git a/packages/cli/src/repowise/cli/helpers.py b/packages/cli/src/repowise/cli/helpers.py index 803b3ff3d..5df54fb31 100644 --- a/packages/cli/src/repowise/cli/helpers.py +++ b/packages/cli/src/repowise/cli/helpers.py @@ -689,6 +689,7 @@ def _resolve_base_url(name: str) -> str | None: "openai": ["OPENAI_BASE_URL"], "gemini": ["GEMINI_BASE_URL"], "deepseek": ["DEEPSEEK_BASE_URL"], + "edenai": ["EDENAI_BASE_URL"], "ollama": ["OLLAMA_BASE_URL"], "litellm": ["LITELLM_BASE_URL", "LITELLM_API_BASE"], } @@ -736,6 +737,8 @@ def _resolve_base_url(name: str) -> str | None: kwargs["api_key"] = os.environ["OPENROUTER_API_KEY"] elif provider_name == "deepseek" and os.environ.get("DEEPSEEK_API_KEY"): kwargs["api_key"] = os.environ["DEEPSEEK_API_KEY"] + elif provider_name == "edenai" and os.environ.get("EDENAI_API_KEY"): + kwargs["api_key"] = os.environ["EDENAI_API_KEY"] elif provider_name == "litellm" and os.environ.get("LITELLM_API_KEY"): kwargs["api_key"] = os.environ["LITELLM_API_KEY"] elif provider_name == "ollama" and os.environ.get("OLLAMA_BASE_URL"): @@ -797,11 +800,22 @@ def _resolve_base_url(name: str) -> str | None: if base_url: kwargs["base_url"] = base_url return get_provider("deepseek", **kwargs) + if os.environ.get("EDENAI_API_KEY") and os.environ["EDENAI_API_KEY"].strip(): + kwargs = ( + {"model": model, "api_key": os.environ["EDENAI_API_KEY"]} + if model + else {"api_key": os.environ["EDENAI_API_KEY"]} + ) + base_url = _resolve_base_url("edenai") + if base_url: + kwargs["base_url"] = base_url + return get_provider("edenai", **kwargs) raise click.ClickException( "No provider configured. Use --provider, set REPOWISE_PROVIDER, " "or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / " "OLLAMA_BASE_URL / GEMINI_API_KEY / GOOGLE_API_KEY / DEEPSEEK_API_KEY / " + "EDENAI_API_KEY / " "LITELLM_API_KEY. Use REPOWISE_PROVIDER=codex_cli to use an authenticated " "Codex CLI subscription, or REPOWISE_PROVIDER=opencode to use opencode." ) @@ -839,6 +853,7 @@ def _is_env_var_exists(var_name: str) -> bool: "openai": ["OPENAI_API_KEY"], "openrouter": ["OPENROUTER_API_KEY"], "deepseek": ["DEEPSEEK_API_KEY"], + "edenai": ["EDENAI_API_KEY"], "gemini": ["GEMINI_API_KEY", "GOOGLE_API_KEY"], # Either one "ollama": ["OLLAMA_BASE_URL"], "litellm": ["LITELLM_API_KEY"], # May need others depending on backend diff --git a/packages/cli/src/repowise/cli/providers/embedders.py b/packages/cli/src/repowise/cli/providers/embedders.py index 17bf2a451..5aec38777 100644 --- a/packages/cli/src/repowise/cli/providers/embedders.py +++ b/packages/cli/src/repowise/cli/providers/embedders.py @@ -61,6 +61,8 @@ def resolve_embedder(embedder_flag: str | None) -> str: return "openai" if os.environ.get("OPENROUTER_API_KEY"): return "openrouter" + if os.environ.get("EDENAI_API_KEY"): + return "edenai" if os.environ.get("OLLAMA_EMBEDDING_MODEL"): return "ollama" return "mock" diff --git a/packages/cli/src/repowise/cli/ui/provider_selection.py b/packages/cli/src/repowise/cli/ui/provider_selection.py index 2a2886d78..7fb79354d 100644 --- a/packages/cli/src/repowise/cli/ui/provider_selection.py +++ b/packages/cli/src/repowise/cli/ui/provider_selection.py @@ -25,6 +25,7 @@ "openai": "gpt-5.4-nano", "anthropic": "claude-sonnet-4-6", "deepseek": "deepseek-v4-flash", + "edenai": "mistral/mistral-small-latest", "codex_cli": "codex_cli/default", "opencode": "opencode/default", "ollama": "llama3.2", @@ -37,6 +38,7 @@ "openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY", "deepseek": "DEEPSEEK_API_KEY", + "edenai": "EDENAI_API_KEY", "codex_cli": "__CODEX_CLI__", "opencode": "__OPENCODE_CLI__", "ollama": "OLLAMA_BASE_URL", @@ -48,6 +50,7 @@ "openai": "https://platform.openai.com/api-keys", "anthropic": "https://console.anthropic.com/settings/keys", "deepseek": "https://platform.deepseek.com/api_keys", + "edenai": "https://app.edenai.run/user/register", "codex_cli": "https://developers.openai.com/codex/cli", "opencode": "https://opencode.ai", "ollama": "https://ollama.com/download", diff --git a/packages/core/src/repowise/core/providers/embedding/edenai.py b/packages/core/src/repowise/core/providers/embedding/edenai.py new file mode 100644 index 000000000..e26ed7446 --- /dev/null +++ b/packages/core/src/repowise/core/providers/embedding/edenai.py @@ -0,0 +1,108 @@ +"""Eden AI embedding support for repowise semantic search. + +Uses Eden AI's OpenAI-compatible endpoint at ``https://api.edenai.run/v3``. +No additional pip install required — uses the ``openai`` package. Set +``EDENAI_BASE_URL=https://api.eu.edenai.run/v3`` for EU data residency. + +Default model: openai/text-embedding-3-small (1536 dims) + +Usage: + from repowise.core.providers.embedding.edenai import EdenAIEmbedder + + embedder = EdenAIEmbedder(api_key="...") + vectors = await embedder.embed(["some text"]) +""" + +from __future__ import annotations + +import asyncio +import math +import os + + +class EdenAIEmbedder: + """Eden AI embedding adapter implementing the repowise Embedder protocol. + + Args: + api_key: Eden AI API key. Falls back to EDENAI_API_KEY env var. + model: Embedding model in ``vendor/model`` form. Default: + "openai/text-embedding-3-small". + base_url: Override the Eden AI API URL. Falls back to EDENAI_BASE_URL, + then the global endpoint. + """ + + _DIMS: dict[str, int] = { + "openai/text-embedding-3-small": 1536, + "openai/text-embedding-3-large": 3072, + "cohere/embed-multilingual-v3.0": 1024, + } + + _DEFAULT_BASE_URL: str = "https://api.edenai.run/v3" + _DEFAULT_TIMEOUT: float = 10.0 + + def __init__( + self, + api_key: str | None = None, + model: str = "openai/text-embedding-3-small", + base_url: str | None = None, + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self._api_key = api_key or os.environ.get("EDENAI_API_KEY") + if not self._api_key: + raise ValueError( + "Eden AI API key required. Pass api_key= or set EDENAI_API_KEY env var." + ) + if model not in self._DIMS: + known = ", ".join(sorted(self._DIMS)) + raise ValueError( + f"Unknown embedding model {model!r}. Stored vectors would be mis-sized " + f"against the model's real output, silently corrupting the vector store. " + f"Add {model!r} to EdenAIEmbedder._DIMS with its correct dimension count, " + f"or pick a known model: {known}." + ) + self._model = model + self._base_url = (base_url or os.environ.get("EDENAI_BASE_URL") or self._DEFAULT_BASE_URL).rstrip( + "/" + ) + self._timeout = timeout + self._client: object | None = None + + @property + def dimensions(self) -> int: + return self._DIMS[self._model] + + async def embed(self, texts: list[str]) -> list[list[float]]: + """Embed a batch of texts using Eden AI. + + Runs the synchronous SDK call in a thread pool to avoid blocking the + asyncio event loop. + """ + if not texts: + return [] + + model = self._model + timeout = self._timeout + base_url = self._base_url + + def _embed_sync() -> list[list[float]]: + import openai + + if self._client is None: + self._client = openai.OpenAI( + api_key=self._api_key, + base_url=base_url, + timeout=timeout, + ) + response = self._client.embeddings.create(model=model, input=texts) # type: ignore[union-attr] + raw_vectors = [list(item.embedding) for item in response.data] + return [_l2_normalize(v) for v in raw_vectors] + + return await asyncio.to_thread(_embed_sync) + + +def _l2_normalize(vec: list[float]) -> list[float]: + """L2-normalize a vector to unit length.""" + norm = math.sqrt(sum(x * x for x in vec)) + if norm == 0.0: + norm = 1.0 + return [x / norm for x in vec] diff --git a/packages/core/src/repowise/core/providers/embedding/registry.py b/packages/core/src/repowise/core/providers/embedding/registry.py index e74b1ddde..c55ef3836 100644 --- a/packages/core/src/repowise/core/providers/embedding/registry.py +++ b/packages/core/src/repowise/core/providers/embedding/registry.py @@ -6,6 +6,7 @@ Built-in embedders: openai → OpenAIEmbedder (text-embedding-3-small default) gemini → GeminiEmbedder (gemini-embedding-001 default) + edenai → EdenAIEmbedder (openai/text-embedding-3-small via Eden AI's EU gateway) mock → MockEmbedder (testing only, zero dependencies) Custom embedder registration: @@ -28,6 +29,7 @@ "gemini": ("repowise.core.providers.embedding.gemini", "GeminiEmbedder"), "ollama": ("repowise.core.providers.embedding.ollama", "OllamaEmbedder"), "openrouter": ("repowise.core.providers.embedding.openrouter", "OpenRouterEmbedder"), + "edenai": ("repowise.core.providers.embedding.edenai", "EdenAIEmbedder"), "mock": ("repowise.core.providers.embedding.base", "MockEmbedder"), } @@ -81,6 +83,7 @@ def get_embedder(name: str, **kwargs: Any) -> Embedder: "gemini": "google-genai", "ollama": "httpx", "openrouter": "openai", # openrouter uses the openai package + "edenai": "openai", # edenai uses the openai package } try: module = importlib.import_module(module_path) diff --git a/packages/core/src/repowise/core/providers/llm/__init__.py b/packages/core/src/repowise/core/providers/llm/__init__.py index b44ee36f8..8bb6ce328 100644 --- a/packages/core/src/repowise/core/providers/llm/__init__.py +++ b/packages/core/src/repowise/core/providers/llm/__init__.py @@ -14,6 +14,7 @@ gemini — gemini-3.1-flash-lite-preview, gemini-3-flash-preview, gemini-3.1-pro-preview openrouter — 200+ models via OpenRouter (anthropic/claude-sonnet-4.6, etc.) deepseek — deepseek-v4-flash, deepseek-v4-pro via api.deepseek.com + edenai — 700+ models (mistral/gpt/claude/gemini/...) via Eden AI's EU gateway ollama — local inference (llama3.2, codellama, etc.) litellm — 100+ providers via LiteLLM proxy codex_cli — local authenticated Codex CLI via codex exec diff --git a/packages/core/src/repowise/core/providers/llm/edenai.py b/packages/core/src/repowise/core/providers/llm/edenai.py new file mode 100644 index 000000000..08cdd4cb9 --- /dev/null +++ b/packages/core/src/repowise/core/providers/llm/edenai.py @@ -0,0 +1,439 @@ +"""Eden AI provider for repowise. + +Routes requests to 700+ models (Mistral, GPT, Claude, Gemini, Cohere, DeepSeek, +Llama, etc.) through a single API key via Eden AI's OpenAI-compatible gateway at +https://api.edenai.run/v3. Eden AI is EU-headquartered and offers an EU endpoint +(https://api.eu.edenai.run/v3) for data residency / GDPR-sensitive workloads. + +No additional pip install required — uses the ``openai`` package with a custom +base_url, following the same pattern as OpenRouterProvider / DeepSeekProvider. + +Models use ``vendor/model`` format: + - mistral/mistral-small-latest — fast, economical EU model [default] + - openai/gpt-4o-mini — OpenAI small model + - anthropic/claude-haiku-4-5 — Anthropic Claude Haiku + - google/gemini-2.5-flash — Google Gemini Flash + +Set the EU endpoint for data residency: + export EDENAI_BASE_URL="https://api.eu.edenai.run/v3" +""" + +from __future__ import annotations + +import contextlib +import os +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING, Any + +import structlog +from openai import APIStatusError as _OpenAIAPIStatusError +from openai import AsyncOpenAI +from openai import RateLimitError as _OpenAIRateLimitError +from tenacity import RetryError, retry + +from repowise.core.providers.llm.base import ( + BaseProvider, + ChatStreamEvent, + ChatToolCall, + GeneratedResponse, + ProviderError, + ProviderModelOption, + RateLimitError, + ensure_reasoning_supported, + fallback_model_option, + parse_retry_after, + provider_retry_stop, + provider_retry_wait, + provider_should_retry, +) +from repowise.core.rate_limiter import RateLimiter +from repowise.core.reasoning import ReasoningMode, normalize_reasoning + +if TYPE_CHECKING: + from repowise.core.generation.cost_tracker import CostTracker + +log = structlog.get_logger(__name__) + +_DEFAULT_BASE_URL = "https://api.edenai.run/v3" + + +def _model_leaf(model: str) -> str: + return model.rsplit("/", 1)[-1].lower() + + +def _supports_reasoning_effort(model: str) -> bool: + """True for OpenAI reasoning models routed through Eden (vendor/model form).""" + return _model_leaf(model).startswith(("gpt-5", "o1", "o3", "o4")) + + +def _edenai_supported_reasoning_modes(model: str) -> tuple[ReasoningMode, ...]: + """Reasoning efforts Eden forwards to the underlying OpenAI reasoning model. + + Eden accepts the OpenAI ``reasoning_effort`` parameter, so support is keyed on + the OpenAI reasoning model families. Non-OpenAI or non-reasoning models expose + only ``auto`` (provider default). + """ + if not _supports_reasoning_effort(model): + return () + leaf = _model_leaf(model) + if leaf.startswith("gpt-5.1"): + return ("none", "low", "medium", "high") + if leaf.startswith("gpt-5-pro"): + return ("high",) + if leaf.startswith("gpt-5"): + return ("minimal", "low", "medium", "high") + return ("low", "medium", "high") + + +def _resolve_edenai_reasoning_mode(reasoning: ReasoningMode, *, model: str) -> ReasoningMode: + """Validate reasoning support before issuing an API call.""" + return ensure_reasoning_supported( + "edenai", + model, + normalize_reasoning(reasoning), + _edenai_supported_reasoning_modes(model), + detail=( + "EdenAIProvider maps explicit efforts to the OpenAI reasoning_effort " + "parameter for OpenAI reasoning model ids routed via Eden AI." + ), + ) + + +def _edenai_reasoning_kwargs(reasoning: ReasoningMode) -> dict[str, Any]: + """Translate a validated repowise reasoning intent to Eden AI kwargs.""" + mode = normalize_reasoning(reasoning) + if mode in ("auto", "off"): + return {} + if mode in ("none", "minimal", "low", "medium", "high", "xhigh"): + return {"reasoning_effort": mode} + return {} + + +def _edenai_model_options( + api_key: str, + base_url: str, + fallback_model: str, +) -> tuple[ProviderModelOption, ...]: + fallback = fallback_model_option( + fallback_model, + reasoning_modes=("auto", *_edenai_supported_reasoning_modes(fallback_model)), + ) + try: + import httpx + + response = httpx.get( + f"{base_url.rstrip('/')}/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=5.0, + ) + response.raise_for_status() + data = response.json().get("data", []) + except Exception: + return (fallback,) + + if not isinstance(data, list): + return (fallback,) + + options: list[ProviderModelOption] = [] + for raw in data: + if not isinstance(raw, dict) or not isinstance(raw.get("id"), str): + continue + model_id = raw["id"] + reasoning_modes = ("auto", *_edenai_supported_reasoning_modes(model_id)) + options.append( + ProviderModelOption( + model=model_id, + label=model_id, + reasoning_modes=reasoning_modes, + recommended=model_id == fallback_model, + source="api", + notes=( + "reasoning_effort forwarded to the underlying OpenAI model" + if len(reasoning_modes) > 1 + else "" + ), + ) + ) + + if not options: + return (fallback,) + + options.sort(key=lambda option: option.model) + return tuple(options) + + +class EdenAIProvider(BaseProvider): + """Eden AI provider — access 700+ models via a single OpenAI-compatible key. + + Args: + api_key: Eden AI API key. Falls back to EDENAI_API_KEY env var. + model: Model identifier in ``vendor/model`` form. Defaults to + ``mistral/mistral-small-latest``. + base_url: Override the Eden AI API URL. Falls back to EDENAI_BASE_URL, + then the global endpoint. Set the EU endpoint + (``https://api.eu.edenai.run/v3``) for data residency. + rate_limiter: Optional RateLimiter instance. + cost_tracker: Optional CostTracker instance for usage recording. + """ + + def __init__( + self, + api_key: str | None = None, + model: str = "mistral/mistral-small-latest", + base_url: str | None = None, + rate_limiter: RateLimiter | None = None, + cost_tracker: CostTracker | None = None, + ) -> None: + resolved_key = api_key or os.environ.get("EDENAI_API_KEY") + if not resolved_key: + raise ProviderError( + "edenai", + "No API key provided. Pass api_key= or set EDENAI_API_KEY.", + ) + resolved_base_url = base_url or os.environ.get("EDENAI_BASE_URL") or _DEFAULT_BASE_URL + self._api_key = resolved_key + self._base_url = resolved_base_url.rstrip("/") + self._client = AsyncOpenAI( + api_key=resolved_key, + base_url=self._base_url, + ) + self._model = model + self._rate_limiter = rate_limiter + self._cost_tracker = cost_tracker + + @property + def provider_name(self) -> str: + return "edenai" + + @property + def model_name(self) -> str: + return self._model + + def supported_reasoning_modes(self) -> tuple[ReasoningMode, ...]: + return ("auto", *_edenai_supported_reasoning_modes(self._model)) + + def available_model_options(self) -> tuple[ProviderModelOption, ...]: + return _edenai_model_options(self._api_key, self._base_url, self._model) + + async def generate( + self, + system_prompt: str, + user_prompt: str, + max_tokens: int = 4096, + temperature: float = 0.3, + request_id: str | None = None, + reasoning: ReasoningMode = "auto", + cache_hints: tuple = (), + ) -> GeneratedResponse: + reasoning_mode = _resolve_edenai_reasoning_mode(reasoning, model=self._model) + if self._rate_limiter: + await self._rate_limiter.acquire(estimated_tokens=max_tokens) + + log.debug( + "edenai.generate.start", + model=self._model, + max_tokens=max_tokens, + request_id=request_id, + ) + + try: + return await self._generate_with_retry( + system_prompt=system_prompt, + user_prompt=user_prompt, + max_tokens=max_tokens, + temperature=temperature, + request_id=request_id, + reasoning=reasoning_mode, + ) + except RetryError as exc: + raise ProviderError( + "edenai", + f"All retries exhausted: {exc}", + ) from exc + + @retry( + retry=provider_should_retry, + stop=provider_retry_stop, + wait=provider_retry_wait, + reraise=True, + ) + async def _generate_with_retry( + self, + system_prompt: str, + user_prompt: str, + max_tokens: int, + temperature: float, + request_id: str | None, + reasoning: ReasoningMode, + ) -> GeneratedResponse: + try: + kwargs: dict[str, Any] = { + "model": self._model, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + kwargs.update(_edenai_reasoning_kwargs(reasoning)) + response = await self._client.chat.completions.create(**kwargs) + except _OpenAIRateLimitError as exc: + raise RateLimitError( + "edenai", + str(exc), + status_code=429, + retry_after=parse_retry_after( + getattr(getattr(exc, "response", None), "headers", None) + ), + ) from exc + except _OpenAIAPIStatusError as exc: + raise ProviderError("edenai", str(exc), status_code=exc.status_code) from exc + + usage = response.usage + result = GeneratedResponse( + content=response.choices[0].message.content or "", + input_tokens=usage.prompt_tokens if usage else 0, + output_tokens=usage.completion_tokens if usage else 0, + cached_tokens=0, + usage={ + "prompt_tokens": usage.prompt_tokens if usage else 0, + "completion_tokens": usage.completion_tokens if usage else 0, + "total_tokens": usage.total_tokens if usage else 0, + }, + ) + log.debug( + "edenai.generate.done", + input_tokens=result.input_tokens, + output_tokens=result.output_tokens, + request_id=request_id, + ) + + if self._cost_tracker is not None: + # Await the cost record inline rather than spawning a detached task — + # a fire-and-forget create_task can still be flushing its aiosqlite + # write when the event loop is torn down, surfacing as a noisy + # "Event loop is closed" traceback. record() swallows its own + # persistence errors, so generation is unaffected. + with contextlib.suppress(Exception): + await self._cost_tracker.record( + model=self._model, + input_tokens=result.input_tokens, + output_tokens=result.output_tokens, + operation="doc_generation", + file_path=None, + ) + + return result + + # --- ChatProvider protocol implementation --- + + async def stream_chat( + self, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]], + system_prompt: str, + max_tokens: int = 8192, + temperature: float = 0.7, + request_id: str | None = None, + tool_executor: Any | None = None, + ) -> AsyncIterator[ChatStreamEvent]: + import json as _json + + full_messages = [{"role": "system", "content": system_prompt}, *messages] + kwargs: dict[str, Any] = { + "model": self._model, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": full_messages, + "stream": True, + } + if tools: + kwargs["tools"] = tools + + try: + stream = await self._client.chat.completions.create(**kwargs) + except _OpenAIRateLimitError as exc: + raise RateLimitError( + "edenai", + str(exc), + status_code=429, + retry_after=parse_retry_after( + getattr(getattr(exc, "response", None), "headers", None) + ), + ) from exc + except _OpenAIAPIStatusError as exc: + raise ProviderError("edenai", str(exc), status_code=exc.status_code) from exc + + # Track in-progress tool calls (OpenAI-compatible streaming) + tool_calls_acc: dict[int, dict[str, Any]] = {} + + try: + async for chunk in stream: + choice = chunk.choices[0] if chunk.choices else None + if not choice: + if chunk.usage: + yield ChatStreamEvent( + type="usage", + input_tokens=chunk.usage.prompt_tokens or 0, + output_tokens=chunk.usage.completion_tokens or 0, + ) + continue + + delta = choice.delta + finish = choice.finish_reason + + # Text content + if delta and delta.content: + yield ChatStreamEvent(type="text_delta", text=delta.content) + + # Tool call fragments + if delta and delta.tool_calls: + for tc_delta in delta.tool_calls: + idx = tc_delta.index + if idx not in tool_calls_acc: + tool_calls_acc[idx] = { + "id": tc_delta.id or "", + "name": "", + "arguments": "", + } + acc = tool_calls_acc[idx] + if tc_delta.id: + acc["id"] = tc_delta.id + if tc_delta.function: + if tc_delta.function.name: + acc["name"] = tc_delta.function.name + if tc_delta.function.arguments: + acc["arguments"] += tc_delta.function.arguments + + if finish: + # Emit accumulated tool calls + for idx in sorted(tool_calls_acc.keys()): + acc = tool_calls_acc[idx] + try: + args = _json.loads(acc["arguments"]) if acc["arguments"] else {} + except Exception: + args = {} + yield ChatStreamEvent( + type="tool_start", + tool_call=ChatToolCall( + id=acc["id"], + name=acc["name"], + arguments=args, + ), + ) + tool_calls_acc.clear() + + stop_reason = "tool_use" if finish == "tool_calls" else "end_turn" + yield ChatStreamEvent(type="stop", stop_reason=stop_reason) + except _OpenAIRateLimitError as exc: + raise RateLimitError( + "edenai", + str(exc), + status_code=429, + retry_after=parse_retry_after( + getattr(getattr(exc, "response", None), "headers", None) + ), + ) from exc + except _OpenAIAPIStatusError as exc: + raise ProviderError("edenai", str(exc), status_code=exc.status_code) from exc diff --git a/packages/core/src/repowise/core/providers/llm/registry.py b/packages/core/src/repowise/core/providers/llm/registry.py index 19d35a025..a20f33591 100644 --- a/packages/core/src/repowise/core/providers/llm/registry.py +++ b/packages/core/src/repowise/core/providers/llm/registry.py @@ -9,6 +9,7 @@ - openai → OpenAIProvider - openrouter → OpenRouterProvider - deepseek → DeepSeekProvider + - edenai → EdenAIProvider - ollama → OllamaProvider - litellm → LiteLLMProvider - codex_cli → CodexCliProvider @@ -46,6 +47,7 @@ "ollama": ("repowise.core.providers.llm.ollama", "OllamaProvider"), "litellm": ("repowise.core.providers.llm.litellm", "LiteLLMProvider"), "deepseek": ("repowise.core.providers.llm.deepseek", "DeepSeekProvider"), + "edenai": ("repowise.core.providers.llm.edenai", "EdenAIProvider"), "codex_cli": ("repowise.core.providers.llm.codex_cli", "CodexCliProvider"), "opencode": ("repowise.core.providers.llm.opencode", "OpenCodeProvider"), "mock": ("repowise.core.providers.llm.mock", "MockProvider"), @@ -142,6 +144,7 @@ def get_provider( "ollama": "openai", # ollama uses the openai package "openrouter": "openai", # openrouter uses the openai package "deepseek": "openai", # deepseek uses the openai package + "edenai": "openai", # edenai uses the openai package "litellm": "litellm", "codex_cli": "@openai/codex", "opencode": "opencode", diff --git a/packages/core/src/repowise/core/rate_limiter.py b/packages/core/src/repowise/core/rate_limiter.py index 7c88a1bf9..292c7e8eb 100644 --- a/packages/core/src/repowise/core/rate_limiter.py +++ b/packages/core/src/repowise/core/rate_limiter.py @@ -66,6 +66,7 @@ class RateLimitConfig: "ollama": RateLimitConfig(requests_per_minute=1_000, tokens_per_minute=10_000_000), "litellm": RateLimitConfig(requests_per_minute=1_000, tokens_per_minute=2_000_000), "deepseek": RateLimitConfig(requests_per_minute=1_000, tokens_per_minute=5_000_000), + "edenai": RateLimitConfig(requests_per_minute=1_000, tokens_per_minute=2_000_000), } diff --git a/packages/server/src/repowise/server/provider_config.py b/packages/server/src/repowise/server/provider_config.py index 9949126f1..be53d50c8 100644 --- a/packages/server/src/repowise/server/provider_config.py +++ b/packages/server/src/repowise/server/provider_config.py @@ -68,6 +68,19 @@ "env_keys": ["DEEPSEEK_API_KEY"], "requires_key": True, }, + { + "id": "edenai", + "name": "Eden AI", + "default_model": "mistral/mistral-small-latest", + "models": [ + "mistral/mistral-small-latest", + "openai/gpt-4o-mini", + "anthropic/claude-haiku-4-5", + "google/gemini-2.5-flash", + ], + "env_keys": ["EDENAI_API_KEY"], + "requires_key": True, + }, { "id": "ollama", "name": "Ollama (Local)", diff --git a/packages/web/src/components/settings/provider-section.tsx b/packages/web/src/components/settings/provider-section.tsx index 266b24a4c..0c12e7f5d 100644 --- a/packages/web/src/components/settings/provider-section.tsx +++ b/packages/web/src/components/settings/provider-section.tsx @@ -14,14 +14,15 @@ import { SelectValue, } from "@repowise-dev/ui/ui/select"; -const PROVIDERS = ["gemini", "openai", "anthropic", "deepseek", "opencode", "ollama", "litellm", "mock"] as const; -const EMBEDDERS = ["mock", "gemini", "openai", "openrouter", "ollama"] as const; +const PROVIDERS = ["gemini", "openai", "anthropic", "deepseek", "edenai", "opencode", "ollama", "litellm", "mock"] as const; +const EMBEDDERS = ["mock", "gemini", "openai", "openrouter", "edenai", "ollama"] as const; const MODEL_PLACEHOLDERS: Record = { gemini: "gemini-3.1-flash-lite-preview", openai: "gpt-5.4-nano", anthropic: "claude-sonnet-4-6", deepseek: "deepseek-v4-flash", + edenai: "mistral/mistral-small-latest", opencode: "opencode/default", ollama: "llama3.2", litellm: "groq/llama-3.1-70b-versatile", @@ -34,6 +35,7 @@ const PROVIDER_ENV_VARS: Record anthropic: { vars: ["ANTHROPIC_API_KEY"], installHint: "pip install anthropic" }, ollama: { vars: ["OLLAMA_BASE_URL"], installHint: "https://ollama.ai" }, deepseek: { vars: ["DEEPSEEK_API_KEY"], installHint: "pip install openai" }, + edenai: { vars: ["EDENAI_API_KEY"], installHint: "pip install openai" }, litellm: { vars: ["LITELLM_*"], installHint: "pip install litellm" }, opencode: { vars: [], installHint: "curl -fsSL https://opencode.ai/install | bash" }, mock: { vars: [], installHint: "No key needed" }, @@ -43,6 +45,7 @@ const EMBEDDER_ENV_VARS: Record = { gemini: ["GEMINI_API_KEY"], openai: ["OPENAI_API_KEY"], openrouter: ["OPENROUTER_API_KEY"], + edenai: ["EDENAI_API_KEY"], ollama: ["OLLAMA_BASE_URL"], mock: [], }; diff --git a/tests/unit/test_persistence/test_edenai_embedder.py b/tests/unit/test_persistence/test_edenai_embedder.py new file mode 100644 index 000000000..073370b04 --- /dev/null +++ b/tests/unit/test_persistence/test_edenai_embedder.py @@ -0,0 +1,131 @@ +"""Unit tests for EdenAIEmbedder. + +All tests mock openai.OpenAI — no real API calls are made. +""" + +from __future__ import annotations + +import math +from unittest.mock import MagicMock, patch + +import pytest + +pytest.importorskip("openai", reason="openai SDK not installed") + +from repowise.core.providers.embedding.edenai import EdenAIEmbedder + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + + +def test_missing_api_key_raises(monkeypatch): + monkeypatch.delenv("EDENAI_API_KEY", raising=False) + with pytest.raises(ValueError, match="Eden AI API key required"): + EdenAIEmbedder(api_key=None) + + +def test_api_key_from_env(monkeypatch): + monkeypatch.setenv("EDENAI_API_KEY", "eden-test") + emb = EdenAIEmbedder() + assert emb._api_key == "eden-test" + + +def test_default_model(): + emb = EdenAIEmbedder(api_key="k") + assert emb._model == "openai/text-embedding-3-small" + + +def test_default_base_url(): + emb = EdenAIEmbedder(api_key="k") + assert emb._base_url == "https://api.edenai.run/v3" + + +def test_eu_base_url_from_env(monkeypatch): + monkeypatch.setenv("EDENAI_BASE_URL", "https://api.eu.edenai.run/v3") + emb = EdenAIEmbedder(api_key="k") + assert emb._base_url == "https://api.eu.edenai.run/v3" + + +def test_dimensions_openai_small(): + emb = EdenAIEmbedder(api_key="k", model="openai/text-embedding-3-small") + assert emb.dimensions == 1536 + + +def test_dimensions_openai_large(): + emb = EdenAIEmbedder(api_key="k", model="openai/text-embedding-3-large") + assert emb.dimensions == 3072 + + +def test_dimensions_cohere_multilingual(): + emb = EdenAIEmbedder(api_key="k", model="cohere/embed-multilingual-v3.0") + assert emb.dimensions == 1024 + + +def test_unknown_model_raises_at_construction(): + """Unknown models must fail fast — a silent dim fallback would corrupt the vector store.""" + with pytest.raises(ValueError, match="Unknown embedding model"): + EdenAIEmbedder(api_key="k", model="some/future-model") + + +# --------------------------------------------------------------------------- +# Embedding +# --------------------------------------------------------------------------- + + +def _make_mock_embedding(values: list[float]) -> MagicMock: + item = MagicMock() + item.embedding = values + return item + + +def _make_mock_response(vectors: list[list[float]]) -> MagicMock: + response = MagicMock() + response.data = [_make_mock_embedding(v) for v in vectors] + return response + + +async def test_embed_empty_returns_empty(): + emb = EdenAIEmbedder(api_key="k") + result = await emb.embed([]) + assert result == [] + + +async def test_embed_returns_normalized_vectors(): + raw = [3.0, 0.0, 0.0, 0.0] + emb = EdenAIEmbedder(api_key="k") + + with patch("openai.OpenAI") as mock_client: + mock_client.return_value.embeddings.create.return_value = _make_mock_response([raw]) + result = await emb.embed(["hello"]) + + assert len(result) == 1 + norm = math.sqrt(sum(x * x for x in result[0])) + assert abs(norm - 1.0) < 1e-6 + + +async def test_embed_passes_model_and_input(): + emb = EdenAIEmbedder(api_key="k", model="openai/text-embedding-3-large") + captured: list = [] + + def fake_create(model, input): + captured.append({"model": model, "input": input}) + return _make_mock_response([[1.0, 0.0]]) + + with patch("openai.OpenAI") as mock_client: + mock_client.return_value.embeddings.create.side_effect = fake_create + await emb.embed(["test text"]) + + assert captured[0]["model"] == "openai/text-embedding-3-large" + assert captured[0]["input"] == ["test text"] + + +async def test_embed_uses_edenai_base_url(): + """Verify the client is created with the Eden AI base URL.""" + emb = EdenAIEmbedder(api_key="eden-test") + + with patch("openai.OpenAI") as mock_client: + mock_client.return_value.embeddings.create.return_value = _make_mock_response([[1.0]]) + await emb.embed(["test"]) + + assert mock_client.call_args.kwargs.get("base_url") == "https://api.edenai.run/v3" diff --git a/tests/unit/test_providers/test_edenai_provider.py b/tests/unit/test_providers/test_edenai_provider.py new file mode 100644 index 000000000..5306385ed --- /dev/null +++ b/tests/unit/test_providers/test_edenai_provider.py @@ -0,0 +1,293 @@ +"""Unit tests for EdenAIProvider. + +All tests mock the AsyncOpenAI client / httpx — no real API calls are made. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytest.importorskip("openai", reason="openai SDK not installed") + +from repowise.core.providers.llm.base import ( + GeneratedResponse, + ProviderError, + RateLimitError, +) +from repowise.core.providers.llm.edenai import EdenAIProvider + + +def test_provider_name(): + p = EdenAIProvider(api_key="test-key") + assert p.provider_name == "edenai" + + +def test_default_model_is_mistral_small(): + p = EdenAIProvider(api_key="test-key") + assert p.model_name == "mistral/mistral-small-latest" + + +def test_api_key_from_env(monkeypatch): + monkeypatch.setenv("EDENAI_API_KEY", "env-key") + p = EdenAIProvider() + assert p.provider_name == "edenai" + + +def test_missing_api_key_raises(monkeypatch): + monkeypatch.delenv("EDENAI_API_KEY", raising=False) + with pytest.raises(ProviderError): + EdenAIProvider() + + +def test_custom_model(): + p = EdenAIProvider(api_key="test-key", model="openai/gpt-4o-mini") + assert p.model_name == "openai/gpt-4o-mini" + + +def test_default_base_url_is_global(): + p = EdenAIProvider(api_key="test-key") + assert p._base_url == "https://api.edenai.run/v3" + + +def test_eu_base_url_from_env(monkeypatch): + monkeypatch.setenv("EDENAI_BASE_URL", "https://api.eu.edenai.run/v3") + p = EdenAIProvider(api_key="test-key") + assert p._base_url == "https://api.eu.edenai.run/v3" + + +def test_non_reasoning_model_exposes_only_auto(): + p = EdenAIProvider(api_key="test-key", model="mistral/mistral-small-latest") + assert p.supported_reasoning_modes() == ("auto",) + + +def test_openai_reasoning_model_exposes_efforts(): + p = EdenAIProvider(api_key="test-key", model="openai/gpt-5-mini") + assert p.supported_reasoning_modes() == ("auto", "minimal", "low", "medium", "high") + + +def test_available_model_options_uses_models_endpoint(monkeypatch): + class FakeResponse: + def raise_for_status(self) -> None: + pass + + def json(self) -> dict: + return { + "data": [ + {"id": "mistral/mistral-small-latest"}, + {"id": "openai/gpt-5-mini"}, + ] + } + + captured: dict[str, object] = {} + + def fake_get(url, *, headers, timeout): + captured["url"] = url + captured["headers"] = headers + captured["timeout"] = timeout + return FakeResponse() + + monkeypatch.setattr("httpx.get", fake_get) + + options = EdenAIProvider(api_key="test-key").available_model_options() + + assert captured["url"] == "https://api.edenai.run/v3/models" + assert captured["headers"] == {"Authorization": "Bearer test-key"} + mistral = next(o for o in options if o.model == "mistral/mistral-small-latest") + assert mistral.recommended is True + assert mistral.reasoning_modes == ("auto",) + gpt5 = next(o for o in options if o.model == "openai/gpt-5-mini") + assert gpt5.reasoning_modes == ("auto", "minimal", "low", "medium", "high") + + +def _make_mock_chat_response(text: str = "# Doc\nContent.") -> MagicMock: + usage = MagicMock() + usage.prompt_tokens = 100 + usage.completion_tokens = 40 + usage.total_tokens = 140 + + choice = MagicMock() + choice.message.content = text + + response = MagicMock() + response.choices = [choice] + response.usage = usage + return response + + +def _make_mock_stream_chunks(text: str) -> list[MagicMock]: + chunks = [] + for char in text: + delta = MagicMock() + delta.content = char + delta.tool_calls = None + choice = MagicMock() + choice.delta = delta + choice.finish_reason = None + chunk = MagicMock() + chunk.choices = [choice] + chunk.usage = None + chunks.append(chunk) + + finish_delta = MagicMock() + finish_delta.content = None + finish_delta.tool_calls = None + finish_choice = MagicMock() + finish_choice.delta = finish_delta + finish_choice.finish_reason = "stop" + finish_chunk = MagicMock() + finish_chunk.choices = [finish_choice] + finish_chunk.usage = None + chunks.append(finish_chunk) + + return chunks + + +async def test_generate_returns_generated_response(): + provider = EdenAIProvider(api_key="test-key") + mock_response = _make_mock_chat_response("Hello from Eden AI") + + with patch("openai.AsyncOpenAI") as mock_client: + mock_client.return_value.chat.completions.create = AsyncMock(return_value=mock_response) + provider._client = mock_client.return_value + + result = await provider.generate( + system_prompt="You are a test assistant", + user_prompt="Say hello", + ) + + assert isinstance(result, GeneratedResponse) + assert result.content == "Hello from Eden AI" + assert result.input_tokens == 100 + assert result.output_tokens == 40 + + +async def test_generate_uses_max_tokens_and_model(): + provider = EdenAIProvider(api_key="test-key", model="mistral/mistral-small-latest") + mock_response = _make_mock_chat_response() + + with patch("openai.AsyncOpenAI") as mock_client: + mock_client.return_value.chat.completions.create = AsyncMock(return_value=mock_response) + provider._client = mock_client.return_value + + await provider.generate(system_prompt="system", user_prompt="user", max_tokens=512) + + kwargs = mock_client.return_value.chat.completions.create.call_args.kwargs + assert kwargs["model"] == "mistral/mistral-small-latest" + assert kwargs["max_tokens"] == 512 + # No reasoning_effort for a non-reasoning model on an "auto" request. + assert "reasoning_effort" not in kwargs + + +async def test_generate_forwards_reasoning_effort_for_openai_model(): + provider = EdenAIProvider(api_key="test-key", model="openai/gpt-5-mini") + mock_response = _make_mock_chat_response() + + with patch("openai.AsyncOpenAI") as mock_client: + mock_client.return_value.chat.completions.create = AsyncMock(return_value=mock_response) + provider._client = mock_client.return_value + await provider.generate("system", "user", reasoning="low") + + kwargs = mock_client.return_value.chat.completions.create.call_args.kwargs + assert kwargs["reasoning_effort"] == "low" + + +async def test_generate_rejects_reasoning_for_non_reasoning_model(): + provider = EdenAIProvider(api_key="test-key", model="mistral/mistral-small-latest") + + with patch("openai.AsyncOpenAI") as mock_client: + provider._client = mock_client.return_value + with pytest.raises(ProviderError, match="reasoning='high' is not supported"): + await provider.generate("system", "user", reasoning="high") + + mock_client.return_value.chat.completions.create.assert_not_called() + + +async def test_generate_rate_limit_retry(): + from openai import RateLimitError as _OpenAIRateLimitError + + provider = EdenAIProvider(api_key="test-key") + + with patch("openai.AsyncOpenAI") as mock_client: + mock_client.return_value.chat.completions.create = AsyncMock( + side_effect=_OpenAIRateLimitError( + message="Rate limited", + body={}, + response=MagicMock(status_code=429), + ) + ) + provider._client = mock_client.return_value + + with pytest.raises(RateLimitError): + await provider.generate(system_prompt="system", user_prompt="user") + + +async def test_generate_api_error(): + from openai import APIStatusError as _OpenAIAPIStatusError + + provider = EdenAIProvider(api_key="test-key") + + with patch("openai.AsyncOpenAI") as mock_client: + mock_client.return_value.chat.completions.create = AsyncMock( + side_effect=_OpenAIAPIStatusError( + message="Internal error", + body={}, + response=MagicMock(status_code=500), + ) + ) + provider._client = mock_client.return_value + + with pytest.raises(ProviderError) as excinfo: + await provider.generate(system_prompt="system", user_prompt="user") + assert excinfo.value.status_code == 500 + + +async def test_cost_tracker_called(): + from repowise.core.generation.cost_tracker import CostTracker + + mock_tracker = MagicMock(spec=CostTracker) + mock_tracker.record = AsyncMock(return_value=0.0) + + provider = EdenAIProvider(api_key="test-key", cost_tracker=mock_tracker) + mock_response = _make_mock_chat_response() + + with patch("openai.AsyncOpenAI") as mock_client: + mock_client.return_value.chat.completions.create = AsyncMock(return_value=mock_response) + provider._client = mock_client.return_value + + await provider.generate(system_prompt="system", user_prompt="user") + + mock_tracker.record.assert_called_once() + call_kwargs = mock_tracker.record.call_args.kwargs + assert call_kwargs["model"] == "mistral/mistral-small-latest" + assert call_kwargs["input_tokens"] == 100 + assert call_kwargs["output_tokens"] == 40 + + +async def test_stream_chat_emits_text_delta_and_stop(): + provider = EdenAIProvider(api_key="test-key") + + async def _async_gen(): + for chunk in _make_mock_stream_chunks("Hi"): + yield chunk + + with patch("openai.AsyncOpenAI") as mock_client: + mock_client.return_value.chat.completions.create = AsyncMock(return_value=_async_gen()) + provider._client = mock_client.return_value + + events = [] + async for event in provider.stream_chat( + messages=[{"role": "user", "content": "Hi"}], + tools=[], + system_prompt="You are helpful", + ): + events.append(event) + + text_deltas = [e for e in events if e.type == "text_delta"] + stops = [e for e in events if e.type == "stop"] + assert len(text_deltas) == 2 + assert text_deltas[0].text == "H" + assert text_deltas[1].text == "i" + assert len(stops) == 1 + assert stops[0].stop_reason == "end_turn"