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
@@ -47,6 +47,22 @@ pub(crate) fn adapt_chat_completions_body_for(
kigi_sampling_types::ChatCompat::Passthrough => {
strip_kigi_private_message_fields(body);
}
kigi_sampling_types::ChatCompat::Mistral => {
strip_kigi_private_message_fields(body);
strip_stream_options(body);
}
}
}
/// Mistral's strict Pydantic validator 422-rejects `stream_options`
/// (`extra_forbidden` on `stream_options.include_usage`; its request model
/// has no such field). kigi injects `stream_options.include_usage` on every
/// streaming request for the other providers, so strip the whole object for
/// Mistral. Streaming usage falls back to token estimation (as for any
/// provider that omits streaming usage).
fn strip_stream_options(body: &mut Value) {
if let Some(obj) = body.as_object_mut() {
obj.remove("stream_options");
}
}
@@ -393,6 +409,38 @@ mod tests {
assert_eq!(body["messages"][0].get("model_id"), None);
}
#[test]
fn mistral_dialect_strips_stream_options_and_private_fields() {
use kigi_sampling_types::ChatCompat;
// Mistral 422s on stream_options (extra_forbidden) and doesn't know
// kigi's private message fields; OpenAI-style reasoning_effort stays.
let mut body = json!({
"model": "mistral-medium-latest",
"reasoning_effort": "high",
"stream": true,
"stream_options": { "include_usage": true },
"messages": [
{ "role": "assistant", "content": "hi",
"reasoning_content": "internal", "model_id": "kigi/x" }
]
});
adapt_chat_completions_body_for(ChatCompat::Mistral, &mut body);
assert_eq!(
body.get("stream_options"),
None,
"stream_options must be stripped"
);
assert_eq!(body["stream"], json!(true), "stream flag stays");
assert_eq!(
body["reasoning_effort"],
json!("high"),
"OpenAI-style effort passes through (Mistral accepts it natively)"
);
assert_eq!(body["messages"][0].get("reasoning_content"), None);
assert_eq!(body["messages"][0].get("model_id"), None);
assert_eq!(body["messages"][0]["content"], json!("hi"));
}
#[test]
fn passthrough_dialect_leaves_openai_body_verbatim() {
use kigi_sampling_types::ChatCompat;
@@ -517,6 +517,85 @@ mod tests {
}
}
/// Deserialize a full chunk from a JSON `delta` so it flows through the
/// `#[serde(from = "RawChatChunkDelta")]` content-split path (Mistral
/// sends array `content`).
fn chunk_from_delta(delta: serde_json::Value) -> ChatCompletionChunk {
serde_json::from_value(serde_json::json!({
"id": "c",
"object": "chat.completion.chunk",
"created": 0,
"model": "mistral-medium-latest",
"choices": [{ "index": 0, "delta": delta, "finish_reason": null }],
}))
.expect("mistral chunk deserializes")
}
/// End-to-end: a Mistral reasoning stream (array `content` thinking
/// deltas → a transition delta carrying both a thinking and a text chunk
/// → plain-string answer deltas) is split by deserialization and consumed
/// into the SAME reasoning-sibling + assistant-answer result the
/// `reasoning_content` string path produces. Closes the array-path
/// integration gap.
#[tokio::test]
async fn mistral_reasoning_array_stream_splits_into_reasoning_and_answer() {
let chunks: Vec<Result<ChatCompletionChunk, SamplingError>> = vec![
Ok(chunk_from_delta(serde_json::json!({ "content": [
{ "type": "thinking",
"thinking": [{ "type": "text", "text": "Let me think. " }] }
]}))),
Ok(chunk_from_delta(serde_json::json!({ "content": [
{ "type": "thinking",
"thinking": [{ "type": "text", "text": "It's 22." }] }
]}))),
// Transition: one array with a closing thinking chunk AND the
// first answer text chunk.
Ok(chunk_from_delta(serde_json::json!({ "content": [
{ "type": "thinking", "thinking": [{ "type": "text", "text": " Done." }] },
{ "type": "text", "text": "Answer: " }
]}))),
// Answer phase: plain-string deltas (no longer arrays).
Ok(chunk_from_delta(serde_json::json!({ "content": "22." }))),
Ok(final_chunk(FinishReason::Stop)),
];
let raw = stream::iter(chunks).boxed();
let events = collect(stream_chat_completions(
raw,
None,
rid(),
Duration::from_secs(60),
))
.await;
// Channel tokens: thinking rode the Reasoning channel, answer the Text
// channel — never crossed.
let mut reasoning = String::new();
let mut answer = String::new();
for e in &events {
if let SamplingEvent::ChannelToken { channel, text, .. } = e {
match channel {
SamplingChannel::Reasoning => reasoning.push_str(text),
SamplingChannel::Text => answer.push_str(text),
}
}
}
assert_eq!(reasoning, "Let me think. It's 22. Done.");
assert_eq!(answer, "Answer: 22.");
// The accumulated final response carries the same split.
match events.last().unwrap() {
SamplingEvent::Completed { response, .. } => {
let r = response
.reasoning_items()
.next()
.expect("array thinking became a reasoning sibling");
let rs::SummaryPart::SummaryText(t) = &r.summary[0];
assert_eq!(t.text, "Let me think. It's 22. Done.");
}
other => panic!("expected Completed, got {other:?}"),
}
}
#[tokio::test]
async fn tool_call_stream_emits_deltas_and_assembles_final_call() {
// First chunk has id + name + part of arguments.