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;
|
||||
|
||||
Reference in New Issue
Block a user