diff --git a/CHANGELOG.md b/CHANGELOG.md index b5c38c17d6..9d32c9dd22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add provider-native search for exact Moonshot K3 Formula, legacy K2.6 + built-in search, and Kimi Code membership `/search` routes. Treat the exact + Moonshot China endpoint as a first-party direct route. - Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50 diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 5916cbd57b..128452a42b 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -4862,7 +4862,10 @@ pub fn provider_base_url_is_official(provider: ProviderKind, base_url: &str) -> "https://api.siliconflow.com/v1" | "https://api.siliconflow.cn/v1" ), ProviderKind::Moonshot => { - normalized == DEFAULT_MOONSHOT_BASE_URL || moonshot_base_url_uses_kimi_code(base_url) + matches!( + normalized.as_str(), + DEFAULT_MOONSHOT_BASE_URL | MOONSHOT_CN_BASE_URL + ) || moonshot_base_url_uses_kimi_code(base_url) } ProviderKind::XiaomiMimo => { xiaomi_mimo_base_url_uses_token_plan(base_url) diff --git a/crates/config/src/provider.rs b/crates/config/src/provider.rs index fa511d2078..d0d4ce17b4 100644 --- a/crates/config/src/provider.rs +++ b/crates/config/src/provider.rs @@ -529,7 +529,9 @@ pub fn migrates_legacy_ollama_cloud_route(kind: ProviderKind, base_url: &str) -> /// neighboring Moonshot paths do not inherit direct-K3 wire semantics. #[must_use] pub fn is_exact_moonshot_platform_route(kind: ProviderKind, base_url: &str) -> bool { - kind == ProviderKind::Moonshot && is_exact_https_route(base_url, "api.moonshot.ai", "v1") + kind == ProviderKind::Moonshot + && (is_exact_https_route(base_url, "api.moonshot.ai", "v1") + || is_exact_https_route(base_url, "api.moonshot.cn", "v1")) } /// Whether a configured route is exactly xAI's first-party OpenAI-compatible @@ -1961,10 +1963,12 @@ mod tests { #[test] fn direct_moonshot_route_matching_is_exact() { - assert!(is_exact_moonshot_platform_route( - ProviderKind::Moonshot, - "HTTPS://API.MOONSHOT.AI/v1/" - )); + for route in ["HTTPS://API.MOONSHOT.AI/v1/", "HTTPS://API.MOONSHOT.CN/v1/"] { + assert!(is_exact_moonshot_platform_route( + ProviderKind::Moonshot, + route + )); + } for neighboring_route in [ "https://api.moonshot.ai/V1", "http://api.moonshot.ai/v1", @@ -1973,6 +1977,7 @@ mod tests { "https://api.moonshot.ai/v1#fragment", "https://api.moonshot.ai/v1//", "https://api.moonshot.ai/v1/chat/completions", + "https://api.moonshot.cn/v1/chat/completions", "https://api.kimi.com/coding/v1", ] { assert!( @@ -1982,7 +1987,7 @@ mod tests { } assert!(!is_exact_moonshot_platform_route( ProviderKind::Openai, - DEFAULT_MOONSHOT_BASE_URL + crate::MOONSHOT_CN_BASE_URL )); } diff --git a/crates/config/src/provider_defaults.rs b/crates/config/src/provider_defaults.rs index 6e8c065c9f..2164b0a147 100644 --- a/crates/config/src/provider_defaults.rs +++ b/crates/config/src/provider_defaults.rs @@ -89,6 +89,7 @@ pub(crate) const ARCEE_TRINITY_MINI_MODEL: &str = "trinity-mini"; pub(crate) const DEFAULT_MOONSHOT_MODEL: &str = "kimi-k2.7-code"; pub(crate) const MOONSHOT_KIMI_K2_6_MODEL: &str = "kimi-k2.6"; pub(crate) const DEFAULT_MOONSHOT_BASE_URL: &str = "https://api.moonshot.ai/v1"; +pub(crate) const MOONSHOT_CN_BASE_URL: &str = "https://api.moonshot.cn/v1"; pub(crate) const DEFAULT_KIMI_CODE_MODEL: &str = "kimi-for-coding"; pub(crate) const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding/v1"; pub(crate) const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro"; diff --git a/crates/config/src/route/capabilities.rs b/crates/config/src/route/capabilities.rs index 58fba589ef..2fa72aa454 100644 --- a/crates/config/src/route/capabilities.rs +++ b/crates/config/src/route/capabilities.rs @@ -7,6 +7,8 @@ use serde::{Deserialize, Serialize}; +use crate::{DEFAULT_KIMI_CODE_BASE_URL, ProviderKind}; + /// Whether a resolved provider/model offering supports one capability. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -50,6 +52,7 @@ impl CapabilityState { /// - OpenAI Responses web search: /// - Anthropic web search tool: /// - xAI web search tool: +/// - Kimi built-in and Formula web search: #[must_use] pub(crate) fn documented_server_side_web_search( provider_id: &str, @@ -74,6 +77,7 @@ pub(crate) fn documented_server_side_web_search( | "claude-sonnet-4-6" ), "xai" => matches!(wire_model_id.as_str(), "grok-4.6" | "grok-4.5"), + "moonshot" => matches!(wire_model_id.as_str(), "kimi-k3" | "kimi-k2.6"), _ => false, }; if supported { @@ -83,6 +87,34 @@ pub(crate) fn documented_server_side_web_search( } } +/// Return the native-search fact for exact Moonshot direct and Kimi Code +/// product routes. Adjacent coding paths and cross-product model ids remain +/// unknown even though they share one provider identity. +#[must_use] +pub(crate) fn documented_moonshot_web_search_for_route( + provider: ProviderKind, + wire_model_id: &str, + base_url: &str, +) -> CapabilityState { + if provider != ProviderKind::Moonshot { + return CapabilityState::Unknown; + } + let normalized = base_url.trim().trim_end_matches('/').to_ascii_lowercase(); + let model = wire_model_id.trim().to_ascii_lowercase(); + if normalized == DEFAULT_KIMI_CODE_BASE_URL + && matches!( + model.as_str(), + "k3" | "k3-256k" | "kimi-for-coding" | "kimi-for-coding-highspeed" + ) + { + return CapabilityState::Supported; + } + if crate::provider::is_exact_moonshot_platform_route(provider, base_url) { + return documented_server_side_web_search("moonshot", &model); + } + CapabilityState::Unknown +} + /// Capability facts owned by one provider/model route offering. /// /// Fields without a current authoritative catalog source remain `Unknown`. @@ -159,6 +191,10 @@ mod tests { documented_server_side_web_search("anthropic", "claude-sonnet-4-6"), CapabilityState::Supported ); + assert_eq!( + documented_server_side_web_search("moonshot", "kimi-k3"), + CapabilityState::Supported + ); for (provider, model) in [ ("openrouter", "openai/gpt-5.6"), @@ -168,6 +204,7 @@ mod tests { ("xai", "grok-4.6-latest"), ("xai", "grok-4.5-fast"), ("anthropic", "claude-haiku-4-5"), + ("moonshot", "kimi-k2.7-code"), ] { assert_eq!( documented_server_side_web_search(provider, model), @@ -176,4 +213,30 @@ mod tests { ); } } + + #[test] + fn moonshot_route_fact_is_exact_to_product_and_model() { + for (model, base_url) in [ + ("kimi-k3", "https://api.moonshot.ai/v1"), + ("kimi-k3", "https://api.moonshot.cn/v1"), + ("kimi-k2.6", "https://api.moonshot.ai/v1"), + ("k3", DEFAULT_KIMI_CODE_BASE_URL), + ("kimi-for-coding", DEFAULT_KIMI_CODE_BASE_URL), + ] { + assert_eq!( + documented_moonshot_web_search_for_route(ProviderKind::Moonshot, model, base_url,), + CapabilityState::Supported + ); + } + for (model, base_url) in [ + ("kimi-k3", "https://api.kimi.com/coding/v2"), + ("kimi-k2.6", "https://api.kimi.com/coding/v1/preview"), + ("k3", "https://api.moonshot.ai/v1"), + ] { + assert_eq!( + documented_moonshot_web_search_for_route(ProviderKind::Moonshot, model, base_url,), + CapabilityState::Unknown + ); + } + } } diff --git a/crates/config/src/route/resolver.rs b/crates/config/src/route/resolver.rs index 5c8d61b55a..98af0df4d1 100644 --- a/crates/config/src/route/resolver.rs +++ b/crates/config/src/route/resolver.rs @@ -31,7 +31,7 @@ use super::candidate::{ LimitField, PricingSku, ReadyRouteCandidate, ResolvedAuthSource, ResolvedEndpoint, SourcedLimitOverride, ValidationReport, }; -use super::capabilities::RouteCapabilities; +use super::capabilities::{RouteCapabilities, documented_moonshot_web_search_for_route}; use super::descriptor::ProviderDescriptor; use super::errors::RouteError; use super::ids::{LogicalModelRef, ModelId, ProviderId, WireModelId}; @@ -243,6 +243,17 @@ impl RouteResolver { selected.capabilities = RouteCapabilities::default(); selected.pricing = PricingSku::UnknownOrStale; } + if provider_kind == ProviderKind::Moonshot { + let effective_base_url = req + .base_url_override + .as_deref() + .unwrap_or_else(|| descriptor.default_base_url()); + selected.capabilities.server_side_web_search = documented_moonshot_web_search_for_route( + provider_kind, + selected.wire_model_id.as_str(), + effective_base_url, + ); + } let protocol = descriptor .protocol_for_endpoint(&selected.endpoint_key) diff --git a/crates/config/src/route/tests.rs b/crates/config/src/route/tests.rs index 5d5f42c9cb..13ea04e638 100644 --- a/crates/config/src/route/tests.rs +++ b/crates/config/src/route/tests.rs @@ -1421,6 +1421,57 @@ fn provider_native_web_search_requires_exact_direct_endpoint_offering() { ); } +#[test] +fn moonshot_native_search_requires_exact_product_model_pair() { + use crate::route::CapabilityState; + + let resolver = RouteResolver::new(); + for (model, base_url) in [ + ("kimi-k3", "https://api.moonshot.ai/v1"), + ("kimi-k3", "https://api.moonshot.cn/v1"), + ("kimi-k2.6", "https://api.moonshot.ai/v1"), + ("k3", "https://api.kimi.com/coding/v1"), + ("kimi-for-coding", "https://api.kimi.com/coding/v1"), + ] { + let direct = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Moonshot), + model_selector: Some(LogicalModelRef::from(model)), + saved_provider_model: None, + base_url_override: Some(base_url.to_string()), + limit_overrides: Vec::new(), + }) + .expect("documented Moonshot/Kimi route resolves"); + assert_eq!( + direct.capabilities().server_side_web_search, + CapabilityState::Supported, + "{base_url}/{model} should expose native search" + ); + } + + for (model, base_url) in [ + ("kimi-k3", "https://api.kimi.com/coding/v2"), + ("kimi-k2.6", "https://api.kimi.com/coding/v1/preview"), + ("k3", "https://api.moonshot.ai/v1"), + ("kimi-k2.7-code", "https://api.moonshot.ai/v1"), + ] { + let adjacent = resolver + .resolve(&RouteRequest { + explicit_provider: Some(ProviderKind::Moonshot), + model_selector: Some(LogicalModelRef::from(model)), + saved_provider_model: None, + base_url_override: Some(base_url.to_string()), + limit_overrides: Vec::new(), + }) + .expect("adjacent Moonshot/Kimi route resolves"); + assert_eq!( + adjacent.capabilities().server_side_web_search, + CapabilityState::Unknown, + "{base_url}/{model} must remain fail-closed" + ); + } +} + #[test] fn priced_offering_yields_token_pricing_sku() { use super::candidate::PricingSku; diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 131b300715..b9a23f8a1a 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add provider-native search for exact Moonshot K3 Formula, legacy K2.6 + built-in search, and Kimi Code membership `/search` routes. Treat the exact + Moonshot China endpoint as a first-party direct route. - Z.ai `GLM-5.3-Flash` and OpenRouter `z-ai/glm-5.3-flash` are first-class picker rows (`/model GLM-5.3-Flash`). Flash is the faster/explore sibling of `GLM-5.3`; the Z.ai default stays `GLM-5.3`. List price is $0.15/$0.50 diff --git a/crates/tui/src/client/provider_native_search.rs b/crates/tui/src/client/provider_native_search.rs index 33e03f2ce3..96768771b5 100644 --- a/crates/tui/src/client/provider_native_search.rs +++ b/crates/tui/src/client/provider_native_search.rs @@ -6,17 +6,20 @@ //! first-party wire contracts. use anyhow::{Context, Result, bail}; +use reqwest::header::{HeaderName, HeaderValue}; use serde_json::{Value, json}; use crate::config::ApiProvider; use super::{DeepSeekClient, api_url}; +mod kimi; + const MAX_NATIVE_ANSWER_CHARS: usize = 4_000; #[derive(Clone)] pub(crate) struct ProviderNativeSearchClient { - inner: DeepSeekClient, + pub(super) inner: DeepSeekClient, } #[derive(Clone)] @@ -45,7 +48,7 @@ impl ProviderNativeSearchClient { pub(crate) fn new(inner: DeepSeekClient) -> Option { matches!( inner.api_provider, - ApiProvider::Openai | ApiProvider::Anthropic | ApiProvider::Xai + ApiProvider::Openai | ApiProvider::Anthropic | ApiProvider::Xai | ApiProvider::Moonshot ) .then_some(Self { inner }) } @@ -97,58 +100,85 @@ impl ProviderNativeSearchClient { // retained through response decode so a relay writer cannot start // while this result is still able to feed the interactive turn. let _inference = self.inner.acquire_remote_control_inference_permit().await; - let body = match self.inner.api_provider { - ApiProvider::Openai => build_responses_search_body( - &self.inner.default_model, - request, - ResponsesSearchDialect::Openai, - ), - ApiProvider::Xai => build_responses_search_body( - &self.inner.default_model, - request, - ResponsesSearchDialect::Xai, - ), + let mut parsed = match self.inner.api_provider { + ApiProvider::Openai | ApiProvider::Xai => { + let dialect = if self.inner.api_provider == ApiProvider::Openai { + ResponsesSearchDialect::Openai + } else { + ResponsesSearchDialect::Xai + }; + let body = build_responses_search_body(&self.inner.default_model, request, dialect); + let url = api_url(&self.inner.base_url, "responses"); + let payload = self.post_json(&url, &body, &[]).await?; + parse_responses_search(&payload) + } ApiProvider::Anthropic => { let route_cap = self .inner .effective_max_output_tokens(&self.inner.default_model); - build_anthropic_search_body( + let body = build_anthropic_search_body( &self.inner.default_model, request, 2_048_u32.min(route_cap), - ) + ); + let payload = self + .post_json(&anthropic_messages_url(&self.inner.base_url), &body, &[]) + .await?; + parse_anthropic_search(&payload) } + ApiProvider::Moonshot => kimi::search(self, request).await?, _ => bail!("active provider has no native web-search adapter"), }; - let url = match self.inner.api_provider { - ApiProvider::Openai | ApiProvider::Xai => api_url(&self.inner.base_url, "responses"), - ApiProvider::Anthropic => anthropic_messages_url(&self.inner.base_url), - _ => unreachable!("provider checked above"), - }; + parsed.citations.truncate(usize::from(request.max_results)); + Ok(parsed) + } + + pub(super) async fn post_json( + &self, + url: &str, + body: &Value, + headers: &[(HeaderName, HeaderValue)], + ) -> Result { let body_bytes = serde_json::to_vec(&body) .context("failed to serialize provider-native web-search request")?; + let headers = headers.to_vec(); + let response = self + .inner + .send_with_retry(|| { + let mut request = self + .inner + .http_client + .post(url) + .header("Accept", "application/json") + .body(body_bytes.clone()); + for (name, value) in &headers { + request = request.header(name, value); + } + request + }) + .await + .context("provider-native web search request failed")?; + response + .json::() + .await + .context("provider-native web search returned invalid JSON") + } + + pub(super) async fn get_json(&self, url: &str) -> Result { let response = self .inner .send_with_retry(|| { self.inner .http_client - .post(&url) + .get(url) .header("Accept", "application/json") - .body(body_bytes.clone()) }) .await .context("provider-native web search request failed")?; - let payload = response + response .json::() .await - .context("provider-native web search returned invalid JSON")?; - let mut parsed = match self.inner.api_provider { - ApiProvider::Openai | ApiProvider::Xai => parse_responses_search(&payload), - ApiProvider::Anthropic => parse_anthropic_search(&payload), - _ => unreachable!("provider checked above"), - }; - parsed.citations.truncate(usize::from(request.max_results)); - Ok(parsed) + .context("provider-native web search returned invalid JSON") } } @@ -385,6 +415,38 @@ fn push_citation( citations.push(candidate); } +fn citations_from_text(text: &str) -> Vec { + let mut citations = Vec::new(); + let mut offset = 0; + while offset < text.len() { + let remaining = &text[offset..]; + let relative_start = match (remaining.find("https://"), remaining.find("http://")) { + (Some(https), Some(http)) => Some(https.min(http)), + (Some(https), None) => Some(https), + (None, Some(http)) => Some(http), + (None, None) => None, + }; + let Some(relative_start) = relative_start else { + break; + }; + let start = offset + relative_start; + let tail = &text[start..]; + let end = tail + .char_indices() + .find_map(|(index, ch)| { + (index > 0 + && (ch.is_whitespace() + || matches!(ch, ')' | ']' | '}' | '>' | '"' | '\'' | '`'))) + .then_some(index) + }) + .unwrap_or(tail.len()); + let url = tail[..end].trim_end_matches(['.', ',', ';', ':', '!', '?']); + push_citation(&mut citations, citation_from_url(url, None, None, None)); + offset = start + end.max(1); + } + citations +} + fn fallback_title(url: &str) -> String { reqwest::Url::parse(url) .ok() diff --git a/crates/tui/src/client/provider_native_search/kimi.rs b/crates/tui/src/client/provider_native_search/kimi.rs new file mode 100644 index 0000000000..e15070a9ce --- /dev/null +++ b/crates/tui/src/client/provider_native_search/kimi.rs @@ -0,0 +1,494 @@ +//! Moonshot/Kimi native search adapters. + +use anyhow::{Context, Result, bail}; +use reqwest::header::{HeaderName, HeaderValue}; +use serde_json::{Map, Value, json}; +use uuid::Uuid; + +use super::{ + ProviderNativeSearchClient, ProviderNativeSearchRequest, ProviderNativeSearchResponse, + bounded_answer, citation_from_url, citations_from_text, push_citation, +}; +use crate::{ + client::api_url, + config::{DEFAULT_KIMI_CODE_BASE_URL, MOONSHOT_KIMI_K3_MODEL}, +}; + +const MAX_NATIVE_SEARCH_ROUNDS: usize = 4; +const MAX_NATIVE_SEARCH_TOOL_CALLS: usize = 8; +const NATIVE_SEARCH_MAX_COMPLETION_TOKENS: u32 = 4_096; +const WEB_SEARCH_FORMULA_URI: &str = "moonshot/web-search:latest"; +const WEB_SEARCH_FORMULA_FUNCTION: &str = "web_search"; + +pub(super) async fn search( + client: &ProviderNativeSearchClient, + request: &ProviderNativeSearchRequest, +) -> Result { + if is_kimi_code_route(&client.inner.base_url) { + search_kimi_code(client, request).await + } else if client + .inner + .default_model + .trim() + .eq_ignore_ascii_case(MOONSHOT_KIMI_K3_MODEL) + { + search_formula(client, request).await + } else { + search_builtin(client, request).await + } +} + +fn is_kimi_code_route(base_url: &str) -> bool { + base_url + .trim() + .trim_end_matches('/') + .eq_ignore_ascii_case(DEFAULT_KIMI_CODE_BASE_URL) +} + +async fn search_kimi_code( + client: &ProviderNativeSearchClient, + request: &ProviderNativeSearchRequest, +) -> Result { + let call_id = HeaderValue::from_str(&Uuid::new_v4().to_string()) + .context("failed to build Kimi search call id")?; + let url = format!("{}/search", client.inner.base_url.trim_end_matches('/')); + let payload = client + .post_json( + &url, + &json!({ "text_query": request.query }), + &[(HeaderName::from_static("x-msh-tool-call-id"), call_id)], + ) + .await?; + Ok(parse_kimi_code(&payload)) +} + +async fn search_builtin( + client: &ProviderNativeSearchClient, + request: &ProviderNativeSearchRequest, +) -> Result { + let tools = builtin_search_tools(); + let mut messages = vec![json!({ + "role": "user", + "content": super::search_prompt(request), + })]; + let mut tool_calls_executed = 0; + let url = api_url(&client.inner.base_url, "chat/completions"); + + for _ in 0..MAX_NATIVE_SEARCH_ROUNDS { + let body = json!({ + "model": client.inner.default_model, + "messages": &messages, + "tools": &tools, + "max_completion_tokens": NATIVE_SEARCH_MAX_COMPLETION_TOKENS, + "stream": false, + "thinking": { "type": "disabled" }, + }); + let payload = client.post_json(&url, &body, &[]).await?; + let choice = payload + .pointer("/choices/0") + .context("Kimi web search response omitted choices[0]")?; + let message = choice + .get("message") + .and_then(Value::as_object) + .context("Kimi web search response omitted assistant message")?; + if choice.get("finish_reason").and_then(Value::as_str) != Some("tool_calls") { + return Ok(parse_final_message(message)); + } + + messages.push(Value::Object(message.clone())); + let tool_calls = message + .get("tool_calls") + .and_then(Value::as_array) + .context("Kimi returned tool_calls finish reason without tool calls")?; + if tool_calls.is_empty() { + bail!("Kimi returned an empty native web-search tool call list"); + } + reserve_native_search_tool_calls(&mut tool_calls_executed, tool_calls.len())?; + for tool_call in tool_calls { + if tool_call.pointer("/function/name").and_then(Value::as_str) != Some("$web_search") { + bail!("Kimi native search requested an unexpected tool"); + } + let id = tool_call + .get("id") + .and_then(Value::as_str) + .context("Kimi native web-search call omitted id")?; + let arguments = tool_call + .pointer("/function/arguments") + .and_then(Value::as_str) + .context("Kimi native web-search call omitted arguments")?; + let _: Value = serde_json::from_str(arguments) + .context("Kimi native web-search arguments were not valid JSON")?; + messages.push(json!({ + "role": "tool", + "tool_call_id": id, + "name": "$web_search", + "content": arguments, + })); + } + } + + bail!("Kimi native web search exceeded the bounded tool-call loop") +} + +async fn search_formula( + client: &ProviderNativeSearchClient, + request: &ProviderNativeSearchRequest, +) -> Result { + let formula_path = format!("formulas/{WEB_SEARCH_FORMULA_URI}"); + let tools_payload = client + .get_json(&api_url( + &client.inner.base_url, + &format!("{formula_path}/tools"), + )) + .await?; + let tools = formula_web_search_tools(&tools_payload)?; + let mut messages = vec![json!({ + "role": "user", + "content": super::search_prompt(request), + })]; + let mut tool_calls_executed = 0; + let chat_url = api_url(&client.inner.base_url, "chat/completions"); + let fiber_url = api_url(&client.inner.base_url, &format!("{formula_path}/fibers")); + + for _ in 0..MAX_NATIVE_SEARCH_ROUNDS { + let body = json!({ + "model": client.inner.default_model, + "messages": &messages, + "tools": &tools, + "max_completion_tokens": NATIVE_SEARCH_MAX_COMPLETION_TOKENS, + "stream": false, + }); + let payload = client.post_json(&chat_url, &body, &[]).await?; + let choice = payload + .pointer("/choices/0") + .context("Kimi Formula web search response omitted choices[0]")?; + let message = choice + .get("message") + .and_then(Value::as_object) + .context("Kimi Formula web search response omitted assistant message")?; + let Some(tool_calls) = message + .get("tool_calls") + .and_then(Value::as_array) + .filter(|calls| !calls.is_empty()) + else { + return Ok(parse_final_message(message)); + }; + + reserve_native_search_tool_calls(&mut tool_calls_executed, tool_calls.len())?; + messages.push(Value::Object(message.clone())); + for tool_call in tool_calls { + let id = tool_call + .get("id") + .and_then(Value::as_str) + .context("Kimi Formula web-search call omitted id")?; + let function = tool_call + .get("function") + .and_then(Value::as_object) + .context("Kimi Formula web-search call omitted function")?; + let name = function + .get("name") + .and_then(Value::as_str) + .context("Kimi Formula web-search call omitted function name")?; + if name != WEB_SEARCH_FORMULA_FUNCTION { + bail!("Kimi Formula web search requested an unexpected tool"); + } + let arguments = function + .get("arguments") + .and_then(Value::as_str) + .context("Kimi Formula web-search call omitted arguments")?; + let _: Value = serde_json::from_str(arguments) + .context("Kimi Formula web-search arguments were not valid JSON")?; + let fiber = client + .post_json( + &fiber_url, + &json!({ "name": name, "arguments": arguments }), + &[], + ) + .await?; + messages.push(json!({ + "role": "tool", + "tool_call_id": id, + "content": formula_fiber_result(&fiber)?, + })); + } + } + + bail!("Kimi Formula web search exceeded the bounded tool-call loop") +} + +fn reserve_native_search_tool_calls(executed: &mut usize, additional: usize) -> Result<()> { + let total = executed + .checked_add(additional) + .context("Kimi native web search tool-call count overflowed")?; + if total > MAX_NATIVE_SEARCH_TOOL_CALLS { + bail!( + "Kimi native web search exceeded the {MAX_NATIVE_SEARCH_TOOL_CALLS}-call safety limit" + ); + } + *executed = total; + Ok(()) +} + +fn formula_web_search_tools(payload: &Value) -> Result { + let tools = payload + .get("tools") + .and_then(Value::as_array) + .context("Kimi web-search Formula omitted tools")?; + if tools.len() != 1 + || tools[0].get("type").and_then(Value::as_str) != Some("function") + || tools[0].pointer("/function/name").and_then(Value::as_str) + != Some(WEB_SEARCH_FORMULA_FUNCTION) + { + bail!("Kimi web-search Formula returned an unexpected tool declaration"); + } + Ok(Value::Array(tools.clone())) +} + +fn formula_fiber_result(payload: &Value) -> Result<&str> { + if payload.get("status").and_then(Value::as_str) != Some("succeeded") { + bail!("Kimi web-search Formula fiber did not succeed"); + } + payload + .pointer("/context/output") + .or_else(|| payload.pointer("/context/encrypted_output")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|result| !result.is_empty()) + .context("Kimi web-search Formula fiber omitted its result") +} + +fn builtin_search_tools() -> Value { + json!([{ + "type": "builtin_function", + "function": { "name": "$web_search" } + }]) +} + +fn parse_kimi_code(payload: &Value) -> ProviderNativeSearchResponse { + let mut citations = Vec::new(); + if let Some(results) = payload.get("search_results").and_then(Value::as_array) { + for result in results { + let Some(url) = result.get("url").and_then(Value::as_str) else { + continue; + }; + let title = result + .get("title") + .and_then(Value::as_str) + .map(str::to_string); + let snippet = result + .get("snippet") + .and_then(Value::as_str) + .map(str::to_string); + let published = result + .get("date") + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string); + push_citation( + &mut citations, + citation_from_url(url, title, snippet, published), + ); + } + } + ProviderNativeSearchResponse { + answer: None, + citations, + } +} + +fn parse_final_message(message: &Map) -> ProviderNativeSearchResponse { + let answer = message + .get("content") + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_string); + let citations = answer + .as_deref() + .map(citations_from_text) + .unwrap_or_default(); + ProviderNativeSearchResponse { + answer: bounded_answer(answer.into_iter().collect()), + citations, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{Config, ProviderConfig, ProvidersConfig}; + use wiremock::matchers::{body_partial_json, body_string_contains, header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn request() -> ProviderNativeSearchRequest { + ProviderNativeSearchRequest { + query: "current release".to_string(), + max_results: 3, + domains: Vec::new(), + } + } + + #[test] + fn kimi_code_request_and_structured_response_contract() { + let body = json!({ "text_query": request().query }); + assert_eq!(body["text_query"], "current release"); + assert_eq!(body.as_object().map(serde_json::Map::len), Some(1)); + + let parsed = parse_kimi_code(&json!({ + "search_results": [{ + "title": "Kimi", + "url": "https://example.com/kimi", + "snippet": "Summary", + "date": "2026-08-28" + }] + })); + assert_eq!(parsed.citations.len(), 1); + assert_eq!(parsed.citations[0].snippet.as_deref(), Some("Summary")); + assert_eq!(parsed.citations[0].published.as_deref(), Some("2026-08-28")); + } + + #[test] + fn direct_search_contracts_are_bounded() { + let tools = builtin_search_tools(); + assert_eq!(tools[0]["function"]["name"], "$web_search"); + assert_eq!(NATIVE_SEARCH_MAX_COMPLETION_TOKENS, 4_096); + + let formula_tools = formula_web_search_tools(&json!({ + "tools": [{ + "type": "function", + "function": { "name": "web_search" } + }] + })) + .expect("formula tools"); + assert_eq!(formula_tools[0]["function"]["name"], "web_search"); + assert_eq!( + formula_fiber_result(&json!({ + "status": "succeeded", + "context": { "encrypted_output": "encrypted result" } + })) + .expect("formula result"), + "encrypted result" + ); + } + + #[test] + fn native_search_tool_call_limit_is_total_not_per_round() { + let mut executed = 0; + reserve_native_search_tool_calls(&mut executed, 4).expect("first rounds"); + reserve_native_search_tool_calls(&mut executed, 4).expect("final allowed round"); + assert_eq!(executed, MAX_NATIVE_SEARCH_TOOL_CALLS); + assert!(reserve_native_search_tool_calls(&mut executed, 1).is_err()); + } + + #[tokio::test] + async fn k3_formula_executes_tool_fiber_and_returns_citations() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/v1/formulas/moonshot/web-search:latest/tools")) + .and(header("authorization", "Bearer moonshot-test-key")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "tools": [{ + "type": "function", + "function": { + "name": "web_search", + "description": "Search the web", + "parameters": { + "type": "object", + "properties": { "query": { "type": "string" } }, + "required": ["query"] + } + } + }] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(header("authorization", "Bearer moonshot-test-key")) + .and(body_partial_json(json!({ + "model": "kimi-k3", + "max_completion_tokens": 4096, + "tools": [{ + "type": "function", + "function": { "name": "web_search" } + }] + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{ + "finish_reason": "tool_calls", + "message": { + "role": "assistant", + "content": "", + "tool_calls": [{ + "id": "web_search:0", + "type": "function", + "function": { + "name": "web_search", + "arguments": "{\"query\":\"current release\"}" + } + }] + } + }] + }))) + .up_to_n_times(1) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/formulas/moonshot/web-search:latest/fibers")) + .and(header("authorization", "Bearer moonshot-test-key")) + .and(body_partial_json(json!({ + "name": "web_search", + "arguments": "{\"query\":\"current release\"}" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "status": "succeeded", + "context": { "encrypted_output": "encrypted-search-result" } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .and(header("authorization", "Bearer moonshot-test-key")) + .and(body_string_contains("encrypted-search-result")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "choices": [{ + "finish_reason": "stop", + "message": { + "role": "assistant", + "content": "See https://example.com/kimi for the current result." + } + }] + }))) + .expect(1) + .mount(&server) + .await; + + let config = Config { + provider: Some("moonshot".to_string()), + providers: Some(ProvidersConfig { + moonshot: ProviderConfig { + api_key: Some("moonshot-test-key".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + model: Some("kimi-k3".to_string()), + ..ProviderConfig::default() + }, + ..ProvidersConfig::default() + }), + ..Config::default() + }; + let client = ProviderNativeSearchClient::new( + crate::client::DeepSeekClient::new(&config).expect("test Moonshot client"), + ) + .expect("Moonshot native adapter"); + + let response = search_formula(&client, &request()) + .await + .expect("K3 Formula search"); + + assert_eq!(response.citations.len(), 1); + assert_eq!(response.citations[0].url, "https://example.com/kimi"); + } +} diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index 5048e6c7fc..7283d05841 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -578,7 +578,7 @@ overlay and lets DSH resolve its own keys. | `siliconflow` | `[providers.siliconflow]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.com/v1` | `deepseek-ai/DeepSeek-V4-Pro`, `deepseek-ai/DeepSeek-V4-Flash` | OpenAI-compatible hosted route. Official docs use the `.com` endpoint. `SILICONFLOW_MODEL` is accepted. Reasoning aliases `deepseek-reasoner` and `deepseek-r1` map to Pro; `deepseek-chat` and `deepseek-v3` map to Flash. | | `siliconflow-CN` | `[providers.siliconflow_cn]` | `SILICONFLOW_API_KEY` | `SILICONFLOW_BASE_URL`; default `https://api.siliconflow.cn/v1` | Uses the SiliconFlow model set | China regional SiliconFlow route. Falls back to `[providers.siliconflow]` for api_key / base_url / model when unset. Select it with `provider = "siliconflow-CN"` or `CODEWHALE_PROVIDER=siliconflow-CN`. | | `arcee` | `[providers.arcee]` | `ARCEE_API_KEY` | `ARCEE_BASE_URL`; default `https://api.arcee.ai/api/v1` | `trinity-large-thinking`, `trinity-large-preview` | Arcee AI direct OpenAI-compatible route, tracked as 256K-context BF16 serving. `ARCEE_MODEL` is accepted. OpenRouter's `arcee-ai/trinity-large-thinking` remains the OpenRouter namespaced model ID; direct Arcee uses the bare `trinity-large-thinking` ID. | -| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = "https://api.moonshot.ai/v1"` and `model = "kimi-k3"`; it is always-thinking and receives top-level `reasoning_effort = "low" | "high" | "max"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = "kimi_oauth"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = "https://api.moonshot.cn/v1"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. | +| `moonshot` | `[providers.moonshot]` | `MOONSHOT_API_KEY`, `KIMI_API_KEY` | `MOONSHOT_BASE_URL`, `KIMI_BASE_URL`; default `https://api.moonshot.ai/v1` | Direct Moonshot: `kimi-k3`, `kimi-k2.7-code`, `kimi-k2.7-code-highspeed`, `kimi-k2.6`; Kimi Code membership: `k3`, `kimi-for-coding`, `kimi-for-coding-highspeed` at `https://api.kimi.com/coding/v1` | Moonshot/Kimi route. Exact direct `kimi-k3` routes use the documented Formula web-search tool/fiber loop; direct `kimi-k2.6` retains the built-in `$web_search` contract, and exact Kimi Code membership routes use their structured `/search` service. Adjacent paths, K2.7 direct models, and cross-product model IDs do not inherit native search. `kimi` and `kimi-k2` aliases select `kimi-k2.7-code`; `MOONSHOT_MODEL`, `KIMI_MODEL_NAME`, and `KIMI_MODEL` are accepted. Kimi thinking streams through `reasoning_content`; Codewhale keeps it in Thinking cells and replays it for thinking/tool-call continuity. For direct K3, use exact `base_url = "https://api.moonshot.ai/v1"` and `model = "kimi-k3"`; it is always-thinking and receives top-level `reasoning_effort = "low" | "high" | "max"` (`off` normalizes to `low`), uses only `max_completion_tokens`, and omits `temperature`/`top_p` per the [K3 quickstart](https://platform.kimi.ai/docs/guide/kimi-k3-quickstart). For Kimi Code K3, use a key from the [Kimi Code console](https://www.kimi.com/code/console), exact `base_url = "https://api.kimi.com/coding/v1"`, and bare `model = "k3"`; `off` becomes enabled `low`, while normal dispatched `auto` selects and sends a concrete Codewhale tier. Only an omitted reasoning setting leaves the provider default in control. That membership route defaults safely to 262,144 context tokens; the [Kimi Code model-tier table](https://www.kimi.com/code/docs/en/kimi-code/models.html) grants Allegretto and higher plans up to 1M, which those plans may express as `context_window = 1048576`. `k3[1m]` is Claude Code-only and Codewhale rejects it. `kimi-for-coding` remains the valid K2.7 membership route, and `kimi-for-coding-highspeed` is its own high-speed roster entry (262,144 context); membership ids are rejected on the direct platform endpoint, and `kimi-k3` stays rejected on the membership endpoint. Billing is decided by the endpoint the route resolves to, judged once against the two exact product endpoints: direct Moonshot (`https://api.moonshot.ai/v1` or the default) bills metered with dollar estimates, the exact Kimi Code membership endpoint bills as Kimi Code quota and never shows dollar estimates, and anything else — a gateway host, a neighboring Kimi-hosted path — reports `cost: unknown` rather than borrowing either product. An imported Kimi Code token with no `base_url` in its table still resolves to the membership endpoint, so it bills as Kimi Code quota and never accrues dollars. A completed turn, parent or sub-agent, is billed from the immutable endpoint receipt its own client was built with, never from a later config re-read: `MOONSHOT_BASE_URL`/`KIMI_BASE_URL` are merged into the *active* provider's table only, and an in-turn provider switch can move the ambient config off the route that actually ran. Legacy `auth_mode = "kimi_oauth"` fails to API-key guidance without probing Kimi CLI files. Codewhale does not impersonate `kimi_cli` or `kimi_code_cli`. **China-region keys:** contributor field evidence (@vFONGv, PR #5229, verified on Windows 10) reports that a China-region Moonshot key must be paired with `base_url = "https://api.moonshot.cn/v1"`; left on the default international host (`https://api.moonshot.ai/v1`) it fails authentication. We have no China-region key to verify this ourselves, so it is recorded as a user report rather than a tested route. Note also that editing `base_url` alone does not take effect until `codewhale auth set` is re-run for that provider. | | `antigravity` | `[providers.antigravity]` | `ANTIGRAVITY_API_KEY` | `ANTIGRAVITY_BASE_URL`; default `https://cloudcode-pa.googleapis.com/v1internal` | none advertised — requests fail closed until the cloud-code wire protocol exists | Antigravity (`agy` 1.1.13) credential plane: consent-gated read-only import of the official CLI's `state.vscdb` OAuth token (`antigravityUnifiedStateSync.oauthToken`), pinned to the exact per-OS app-profile path. The store is opened read-only through the secure no-follow boundary with an inode recheck; Codewhale never writes, refreshes, or re-authenticates. Precedence: `ANTIGRAVITY_API_KEY` > process `AGY_ADC_AUTH` > consented file. Not an embed of any other harness. No live calls made in this environment. | | `google` | `[providers.google]` | `GOOGLE_API_KEY`, `GEMINI_API_KEY` | `GOOGLE_BASE_URL`, `GEMINI_BASE_URL`; default `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-3.1-pro-preview` (default); `/model` also lists `gemini-3-pro-preview`, `gemini-3.7-flash`, `gemini-3.6-flash`, `gemini-3.5-flash`, `gemini-3.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash` | Google Gemini as its own backend on the official OpenAI-compatible Chat Completions route. Thinking models capture `extra_content.google.thought_signature` on tool calls and replay it with the assistant tool-call messages; replaying a tool call whose signature was not captured fails closed with an actionable error instead of letting the tool loop break. `gemini-2.5-flash-lite` ships thinking off and degrades with a warning instead. Reasoning effort maps onto the documented `google.thinking_config.thinking_level` (`low`/`high`). The dialect binds to the exact official base URL: a `google` row pointed at another gateway gets plain OpenAI semantics and no signature requirements. Codewhale never reads Google OAuth files; only an AI Studio API key is used. Not live-tested against the real endpoint in this environment. | | `zai` | `[providers.zai]` | `ZAI_API_KEY`, `Z_AI_API_KEY` | `ZAI_BASE_URL`, `Z_AI_BASE_URL`; default `https://api.z.ai/api/coding/paas/v4`; general API `https://api.z.ai/api/paas/v4` | `GLM-5.3` default; `/model` also lists `GLM-5.3-Flash`, `GLM-5.2`, `GLM-5.1`, and `GLM-5-Turbo` | Z.AI GLM Coding Plan route. `GLM-5.3` is the default and a first-class picker row (`model = "GLM-5.3"` or `ZAI_MODEL=GLM-5.3`); `GLM-5.3-Flash` is the 1M multimodal fast sibling (`model = "GLM-5.3-Flash"`). An explicit `GLM-5.2` selection keeps its own id. Limits and reasoning options for 5.3 are inherited from `GLM-5.2` until Z.ai publishes distinct 5.3 metadata; 5.3 carries no price. Flash ships the published $0.15/$0.50 list. A live call can still 429 with entitlement code 1311 on accounts that are not provisioned for 5.3. |