mirror of
https://github.com/katanemo/plano.git
synced 2026-06-05 14:45:15 +02:00
Support Kimi Code API for Claude Code routing (#951)
* Support Kimi Code API and Claude Code protocol compatibility Co-authored-by: Musa <musa@spherrrical.dev> * Fix black formatting in config_generator Co-authored-by: Musa <musa@spherrrical.dev> * Warn when stripping unsupported Kimi Code request fields Co-authored-by: Musa <musa@spherrrical.dev> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
parent
554a3d1f6a
commit
f3d6ea41ad
8 changed files with 183 additions and 2 deletions
|
|
@ -39,6 +39,42 @@ CHATGPT_API_BASE = "https://chatgpt.com/backend-api/codex"
|
||||||
CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs"
|
CHATGPT_DEFAULT_ORIGINATOR = "codex_cli_rs"
|
||||||
CHATGPT_DEFAULT_USER_AGENT = "codex_cli_rs/0.0.0 (Unknown 0; unknown) unknown"
|
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 = (
|
||||||
SUPPORTED_PROVIDERS_WITHOUT_BASE_URL + SUPPORTED_PROVIDERS_WITH_BASE_URL
|
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()))
|
headers.setdefault("session_id", str(uuid.uuid4()))
|
||||||
model_provider["headers"] = headers
|
model_provider["headers"] = headers
|
||||||
|
|
||||||
|
apply_kimi_code_provider_defaults(model_provider)
|
||||||
|
|
||||||
updated_model_providers.append(model_provider)
|
updated_model_providers.append(model_provider)
|
||||||
|
|
||||||
if model_provider.get("base_url", None):
|
if model_provider.get("base_url", None):
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ import pytest
|
||||||
import yaml
|
import yaml
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from planoai.config_generator import (
|
from planoai.config_generator import (
|
||||||
validate_and_render_schema,
|
apply_kimi_code_provider_defaults,
|
||||||
migrate_inline_routing_preferences,
|
migrate_inline_routing_preferences,
|
||||||
|
normalize_kimi_code_base_url,
|
||||||
|
validate_and_render_schema,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -795,3 +797,29 @@ model_providers:
|
||||||
migrate_inline_routing_preferences(config_yaml)
|
migrate_inline_routing_preferences(config_yaml)
|
||||||
|
|
||||||
assert config_yaml["version"] == "v0.5.0"
|
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"
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,7 @@ properties:
|
||||||
- digitalocean
|
- digitalocean
|
||||||
- vercel
|
- vercel
|
||||||
- openrouter
|
- openrouter
|
||||||
|
- moonshotai
|
||||||
headers:
|
headers:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
|
|
@ -252,6 +253,7 @@ properties:
|
||||||
- digitalocean
|
- digitalocean
|
||||||
- vercel
|
- vercel
|
||||||
- openrouter
|
- openrouter
|
||||||
|
- moonshotai
|
||||||
headers:
|
headers:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties:
|
additionalProperties:
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use log::warn;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use serde_with::skip_serializing_none;
|
use serde_with::skip_serializing_none;
|
||||||
|
|
@ -136,6 +137,39 @@ impl ChatCompletionsRequest {
|
||||||
self.temperature = Some(1.0);
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,7 @@ providers:
|
||||||
- deepseek/deepseek-chat
|
- deepseek/deepseek-chat
|
||||||
- deepseek/deepseek-reasoner
|
- deepseek/deepseek-reasoner
|
||||||
moonshotai:
|
moonshotai:
|
||||||
|
- moonshotai/kimi-for-coding
|
||||||
- moonshotai/kimi-k2-thinking
|
- moonshotai/kimi-k2-thinking
|
||||||
- moonshotai/moonshot-v1-auto
|
- moonshotai/moonshot-v1-auto
|
||||||
- moonshotai/moonshot-v1-32k-vision-preview
|
- moonshotai/moonshot-v1-32k-vision-preview
|
||||||
|
|
|
||||||
|
|
@ -500,6 +500,19 @@ mod tests {
|
||||||
"/custom/api/v2/chat/completions"
|
"/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
|
// Test Groq with custom prefix
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
api.target_endpoint_for_provider(
|
api.target_endpoint_for_provider(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::apis::anthropic::MessagesRequest;
|
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::amazon_bedrock::{ConverseRequest, ConverseStreamRequest};
|
||||||
use crate::apis::openai_responses::ResponsesAPIRequest;
|
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
|
// ChatGPT requires instructions, store=false, and input as a list
|
||||||
if provider_id == ProviderId::ChatGPT {
|
if provider_id == ProviderId::ChatGPT {
|
||||||
if let Self::ResponsesAPIRequest(req) = self {
|
if let Self::ResponsesAPIRequest(req) = self {
|
||||||
|
|
@ -879,6 +898,42 @@ mod tests {
|
||||||
assert!(req.web_search_options.is_none());
|
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]
|
#[test]
|
||||||
fn test_normalize_for_upstream_non_xai_keeps_chat_web_search_options() {
|
fn test_normalize_for_upstream_non_xai_keeps_chat_web_search_options() {
|
||||||
use crate::apis::openai::{Message, MessageContent, OpenAIApi, Role};
|
use crate::apis::openai::{Message, MessageContent, OpenAIApi, Role};
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,9 @@ Moonshot AI
|
||||||
* - Model Name
|
* - Model Name
|
||||||
- Model ID for Config
|
- Model ID for Config
|
||||||
- Description
|
- 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
|
* - Kimi K2 Preview
|
||||||
- ``moonshotai/kimi-k2-0905-preview``
|
- ``moonshotai/kimi-k2-0905-preview``
|
||||||
- Foundation model optimized for agentic tasks with 32B activated parameters
|
- Foundation model optimized for agentic tasks with 32B activated parameters
|
||||||
|
|
@ -447,6 +450,13 @@ Moonshot AI
|
||||||
.. code-block:: yaml
|
.. code-block:: yaml
|
||||||
|
|
||||||
llm_providers:
|
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
|
# Latest K2 models for agentic tasks
|
||||||
- model: moonshotai/kimi-k2-0905-preview
|
- model: moonshotai/kimi-k2-0905-preview
|
||||||
access_key: $MOONSHOTAI_API_KEY
|
access_key: $MOONSHOTAI_API_KEY
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue