From 85051616f9e47e6e7668a0231ee4b3598bd23dcf Mon Sep 17 00:00:00 2001
From: SavitarC <71271200+SavitarC@users.noreply.github.com>
Date: Wed, 19 Aug 2026 17:30:08 +0800
Subject: [PATCH 1/2] feat(accounts): add adaptive API protocol routing
---
backend/internal/domain/constants.go | 1 +
backend/internal/service/account.go | 74 ++++++++++-
.../internal/service/account_test_service.go | 3 +-
.../service/adaptive_api_protocol_test.go | 124 ++++++++++++++++++
backend/internal/service/cn_providers_test.go | 78 +++++++++--
backend/internal/service/domain_constants.go | 1 +
.../service/openai_apikey_responses_probe.go | 3 +-
.../openai_apikey_responses_probe_test.go | 37 ++++++
.../openai_gateway_chat_completions.go | 5 +
.../service/openai_gateway_forward.go | 7 +
.../service/openai_gateway_messages.go | 2 +-
.../service/openai_gateway_request_body.go | 3 +-
.../components/account/CnBaseUrlPresets.vue | 2 +-
.../components/account/CreateAccountModal.vue | 80 +++++++++--
.../components/account/EditAccountModal.vue | 99 +++++++++++++-
.../__tests__/CreateAccountModal.spec.ts | 22 ++++
.../__tests__/EditAccountModal.spec.ts | 31 +++++
.../credentialsBuilder.cnAdaptive.spec.ts | 39 ++++++
.../components/account/credentialsBuilder.ts | 17 ++-
.../src/i18n/locales/en/admin/accounts.ts | 4 +
.../src/i18n/locales/zh/admin/accounts.ts | 4 +
21 files changed, 605 insertions(+), 31 deletions(-)
create mode 100644 backend/internal/service/adaptive_api_protocol_test.go
create mode 100644 frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts
diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go
index 3640d1b82e..cd821165a4 100644
--- a/backend/internal/domain/constants.go
+++ b/backend/internal/domain/constants.go
@@ -44,6 +44,7 @@ const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
+ APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
// Account type constants
diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go
index cd66ef89ce..20d4ec5823 100644
--- a/backend/internal/service/account.go
+++ b/backend/internal/service/account.go
@@ -1310,6 +1310,13 @@ func (a *Account) GetOpenAIBaseURL() string {
if !a.IsOpenAI() && !a.IsCNProvider() {
return ""
}
+ if a.IsCNProvider() && a.IsAdaptiveAPIProtocol() {
+ if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
+ if baseURL, ok := baseURLs[APIProtocolChatCompletions].(string); ok && strings.TrimSpace(baseURL) != "" {
+ return strings.TrimSpace(baseURL)
+ }
+ }
+ }
if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return baseURL
@@ -1361,6 +1368,8 @@ func (a *Account) GetAPIProtocol() string {
return APIProtocolChatCompletions
}
switch strings.TrimSpace(a.GetCredential("api_protocol")) {
+ case APIProtocolAdaptive:
+ return APIProtocolAdaptive
case APIProtocolAnthropic:
return APIProtocolAnthropic
case APIProtocolResponses:
@@ -1373,6 +1382,66 @@ func (a *Account) GetAPIProtocol() string {
return APIProtocolChatCompletions
}
+// IsAdaptiveAPIProtocol 报告账号是否按入站协议动态选择供应商原生端点。
+func (a *Account) IsAdaptiveAPIProtocol() bool {
+ return a.GetAPIProtocol() == APIProtocolAdaptive
+}
+
+// GetCNProtocolBaseURL 返回国产供应商指定协议的上游 base URL。
+// adaptive 账号优先使用 api_base_urls 中的分协议地址,缺失时按平台和
+// account_mode 使用官方默认端点。base_url 继续作为 Chat Completions 地址兼容旧字段。
+func (a *Account) GetCNProtocolBaseURL(protocol string) string {
+ if a == nil || !a.IsCNProvider() {
+ return ""
+ }
+ if a.IsAdaptiveAPIProtocol() {
+ if baseURLs, ok := a.Credentials["api_base_urls"].(map[string]any); ok {
+ if baseURL, ok := baseURLs[protocol].(string); ok && strings.TrimSpace(baseURL) != "" {
+ return strings.TrimSpace(baseURL)
+ }
+ }
+ if protocol == APIProtocolChatCompletions {
+ if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
+ return baseURL
+ }
+ }
+ }
+ return a.defaultCNProtocolBaseURL(protocol)
+}
+
+func (a *Account) defaultCNProtocolBaseURL(protocol string) string {
+ switch protocol {
+ case APIProtocolAnthropic:
+ switch a.Platform {
+ case PlatformKimi:
+ if a.GetAccountMode() == AccountModeCoding {
+ return DefaultKimiCodingAnthropicBaseURL
+ }
+ return DefaultKimiPayGAnthropicBaseURL
+ case PlatformZhipu:
+ return DefaultZhipuAnthropicBaseURL
+ case PlatformDeepseek:
+ return DefaultDeepseekAnthropicBaseURL
+ }
+ case APIProtocolChatCompletions, APIProtocolResponses:
+ switch a.Platform {
+ case PlatformKimi:
+ if a.GetAccountMode() == AccountModeCoding {
+ return DefaultKimiCodingBaseURL
+ }
+ return DefaultKimiPayGBaseURL
+ case PlatformZhipu:
+ if a.GetAccountMode() == AccountModeCoding {
+ return DefaultZhipuCodingBaseURL
+ }
+ return DefaultZhipuPayGBaseURL
+ case PlatformDeepseek:
+ return DefaultDeepseekBaseURL
+ }
+ }
+ return ""
+}
+
// IsAnthropicProtocol 报告账号是否以原生 Anthropic 协议接入上游
// (/v1/messages 直通,适配 Claude Code 等客户端)。
func (a *Account) IsAnthropicProtocol() bool {
@@ -1383,9 +1452,12 @@ func (a *Account) IsAnthropicProtocol() bool {
// (上游路径为 {base}/v1/messages)。优先取凭证 base_url,缺失时按
// 供应商 × 接入模式返回默认端点。非 Anthropic 协议账号返回空串。
func (a *Account) GetAnthropicProtocolBaseURL() string {
- if a == nil || !a.IsAnthropicProtocol() {
+ if a == nil || (!a.IsAnthropicProtocol() && !a.IsAdaptiveAPIProtocol()) {
return ""
}
+ if a.IsAdaptiveAPIProtocol() {
+ return a.GetCNProtocolBaseURL(APIProtocolAnthropic)
+ }
if a.Type == AccountTypeAPIKey || a.Type == AccountTypeUpstream {
if baseURL := strings.TrimSpace(a.GetCredential("base_url")); baseURL != "" {
return baseURL
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index a2a08f2e0a..900d3f8e15 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -285,7 +285,8 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}
// Route to platform-specific test method
- if account.IsCNProvider() && account.GetAPIProtocol() == APIProtocolChatCompletions {
+ if account.IsCNProvider() &&
+ (account.GetAPIProtocol() == APIProtocolChatCompletions || account.IsAdaptiveAPIProtocol()) {
return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt)
}
diff --git a/backend/internal/service/adaptive_api_protocol_test.go b/backend/internal/service/adaptive_api_protocol_test.go
new file mode 100644
index 0000000000..1da65203c6
--- /dev/null
+++ b/backend/internal/service/adaptive_api_protocol_test.go
@@ -0,0 +1,124 @@
+//go:build unit
+
+package service
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/gin-gonic/gin"
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func adaptiveProtocolTestAccount(platform string, baseURLs map[string]any) *Account {
+ return &Account{
+ ID: 701,
+ Name: "adaptive-cn",
+ Platform: platform,
+ Type: AccountTypeAPIKey,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-test",
+ "api_protocol": APIProtocolAdaptive,
+ "account_mode": AccountModePayG,
+ "api_base_urls": baseURLs,
+ },
+ }
+}
+
+func adaptiveProtocolTestContext(path string, body []byte) *gin.Context {
+ recorder := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(recorder)
+ c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
+ c.Request.Header.Set("Content-Type", "application/json")
+ return c
+}
+
+func TestAdaptiveProtocolRoutesChatCompletionsToNativeChat(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"glm-4.7","messages":[{"role":"user","content":"hello"}],"stream":false}`)
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{
+ APIProtocolChatCompletions: "http://chat.example",
+ APIProtocolAnthropic: "http://anthropic.example",
+ })
+
+ _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", 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())
+}
+
+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}`)
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformZhipu, map[string]any{
+ APIProtocolChatCompletions: "http://chat.example",
+ APIProtocolAnthropic: "http://anthropic.example",
+ })
+
+ _, err := svc.ForwardAsAnthropic(context.Background(), adaptiveProtocolTestContext("/v1/messages", body), account, body, "", "")
+ require.Error(t, err)
+ require.Equal(t, "http://anthropic.example/v1/messages", upstream.lastReq.URL.String())
+ require.Equal(t, "glm-4.7", gjson.GetBytes(upstream.lastBody, "model").String())
+}
+
+func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"kimi-k2.5","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: "http://chat.example",
+ APIProtocolAnthropic: "http://anthropic.example",
+ })
+
+ _, 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())
+}
+
+func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"deepseek-v4","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(PlatformDeepseek, map[string]any{
+ APIProtocolChatCompletions: "http://chat.example",
+ APIProtocolAnthropic: "http://anthropic.example",
+ APIProtocolResponses: "http://responses.example",
+ })
+
+ _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses", body), account, body)
+ require.Error(t, err)
+ require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
+}
+
+func TestAdaptiveProtocolConvertsDeepSeekResponsesCompactToChatCompletions(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"deepseek-v4","input":"hello","stream":false}`)
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{
+ APIProtocolChatCompletions: "http://chat.example",
+ APIProtocolAnthropic: "http://anthropic.example",
+ APIProtocolResponses: "http://responses.example",
+ })
+
+ _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses/compact", body), account, body)
+ require.Error(t, err)
+ require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String())
+}
diff --git a/backend/internal/service/cn_providers_test.go b/backend/internal/service/cn_providers_test.go
index 24b8bd4405..8f46e828e1 100644
--- a/backend/internal/service/cn_providers_test.go
+++ b/backend/internal/service/cn_providers_test.go
@@ -55,9 +55,9 @@ func TestCNMillisToRFC3339(t *testing.T) {
t.Parallel()
// 1700000000 秒 = 1700000000000 毫秒
want := time.UnixMilli(1700000000000).UTC().Format(time.RFC3339)
- require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级
- require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级
- require.Equal(t, "", cnMillisToRFC3339(0)) // 非正
+ require.Equal(t, want, cnMillisToRFC3339(1700000000)) // 秒级
+ require.Equal(t, want, cnMillisToRFC3339(1700000000000)) // 毫秒级
+ require.Equal(t, "", cnMillisToRFC3339(0)) // 非正
require.Equal(t, "", cnMillisToRFC3339(-1))
}
@@ -294,8 +294,8 @@ func TestEvaluateAccountSchedulingThreshold_KimiCodingPlan(t *testing.T) {
account := &Account{
Platform: PlatformKimi,
Extra: map[string]any{
- "kimi_5h_used_percent": 90.0,
- "kimi_5h_reset_at": reset.Format(time.RFC3339),
+ "kimi_5h_used_percent": 90.0,
+ "kimi_5h_reset_at": reset.Format(time.RFC3339),
"kimi_weekly_used_percent": 30.0,
"kimi_weekly_reset_at": now.Add(7 * 24 * time.Hour).Format(time.RFC3339),
},
@@ -410,9 +410,9 @@ func TestGetOpenAIProtocolAPIKey_CNProviders(t *testing.T) {
t.Parallel()
kimi := &Account{
- Platform: PlatformKimi,
- Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "sk-kimi"},
+ Platform: PlatformKimi,
+ Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"api_key": "sk-kimi"},
}
require.Equal(t, "sk-kimi", kimi.GetOpenAIProtocolAPIKey())
require.False(t, kimi.IsOpenAIApiKey(), "IsOpenAIApiKey stays openai-only for scheduling gates")
@@ -486,12 +486,66 @@ 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, 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 TestAdaptiveProtocolBaseURLs(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ platform string
+ mode string
+ wantChat string
+ wantAnthropic string
+ wantResponses string
+ }{
+ {"kimi payg", PlatformKimi, AccountModePayG, DefaultKimiPayGBaseURL, DefaultKimiPayGAnthropicBaseURL, DefaultKimiPayGBaseURL},
+ {"kimi coding", PlatformKimi, AccountModeCoding, DefaultKimiCodingBaseURL, DefaultKimiCodingAnthropicBaseURL, DefaultKimiCodingBaseURL},
+ {"zhipu payg", PlatformZhipu, AccountModePayG, DefaultZhipuPayGBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuPayGBaseURL},
+ {"zhipu coding", PlatformZhipu, AccountModeCoding, DefaultZhipuCodingBaseURL, DefaultZhipuAnthropicBaseURL, DefaultZhipuCodingBaseURL},
+ {"deepseek", PlatformDeepseek, AccountModePayG, DefaultDeepseekBaseURL, DefaultDeepseekAnthropicBaseURL, DefaultDeepseekBaseURL},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ account := &Account{Platform: tc.platform, Type: AccountTypeAPIKey, Credentials: map[string]any{
+ "api_protocol": APIProtocolAdaptive,
+ "account_mode": tc.mode,
+ }}
+ require.Equal(t, tc.wantChat, account.GetCNProtocolBaseURL(APIProtocolChatCompletions))
+ require.Equal(t, tc.wantAnthropic, account.GetCNProtocolBaseURL(APIProtocolAnthropic))
+ require.Equal(t, tc.wantResponses, account.GetCNProtocolBaseURL(APIProtocolResponses))
+ require.Equal(t, tc.wantAnthropic, account.GetAnthropicProtocolBaseURL())
+ })
+ }
+}
+
+func TestAdaptiveProtocolBaseURLOverrides(t *testing.T) {
+ t.Parallel()
+
+ account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{
+ "api_protocol": APIProtocolAdaptive,
+ "base_url": "https://legacy-chat.example.com",
+ "api_base_urls": map[string]any{
+ APIProtocolChatCompletions: "https://chat.example.com",
+ APIProtocolAnthropic: "https://anthropic.example.com",
+ APIProtocolResponses: "https://responses.example.com",
+ },
+ }}
+
+ require.Equal(t, "https://chat.example.com", account.GetOpenAIBaseURL())
+ require.Equal(t, "https://chat.example.com", account.GetCNProtocolBaseURL(APIProtocolChatCompletions))
+ require.Equal(t, "https://anthropic.example.com", account.GetAnthropicProtocolBaseURL())
+ require.Equal(t, "https://responses.example.com", account.GetCNProtocolBaseURL(APIProtocolResponses))
+}
+
// TestAnthropicProtocolBaseURL 验证 Anthropic 协议默认端点与协议感知的
// OpenAI 格式 base 回退。
func TestAnthropicProtocolBaseURL(t *testing.T) {
@@ -600,6 +654,14 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
require.False(t, gjson.GetBytes(normalized, "previous_response_id").Exists())
require.Equal(t, "deepseek-v4-pro", gjson.GetBytes(normalized, "model").String())
+ deepseekAdaptive := &Account{
+ Platform: PlatformDeepseek, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"api_protocol": APIProtocolAdaptive},
+ }
+ adaptiveNormalized := normalizeDeepSeekResponsesRequestBody(deepseekAdaptive, body)
+ require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool())
+ require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists())
+
// 非 responses 协议(deepseek CC 账号)原样返回
deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index ba35301be0..860276b753 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -64,6 +64,7 @@ const (
APIProtocolChatCompletions = domain.APIProtocolChatCompletions
APIProtocolAnthropic = domain.APIProtocolAnthropic
APIProtocolResponses = domain.APIProtocolResponses
+ APIProtocolAdaptive = domain.APIProtocolAdaptive
)
// 国产 OpenAI 兼容供应商各模式的默认 base_url。
diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go
index 645a581694..43ad7c82d7 100644
--- a/backend/internal/service/openai_apikey_responses_probe.go
+++ b/backend/internal/service/openai_apikey_responses_probe.go
@@ -127,7 +127,8 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
// 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。
// 例外:deepseek 的 responses 协议账号(api_protocol=responses)使用官方原生
// /responses 端点,落标 force_responses 强制走 Responses 路径。
- if account.GetAPIProtocol() == APIProtocolResponses {
+ if account.GetAPIProtocol() == APIProtocolResponses ||
+ (account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) {
_ = 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 fac351a0ec..1b9cdd65e6 100644
--- a/backend/internal/service/openai_apikey_responses_probe_test.go
+++ b/backend/internal/service/openai_apikey_responses_probe_test.go
@@ -49,6 +49,43 @@ func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) {
require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported])
}
+func TestProbeOpenAIAPIKeyResponsesSupportAdaptiveCNProviders(t *testing.T) {
+ tests := []struct {
+ name string
+ id int64
+ platform string
+ wantSupport bool
+ wantForceMode bool
+ }{
+ {name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, wantSupport: true, wantForceMode: true},
+ {name: "kimi adaptive falls back to chat", id: 202, platform: PlatformKimi, wantSupport: false},
+ {name: "zhipu adaptive falls back to chat", id: 203, platform: PlatformZhipu, wantSupport: false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ updateCalls := make(chan map[string]any, 1)
+ account := Account{
+ ID: tc.id, Platform: tc.platform, Type: AccountTypeAPIKey,
+ Credentials: map[string]any{"api_key": "sk-test", "api_protocol": APIProtocolAdaptive},
+ }
+ repo := &snapshotUpdateAccountRepo{
+ stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
+ updateExtraCalls: updateCalls,
+ }
+ svc := &AccountTestService{accountRepo: repo}
+
+ svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
+
+ updates := <-updateCalls
+ require.Equal(t, tc.wantSupport, updates[openai_compat.ExtraKeyResponsesSupported])
+ if tc.wantForceMode {
+ require.Equal(t, string(openai_compat.ResponsesSupportModeForceResponses), updates[openai_compat.ExtraKeyResponsesMode])
+ }
+ })
+ }
+}
+
func TestDecideResponsesProbeSupport(t *testing.T) {
fnCall := []byte(`{"output":[{"type":"reasoning"},{"type":"function_call","name":"probe_ping"}]}`)
reasoningOnly := []byte(`{"output":[{"type":"reasoning"}]}`)
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index 8308c4c0c3..5dac6e9268 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -88,6 +88,11 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
+ // 自适应账号的 Chat Completions 入站始终使用供应商原生 CC 端点。
+ if account.IsAdaptiveAPIProtocol() {
+ return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
+ }
+
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点,
// CC 入站请求经 CC→Responses→Anthropic 转换链直通该端点。必须先于
// ShouldUseResponsesAPI 分流:该类账号经 probe 落标
diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go
index 47c02d866e..415164cc8e 100644
--- a/backend/internal/service/openai_gateway_forward.go
+++ b/backend/internal/service/openai_gateway_forward.go
@@ -115,6 +115,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if account.IsAnthropicProtocol() {
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
}
+ if account.IsAdaptiveAPIProtocol() &&
+ (account.Platform != PlatformDeepseek || isOpenAIResponsesCompactPath(c)) {
+ return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
+ }
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
@@ -1055,6 +1059,9 @@ 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() {
+ baseURL = account.GetCNProtocolBaseURL(APIProtocolResponses)
+ }
if baseURL == "" {
targetURL = openaiPlatformAPIURL
} else {
diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go
index 4b0aa58edf..99c74bedd2 100644
--- a/backend/internal/service/openai_gateway_messages.go
+++ b/backend/internal/service/openai_gateway_messages.go
@@ -41,7 +41,7 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
// thinking / tool_use / cache 语义,适配 Claude Code 等原生客户端。
// 必须先于 ShouldUseResponsesAPI 分流:Anthropic 协议账号经 probe 落标
// openai_responses_supported=false,会先命中下方的 CC 直转分支。
- if account.IsAnthropicProtocol() {
+ if account.IsAnthropicProtocol() || account.IsAdaptiveAPIProtocol() {
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel)
}
diff --git a/backend/internal/service/openai_gateway_request_body.go b/backend/internal/service/openai_gateway_request_body.go
index f2efddcf77..59199cea05 100644
--- a/backend/internal/service/openai_gateway_request_body.go
+++ b/backend/internal/service/openai_gateway_request_body.go
@@ -59,7 +59,8 @@ func buildOpenAIResponsesURLForPlatform(platform string, base string) string {
// 强制 store=false 并清除 previous_response_id(官方 /responses 不支持服务端
// 状态存储,携带这些字段会被拒绝)。非 deepseek responses 协议账号原样返回。
func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte {
- if account == nil || account.Platform != PlatformDeepseek || account.GetAPIProtocol() != APIProtocolResponses {
+ if account == nil || account.Platform != PlatformDeepseek ||
+ (account.GetAPIProtocol() != APIProtocolResponses && !account.IsAdaptiveAPIProtocol()) {
return body
}
normalized, err := sjson.SetBytes(body, "store", false)
diff --git a/frontend/src/components/account/CnBaseUrlPresets.vue b/frontend/src/components/account/CnBaseUrlPresets.vue
index 5a5172555a..45ccb32dc3 100644
--- a/frontend/src/components/account/CnBaseUrlPresets.vue
+++ b/frontend/src/components/account/CnBaseUrlPresets.vue
@@ -30,7 +30,7 @@ const props = defineProps<{
/** 当前已选账号类型,用于过滤和高亮匹配的预设 */
mode?: 'payg' | 'coding'
/** 当前已选 API 协议,用于过滤和高亮匹配的预设 */
- protocol?: 'chat_completions' | 'anthropic' | 'responses'
+ protocol?: 'adaptive' | 'chat_completions' | 'anthropic' | 'responses'
/** 当前输入框中的 base url,用于高亮完全匹配项 */
currentUrl?: string
}>()
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 47ee8c27d5..546c5f0066 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -537,7 +537,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
-
+
{{ t(`admin.accounts.cnProviders.apiProtocol.${opt.labelKey}`) }}
@@ -1250,7 +1250,7 @@
-
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
+
+
('payg')
// API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code),
// responses=deepseek 原生 Responses 端点(Codex)。与账号类型正交。
-const apiProtocol = ref
('chat_completions')
+const apiProtocol = ref('adaptive')
+const adaptiveBaseUrls = ref>({
+ chat_completions: '',
+ anthropic: '',
+ responses: ''
+})
const isCNPlatform = computed(
() => form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek'
)
@@ -3947,6 +3968,7 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
// 当前平台可选的协议档(responses 仅 deepseek)。
const cnProtocolOptions = computed>(() => {
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
+ { value: 'adaptive', labelKey: 'adaptive' },
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
@@ -3955,6 +3977,18 @@ const cnProtocolOptions = computed>(() => {
+ const opts: Array<{ value: CnNativeApiProtocol; labelKey: string }> = [
+ { value: 'chat_completions', labelKey: 'chatCompletions' },
+ { value: 'anthropic', labelKey: 'anthropic' }
+ ]
+ if (form.platform === 'deepseek') opts.push({ value: 'responses', labelKey: 'responses' })
+ return opts
+})
+
+function resetAdaptiveBaseUrls(platform: 'kimi' | 'zhipu' | 'deepseek', mode: CnAccountMode) {
+ adaptiveBaseUrls.value = defaultCNAdaptiveBaseUrls(platform, mode)
+}
// 当前选中平台的品牌色(选中卡片描边 / 图标底色),与 platformColors 取色一致。
const cnAccentActiveClass = computed(() => {
switch (form.platform) {
@@ -3981,24 +4015,44 @@ const cnAccentIconClass = computed(() => {
}
})
// 切换国产供应商平台:强制 apikey 类型,deepseek 无 coding 套餐故锁定 payg,
-// 协议回落 chat_completions,并把 base url 重置为该平台默认端点。
+// 协议回落 adaptive,并把 base url 重置为该平台默认端点。
function selectCNPlatform(platform: 'kimi' | 'zhipu' | 'deepseek') {
form.platform = platform
form.type = 'apikey'
accountCategory.value = 'apikey'
- apiProtocol.value = 'chat_completions'
+ apiProtocol.value = 'adaptive'
if (platform === 'deepseek') {
accountMode.value = 'payg'
}
apiKeyBaseUrl.value = defaultCNBaseUrl(platform, accountMode.value, apiProtocol.value)
+ resetAdaptiveBaseUrls(platform, accountMode.value)
}
// 账号类型 / 协议变更时同步默认 base url。
-watch(accountMode, (mode) => {
+watch(accountMode, (mode, previousMode) => {
if (!isCNPlatform.value) return
+ if (apiProtocol.value === 'adaptive') {
+ const previousDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, previousMode)
+ const nextDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, mode)
+ for (const item of cnAdaptiveProtocolOptions.value) {
+ if (!adaptiveBaseUrls.value[item.value] || adaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
+ adaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
+ }
+ }
+ apiKeyBaseUrl.value = adaptiveBaseUrls.value.chat_completions
+ return
+ }
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, mode, apiProtocol.value)
})
watch(apiProtocol, (protocol) => {
if (!isCNPlatform.value) return
+ if (protocol === 'adaptive') {
+ const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, accountMode.value)
+ for (const item of cnAdaptiveProtocolOptions.value) {
+ if (!adaptiveBaseUrls.value[item.value]) adaptiveBaseUrls.value[item.value] = defaults[item.value]
+ }
+ apiKeyBaseUrl.value = adaptiveBaseUrls.value.chat_completions
+ return
+ }
apiKeyBaseUrl.value = defaultCNBaseUrl(form.platform, accountMode.value, protocol)
})
// 点击预设端点:同时回填 base url、账号类型与协议。
@@ -4956,7 +5010,8 @@ const resetForm = () => {
accountCategory.value = 'oauth-based'
addMethod.value = 'oauth'
accountMode.value = 'payg'
- apiProtocol.value = 'chat_completions'
+ apiProtocol.value = 'adaptive'
+ adaptiveBaseUrls.value = { chat_completions: '', anthropic: '', responses: '' }
apiKeyBaseUrl.value = 'https://api.anthropic.com'
apiKeyValue.value = ''
upstreamBillingAutoProbeEnabled.value = true
@@ -5425,10 +5480,19 @@ const handleSubmit = async () => {
if (form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek') {
credentials.account_mode = accountMode.value
credentials.api_protocol = apiProtocol.value
+ if (apiProtocol.value === 'adaptive') {
+ const defaults = defaultCNAdaptiveBaseUrls(form.platform, accountMode.value)
+ const protocolBaseUrls: Record = {}
+ for (const item of cnAdaptiveProtocolOptions.value) {
+ protocolBaseUrls[item.value] = (adaptiveBaseUrls.value[item.value] || defaults[item.value]).trim()
+ }
+ credentials.api_base_urls = protocolBaseUrls
+ credentials.base_url = protocolBaseUrls.chat_completions
+ }
const resolvedCNBase = (
apiKeyBaseUrl.value.trim() || defaultCNBaseUrl(form.platform, accountMode.value, apiProtocol.value)
).trim()
- if (resolvedCNBase) {
+ if (apiProtocol.value !== 'adaptive' && resolvedCNBase) {
credentials.base_url = resolvedCNBase
}
}
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 6495b61e6a..0f433cd53a 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -28,7 +28,7 @@
-
+
+
+
+
+
+
+
+
+
+
+ {{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
+
+
@@ -2802,11 +2816,13 @@ import {
isHeaderOverrideCapable,
splitHeaderOverridesObject,
validateHeaderOverrideRows,
+ defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
HEADER_OVERRIDE_ENABLED_CREDENTIAL_KEY,
HEADER_OVERRIDES_CREDENTIAL_KEY,
type CnAccountMode,
type CnApiProtocol,
+ type CnNativeApiProtocol,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTime, formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
@@ -2909,8 +2925,13 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
}
return 'kimi'
})
-const editApiProtocol = ref
('chat_completions')
+const editApiProtocol = ref('adaptive')
const editAccountMode = ref('payg')
+const editAdaptiveBaseUrls = ref>({
+ chat_completions: '',
+ anthropic: '',
+ responses: ''
+})
// 回填窗口标志:syncFormFromAccount 会同步改写 editAccountMode / editApiProtocol,
// 而 watcher(pre-flush)在同步代码执行完之后才触发——若不抑制,会把刚恢复的
// 存储版 base_url(可能是用户自定义/中转地址)覆盖为官方预设并在下次保存时持久化。
@@ -2930,6 +2951,7 @@ const cnAccountModeOptions = computed>(() => {
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
+ { value: 'adaptive', labelKey: 'adaptive' },
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
@@ -2938,11 +2960,35 @@ const cnProtocolOptions = computed {
+const editAdaptiveProtocolOptions = computed>(() => {
+ const opts: Array<{ value: CnNativeApiProtocol; labelKey: string }> = [
+ { value: 'chat_completions', labelKey: 'chatCompletions' },
+ { value: 'anthropic', labelKey: 'anthropic' }
+ ]
+ if (props.account?.platform === 'deepseek') opts.push({ value: 'responses', labelKey: 'responses' })
+ return opts
+})
+watch(editApiProtocol, (protocol, previousProtocol) => {
if (!isCNApiKeyAccount.value || syncingForm.value) return
+ if (protocol === 'adaptive') {
+ const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, editAccountMode.value)
+ for (const item of editAdaptiveProtocolOptions.value) {
+ if (!editAdaptiveBaseUrls.value[item.value]) editAdaptiveBaseUrls.value[item.value] = defaults[item.value]
+ }
+ if (previousProtocol !== 'adaptive' && editBaseUrl.value.trim()) {
+ editAdaptiveBaseUrls.value[previousProtocol] = editBaseUrl.value.trim()
+ }
+ editBaseUrl.value = editAdaptiveBaseUrls.value.chat_completions
+ return
+ }
+ if (previousProtocol === 'adaptive') {
+ editBaseUrl.value = editAdaptiveBaseUrls.value[protocol] ||
+ defaultCNBaseUrl(props.account!.platform, editAccountMode.value, protocol)
+ return
+ }
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, editAccountMode.value, protocol)
})
-watch(editAccountMode, (mode) => {
+watch(editAccountMode, (mode, previousMode) => {
if (!isCNApiKeyAccount.value || syncingForm.value) return
// deepseek 无 coding 套餐:防御性回退(UI 已隐藏该选项)。
const effectiveMode = props.account!.platform === 'deepseek' && mode === 'coding' ? 'payg' : mode
@@ -2950,6 +2996,17 @@ watch(editAccountMode, (mode) => {
editAccountMode.value = effectiveMode
return
}
+ if (editApiProtocol.value === 'adaptive') {
+ const previousDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, previousMode)
+ const nextDefaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, mode)
+ for (const item of editAdaptiveProtocolOptions.value) {
+ if (!editAdaptiveBaseUrls.value[item.value] || editAdaptiveBaseUrls.value[item.value] === previousDefaults[item.value]) {
+ editAdaptiveBaseUrls.value[item.value] = nextDefaults[item.value]
+ }
+ }
+ editBaseUrl.value = editAdaptiveBaseUrls.value.chat_completions
+ return
+ }
editBaseUrl.value = defaultCNBaseUrl(props.account!.platform, mode, editApiProtocol.value)
})
const cnProtocolDescKey = computed(
@@ -3774,12 +3831,27 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editAccountMode.value = credentials.account_mode === 'coding' ? 'coding' : 'payg'
const storedProtocol = credentials.api_protocol
editApiProtocol.value =
- storedProtocol === 'anthropic' || storedProtocol === 'responses' ? storedProtocol : 'chat_completions'
+ storedProtocol === 'adaptive' || storedProtocol === 'anthropic' || storedProtocol === 'responses'
+ ? storedProtocol
+ : 'adaptive'
if (newAccount.platform !== 'deepseek' && editApiProtocol.value === 'responses') {
editApiProtocol.value = 'chat_completions'
}
+ const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, editAccountMode.value)
+ const storedBaseUrls = (credentials.api_base_urls as Record | undefined) || {}
+ editAdaptiveBaseUrls.value = {
+ chat_completions: typeof storedBaseUrls.chat_completions === 'string'
+ ? storedBaseUrls.chat_completions
+ : adaptiveDefaults.chat_completions,
+ anthropic: typeof storedBaseUrls.anthropic === 'string'
+ ? storedBaseUrls.anthropic
+ : adaptiveDefaults.anthropic,
+ responses: typeof storedBaseUrls.responses === 'string'
+ ? storedBaseUrls.responses
+ : adaptiveDefaults.responses
+ }
}
- const platformDefaultUrl =
+ const platformDefaultUrl =
newAccount.platform === 'openai'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
@@ -3791,7 +3863,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
newAccount.platform === 'deepseek'
? defaultCNBaseUrl(newAccount.platform, editAccountMode.value, editApiProtocol.value)
: 'https://api.anthropic.com'
- editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
+ editBaseUrl.value = isCNApiKeyAccount.value && editApiProtocol.value === 'adaptive'
+ ? editAdaptiveBaseUrls.value.chat_completions
+ : (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
loadModelRestrictionFromMapping(credentials.model_mapping as Record | undefined)
@@ -4463,6 +4537,17 @@ const handleSubmit = async () => {
if (isCNApiKeyAccount.value) {
newCredentials.account_mode = editAccountMode.value
newCredentials.api_protocol = editApiProtocol.value
+ if (editApiProtocol.value === 'adaptive') {
+ const defaults = defaultCNAdaptiveBaseUrls(cnPresetPlatform.value, editAccountMode.value)
+ const protocolBaseUrls: Record = {}
+ for (const item of editAdaptiveProtocolOptions.value) {
+ protocolBaseUrls[item.value] = (editAdaptiveBaseUrls.value[item.value] || defaults[item.value]).trim()
+ }
+ newCredentials.api_base_urls = protocolBaseUrls
+ newCredentials.base_url = protocolBaseUrls.chat_completions
+ } else {
+ delete newCredentials.api_base_urls
+ }
}
// Handle API key
diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
index 552998802f..2681958d9a 100644
--- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
@@ -269,6 +269,28 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
expect(probeUpstreamBillingMock).not.toHaveBeenCalled()
})
+ it('submits adaptive Kimi protocol endpoints', async () => {
+ const wrapper = mountModal()
+ await selectButtonByText(wrapper, 'Kimi')
+ await selectButtonByText(wrapper, 'admin.accounts.cnProviders.apiProtocol.adaptive')
+ await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi adaptive')
+ await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi')
+
+ 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: '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'
+ }
+ })
+ })
+
it('exposes Agent Identity in the OpenAI authorization methods', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
index e21089313e..2e82bb4542 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
@@ -343,6 +343,37 @@ describe('EditAccountModal', () => {
})
})
+ it('preserves adaptive GLM endpoints on submit', async () => {
+ const account = buildAccount()
+ account.platform = 'zhipu'
+ account.credentials = {
+ api_key: 'sk-glm',
+ account_mode: 'coding',
+ api_protocol: 'adaptive',
+ base_url: 'https://open.bigmodel.cn/api/coding/paas/v4',
+ api_base_urls: {
+ chat_completions: 'https://open.bigmodel.cn/api/coding/paas/v4',
+ anthropic: 'https://open.bigmodel.cn/api/anthropic'
+ }
+ }
+ 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: 'coding',
+ api_protocol: 'adaptive',
+ base_url: 'https://open.bigmodel.cn/api/coding/paas/v4',
+ api_base_urls: {
+ chat_completions: 'https://open.bigmodel.cn/api/coding/paas/v4',
+ anthropic: 'https://open.bigmodel.cn/api/anthropic'
+ }
+ })
+ })
+
it('preserves model mappings when editing the whitelist', async () => {
const account = buildAccount()
account.credentials.model_mapping = {
diff --git a/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts
new file mode 100644
index 0000000000..618210ddf0
--- /dev/null
+++ b/frontend/src/components/account/__tests__/credentialsBuilder.cnAdaptive.spec.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest'
+
+import { defaultCNAdaptiveBaseUrls } from '../credentialsBuilder'
+
+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: ''
+ })
+ expect(defaultCNAdaptiveBaseUrls('kimi', 'coding')).toEqual({
+ chat_completions: 'https://api.kimi.com/coding/v1',
+ anthropic: 'https://api.kimi.com/coding',
+ responses: ''
+ })
+ })
+
+ it('resolves GLM endpoints by account mode', () => {
+ expect(defaultCNAdaptiveBaseUrls('zhipu', 'payg')).toEqual({
+ chat_completions: 'https://open.bigmodel.cn/api/paas/v4',
+ anthropic: 'https://open.bigmodel.cn/api/anthropic',
+ responses: ''
+ })
+ expect(defaultCNAdaptiveBaseUrls('zhipu', 'coding')).toEqual({
+ chat_completions: 'https://open.bigmodel.cn/api/coding/paas/v4',
+ anthropic: 'https://open.bigmodel.cn/api/anthropic',
+ responses: ''
+ })
+ })
+
+ it('includes all three native DeepSeek endpoints', () => {
+ expect(defaultCNAdaptiveBaseUrls('deepseek', 'payg')).toEqual({
+ chat_completions: 'https://api.deepseek.com',
+ anthropic: 'https://api.deepseek.com/anthropic',
+ responses: 'https://api.deepseek.com'
+ })
+ })
+})
diff --git a/frontend/src/components/account/credentialsBuilder.ts b/frontend/src/components/account/credentialsBuilder.ts
index 1ba2cb22bf..7f844fba0a 100644
--- a/frontend/src/components/account/credentialsBuilder.ts
+++ b/frontend/src/components/account/credentialsBuilder.ts
@@ -256,8 +256,9 @@ export const GROK_BASE_URL_PRESETS: GrokBaseUrlPreset[] = [
export type CnAccountMode = 'payg' | 'coding'
-/** 仅 deepseek 支持 responses 协议(官方原生 /responses 端点,适配 Codex)。 */
-export type CnApiProtocol = 'chat_completions' | 'anthropic' | 'responses'
+/** 仅 deepseek 支持原生 responses;adaptive 会按入站协议选择原生端点。 */
+export type CnApiProtocol = 'adaptive' | 'chat_completions' | 'anthropic' | 'responses'
+export type CnNativeApiProtocol = Exclude
export interface CnBaseUrlPreset {
mode: CnAccountMode
@@ -321,6 +322,18 @@ export function defaultCNBaseUrl(
}
}
+/** 返回自适应模式下需要配置的原生协议及其默认端点。 */
+export function defaultCNAdaptiveBaseUrls(
+ platform: 'kimi' | 'zhipu' | 'deepseek',
+ mode: CnAccountMode
+): Record {
+ return {
+ chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'),
+ anthropic: defaultCNBaseUrl(platform, mode, 'anthropic'),
+ responses: platform === 'deepseek' ? defaultCNBaseUrl(platform, mode, 'responses') : ''
+ }
+}
+
// ===== 国产供应商用量单元格可见性(单一事实源) =====
// CNProviderQuotaCell / CNProviderBalanceCell 与 AccountUsageCell 的占位符判定
// 共用,避免多处复制条件后一处改另一处漏改。
diff --git a/frontend/src/i18n/locales/en/admin/accounts.ts b/frontend/src/i18n/locales/en/admin/accounts.ts
index 858e420b99..b207251a60 100644
--- a/frontend/src/i18n/locales/en/admin/accounts.ts
+++ b/frontend/src/i18n/locales/en/admin/accounts.ts
@@ -118,6 +118,10 @@ export default {
},
apiProtocol: {
title: 'API Protocol',
+ adaptive: 'Adaptive',
+ adaptiveDesc: 'Uses the matching native provider endpoint for each inbound protocol, converting only when unavailable.',
+ endpoints: 'Protocol endpoints',
+ responsesFallbackDesc: 'Responses requests are converted to Chat Completions because this provider has no native Responses endpoint.',
chatCompletions: 'Chat Completions',
chatCompletionsDesc: 'Standard OpenAI-compatible endpoint; requests in other formats are converted.',
anthropic: 'Anthropic',
diff --git a/frontend/src/i18n/locales/zh/admin/accounts.ts b/frontend/src/i18n/locales/zh/admin/accounts.ts
index 0d1883da06..382e86cbd1 100644
--- a/frontend/src/i18n/locales/zh/admin/accounts.ts
+++ b/frontend/src/i18n/locales/zh/admin/accounts.ts
@@ -321,6 +321,10 @@ export default {
},
apiProtocol: {
title: 'API 协议',
+ adaptive: '自适应',
+ adaptiveDesc: '按入站协议优先使用供应商原生端点,仅在没有对应端点时转换。',
+ endpoints: '协议端点',
+ responsesFallbackDesc: '该供应商没有原生 Responses 端点,Responses 请求将转换为 Chat Completions。',
chatCompletions: 'Chat Completions',
chatCompletionsDesc: '标准 OpenAI 兼容端点,其他格式请求将被转换。',
anthropic: 'Anthropic',
From b3092145dbd56e3632d0b26c2b27426173ee6592 Mon Sep 17 00:00:00 2001
From: SavitarC <71271200+SavitarC@users.noreply.github.com>
Date: Thu, 20 Aug 2026 12:40:59 +0800
Subject: [PATCH 2/2] fix(accounts): harden adaptive protocol compatibility
---
.../internal/service/account_test_service.go | 17 +-
.../account_test_service_cn_adaptive.go | 206 ++++++++++++++++++
.../account_test_service_cn_adaptive_test.go | 191 ++++++++++++++++
.../service/adaptive_api_protocol_test.go | 129 +++++++++--
.../service/openai_apikey_responses_probe.go | 5 +-
.../openai_apikey_responses_probe_test.go | 29 +--
.../openai_gateway_chat_completions.go | 52 +++--
.../service/openai_gateway_forward.go | 29 ++-
.../service/openai_gateway_messages.go | 9 +-
.../service/upstream_billing_probe.go | 3 +
.../service/upstream_billing_probe_test.go | 32 +++
.../components/account/CreateAccountModal.vue | 12 +-
.../components/account/EditAccountModal.vue | 53 +++--
.../__tests__/CreateAccountModal.spec.ts | 33 ++-
.../__tests__/EditAccountModal.spec.ts | 141 ++++++++++++
15 files changed, 862 insertions(+), 79 deletions(-)
create mode 100644 backend/internal/service/account_test_service_cn_adaptive.go
create mode 100644 backend/internal/service/account_test_service_cn_adaptive_test.go
diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go
index 900d3f8e15..49d5c76295 100644
--- a/backend/internal/service/account_test_service.go
+++ b/backend/internal/service/account_test_service.go
@@ -285,9 +285,13 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}
// Route to platform-specific test method
- if account.IsCNProvider() &&
- (account.GetAPIProtocol() == APIProtocolChatCompletions || account.IsAdaptiveAPIProtocol()) {
- return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt)
+ if account.IsCNProvider() {
+ switch account.GetAPIProtocol() {
+ case APIProtocolAdaptive:
+ return s.testCNProviderAdaptiveConnection(c, account, modelID, prompt)
+ case APIProtocolChatCompletions:
+ return s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt)
+ }
}
if account.IsOpenAI() {
@@ -3064,6 +3068,13 @@ func (s *AccountTestService) testOpenAIImageOAuth(c *gin.Context, ctx context.Co
}
func (s *AccountTestService) sendEvent(c *gin.Context, event TestEvent) {
+ if event.Type == "test_complete" {
+ if suppress, ok := c.Get(accountTestSuppressCompletionContextKey); ok {
+ if suppressCompletion, _ := suppress.(bool); suppressCompletion {
+ return
+ }
+ }
+ }
eventJSON, _ := json.Marshal(event)
if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", eventJSON); err != nil {
log.Printf("failed to write SSE event: %v", err)
diff --git a/backend/internal/service/account_test_service_cn_adaptive.go b/backend/internal/service/account_test_service_cn_adaptive.go
new file mode 100644
index 0000000000..ae52e4f117
--- /dev/null
+++ b/backend/internal/service/account_test_service_cn_adaptive.go
@@ -0,0 +1,206 @@
+package service
+
+import (
+ "bufio"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+
+ "github.com/Wei-Shaw/sub2api/internal/pkg/claude"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
+ "github.com/gin-gonic/gin"
+)
+
+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.
+func (s *AccountTestService) testCNProviderAdaptiveConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
+ testModelID := strings.TrimSpace(modelID)
+ if testModelID == "" {
+ testModelID = openai.DefaultTestModel
+ }
+ testModelID = account.GetMappedModel(testModelID)
+
+ authToken := strings.TrimSpace(account.GetOpenAIProtocolAPIKey())
+ if authToken == "" {
+ return s.sendErrorAndEnd(c, "No API key available")
+ }
+
+ // The existing Chat probe owns the SSE lifecycle. Suppress intermediate
+ // completion events until every native adaptive endpoint has passed.
+ c.Set(accountTestSuppressCompletionContextKey, true)
+ defer c.Set(accountTestSuppressCompletionContextKey, false)
+ if err := s.testCNProviderChatCompletionsConnection(c, account, modelID, prompt); err != nil {
+ return err
+ }
+
+ if err := s.testCNProviderAdaptiveAnthropicConnection(c, account, testModelID, authToken); err != nil {
+ return err
+ }
+
+ if account.Platform == PlatformDeepseek {
+ if err := s.testCNProviderAdaptiveResponsesConnection(c, account, testModelID, authToken); err != nil {
+ return err
+ }
+ }
+
+ c.Set(accountTestSuppressCompletionContextKey, false)
+ s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
+ return nil
+}
+
+func (s *AccountTestService) testCNProviderAdaptiveAnthropicConnection(c *gin.Context, account *Account, testModelID string, authToken string) error {
+ ctx := c.Request.Context()
+ baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolAnthropic))
+ if err != nil {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Anthropic base URL: %s", err.Error()))
+ }
+ apiURL := strings.TrimRight(baseURL, "/") + "/v1/messages"
+
+ payload, err := createTestPayload(testModelID)
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic test payload")
+ }
+ payloadBytes, _ := json.Marshal(payload)
+
+ s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /v1/messages 测试自适应 Anthropic 端点"})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes))
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Failed to create adaptive Anthropic request")
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("anthropic-version", "2023-06-01")
+ for key, value := range claude.DefaultHeaders {
+ req.Header.Set(key, value)
+ }
+ req.Header.Set("anthropic-beta", claude.APIKeyBetaHeader)
+ setAnthropicAPIKeyAuthHeader(req.Header, account, authToken)
+ account.ApplyHeaderOverrides(req.Header)
+
+ resp, err := s.doCNProviderAdaptiveRequest(req, account)
+ if err != nil {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint request failed: %s", err.Error()))
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ errMsg := fmt.Sprintf("Adaptive Anthropic endpoint returned %d: %s", resp.StatusCode, string(body))
+ if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
+ _ = s.accountRepo.SetError(ctx, account.ID, errMsg)
+ }
+ return s.sendErrorAndEnd(c, errMsg)
+ }
+
+ if err := s.processCNProviderAdaptiveAnthropicStream(c, resp.Body); err != nil {
+ return err
+ }
+ s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /v1/messages 验证"})
+ return nil
+}
+
+func (s *AccountTestService) processCNProviderAdaptiveAnthropicStream(c *gin.Context, body io.Reader) error {
+ reader := bufio.NewReader(body)
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if err == io.EOF {
+ return s.sendErrorAndEnd(c, "Adaptive Anthropic stream ended before message_stop")
+ }
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic stream read error: %s", err.Error()))
+ }
+
+ line = strings.TrimSpace(line)
+ if line == "" || !sseDataPrefix.MatchString(line) {
+ continue
+ }
+ jsonStr := sseDataPrefix.ReplaceAllString(line, "")
+ if jsonStr == "[DONE]" {
+ return nil
+ }
+
+ var data map[string]any
+ if err := json.Unmarshal([]byte(jsonStr), &data); err != nil {
+ continue
+ }
+ switch eventType, _ := data["type"].(string); eventType {
+ case "content_block_delta":
+ if delta, ok := data["delta"].(map[string]any); ok {
+ if text, ok := delta["text"].(string); ok && text != "" {
+ s.sendEvent(c, TestEvent{Type: "content", Text: text})
+ }
+ }
+ case "message_stop":
+ return nil
+ case "error":
+ errorMsg := "Unknown error"
+ if errData, ok := data["error"].(map[string]any); ok {
+ if message, ok := errData["message"].(string); ok && message != "" {
+ errorMsg = message
+ }
+ }
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Anthropic endpoint error: %s", errorMsg))
+ }
+ }
+}
+
+func (s *AccountTestService) testCNProviderAdaptiveResponsesConnection(c *gin.Context, account *Account, testModelID string, authToken string) error {
+ ctx := c.Request.Context()
+ baseURL, err := s.validateUpstreamBaseURL(account.GetCNProtocolBaseURL(APIProtocolResponses))
+ if err != nil {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Invalid adaptive Responses base URL: %s", err.Error()))
+ }
+ 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.
+ delete(payload, "instructions")
+ payloadBytes, _ := json.Marshal(payload)
+ payloadBytes = normalizeDeepSeekResponsesRequestBody(account, payloadBytes)
+
+ s.sendEvent(c, TestEvent{Type: "status", Text: "正在通过原生 /responses 测试自适应 Responses 端点"})
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(payloadBytes))
+ if err != nil {
+ return s.sendErrorAndEnd(c, "Failed to create adaptive Responses request")
+ }
+ req = req.WithContext(WithHTTPUpstreamProfile(req.Context(), HTTPUpstreamProfileOpenAI))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "text/event-stream")
+ req.Header.Set("Authorization", "Bearer "+authToken)
+ applyOpenAICodexProbeHeaders(req.Header)
+ account.ApplyHeaderOverrides(req.Header)
+
+ resp, err := s.doCNProviderAdaptiveRequest(req, account)
+ if err != nil {
+ return s.sendErrorAndEnd(c, fmt.Sprintf("Adaptive Responses endpoint request failed: %s", err.Error()))
+ }
+ defer func() { _ = resp.Body.Close() }()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ errMsg := fmt.Sprintf("Adaptive Responses endpoint returned %d: %s", resp.StatusCode, string(body))
+ if resp.StatusCode == http.StatusUnauthorized && s.accountRepo != nil {
+ _ = s.accountRepo.SetError(ctx, account.ID, errMsg)
+ }
+ return s.sendErrorAndEnd(c, errMsg)
+ }
+
+ if err := s.processOpenAIStream(c, resp.Body); err != nil {
+ return err
+ }
+ s.sendEvent(c, TestEvent{Type: "status", Text: "已通过原生 /responses 验证"})
+ return nil
+}
+
+func (s *AccountTestService) doCNProviderAdaptiveRequest(req *http.Request, account *Account) (*http.Response, error) {
+ proxyURL := ""
+ if account.ProxyID != nil && account.Proxy != nil {
+ proxyURL = account.Proxy.URL()
+ }
+ return s.httpUpstream.DoWithTLS(req, proxyURL, account.ID, account.Concurrency, s.tlsFPProfileService.ResolveTLSProfile(account))
+}
diff --git a/backend/internal/service/account_test_service_cn_adaptive_test.go b/backend/internal/service/account_test_service_cn_adaptive_test.go
new file mode 100644
index 0000000000..808aec33b4
--- /dev/null
+++ b/backend/internal/service/account_test_service_cn_adaptive_test.go
@@ -0,0 +1,191 @@
+//go:build unit
+
+package service
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "github.com/tidwall/gjson"
+)
+
+func adaptiveCNAccountTestAccount(id int64, platform string) *Account {
+ return &Account{
+ ID: id,
+ Name: "adaptive-cn-test",
+ Platform: platform,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-adaptive-test",
+ "api_protocol": APIProtocolAdaptive,
+ "api_base_urls": map[string]any{
+ APIProtocolChatCompletions: "http://chat.example/v1",
+ APIProtocolAnthropic: "http://anthropic.example",
+ APIProtocolResponses: "http://responses.example",
+ },
+ },
+ }
+}
+
+func adaptiveCNAccountTestService(account *Account, responses ...*http.Response) (*AccountTestService, *httpUpstreamRecorder) {
+ repo := &openAIAccountTestRepo{
+ mockAccountRepoForGemini: mockAccountRepoForGemini{
+ accountsByID: map[int64]*Account{account.ID: account},
+ },
+ }
+ upstream := &httpUpstreamRecorder{responses: responses}
+ return &AccountTestService{
+ accountRepo: repo,
+ httpUpstream: upstream,
+ cfg: rawChatCompletionsTestConfig(),
+ }, upstream
+}
+
+func adaptiveCNChatTestResponse() *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(`data: {"choices":[{"delta":{"content":"chat ok"},"finish_reason":"stop"}]}
+
+data: [DONE]
+
+`)),
+ }
+}
+
+func adaptiveCNAnthropicTestResponse() *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(`data: {"type":"content_block_delta","delta":{"text":"anthropic ok"}}
+
+data: {"type":"message_stop"}
+
+`)),
+ }
+}
+
+func adaptiveCNResponsesTestResponse() *http.Response {
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(`data: {"type":"response.output_text.delta","delta":"responses ok"}
+
+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()
+
+ err := svc.TestAccountConnection(c, account.ID, testCase.model, "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 验证")
+ })
+ }
+}
+
+func TestAccountTestService_AdaptiveDeepSeekAlsoTestsResponsesEndpoint(t *testing.T) {
+ account := adaptiveCNAccountTestAccount(302, PlatformDeepseek)
+ svc, upstream := adaptiveCNAccountTestService(
+ account,
+ adaptiveCNChatTestResponse(),
+ adaptiveCNAnthropicTestResponse(),
+ adaptiveCNResponsesTestResponse(),
+ )
+ c, recorder := newTestContext()
+
+ err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault)
+
+ require.NoError(t, err)
+ require.Len(t, upstream.requests, 3)
+ require.Equal(t, "http://responses.example/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(
+ account,
+ adaptiveCNChatTestResponse(),
+ newJSONResponse(http.StatusNotFound, `{"error":{"message":"missing messages route"}}`),
+ )
+ c, recorder := newTestContext()
+
+ err := svc.TestAccountConnection(c, account.ID, "deepseek-chat", "", AccountTestModeDefault)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Adaptive Anthropic endpoint returned 404")
+ require.Len(t, upstream.requests, 2)
+ require.Contains(t, recorder.Body.String(), `"type":"error"`)
+ require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`)
+}
+
+func TestAccountTestService_AdaptiveRejectsInvalidAnthropicSuccessBody(t *testing.T) {
+ account := adaptiveCNAccountTestAccount(305, PlatformKimi)
+ svc, upstream := adaptiveCNAccountTestService(
+ account,
+ adaptiveCNChatTestResponse(),
+ newJSONResponse(http.StatusOK, `not an Anthropic stream`),
+ )
+ c, recorder := newTestContext()
+
+ err := svc.TestAccountConnection(c, account.ID, "kimi-k2.5", "", AccountTestModeDefault)
+
+ require.Error(t, err)
+ require.Contains(t, err.Error(), "Adaptive Anthropic stream ended before message_stop")
+ require.Len(t, upstream.requests, 2)
+ require.Contains(t, recorder.Body.String(), `"type":"error"`)
+ require.NotContains(t, recorder.Body.String(), `"type":"test_complete"`)
+}
+
+func TestAccountTestService_FixedCNChatProtocolStillTestsOnlyChatEndpoint(t *testing.T) {
+ account := adaptiveCNAccountTestAccount(304, PlatformZhipu)
+ account.Credentials["api_protocol"] = APIProtocolChatCompletions
+ account.Credentials["base_url"] = "http://fixed-chat.example/v1"
+ svc, upstream := adaptiveCNAccountTestService(account, adaptiveCNChatTestResponse())
+ c, recorder := newTestContext()
+
+ err := svc.TestAccountConnection(c, account.ID, "glm-4.7", "", AccountTestModeDefault)
+
+ require.NoError(t, err)
+ require.Len(t, upstream.requests, 1)
+ require.Equal(t, "http://fixed-chat.example/v1/chat/completions", upstream.requests[0].URL.String())
+ require.Equal(t, 1, strings.Count(recorder.Body.String(), `"type":"test_complete"`))
+}
diff --git a/backend/internal/service/adaptive_api_protocol_test.go b/backend/internal/service/adaptive_api_protocol_test.go
index 1da65203c6..210caa0695 100644
--- a/backend/internal/service/adaptive_api_protocol_test.go
+++ b/backend/internal/service/adaptive_api_protocol_test.go
@@ -10,6 +10,7 @@ import (
"net/http/httptest"
"testing"
+ "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
@@ -39,6 +40,45 @@ func adaptiveProtocolTestContext(path string, body []byte) *gin.Context {
return c
}
+type cnProtocolIngressCase struct {
+ name string
+ path string
+ body []byte
+ forward func(*OpenAIGatewayService, *gin.Context, *Account, []byte) error
+}
+
+func cnProtocolIngressCases() []cnProtocolIngressCase {
+ return []cnProtocolIngressCase{
+ {
+ name: "chat completions",
+ path: "/v1/chat/completions",
+ body: []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}],"stream":false}`),
+ forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
+ _, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
+ return err
+ },
+ },
+ {
+ name: "messages",
+ path: "/v1/messages",
+ body: []byte(`{"model":"deepseek-chat","max_tokens":32,"messages":[{"role":"user","content":"hello"}],"stream":false}`),
+ forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
+ _, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
+ return err
+ },
+ },
+ {
+ name: "responses",
+ path: "/v1/responses",
+ body: []byte(`{"model":"deepseek-chat","input":"hello","stream":false}`),
+ forward: func(svc *OpenAIGatewayService, c *gin.Context, account *Account, body []byte) error {
+ _, err := svc.Forward(context.Background(), c, account, body)
+ return err
+ },
+ },
+ }
+}
+
func TestAdaptiveProtocolRoutesChatCompletionsToNativeChat(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"glm-4.7","messages":[{"role":"user","content":"hello"}],"stream":false}`)
@@ -56,6 +96,41 @@ func TestAdaptiveProtocolRoutesChatCompletionsToNativeChat(t *testing.T) {
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
}
+func TestAdaptiveProtocolRoutesResponsesShapedChatToNativeResponses(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"stream":false}`)
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{
+ APIProtocolChatCompletions: "http://chat.example",
+ APIProtocolAnthropic: "http://anthropic.example",
+ APIProtocolResponses: "http://responses.example",
+ })
+
+ _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", body), account, body, "", "")
+ require.Error(t, err)
+ require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String())
+ require.True(t, gjson.GetBytes(upstream.lastBody, "input").Exists())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "messages").Exists())
+}
+
+func TestAdaptiveProtocolConvertsResponsesShapedChatForChatOnlyProvider(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ body := []byte(`{"model":"kimi-k2.5","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",
+ })
+
+ _, err := svc.ForwardAsChatCompletions(context.Background(), adaptiveProtocolTestContext("/v1/chat/completions", 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())
+}
+
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}`)
@@ -91,7 +166,7 @@ func TestAdaptiveProtocolConvertsKimiResponsesToChatCompletions(t *testing.T) {
func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"deepseek-v4","input":"hello","store":true,"previous_response_id":"resp_old","stream":false}`)
+ body := []byte(`{"model":"deepseek-v4","input":"hello","max_output_tokens":32,"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(PlatformDeepseek, map[string]any{
@@ -105,20 +180,48 @@ func TestAdaptiveProtocolRoutesDeepSeekResponsesToNativeResponses(t *testing.T)
require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String())
require.False(t, gjson.GetBytes(upstream.lastBody, "store").Bool())
require.False(t, gjson.GetBytes(upstream.lastBody, "previous_response_id").Exists())
+ require.Equal(t, int64(32), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
+ require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists())
}
-func TestAdaptiveProtocolConvertsDeepSeekResponsesCompactToChatCompletions(t *testing.T) {
+func TestFixedCNChatProtocolOverridesStaleResponsesMode(t *testing.T) {
gin.SetMode(gin.TestMode)
- body := []byte(`{"model":"deepseek-v4","input":"hello","stream":false}`)
- upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
- svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
- account := adaptiveProtocolTestAccount(PlatformDeepseek, map[string]any{
- APIProtocolChatCompletions: "http://chat.example",
- APIProtocolAnthropic: "http://anthropic.example",
- APIProtocolResponses: "http://responses.example",
- })
+ for _, tc := range cnProtocolIngressCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformDeepseek, nil)
+ account.Credentials["api_protocol"] = APIProtocolChatCompletions
+ account.Credentials["base_url"] = "http://chat.example"
+ account.Extra = map[string]any{
+ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
+ }
- _, err := svc.Forward(context.Background(), adaptiveProtocolTestContext("/v1/responses/compact", body), account, body)
- require.Error(t, err)
- require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String())
+ err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body)
+
+ require.Error(t, err)
+ require.Equal(t, "http://chat.example/v1/chat/completions", upstream.lastReq.URL.String())
+ })
+ }
+}
+
+func TestFixedCNResponsesProtocolOverridesStaleChatMode(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ for _, tc := range cnProtocolIngressCases() {
+ t.Run(tc.name, func(t *testing.T) {
+ upstream := &httpUpstreamRecorder{err: errors.New("stop after capture")}
+ svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
+ account := adaptiveProtocolTestAccount(PlatformDeepseek, nil)
+ account.Credentials["api_protocol"] = APIProtocolResponses
+ account.Credentials["base_url"] = "http://responses.example"
+ account.Extra = map[string]any{
+ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
+ }
+
+ err := tc.forward(svc, adaptiveProtocolTestContext(tc.path, tc.body), account, tc.body)
+
+ require.Error(t, err)
+ require.Equal(t, "http://responses.example/responses", upstream.lastReq.URL.String())
+ })
+ }
}
diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go
index 43ad7c82d7..329cfebad4 100644
--- a/backend/internal/service/openai_apikey_responses_probe.go
+++ b/backend/internal/service/openai_apikey_responses_probe.go
@@ -125,8 +125,8 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
if account.IsCNProvider() {
// 国产 OpenAI 兼容上游(kimi/zhipu/deepseek)普遍仅支持 /v1/chat/completions,
// 不存在 /v1/responses 端点。直接落标 false 走 Chat Completions 直转,跳过网络探测。
- // 例外:deepseek 的 responses 协议账号(api_protocol=responses)使用官方原生
- // /responses 端点,落标 force_responses 强制走 Responses 路径。
+ // 例外:deepseek 的固定 responses 和 adaptive 账号使用官方原生 /responses
+ // 端点,落标 force_responses;其余协议显式重置为 auto,避免切换后残留强制模式。
if account.GetAPIProtocol() == APIProtocolResponses ||
(account.Platform == PlatformDeepseek && account.IsAdaptiveAPIProtocol()) {
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
@@ -136,6 +136,7 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
return
}
_ = s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
+ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeAuto),
openai_compat.ExtraKeyResponsesSupported: false,
})
return
diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go
index 1b9cdd65e6..f3b4eb0e6d 100644
--- a/backend/internal/service/openai_apikey_responses_probe_test.go
+++ b/backend/internal/service/openai_apikey_responses_probe_test.go
@@ -49,17 +49,19 @@ func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) {
require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported])
}
-func TestProbeOpenAIAPIKeyResponsesSupportAdaptiveCNProviders(t *testing.T) {
+func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) {
tests := []struct {
- name string
- id int64
- platform string
- wantSupport bool
- wantForceMode bool
+ name string
+ id int64
+ platform string
+ protocol string
+ wantSupport bool
+ wantMode string
}{
- {name: "deepseek adaptive supports responses", id: 201, platform: PlatformDeepseek, wantSupport: true, wantForceMode: true},
- {name: "kimi adaptive falls back to chat", id: 202, platform: PlatformKimi, wantSupport: false},
- {name: "zhipu adaptive falls back to chat", id: 203, platform: PlatformZhipu, wantSupport: false},
+ {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: "zhipu adaptive falls back to chat", id: 204, platform: PlatformZhipu, protocol: APIProtocolAdaptive, wantSupport: false, wantMode: string(openai_compat.ResponsesSupportModeAuto)},
}
for _, tc := range tests {
@@ -67,7 +69,10 @@ func TestProbeOpenAIAPIKeyResponsesSupportAdaptiveCNProviders(t *testing.T) {
updateCalls := make(chan map[string]any, 1)
account := Account{
ID: tc.id, Platform: tc.platform, Type: AccountTypeAPIKey,
- Credentials: map[string]any{"api_key": "sk-test", "api_protocol": APIProtocolAdaptive},
+ Credentials: map[string]any{"api_key": "sk-test", "api_protocol": tc.protocol},
+ Extra: map[string]any{
+ openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
+ },
}
repo := &snapshotUpdateAccountRepo{
stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}},
@@ -79,9 +84,7 @@ func TestProbeOpenAIAPIKeyResponsesSupportAdaptiveCNProviders(t *testing.T) {
updates := <-updateCalls
require.Equal(t, tc.wantSupport, updates[openai_compat.ExtraKeyResponsesSupported])
- if tc.wantForceMode {
- require.Equal(t, string(openai_compat.ResponsesSupportModeForceResponses), updates[openai_compat.ExtraKeyResponsesMode])
- }
+ require.Equal(t, tc.wantMode, updates[openai_compat.ExtraKeyResponsesMode])
})
}
}
diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go
index 5dac6e9268..9c413f0bc6 100644
--- a/backend/internal/service/openai_gateway_chat_completions.go
+++ b/backend/internal/service/openai_gateway_chat_completions.go
@@ -46,11 +46,11 @@ var cursorResponsesUnsupportedFields = []string{
// 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂:
// 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。
//
-// 当前路由策略(基于账号覆盖模式/探测标记,详见 openai_compat.ShouldUseResponsesAPI):
-// - APIKey 账号 + 强制或探测确认不支持 Responses → 走 forwardAsRawChatCompletions
-// 直转上游 /v1/chat/completions,不做协议转换
-// - 其他所有情况(OAuth、APIKey 强制/探测确认支持、未探测)→ 走原有 CC→Responses
-// 转换路径(保留旧行为,存量未探测账号零兼容破坏)
+// 当前路由策略:
+// - CN 账号以 credentials.api_protocol 为权威;adaptive/chat_completions 入站 Chat
+// 直转原生 CC,anthropic 走原生 Anthropic,responses 走 Responses
+// - 其他 APIKey 账号仍按覆盖模式/探测标记分流(详见
+// openai_compat.ShouldUseResponsesAPI)
func (s *OpenAIGatewayService) ForwardAsChatCompletions(
ctx context.Context,
c *gin.Context,
@@ -88,9 +88,37 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
- // 自适应账号的 Chat Completions 入站始终使用供应商原生 CC 端点。
+ // Cursor compatibility: some clients send a Responses-shaped body to the
+ // /v1/chat/completions URL. Detect it before adaptive routing so adaptive
+ // accounts never forward the body unchanged to a Chat Completions endpoint.
+ isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
+
+ // 自适应账号的标准 Chat Completions 入站使用供应商原生 CC 端点。
+ // Responses 形状下,DeepSeek 继续走下方原生 Responses 链;Kimi/GLM
+ // 没有 Responses 端点,先转换成 Chat Completions 再直转。
if account.IsAdaptiveAPIProtocol() {
- return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
+ if !isResponsesShape {
+ return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
+ }
+ if account.Platform != PlatformDeepseek {
+ var responsesReq apicompat.ResponsesRequest
+ if err := json.Unmarshal(body, &responsesReq); err != nil {
+ return nil, fmt.Errorf("parse responses-shaped chat completions request: %w", err)
+ }
+ chatReq, err := apicompat.ResponsesToChatCompletionsRequestWithOptions(
+ &responsesReq,
+ &apicompat.ResponsesToChatOptions{ReasoningContentByID: s.reasoningContentByID},
+ )
+ if err != nil {
+ return nil, fmt.Errorf("convert responses-shaped chat completions request: %w", err)
+ }
+ chatBody, err := json.Marshal(chatReq)
+ if err != nil {
+ return nil, fmt.Errorf("marshal converted chat completions request: %w", err)
+ }
+ return s.forwardAsRawChatCompletions(ctx, c, account, chatBody, defaultMappedModel)
+ }
+ // DeepSeek 原生 Responses 请求继续走下方 Responses→Chat 回程转换。
}
// 入口分流(国产供应商 Anthropic 协议):上游为供应商原生 Anthropic 端点,
@@ -101,9 +129,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
return s.forwardChatCompletionsViaNativeAnthropic(ctx, c, account, body, defaultMappedModel)
}
- // 入口分流:APIKey 账号 + 强制或已探测确认上游不支持 Responses,走 CC 直转。
- // 自动模式下标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。
- if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
+ // 固定 chat_completions 的 CN 账号,以及强制或已探测确认不支持 Responses
+ // 的其他 APIKey 账号,均走 CC 直转。
+ if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
@@ -138,11 +166,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
// and produce `input: null`, which Codex upstreams reject with
// "Invalid type for 'input': expected a string, but got an object".
//
- // Detect that shape and forward the raw body as-is, only rewriting `model`
+ // Forward that shape as-is, only rewriting `model`
// to the resolved upstream model. The downstream codex OAuth transform will
// still normalize store/stream/instructions/etc.
- isResponsesShape := !gjson.GetBytes(body, "messages").Exists() && gjson.GetBytes(body, "input").Exists()
-
var (
responsesReq *apicompat.ResponsesRequest
responsesBody []byte
diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go
index 415164cc8e..ec5fafdb72 100644
--- a/backend/internal/service/openai_gateway_forward.go
+++ b/backend/internal/service/openai_gateway_forward.go
@@ -104,6 +104,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
requestView := newOpenAIRequestView(body)
reqModel, reqStream, promptCacheKey := requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel := reqModel
+ nativeDeepSeekResponses := account.Platform == PlatformDeepseek &&
+ (account.GetAPIProtocol() == APIProtocolResponses || account.IsAdaptiveAPIProtocol())
if account.Platform == PlatformGrok {
return s.forwardGrokResponses(ctx, c, account, body, originalModel, reqStream, startTime)
@@ -115,10 +117,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if account.IsAnthropicProtocol() {
return s.forwardResponsesViaNativeAnthropic(ctx, c, account, body, reqModel)
}
- if account.IsAdaptiveAPIProtocol() &&
- (account.Platform != PlatformDeepseek || isOpenAIResponsesCompactPath(c)) {
- return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
- }
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
@@ -290,7 +288,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 && !compatMessagesBridge {
+ if instructionsEmpty && !compatMessagesBridge && !nativeDeepSeekResponses {
markPatchSet("instructions", defaultCodexSynthInstructions(reqModel))
}
@@ -463,7 +461,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
maxOutputTokens := gjson.GetBytes(body, "max_output_tokens")
if maxOutputTokens.Exists() {
switch account.Platform {
- case PlatformOpenAI:
+ case PlatformOpenAI, PlatformDeepseek:
// Preserve Responses-native output limits unless the selected upstream
// explicitly rejects the field in the bounded HTTP retry loop below.
case PlatformAnthropic:
@@ -1044,9 +1042,22 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
}
func shouldForwardOpenAIResponsesViaRawChatCompletions(account *Account) bool {
- return account != nil &&
- account.Type == AccountTypeAPIKey &&
- !openai_compat.ShouldUseResponsesAPI(account.Extra)
+ if account == nil || account.Type != AccountTypeAPIKey {
+ return false
+ }
+ if account.IsCNProvider() {
+ // CN 的显式协议配置优先于异步探针 Extra;adaptive 仅 DeepSeek 有原生
+ // Responses,Kimi/GLM 回退 Chat Completions。
+ switch account.GetAPIProtocol() {
+ case APIProtocolChatCompletions:
+ return true
+ case APIProtocolAdaptive:
+ return account.Platform != PlatformDeepseek
+ default:
+ return false
+ }
+ }
+ return !openai_compat.ShouldUseResponsesAPI(account.Extra)
}
func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token string, isStream bool, promptCacheKey string, isCodexCLI bool) (*http.Request, error) {
diff --git a/backend/internal/service/openai_gateway_messages.go b/backend/internal/service/openai_gateway_messages.go
index 99c74bedd2..f1ae729ad1 100644
--- a/backend/internal/service/openai_gateway_messages.go
+++ b/backend/internal/service/openai_gateway_messages.go
@@ -15,7 +15,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
@@ -45,11 +44,9 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
return s.forwardAnthropicViaNativeAnthropicEndpoint(ctx, c, account, body, defaultMappedModel)
}
- // 入口分流:APIKey 账号 + 上游不支持 Responses API → 走 CC 直转(与
- // ForwardAsChatCompletions 对称)。缺少此分流时,/v1/messages 入站请求
- // 会被无条件转为 Responses 格式发往上游 /v1/responses,导致只支持
- // /v1/chat/completions 的第三方 OpenAI 兼容上游全部 400。
- if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
+ // 固定 chat_completions 的 CN 账号,以及不支持 Responses 的其他 APIKey
+ // 账号,均将 Messages 转为 CC;固定 responses 的 CN 账号不受探针旧值覆盖。
+ if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
return s.forwardAnthropicViaRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
diff --git a/backend/internal/service/upstream_billing_probe.go b/backend/internal/service/upstream_billing_probe.go
index c6a62bab09..7255464c03 100644
--- a/backend/internal/service/upstream_billing_probe.go
+++ b/backend/internal/service/upstream_billing_probe.go
@@ -597,6 +597,9 @@ func (s *UpstreamBillingProbeService) probeLoadedAccount(ctx context.Context, ac
return s.persistProbeFailure(ctx, account, intervalMinutes, now, 0, "missing_api_key", 0)
}
baseURL := account.GetCredential("base_url")
+ if account.IsCNProvider() && account.IsAdaptiveAPIProtocol() {
+ baseURL = account.GetCNProtocolBaseURL(APIProtocolChatCompletions)
+ }
if account.Platform == PlatformOpenAI {
if baseURL == "" {
// 保持官方语义:OpenAI 账号无自定义 base 时探官方域(404 → unsupported)。
diff --git a/backend/internal/service/upstream_billing_probe_test.go b/backend/internal/service/upstream_billing_probe_test.go
index e032e9aa16..9a5f774f4f 100644
--- a/backend/internal/service/upstream_billing_probe_test.go
+++ b/backend/internal/service/upstream_billing_probe_test.go
@@ -340,6 +340,38 @@ func TestUpstreamBillingProbeSuccessPersistsSanitizedSnapshot(t *testing.T) {
require.Equal(t, snapshot.Status, persisted.Status)
}
+func TestUpstreamBillingProbeAdaptiveCNUsesChatProtocolBaseURL(t *testing.T) {
+ account := &Account{
+ ID: 18,
+ Platform: PlatformKimi,
+ Type: AccountTypeAPIKey,
+ Status: StatusActive,
+ Concurrency: 1,
+ Credentials: map[string]any{
+ "api_key": "sk-sensitive",
+ "api_protocol": APIProtocolAdaptive,
+ "base_url": "https://legacy-relay.example/v1",
+ "api_base_urls": map[string]any{
+ APIProtocolChatCompletions: "https://chat-relay.example/v1",
+ },
+ },
+ Extra: map[string]any{UpstreamBillingProbeEnabledExtraKey: true},
+ }
+ repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{account.ID: account}}
+ upstream := &httpUpstreamRecorder{resp: &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ Body: upstreamBillingProbeValidBody(),
+ }}
+ svc := newUpstreamBillingProbeTestService(repo, upstream, &upstreamBillingProbeSettingRepo{})
+
+ snapshot, err := svc.ProbeAccount(context.Background(), account.ID)
+
+ require.NoError(t, err)
+ require.Equal(t, UpstreamBillingProbeStatusOK, snapshot.Status)
+ require.Equal(t, "https://chat-relay.example/v1/sub2api/billing", upstream.lastReq.URL.String())
+}
+
func TestUpstreamBillingProbeSyncsResolvedRateForAllAPIKeyPlatforms(t *testing.T) {
for _, platform := range []string{
PlatformOpenAI,
diff --git a/frontend/src/components/account/CreateAccountModal.vue b/frontend/src/components/account/CreateAccountModal.vue
index 546c5f0066..2c9621f17f 100644
--- a/frontend/src/components/account/CreateAccountModal.vue
+++ b/frontend/src/components/account/CreateAccountModal.vue
@@ -1281,7 +1281,12 @@
-
+
@@ -4064,10 +4069,13 @@ function onCnPresetSelect(preset: { mode: CnAccountMode; protocol: CnApiProtocol
const syncPreviewCredentials = computed(() => {
if (!apiKeyValue.value) return undefined
+ const baseUrl = isCNPlatform.value && apiProtocol.value === 'adaptive'
+ ? adaptiveBaseUrls.value.chat_completions.trim() || apiKeyBaseUrl.value.trim()
+ : apiKeyBaseUrl.value.trim()
return {
platform: form.platform,
type: form.type,
- base_url: apiKeyBaseUrl.value || undefined,
+ base_url: baseUrl || undefined,
api_key: apiKeyValue.value
}
})
diff --git a/frontend/src/components/account/EditAccountModal.vue b/frontend/src/components/account/EditAccountModal.vue
index 0f433cd53a..d1cb92df59 100644
--- a/frontend/src/components/account/EditAccountModal.vue
+++ b/frontend/src/components/account/EditAccountModal.vue
@@ -3831,27 +3831,48 @@ const syncFormFromAccount = (newAccount: Account | null) => {
editAccountMode.value = credentials.account_mode === 'coding' ? 'coding' : 'payg'
const storedProtocol = credentials.api_protocol
editApiProtocol.value =
- storedProtocol === 'adaptive' || storedProtocol === 'anthropic' || storedProtocol === 'responses'
+ storedProtocol === 'adaptive' ||
+ storedProtocol === 'chat_completions' ||
+ storedProtocol === 'anthropic' ||
+ storedProtocol === 'responses'
? storedProtocol
- : 'adaptive'
+ : 'chat_completions'
if (newAccount.platform !== 'deepseek' && editApiProtocol.value === 'responses') {
editApiProtocol.value = 'chat_completions'
}
const adaptiveDefaults = defaultCNAdaptiveBaseUrls(newAccount.platform, editAccountMode.value)
const storedBaseUrls = (credentials.api_base_urls as Record | undefined) || {}
- editAdaptiveBaseUrls.value = {
- chat_completions: typeof storedBaseUrls.chat_completions === 'string'
- ? storedBaseUrls.chat_completions
- : adaptiveDefaults.chat_completions,
- anthropic: typeof storedBaseUrls.anthropic === 'string'
- ? storedBaseUrls.anthropic
- : adaptiveDefaults.anthropic,
- responses: typeof storedBaseUrls.responses === 'string'
- ? storedBaseUrls.responses
- : adaptiveDefaults.responses
+ const legacyBaseUrl = typeof credentials.base_url === 'string' ? credentials.base_url.trim() : ''
+ const storedChatBaseUrl = typeof storedBaseUrls.chat_completions === 'string'
+ ? storedBaseUrls.chat_completions.trim()
+ : ''
+ const storedAnthropicBaseUrl = typeof storedBaseUrls.anthropic === 'string'
+ ? storedBaseUrls.anthropic.trim()
+ : ''
+ const storedResponsesBaseUrl = typeof storedBaseUrls.responses === 'string'
+ ? storedBaseUrls.responses.trim()
+ : ''
+ const nextAdaptiveBaseUrls: Record = {
+ chat_completions: storedChatBaseUrl || adaptiveDefaults.chat_completions,
+ anthropic: storedAnthropicBaseUrl || adaptiveDefaults.anthropic,
+ responses: storedResponsesBaseUrl || adaptiveDefaults.responses
}
+ const legacyProtocol: CnNativeApiProtocol = editApiProtocol.value === 'anthropic'
+ ? 'anthropic'
+ : editApiProtocol.value === 'responses'
+ ? 'responses'
+ : 'chat_completions'
+ const storedLegacyBaseUrl = legacyProtocol === 'anthropic'
+ ? storedAnthropicBaseUrl
+ : legacyProtocol === 'responses'
+ ? storedResponsesBaseUrl
+ : storedChatBaseUrl
+ if (legacyBaseUrl && !storedLegacyBaseUrl) {
+ nextAdaptiveBaseUrls[legacyProtocol] = legacyBaseUrl
+ }
+ editAdaptiveBaseUrls.value = nextAdaptiveBaseUrls
}
- const platformDefaultUrl =
+ const platformDefaultUrl =
newAccount.platform === 'openai'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
@@ -3863,9 +3884,9 @@ const syncFormFromAccount = (newAccount: Account | null) => {
newAccount.platform === 'deepseek'
? defaultCNBaseUrl(newAccount.platform, editAccountMode.value, editApiProtocol.value)
: 'https://api.anthropic.com'
- editBaseUrl.value = isCNApiKeyAccount.value && editApiProtocol.value === 'adaptive'
- ? editAdaptiveBaseUrls.value.chat_completions
- : (credentials.base_url as string) || platformDefaultUrl
+ editBaseUrl.value = isCNApiKeyAccount.value && editApiProtocol.value === 'adaptive'
+ ? editAdaptiveBaseUrls.value.chat_completions
+ : (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
loadModelRestrictionFromMapping(credentials.model_mapping as Record | undefined)
diff --git a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
index 2681958d9a..e5a0c8c9c1 100644
--- a/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/CreateAccountModal.spec.ts
@@ -110,6 +110,20 @@ const GroupSelectorStub = defineComponent({
`,
})
+const ModelWhitelistSelectorStub = defineComponent({
+ name: 'ModelWhitelistSelector',
+ props: {
+ modelValue: {
+ type: Array,
+ default: () => [],
+ },
+ platform: String,
+ syncCredentials: Object,
+ },
+ emits: ['update:modelValue'],
+ template: '',
+})
+
function mountModal(groups: any[] = []) {
return mount(CreateAccountModal, {
props: { show: true, proxies: [], groups },
@@ -124,7 +138,7 @@ function mountModal(groups: any[] = []) {
ProxySelector: true,
ProxyAdBanner: true,
GroupSelector: GroupSelectorStub,
- ModelWhitelistSelector: true,
+ ModelWhitelistSelector: ModelWhitelistSelectorStub,
QuotaLimitCard: true,
},
},
@@ -272,7 +286,6 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
it('submits adaptive Kimi protocol endpoints', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'Kimi')
- await selectButtonByText(wrapper, 'admin.accounts.cnProviders.apiProtocol.adaptive')
await wrapper.get('form#create-account-form input[type="text"]').setValue('Kimi adaptive')
await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-kimi')
@@ -291,6 +304,22 @@ describe('CreateAccountModal OpenAI long-context billing', () => {
})
})
+ it('uses the edited adaptive Chat endpoint when previewing upstream models', async () => {
+ const wrapper = mountModal()
+ await selectButtonByText(wrapper, 'Kimi')
+ await wrapper
+ .get('[data-testid="cn-adaptive-base-url-chat_completions"]')
+ .setValue('https://relay.example.com/v1')
+ await wrapper.get('form#create-account-form input[type="password"]').setValue('sk-relay')
+
+ expect(wrapper.getComponent(ModelWhitelistSelectorStub).props('syncCredentials')).toMatchObject({
+ platform: 'kimi',
+ type: 'apikey',
+ base_url: 'https://relay.example.com/v1',
+ api_key: 'sk-relay'
+ })
+ })
+
it('exposes Agent Identity in the OpenAI authorization methods', async () => {
const wrapper = mountModal()
await selectButtonByText(wrapper, 'OpenAI')
diff --git a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
index 2e82bb4542..acd0436f6a 100644
--- a/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
+++ b/frontend/src/components/account/__tests__/EditAccountModal.spec.ts
@@ -374,6 +374,147 @@ describe('EditAccountModal', () => {
})
})
+ it.each([
+ ['explicit Chat Completions', 'chat_completions'],
+ ['legacy missing protocol', undefined]
+ ])('preserves a custom CN relay for %s accounts', async (_name, storedProtocol) => {
+ const account = buildAccount()
+ account.platform = 'zhipu'
+ account.credentials = {
+ api_key: 'sk-glm',
+ account_mode: 'payg',
+ base_url: 'https://relay.example.com/v1'
+ }
+ if (storedProtocol) {
+ account.credentials.api_protocol = storedProtocol
+ }
+ 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)
+ const submittedCredentials = updateAccountMock.mock.calls[0]?.[1]?.credentials
+ expect(submittedCredentials).toMatchObject({
+ account_mode: 'payg',
+ api_protocol: 'chat_completions',
+ base_url: 'https://relay.example.com/v1'
+ })
+ expect(submittedCredentials).not.toHaveProperty('api_base_urls')
+ })
+
+ it('uses the legacy base_url when adaptive endpoints are missing', async () => {
+ const account = buildAccount()
+ account.platform = 'zhipu'
+ account.credentials = {
+ api_key: 'sk-glm',
+ account_mode: 'payg',
+ api_protocol: 'adaptive',
+ base_url: 'https://relay.example.com/v1',
+ api_base_urls: {
+ chat_completions: ' '
+ }
+ }
+ 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({
+ api_protocol: 'adaptive',
+ base_url: 'https://relay.example.com/v1',
+ api_base_urls: {
+ chat_completions: 'https://relay.example.com/v1',
+ anthropic: 'https://open.bigmodel.cn/api/anthropic'
+ }
+ })
+ })
+
+ it('carries a fixed Chat relay into Adaptive when the user switches protocols', async () => {
+ const account = buildAccount()
+ account.platform = 'zhipu'
+ account.credentials = {
+ api_key: 'sk-glm',
+ account_mode: 'payg',
+ api_protocol: 'chat_completions',
+ base_url: 'https://relay.example.com/v1'
+ }
+ updateAccountMock.mockReset().mockResolvedValue(account)
+ checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
+
+ const wrapper = mountModal(account)
+ const adaptiveButton = wrapper
+ .findAll('button')
+ .find(button => button.text().includes('admin.accounts.cnProviders.apiProtocol.adaptive'))
+ expect(adaptiveButton).toBeDefined()
+ await adaptiveButton!.trigger('click')
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+
+ expect(updateAccountMock).toHaveBeenCalledTimes(1)
+ expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({
+ api_protocol: 'adaptive',
+ base_url: 'https://relay.example.com/v1',
+ api_base_urls: {
+ chat_completions: 'https://relay.example.com/v1'
+ }
+ })
+ })
+
+ it.each([
+ {
+ name: 'Anthropic',
+ platform: 'zhipu',
+ protocol: 'anthropic',
+ baseUrl: 'https://relay.example.com/anthropic',
+ expectedBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
+ expectedProtocolUrls: {
+ chat_completions: 'https://open.bigmodel.cn/api/paas/v4',
+ anthropic: 'https://relay.example.com/anthropic'
+ }
+ },
+ {
+ name: 'Responses',
+ platform: 'deepseek',
+ protocol: 'responses',
+ baseUrl: 'https://relay.example.com/responses',
+ expectedBaseUrl: 'https://api.deepseek.com',
+ expectedProtocolUrls: {
+ chat_completions: 'https://api.deepseek.com',
+ anthropic: 'https://api.deepseek.com/anthropic',
+ responses: 'https://relay.example.com/responses'
+ }
+ }
+ ])('keeps a fixed $name relay in its protocol slot when switching to Adaptive', async (testCase) => {
+ const account = buildAccount()
+ account.platform = testCase.platform
+ account.credentials = {
+ api_key: 'sk-cn',
+ account_mode: 'payg',
+ api_protocol: testCase.protocol,
+ base_url: testCase.baseUrl
+ }
+ updateAccountMock.mockReset().mockResolvedValue(account)
+ checkMixedChannelRiskMock.mockReset().mockResolvedValue({ has_risk: false })
+
+ const wrapper = mountModal(account)
+ const adaptiveButton = wrapper
+ .findAll('button')
+ .find(button => button.text().includes('admin.accounts.cnProviders.apiProtocol.adaptive'))
+ expect(adaptiveButton).toBeDefined()
+ await adaptiveButton!.trigger('click')
+ await wrapper.get('form#edit-account-form').trigger('submit.prevent')
+
+ expect(updateAccountMock).toHaveBeenCalledTimes(1)
+ expect(updateAccountMock.mock.calls[0]?.[1]?.credentials).toMatchObject({
+ api_protocol: 'adaptive',
+ base_url: testCase.expectedBaseUrl,
+ api_base_urls: testCase.expectedProtocolUrls
+ })
+ })
+
it('preserves model mappings when editing the whitelist', async () => {
const account = buildAccount()
account.credentials.model_mapping = {