Skip to content
Closed
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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@
# NOVITA_API_KEY=
# NOVITA_BASE_URL=https://api.novita.ai/openai/v1 # Override default base URL

# =============================================================================
# LLM PROVIDER (Mistral AI)
# =============================================================================
# Mistral AI: Mistral, Codestral, and Devstral models (direct API).
# The same key also powers Voxtral TTS and transcription.
# Get your key at: https://console.mistral.ai/
# MISTRAL_API_KEY=
# MISTRAL_BASE_URL=https://api.mistral.ai/v1 # Override default base URL

# =============================================================================
# LLM PROVIDER (Google AI Studio / Gemini)
# =============================================================================
Expand Down
4 changes: 3 additions & 1 deletion agent/model_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def _resolve_requests_verify() -> bool | str:
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita", "mistral",
"qwen-oauth",
"xiaomi",
"arcee",
Expand All @@ -68,6 +68,7 @@ def _resolve_requests_verify() -> bool | str:
"xai", "x-ai", "x.ai", "grok",
"nvidia", "nim", "nvidia-nim", "nemotron",
"qwen-portal", "novita-ai", "novitaai",
"mistral-ai", "mistralai",
})


Expand Down Expand Up @@ -428,6 +429,7 @@ def _is_custom_endpoint(base_url: str) -> bool:
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
"api.mistral.ai": "mistral",
"api.githubcopilot.com": "copilot",
"models.github.ai": "copilot",
# GitHub Models free tier (Azure-hosted prototyping endpoint) — same
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3592,7 +3592,7 @@ def _ensure_hermes_home_managed(home: Path):
"category": "tool",
},
"MISTRAL_API_KEY": {
"description": "Mistral API key for Voxtral TTS and transcription (STT)",
"description": "Mistral API key for LLM inference (provider: mistral), Voxtral TTS, and transcription (STT)",
"prompt": "Mistral API key",
"url": "https://console.mistral.ai/",
"password": True,
Expand Down
11 changes: 11 additions & 0 deletions hermes_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,14 @@ def _xai_curated_models() -> list[str]:
"deepseek-chat",
"deepseek-reasoner",
],
"mistral": [
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"codestral-latest",
"devstral-medium-latest",
"ministral-8b-latest",
],
"xiaomi": [
"mimo-v2.5-pro",
"mimo-v2.5",
Expand Down Expand Up @@ -1033,6 +1041,7 @@ class ProviderEntry(NamedTuple):
ProviderEntry("huggingface", "Hugging Face", "Hugging Face Inference Providers"),
ProviderEntry("gemini", "Google AI Studio", "Google AI Studio (Native Gemini API)"),
ProviderEntry("deepseek", "DeepSeek", "DeepSeek (V3, R1, coder, direct API)"),
ProviderEntry("mistral", "Mistral AI", "Mistral AI (Large, Medium, Small, Codestral, Devstral; direct API)"),
ProviderEntry("xai", "xAI", "xAI Grok (Direct API)"),
ProviderEntry("zai", "Z.AI / GLM", "Z.AI / GLM (Zhipu direct API)"),
ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "Kimi Coding Plan (api.kimi.com & Moonshot API)"),
Expand Down Expand Up @@ -1211,6 +1220,8 @@ def group_providers(slugs):
"claude": "anthropic",
"claude-code": "anthropic",
"deep-seek": "deepseek",
"mistral-ai": "mistral",
"mistralai": "mistral",
"opencode": "opencode-zen",
"zen": "opencode-zen",
"go": "opencode-go",
Expand Down
9 changes: 9 additions & 0 deletions hermes_cli/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,11 @@ class HermesOverlay:
transport="openai_chat",
base_url_env_var="DEEPSEEK_BASE_URL",
),
"mistral": HermesOverlay(
transport="openai_chat",
base_url_override="https://api.mistral.ai/v1",
base_url_env_var="MISTRAL_BASE_URL",
),
"alibaba": HermesOverlay(
transport="openai_chat",
base_url_env_var="DASHSCOPE_BASE_URL",
Expand Down Expand Up @@ -301,6 +306,10 @@ class ProviderDef:
# deepseek
"deep-seek": "deepseek",

# mistral
"mistral-ai": "mistral",
"mistralai": "mistral",

# alibaba
"dashscope": "alibaba",
"aliyun": "alibaba",
Expand Down
42 changes: 42 additions & 0 deletions plugins/model-providers/mistral/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""Mistral AI provider profile.

Mistral's platform speaks the OpenAI-compatible Chat Completions API at
``https://api.mistral.ai/v1``, so the default ``openai_chat`` transport carries
tool calling and streaming without special handling. The endpoint is strict and
rejects unknown message fields, but the transport already strips Hermes-internal
keys (reasoning carriers, codex fields, finish_reason, scaffolding markers)
before the request leaves, so no extra message handling is needed here.

Adjustable reasoning (``reasoning_effort``) is intentionally not wired: Mistral
streams reasoning as structured content blocks (``[{"type": "thinking"}, ...]``)
rather than a plain string, which the streaming path does not flatten yet, so
enabling it truncates responses. That belongs in the streaming layer, not this
profile.
"""

from providers import register_provider
from providers.base import ProviderProfile


mistral = ProviderProfile(
name="mistral",
aliases=("mistral-ai", "mistralai"),
env_vars=("MISTRAL_API_KEY", "MISTRAL_BASE_URL"),
display_name="Mistral AI",
description="Mistral AI: Mistral, Codestral, and Devstral models (direct API)",
signup_url="https://console.mistral.ai/",
base_url="https://api.mistral.ai/v1",
auth_type="api_key",
supports_vision=True,
default_aux_model="mistral-small-latest",
fallback_models=(
"mistral-large-latest",
"mistral-medium-latest",
"mistral-small-latest",
"codestral-latest",
"devstral-medium-latest",
"ministral-8b-latest",
),
)

register_provider(mistral)
5 changes: 5 additions & 0 deletions plugins/model-providers/mistral/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: mistral-provider
kind: model-provider
version: 1.0.0
description: Mistral AI direct API (Mistral, Codestral, Devstral)
author: Adolanium
164 changes: 164 additions & 0 deletions tests/plugins/model_providers/test_mistral_profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
"""Unit tests for the Mistral provider profile.

Mistral is a plain OpenAI-compatible provider: chat and tool calling ride the
default ``openai_chat`` transport. These tests pin the profile's identity,
endpoint, aliases, auxiliary model, and curated fallback catalog so the
first-class wiring stays intact.
"""

from __future__ import annotations

import pytest


@pytest.fixture
def mistral_profile():
"""Resolve the registered Mistral profile through the public registry."""
# Importing ``model_tools`` triggers plugin discovery, which registers the
# Mistral profile in the global provider registry.
import model_tools # noqa: F401
import providers

profile = providers.get_provider_profile("mistral")
assert profile is not None, "mistral provider profile must be registered"
return profile


class TestMistralProfileMetadata:
"""Identity, endpoint, and aux model are wired as a first-class provider."""

def test_identity_and_endpoint(self, mistral_profile):
assert mistral_profile.name == "mistral"
assert mistral_profile.base_url == "https://api.mistral.ai/v1"
assert mistral_profile.auth_type == "api_key"
assert "MISTRAL_API_KEY" in mistral_profile.env_vars
assert mistral_profile.display_name == "Mistral AI"

def test_aliases_resolve(self):
import model_tools # noqa: F401
import providers

assert providers.get_provider_profile("mistral-ai").name == "mistral"
assert providers.get_provider_profile("mistralai").name == "mistral"

def test_default_aux_model(self, mistral_profile):
assert mistral_profile.default_aux_model == "mistral-small-latest"

def test_consumer_api_returns_aux_model(self):
from agent.auxiliary_client import _get_aux_model_for_provider

assert _get_aux_model_for_provider("mistral") == "mistral-small-latest"

def test_fallback_catalog_is_latest_aliases(self, mistral_profile):
models = mistral_profile.fallback_models
assert "mistral-large-latest" in models
assert "codestral-latest" in models
# Curated fallbacks use stable -latest aliases so they don't go stale.
assert all(m.endswith("-latest") for m in models)

def test_no_reasoning_kwargs_emitted(self, mistral_profile):
"""The plain profile sends no provider-specific reasoning kwargs."""
extra_body, top_level = mistral_profile.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
model="mistral-small-latest",
)
assert extra_body == {}
assert top_level == {}


class TestMistralConfigResolution:
"""Selecting Mistral via ``config.yaml`` reaches the first-class path.

``hermes chat`` with no ``--provider`` flag resolves the provider from
``model.provider`` in ``config.yaml`` through the same ``normalize_provider``
and api-key dispatch the flag uses, so the config-driven flow lands on the
first-class provider, not a custom-endpoint fallback.
"""

@pytest.mark.parametrize("configured", ["mistral", "mistral-ai", "mistralai"])
def test_config_provider_value_normalizes(self, configured):
from hermes_cli.models import normalize_provider

assert normalize_provider(configured) == "mistral"

def test_auto_resolves_provider_from_config_yaml(self, monkeypatch):
# The exact flow from the reviewer's example: ``config.yaml`` sets
# ``model.provider: mistral`` and ``hermes chat`` runs without
# ``--provider`` (requested == "auto"). resolve_provider() must read the
# config provider and land on the first-class ``mistral``, not fall
# through to an OpenRouter/env default.
import model_tools # noqa: F401
import hermes_cli.config as config_mod
from hermes_cli.auth import resolve_provider

monkeypatch.setattr(
config_mod,
"load_config",
lambda: {"model": {"provider": "mistral", "default": "mistral-large-latest"}},
)
assert resolve_provider("auto") == "mistral"

def test_config_provider_takes_api_key_path(self):
# The agent treats this provider as a profile-backed api-key provider,
# which is the branch a config-set ``provider: mistral`` flows through.
import model_tools # noqa: F401
from hermes_cli.main import _is_profile_api_key_provider

assert _is_profile_api_key_provider("mistral") is True


class TestMistralStrictTransport:
"""Mistral relies on the generic transport sanitization, not its own.

``api.mistral.ai`` is a strict OpenAI-compatible endpoint that rejects
unknown message fields with HTTP 400. The Mistral profile carries no
provider-specific message cleanup (``prepare_messages`` is pass-through);
the shared ``chat_completions`` transport strips the Hermes-internal carriers
(``timestamp``, ``tool_name``, ``_``-scaffolding, Gemini ``extra_content``)
before the request leaves. These tests pin that contract so a future change
that reintroduces a leak fails here.
"""

def test_profile_prepare_messages_is_passthrough(self, mistral_profile):
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "ok"},
]
assert mistral_profile.prepare_messages(messages) == messages

def test_transport_strips_fields_mistral_rejects(self):
from agent.transports import get_transport
import agent.transports.chat_completions # noqa: F401

transport = get_transport("chat_completions")
messages = [
{
"role": "assistant",
"content": "ok",
"timestamp": "2026-06-21T00:00:00Z",
"tool_name": "terminal",
"_empty_recovery_synthetic": True,
"tool_calls": [
{
"id": "call_1",
"type": "function",
"extra_content": {"google": {"thought_signature": "SIG"}},
"function": {"name": "t", "arguments": "{}"},
}
],
}
]
result = transport.convert_messages(messages, model="mistral-large-latest")
sent = result[0]
# Internal carriers that api.mistral.ai rejects are gone.
assert "timestamp" not in sent
assert "tool_name" not in sent
assert "_empty_recovery_synthetic" not in sent
assert "extra_content" not in sent["tool_calls"][0]
# Real chat-completions fields survive untouched.
assert sent["role"] == "assistant"
assert sent["content"] == "ok"
assert sent["tool_calls"][0]["id"] == "call_1"
assert sent["tool_calls"][0]["function"]["name"] == "t"
# The caller's list is not mutated (sanitization deep-copies on demand).
assert "timestamp" in messages[0]
32 changes: 30 additions & 2 deletions website/docs/integrations/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ You need at least one way to connect to an LLM. Use `hermes model` to switch pro
| **OpenCode Zen** | `OPENCODE_ZEN_API_KEY` in `~/.hermes/.env` (provider: `opencode-zen`) |
| **OpenCode Go** | `OPENCODE_GO_API_KEY` in `~/.hermes/.env` (provider: `opencode-go`) |
| **DeepSeek** | `DEEPSEEK_API_KEY` in `~/.hermes/.env` (provider: `deepseek`) |
| **Mistral AI** | `MISTRAL_API_KEY` in `~/.hermes/.env` (provider: `mistral`; aliases: `mistral-ai`, `mistralai`) |
| **Hugging Face** | `HF_TOKEN` in `~/.hermes/.env` (provider: `huggingface`, aliases: `hf`) |
| **Google / Gemini** | `GOOGLE_API_KEY` (or `GEMINI_API_KEY`) in `~/.hermes/.env` (provider: `gemini`) |
| **OpenAI API (direct)** | `OPENAI_API_KEY` in `~/.hermes/.env` (provider: `openai-api`, optional `OPENAI_BASE_URL`) |
Expand Down Expand Up @@ -257,6 +258,10 @@ hermes chat --provider arcee --model trinity-large-thinking
# Use the exact model ID returned by GMI's /v1/models endpoint.
hermes chat --provider gmi --model zai-org/GLM-5.1-FP8
# Requires: GMI_API_KEY in ~/.hermes/.env

# Mistral AI (Mistral, Codestral, Devstral)
hermes chat --provider mistral --model mistral-large-latest
# Requires: MISTRAL_API_KEY in ~/.hermes/.env
```

Or set the provider permanently in `config.yaml`:
Expand All @@ -266,7 +271,7 @@ model:
default: "zai-org/GLM-5.1-FP8"
```

Base URLs can be overridden with `NOVITA_BASE_URL`, `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, `DASHSCOPE_BASE_URL`, `XIAOMI_BASE_URL`, `GMI_BASE_URL`, or `TOKENHUB_BASE_URL` environment variables.
Base URLs can be overridden with `NOVITA_BASE_URL`, `GLM_BASE_URL`, `KIMI_BASE_URL`, `MINIMAX_BASE_URL`, `MINIMAX_CN_BASE_URL`, `DASHSCOPE_BASE_URL`, `XIAOMI_BASE_URL`, `GMI_BASE_URL`, `MISTRAL_BASE_URL`, or `TOKENHUB_BASE_URL` environment variables.

:::note Z.AI Endpoint Auto-Detection
When using the Z.AI / GLM provider, Hermes automatically probes multiple endpoints (global, China, coding variants) to find one that accepts your API key. You don't need to set `GLM_BASE_URL` manually — the working endpoint is detected and cached automatically.
Expand Down Expand Up @@ -316,6 +321,29 @@ model:

Get your API key at [novita.ai/settings/key-management](https://novita.ai/settings/key-management). The base URL can be overridden with `NOVITA_BASE_URL`.

### Mistral AI

[Mistral AI](https://mistral.ai) serves its models over an OpenAI-compatible Chat Completions API, so tool calling and streaming work through the standard provider path. Set `MISTRAL_API_KEY` in `~/.hermes/.env` and pick **Mistral AI** in `hermes model`, or pass `--provider mistral`.

```bash
hermes chat --provider mistral --model mistral-large-latest
# Requires: MISTRAL_API_KEY in ~/.hermes/.env

# Short alias
hermes chat --provider mistralai --model codestral-latest
```

Or set it permanently in `config.yaml`, with no endpoint needed since the provider is first-class:
```yaml
model:
provider: "mistral"
default: "mistral-large-latest"
```

The picker reads the live catalog from [models.dev](https://models.dev) and merges in a curated fallback (`mistral-large-latest`, `mistral-medium-latest`, `mistral-small-latest`, `codestral-latest`, `devstral-medium-latest`, `ministral-8b-latest`) when the catalog is unreachable. That fallback is a deliberately small set of current general chat and coding models, not the full lineup; vision-capable and other Mistral model IDs stay reachable through the live catalog or by passing the ID directly. Use any model ID Mistral exposes; the `-latest` aliases always track the current release.

This is LLM inference over Mistral's REST API; it does not use the `mistralai` Python SDK. Get your API key at [console.mistral.ai](https://console.mistral.ai/). The base URL can be overridden with `MISTRAL_BASE_URL`.

### Ollama Cloud — Managed Ollama Models, OAuth + API Key

[Ollama Cloud](https://ollama.com/cloud) hosts the same open-weight catalog as local Ollama but without the GPU requirement. Pick it in `hermes model` as **Ollama Cloud**, paste your API key from [ollama.com/settings/keys](https://ollama.com/settings/keys), and Hermes auto-discovers the available models.
Expand Down Expand Up @@ -1074,7 +1102,7 @@ Any service with an OpenAI-compatible API works. Some popular options:
| [Fireworks AI](https://fireworks.ai) | `https://api.fireworks.ai/inference/v1` | Fast open model hosting |
| [GMI Cloud](https://www.gmicloud.ai/) | `https://api.gmi-serving.com/v1` | Managed OpenAI-compatible inference |
| [Cerebras](https://cerebras.ai) | `https://api.cerebras.ai/v1` | Wafer-scale chip inference |
| [Mistral AI](https://mistral.ai) | `https://api.mistral.ai/v1` | Mistral models |
| [Mistral AI](https://mistral.ai) | `https://api.mistral.ai/v1` | First-class provider: use `--provider mistral` with `MISTRAL_API_KEY` (see above) |
| [OpenAI](https://openai.com) | `https://api.openai.com/v1` | Direct OpenAI access |
| [Azure OpenAI](https://azure.microsoft.com) | `https://YOUR.openai.azure.com/` | Enterprise OpenAI |
| [LocalAI](https://localai.io) | `http://localhost:8080/v1` | Self-hosted, multi-model |
Expand Down
Loading
Loading