Add Codestral Provider

This commit is contained in:
Alishahryar1 2026-05-23 19:09:22 -07:00
parent fbb1d6586d
commit a4d7d76040
18 changed files with 369 additions and 23 deletions

View file

@ -10,6 +10,10 @@ OPENROUTER_API_KEY=""
MISTRAL_API_KEY=""
# Mistral Codestral (separate key from La Plateforme; OpenAI-compatible at codestral.mistral.ai/v1)
CODESTRAL_API_KEY=""
# DeepSeek Config (uses native Anthropic Messages at api.deepseek.com/anthropic)
DEEPSEEK_API_KEY=""
@ -60,7 +64,7 @@ OLLAMA_BASE_URL="http://localhost:11434"
# All Claude model requests are mapped to these models, plain model is fallback
# Format: provider_type/model/name
# Valid providers: "nvidia_nim" | "open_router" | "mistral" | "deepseek" | "kimi" | "wafer" | "lmstudio" | "llamacpp" | "ollama" | "opencode" | "opencode_go" | "zai" | "fireworks" | "gemini" | "groq" | "cerebras"
# Valid providers: "nvidia_nim" | "open_router" | "mistral" | "mistral_codestral" | "deepseek" | "kimi" | "wafer" | "lmstudio" | "llamacpp" | "ollama" | "opencode" | "opencode_go" | "zai" | "fireworks" | "gemini" | "groq" | "cerebras"
MODEL_OPUS=
MODEL_SONNET=
MODEL_HAIKU=
@ -72,6 +76,7 @@ MODEL="nvidia_nim/nvidia/nemotron-3-super-120b-a12b"
FCC_SMOKE_MODEL_NVIDIA_NIM=
FCC_SMOKE_MODEL_OPEN_ROUTER=
FCC_SMOKE_MODEL_MISTRAL=
FCC_SMOKE_MODEL_MISTRAL_CODESTRAL=
FCC_SMOKE_MODEL_DEEPSEEK=
FCC_SMOKE_MODEL_LMSTUDIO=
FCC_SMOKE_MODEL_LLAMACPP=
@ -105,6 +110,7 @@ ENABLE_MODEL_THINKING=true
NVIDIA_NIM_PROXY=""
OPENROUTER_PROXY=""
MISTRAL_PROXY=""
CODESTRAL_PROXY=""
LMSTUDIO_PROXY=""
LLAMACPP_PROXY=""
KIMI_PROXY=""

View file

@ -37,7 +37,7 @@ Free Claude Code routes Anthropic Messages API traffic from Claude Code to any p
## What You Get
- Drop-in proxy for Claude Code's Anthropic API calls.
- Sixteen provider backends: NVIDIA NIM, OpenRouter, Mistral La Plateforme, DeepSeek, Kimi, Wafer, LM Studio, llama.cpp, Ollama, OpenCode Zen, OpenCode Go, Z.ai, Fireworks AI, Google AI Studio (Gemini), Groq, and Cerebras Inference.
- Seventeen provider backends: NVIDIA NIM, OpenRouter, Mistral La Plateforme, Mistral Codestral, DeepSeek, Kimi, Wafer, LM Studio, llama.cpp, Ollama, OpenCode Zen, OpenCode Go, Z.ai, Fireworks AI, Google AI Studio (Gemini), Groq, and Cerebras Inference.
- Per-model routing: send Opus, Sonnet, Haiku, and fallback traffic to different providers.
- Native Claude Code `/model` picker support through the proxy's `/v1/models` endpoint (Claude Code must opt in to Gateway model discovery; see [Model Picker](#model-picker)).
- Streaming, tool use, reasoning/thinking block handling, and local request optimizations.
@ -144,7 +144,15 @@ Popular examples:
Browse models at [Mistral documentation](https://docs.mistral.ai/).
### 4. [DeepSeek](https://platform.deepseek.com/)
### 4. [Mistral Codestral](https://console.mistral.ai/)
Mistrals **Codestral** gateway uses a **separate API key** from La Plateforme: provision `CODESTRAL_API_KEY`, then route with the `mistral_codestral/` prefix. The default upstream is **`https://codestral.mistral.ai/v1`** (OpenAI-compatible Chat Completions; same request shaping as the `mistral` provider). See Mistrals [coding / FIM domains](https://docs.mistral.ai/mistral-vibe/using-fim-api); the curated [free LLM API list](https://github.com/cheahjs/free-llm-api-resources#mistral-codestral) summarizes typical Codestral access terms.
Popular examples:
- `mistral_codestral/codestral-latest`
### 5. [DeepSeek](https://platform.deepseek.com/)
Get a key at [platform.deepseek.com/api_keys](https://platform.deepseek.com/api_keys).
@ -152,7 +160,7 @@ In the Admin UI, paste it into `DEEPSEEK_API_KEY`, then set `MODEL` to a DeepSee
This provider uses DeepSeek's Anthropic-compatible endpoint, not the OpenAI chat-completions endpoint.
### 5. [Kimi](https://platform.moonshot.ai/)
### 6. [Kimi](https://platform.moonshot.ai/)
Get a key at [platform.moonshot.ai/console/api-keys](https://platform.moonshot.ai/console/api-keys).
@ -162,7 +170,7 @@ This provider calls Kimi's **Anthropic-compatible** Messages API (`https://api.m
Browse models at [platform.moonshot.ai](https://platform.moonshot.ai).
### 6. [Wafer](https://wafer.ai/)
### 7. [Wafer](https://wafer.ai/)
Get a key from [wafer.ai](https://wafer.ai). In the Admin UI, paste it into `WAFER_API_KEY`, then set `MODEL` to a Wafer Pass model such as `wafer/DeepSeek-V4-Pro`.
@ -175,13 +183,13 @@ Popular examples:
This provider uses Wafer's Anthropic-compatible endpoint at `https://pass.wafer.ai/v1/messages`.
### 7. [LM Studio](https://lmstudio.ai/)
### 8. [LM Studio](https://lmstudio.ai/)
Start LM Studio's local server and load a model. In the Admin UI, keep or update `LM_STUDIO_BASE_URL`, then set `MODEL` to the model identifier shown by LM Studio, prefixed with `lmstudio/`.
Prefer models with tool-use support for Claude Code workflows.
### 8. [llama.cpp](https://github.com/ggml-org/llama.cpp)
### 9. [llama.cpp](https://github.com/ggml-org/llama.cpp)
Start `llama-server` with an Anthropic-compatible `/v1/messages` endpoint and enough context for Claude Code requests.
@ -189,7 +197,7 @@ In the Admin UI, keep or update `LLAMACPP_BASE_URL`, then set `MODEL` to the loc
For local coding models, context size matters. If llama.cpp returns HTTP 400 for normal Claude Code requests, increase `--ctx-size` and verify the model/server build supports the requested features.
### 9. [Ollama](https://ollama.com/)
### 10. [Ollama](https://ollama.com/)
Run Ollama and pull a model:
@ -202,7 +210,7 @@ In the Admin UI, keep or update `OLLAMA_BASE_URL`, then set `MODEL` to the same
`OLLAMA_BASE_URL` is the Ollama server root; do not append `/v1`. Example model slugs include `ollama/llama3.1` and `ollama/llama3.1:8b`.
### 10. [OpenCode Zen](https://opencode.ai/)
### 11. [OpenCode Zen](https://opencode.ai/)
Get an API key at [opencode.ai/auth](https://opencode.ai/auth).
@ -221,7 +229,7 @@ Popular examples:
Browse available models at [opencode.ai](https://opencode.ai).
### 11. [OpenCode Go](https://opencode.ai/)
### 12. [OpenCode Go](https://opencode.ai/)
Get an API key at [opencode.ai/auth](https://opencode.ai/auth) (same as OpenCode Zen).
@ -235,7 +243,7 @@ Popular examples:
Browse available models at [opencode.ai](https://opencode.ai).
### 12. [Z.ai](https://z.ai/)
### 13. [Z.ai](https://z.ai/)
Get an API key at [Z.ai/manage-apikey/apikey-list](https://z.ai/manage-apikey/apikey-list).
@ -250,7 +258,7 @@ Popular examples:
Browse models at [Z.ai](https://z.ai).
### 13. [Fireworks AI](https://fireworks.ai/)
### 14. [Fireworks AI](https://fireworks.ai/)
Get an API key at [fireworks.ai/account/api-keys](https://fireworks.ai/account/api-keys).
@ -260,7 +268,7 @@ Fireworks exposes an **Anthropic-compatible** Messages API at `https://api.firew
Browse models at [fireworks.ai/models](https://fireworks.ai/models).
### 14. [Google AI Studio (Gemini)](https://aistudio.google.com/)
### 15. [Google AI Studio (Gemini)](https://aistudio.google.com/)
Get a Gemini API key at [Google AI Studio](https://aistudio.google.com/apikey) (see Google's [Gemini OpenAI compatibility](https://ai.google.dev/gemini-api/docs/openai) docs).
@ -273,7 +281,7 @@ Popular examples:
- `gemini/gemini-2.5-flash`
- `gemini/gemini-3.1-flash-lite`
### 15. [Groq](https://console.groq.com/)
### 16. [Groq](https://console.groq.com/)
Get an API key at [console.groq.com/keys](https://console.groq.com/keys).
@ -285,7 +293,7 @@ Reasoning-heavy models expose extra knobs documented under [Groq reasoning](http
Browse models at [console.groq.com/docs/models](https://console.groq.com/docs/models).
### 16. [Cerebras Inference](https://inference-docs.cerebras.ai/quickstart)
### 17. [Cerebras Inference](https://inference-docs.cerebras.ai/quickstart)
Sign up and create an API key in the [Cerebras Cloud Console](https://cloud.cerebras.ai) (see [Quickstart](https://inference-docs.cerebras.ai/quickstart)).
@ -293,7 +301,7 @@ In the Admin UI, set `CEREBRAS_API_KEY`, then route with `MODEL` such as `cerebr
Cerebras exposes an OpenAI-compatible API at `https://api.cerebras.ai/v1` ([OpenAI compatibility](https://inference-docs.cerebras.ai/resources/openai)). Non-standard request fields should go in `extra_body` when using the OpenAI client; see the same page. For reasoning models and parameters, see [Reasoning](https://inference-docs.cerebras.ai/capabilities/reasoning). This proxy follows other OpenAI-compat adapters for thinking via `reasoning_content` when Claude-style thinking is enabled.
### 17. Mix Providers By Model Tier
### 18. Mix Providers By Model Tier
Each model tier can use a different provider by setting `MODEL_OPUS`, `MODEL_SONNET`, and `MODEL_HAIKU` in the Admin UI. Leave a tier blank to inherit `MODEL`.

View file

@ -145,6 +145,18 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
"Mistral La Plateforme (api.mistral.ai); Experiment plan is free tier with rate limits."
),
),
ConfigFieldSpec(
"CODESTRAL_API_KEY",
"Codestral API Key",
"providers",
"secret",
settings_attr="codestral_api_key",
secret=True,
description=(
"Mistral Codestral endpoint (codestral.mistral.ai); distinct from Mistral "
"La Plateforme ``MISTRAL_API_KEY``. See Mistral docs for coding/FIM domains."
),
),
ConfigFieldSpec(
"DEEPSEEK_API_KEY",
"DeepSeek API Key",
@ -287,6 +299,15 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
secret=True,
advanced=True,
),
ConfigFieldSpec(
"CODESTRAL_PROXY",
"Codestral Proxy",
"providers",
"secret",
settings_attr="codestral_proxy",
secret=True,
advanced=True,
),
ConfigFieldSpec(
"LMSTUDIO_PROXY",
"LM Studio Proxy",
@ -792,6 +813,12 @@ FIELDS: tuple[ConfigFieldSpec, ...] = (
"smoke",
advanced=True,
),
ConfigFieldSpec(
"FCC_SMOKE_MODEL_MISTRAL_CODESTRAL",
"Smoke Mistral Codestral Model",
"smoke",
advanced=True,
),
ConfigFieldSpec(
"FCC_SMOKE_MODEL_DEEPSEEK",
"Smoke DeepSeek Model",

View file

@ -61,6 +61,7 @@ function providerName(providerId) {
const names = {
nvidia_nim: "NVIDIA NIM",
open_router: "OpenRouter",
mistral_codestral: "Mistral Codestral",
deepseek: "DeepSeek",
lmstudio: "LM Studio",
llamacpp: "llama.cpp",

View file

@ -23,6 +23,8 @@ DEEPSEEK_DEFAULT_BASE = DEEPSEEK_ANTHROPIC_DEFAULT_BASE
FIREWORKS_DEFAULT_BASE = "https://api.fireworks.ai/inference/v1"
OPENROUTER_DEFAULT_BASE = "https://openrouter.ai/api/v1"
MISTRAL_DEFAULT_BASE = "https://api.mistral.ai/v1"
# Codestral IDE/personal endpoint (distinct from La Plateforme ``api.mistral.ai`` keys).
CODESTRAL_DEFAULT_BASE = "https://codestral.mistral.ai/v1"
LMSTUDIO_DEFAULT_BASE = "http://localhost:1234/v1"
LLAMACPP_DEFAULT_BASE = "http://localhost:8080/v1"
OLLAMA_DEFAULT_BASE = "http://localhost:11434"
@ -83,6 +85,16 @@ PROVIDER_CATALOG: dict[str, ProviderDescriptor] = {
proxy_attr="mistral_proxy",
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
),
"mistral_codestral": ProviderDescriptor(
provider_id="mistral_codestral",
transport_type="openai_chat",
credential_env="CODESTRAL_API_KEY",
credential_url="https://console.mistral.ai/",
credential_attr="codestral_api_key",
default_base_url=CODESTRAL_DEFAULT_BASE,
proxy_attr="codestral_proxy",
capabilities=("chat", "streaming", "tools", "thinking", "rate_limit"),
),
"deepseek": ProviderDescriptor(
provider_id="deepseek",
transport_type="anthropic_messages",

View file

@ -113,6 +113,9 @@ class Settings(BaseSettings):
# ==================== Mistral La Plateforme ====================
mistral_api_key: str = Field(default="", validation_alias="MISTRAL_API_KEY")
# ==================== Mistral Codestral (codestral.mistral.ai) ====================
codestral_api_key: str = Field(default="", validation_alias="CODESTRAL_API_KEY")
# ==================== DeepSeek Config ====================
deepseek_api_key: str = Field(default="", validation_alias="DEEPSEEK_API_KEY")
@ -189,6 +192,7 @@ class Settings(BaseSettings):
nvidia_nim_proxy: str = Field(default="", validation_alias="NVIDIA_NIM_PROXY")
open_router_proxy: str = Field(default="", validation_alias="OPENROUTER_PROXY")
mistral_proxy: str = Field(default="", validation_alias="MISTRAL_PROXY")
codestral_proxy: str = Field(default="", validation_alias="CODESTRAL_PROXY")
lmstudio_proxy: str = Field(default="", validation_alias="LMSTUDIO_PROXY")
llamacpp_proxy: str = Field(default="", validation_alias="LLAMACPP_PROXY")
kimi_proxy: str = Field(default="", validation_alias="KIMI_PROXY")

View file

@ -0,0 +1,7 @@
"""Mistral Codestral provider (codestral.mistral.ai) exports."""
from providers.defaults import CODESTRAL_DEFAULT_BASE
from .client import CodestralProvider
__all__ = ["CODESTRAL_DEFAULT_BASE", "CodestralProvider"]

View file

@ -0,0 +1,34 @@
"""Mistral Codestral provider (OpenAI-compatible chat on codestral.mistral.ai)."""
from __future__ import annotations
from typing import Any
from providers.base import ProviderConfig
from providers.defaults import CODESTRAL_DEFAULT_BASE
from providers.mistral.request import build_request_body
from providers.openai_compat import OpenAIChatTransport
class CodestralProvider(OpenAIChatTransport):
"""Codestral host using ``https://codestral.mistral.ai/v1/chat/completions``.
Uses a separate Codestral API key from La Plateforme (``MISTRAL_API_KEY``).
Request shaping matches Mistral La Plateforme (shared ``build_request_body``).
"""
def __init__(self, config: ProviderConfig):
super().__init__(
config,
provider_name="CODESTRAL",
base_url=config.base_url or CODESTRAL_DEFAULT_BASE,
api_key=config.api_key,
)
def _build_request_body(
self, request: Any, thinking_enabled: bool | None = None
) -> dict:
return build_request_body(
request,
thinking_enabled=self._is_thinking_enabled(request, thinking_enabled),
)

View file

@ -2,6 +2,7 @@
from config.provider_catalog import (
CEREBRAS_DEFAULT_BASE,
CODESTRAL_DEFAULT_BASE,
DEEPSEEK_ANTHROPIC_DEFAULT_BASE,
DEEPSEEK_DEFAULT_BASE,
GEMINI_DEFAULT_BASE,
@ -21,6 +22,7 @@ from config.provider_catalog import (
__all__ = (
"CEREBRAS_DEFAULT_BASE",
"CODESTRAL_DEFAULT_BASE",
"DEEPSEEK_ANTHROPIC_DEFAULT_BASE",
"DEEPSEEK_DEFAULT_BASE",
"GEMINI_DEFAULT_BASE",

View file

@ -44,18 +44,26 @@ def _create_open_router(config: ProviderConfig, _settings: Settings) -> BaseProv
return OpenRouterProvider(config)
def _create_deepseek(config: ProviderConfig, _settings: Settings) -> BaseProvider:
from providers.deepseek import DeepSeekProvider
return DeepSeekProvider(config)
def _create_mistral(config: ProviderConfig, _settings: Settings) -> BaseProvider:
from providers.mistral import MistralProvider
return MistralProvider(config)
def _create_mistral_codestral(
config: ProviderConfig, _settings: Settings
) -> BaseProvider:
from providers.codestral import CodestralProvider
return CodestralProvider(config)
def _create_deepseek(config: ProviderConfig, _settings: Settings) -> BaseProvider:
from providers.deepseek import DeepSeekProvider
return DeepSeekProvider(config)
def _create_lmstudio(config: ProviderConfig, _settings: Settings) -> BaseProvider:
from providers.lmstudio import LMStudioProvider
@ -132,6 +140,7 @@ PROVIDER_FACTORIES: dict[str, ProviderFactory] = {
"nvidia_nim": _create_nvidia_nim,
"open_router": _create_open_router,
"mistral": _create_mistral,
"mistral_codestral": _create_mistral_codestral,
"deepseek": _create_deepseek,
"lmstudio": _create_lmstudio,
"llamacpp": _create_llamacpp,

View file

@ -118,7 +118,8 @@ uv run pytest smoke/product -n 0 -s --tb=short
- `FCC_SMOKE_TARGETS`: comma-separated targets, or `all`.
- `FCC_SMOKE_PROVIDER_MATRIX`: comma-separated provider prefixes to require.
- `FCC_SMOKE_MODEL_NVIDIA_NIM`, `FCC_SMOKE_MODEL_OPEN_ROUTER`,
`FCC_SMOKE_MODEL_MISTRAL`, `FCC_SMOKE_MODEL_DEEPSEEK`, `FCC_SMOKE_MODEL_KIMI`,
`FCC_SMOKE_MODEL_MISTRAL`, `FCC_SMOKE_MODEL_MISTRAL_CODESTRAL`,
`FCC_SMOKE_MODEL_DEEPSEEK`, `FCC_SMOKE_MODEL_KIMI`,
`FCC_SMOKE_MODEL_WAFER`, `FCC_SMOKE_MODEL_OPENCODE`, `FCC_SMOKE_MODEL_OPENCODE_GO`,
`FCC_SMOKE_MODEL_ZAI`, `FCC_SMOKE_MODEL_FIREWORKS`, `FCC_SMOKE_MODEL_GEMINI`,
`FCC_SMOKE_MODEL_GROQ`, `FCC_SMOKE_MODEL_CEREBRAS`,

View file

@ -45,6 +45,7 @@ PROVIDER_SMOKE_DEFAULT_MODELS: dict[str, str] = {
"nvidia_nim": "nvidia_nim/z-ai/glm4.7",
"open_router": "open_router/stepfun/step-3.5-flash:free",
"mistral": "mistral/devstral-small-latest",
"mistral_codestral": "mistral_codestral/codestral-latest",
"deepseek": "deepseek/deepseek-v4-pro",
"lmstudio": "lmstudio/local-model",
"llamacpp": "llamacpp/local-model",
@ -230,6 +231,8 @@ class SmokeConfig:
return bool(self.settings.open_router_api_key.strip())
if provider == "mistral":
return bool(self.settings.mistral_api_key.strip())
if provider == "mistral_codestral":
return bool(self.settings.codestral_api_key.strip())
if provider == "deepseek":
return bool(self.settings.deepseek_api_key.strip())
if provider == "kimi":

View file

@ -16,6 +16,7 @@ from api.dependencies import (
)
from config.nim import NimSettings
from providers.cerebras import CerebrasProvider
from providers.codestral import CodestralProvider
from providers.deepseek import DeepSeekProvider
from providers.exceptions import ServiceUnavailableError, UnknownProviderTypeError
from providers.gemini import GeminiProvider
@ -40,6 +41,7 @@ def _make_mock_settings(**overrides):
mock.provider_max_concurrency = 5
mock.open_router_api_key = "test_openrouter_key"
mock.mistral_api_key = "test_mistral_key"
mock.codestral_api_key = "test_codestral_key"
mock.deepseek_api_key = "test_deepseek_key"
mock.wafer_api_key = "test_wafer_key"
mock.opencode_api_key = "test_opencode_key"
@ -50,6 +52,7 @@ def _make_mock_settings(**overrides):
mock.nvidia_nim_proxy = ""
mock.open_router_proxy = ""
mock.mistral_proxy = ""
mock.codestral_proxy = ""
mock.lmstudio_proxy = ""
mock.llamacpp_proxy = ""
mock.kimi_proxy = ""
@ -229,6 +232,21 @@ async def test_get_provider_mistral():
assert provider._api_key == "test_mistral_key"
@pytest.mark.asyncio
async def test_get_provider_mistral_codestral():
"""provider_type=mistral_codestral returns CodestralProvider."""
with patch("api.dependencies.get_settings") as mock_settings:
mock_settings.return_value = _make_mock_settings(
provider_type="mistral_codestral",
)
provider = get_provider()
assert isinstance(provider, CodestralProvider)
assert provider._base_url == "https://codestral.mistral.ai/v1"
assert provider._api_key == "test_codestral_key"
@pytest.mark.asyncio
async def test_get_provider_gemini():
"""Test that provider_type=gemini returns GeminiProvider."""
@ -479,6 +497,23 @@ async def test_get_provider_mistral_missing_api_key():
assert "console.mistral.ai" in exc_info.value.detail
@pytest.mark.asyncio
async def test_get_provider_mistral_codestral_missing_api_key():
"""Mistral Codestral with empty API key raises HTTPException 503."""
with patch("api.dependencies.get_settings") as mock_settings:
mock_settings.return_value = _make_mock_settings(
provider_type="mistral_codestral",
codestral_api_key="",
)
with pytest.raises(HTTPException) as exc_info:
get_provider()
assert exc_info.value.status_code == 503
assert "CODESTRAL_API_KEY" in exc_info.value.detail
assert "console.mistral.ai" in exc_info.value.detail
@pytest.mark.asyncio
async def test_get_provider_deepseek_missing_api_key():
"""DeepSeek with empty API key raises HTTPException 503."""

View file

@ -761,6 +761,10 @@ class TestPerModelMapping:
assert (
Settings.parse_provider_type("mistral/devstral-small-latest") == "mistral"
)
assert (
Settings.parse_provider_type("mistral_codestral/codestral-latest")
== "mistral_codestral"
)
assert Settings.parse_provider_type("deepseek/deepseek-chat") == "deepseek"
assert Settings.parse_provider_type("lmstudio/qwen") == "lmstudio"
assert Settings.parse_provider_type("llamacpp/model") == "llamacpp"
@ -779,6 +783,10 @@ class TestPerModelMapping:
Settings.parse_model_name("mistral/devstral-small-latest")
== "devstral-small-latest"
)
assert (
Settings.parse_model_name("mistral_codestral/codestral-latest")
== "codestral-latest"
)
assert Settings.parse_model_name("deepseek/deepseek-chat") == "deepseek-chat"
assert Settings.parse_model_name("lmstudio/qwen") == "qwen"
assert Settings.parse_model_name("llamacpp/model") == "model"

View file

@ -6,6 +6,7 @@ from pathlib import Path
from messaging.platforms.factory import create_messaging_platform
from providers.base import BaseProvider
from providers.cerebras import CerebrasProvider
from providers.codestral import CodestralProvider
from providers.deepseek import DeepSeekProvider
from providers.fireworks import FireworksProvider
from providers.gemini import GeminiProvider
@ -79,6 +80,7 @@ def test_provider_and_platform_registries_include_advertised_builtins() -> None:
"nvidia_nim": NvidiaNimProvider,
"open_router": OpenRouterProvider,
"mistral": MistralProvider,
"mistral_codestral": CodestralProvider,
"deepseek": DeepSeekProvider,
"kimi": KimiProvider,
"fireworks": FireworksProvider,

View file

@ -26,6 +26,7 @@ def _settings(**overrides):
"nvidia_nim_api_key": "",
"open_router_api_key": "",
"mistral_api_key": "",
"codestral_api_key": "",
"deepseek_api_key": "",
"kimi_api_key": "",
"wafer_api_key": "",

View file

@ -0,0 +1,182 @@
"""Tests for Mistral Codestral provider."""
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from providers.base import ProviderConfig
from providers.codestral import CODESTRAL_DEFAULT_BASE, CodestralProvider
class MockMessage:
def __init__(self, role, content):
self.role = role
self.content = content
class MockRequest:
def __init__(self, **kwargs):
self.model = "devstral-small-latest"
self.messages = [MockMessage("user", "Hello")]
self.max_tokens = 100
self.temperature = 0.5
self.top_p = 0.9
self.system = "System prompt"
self.stop_sequences = None
self.tools = []
self.thinking = MagicMock()
self.thinking.enabled = True
for key, value in kwargs.items():
setattr(self, key, value)
@pytest.fixture
def codestral_config():
return ProviderConfig(
api_key="test_codestral_key",
base_url=CODESTRAL_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
enable_thinking=True,
)
@pytest.fixture(autouse=True)
def mock_rate_limiter():
"""Mock the global rate limiter to prevent waiting."""
@asynccontextmanager
async def _slot():
yield
with patch("providers.openai_compat.GlobalRateLimiter") as mock:
instance = mock.get_scoped_instance.return_value
async def _passthrough(fn, *args, **kwargs):
return await fn(*args, **kwargs)
instance.execute_with_retry = AsyncMock(side_effect=_passthrough)
instance.concurrency_slot.side_effect = _slot
yield instance
@pytest.fixture
def codestral_provider(codestral_config):
return CodestralProvider(codestral_config)
def test_init(codestral_config):
"""Test provider initialization."""
with patch("providers.openai_compat.AsyncOpenAI") as mock_openai:
provider = CodestralProvider(codestral_config)
assert provider._api_key == "test_codestral_key"
assert provider._base_url == CODESTRAL_DEFAULT_BASE
mock_openai.assert_called_once()
def test_default_base_url():
assert CODESTRAL_DEFAULT_BASE == "https://codestral.mistral.ai/v1"
def test_build_request_body_basic(codestral_provider):
"""Basic request body conversion works for Codestral."""
req = MockRequest()
body = codestral_provider._build_request_body(req)
assert body["model"] == "devstral-small-latest"
assert body["messages"][0]["role"] == "system"
def test_build_request_body_global_disable_blocks_reasoning_mapping():
"""Global disable disables reasoning replay in the converter."""
provider = CodestralProvider(
ProviderConfig(
api_key="test_codestral_key",
base_url=CODESTRAL_DEFAULT_BASE,
rate_limit=10,
rate_window=60,
enable_thinking=False,
)
)
req = MockRequest()
body = provider._build_request_body(req)
roles = [m.get("role") for m in body.get("messages", [])]
assert "assistant_reasoning_content" not in roles
@pytest.mark.asyncio
async def test_stream_response_text(codestral_provider):
"""Text content deltas are emitted as text blocks."""
req = MockRequest()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content="Hello back!",
reasoning_content=None,
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=5, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
codestral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in codestral_provider.stream_response(req)]
assert any(
'"text_delta"' in event and "Hello back!" in event for event in events
)
@pytest.mark.asyncio
async def test_stream_response_reasoning_content(codestral_provider):
"""reasoning_content deltas are emitted as thinking blocks."""
req = MockRequest()
mock_chunk = MagicMock()
mock_chunk.choices = [
MagicMock(
delta=MagicMock(
content=None,
reasoning_content="Thinking...",
tool_calls=None,
),
finish_reason="stop",
)
]
mock_chunk.usage = MagicMock(completion_tokens=2, prompt_tokens=10)
async def mock_stream():
yield mock_chunk
with patch.object(
codestral_provider._client.chat.completions, "create", new_callable=AsyncMock
) as mock_create:
mock_create.return_value = mock_stream()
events = [event async for event in codestral_provider.stream_response(req)]
assert any(
'"thinking_delta"' in event and "Thinking..." in event for event in events
)
@pytest.mark.asyncio
async def test_cleanup(codestral_provider):
"""cleanup closes the OpenAI client."""
codestral_provider._client = AsyncMock()
await codestral_provider.cleanup()
codestral_provider._client.close.assert_called_once()

View file

@ -8,6 +8,7 @@ from config.nim import NimSettings
from config.provider_catalog import PROVIDER_CATALOG, ZAI_DEFAULT_BASE
from config.provider_ids import SUPPORTED_PROVIDER_IDS
from providers.cerebras import CerebrasProvider
from providers.codestral import CodestralProvider
from providers.deepseek import DeepSeekProvider
from providers.exceptions import UnknownProviderTypeError
from providers.fireworks import FireworksProvider
@ -38,6 +39,7 @@ def _make_settings(**overrides):
mock.nvidia_nim_api_key = "test_key"
mock.open_router_api_key = "test_openrouter_key"
mock.mistral_api_key = "test_mistral_key"
mock.codestral_api_key = "test_codestral_key"
mock.deepseek_api_key = "test_deepseek_key"
mock.wafer_api_key = "test_wafer_key"
mock.opencode_api_key = "test_opencode_key"
@ -50,6 +52,7 @@ def _make_settings(**overrides):
mock.lmstudio_proxy = ""
mock.llamacpp_proxy = ""
mock.mistral_proxy = ""
mock.codestral_proxy = ""
mock.kimi_proxy = ""
mock.kimi_api_key = "test_kimi_key"
mock.wafer_proxy = ""
@ -171,6 +174,7 @@ def test_create_provider_instantiates_each_builtin():
cases = {
"nvidia_nim": NvidiaNimProvider,
"mistral": MistralProvider,
"mistral_codestral": CodestralProvider,
"deepseek": DeepSeekProvider,
"kimi": KimiProvider,
"fireworks": FireworksProvider,