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,
|
||||
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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -487,7 +487,69 @@ pub enum FinishReason {
|
||||
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)]
|
||||
#[serde(from = "RawChatResponseMessage")]
|
||||
pub struct ChatResponseMessage {
|
||||
pub role: Role,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -502,6 +564,38 @@ pub struct ChatResponseMessage {
|
||||
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)]
|
||||
pub struct ToolCallResponse {
|
||||
pub id: String,
|
||||
@@ -649,6 +743,7 @@ pub struct ToolCallFunctionDelta {
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
#[serde(from = "RawChatChunkDelta")]
|
||||
pub struct ChatChunkDelta {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub role: Option<Role>,
|
||||
@@ -656,16 +751,43 @@ pub struct ChatChunkDelta {
|
||||
pub content: Option<String>,
|
||||
pub reasoning_content: Option<String>,
|
||||
/// Tool call deltas. Handles `null` in JSON as empty vec.
|
||||
#[serde(
|
||||
default,
|
||||
skip_serializing_if = "Vec::is_empty",
|
||||
deserialize_with = "deserialize_null_default"
|
||||
)]
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub tool_calls: Vec<ToolCallDelta>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct SearchParameters {
|
||||
@@ -956,6 +1078,11 @@ pub enum ChatCompat {
|
||||
DeepSeek,
|
||||
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
||||
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";
|
||||
@@ -1319,6 +1446,98 @@ mod tests {
|
||||
use super::*;
|
||||
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]
|
||||
fn reasoning_effort_serde_lowercase_round_trip() {
|
||||
for v in [
|
||||
|
||||
@@ -595,7 +595,8 @@ mod tests {
|
||||
"openai",
|
||||
"anthropic",
|
||||
"deepseek",
|
||||
"groq"
|
||||
"groq",
|
||||
"mistral"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||
@@ -625,7 +626,8 @@ mod tests {
|
||||
"openai",
|
||||
"anthropic",
|
||||
"deepseek",
|
||||
"groq"
|
||||
"groq",
|
||||
"mistral"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -648,7 +650,8 @@ mod tests {
|
||||
"openai",
|
||||
"anthropic",
|
||||
"deepseek",
|
||||
"groq"
|
||||
"groq",
|
||||
"mistral"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -674,7 +677,8 @@ mod tests {
|
||||
"openai",
|
||||
"anthropic",
|
||||
"deepseek",
|
||||
"groq"
|
||||
"groq",
|
||||
"mistral"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), None);
|
||||
|
||||
@@ -4074,6 +4074,7 @@ pub fn sampling_config_for_model(
|
||||
kigi_models::PlatformChatCompat::Passthrough => {
|
||||
kigi_sampling_types::ChatCompat::Passthrough
|
||||
}
|
||||
kigi_models::PlatformChatCompat::Mistral => kigi_sampling_types::ChatCompat::Mistral,
|
||||
})
|
||||
.unwrap_or_default();
|
||||
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]
|
||||
fn get_env_keys_parses_strings_and_rejects_non_strings() {
|
||||
use crate::agent::config::EnvKeys;
|
||||
|
||||
@@ -6894,7 +6894,7 @@ pub(crate) mod tests {
|
||||
#[test]
|
||||
fn pending_menu_items_lists_interactive_methods_plus_quit() {
|
||||
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!(
|
||||
matches!(&items[0], PendingMenuItem::Login { label } if label == "Kimi Code (OAuth)"),
|
||||
"row 0 must be the OAuth login, got {:?}",
|
||||
@@ -6943,7 +6943,14 @@ pub(crate) mod tests {
|
||||
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.
|
||||
let byok = kigi_shell::agent::auth_method::build_auth_methods(
|
||||
kigi_shell::agent::auth_method::AuthMethodsBuildInputs {
|
||||
@@ -6954,7 +6961,7 @@ pub(crate) mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
pending_menu_items(&byok.methods, None).len(),
|
||||
8,
|
||||
9,
|
||||
"xai.api_key / cached_token must not add rows"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user