Add DeepSeek platform + ChatCompletions dialect system (provider 3)
The 6th registry row: id "deepseek", DEEPSEEK_API_KEY > auth.json "deepseek" scope, base https://api.deepseek.com (chat rides {base}/chat/completions per official docs) with KIGI_DEEPSEEK_BASE_URL override, enrichment-backed metadata (1M context, 384k output cap, high/max effort menu). Structural fix the cycle exposed: kigi's Kimi-specific body adaptation ran UNCONDITIONALLY on every ChatCompletions request. New ChatCompat dialect, declared per platform row and threaded through SamplerConfig, ClientDefaults, and the session-persisted SamplingConfig (serde-default Kimi keeps restored pre-field sessions and BYOK endpoints byte-identical; production persist seams copy it; subagents inherit it): - Kimi: full legacy pipeline (dispatch ≡ legacy pinned) - DeepSeek: thinking:{type, reasoning_effort} per api-docs.deepseek.com (server maps low/medium→high, xhigh→max itself; none disables; absent leaves the server default) - Passthrough: OpenAI-style reasoning_effort scalar untouched (unblocks Groq and the rest of the OpenAI-compatible list) Review-confirmed release blocker fixed: kigi replays Kimi's reasoning_content (and its private model_id) on input assistant messages — Kimi consumes these, but DeepSeek documents input reasoning_content as prefix-mode-only (historically a 400) and other providers don't know either field. The DeepSeek and Passthrough arms now strip both; Kimi's own pipeline is untouched. Pinned on both message shapes.
This commit is contained in:
@@ -89,6 +89,7 @@ mod tests {
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
force_http1: false,
|
||||
|
||||
@@ -270,6 +270,7 @@ struct ClientDefaults {
|
||||
top_p: Option<f32>,
|
||||
api_backend: ApiBackend,
|
||||
auth_scheme: AuthScheme,
|
||||
chat_compat: kigi_sampling_types::ChatCompat,
|
||||
stream_tool_calls: bool,
|
||||
doom_loop_recovery: Option<kigi_sampling_types::DoomLoopRecoveryPolicy>,
|
||||
}
|
||||
@@ -456,6 +457,7 @@ impl SamplingClient {
|
||||
top_p: config.top_p,
|
||||
api_backend: config.api_backend,
|
||||
auth_scheme: config.auth_scheme,
|
||||
chat_compat: config.chat_compat,
|
||||
stream_tool_calls: config.stream_tool_calls,
|
||||
doom_loop_recovery: config.doom_loop_recovery,
|
||||
};
|
||||
@@ -775,7 +777,10 @@ impl SamplingClient {
|
||||
tracing::error!("Failed to serialize chat/completions request: {}", e);
|
||||
SamplingError::Serialization(e)
|
||||
})?;
|
||||
crate::kimi_compat::adapt_chat_completions_body(&mut request_body);
|
||||
crate::kimi_compat::adapt_chat_completions_body_for(
|
||||
self.defaults.chat_compat,
|
||||
&mut request_body,
|
||||
);
|
||||
|
||||
let http_request = self
|
||||
.post(self.endpoint("chat/completions"))
|
||||
@@ -829,7 +834,10 @@ impl SamplingClient {
|
||||
tracing::error!("Failed to serialize chat/completions request: {}", e);
|
||||
SamplingError::Serialization(e)
|
||||
})?;
|
||||
crate::kimi_compat::adapt_chat_completions_body(&mut request_body);
|
||||
crate::kimi_compat::adapt_chat_completions_body_for(
|
||||
self.defaults.chat_compat,
|
||||
&mut request_body,
|
||||
);
|
||||
|
||||
let http_request = self
|
||||
.post(self.endpoint("chat/completions"))
|
||||
@@ -1880,6 +1888,7 @@ mod tests {
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 8192,
|
||||
force_http1: false,
|
||||
@@ -2076,6 +2085,7 @@ mod tests {
|
||||
api_key: Some("bearer-key-abc123".to_string()),
|
||||
api_backend: ApiBackend::Messages,
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
chat_compat: Default::default(),
|
||||
..minimal_config()
|
||||
};
|
||||
let client = SamplingClient::new(cfg).expect("client should build");
|
||||
@@ -2242,6 +2252,7 @@ mod tests {
|
||||
api_key: Some("stale-bearer".to_string()),
|
||||
api_backend: ApiBackend::Messages,
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
chat_compat: Default::default(),
|
||||
bearer_resolver: Some(std::sync::Arc::new(StaticBearerResolver("fresh-bearer"))),
|
||||
..minimal_config()
|
||||
};
|
||||
@@ -2270,6 +2281,7 @@ mod tests {
|
||||
api_key: Some("stale-bearer".to_string()),
|
||||
api_backend: ApiBackend::Responses,
|
||||
auth_scheme: AuthScheme::Bearer,
|
||||
chat_compat: Default::default(),
|
||||
bearer_resolver: Some(std::sync::Arc::new(StaticBearerResolver("fresh-bearer"))),
|
||||
..minimal_config()
|
||||
};
|
||||
|
||||
@@ -70,6 +70,11 @@ pub struct SamplerConfig {
|
||||
|
||||
// Reasoning effort
|
||||
pub reasoning_effort: Option<ReasoningEffort>,
|
||||
/// ChatCompletions body-adaptation dialect (per-platform; BYOK/custom
|
||||
/// endpoints default to the historical Kimi behavior; lenient default on
|
||||
/// deserialize so persisted configs from before the field parse).
|
||||
#[serde(default)]
|
||||
pub chat_compat: kigi_sampling_types::ChatCompat,
|
||||
|
||||
/// Client identity for the User-Agent header (`kigi/{version}` plus an
|
||||
/// optional origin product). The old xAI proxy's identity headers
|
||||
@@ -135,6 +140,7 @@ impl Default for SamplerConfig {
|
||||
model: String::new(),
|
||||
max_completion_tokens: None,
|
||||
temperature: None,
|
||||
chat_compat: kigi_sampling_types::ChatCompat::default(),
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::default(),
|
||||
auth_scheme: AuthScheme::default(),
|
||||
|
||||
@@ -29,6 +29,73 @@ pub(crate) fn adapt_chat_completions_body(body: &mut Value) {
|
||||
adapt_tool_schemas(body);
|
||||
}
|
||||
|
||||
/// Dialect-dispatched body adaptation. Kimi keeps the full historical
|
||||
/// pipeline (thinking + message hygiene + schema normalization — all built
|
||||
/// for the Kimi wire's strictness); DeepSeek differs ONLY in how thinking
|
||||
/// rides the body; Passthrough providers take OpenAI-style bodies verbatim
|
||||
/// (their `reasoning_effort` scalar is already the wire form).
|
||||
pub(crate) fn adapt_chat_completions_body_for(
|
||||
compat: kigi_sampling_types::ChatCompat,
|
||||
body: &mut Value,
|
||||
) {
|
||||
match compat {
|
||||
kigi_sampling_types::ChatCompat::Kimi => adapt_chat_completions_body(body),
|
||||
kigi_sampling_types::ChatCompat::DeepSeek => {
|
||||
adapt_thinking_deepseek(body);
|
||||
strip_kigi_private_message_fields(body);
|
||||
}
|
||||
kigi_sampling_types::ChatCompat::Passthrough => {
|
||||
strip_kigi_private_message_fields(body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove kigi-internal history artifacts from input messages before they
|
||||
/// reach a non-Kimi wire. `reasoning_content` is Kimi's replayed-thinking
|
||||
/// field (Kimi consumes it; DeepSeek documents it as prefix-mode-only and
|
||||
/// historically 400s on it; other providers don't know it) and `model_id`
|
||||
/// is kigi's private per-message provenance. Kimi's own pipeline handles
|
||||
/// these in `adapt_messages`.
|
||||
fn strip_kigi_private_message_fields(body: &mut Value) {
|
||||
let Some(messages) = body.get_mut("messages").and_then(|m| m.as_array_mut()) else {
|
||||
return;
|
||||
};
|
||||
for message in messages {
|
||||
if let Some(obj) = message.as_object_mut() {
|
||||
obj.remove("reasoning_content");
|
||||
obj.remove("model_id");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// DeepSeek spells the thinking control `thinking:{type, reasoning_effort}`
|
||||
/// (api-docs.deepseek.com create-chat-completion; the server maps
|
||||
/// low/medium→high and xhigh→max itself, so the canonical level passes
|
||||
/// through verbatim). `none` disables thinking; absent leaves the server
|
||||
/// default (enabled).
|
||||
fn adapt_thinking_deepseek(body: &mut Value) {
|
||||
let Some(obj) = body.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
let Some(effort) = obj.remove("reasoning_effort") else {
|
||||
return;
|
||||
};
|
||||
let Some(level) = effort.as_str().map(str::to_owned) else {
|
||||
return;
|
||||
};
|
||||
if level == "none" {
|
||||
obj.insert(
|
||||
"thinking".to_string(),
|
||||
serde_json::json!({ "type": "disabled" }),
|
||||
);
|
||||
} else {
|
||||
obj.insert(
|
||||
"thinking".to_string(),
|
||||
serde_json::json!({ "type": "enabled", "reasoning_effort": level }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Map the OpenAI-style `reasoning_effort` knob onto Kimi's `thinking`
|
||||
/// request field and drop `reasoning_effort` from the wire.
|
||||
///
|
||||
@@ -280,6 +347,91 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn deepseek_dialect_spells_thinking_reasoning_effort() {
|
||||
use kigi_sampling_types::ChatCompat;
|
||||
// Official docs: thinking:{type, reasoning_effort}; server maps
|
||||
// low/medium→high, xhigh→max itself — levels pass through verbatim.
|
||||
let mut body = json!({ "model": "deepseek-v4-pro", "reasoning_effort": "high" });
|
||||
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
|
||||
assert_eq!(body.get("reasoning_effort"), None);
|
||||
assert_eq!(
|
||||
body["thinking"],
|
||||
json!({ "type": "enabled", "reasoning_effort": "high" })
|
||||
);
|
||||
|
||||
let mut body = json!({ "model": "deepseek-v4-flash", "reasoning_effort": "max" });
|
||||
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
|
||||
assert_eq!(
|
||||
body["thinking"],
|
||||
json!({ "type": "enabled", "reasoning_effort": "max" })
|
||||
);
|
||||
|
||||
// none disables; absent leaves the server default (no thinking key).
|
||||
let mut body = json!({ "reasoning_effort": "none" });
|
||||
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
|
||||
assert_eq!(body["thinking"], json!({ "type": "disabled" }));
|
||||
let mut body = json!({ "model": "deepseek-chat" });
|
||||
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
|
||||
assert_eq!(body.get("thinking"), None);
|
||||
|
||||
// DeepSeek does NOT get kimi's message/tool-schema rewrites (empty
|
||||
// assistant tool-call content survives — DeepSeek's documented
|
||||
// function-calling round-trip uses that shape), but kigi-private
|
||||
// fields are stripped: replayed reasoning_content is prefix-mode-only
|
||||
// on the DeepSeek wire (historically a 400 in input messages).
|
||||
let mut body = json!({
|
||||
"reasoning_effort": "high",
|
||||
"messages": [
|
||||
{ "role": "assistant", "content": "", "tool_calls": [{}],
|
||||
"reasoning_content": "replayed thinking", "model_id": "kigi/x" }
|
||||
]
|
||||
});
|
||||
adapt_chat_completions_body_for(ChatCompat::DeepSeek, &mut body);
|
||||
assert_eq!(body["messages"][0]["content"], json!(""));
|
||||
assert_eq!(body["messages"][0].get("reasoning_content"), None);
|
||||
assert_eq!(body["messages"][0].get("model_id"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_dialect_leaves_openai_body_verbatim() {
|
||||
use kigi_sampling_types::ChatCompat;
|
||||
// Verbatim EXCEPT kigi-private history artifacts, which no non-Kimi
|
||||
// wire understands.
|
||||
let mut body = json!({
|
||||
"model": "gpt-oss",
|
||||
"reasoning_effort": "high",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "hi" },
|
||||
{ "role": "assistant", "content": "yo",
|
||||
"reasoning_content": "internal", "model_id": "kigi/x" }
|
||||
]
|
||||
});
|
||||
adapt_chat_completions_body_for(ChatCompat::Passthrough, &mut body);
|
||||
assert_eq!(
|
||||
body,
|
||||
json!({
|
||||
"model": "gpt-oss",
|
||||
"reasoning_effort": "high",
|
||||
"messages": [
|
||||
{ "role": "user", "content": "hi" },
|
||||
{ "role": "assistant", "content": "yo" }
|
||||
]
|
||||
}),
|
||||
"reasoning_effort stays OpenAI-style; private fields are stripped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kimi_dialect_dispatch_matches_legacy_pipeline() {
|
||||
use kigi_sampling_types::ChatCompat;
|
||||
let mut via_dispatch = json!({ "model": "k3", "reasoning_effort": "max" });
|
||||
adapt_chat_completions_body_for(ChatCompat::Kimi, &mut via_dispatch);
|
||||
let mut via_legacy = json!({ "model": "k3", "reasoning_effort": "max" });
|
||||
adapt_chat_completions_body(&mut via_legacy);
|
||||
assert_eq!(via_dispatch, via_legacy, "Kimi dispatch = legacy pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_maps_to_kimi_thinking_field() {
|
||||
// Level rides along as thinking.effort (live wire: 200 with
|
||||
|
||||
Reference in New Issue
Block a user