diff --git a/cli/planoai/config_generator.py b/cli/planoai/config_generator.py index cb07767e0..3f82f0ac2 100644 --- a/cli/planoai/config_generator.py +++ b/cli/planoai/config_generator.py @@ -39,6 +39,42 @@ CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs" CHATGPT_DEFAULT_USER_AGENT = "codex_cli_rs/0.0.0 (Unknown 0; unknown) unknown" +KIMI_CODE_API_HOST = "api.kimi.com" +KIMI_CODE_DEFAULT_USER_AGENT = "KimiCLI/1.3" + + +def normalize_kimi_code_base_url(base_url: str) -> str: + """Ensure Kimi Code API base URLs include the /v1 suffix.""" + parsed = urlparse(base_url) + if parsed.hostname != KIMI_CODE_API_HOST: + return base_url + path = parsed.path.rstrip("/") + if path.endswith("/coding"): + return f"{parsed.scheme}://{parsed.netloc}{path}/v1" + return base_url + + +def apply_kimi_code_provider_defaults(model_provider: dict) -> None: + """Inject Kimi Code API defaults (User-Agent, normalized base URL).""" + base_url = model_provider.get("base_url") + if not base_url: + return + parsed = urlparse(base_url) + model_id = model_provider.get("model", "") + is_kimi_code = ( + parsed.hostname == KIMI_CODE_API_HOST or model_id == "kimi-for-coding" + ) + if not is_kimi_code: + return + + normalized = normalize_kimi_code_base_url(base_url) + if normalized != base_url: + model_provider["base_url"] = normalized + + headers = model_provider.setdefault("headers", {}) + headers.setdefault("User-Agent", KIMI_CODE_DEFAULT_USER_AGENT) + + SUPPORTED_PROVIDERS = ( SUPPORTED_PROVIDERS_WITHOUT_BASE_URL + SUPPORTED_PROVIDERS_WITH_BASE_URL ) @@ -463,6 +499,8 @@ def validate_and_render_schema(): headers.setdefault("session_id", str(uuid.uuid4())) model_provider["headers"] = headers + apply_kimi_code_provider_defaults(model_provider) + updated_model_providers.append(model_provider) if model_provider.get("base_url", None): diff --git a/cli/test/test_config_generator.py b/cli/test/test_config_generator.py index 77b5b4803..cf623e6cf 100644 --- a/cli/test/test_config_generator.py +++ b/cli/test/test_config_generator.py @@ -3,8 +3,10 @@ import yaml from unittest import mock from planoai.config_generator import ( - validate_and_render_schema, + apply_kimi_code_provider_defaults, migrate_inline_routing_preferences, + normalize_kimi_code_base_url, + validate_and_render_schema, ) @@ -738,3 +740,29 @@ def test_migration_does_not_downgrade_newer_versions(): migrate_inline_routing_preferences(config_yaml) assert config_yaml["version"] == "v0.5.0" + + +def test_normalize_kimi_code_base_url_appends_v1_suffix(): + assert ( + normalize_kimi_code_base_url("https://api.kimi.com/coding") + == "https://api.kimi.com/coding/v1" + ) + assert ( + normalize_kimi_code_base_url("https://api.kimi.com/coding/") + == "https://api.kimi.com/coding/v1" + ) + assert ( + normalize_kimi_code_base_url("https://api.kimi.com/coding/v1") + == "https://api.kimi.com/coding/v1" + ) + + +def test_apply_kimi_code_provider_defaults_injects_user_agent(): + provider = { + "model": "kimi-for-coding", + "base_url": "https://api.kimi.com/coding", + "access_key": "$MOONSHOTAI_API_KEY", + } + apply_kimi_code_provider_defaults(provider) + assert provider["base_url"] == "https://api.kimi.com/coding/v1" + assert provider["headers"]["User-Agent"] == "KimiCLI/1.3" diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml index 9560b4376..2ecf38921 100644 --- a/config/plano_config_schema.yaml +++ b/config/plano_config_schema.yaml @@ -194,6 +194,7 @@ properties: - digitalocean - vercel - openrouter + - moonshotai headers: type: object additionalProperties: @@ -252,6 +253,7 @@ properties: - digitalocean - vercel - openrouter + - moonshotai headers: type: object additionalProperties: diff --git a/crates/hermesllm/src/apis/openai.rs b/crates/hermesllm/src/apis/openai.rs index bb93fd34f..514e8b245 100644 --- a/crates/hermesllm/src/apis/openai.rs +++ b/crates/hermesllm/src/apis/openai.rs @@ -1,3 +1,4 @@ +use log::warn; use serde::{Deserialize, Serialize}; use serde_json::Value; use serde_with::skip_serializing_none; @@ -136,6 +137,39 @@ impl ChatCompletionsRequest { self.temperature = Some(1.0); } } + + /// Strip request fields that Kimi Code API (`kimi-for-coding`) rejects or mishandles. + pub fn normalize_for_kimi_code_api(&mut self) { + if self.stream_options.is_some() { + warn!("kimi-for-coding: stripping unsupported stream_options from upstream request"); + self.stream_options = None; + } + if self.reasoning_effort.is_some() { + warn!( + "kimi-for-coding: stripping unsupported reasoning_effort from upstream request" + ); + self.reasoning_effort = None; + } + if self.web_search_options.is_some() { + warn!( + "kimi-for-coding: stripping unsupported web_search_options from upstream request" + ); + self.web_search_options = None; + } + if self.service_tier.is_some() { + warn!("kimi-for-coding: stripping unsupported service_tier from upstream request"); + self.service_tier = None; + } + if self.store.is_some() { + warn!("kimi-for-coding: stripping unsupported store from upstream request"); + self.store = None; + } + } +} + +/// True when the upstream model id is Moonshot's Kimi Code endpoint model. +pub fn is_kimi_code_model(model: &str) -> bool { + model == "kimi-for-coding" } // ============================================================================ diff --git a/crates/hermesllm/src/bin/provider_models.yaml b/crates/hermesllm/src/bin/provider_models.yaml index 2e9e0a9b4..ccc4416fc 100644 --- a/crates/hermesllm/src/bin/provider_models.yaml +++ b/crates/hermesllm/src/bin/provider_models.yaml @@ -312,6 +312,7 @@ providers: - deepseek/deepseek-chat - deepseek/deepseek-reasoner moonshotai: + - moonshotai/kimi-for-coding - moonshotai/kimi-k2-thinking - moonshotai/moonshot-v1-auto - moonshotai/moonshot-v1-32k-vision-preview diff --git a/crates/hermesllm/src/clients/endpoints.rs b/crates/hermesllm/src/clients/endpoints.rs index eeef88565..d7a9b4719 100644 --- a/crates/hermesllm/src/clients/endpoints.rs +++ b/crates/hermesllm/src/clients/endpoints.rs @@ -500,6 +500,19 @@ mod tests { "/custom/api/v2/chat/completions" ); + // Kimi Code API: base_url path prefix already includes /coding/v1 + assert_eq!( + api.target_endpoint_for_provider( + &ProviderId::Moonshotai, + "/v1/messages", + "kimi-for-coding", + false, + Some("/coding/v1"), + false + ), + "/coding/v1/chat/completions" + ); + // Test Groq with custom prefix assert_eq!( api.target_endpoint_for_provider( diff --git a/crates/hermesllm/src/providers/request.rs b/crates/hermesllm/src/providers/request.rs index aa100a175..bcc0eafd2 100644 --- a/crates/hermesllm/src/providers/request.rs +++ b/crates/hermesllm/src/providers/request.rs @@ -1,5 +1,6 @@ use crate::apis::anthropic::MessagesRequest; -use crate::apis::openai::ChatCompletionsRequest; +use crate::apis::openai::{is_kimi_code_model, ChatCompletionsRequest}; +use log::warn; use crate::apis::amazon_bedrock::{ConverseRequest, ConverseStreamRequest}; use crate::apis::openai_responses::ResponsesAPIRequest; @@ -90,6 +91,24 @@ impl ProviderRequestType { } } + if matches!( + upstream_api, + SupportedUpstreamAPIs::OpenAIChatCompletions(_) + ) { + if let Self::ChatCompletionsRequest(req) = self { + if is_kimi_code_model(req.model()) { + req.normalize_for_kimi_code_api(); + } + } else if let Self::MessagesRequest(req) = self { + if is_kimi_code_model(req.model.as_str()) && req.thinking.is_some() { + warn!( + "kimi-for-coding: stripping unsupported thinking config from upstream request" + ); + req.thinking = None; + } + } + } + // ChatGPT requires instructions, store=false, and input as a list if provider_id == ProviderId::ChatGPT { if let Self::ResponsesAPIRequest(req) = self { @@ -879,6 +898,42 @@ mod tests { assert!(req.web_search_options.is_none()); } + #[test] + fn test_normalize_for_upstream_kimi_code_strips_unsupported_chat_fields() { + use crate::apis::openai::{Message, MessageContent, OpenAIApi, Role, StreamOptions}; + + let mut request = ProviderRequestType::ChatCompletionsRequest(ChatCompletionsRequest { + model: "kimi-for-coding".to_string(), + messages: vec![Message { + role: Role::User, + content: Some(MessageContent::Text("hello".to_string())), + name: None, + tool_calls: None, + tool_call_id: None, + }], + stream_options: Some(StreamOptions { + include_usage: Some(true), + }), + reasoning_effort: Some("high".to_string()), + web_search_options: Some(serde_json::json!({"search_context_size":"medium"})), + ..Default::default() + }); + + request + .normalize_for_upstream( + ProviderId::Moonshotai, + &SupportedUpstreamAPIs::OpenAIChatCompletions(OpenAIApi::ChatCompletions), + ) + .unwrap(); + + let ProviderRequestType::ChatCompletionsRequest(req) = request else { + panic!("expected chat request"); + }; + assert!(req.stream_options.is_none()); + assert!(req.reasoning_effort.is_none()); + assert!(req.web_search_options.is_none()); + } + #[test] fn test_normalize_for_upstream_non_xai_keeps_chat_web_search_options() { use crate::apis::openai::{Message, MessageContent, OpenAIApi, Role}; diff --git a/docs/source/concepts/llm_providers/supported_providers.rst b/docs/source/concepts/llm_providers/supported_providers.rst index 60f468e0a..d95340f4b 100644 --- a/docs/source/concepts/llm_providers/supported_providers.rst +++ b/docs/source/concepts/llm_providers/supported_providers.rst @@ -432,6 +432,9 @@ Moonshot AI * - Model Name - Model ID for Config - Description + * - Kimi for Coding + - ``moonshotai/kimi-for-coding`` + - Kimi Code API model for agentic coding (use with ``base_url: https://api.kimi.com/coding/v1``) * - Kimi K2 Preview - ``moonshotai/kimi-k2-0905-preview`` - Foundation model optimized for agentic tasks with 32B activated parameters @@ -447,6 +450,13 @@ Moonshot AI .. code-block:: yaml llm_providers: + # Kimi Code API (Claude Code / agentic clients via Plano translation) + - model: moonshotai/kimi-for-coding + access_key: $MOONSHOTAI_API_KEY + base_url: https://api.kimi.com/coding/v1 + headers: + User-Agent: "KimiCLI/1.3" + # Latest K2 models for agentic tasks - model: moonshotai/kimi-k2-0905-preview access_key: $MOONSHOTAI_API_KEY