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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |

Expand Down Expand Up @@ -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) |
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/repowise/cli/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
}
Expand Down Expand Up @@ -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"):
Expand Down Expand Up @@ -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."
)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/repowise/cli/providers/embedders.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/repowise/cli/ui/provider_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
108 changes: 108 additions & 0 deletions packages/core/src/repowise/core/providers/embedding/edenai.py
Original file line number Diff line number Diff line change
@@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"),
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading