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:
2026-07-21 07:21:05 -04:00
parent b86722f508
commit 7efb4b07cc
30 changed files with 423 additions and 11 deletions
@@ -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,
+14 -2
View File
@@ -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