Add Mistral platform + Mistral dialect + array-content handling (provider 5)

The 8th registry row: id "mistral", MISTRAL_API_KEY > auth.json
"mistral" scope, https://api.mistral.ai/v1 with KIGI_MISTRAL_BASE_URL
override, enrichment-backed metadata with the tool-calling listing
restriction (embed/moderation/OCR noise).

Mistral is NOT a pure-pattern provider — an adversarial review found two
doc-confirmed blockers that no test exercises (no e2e covers a chat POST),
so the gate-green registry row alone would have shipped it DOA. A research
workflow pinned the exact wire shapes against the mistralai/client-python
SDK source (adversarially verified), then both were fixed:

1. stream_options 422: Mistral's strict Pydantic validator rejects the
   stream_options.include_usage field kigi injects on every streaming
   request (the SDK's request model has no such field). New
   ChatCompat::Mistral dialect strips it (plus the kigi-private message
   fields, like Passthrough). Streaming usage falls back to token
   estimation.
2. Reasoning content arrays: Mistral reasoning models return content as
   Union[str, List[ContentChunk]] on both streaming and non-streaming,
   which the flat Option<String> path could not decode -> aborted turn.
   A UNIVERSAL lenient deserializer (#[serde(from = "Raw..")] on
   ChatResponseMessage + ChatChunkDelta) accepts string-or-array, routing
   {type:text} chunks to the answer and the nested text of {type:thinking}
   chunks to reasoning_content, tolerant of the OPEN chunk union (unknown
   types ignored, never fatal). String content stays byte-identical for
   every other provider (kimi/deepseek/groq/BYOK).

Review refuted all seven attack lines (no regression, no crash, exhaustive)
and flagged one coverage gap, now closed: a stream-consumer integration
test drives a full thinking -> transition -> answer chunk sequence and
proves it yields the same reasoning-sibling + assistant-answer result as
the reasoning_content string path.

Also folds a verified quirk matrix for all 23 remaining API providers into
providers-plan.md, tiered by real difficulty (self-enriching OpenRouter/
Vercel; bare-array Together listing; Messages-dialect MiniMax reusing the
Anthropic machinery; non-Bearer Azure/Bedrock; router wildcards; the OAuth
block).
This commit is contained in:
2026-07-21 10:36:33 -04:00
parent 49e6414c29
commit 9953a26b8d
8 changed files with 491 additions and 14 deletions
+37 -2
View File
@@ -70,6 +70,7 @@ pub enum PlatformChatCompat {
Kimi,
DeepSeek,
Passthrough,
Mistral,
}
/// How a platform's API key rides requests (listing, validation, inference).
@@ -316,6 +317,35 @@ const GROQ_SPEC: PlatformSpec = PlatformSpec {
restrict_to_enriched: true,
};
/// Base-URL override for Mistral (dev/test escape hatch).
pub const MISTRAL_BASE_URL_ENV: &str = "KIGI_MISTRAL_BASE_URL";
const MISTRAL_SPEC: PlatformSpec = PlatformSpec {
id: "mistral",
display_name: "Mistral",
base_url: BaseUrlSource::EnvOr {
env: MISTRAL_BASE_URL_ENV,
default: "https://api.mistral.ai/v1",
},
uses_oauth: false,
allowed_model_prefixes: None,
api_key_envs: &["MISTRAL_API_KEY"],
vendor: "Mistral",
console_host: Some("console.mistral.ai"),
login_label: Some("Mistral (API key)"),
models_dev_id: Some("mistral"),
wire_serves_metadata: false,
wire_api: PlatformWireApi::ChatCompletions,
listing: ListingDialect::OpenAi,
// Mistral's strict validator 422s on `stream_options`, and its reasoning
// models return array content — the Mistral dialect handles both.
chat_compat: PlatformChatCompat::Mistral,
key_header: PlatformKeyHeader::Bearer,
// The listing carries embed/moderation/OCR entries; keep tool-calling
// chat models only.
restrict_to_enriched: true,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -334,12 +364,14 @@ pub enum PlatformId {
DeepSeek,
/// Groq platform API (API key, OpenAI-compatible ChatCompletions).
Groq,
/// Mistral platform API (API key, OpenAI-compatible ChatCompletions).
Mistral,
}
impl PlatformId {
/// All platforms, in catalog precedence order: the subscription channel
/// first so "default model = first list item" favors it when present.
pub const ALL: [PlatformId; 7] = [
pub const ALL: [PlatformId; 8] = [
Self::KimiCode,
Self::MoonshotCn,
Self::MoonshotAi,
@@ -347,6 +379,7 @@ impl PlatformId {
Self::Anthropic,
Self::DeepSeek,
Self::Groq,
Self::Mistral,
];
/// The registry row backing this platform (single source of per-platform
@@ -360,6 +393,7 @@ impl PlatformId {
Self::Anthropic => &ANTHROPIC_SPEC,
Self::DeepSeek => &DEEPSEEK_SPEC,
Self::Groq => &GROQ_SPEC,
Self::Mistral => &MISTRAL_SPEC,
}
}
@@ -1017,9 +1051,10 @@ mod tests {
PlatformId::Anthropic => 4,
PlatformId::DeepSeek => 5,
PlatformId::Groq => 6,
PlatformId::Mistral => 7,
}
}
const VARIANT_COUNT: usize = 7; // update together with `ordinal`
const VARIANT_COUNT: usize = 8; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable();
seen.dedup();