Files
mcp-nextcloud/nextcloud_mcp_server/providers/mistral.py
T
Chris CoutinhoandClaude Opus 4.7 3268a13d11 feat(providers): add Mistral embedding provider, route registry through dynaconf
Adds a hosted Mistral embedding option (mistral-embed, 1024-dim) alongside
the existing Bedrock / OpenAI / Ollama / Simple providers. Implementation
mirrors OpenAIProvider: lazy dimension detection with a known-models lookup,
chunked batch requests, defensive index sort, and a 429-aware retry decorator.

In the same change, ProviderRegistry switches from os.getenv to the
dynaconf-backed Settings dataclass so all five providers share a single
configuration path. config.py gains the previously-uncovered Bedrock keys,
the new Mistral keys, the missing OPENAI_GENERATION_MODEL /
OLLAMA_GENERATION_MODEL, and SIMPLE_EMBEDDING_DIMENSION.

Auto-detection priority: Bedrock → OpenAI → Mistral → Ollama → Simple.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 17:25:24 +02:00

232 lines
7.9 KiB
Python

"""Mistral provider for embeddings.
Currently supports embeddings only (``mistral-embed``, 1024-dim). Generation
can be added later if needed; see ADR-015.
"""
import logging
from functools import wraps
import anyio
from mistralai.client import Mistral
from mistralai.client.errors.sdkerror import SDKError
from .base import Provider
logger = logging.getLogger(__name__)
MAX_RETRIES = 5
INITIAL_RETRY_DELAY = 2.0
MAX_RETRY_DELAY = 60.0
def retry_on_rate_limit(func):
"""Retry on Mistral 429 (rate limit) responses with exponential backoff."""
@wraps(func)
async def wrapper(*args, **kwargs):
retry_delay = INITIAL_RETRY_DELAY
last_error: Exception | None = None
for attempt in range(1, MAX_RETRIES + 1):
try:
return await func(*args, **kwargs)
except SDKError as e:
# SDKError carries a status_code attribute populated from the
# raw response. Only 429 is retryable here.
status = getattr(e, "status_code", None)
if status != 429:
raise
last_error = e
if attempt < MAX_RETRIES:
logger.warning(
"Mistral rate limit hit (attempt %d/%d), retrying in %.1fs...",
attempt,
MAX_RETRIES,
retry_delay,
)
await anyio.sleep(retry_delay)
retry_delay = min(retry_delay * 2, MAX_RETRY_DELAY)
logger.error("Mistral rate limit exceeded after %d attempts", MAX_RETRIES)
raise last_error # type: ignore[misc]
return wrapper
# Well-known Mistral embedding model dimensions
MISTRAL_EMBEDDING_DIMENSIONS: dict[str, int] = {
"mistral-embed": 1024,
}
# Conservative chunk size for batch embeddings. Mistral allows large batches,
# but we keep this in line with sibling providers (OpenAI=100, Ollama=32).
BATCH_SIZE = 64
class MistralProvider(Provider):
"""
Mistral provider — embeddings only.
Uses the official ``mistralai`` SDK. Lazy dimension detection mirrors the
OpenAI provider: known models populate the cached dimension at construction
time; unknown models get their dimension detected on the first ``embed()``
call.
"""
def __init__(
self,
api_key: str,
embedding_model: str | None = "mistral-embed",
base_url: str | None = None,
):
"""
Initialize the Mistral provider.
Args:
api_key: Mistral API key.
embedding_model: Embedding model ID (default: ``mistral-embed``).
Pass ``None`` to disable embeddings (the provider will then
support no capabilities, which is mostly useful for tests).
base_url: Optional base URL override (e.g. proxies, on-prem).
"""
self.embedding_model = embedding_model
self._dimension: int | None = None
self.client = Mistral(api_key=api_key, server_url=base_url)
if embedding_model and embedding_model in MISTRAL_EMBEDDING_DIMENSIONS:
self._dimension = MISTRAL_EMBEDDING_DIMENSIONS[embedding_model]
logger.info(
"Initialized Mistral provider: base_url=%s, embedding_model=%s, "
"dimension=%s",
base_url or "default",
embedding_model,
self._dimension,
)
@property
def supports_embeddings(self) -> bool:
return self.embedding_model is not None
@property
def supports_generation(self) -> bool:
return False
@retry_on_rate_limit
async def embed(self, text: str) -> list[float]:
"""Generate an embedding for a single text."""
if not self.supports_embeddings:
raise NotImplementedError(
"Embedding not supported - no embedding_model configured"
)
assert self.embedding_model is not None
response = await self.client.embeddings.create_async(
model=self.embedding_model,
inputs=[text],
)
if not response.data or response.data[0].embedding is None:
raise RuntimeError(
f"Mistral embeddings API returned no embedding for model "
f"{self.embedding_model}"
)
embedding = response.data[0].embedding
if self._dimension is None:
self._dimension = len(embedding)
logger.info(
"Detected embedding dimension: %d for model %s",
self._dimension,
self.embedding_model,
)
return embedding
async def embed_batch(self, texts: list[str]) -> list[list[float]]:
"""Generate embeddings for multiple texts, chunking by ``BATCH_SIZE``."""
if not self.supports_embeddings:
raise NotImplementedError(
"Embedding not supported - no embedding_model configured"
)
if not texts:
return []
all_embeddings: list[list[float]] = []
for i in range(0, len(texts), BATCH_SIZE):
batch = texts[i : i + BATCH_SIZE]
batch_embeddings = await self._embed_batch_request(batch)
all_embeddings.extend(batch_embeddings)
if self._dimension is None and batch_embeddings:
self._dimension = len(batch_embeddings[0])
logger.info(
"Detected embedding dimension: %d for model %s",
self._dimension,
self.embedding_model,
)
return all_embeddings
@retry_on_rate_limit
async def _embed_batch_request(self, batch: list[str]) -> list[list[float]]:
"""Single batch request with rate-limit retry."""
assert self.embedding_model is not None
response = await self.client.embeddings.create_async(
model=self.embedding_model,
inputs=batch,
)
# Defensive: response.data items have Optional fields. Sort by index
# (default 0 if missing) and reject None embeddings explicitly.
sorted_data = sorted(response.data or [], key=lambda x: x.index or 0)
result: list[list[float]] = []
for item in sorted_data:
if item.embedding is None:
raise RuntimeError(
f"Mistral embeddings API returned a null embedding for "
f"model {self.embedding_model}"
)
result.append(item.embedding)
if len(result) != len(batch):
raise RuntimeError(
f"Mistral embeddings API returned {len(result)} embeddings "
f"for {len(batch)} inputs"
)
return result
def get_dimension(self) -> int:
if not self.supports_embeddings:
raise NotImplementedError(
"Embedding not supported - no embedding_model configured"
)
if self._dimension is None:
raise RuntimeError(
f"Embedding dimension not detected yet for model "
f"{self.embedding_model}. Call embed() first or use a known "
"model."
)
return self._dimension
async def generate(self, prompt: str, max_tokens: int = 500) -> str:
raise NotImplementedError(
"MistralProvider does not support generation. "
"Use OpenAI, Anthropic, or Bedrock for text generation."
)
async def close(self) -> None:
# The Mistral SDK manages its own httpx client lifecycle; close it
# via the SDK's context-manager hook if present, otherwise no-op.
close = getattr(self.client, "__aexit__", None)
if close is not None:
try:
await close(None, None, None)
except Exception: # pragma: no cover - best-effort cleanup
logger.debug("Mistral client close raised; ignoring", exc_info=True)