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
+37 -2
View File
@@ -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.
+224 -5
View File
@@ -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;
+10 -3
View File
@@ -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"
);
}