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:
@@ -272,6 +272,7 @@ mod tests {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
|
||||
@@ -23,6 +23,7 @@ fn test_config_with_window(context_window: u64) -> SamplingConfig {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(context_window)
|
||||
.expect("test context_window must be non-zero"),
|
||||
@@ -913,6 +914,7 @@ async fn update_sampling_config_is_queryable() {
|
||||
temperature: Some(0.5),
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(200_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -1298,6 +1300,7 @@ async fn build_request_uses_sampling_config() {
|
||||
temperature: Some(0.7),
|
||||
top_p: Some(0.9),
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -3401,6 +3404,7 @@ async fn sampling_config_survives_compaction_replacement() {
|
||||
temperature: Some(0.7),
|
||||
top_p: Some(0.95),
|
||||
api_backend: ApiBackend::Responses,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(500_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -3481,6 +3485,7 @@ async fn model_metadata_lost_after_compaction_then_recovered_on_next_turn() {
|
||||
temperature: Some(0.7),
|
||||
top_p: Some(0.95),
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(500_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -3569,6 +3574,7 @@ async fn context_window_downgrade_triggers_auto_compact() {
|
||||
temperature: Some(0.7),
|
||||
top_p: Some(0.95),
|
||||
api_backend: ApiBackend::Responses,
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(500_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
|
||||
@@ -370,6 +370,7 @@ mod tests {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
|
||||
@@ -178,6 +178,7 @@ mod tests {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -221,6 +222,7 @@ mod tests {
|
||||
temperature: Some(0.7),
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(128_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
|
||||
@@ -63,6 +63,15 @@ pub enum ListingDialect {
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
/// ChatCompletions body-adaptation dialect (leaf-safe mirror of the
|
||||
/// sampler's `ChatCompat`; the shell maps it).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlatformChatCompat {
|
||||
Kimi,
|
||||
DeepSeek,
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
/// How a platform's API key rides requests (listing, validation, inference).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PlatformKeyHeader {
|
||||
@@ -125,6 +134,8 @@ struct PlatformSpec {
|
||||
wire_api: PlatformWireApi,
|
||||
/// Model-listing endpoint shape + headers.
|
||||
listing: ListingDialect,
|
||||
/// ChatCompletions body-adaptation dialect (ignored for other backends).
|
||||
chat_compat: PlatformChatCompat,
|
||||
/// Key header style for listing/validation/inference.
|
||||
key_header: PlatformKeyHeader,
|
||||
/// Restrict the live listing to models the enrichment catalog knows —
|
||||
@@ -148,6 +159,7 @@ const KIMI_CODE_SPEC: PlatformSpec = PlatformSpec {
|
||||
wire_serves_metadata: true,
|
||||
wire_api: PlatformWireApi::ChatCompletions,
|
||||
listing: ListingDialect::OpenAi,
|
||||
chat_compat: PlatformChatCompat::Kimi,
|
||||
key_header: PlatformKeyHeader::Bearer,
|
||||
restrict_to_enriched: false,
|
||||
};
|
||||
@@ -169,6 +181,7 @@ const MOONSHOT_CN_SPEC: PlatformSpec = PlatformSpec {
|
||||
wire_serves_metadata: true,
|
||||
wire_api: PlatformWireApi::ChatCompletions,
|
||||
listing: ListingDialect::OpenAi,
|
||||
chat_compat: PlatformChatCompat::Kimi,
|
||||
key_header: PlatformKeyHeader::Bearer,
|
||||
restrict_to_enriched: false,
|
||||
};
|
||||
@@ -190,6 +203,7 @@ const MOONSHOT_AI_SPEC: PlatformSpec = PlatformSpec {
|
||||
wire_serves_metadata: true,
|
||||
wire_api: PlatformWireApi::ChatCompletions,
|
||||
listing: ListingDialect::OpenAi,
|
||||
chat_compat: PlatformChatCompat::Kimi,
|
||||
key_header: PlatformKeyHeader::Bearer,
|
||||
restrict_to_enriched: false,
|
||||
};
|
||||
@@ -216,6 +230,7 @@ const OPENAI_SPEC: PlatformSpec = PlatformSpec {
|
||||
wire_serves_metadata: false,
|
||||
wire_api: PlatformWireApi::Responses,
|
||||
listing: ListingDialect::OpenAi,
|
||||
chat_compat: PlatformChatCompat::Passthrough,
|
||||
key_header: PlatformKeyHeader::Bearer,
|
||||
restrict_to_enriched: true,
|
||||
};
|
||||
@@ -242,10 +257,38 @@ const ANTHROPIC_SPEC: PlatformSpec = PlatformSpec {
|
||||
wire_serves_metadata: false,
|
||||
wire_api: PlatformWireApi::Messages,
|
||||
listing: ListingDialect::Anthropic,
|
||||
chat_compat: PlatformChatCompat::Passthrough,
|
||||
key_header: PlatformKeyHeader::XApiKey,
|
||||
restrict_to_enriched: false,
|
||||
};
|
||||
|
||||
/// Base-URL override for DeepSeek (dev/test escape hatch).
|
||||
pub const DEEPSEEK_BASE_URL_ENV: &str = "KIGI_DEEPSEEK_BASE_URL";
|
||||
|
||||
const DEEPSEEK_SPEC: PlatformSpec = PlatformSpec {
|
||||
id: "deepseek",
|
||||
display_name: "DeepSeek",
|
||||
base_url: BaseUrlSource::EnvOr {
|
||||
env: DEEPSEEK_BASE_URL_ENV,
|
||||
// No /v1: chat rides {base}/chat/completions, listing {base}/models
|
||||
// (official docs).
|
||||
default: "https://api.deepseek.com",
|
||||
},
|
||||
uses_oauth: false,
|
||||
allowed_model_prefixes: None,
|
||||
api_key_envs: &["DEEPSEEK_API_KEY"],
|
||||
vendor: "DeepSeek",
|
||||
console_host: Some("platform.deepseek.com"),
|
||||
login_label: Some("DeepSeek (API key)"),
|
||||
models_dev_id: Some("deepseek"),
|
||||
wire_serves_metadata: false,
|
||||
wire_api: PlatformWireApi::ChatCompletions,
|
||||
listing: ListingDialect::OpenAi,
|
||||
chat_compat: PlatformChatCompat::DeepSeek,
|
||||
key_header: PlatformKeyHeader::Bearer,
|
||||
restrict_to_enriched: false,
|
||||
};
|
||||
|
||||
/// 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)]
|
||||
@@ -260,17 +303,20 @@ pub enum PlatformId {
|
||||
OpenAi,
|
||||
/// Anthropic platform API (API key, Messages dialect).
|
||||
Anthropic,
|
||||
/// DeepSeek platform API (API key, ChatCompletions dialect).
|
||||
DeepSeek,
|
||||
}
|
||||
|
||||
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; 5] = [
|
||||
pub const ALL: [PlatformId; 6] = [
|
||||
Self::KimiCode,
|
||||
Self::MoonshotCn,
|
||||
Self::MoonshotAi,
|
||||
Self::OpenAi,
|
||||
Self::Anthropic,
|
||||
Self::DeepSeek,
|
||||
];
|
||||
|
||||
/// The registry row backing this platform (single source of per-platform
|
||||
@@ -282,6 +328,7 @@ impl PlatformId {
|
||||
Self::MoonshotAi => &MOONSHOT_AI_SPEC,
|
||||
Self::OpenAi => &OPENAI_SPEC,
|
||||
Self::Anthropic => &ANTHROPIC_SPEC,
|
||||
Self::DeepSeek => &DEEPSEEK_SPEC,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -381,6 +428,12 @@ impl PlatformId {
|
||||
pub fn key_header(self) -> PlatformKeyHeader {
|
||||
self.spec().key_header
|
||||
}
|
||||
|
||||
/// ChatCompletions body-adaptation dialect (shell maps to the sampler's
|
||||
/// `ChatCompat`; meaningless for other backends).
|
||||
pub fn chat_compat(self) -> PlatformChatCompat {
|
||||
self.spec().chat_compat
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a managed catalog key `{platform_id}/{model_id}` back into its
|
||||
@@ -931,9 +984,10 @@ mod tests {
|
||||
PlatformId::MoonshotAi => 2,
|
||||
PlatformId::OpenAi => 3,
|
||||
PlatformId::Anthropic => 4,
|
||||
PlatformId::DeepSeek => 5,
|
||||
}
|
||||
}
|
||||
const VARIANT_COUNT: usize = 5; // update together with `ordinal`
|
||||
const VARIANT_COUNT: usize = 6; // update together with `ordinal`
|
||||
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
|
||||
seen.sort_unstable();
|
||||
seen.dedup();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -78,6 +78,7 @@ fn test_config(base_url: String, model: &str) -> SamplerConfig {
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::ChatCompletions,
|
||||
auth_scheme: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: IndexMap::new(),
|
||||
context_window: 128_000,
|
||||
force_http1: false,
|
||||
|
||||
@@ -938,6 +938,26 @@ pub fn normalize_effort_echo(value: &mut Value) {
|
||||
/// wires (Messages inference and the /v1/models listing).
|
||||
pub const ANTHROPIC_VERSION: &str = "2023-06-01";
|
||||
|
||||
/// ChatCompletions request-body adaptation dialect. Providers disagree on
|
||||
/// how thinking rides an OpenAI-compatible body: Kimi wants
|
||||
/// `thinking:{type,effort}`, DeepSeek wants
|
||||
/// `thinking:{type,reasoning_effort}`, most others take the OpenAI-style
|
||||
/// `reasoning_effort` scalar untouched.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ChatCompat {
|
||||
/// Kimi wire (`thinking:{type,effort}`, canonical xhigh spelled max).
|
||||
/// The default: BYOK/custom ChatCompletions endpoints keep the
|
||||
/// historical kigi behavior.
|
||||
#[default]
|
||||
Kimi,
|
||||
/// DeepSeek wire (`thinking:{type,reasoning_effort}`, official docs:
|
||||
/// low/medium map to high and xhigh to max server-side).
|
||||
DeepSeek,
|
||||
/// Leave the body as-is (OpenAI-style `reasoning_effort` passes through).
|
||||
Passthrough,
|
||||
}
|
||||
|
||||
pub const REASONING_EFFORT_META_KEY: &str = "reasoningEffort";
|
||||
pub const SUPPORTS_REASONING_EFFORT_META_KEY: &str = "supportsReasoningEffort";
|
||||
|
||||
@@ -1126,6 +1146,11 @@ pub struct SamplingConfig {
|
||||
pub max_completion_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub top_p: Option<f32>,
|
||||
/// ChatCompletions body-adaptation dialect (per-platform; serde-default
|
||||
/// Kimi keeps pre-field sessions and BYOK endpoints on the historical
|
||||
/// behavior).
|
||||
#[serde(default)]
|
||||
pub chat_compat: ChatCompat,
|
||||
/// Which API backend to use for this model
|
||||
#[serde(default)]
|
||||
pub api_backend: ApiBackend,
|
||||
|
||||
@@ -593,7 +593,8 @@ mod tests {
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
"anthropic"
|
||||
"anthropic",
|
||||
"deepseek"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(XAI_API_KEY_METHOD_ID));
|
||||
@@ -621,7 +622,8 @@ mod tests {
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
"anthropic"
|
||||
"anthropic",
|
||||
"deepseek"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -642,7 +644,8 @@ mod tests {
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
"anthropic"
|
||||
"anthropic",
|
||||
"deepseek"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), Some(CACHED_TOKEN_AUTH_METHOD_ID));
|
||||
@@ -666,7 +669,8 @@ mod tests {
|
||||
MOONSHOT_CN_METHOD_ID,
|
||||
MOONSHOT_AI_METHOD_ID,
|
||||
"openai",
|
||||
"anthropic"
|
||||
"anthropic",
|
||||
"deepseek"
|
||||
]
|
||||
);
|
||||
assert_eq!(default_id(&built), None);
|
||||
|
||||
@@ -4062,6 +4062,20 @@ pub fn sampling_config_for_model(
|
||||
&credentials.base_url,
|
||||
);
|
||||
let api_backend = info.api_backend.clone();
|
||||
// Managed platform entries speak their registry dialect; BYOK/custom
|
||||
// entries keep the historical Kimi body adaptation.
|
||||
let chat_compat = info
|
||||
.id
|
||||
.as_deref()
|
||||
.and_then(kigi_models::parse_managed_model_key)
|
||||
.map(|(platform, _)| match platform.chat_compat() {
|
||||
kigi_models::PlatformChatCompat::Kimi => kigi_sampling_types::ChatCompat::Kimi,
|
||||
kigi_models::PlatformChatCompat::DeepSeek => kigi_sampling_types::ChatCompat::DeepSeek,
|
||||
kigi_models::PlatformChatCompat::Passthrough => {
|
||||
kigi_sampling_types::ChatCompat::Passthrough
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
SamplerConfig {
|
||||
api_key: credentials.api_key,
|
||||
model: model_name,
|
||||
@@ -4071,6 +4085,7 @@ pub fn sampling_config_for_model(
|
||||
top_p,
|
||||
api_backend,
|
||||
auth_scheme: credentials.auth_scheme,
|
||||
chat_compat,
|
||||
extra_headers,
|
||||
context_window: info.context_window.get(),
|
||||
reasoning_effort: info.reasoning_effort,
|
||||
|
||||
@@ -1022,6 +1022,107 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// DeepSeek-cycle e2e: bare OpenAI-shape listing + enrichment efforts
|
||||
/// (high/max) produce ChatCompletions entries whose sampler config
|
||||
/// speaks the DeepSeek thinking dialect.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
#[serial_test::serial]
|
||||
async fn deepseek_listing_enriches_and_maps_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 sk-ds"))
|
||||
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
|
||||
serde_json::json!({ "data": [
|
||||
{ "id": "deepseek-v4-pro", "object": "model", "owned_by": "deepseek" }
|
||||
]}),
|
||||
))
|
||||
.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!({ "deepseek": { "models": { "deepseek-v4-pro": {
|
||||
"reasoning": true,
|
||||
"reasoning_options": [
|
||||
{"type": "toggle"},
|
||||
{"type": "effort", "values": ["high", "max"]}
|
||||
],
|
||||
"limit": {"context": 1000000, "output": 384000},
|
||||
"tool_call": true
|
||||
}}}}),
|
||||
))
|
||||
.expect(1)
|
||||
.mount(&modelsdev_server)
|
||||
.await;
|
||||
let cache_dir = tempfile::tempdir().unwrap();
|
||||
let _base = kigi_test_support::EnvGuard::set(
|
||||
kigi_models::DEEPSEEK_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::DeepSeek,
|
||||
"sk-ds",
|
||||
);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
fetch_platform_models_blocking(&endpoints, None, &keys)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("fetch must succeed");
|
||||
assert_eq!(result.models.len(), 1);
|
||||
let entry = &result.models[0];
|
||||
assert_eq!(entry.id.as_deref(), Some("deepseek/deepseek-v4-pro"));
|
||||
assert_eq!(entry.context_window.get(), 1_000_000);
|
||||
assert_eq!(entry.max_completion_tokens, Some(384_000));
|
||||
assert_eq!(
|
||||
entry.api_backend,
|
||||
crate::sampling::ApiBackend::ChatCompletions
|
||||
);
|
||||
assert_eq!(
|
||||
entry
|
||||
.reasoning_efforts
|
||||
.iter()
|
||||
.map(|o| o.id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["high", "max"]
|
||||
);
|
||||
|
||||
// The managed id maps to the DeepSeek chat dialect; a BYOK entry
|
||||
// (no managed key) keeps the historical Kimi adaptation.
|
||||
let model_entry = crate::agent::config::ModelEntry::from_config_entry(entry);
|
||||
let creds = crate::agent::config::ResolvedCredentials {
|
||||
api_key: Some("sk-ds".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::DeepSeek);
|
||||
let mut byok = entry.clone();
|
||||
byok.id = Some("my-custom".into());
|
||||
let byok_entry = crate::agent::config::ModelEntry::from_config_entry(&byok);
|
||||
let creds = crate::agent::config::ResolvedCredentials {
|
||||
api_key: Some("sk-x".into()),
|
||||
base_url: byok.base_url.clone(),
|
||||
auth_type: kigi_chat_state::AuthType::ApiKey,
|
||||
auth_scheme: Default::default(),
|
||||
};
|
||||
let cfg = crate::agent::config::sampling_config_for_model(&byok_entry, creds, None);
|
||||
assert_eq!(cfg.chat_compat, kigi_sampling_types::ChatCompat::Kimi);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_env_keys_parses_strings_and_rejects_non_strings() {
|
||||
use crate::agent::config::EnvKeys;
|
||||
|
||||
@@ -889,6 +889,7 @@ async fn read_parent_sampling_config(
|
||||
top_p: cfg.top_p,
|
||||
api_backend: cfg.api_backend,
|
||||
auth_scheme,
|
||||
chat_compat: cfg.chat_compat,
|
||||
extra_headers,
|
||||
context_window: cfg.context_window.get(),
|
||||
reasoning_effort: cfg.reasoning_effort,
|
||||
|
||||
@@ -3272,6 +3272,7 @@ fn test_sampling_config(model_slug: &str) -> kigi_sampling_types::SamplingConfig
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: NonZeroU64::new(256_000).expect("non-zero context window"),
|
||||
reasoning_effort: None,
|
||||
|
||||
@@ -53,6 +53,7 @@ impl SessionActor {
|
||||
temperature: sampling_config.temperature,
|
||||
top_p: sampling_config.top_p,
|
||||
api_backend: sampling_config.api_backend.clone(),
|
||||
chat_compat: sampling_config.chat_compat,
|
||||
extra_headers: sampling_config.extra_headers.clone(),
|
||||
context_window: new_context_window,
|
||||
reasoning_effort: sampling_config.reasoning_effort,
|
||||
|
||||
@@ -260,6 +260,7 @@ impl SessionActor {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(256_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -312,6 +313,7 @@ impl SessionActor {
|
||||
top_p: cfg.top_p,
|
||||
api_backend: cfg.api_backend,
|
||||
auth_scheme,
|
||||
chat_compat: cfg.chat_compat,
|
||||
extra_headers,
|
||||
context_window: cfg.context_window.get(),
|
||||
reasoning_effort: cfg.reasoning_effort,
|
||||
|
||||
@@ -346,6 +346,7 @@ pub(crate) async fn spawn_session_actor(
|
||||
temperature: sampling_config.temperature,
|
||||
top_p: sampling_config.top_p,
|
||||
api_backend: sampling_config.api_backend.clone(),
|
||||
chat_compat: sampling_config.chat_compat,
|
||||
extra_headers: sampling_config.extra_headers.clone(),
|
||||
context_window: context_window_override.unwrap_or(baseline_context_window),
|
||||
reasoning_effort: sampling_config.reasoning_effort,
|
||||
|
||||
@@ -847,6 +847,7 @@ async fn set_session_model_invalidates_byok_memo_for_same_model_id() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: crate::sampling::ApiBackend::ChatCompletions,
|
||||
chat_compat: Default::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: 256_000,
|
||||
|
||||
@@ -44,6 +44,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: 100_000,
|
||||
@@ -87,6 +88,7 @@ async fn persist_ack_waits_for_disk_flush_before_success() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(100_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -338,6 +340,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
auth_scheme: Default::default(),
|
||||
context_window: 100_000,
|
||||
force_http1: false,
|
||||
@@ -381,6 +384,7 @@ async fn first_turn_memory_injection_persists_to_chat_history() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(100_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -466,6 +470,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
auth_scheme: Default::default(),
|
||||
context_window: 100_000,
|
||||
force_http1: false,
|
||||
@@ -513,6 +518,7 @@ async fn first_turn_memory_injection_disabled_does_not_persist_to_chat_history()
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(100_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
@@ -1732,6 +1738,7 @@ async fn cancel_propagates_to_sampler_handle_so_no_further_emission() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: kigi_sampler::ApiBackend::Responses,
|
||||
chat_compat: Default::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: 100_000,
|
||||
|
||||
@@ -104,6 +104,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
|
||||
+3
@@ -52,6 +52,7 @@ async fn create_test_actor(
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(context_window)
|
||||
.expect("test context_window must be non-zero"),
|
||||
@@ -486,6 +487,7 @@ async fn create_test_actor_with_memory(
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(context_window)
|
||||
.expect("test context_window must be non-zero"),
|
||||
@@ -1240,6 +1242,7 @@ async fn test_e2e_idle_resume_refreshes_model_metadata() {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(200_000).unwrap(),
|
||||
reasoning_effort: None,
|
||||
|
||||
@@ -104,6 +104,7 @@ async fn create_test_actor_with_memory(
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(context_window)
|
||||
.expect("test context_window must be non-zero"),
|
||||
|
||||
@@ -168,6 +168,7 @@ pub(crate) async fn create_test_actor_ex(
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(context_window)
|
||||
.expect("test context_window must be non-zero"),
|
||||
|
||||
@@ -2149,6 +2149,7 @@ mod inline_auto_compact_flow_tests {
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: std::num::NonZeroU64::new(context_window)
|
||||
.expect("test context_window must be non-zero"),
|
||||
|
||||
@@ -1588,6 +1588,7 @@ mod reasoning_compaction_regression_tests {
|
||||
temperature: Some(0.7),
|
||||
top_p: None,
|
||||
api_backend: ApiBackend::ChatCompletions,
|
||||
chat_compat: Default::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: 256_000,
|
||||
|
||||
@@ -44,6 +44,7 @@ pub(crate) fn ctx_with_toggle(toggle: HashMap<String, bool>) -> SubagentSpawnCon
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
api_backend: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
auth_scheme: Default::default(),
|
||||
extra_headers: Default::default(),
|
||||
context_window: 256_000,
|
||||
|
||||
@@ -38,6 +38,7 @@ pub fn test_sampler_config(
|
||||
top_p: None,
|
||||
api_backend,
|
||||
auth_scheme: Default::default(),
|
||||
chat_compat: Default::default(),
|
||||
extra_headers: extra_headers
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.to_string()))
|
||||
|
||||
@@ -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(), 6, "5 login rows + Quit, got {items:?}");
|
||||
assert_eq!(items.len(), 7, "6 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 {:?}",
|
||||
@@ -6929,7 +6929,14 @@ pub(crate) mod tests {
|
||||
label: "Anthropic (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(items[5], PendingMenuItem::Quit);
|
||||
assert_eq!(
|
||||
items[5],
|
||||
PendingMenuItem::ApiKey {
|
||||
target: PlatformLogin(kigi_shell::models::PlatformId::DeepSeek),
|
||||
label: "DeepSeek (API key)".into(),
|
||||
}
|
||||
);
|
||||
assert_eq!(items[6], 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 {
|
||||
@@ -6940,7 +6947,7 @@ pub(crate) mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
pending_menu_items(&byok.methods, None).len(),
|
||||
6,
|
||||
7,
|
||||
"xai.api_key / cached_token must not add rows"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user