Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4616,11 +4616,18 @@ fn default_base_url_for_provider(provider: ProviderKind) -> &'static str {
}
}

/// Loose Kimi Code membership-endpoint recognition for default-model
/// fallback and official-host scoping. The exact gate
/// ([`provider::is_exact_kimi_code_route`]) must stay a subset of this: the
/// membership plan serves both the default host and the overseas alias, so
/// route canonicalization and credential scoping cannot disagree with it.
fn moonshot_base_url_uses_kimi_code(base_url: &str) -> bool {
let normalized = base_url.trim_end_matches('/').to_ascii_lowercase();
normalized == DEFAULT_KIMI_CODE_BASE_URL
|| normalized == "https://api.kimi.com/coding"
|| normalized.starts_with("https://api.kimi.com/coding/")
|| normalized == "https://api.kimi.ai/coding"
|| normalized.starts_with("https://api.kimi.ai/coding/")
}

/// Dual-wire vendors: dialect is config (`wire`), not a separate ProviderKind.
Expand Down
22 changes: 21 additions & 1 deletion crates/config/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,13 @@ pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool {
return false;
}

// The membership plan serves both the default host and the official
// overseas alias the app's model catalog registers (pinvoy-agent#825
// review P2: saved settings keep api.kimi.ai/coding/v1 verbatim, so the
// route gate must learn it or the K3.1 replay contract silently misses
// there).
is_exact_https_route(base_url, "api.kimi.com", "coding/v1")
|| is_exact_https_route(base_url, "api.kimi.ai", "coding/v1")
}

/// Whether a configured Ollama route is exactly the hosted OpenAI-compatible
Expand Down Expand Up @@ -627,7 +633,7 @@ pub fn credential_help_for_route(kind: ProviderKind, base_url: &str) -> Credenti
acquisition: CredentialAcquisition::ApiKey,
credential_url: Some(KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL),
docs_url: None,
guidance: "Create a Kimi Code membership-plan API key in the Kimi Code console. This route uses api.kimi.com/coding/v1; Codewhale does not import Kimi CLI credentials.",
guidance: "Create a Kimi Code membership-plan API key in the Kimi Code console. This route uses an official Kimi Code coding endpoint (api.kimi.com/coding/v1 or api.kimi.ai/coding/v1); Codewhale does not import Kimi CLI credentials.",
};
}

Expand Down Expand Up @@ -2218,7 +2224,21 @@ mod tests {
ProviderKind::Moonshot,
"HTTPS://API.KIMI.COM/coding/v1/"
));
// The official overseas alias carries the same membership semantics.
assert!(is_exact_kimi_code_route(
ProviderKind::Moonshot,
"https://api.kimi.ai/coding/v1"
));
assert!(is_exact_kimi_code_route(
ProviderKind::Moonshot,
"https://api.kimi.ai/coding/v1/"
));
for neighboring_route in [
"https://api.kimi.ai/CODING/v1",
"https://api.kimi.ai/coding/V1",
"http://api.kimi.ai/coding/v1",
"https://api.kimi.ai:443/coding/v1",
"https://api.kimi.ai/coding/v1?preview=1",
"https://api.kimi.com/CODING/v1",
"https://api.kimi.com/coding/V1",
"http://api.kimi.com/coding/v1",
Expand Down
74 changes: 74 additions & 0 deletions crates/config/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2521,6 +2521,80 @@ fn moonshot_kimi_code_smoke_config_set_then_resolve() -> Result<()> {
Ok(())
}

/// The loose membership recognition (default-model fallback, official-host
/// scoping) must stay a superset of the exact route gate: both membership
/// hosts, case- and trailing-slash-insensitively, while lookalike hosts,
/// neighboring paths, and scheme downgrades stay outside so they cannot
/// inherit membership semantics.
#[test]
fn kimi_code_loose_route_recognition_covers_both_membership_hosts() {
// The exact membership endpoint on both hosts, recognized by the loose
// gate, official-host scoping, and the exact gate alike.
for base_url in [
DEFAULT_KIMI_CODE_BASE_URL,
"https://api.kimi.com/coding/v1/",
"https://api.kimi.ai/coding/v1",
"https://api.kimi.ai/coding/v1/",
] {
assert!(
crate::moonshot_base_url_uses_kimi_code(base_url),
"{base_url} must be recognized as a Kimi Code membership route"
);
assert!(
crate::provider_base_url_is_official(ProviderKind::Moonshot, base_url),
"{base_url} must be an official Moonshot route"
);
assert!(
crate::provider::is_exact_kimi_code_route(ProviderKind::Moonshot, base_url),
"{base_url} must stay an exact Kimi Code membership route"
);
}
// Loose recognition and official-host scoping are case- and
// trailing-slash-insensitive over neighboring Kimi-hosted /coding
// paths, while the exact gate does not widen with it (an uppercase path
// is not the route identifier).
for base_url in [
"https://api.kimi.com/coding",
"https://api.kimi.com/coding/",
"https://api.kimi.ai/coding",
"HTTPS://API.KIMI.AI/CODING/V1",
] {
assert!(
crate::moonshot_base_url_uses_kimi_code(base_url),
"{base_url} must stay inside the loose membership recognition"
);
assert!(
crate::provider_base_url_is_official(ProviderKind::Moonshot, base_url),
"{base_url} must stay an official Moonshot route"
);
assert!(
!crate::provider::is_exact_kimi_code_route(ProviderKind::Moonshot, base_url),
"{base_url} must not become an exact membership route"
);
}
for lookalike in [
"https://api.kimi.com/v1",
"https://api.kimi.ai/v1",
"https://kimi.com/coding/v1",
"http://api.kimi.ai/coding/v1",
"https://api.example.com/coding/v1",
] {
assert!(
!crate::moonshot_base_url_uses_kimi_code(lookalike),
"{lookalike} must not be recognized as a Kimi Code membership route"
);
}
// The direct platform stays official while remaining outside the
// membership family.
assert!(crate::provider_base_url_is_official(
ProviderKind::Moonshot,
"https://api.moonshot.ai/v1"
));
assert!(!crate::moonshot_base_url_uses_kimi_code(
"https://api.moonshot.ai/v1"
));
}

#[test]
fn moonshot_provider_config_values_round_trip() -> Result<()> {
let mut config = ConfigToml::default();
Expand Down
180 changes: 179 additions & 1 deletion crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4490,7 +4490,8 @@ mod tests {
use super::*;
use crate::client::chat::{
ReasoningDetailsBuffer, build_chat_messages, build_chat_messages_for_request,
build_chat_messages_for_request_and_provider, count_reasoning_replay_chars,
build_chat_messages_for_request_and_provider,
build_chat_messages_for_request_and_provider_and_route, count_reasoning_replay_chars,
parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat,
tool_to_chat_for_base_url,
};
Expand Down Expand Up @@ -9324,6 +9325,183 @@ mod tests {
);
}

/// K3.1 replay contract (2026-11-04 pinvou-agent#825 review): the K3.1
/// spellings inherit the K3 contract verbatim, and the Kimi Code error
/// reference makes a replayed tool-call assistant message WITHOUT its
/// `reasoning_content` an HTTP 400. The exact-K3 matchers only learn the
/// bare k3 ids, so the replay gate carries its own K3.1 arm — pin all
/// four spellings across both vendor routes (direct platform + Kimi Code
/// membership endpoint), the stale-effort-off always-replay case, the
/// case/padding normalization, and the custom-gateway / non-Moonshot
/// dialect boundary.
#[test]
fn kimi_k31_tool_rounds_replay_reasoning_content_on_the_vendor_routes() {
let tool_round = || {
vec![
Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "Need the date".to_string(),
cache_control: None,
}],
},
Message {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
signature: None,
state: None,
thinking: "Need to call a tool".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
name: "get_date".to_string(),
input: json!({}),
caller: None,
thought_signature: None,
},
],
},
Message {
role: Role::User,
content: vec![ContentBlock::ToolResult {
tool_use_id: "tool-1".to_string(),
content: "2026-04-23".to_string(),
is_error: None,
content_blocks: None,
}],
},
]
};
let tool_assistant_reasoning = |label: &str,
provider: ApiProvider,
base_url: &str,
model: &str,
effort: Option<&str>|
-> Option<String> {
let request = MessageRequest {
model: model.to_string(),
messages: tool_round(),
max_tokens: 16,
system: None,
tools: None,
tool_choice: None,
metadata: None,
thinking: None,
reasoning_effort: effort.map(str::to_string),
stream: None,
temperature: None,
top_p: None,
};
let out = build_chat_messages_for_request_and_provider_and_route(
&request, provider, base_url,
);
let tool_assistant = out
.iter()
.find(|value| {
value.get("role").and_then(Value::as_str) == Some("assistant")
&& value.get("tool_calls").is_some()
})
.unwrap_or_else(|| panic!("{label}: tool-call assistant message missing"));
tool_assistant
.get("reasoning_content")
.and_then(Value::as_str)
.map(str::to_string)
};
for (label, base_url, model, effort) in [
(
"direct platform",
"https://api.moonshot.cn/v1",
"kimi-k3.1-pro",
None,
),
(
"direct platform highspeed",
"https://api.moonshot.ai/v1",
"kimi-k3.1-pro-highspeed",
None,
),
// The arm must precede the effort check like bare K3: a stored
// effort off would otherwise strip the field via the generic
// path on the direct platform, where no catalog entry or
// `kimi-`-prefix-independent pin exists.
(
"direct platform ignores a stale effort off",
"https://api.moonshot.cn/v1",
"kimi-k3.1-pro",
Some("off"),
),
(
"kimi code",
"https://api.kimi.com/coding/v1",
"k3.1-pro-highspeed",
None,
),
// The official overseas Kimi Code alias the app's model catalog
// registers and prefs preserve verbatim (review P2,
// pinjou-agent#825): the replay contract must hold there too.
(
"kimi code overseas",
"https://api.kimi.ai/coding/v1",
"k3.1-pro",
None,
),
(
"kimi code overseas highspeed",
"https://api.kimi.ai/coding/v1",
"k3.1-pro-highspeed",
None,
),
// The spelling matcher normalizes case and padding; pin it on
// the bare Kimi Code ids, where no generic heuristic would
// replay in the matcher's place.
(
"kimi code is case-insensitive",
"https://api.kimi.com/coding/v1",
"K3.1-PRO",
None,
),
(
"kimi code trims padding",
"https://api.kimi.com/coding/v1",
" k3.1-pro ",
None,
),
] {
assert_eq!(
tool_assistant_reasoning(label, ApiProvider::Moonshot, base_url, model, effort),
Some("Need to call a tool".to_string()),
"{label}: the K3.1 replay contract must keep reasoning_content on the tool-round assistant message"
);
}

// The arm is pinned to the vendor's own routes: a custom
// Moonshot-compatible gateway keeps its own dialect, and so does a
// different provider that happens to point at the membership host.
for (label, provider, base_url, model) in [
(
"custom gateway keeps its own dialect",
ApiProvider::Moonshot,
"https://gateway.example/v1",
"k3.1-pro",
),
(
"non-moonshot provider keeps its own dialect",
ApiProvider::Openai,
"https://api.kimi.com/coding/v1",
"k3.1-pro",
),
] {
assert_eq!(
tool_assistant_reasoning(label, provider, base_url, model, None),
None,
"{label}: reasoning_content must stay stripped outside the vendor routes"
);
}
}

#[test]
fn chat_messages_replay_tool_round_reasoning_before_new_user_turn() {
let messages = vec![
Expand Down
Loading
Loading