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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user