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
38 changes: 38 additions & 0 deletions cli/planoai/config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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):
Expand Down
30 changes: 29 additions & 1 deletion cli/test/test_config_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down Expand Up @@ -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"
2 changes: 2 additions & 0 deletions config/plano_config_schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ properties:
- digitalocean
- vercel
- openrouter
- moonshotai
headers:
type: object
additionalProperties:
Expand Down Expand Up @@ -252,6 +253,7 @@ properties:
- digitalocean
- vercel
- openrouter
- moonshotai
headers:
type: object
additionalProperties:
Expand Down
34 changes: 34 additions & 0 deletions crates/hermesllm/src/apis/openai.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use log::warn;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use serde_with::skip_serializing_none;
Expand Down Expand Up @@ -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;
}
}
Comment on lines +142 to +167
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if they are set? Would we silently ignore these fields? I think we should emit warn if any of these fields are set so not to cause any unwanted behavior.

}

/// 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"
}

// ============================================================================
Expand Down
1 change: 1 addition & 0 deletions crates/hermesllm/src/bin/provider_models.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions crates/hermesllm/src/clients/endpoints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
57 changes: 56 additions & 1 deletion crates/hermesllm/src/providers/request.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here if thinking is set then we should emit a warn

}
}
}

// ChatGPT requires instructions, store=false, and input as a list
if provider_id == ProviderId::ChatGPT {
if let Self::ResponsesAPIRequest(req) = self {
Expand Down Expand Up @@ -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};
Expand Down
10 changes: 10 additions & 0 deletions docs/source/concepts/llm_providers/supported_providers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Loading