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:
@@ -70,6 +70,7 @@ pub enum PlatformChatCompat {
|
|||||||
Kimi,
|
Kimi,
|
||||||
DeepSeek,
|
DeepSeek,
|
||||||
Passthrough,
|
Passthrough,
|
||||||
|
Mistral,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How a platform's API key rides requests (listing, validation, inference).
|
/// How a platform's API key rides requests (listing, validation, inference).
|
||||||
@@ -316,6 +317,35 @@ const GROQ_SPEC: PlatformSpec = PlatformSpec {
|
|||||||
restrict_to_enriched: true,
|
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
|
/// The platform registry. Platforms are compiled-in spec rows; there is no
|
||||||
/// dynamic provider registration (PRD F2).
|
/// dynamic provider registration (PRD F2).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||||
@@ -334,12 +364,14 @@ pub enum PlatformId {
|
|||||||
DeepSeek,
|
DeepSeek,
|
||||||
/// Groq platform API (API key, OpenAI-compatible ChatCompletions).
|
/// Groq platform API (API key, OpenAI-compatible ChatCompletions).
|
||||||
Groq,
|
Groq,
|
||||||
|
/// Mistral platform API (API key, OpenAI-compatible ChatCompletions).
|
||||||
|
Mistral,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PlatformId {
|
impl PlatformId {
|
||||||
/// All platforms, in catalog precedence order: the subscription channel
|
/// All platforms, in catalog precedence order: the subscription channel
|
||||||
/// first so "default model = first list item" favors it when present.
|
/// first so "default model = first list item" favors it when present.
|
||||||
pub const ALL: [PlatformId; 7] = [
|
pub const ALL: [PlatformId; 8] = [
|
||||||
Self::KimiCode,
|
Self::KimiCode,
|
||||||
Self::MoonshotCn,
|
Self::MoonshotCn,
|
||||||
Self::MoonshotAi,
|
Self::MoonshotAi,
|
||||||
@@ -347,6 +379,7 @@ impl PlatformId {
|
|||||||
Self::Anthropic,
|
Self::Anthropic,
|
||||||
Self::DeepSeek,
|
Self::DeepSeek,
|
||||||
Self::Groq,
|
Self::Groq,
|
||||||
|
Self::Mistral,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The registry row backing this platform (single source of per-platform
|
/// The registry row backing this platform (single source of per-platform
|
||||||
@@ -360,6 +393,7 @@ impl PlatformId {
|
|||||||
Self::Anthropic => &ANTHROPIC_SPEC,
|
Self::Anthropic => &ANTHROPIC_SPEC,
|
||||||
Self::DeepSeek => &DEEPSEEK_SPEC,
|
Self::DeepSeek => &DEEPSEEK_SPEC,
|
||||||
Self::Groq => &GROQ_SPEC,
|
Self::Groq => &GROQ_SPEC,
|
||||||
|
Self::Mistral => &MISTRAL_SPEC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,9 +1051,10 @@ mod tests {
|
|||||||
PlatformId::Anthropic => 4,
|
PlatformId::Anthropic => 4,
|
||||||
PlatformId::DeepSeek => 5,
|
PlatformId::DeepSeek => 5,
|
||||||
PlatformId::Groq => 6,
|
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();
|
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
|
||||||
seen.sort_unstable();
|
seen.sort_unstable();
|
||||||
seen.dedup();
|
seen.dedup();
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ pub(crate) fn adapt_chat_completions_body_for(
|
|||||||
kigi_sampling_types::ChatCompat::Passthrough => {
|
kigi_sampling_types::ChatCompat::Passthrough => {
|
||||||
strip_kigi_private_message_fields(body);
|
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);
|
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]
|
#[test]
|
||||||
fn passthrough_dialect_leaves_openai_body_verbatim() {
|
fn passthrough_dialect_leaves_openai_body_verbatim() {
|
||||||
use kigi_sampling_types::ChatCompat;
|
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]
|
#[tokio::test]
|
||||||
async fn tool_call_stream_emits_deltas_and_assembles_final_call() {
|
async fn tool_call_stream_emits_deltas_and_assembles_final_call() {
|
||||||
// First chunk has id + name + part of arguments.
|
// First chunk has id + name + part of arguments.
|
||||||
|
|||||||
@@ -487,7 +487,69 @@ pub enum FinishReason {
|
|||||||
FunctionCall,
|
FunctionCall,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Wire `content` field on a chat response/delta. OpenAI-compatible providers
|
||||||
|
/// send a plain string, but reasoning providers (Mistral) send an ARRAY of
|
||||||
|
/// typed chunks: `{"type":"text","text":".."}` for the answer and
|
||||||
|
/// `{"type":"thinking","thinking":[{"type":"text","text":".."}],...}` for the
|
||||||
|
/// chain of thought (verified against the mistralai/client-python SDK models).
|
||||||
|
/// The chunk union is OPEN — unknown chunk types are ignored, never fatal.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum WireContent {
|
||||||
|
Text(String),
|
||||||
|
Chunks(Vec<Value>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a chunk's `text` string field into `dst` when present (defensive: a
|
||||||
|
/// reference/tool-reference/unknown chunk simply has no `text`).
|
||||||
|
fn push_chunk_text(dst: &mut String, chunk: &Value) {
|
||||||
|
if let Some(t) = chunk.get("text").and_then(Value::as_str) {
|
||||||
|
dst.push_str(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty(s: String) -> Option<String> {
|
||||||
|
if s.is_empty() { None } else { Some(s) }
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WireContent {
|
||||||
|
/// Split into `(visible answer text, thinking text)`. A plain string is
|
||||||
|
/// the answer with no thinking; an array routes `text` chunks to the
|
||||||
|
/// answer and the nested `text` of `thinking` chunks to the reasoning
|
||||||
|
/// channel. Byte-identical for string content (the only shape non-Mistral
|
||||||
|
/// providers ever send).
|
||||||
|
fn split(self) -> (Option<String>, Option<String>) {
|
||||||
|
match self {
|
||||||
|
// Preserve string content verbatim (incl. empty) — byte-identical
|
||||||
|
// to the pre-change `content: Option<String>` for every provider
|
||||||
|
// that sends a string.
|
||||||
|
WireContent::Text(s) => (Some(s), None),
|
||||||
|
WireContent::Chunks(chunks) => {
|
||||||
|
let mut answer = String::new();
|
||||||
|
let mut thinking = String::new();
|
||||||
|
for c in &chunks {
|
||||||
|
match c.get("type").and_then(Value::as_str) {
|
||||||
|
Some("text") => push_chunk_text(&mut answer, c),
|
||||||
|
Some("thinking") => {
|
||||||
|
// `thinking` is a nested list of chunks.
|
||||||
|
if let Some(inner) = c.get("thinking").and_then(Value::as_array) {
|
||||||
|
for ic in inner {
|
||||||
|
push_chunk_text(&mut thinking, ic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// reference / tool_reference / unknown → not text.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(non_empty(answer), non_empty(thinking))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
#[serde(from = "RawChatResponseMessage")]
|
||||||
pub struct ChatResponseMessage {
|
pub struct ChatResponseMessage {
|
||||||
pub role: Role,
|
pub role: Role,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
@@ -502,6 +564,38 @@ pub struct ChatResponseMessage {
|
|||||||
pub citations: Option<Vec<String>>,
|
pub citations: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deserialization mirror of [`ChatResponseMessage`] that accepts array
|
||||||
|
/// `content` and folds thinking chunks into `reasoning_content`.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawChatResponseMessage {
|
||||||
|
role: Role,
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<WireContent>,
|
||||||
|
#[serde(default)]
|
||||||
|
reasoning_content: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tool_calls: Vec<ToolCallResponse>,
|
||||||
|
#[serde(default)]
|
||||||
|
tool_call_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
citations: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RawChatResponseMessage> for ChatResponseMessage {
|
||||||
|
fn from(r: RawChatResponseMessage) -> Self {
|
||||||
|
let (answer, thinking) = r.content.map(WireContent::split).unwrap_or((None, None));
|
||||||
|
ChatResponseMessage {
|
||||||
|
role: r.role,
|
||||||
|
content: answer,
|
||||||
|
// The wire's own `reasoning_content` wins; else thinking chunks.
|
||||||
|
reasoning_content: r.reasoning_content.or(thinking),
|
||||||
|
tool_calls: r.tool_calls,
|
||||||
|
tool_call_id: r.tool_call_id,
|
||||||
|
citations: r.citations,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct ToolCallResponse {
|
pub struct ToolCallResponse {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
@@ -649,6 +743,7 @@ pub struct ToolCallFunctionDelta {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||||
|
#[serde(from = "RawChatChunkDelta")]
|
||||||
pub struct ChatChunkDelta {
|
pub struct ChatChunkDelta {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub role: Option<Role>,
|
pub role: Option<Role>,
|
||||||
@@ -656,16 +751,43 @@ pub struct ChatChunkDelta {
|
|||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
pub reasoning_content: Option<String>,
|
pub reasoning_content: Option<String>,
|
||||||
/// Tool call deltas. Handles `null` in JSON as empty vec.
|
/// Tool call deltas. Handles `null` in JSON as empty vec.
|
||||||
#[serde(
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
default,
|
|
||||||
skip_serializing_if = "Vec::is_empty",
|
|
||||||
deserialize_with = "deserialize_null_default"
|
|
||||||
)]
|
|
||||||
pub tool_calls: Vec<ToolCallDelta>,
|
pub tool_calls: Vec<ToolCallDelta>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub tool_call_id: Option<String>,
|
pub tool_call_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deserialization mirror of [`ChatChunkDelta`] that accepts array `content`
|
||||||
|
/// (Mistral reasoning streams a thinking-chunk array during the thinking
|
||||||
|
/// phase, then plain-string answer deltas) and folds thinking into
|
||||||
|
/// `reasoning_content`.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RawChatChunkDelta {
|
||||||
|
#[serde(default)]
|
||||||
|
role: Option<Role>,
|
||||||
|
#[serde(default)]
|
||||||
|
content: Option<WireContent>,
|
||||||
|
#[serde(default)]
|
||||||
|
reasoning_content: Option<String>,
|
||||||
|
#[serde(default, deserialize_with = "deserialize_null_default")]
|
||||||
|
tool_calls: Vec<ToolCallDelta>,
|
||||||
|
#[serde(default)]
|
||||||
|
tool_call_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<RawChatChunkDelta> for ChatChunkDelta {
|
||||||
|
fn from(r: RawChatChunkDelta) -> Self {
|
||||||
|
let (answer, thinking) = r.content.map(WireContent::split).unwrap_or((None, None));
|
||||||
|
ChatChunkDelta {
|
||||||
|
role: r.role,
|
||||||
|
content: answer,
|
||||||
|
reasoning_content: r.reasoning_content.or(thinking),
|
||||||
|
tool_calls: r.tool_calls,
|
||||||
|
tool_call_id: r.tool_call_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Parameters to control realtime data.
|
/// Parameters to control realtime data.
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
pub struct SearchParameters {
|
pub struct SearchParameters {
|
||||||
@@ -956,6 +1078,11 @@ pub enum ChatCompat {
|
|||||||
DeepSeek,
|
DeepSeek,
|
||||||
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
||||||
Passthrough,
|
Passthrough,
|
||||||
|
/// Mistral wire: OpenAI-style `reasoning_effort` passes through, but the
|
||||||
|
/// strict Pydantic validator 422-rejects `stream_options` (the SDK's
|
||||||
|
/// request model has no such field), so it must be stripped. Also strips
|
||||||
|
/// the kigi-private message fields like Passthrough.
|
||||||
|
Mistral,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
|
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
|
||||||
@@ -1319,6 +1446,98 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// String content (the only shape non-Mistral providers send) stays the
|
||||||
|
/// answer verbatim with no thinking — byte-identical to the pre-change
|
||||||
|
/// deserialization.
|
||||||
|
#[test]
|
||||||
|
fn chunk_delta_string_content_unchanged() {
|
||||||
|
let delta: ChatChunkDelta =
|
||||||
|
serde_json::from_value(json!({ "content": "hello world" })).unwrap();
|
||||||
|
assert_eq!(delta.content.as_deref(), Some("hello world"));
|
||||||
|
assert_eq!(delta.reasoning_content, None);
|
||||||
|
// A DeepSeek-style wire reasoning_content still rides its own field.
|
||||||
|
let delta: ChatChunkDelta =
|
||||||
|
serde_json::from_value(json!({ "content": "hi", "reasoning_content": "because" }))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delta.content.as_deref(), Some("hi"));
|
||||||
|
assert_eq!(delta.reasoning_content.as_deref(), Some("because"));
|
||||||
|
// Null / absent content → None.
|
||||||
|
let delta: ChatChunkDelta = serde_json::from_value(json!({})).unwrap();
|
||||||
|
assert_eq!(delta.content, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mistral reasoning: array content routes `text` chunks to the answer
|
||||||
|
/// and the nested `text` of `thinking` chunks to reasoning_content
|
||||||
|
/// (verified shapes from the mistralai/client-python SDK).
|
||||||
|
#[test]
|
||||||
|
fn chunk_delta_array_content_splits_thinking_and_answer() {
|
||||||
|
// Thinking phase: array with only a thinking chunk (nested list).
|
||||||
|
let delta: ChatChunkDelta = serde_json::from_value(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "thinking", "thinking": [{ "type": "text", "text": "step 1" }],
|
||||||
|
"signature": null, "closed": false }
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delta.content, None, "thinking phase has no visible answer");
|
||||||
|
assert_eq!(delta.reasoning_content.as_deref(), Some("step 1"));
|
||||||
|
|
||||||
|
// Answer phase: plain string delta.
|
||||||
|
let delta: ChatChunkDelta =
|
||||||
|
serde_json::from_value(json!({ "content": "the answer" })).unwrap();
|
||||||
|
assert_eq!(delta.content.as_deref(), Some("the answer"));
|
||||||
|
assert_eq!(delta.reasoning_content, None);
|
||||||
|
|
||||||
|
// Transition delta: one array carrying both a closing thinking chunk
|
||||||
|
// and the first text chunk → both channels populated.
|
||||||
|
let delta: ChatChunkDelta = serde_json::from_value(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "thinking", "thinking": [{ "type": "text", "text": "done" }] },
|
||||||
|
{ "type": "text", "text": "Answer: 22" }
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delta.content.as_deref(), Some("Answer: 22"));
|
||||||
|
assert_eq!(delta.reasoning_content.as_deref(), Some("done"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chunk union is OPEN: unknown chunk types and reference chunks are
|
||||||
|
/// ignored, never fatal (SDK's UnknownContentChunk fallback).
|
||||||
|
#[test]
|
||||||
|
fn content_array_tolerates_unknown_chunk_types() {
|
||||||
|
let delta: ChatChunkDelta = serde_json::from_value(json!({
|
||||||
|
"content": [
|
||||||
|
{ "type": "reference", "reference_ids": [1, 2] },
|
||||||
|
{ "type": "some_future_chunk", "payload": { "x": 1 } },
|
||||||
|
{ "type": "text", "text": "visible" }
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(delta.content.as_deref(), Some("visible"));
|
||||||
|
assert_eq!(delta.reasoning_content, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Non-streaming ChatResponseMessage gets the same array handling.
|
||||||
|
#[test]
|
||||||
|
fn response_message_array_content_splits() {
|
||||||
|
let msg: ChatResponseMessage = serde_json::from_value(json!({
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [
|
||||||
|
{ "type": "thinking", "thinking": [{ "type": "text", "text": "reasoning" }] },
|
||||||
|
{ "type": "text", "text": "final" }
|
||||||
|
]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(msg.content.as_deref(), Some("final"));
|
||||||
|
assert_eq!(msg.reasoning_content.as_deref(), Some("reasoning"));
|
||||||
|
// String content still works.
|
||||||
|
let msg: ChatResponseMessage = serde_json::from_value(json!({
|
||||||
|
"role": "assistant", "content": "plain"
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(msg.content.as_deref(), Some("plain"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reasoning_effort_serde_lowercase_round_trip() {
|
fn reasoning_effort_serde_lowercase_round_trip() {
|
||||||
for v in [
|
for v in [
|
||||||
|
|||||||
@@ -595,7 +595,8 @@ mod tests {
|
|||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"groq"
|
"groq",
|
||||||
|
"mistral"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||||
@@ -625,7 +626,8 @@ mod tests {
|
|||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"groq"
|
"groq",
|
||||||
|
"mistral"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -648,7 +650,8 @@ mod tests {
|
|||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"groq"
|
"groq",
|
||||||
|
"mistral"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||||
@@ -674,7 +677,8 @@ mod tests {
|
|||||||
"openai",
|
"openai",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
"deepseek",
|
"deepseek",
|
||||||
"groq"
|
"groq",
|
||||||
|
"mistral"
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(default_id(&built), None);
|
assert_eq!(default_id(&built), None);
|
||||||
|
|||||||
@@ -4074,6 +4074,7 @@ pub fn sampling_config_for_model(
|
|||||||
kigi_models::PlatformChatCompat::Passthrough => {
|
kigi_models::PlatformChatCompat::Passthrough => {
|
||||||
kigi_sampling_types::ChatCompat::Passthrough
|
kigi_sampling_types::ChatCompat::Passthrough
|
||||||
}
|
}
|
||||||
|
kigi_models::PlatformChatCompat::Mistral => kigi_sampling_types::ChatCompat::Mistral,
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
SamplerConfig {
|
SamplerConfig {
|
||||||
|
|||||||
@@ -1206,6 +1206,90 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Mistral-cycle e2e: embed pollution restricted away, and the Mistral
|
||||||
|
/// dialect (strips stream_options, handles reasoning arrays) is mapped.
|
||||||
|
#[tokio::test(flavor = "multi_thread")]
|
||||||
|
#[serial_test::serial]
|
||||||
|
async fn mistral_listing_restricts_and_maps_mistral_dialect() {
|
||||||
|
let platform_server = wiremock::MockServer::start().await;
|
||||||
|
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||||
|
.and(wiremock::matchers::path("/models"))
|
||||||
|
.and(wiremock::matchers::header("Authorization", "Bearer msk-1"))
|
||||||
|
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
|
||||||
|
serde_json::json!({ "data": [
|
||||||
|
{ "id": "devstral-latest", "object": "model" },
|
||||||
|
{ "id": "mistral-embed", "object": "model" }
|
||||||
|
]}),
|
||||||
|
))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&platform_server)
|
||||||
|
.await;
|
||||||
|
let modelsdev_server = wiremock::MockServer::start().await;
|
||||||
|
wiremock::Mock::given(wiremock::matchers::method("GET"))
|
||||||
|
.and(wiremock::matchers::path("/api.json"))
|
||||||
|
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
|
||||||
|
serde_json::json!({ "mistral": { "models": {
|
||||||
|
"devstral-latest": {
|
||||||
|
"limit": {"context": 262144, "output": 65536},
|
||||||
|
"tool_call": true
|
||||||
|
},
|
||||||
|
"mistral-embed": { "limit": {"context": 8000} }
|
||||||
|
}}}),
|
||||||
|
))
|
||||||
|
.expect(1)
|
||||||
|
.mount(&modelsdev_server)
|
||||||
|
.await;
|
||||||
|
let cache_dir = tempfile::tempdir().unwrap();
|
||||||
|
let _base = kigi_test_support::EnvGuard::set(
|
||||||
|
kigi_models::MISTRAL_BASE_URL_ENV,
|
||||||
|
platform_server.uri(),
|
||||||
|
);
|
||||||
|
let _mdev = kigi_test_support::EnvGuard::set(
|
||||||
|
crate::agent::enrichment_fetch::MODELS_DEV_URL_ENV,
|
||||||
|
format!("{}/api.json", modelsdev_server.uri()),
|
||||||
|
);
|
||||||
|
let _mdev_cache = kigi_test_support::EnvGuard::set(
|
||||||
|
crate::agent::enrichment_fetch::MODELS_DEV_CACHE_DIR_ENV,
|
||||||
|
cache_dir.path(),
|
||||||
|
);
|
||||||
|
let endpoints = crate::agent::config::EndpointsConfig::default();
|
||||||
|
let keys = crate::agent::models::PlatformApiKeys::test_single(
|
||||||
|
kigi_models::PlatformId::Mistral,
|
||||||
|
"msk-1",
|
||||||
|
);
|
||||||
|
let result = tokio::task::spawn_blocking(move || {
|
||||||
|
fetch_platform_models_blocking(&endpoints, None, &keys)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.expect("fetch must succeed");
|
||||||
|
assert_eq!(
|
||||||
|
result
|
||||||
|
.models
|
||||||
|
.iter()
|
||||||
|
.map(|m| m.id.as_deref().unwrap_or_default())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["mistral/devstral-latest"],
|
||||||
|
"embed (enrichment-known, not tool-calling) must be dropped"
|
||||||
|
);
|
||||||
|
let entry = &result.models[0];
|
||||||
|
assert_eq!(entry.context_window.get(), 262_144);
|
||||||
|
assert_eq!(entry.max_completion_tokens, Some(65_536));
|
||||||
|
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
|
||||||
|
let creds = crate::agent::config::ResolvedCredentials {
|
||||||
|
api_key: Some("msk-1".into()),
|
||||||
|
base_url: entry.base_url.clone(),
|
||||||
|
auth_type: kigi_chat_state::AuthType::ApiKey,
|
||||||
|
auth_scheme: Default::default(),
|
||||||
|
};
|
||||||
|
let cfg = crate::agent::config::sampling_config_for_model(&model_entry, creds, None);
|
||||||
|
assert_eq!(
|
||||||
|
cfg.chat_compat,
|
||||||
|
kigi_sampling_types::ChatCompat::Mistral,
|
||||||
|
"mistral entries must map to the Mistral dialect (stream_options strip)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_env_keys_parses_strings_and_rejects_non_strings() {
|
fn get_env_keys_parses_strings_and_rejects_non_strings() {
|
||||||
use crate::agent::config::EnvKeys;
|
use crate::agent::config::EnvKeys;
|
||||||
|
|||||||
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||||
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
let items = pending_menu_items(&fresh_user_auth_methods(), None);
|
||||||
assert_eq!(items.len(), 8, "7 login rows + Quit, got {items:?}");
|
assert_eq!(items.len(), 9, "8 login rows + Quit, got {items:?}");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
|
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
|
||||||
"row 0 must be the OAuth login, got {:?}",
|
"row 0 must be the OAuth login, got {:?}",
|
||||||
@@ -6943,7 +6943,14 @@ pub(crate) mod tests {
|
|||||||
label: "Groq (API key)".into(),
|
label: "Groq (API key)".into(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(items[7], PendingMenuItem::Quit);
|
assert_eq!(
|
||||||
|
items[7],
|
||||||
|
PendingMenuItem::ApiKey {
|
||||||
|
target: PlatformLogin(kigi_shell::models::PlatformId::Mistral),
|
||||||
|
label: "Mistral (API key)".into(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(items[8], PendingMenuItem::Quit);
|
||||||
// The non-interactive methods must never appear as rows.
|
// The non-interactive methods must never appear as rows.
|
||||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||||
@@ -6954,7 +6961,7 @@ pub(crate) mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pending_menu_items(&byok.methods, None).len(),
|
pending_menu_items(&byok.methods, None).len(),
|
||||||
8,
|
9,
|
||||||
"xai.api_key / cached_token must not add rows"
|
"xai.api_key / cached_token must not add rows"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user