From 7e65eafbe4f2b8136808653da100c2a2c5f02b2e Mon Sep 17 00:00:00 2001 From: alfadb Date: Sun, 26 Jul 2026 16:16:44 +0800 Subject: [PATCH] feat: add Kimi K3 support --- backend/internal/config/config.go | 2 + backend/internal/config/config_test.go | 15 ++++++ backend/internal/service/billing_service.go | 19 ++++++- .../internal/service/billing_service_test.go | 49 +++++++++++++++++++ .../internal/service/openai_model_mapping.go | 12 +++-- .../openai_oauth_model_support_test.go | 13 ++++- backend/internal/service/thinking_protocol.go | 9 +++- .../service/thinking_protocol_test.go | 6 +++ deploy/config.example.yaml | 2 + 9 files changed, 120 insertions(+), 7 deletions(-) diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 3bd071e9a1..cbb85ca439 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -1881,6 +1881,8 @@ func setDefaults() { "api.openai.com", "api.anthropic.com", "api.kimi.com", + "api.moonshot.ai", + "api.moonshot.cn", "open.bigmodel.cn", "api.minimaxi.com", "generativelanguage.googleapis.com", diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index f67fbb4cc1..c22c0e8852 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -755,6 +755,21 @@ func TestLoadDefaultSecurityToggles(t *testing.T) { if !cfg.Security.ResponseHeaders.Enabled { t.Fatalf("ResponseHeaders.Enabled = false, want true") } + + wantHosts := []string{ + "api.kimi.com", + "api.moonshot.ai", + "api.moonshot.cn", + } + hostSet := make(map[string]struct{}, len(cfg.Security.URLAllowlist.UpstreamHosts)) + for _, h := range cfg.Security.URLAllowlist.UpstreamHosts { + hostSet[h] = struct{}{} + } + for _, want := range wantHosts { + if _, ok := hostSet[want]; !ok { + t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts) + } + } } func TestLoadDefaultServerMode(t *testing.T) { diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index 748eaf0097..01f93285d7 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -485,6 +485,15 @@ func (s *BillingService) initFallbackPricing() { // 交叉验证:https://www.tmtpost.com/7961404.html (USD 口径) // Moonshot V1 (¥2/¥5/¥10 多 tier) 公开页未直接标注 USD 价,本分支不覆盖,避免误计价。 // K2-0905 / K2-0711 官方页面未保留定价,不覆盖。 + // Kimi K3 国际站 USD 价目:https://platform.kimi.ai/docs/pricing/chat-k3.md + // Kimi Code bare aliases(k3 / k3-256k)官方无按 token 价目;复用 API Platform + // kimi-k3 档位作代理计费 fallback(同 kimi-for-coding 对 K2.6 的处理口径)。 + s.fallbackPrices["kimi-k3"] = &ModelPricing{ + InputPricePerToken: 3e-6, // $3.00 per MTok (cache miss) + OutputPricePerToken: 15e-6, // $15.00 per MTok + CacheReadPricePerToken: 0.30e-6, // $0.30 per MTok (cache hit) + SupportsCacheBreakdown: false, + } s.fallbackPrices["kimi-k2.6"] = &ModelPricing{ InputPricePerToken: 0.95e-6, // $0.95 per MTok (cache miss) OutputPricePerToken: 4e-6, // $4.00 per MTok @@ -703,11 +712,19 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { return s.fallbackPrices["glm-4-32b-0414-128k"] } - // 月之暗面 Kimi(kimi-k2.6 / kimi-for-coding / kimi-k2.5 / kimi-k2-thinking / kimi-k2) + // 月之暗面 Kimi(kimi-k3 / k3 / k3-256k / kimi-k2.6 / kimi-for-coding / kimi-k2.5 / kimi-k2-thinking / kimi-k2) // K2-0905 / K2-0711 官方未保留定价,不进入 fallback。 + // K3 规则置于 K2 前:API Platform 仅官方 kimi-k3 / kimi-k3[1m](及 / 路径后缀); + // Code bare aliases 仅精确 k3 / k3-256k 或 /k3|/k3-256k 后缀,避免 kimi-k30 等未知型号误命中。 if strings.Contains(modelLower, "kimi-for-coding") { return s.fallbackPrices["kimi-for-coding"] } + if modelLower == "kimi-k3" || modelLower == "kimi-k3[1m]" || + strings.HasSuffix(modelLower, "/kimi-k3") || strings.HasSuffix(modelLower, "/kimi-k3[1m]") || + modelLower == "k3" || modelLower == "k3-256k" || + strings.HasSuffix(modelLower, "/k3") || strings.HasSuffix(modelLower, "/k3-256k") { + return s.fallbackPrices["kimi-k3"] + } if strings.Contains(modelLower, "kimi-k2.6") || strings.Contains(modelLower, "kimi-k2-6") { return s.fallbackPrices["kimi-k2.6"] } diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 265fbf4fa4..e1f2bd7b22 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -589,6 +589,48 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { }, // ---- 月之暗面 Kimi ---- + { + name: "kimi k3 flagship", + model: "kimi-k3", + expectedInput: 3e-6, + expectedOutput: floatPtr(15e-6), + expectedCacheRead: floatPtr(0.30e-6), + }, + { + name: "kimi k3 anthropic 1m suffix", + model: "kimi-k3[1m]", + expectedInput: 3e-6, + expectedOutput: floatPtr(15e-6), + expectedCacheRead: floatPtr(0.30e-6), + }, + { + name: "kimi code bare alias k3", + model: "k3", + expectedInput: 3e-6, + expectedOutput: floatPtr(15e-6), + expectedCacheRead: floatPtr(0.30e-6), + }, + { + name: "kimi code bare alias k3-256k", + model: "k3-256k", + expectedInput: 3e-6, + expectedOutput: floatPtr(15e-6), + expectedCacheRead: floatPtr(0.30e-6), + }, + { + name: "kimi k3 path suffix moonshot", + model: "moonshot/kimi-k3[1m]", + expectedInput: 3e-6, + expectedOutput: floatPtr(15e-6), + expectedCacheRead: floatPtr(0.30e-6), + }, + { + name: "kimi code bare path suffix", + model: "kimi-code/k3", + expectedInput: 3e-6, + expectedOutput: floatPtr(15e-6), + expectedCacheRead: floatPtr(0.30e-6), + }, { name: "kimi k2.6 flagship", model: "kimi-k2.6", @@ -704,6 +746,13 @@ func TestGetFallbackPricing_FamilyMatching(t *testing.T) { {name: "doubao text embedding no fallback", model: "doubao-embedding-text-240515", expectNilPricing: true}, {name: "hunyuan unknown no fallback", model: "hunyuan-t1", expectNilPricing: true}, {name: "moonshot v1 not covered", model: "moonshot-v1-8k", expectNilPricing: true}, + // bare k3 仅精确/后缀匹配:相似未知型号不得因含 "k3" 误命中。 + {name: "k3-like unknown no fallback", model: "foo-k3-bar", expectNilPricing: true}, + // 路径最后一段不是 /k3:foo-k3 不得因 HasSuffix("/k3") 或 Contains 误命中。 + {name: "path segment not bare k3 no fallback", model: "vendor/foo-k3", expectNilPricing: true}, + // kimi-k3 非 Contains:kimi-k30 / 内嵌 foo-kimi-k3-bar 不得误命中。 + {name: "kimi-k30 unknown no fallback", model: "kimi-k30", expectNilPricing: true}, + {name: "embedded kimi-k3 unknown no fallback", model: "foo-kimi-k3-bar", expectNilPricing: true}, // kimi-k2-0905 / kimi-k2-0711 官方未公布独立价,走 kimi-k2 隐性回退(接受)—— // 如未来官方公布独立价,需在 getFallbackPricing 加显式分支。 { diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 8ba1d6fe1b..0eb0672f8d 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -60,14 +60,20 @@ var openAIOAuthForeignModelPrefixes = []string{ // isOpenAIOAuthServableModel 判断「空 model_mapping 的 OpenAI OAuth 账号」能否 // 服务请求模型。空映射默认仍是「允许」,仅排除明确属于其他厂商家族的模型 -// (deepseek-*/glm-* 等)——这类请求原样透传必然被 Codex 上游以不可重试的 -// 400 拒绝,且不触发 failover,应在调度阶段就跳过该账号,把请求让给 -// 显式声明支持该模型的账号(#3662)。 +// (deepseek-*/glm-*、以及 Kimi Code 官方 bare ID k3 / k3-256k 等)——这类 +// 请求原样透传必然被 Codex 上游以不可重试的 400 拒绝,且不触发 failover, +// 应在调度阶段就跳过该账号,把请求让给显式声明支持该模型的账号(#3662)。 +// bare k3 仅精确匹配(取 last segment 后),不使用宽泛 k3- 前缀,以免误伤 +// 自定义别名;显式 model_mapping 命中路径不经过本函数,语义不变。 func isOpenAIOAuthServableModel(requestedModel string) bool { model := strings.ToLower(lastOpenAIModelSegment(requestedModel)) if model == "" { return true // 空模型交由上层必填校验处理 } + // Kimi Code 官方 bare model ID:无厂商前缀,prefix 黑名单挡不住。 + if model == "k3" || model == "k3-256k" { + return false + } for _, prefix := range openAIOAuthForeignModelPrefixes { if strings.HasPrefix(model, prefix) { return false diff --git a/backend/internal/service/openai_oauth_model_support_test.go b/backend/internal/service/openai_oauth_model_support_test.go index 1350dcd3bf..8b412d4af7 100644 --- a/backend/internal/service/openai_oauth_model_support_test.go +++ b/backend/internal/service/openai_oauth_model_support_test.go @@ -49,6 +49,9 @@ func TestIsModelSupported_OpenAIOAuthEmptyMapping_RejectsForeignModels(t *testin "deepseek-chat", "glm-4.7", "kimi-k2", + "k3", // Kimi Code bare ID(无厂商前缀,需精确拒绝) + "k3-256k", // Kimi Code bare ID + "provider/k3", // vendor/model 取 last segment 后仍为 k3 "moonshot-v1-128k", "gemini-3.0-pro", "grok-4", @@ -65,11 +68,15 @@ func TestIsModelSupported_OpenAIOAuthEmptyMapping_RejectsForeignModels(t *testin func TestIsModelSupported_OpenAIOAuthExplicitMappingUnchanged(t *testing.T) { account := newOpenAIOAuthAccountForModelTest() account.Credentials = map[string]any{ - "model_mapping": map[string]any{"deepseek-v4": "gpt-5.4"}, + "model_mapping": map[string]any{ + "deepseek-v4": "gpt-5.4", + "k3": "gpt-5.4", // 显式映射优先:bare k3 仍可被账号声明支持 + }, } // 显式映射沿用原有语义:命中映射即支持,未命中即不支持。 require.True(t, account.IsModelSupported("deepseek-v4")) + require.True(t, account.IsModelSupported("k3")) require.False(t, account.IsModelSupported("glm-4.7")) } @@ -107,4 +114,8 @@ func TestIsOpenAIOAuthServableModel(t *testing.T) { require.False(t, isOpenAIOAuthServableModel("DeepSeek-V4")) // 大小写不敏感 require.False(t, isOpenAIOAuthServableModel("qwen3-235b-thinking")) require.True(t, isOpenAIOAuthServableModel("deepseekcoder")) // 无连字符 → 非黑名单前缀,保持允许 + require.False(t, isOpenAIOAuthServableModel("k3")) + require.False(t, isOpenAIOAuthServableModel("k3-256k")) + require.False(t, isOpenAIOAuthServableModel("provider/k3")) + require.True(t, isOpenAIOAuthServableModel("my-k3-alias")) // 非精确 bare ID,自定义别名 fail-open } diff --git a/backend/internal/service/thinking_protocol.go b/backend/internal/service/thinking_protocol.go index 686baf467d..a5ea235c4d 100644 --- a/backend/internal/service/thinking_protocol.go +++ b/backend/internal/service/thinking_protocol.go @@ -39,7 +39,8 @@ const ( // 匹配规则按厂商前缀硬编码: // - anthropic-strict: claude-* / opus-* / sonnet-* / haiku-* // - passback-required: deepseek-* / kimi-* / moonshot-* / glm-* / -// minimax-* / minimax-m* / (qwen-|qwen2-|qwen3-|qwen4-)*-thinking +// minimax-* / minimax-m* / (qwen-|qwen2-|qwen3-|qwen4-)*-thinking / +// Kimi Code bare aliases k3 / k3-256k(精确匹配,避免宽泛 k3 前缀) // - unknown: 其他模型(保守不剥离) // // 已知局限:前缀贪婪匹配(如 `claudette-`、`claude-foreign-relay-` 也会被分类为 @@ -56,11 +57,15 @@ func ResolveThinkingProtocol(modelID string) ThinkingProtocol { id := strings.ToLower(modelID) // Passback-required 优先匹配(特定厂商前缀),避免误判 claude-* 时也命中。 + // kimi-k3* 已由 kimi- 前缀覆盖;Kimi Code endpoint 的 bare model ID(k3 / k3-256k) + // 无厂商前缀,仅精确匹配,避免 "foo-k3" 等未知型号被宽泛前缀误判。 switch { case strings.HasPrefix(id, "deepseek-"), strings.HasPrefix(id, "kimi-"), strings.HasPrefix(id, "moonshot-"), - strings.HasPrefix(id, "glm-"): + strings.HasPrefix(id, "glm-"), + id == "k3", + id == "k3-256k": return ThinkingProtocolPassbackRequired } // MiniMax M 系列:走 https://api.minimax.io/anthropic 端点, diff --git a/backend/internal/service/thinking_protocol_test.go b/backend/internal/service/thinking_protocol_test.go index 3ca860bb1c..e16cfdec58 100644 --- a/backend/internal/service/thinking_protocol_test.go +++ b/backend/internal/service/thinking_protocol_test.go @@ -22,6 +22,10 @@ func TestResolveThinkingProtocol(t *testing.T) { {"deepseek-r2-thinking", "deepseek-r2-thinking", ThinkingProtocolPassbackRequired}, {"kimi-coding", "kimi-coding-v2", ThinkingProtocolPassbackRequired}, {"kimi-k2-thinking", "kimi-k2-thinking", ThinkingProtocolPassbackRequired}, + {"kimi-k3 platform", "kimi-k3", ThinkingProtocolPassbackRequired}, + {"kimi-k3 anthropic 1m", "kimi-k3[1m]", ThinkingProtocolPassbackRequired}, + {"kimi code bare k3", "k3", ThinkingProtocolPassbackRequired}, + {"kimi code bare k3-256k", "k3-256k", ThinkingProtocolPassbackRequired}, {"moonshot-v1", "moonshot-v1-32k", ThinkingProtocolPassbackRequired}, {"glm-5.1", "glm-5.1", ThinkingProtocolPassbackRequired}, {"qwen-2 thinking variant", "qwen-2-72b-thinking", ThinkingProtocolPassbackRequired}, @@ -44,6 +48,8 @@ func TestResolveThinkingProtocol(t *testing.T) { {"qwen3 non-thinking", "qwen3-32b", ThinkingProtocolUnknown}, {"qwen2 non-thinking", "qwen-2-72b", ThinkingProtocolUnknown}, {"random vendor", "yi-large", ThinkingProtocolUnknown}, + // 相似但未知的 k3 型号:不得因含 k3 被宽泛匹配为 passback-required + {"k3-like unknown", "foo-k3-bar", ThinkingProtocolUnknown}, // MiniMax 非 M 系列(如 abab、speech 等其他产品线)—— unknown {"minimax abab non-M", "abab6.5-chat", ThinkingProtocolUnknown}, // Doubao 走 OpenAI 协议,不属于本网关 Anthropic 路径——归 unknown diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 535a599845..f737ce5bba 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -127,6 +127,8 @@ security: - "api.openai.com" - "api.anthropic.com" - "api.kimi.com" + - "api.moonshot.ai" + - "api.moonshot.cn" - "open.bigmodel.cn" - "api.minimaxi.com" - "generativelanguage.googleapis.com"