From e377c4358c49c1b2ca47d5e5678de5ee39759d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=88=98=E4=BD=B3=E6=9E=97?= Date: Tue, 1 Sep 2026 16:50:04 +0800 Subject: [PATCH] feat(kimi): forward native OpenAI Responses API Moonshot pay-as-you-go and Kimi For Coding both expose native /v1/responses. Stop converting Kimi Responses traffic to Chat Completions, and treat Kimi like DeepSeek for protocol selection while keeping /v1/responses URL construction. --- backend/internal/domain/constants.go | 2 +- backend/internal/service/account.go | 35 +++++++++- .../account_test_service_cn_adaptive.go | 10 +-- .../account_test_service_cn_adaptive_test.go | 69 +++++++++++-------- .../service/adaptive_api_protocol_test.go | 53 ++++++++++++-- backend/internal/service/cn_providers_test.go | 33 ++++++++- .../service/openai_apikey_responses_probe.go | 12 ++-- .../openai_apikey_responses_probe_test.go | 3 +- .../openai_gateway_chat_completions.go | 6 +- .../openai_gateway_chat_completions_raw.go | 3 +- .../service/openai_gateway_forward.go | 16 ++--- .../service/openai_gateway_passthrough.go | 5 +- .../service/openai_gateway_request_body.go | 10 +-- .../service/openai_ws_forwarder_payload.go | 3 + .../components/account/CreateAccountModal.vue | 11 +-- .../components/account/EditAccountModal.vue | 9 +-- .../__tests__/CreateAccountModal.spec.ts | 26 ++++++- .../__tests__/EditAccountModal.spec.ts | 33 +++++++++ .../credentialsBuilder.cnAdaptive.spec.ts | 15 +++- .../components/account/credentialsBuilder.ts | 15 ++-- 20 files changed, 280 insertions(+), 89 deletions(-) diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 1ed90ee940..388cab606c 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -43,7 +43,7 @@ const ( const ( APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认) APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code) - APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex) + APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex) APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点 ) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 3967e407bd..ad1ad82a1b 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -1387,8 +1387,8 @@ func (a *Account) IsCodingPlan() bool { // GetAPIProtocol 返回国产供应商账号的上游 API 协议。存储于 // credentials["api_protocol"];缺失或与平台不匹配时回退 chat_completions -// (与既有行为完全一致)。responses 协议仅 deepseek 支持(官方原生 /responses -// 端点,适配 Codex);kimi/zhipu 无此端点。 +// (与既有行为完全一致)。responses 协议仅 deepseek / kimi 支持(官方原生 +// Responses 端点,适配 Codex);zhipu 无此端点。 func (a *Account) GetAPIProtocol() string { if a == nil || !a.IsCNProvider() { return APIProtocolChatCompletions @@ -1399,7 +1399,7 @@ func (a *Account) GetAPIProtocol() string { case APIProtocolAnthropic: return APIProtocolAnthropic case APIProtocolResponses: - if a.Platform == PlatformDeepseek { + if a.SupportsNativeCNResponses() { return APIProtocolResponses } case APIProtocolChatCompletions: @@ -1408,6 +1408,35 @@ func (a *Account) GetAPIProtocol() string { return APIProtocolChatCompletions } +// SupportsNativeCNResponses 报告该国产供应商是否提供原生 Responses 端点。 +// DeepSeek 官方为 /responses(无 /v1);Kimi 按量付费与 Coding Plan 均为 +// /v1/responses(moonshot.cn / kimi.com/coding)。 +func (a *Account) SupportsNativeCNResponses() bool { + if a == nil { + return false + } + switch a.Platform { + case PlatformDeepseek, PlatformKimi: + return true + default: + return false + } +} + +// UsesNativeCNResponses 报告当前账号是否应按原生 Responses 协议转发 +// (显式 responses,或 adaptive 且平台具备原生端点)。 +func (a *Account) UsesNativeCNResponses() bool { + if a == nil || !a.SupportsNativeCNResponses() { + return false + } + switch a.GetAPIProtocol() { + case APIProtocolResponses, APIProtocolAdaptive: + return true + default: + return false + } +} + // IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。 func (a *Account) IsAdaptiveAPIProtocol() bool { return a.GetAPIProtocol() == APIProtocolAdaptive diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go index 8811fd840f..db7b2418e1 100644 --- a/backend/internal/service/account_test_service_cn_adaptive.go +++ b/backend/internal/service/account_test_service_cn_adaptive.go @@ -18,8 +18,8 @@ import ( const accountTestSuppressCompletionContextKey = "account_test_suppress_completion" // testCNProviderAdaptiveConnection verifies every native endpoint used by an -// adaptive CN-provider account. Kimi and Zhipu use Chat Completions plus -// Anthropic; DeepSeek additionally uses its native Responses endpoint. +// adaptive CN-provider account. Zhipu uses Chat Completions plus Anthropic; +// DeepSeek and Kimi additionally use their native Responses endpoints. func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error { testModelID := strings.TrimSpace(modelID) if testModelID == "" { @@ -44,7 +44,7 @@ func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, ac return err } - if account.Platform == PlatformDeepseek { + if account.SupportsNativeCNResponses() { if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil { return err } @@ -159,8 +159,8 @@ func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Co apiURL := buildOpenAIResponsesURLForPlatform(account.Platform, baseURL) payload := createOpenAITestPayload(testModelID, false) - // DeepSeek's native Responses endpoint is stateless and does not need the - // OpenAI probe's synthetic instructions. + // DeepSeek / Kimi native Responses endpoints are stateless and do not need + // the OpenAI probe's synthetic instructions. delete(payload, "instructions") payloadBytes, _ := json.Marshal(payload) payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes) diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go index 410cc26261..ffbc497ced 100644 --- a/backend/internal/service/account_test_service_cn_adaptive_test.go +++ b/backend/internal/service/account_test_service_cn_adaptive_test.go @@ -83,36 +83,25 @@ data: {"type":"response.completed"} } func TestAccountTestService_AdaptiveChatOnlyProvidersTestChatAndAnthropicEndpoints(t *testing.T) { - for index, testCase := range []struct { - name string - platform string - model string - }{ - {name: "Kimi", platform: PlatformKimi, model: "kimi-k2.5"}, - {name: "Zhipu", platform: PlatformZhipu, model: "glm-4.7"}, - } { - t.Run(testCase.name, func(t *testing.T) { - account := adaptiveCNAccountTestAccount(int64(301+index), testCase.platform) - svc, upstream := adaptiveCNAccountTestService( - account, - adaptiveCNChatTestResponse(), - adaptiveCNAnthropicTestResponse(), - ) - c, recorder := newTestContext() + account := adaptiveCNAccountTestAccount(301, PlatformZhipu) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + ) + c, recorder := newTestContext() - err := svc.TestAccountConnection(c, account.ID, testCase.model, "hello", AccountTestModeDefault) + err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "hello", AccountTestModeDefault) - require.NoError(t, err) - require.Len(t, upstream.requests, 2) - require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String()) - require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String()) - require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization")) - require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key")) - require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`)) - require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) - require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证") - }) - } + require.NoError(t, err) + require.Len(t, upstream.requests, 2) + require.Equal(t, "http://chat.example/v1/chat/completions", upstream.requests[0].URL.String()) + require.Equal(t, "http://anthropic.example/v1/messages", upstream.requests[1].URL.String()) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[0].Header.Get("Authorization")) + require.Equal(t, "sk-adaptive-test", upstream.requests[1].Header.Get("x-api-key")) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_start"`)) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /v1/messages 验证") } func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) { @@ -139,6 +128,30 @@ func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testin require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证") } +func TestAccountTestService_AdaptiveKimiAlsoTestsResponsesEndpoint(t *testing.T) { + account := adaptiveCNAccountTestAccount(306, PlatformKimi) + svc, upstream := adaptiveCNAccountTestService( + account, + adaptiveCNChatTestResponse(), + adaptiveCNAnthropicTestResponse(), + adaptiveCNResponsesTestResponse(), + ) + c, recorder := newTestContext() + + err := svc.TestAccountConnection(c, account.ID, "k3-256k", "", AccountTestModeDefault) + + require.NoError(t, err) + require.Len(t, upstream.requests, 3) + require.Equal(t, "http://responses.example/v1/responses", upstream.requests[2].URL.String()) + require.Equal(t, HTTPUpstreamProfileOpenAI, HTTPUpstreamProfileFromContext(upstream.requests[2].Context())) + require.Equal(t, "Bearer sk-adaptive-test", upstream.requests[2].Header.Get("Authorization")) + require.True(t, gjson.GetBytes(upstream.bodies[2], "stream").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "store").Bool()) + require.False(t, gjson.GetBytes(upstream.bodies[2], "instructions").Exists()) + require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`)) + require.Contains(t, recorder.Body.String(), "已通过原生 /responses 验证") +} + func TestAccountTestService_AdaptiveStopsAndNamesFailingEndpoint(t *testing.T) { account := adaptiveCNAccountTestAccount(303, PlatformDeepseek) svc, upstream := adaptiveCNAccountTestService( diff --git a/backend/internal/service/adaptive_api_protocol_test.go b/backend/internal/service/adaptive_api_protocol_test.go index 210caa0695..3aef38698a 100644 --- a/backend/internal/service/adaptive_api_protocol_test.go +++ b/backend/internal/service/adaptive_api_protocol_test.go @@ -116,10 +116,10 @@ func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) { gin.SetMode(gin.TestMode) - body := []byte(`{"model":"kimi-k2.5","input":"hello","max_output_tokens":32,"stream":false}`) + body := []byte(`{"model":"glm-4.7","input":"hello","max_output_tokens":32,"stream":false}`) upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} - account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{ APIProtocolChatCompletions: "http://chat.example", APIProtocolAnthropic: "http://anthropic.example", }) @@ -131,6 +131,24 @@ func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testi require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) } +func TestAdaptiveProtocolRoutesKimiResponsesShapedChatToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"k3-256k","input":"hello","max_output_tokens":32,"stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "http://chat.example", + APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example/v1", + }) + + _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "") + require.Error(t, err) + require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists()) +} + func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) { gin.SetMode(gin.TestMode) body := []byte(`{"model":"glm-4.7","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`) @@ -147,21 +165,42 @@ func TestAdaptiveProtocolRoutesMessagesToNativeAnthropic(t *testing.T) { require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String()) } -func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) { +func TestAdaptiveProtocolRoutesKimiResponsesToNativeResponses(t *testing.T) { gin.SetMode(gin.TestMode) - body := []byte(`{"model":"kimi-k2.5","input":"hello","stream":false}`) + body := []byte(`{"model":"k3-256k","input":"hello","store":true,"previous_response_id":"resp_old","stream":false}`) upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ APIProtocolChatCompletions: "http://chat.example", APIProtocolAnthropic: "http://anthropic.example", + APIProtocolResponses: "http://responses.example/v1", }) _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) require.Error(t, err) - require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String()) - require.True(t, gjson.GetBytes(upstream.lastBody, "messages").IsArray()) - require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.Equal(t, "http://responses.example/v1/responses", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists()) + require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool()) + require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists()) +} + +func TestAdaptiveProtocolRoutesKimiCodingResponsesToNativeResponses(t *testing.T) { + gin.SetMode(gin.TestMode) + body := []byte(`{"model":"k3-256k","input":"hello","stream":false}`) + upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")} + svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream} + account := adaptiveProtocolTestAccount(PlatformKimi, map[string]any{ + APIProtocolChatCompletions: "https://api.kimi.com/coding/v1", + APIProtocolAnthropic: "https://api.kimi.com/coding", + APIProtocolResponses: "https://api.kimi.com/coding/v1", + }) + account.Credentials["account_mode"] = AccountModeCoding + + _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body) + require.Error(t, err) + require.Equal(t, "https://api.kimi.com/coding/v1/responses", upstream.lastReq.URL.String()) + require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists()) } func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) { diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go index 7ec7a4fddd..84598f3ba7 100644 --- a/backend/internal/service/cn_providers_test.go +++ b/backend/internal/service/cn_providers_test.go @@ -469,7 +469,7 @@ func TestBuildUpstreamModelsRequest_CNProviders(t *testing.T) { } // TestGetAPIProtocol 验证协议凭证维度的平台校验矩阵: -// responses 仅 deepseek;缺失/非法值回退 chat_completions(与旧行为一致)。 +// responses 仅 deepseek / kimi;缺失/非法值回退 chat_completions(与旧行为一致)。 func TestGetAPIProtocol(t *testing.T) { t.Parallel() @@ -486,15 +486,24 @@ func TestGetAPIProtocol(t *testing.T) { require.Equal(t, APIProtocolAnthropic, mk(PlatformKimi, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolAnthropic, mk(PlatformDeepseek, APIProtocolAnthropic).GetAPIProtocol()) require.Equal(t, APIProtocolResponses, mk(PlatformDeepseek, APIProtocolResponses).GetAPIProtocol()) + require.Equal(t, APIProtocolResponses, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformKimi, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformZhipu, APIProtocolAdaptive).GetAPIProtocol()) require.Equal(t, APIProtocolAdaptive, mk(PlatformDeepseek, APIProtocolAdaptive).GetAPIProtocol()) - require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, APIProtocolResponses).GetAPIProtocol(), "kimi 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformZhipu, APIProtocolResponses).GetAPIProtocol(), "zhipu 无 responses 端点") require.Equal(t, APIProtocolChatCompletions, mk(PlatformKimi, "bogus").GetAPIProtocol(), "非法值回退默认") require.Equal(t, APIProtocolChatCompletions, (&Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}).GetAPIProtocol(), "非 CN 供应商恒为默认") } +func TestSupportsNativeCNResponses(t *testing.T) { + t.Parallel() + require.True(t, (&Account{Platform: PlatformDeepseek}).SupportsNativeCNResponses()) + require.True(t, (&Account{Platform: PlatformKimi}).SupportsNativeCNResponses()) + require.True(t, (&Account{Platform: PlatformKimi, Credentials: map[string]any{"account_mode": AccountModeCoding}}).SupportsNativeCNResponses()) + require.False(t, (&Account{Platform: PlatformZhipu}).SupportsNativeCNResponses()) + require.False(t, (&Account{Platform: PlatformOpenAI}).SupportsNativeCNResponses()) +} + func TestAdaptiveProtocolBaseURLs(t *testing.T) { t.Parallel() @@ -639,10 +648,12 @@ func TestBuildOpenAIResponsesURLForPlatform(t *testing.T) { require.Equal(t, "https://relay.example.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformDeepseek, "https://relay.example.com/v1")) require.Equal(t, "https://api.openai.com/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformOpenAI, "https://api.openai.com")) require.Equal(t, "https://open.bigmodel.cn/api/paas/v4/responses", buildOpenAIResponsesURLForPlatform(PlatformZhipu, "https://open.bigmodel.cn/api/paas/v4")) + require.Equal(t, "https://api.moonshot.cn/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.moonshot.cn/v1")) + require.Equal(t, "https://api.kimi.com/coding/v1/responses", buildOpenAIResponsesURLForPlatform(PlatformKimi, "https://api.kimi.com/coding/v1")) } // TestNormalizeDeepSeekResponsesRequestBody 无状态适配:强制 store=false、 -// 清除 previous_response_id;非 deepseek responses 协议原样返回。 +// 清除 previous_response_id;非原生 CN Responses 协议原样返回。 func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { t.Parallel() @@ -668,6 +679,22 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) { deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey} require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body))) + kimiResponses := &Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolResponses}, + } + kimiNormalized := normalizeDeepSeekResponsesRequestBody(kimiResponses, body) + require.False(t, gjson.GetBytes(kimiNormalized, "store").Bool()) + require.False(t, gjson.GetBytes(kimiNormalized, "previous_response_id").Exists()) + + kimiCodingAdaptive := &Account{ + Platform: PlatformKimi, Type: AccountTypeAPIKey, + Credentials: map[string]any{"api_protocol": APIProtocolAdaptive, "account_mode": AccountModeCoding}, + } + kimiCodingNormalized := normalizeDeepSeekResponsesRequestBody(kimiCodingAdaptive, body) + require.False(t, gjson.GetBytes(kimiCodingNormalized, "store").Bool()) + require.False(t, gjson.GetBytes(kimiCodingNormalized, "previous_response_id").Exists()) + // openai 账号原样返回 openai := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey} require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(openai, body))) diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index 329cfebad4..5b2d92c69a 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -123,12 +123,12 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte return } if account.IsCNProvider() { - // 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions, - // 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。 - // 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses - // 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。 - if account.GetAPIProtocol() == APIProtocolResponses || - (account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) { + // 国产 OpenAI 兼容上游默认仅支持 /v1/chat/completions。直接落标 false + // 走 Chat Completions 直转,跳过网络探测。 + // 例外:deepseek / kimi 的固定 responses 和 adaptive 账号使用官方原生 + // Responses 端点,落标 force_responses;其余协议显式重置为 auto,避免 + // 切换后残留强制模式。 + if account.UsesNativeCNResponses() { _ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses), openai_compat.ExtraKeyResponsesSupported: true, diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go index f3b4eb0e6d..7da1da0055 100644 --- a/backend/internal/service/openai_apikey_responses_probe_test.go +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -60,7 +60,8 @@ func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) { }{ {name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)}, {name: "deepseek chat clears forced responses", id: 202, platform: PlatformDeepseek, protocol: APIProtocolChatCompletions, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, - {name: "kimi adaptive falls back to chat", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, + {name: "kimi adaptive supports responses", id: 203, platform: PlatformKimi, protocol: APIProtocolAdaptive, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)}, + {name: "kimi responses protocol supports responses", id: 205, platform: PlatformKimi, protocol: APIProtocolResponses, wantSupport: true, wantMode: string(openai_compat.ResponsesSupportModeForceResponses)}, {name: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)}, } diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 07b32497bd..6afca5179a 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -102,13 +102,13 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists() // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。 - // Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM + // Responses 形状下,DeepSeek / Kimi 继续走下方原生 Responses 链;GLM // 没有 Responses 端点,先转换成 Chat Completions 再直转。 if account.IsAdaptiveAPIProtocol() { if !isResponsesShape { return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel) } - if account.Platform != PlatformDeepseek { + if !account.SupportsNativeCNResponses() { var responsesReq apicompat.ResponsesRequest if err := json.Unmarshal(body, &responsesReq); err != nil { return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err) @@ -126,7 +126,7 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions( } return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel) } - // DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 + // DeepSeek / Kimi 原生 Responses 请求继续走下方 Responses→Chat 回程转换。 } // 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点, diff --git a/backend/internal/service/openai_gateway_chat_completions_raw.go b/backend/internal/service/openai_gateway_chat_completions_raw.go index 7785e44ed8..a98c11e5e8 100644 --- a/backend/internal/service/openai_gateway_chat_completions_raw.go +++ b/backend/internal/service/openai_gateway_chat_completions_raw.go @@ -39,7 +39,8 @@ var openaiCCRawAllowedHeaders = map[string]bool{ // `{base_url}/v1/chat/completions`,**不**做 CC↔Responses 协议转换。 // // 适用场景:account.platform=openai && account.type=apikey && 上游已被探测确认 -// 不支持 /v1/responses 端点(如 DeepSeek/Kimi/GLM/Qwen 等第三方 OpenAI 兼容上游)。 +// 不支持 /v1/responses 端点(如 GLM/Qwen 等第三方 OpenAI 兼容上游);CN 供应商 +// 固定 chat_completions 协议也走此路径。 // // 与 ForwardAsChatCompletions 的关键差异: // diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go index 23a41d29c6..875dd3ae8b 100644 --- a/backend/internal/service/openai_gateway_forward.go +++ b/backend/internal/service/openai_gateway_forward.go @@ -133,8 +133,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } - nativeDeepSeekResponses := account.Platform == PlatformDeepseek && - (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol()) + nativeCNResponses := account.UsesNativeCNResponses() + nativeDeepSeekResponses := account.Platform == PlatformDeepseek && nativeCNResponses if nativeDeepSeekResponses && account.Type == AccountTypeAPIKey && !compactPath && needsOpenAIResponsesClientToolAdaptation(body) { adaptedBody, mapping, adaptErr := adaptOpenAIResponsesClientTools(body) @@ -360,7 +360,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" - if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeDeepSeekResponses { + if instructionsEmpty && account.UsesOpenAICodexProtocol() && !compatMessagesBridge && !nativeCNResponses { markPatchSet("instructions", defaultCodexSynthInstructions(reqModel)) } @@ -1268,13 +1268,13 @@ func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool { return false } if account.IsCNProvider() { - // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生 - // Responses,Kimi/GLM 回退 Chat Completions。 + // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek / Kimi + // 有原生 Responses,GLM 回退 Chat Completions。 switch account.GetAPIProtocol() { case APIProtocolChatCompletions: return true case APIProtocolAdaptive: - return account.Platform != PlatformDeepseek + return !account.SupportsNativeCNResponses() default: return false } @@ -1298,7 +1298,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. case AccountTypeAPIKey: // API Key accounts use Platform API or custom base URL baseURL := account.GetOpenAIBaseURL() - if account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol() { + if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() { baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) } if baseURL == "" { @@ -1315,7 +1315,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin. } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) - // DeepSeek 原生 Responses 端点为无状态实现:强制 store=false、清除 + // DeepSeek / Kimi 原生 Responses 端点为无状态实现:强制 store=false、清除 // previous_response_id,避免携带状态字段被上游拒绝。 body = normalizeDeepSeekResponsesRequestBody(account, body) diff --git a/backend/internal/service/openai_gateway_passthrough.go b/backend/internal/service/openai_gateway_passthrough.go index 4bd4ba26c1..ecca154781 100644 --- a/backend/internal/service/openai_gateway_passthrough.go +++ b/backend/internal/service/openai_gateway_passthrough.go @@ -591,6 +591,9 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( } case AccountTypeAPIKey: baseURL := account.GetOpenAIBaseURL() + if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) + } if baseURL != "" { validatedURL, err := s.validateUpstreamBaseURL(baseURL) if err != nil { @@ -601,7 +604,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequestOpenAIPassthrough( } targetURL = appendOpenAIResponsesRequestPathSuffix(targetURL, openAIResponsesRequestPathSuffix(c)) - // DeepSeek 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。 + // DeepSeek / Kimi 原生 Responses 端点为无状态实现(见 normalizeDeepSeekResponsesRequestBody)。 body = normalizeDeepSeekResponsesRequestBody(account, body) req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body)) diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go index d4bdbb67b9..be89cdbc07 100644 --- a/backend/internal/service/openai_gateway_request_body.go +++ b/backend/internal/service/openai_gateway_request_body.go @@ -118,12 +118,12 @@ func deleteOpenAIResponsesNoneReasoningEffortFromObject(account *Account, body m } } -// normalizeDeepSeekResponsesRequestBody 适配 DeepSeek 无状态 Responses 端点: -// 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端 -// 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。 +// normalizeDeepSeekResponsesRequestBody 适配无状态 CN Responses 端点: +// 强制 store=false 并清除 previous_response_id(DeepSeek / Kimi 官方 +// Responses 均不支持服务端状态存储,携带这些字段会被拒绝)。 +// 非原生 Responses 协议账号原样返回。 func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte { - if account == nil || account.Platform != PlatformDeepseek || - (account.GetAPIProtocol() != APIProtocolResponses && !account.IsAdaptiveAPIProtocol()) { + if account == nil || !account.UsesNativeCNResponses() { return body } normalized, err := sjson.SetBytes(body, "store", false) diff --git a/backend/internal/service/openai_ws_forwarder_payload.go b/backend/internal/service/openai_ws_forwarder_payload.go index 94738221a2..939db28437 100644 --- a/backend/internal/service/openai_ws_forwarder_payload.go +++ b/backend/internal/service/openai_ws_forwarder_payload.go @@ -41,6 +41,9 @@ func (s *OpenAIGatewayService) buildOpenAIResponsesWSURL(account *Account) (stri } case AccountTypeAPIKey: baseURL := account.GetOpenAIBaseURL() + if account.UsesNativeCNResponses() && account.IsAdaptiveAPIProtocol() { + baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses) + } if baseURL == "" { targetURL = openaiPlatformAPIURL } else { diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue index 364436d109..8702ca7595 100644 --- a/frontend/src/components/account/CreateAccountModal.vue +++ b/frontend/src/components/account/CreateAccountModal.vue @@ -1319,7 +1319,7 @@ /> -

+

{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}

@@ -3814,6 +3814,7 @@ import { applyAntigravityProjectID, applyHeaderOverride, applyInterceptWarmup, + cnSupportsNativeResponses, defaultCNAdaptiveBaseUrls, defaultCNBaseUrl, isHeaderOverrideCapable, @@ -4006,7 +4007,7 @@ const upstreamBillingAutoProbeEnabled = ref(true) // ── 国产供应商(Kimi / Zhipu / DeepSeek)账号类型、API 协议与端点 ── const accountMode = ref('payg') // API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code), -// responses=deepseek 原生 Responses 端点(Codex)。与账号类型正交。 +// responses=deepseek / kimi 原生 Responses 端点(Codex)。与账号类型正交。 const apiProtocol = ref('adaptive') // 智谱团队版 Coding Plan:组织/项目 ID,写入 credentials 供额度探测切换团队端点 const zhipuOrganization = ref('') @@ -4027,14 +4028,14 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => { } return 'kimi' }) -// 当前平台可选的协议档(responses 仅 deepseek)。 +// 当前平台可选的协议档(responses 仅 deepseek / kimi)。 const cnProtocolOptions = computed>(() => { const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [ { value: 'adaptive', labelKey: 'adaptive' }, { value: 'chat_completions', labelKey: 'chatCompletions' }, { value: 'anthropic', labelKey: 'anthropic' } ] - if (form.platform === 'deepseek') { + if (cnSupportsNativeResponses(form.platform)) { opts.push({ value: 'responses', labelKey: 'responses' }) } return opts @@ -4044,7 +4045,7 @@ const cnAdaptiveProtocolOptions = computed -

+

{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}

@@ -2909,6 +2909,7 @@ import { isHeaderOverrideCapable, splitHeaderOverridesObject, validateHeaderOverrideRows, + cnSupportsNativeResponses, defaultCNAdaptiveBaseUrls, defaultCNBaseUrl, HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY, @@ -3057,7 +3058,7 @@ const cnProtocolOptions = computed { @@ -3947,7 +3948,7 @@ const syncFormFromAccount = (newAccount: Account | null) => { storedProtocol === 'responses' ? storedProtocol : 'chat_completions' - if (newAccount.platform !== 'deepseek' && editApiProtocol.value === 'responses') { + if (!cnSupportsNativeResponses(newAccount.platform) && editApiProtocol.value === 'responses') { editApiProtocol.value = 'chat_completions' } const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, editAccountMode.value) diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts index 26305c4233..876e719fd7 100644 --- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts @@ -375,7 +375,31 @@ describe('CreateAccountModal OpenAI long-context billing', () => { base_url: 'https://api.moonshot.cn/v1', api_base_urls: { chat_completions: 'https://api.moonshot.cn/v1', - anthropic: 'https://api.moonshot.cn/anthropic' + anthropic: 'https://api.moonshot.cn/anthropic', + responses: 'https://api.moonshot.cn/v1' + } + }) + }) + + it('submits adaptive Kimi Coding Plan Responses endpoint', async () => { + const wrapper = mountModal() + await selectButtonByText(wrapper, 'Kimi') + await selectButtonByText(wrapper, 'admin.accounts.cnProviders.accountMode.coding') + await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi coding') + await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi-coding') + + await wrapper.get('form#create-account-form').trigger('submit.prevent') + await flushPromises() + + expect(createAccountMock).toHaveBeenCalledTimes(1) + expect(createAccountMock.mock.calls[0]?.[0]?.credentials).toMatchObject({ + account_mode: 'coding', + api_protocol: 'adaptive', + base_url: 'https://api.kimi.com/coding/v1', + api_base_urls: { + chat_completions: 'https://api.kimi.com/coding/v1', + anthropic: 'https://api.kimi.com/coding', + responses: 'https://api.kimi.com/coding/v1' } }) }) diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts index eceb670c92..d81ffacebe 100644 --- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts +++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts @@ -355,6 +355,39 @@ describe('EditAccountModal', () => { }) }) + it('preserves adaptive Kimi Responses endpoint on submit', async () => { + const account = buildAccount() + account.platform = 'kimi' + account.credentials = { + api_key: 'sk-kimi', + account_mode: 'payg', + api_protocol: 'adaptive', + base_url: 'https://api.moonshot.cn/v1', + api_base_urls: { + chat_completions: 'https://api.moonshot.cn/v1', + anthropic: 'https://api.moonshot.cn/anthropic', + responses: 'https://api.moonshot.cn/v1' + } + } + updateAccountMock.mockReset().mockResolvedValue(account) + checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false }) + + const wrapper = mountModal(account) + await wrapper.get('form#edit-account-form').trigger('submit.prevent') + + expect(updateAccountMock).toHaveBeenCalledTimes(1) + expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({ + account_mode: 'payg', + api_protocol: 'adaptive', + base_url: 'https://api.moonshot.cn/v1', + api_base_urls: { + chat_completions: 'https://api.moonshot.cn/v1', + anthropic: 'https://api.moonshot.cn/anthropic', + responses: 'https://api.moonshot.cn/v1' + } + }) + }) + it('preserves adaptive GLM endpoints on submit', async () => { const account = buildAccount() account.platform = 'zhipu' diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts index 618210ddf0..d7326631e1 100644 --- a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts +++ b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts @@ -1,18 +1,27 @@ import { describe, expect, it } from 'vitest' -import { defaultCNAdaptiveBaseUrls } from '../credentialsBuilder' +import { cnSupportsNativeResponses, defaultCNAdaptiveBaseUrls } from '../credentialsBuilder' + +describe('cnSupportsNativeResponses', () => { + it('is true for DeepSeek and Kimi only', () => { + expect(cnSupportsNativeResponses('deepseek')).toBe(true) + expect(cnSupportsNativeResponses('kimi')).toBe(true) + expect(cnSupportsNativeResponses('zhipu')).toBe(false) + expect(cnSupportsNativeResponses('openai')).toBe(false) + }) +}) describe('defaultCNAdaptiveBaseUrls', () => { it('resolves Kimi endpoints by account mode', () => { expect(defaultCNAdaptiveBaseUrls('kimi', 'payg')).toEqual({ chat_completions: 'https://api.moonshot.cn/v1', anthropic: 'https://api.moonshot.cn/anthropic', - responses: '' + responses: 'https://api.moonshot.cn/v1' }) expect(defaultCNAdaptiveBaseUrls('kimi', 'coding')).toEqual({ chat_completions: 'https://api.kimi.com/coding/v1', anthropic: 'https://api.kimi.com/coding', - responses: '' + responses: 'https://api.kimi.com/coding/v1' }) }) diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts index 7f844fba0a..0ed581567c 100644 --- a/frontend/src/components/account/credentialsBuilder.ts +++ b/frontend/src/components/account/credentialsBuilder.ts @@ -256,10 +256,15 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [ export type CnAccountMode = 'payg' | 'coding' -/** 仅 deepseek 支持原生 responses;adaptive 会按入站协议选择原生端点。 */ +/** deepseek / kimi 支持原生 responses;adaptive 会按入站协议选择原生端点。 */ export type CnApiProtocol = 'adaptive' | 'chat_completions' | 'anthropic' | 'responses' export type CnNativeApiProtocol = Exclude +/** DeepSeek 与 Kimi(按量付费 / Coding Plan)提供原生 Responses 端点。 */ +export function cnSupportsNativeResponses(platform: string): boolean { + return platform === 'deepseek' || platform === 'kimi' +} + export interface CnBaseUrlPreset { mode: CnAccountMode protocol: CnApiProtocol @@ -273,8 +278,10 @@ export const CN_BASE_URL_PRESETS: Record<'kimi' | 'zhipu' | 'deepseek', CnBaseUr kimi: [ { mode: 'payg', protocol: 'chat_completions', label: 'Moonshot', url: 'https://api.moonshot.cn/v1' }, { mode: 'payg', protocol: 'anthropic', label: 'Moonshot Anthropic', url: 'https://api.moonshot.cn/anthropic' }, + { mode: 'payg', protocol: 'responses', label: 'Moonshot Responses', url: 'https://api.moonshot.cn/v1' }, { mode: 'coding', protocol: 'chat_completions', label: 'Kimi For Coding', url: 'https://api.kimi.com/coding/v1' }, - { mode: 'coding', protocol: 'anthropic', label: 'Kimi Coding Anthropic', url: 'https://api.kimi.com/coding' } + { mode: 'coding', protocol: 'anthropic', label: 'Kimi Coding Anthropic', url: 'https://api.kimi.com/coding' }, + { mode: 'coding', protocol: 'responses', label: 'Kimi Coding Responses', url: 'https://api.kimi.com/coding/v1' } ], zhipu: [ { mode: 'payg', protocol: 'chat_completions', label: 'GLM PaaS', url: 'https://open.bigmodel.cn/api/paas/v4' }, @@ -307,7 +314,7 @@ export function defaultCNBaseUrl( return '' } } - // responses 仅 deepseek:base 与 chat_completions 相同(端点路径差异由后端处理)。 + // responses:Kimi / DeepSeek 的 base 与 chat_completions 相同(端点路径差异由后端处理)。 switch (platform) { case 'kimi': return mode === 'coding' ? 'https://api.kimi.com/coding/v1' : 'https://api.moonshot.cn/v1' @@ -330,7 +337,7 @@ export function defaultCNAdaptiveBaseUrls( return { chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'), anthropic: defaultCNBaseUrl(platform, mode, 'anthropic'), - responses: platform === 'deepseek' ? defaultCNBaseUrl(platform, mode, 'responses') : '' + responses: cnSupportsNativeResponses(platform) ? defaultCNBaseUrl(platform, mode, 'responses') : '' } }