Merge remote-tracking branch 'upstream/main' into fix/openai-responses-compatibility

# Conflicts:
#	backend/internal/handler/ops_error_logger.go
This commit is contained in:
IanShaw
2026-08-20 00:31:12 -07:00
36 changed files with 1582 additions and 107 deletions
-12
View File
@@ -117,18 +117,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
-12
View File
@@ -120,18 +120,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
-12
View File
@@ -119,18 +119,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.178
0.1.179
+1
View File
@@ -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
+5 -1
View File
@@ -131,7 +131,11 @@ func classifyNoAccountErrorFromGin(
if c != nil && c.Request != nil {
ctx = c.Request.Context()
}
return classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
classification := classifyNoAccountError(ctx, diag, apiKey, routingModel, displayModel, platform)
if classification.ModelNotFound {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
}
return classification
}
func classifyOpenAICompatibleNoAccountErrorFromGin(
@@ -128,6 +128,8 @@ func TestClassifyNoAccountError_ModelNotSupported_Returns404(t *testing.T) {
require.Equal(t, service.PlatformOpenAI, fd.calls[0].Platform)
require.NotNil(t, fd.calls[0].GroupID)
require.Equal(t, int64(42), *fd.calls[0].GroupID)
require.True(t, service.HasOpsClientBusinessLimited(c))
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
}
func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.T) {
@@ -149,6 +151,8 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.
require.True(t, cls.ModelNotFound)
require.Len(t, fd.calls, 1)
require.Equal(t, service.PlatformGrok, fd.calls[0].Platform)
require.True(t, service.HasOpsClientBusinessLimited(c))
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
logErr := openAICompatibleSelectionErrorForLog(
fmt.Errorf("no available OpenAI accounts supporting model: grok-4.5"),
@@ -157,6 +161,18 @@ func TestClassifyOpenAICompatibleNoAccountError_GrokUsesGrokPlatform(t *testing.
require.EqualError(t, logErr, "no available Grok accounts supporting model: grok-4.5")
}
func TestClassifyNoAccountError_PureClassifierDoesNotMarkGinContext(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: ptrInt64(7)}
cls := classifyNoAccountError(c.Request.Context(), fd, apiKey, "gpt-5", "gpt-5", service.PlatformOpenAI)
require.True(t, cls.ModelNotFound)
require.False(t, service.HasOpsClientBusinessLimited(c))
require.Empty(t, service.OpsClientBusinessLimitedReason(c))
}
func TestClassifyNoAccountError_HasModelSupport_KeepsRoutingMessageGenerationToCaller(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: true}}
@@ -215,4 +231,6 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
require.Equal(t, http.StatusNotFound, cls.Status, "even with a nil gin context the classifier must still run and yield a coherent response")
require.True(t, cls.ModelNotFound)
require.False(t, service.HasOpsClientBusinessLimited(nil))
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
}
+23 -5
View File
@@ -1186,6 +1186,7 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
entry.UpstreamStatusCode = &finalStatus
}
}
suppressOpsUpstreamAttributionForLocalModelConfiguration(c, entry)
if apiKey != nil {
entry.APIKeyID = &apiKey.ID
@@ -1531,6 +1532,19 @@ func applyOpsUpstreamFieldsFromContext(c *gin.Context, entry *service.OpsInsertE
}
}
func suppressOpsUpstreamAttributionForLocalModelConfiguration(c *gin.Context, entry *service.OpsInsertErrorLogInput) {
if entry == nil || !service.HasOpsClientBusinessLimited(c) || service.OpsClientBusinessLimitedReason(c) != service.OpsClientBusinessLimitedReasonLocalModelConfiguration {
return
}
entry.AccountID = nil
entry.UpstreamEndpoint = ""
entry.UpstreamModel = ""
entry.UpstreamStatusCode = nil
entry.UpstreamErrorMessage = nil
entry.UpstreamErrorDetail = nil
entry.UpstreamErrors = nil
}
func getContextLatencyMs(c *gin.Context, key string) *int64 {
if c == nil || strings.TrimSpace(key) == "" {
return nil
@@ -1990,23 +2004,27 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i
phase = classifyOpsPhase(errType, message, code)
routingCapacityLimited := isOpsRoutingCapacityLimited(c)
clientBusinessLimited := service.HasOpsClientBusinessLimited(c)
localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration
upstreamError := hasOpsUpstreamErrorContext(c)
accountAuthFailure := hasOpsAccountAuthFailure(c)
if accountAuthFailure && !routingCapacityLimited {
if localModelConfiguration {
phase = "routing"
} else if accountAuthFailure && !routingCapacityLimited {
phase = "account_auth"
} else if upstreamError && !routingCapacityLimited {
phase = "upstream"
}
if clientBusinessLimited && !upstreamError && !routingCapacityLimited {
if clientBusinessLimited && !upstreamError && !routingCapacityLimited && !localModelConfiguration {
phase = "auth"
}
if routingCapacityLimited {
phase = "routing"
}
msg := strings.ToLower(message)
localClientAuthError := !upstreamError && phase == "auth" && isOpsClientAuthError(code, msg)
localBusinessLimited := !upstreamError && classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError)
isBusinessLimited = routingCapacityLimited || (clientBusinessLimited && !upstreamError) || localBusinessLimited
effectiveUpstreamError := upstreamError && !localModelConfiguration
localClientAuthError := !effectiveUpstreamError && phase == "auth" && isOpsClientAuthError(code, msg)
localBusinessLimited := !effectiveUpstreamError && classifyOpsIsBusinessLimited(errType, phase, code, status, message, localClientAuthError)
isBusinessLimited = localModelConfiguration || routingCapacityLimited || (clientBusinessLimited && !effectiveUpstreamError) || localBusinessLimited
errorOwner = classifyOpsErrorOwner(phase, message)
errorSource = classifyOpsErrorSource(phase, message)
return phase, isBusinessLimited, errorOwner, errorSource
@@ -754,6 +754,107 @@ func TestClassifyOpsRoutingCapacityMarkerExcludesMaskedSelectionFailureFromSLA(t
require.Equal(t, "gateway", errorSource)
}
func TestClassifyOpsLocalModelConfigurationRejection(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
c,
"model_not_found",
"Model \"gpt-missing\" is not supported by any configured account in this group",
"",
http.StatusNotFound,
)
require.Equal(t, "routing", phase)
require.True(t, isBusinessLimited)
require.Equal(t, "platform", errorOwner)
require.Equal(t, "gateway", errorSource)
}
func TestClassifyOpsLocalModelConfigurationOverridesStaleUpstreamMarkers(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
Stage: string(service.GatewayFailureStageAccountAuth),
UpstreamStatusCode: http.StatusUnauthorized,
}})
phase, limited, owner, source := classifyOpsErrorLog(c, "model_not_found", "unsupported configured model", "", http.StatusNotFound)
require.Equal(t, "routing", phase)
require.True(t, limited)
require.Equal(t, "platform", owner)
require.Equal(t, "gateway", source)
}
func TestClassifyOpsLocalModelConfigurationRequiresMarkerAndReason(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Set(service.OpsClientBusinessLimitedReasonKey, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusBadGateway)
phase, limited, owner, source := classifyOpsErrorLog(c, "upstream_error", "provider failed", "", http.StatusBadGateway)
require.Equal(t, "upstream", phase)
require.False(t, limited)
require.Equal(t, "provider", owner)
require.Equal(t, "upstream_http", source)
}
func TestOpsErrorLoggerMiddleware_LocalModelConfigurationFields(t *testing.T) {
setupOpsErrorLogTestQueue(t, 1)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/chat/completions", func(c *gin.Context) {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
c.Set(opsAccountIDKey, int64(99))
c.Set(opsUpstreamModelKey, "stale-upstream-model")
setActualUpstreamEndpoint(c, "/v1/chat/completions")
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusUnauthorized)
c.Set(service.OpsUpstreamErrorMessageKey, "stale upstream error")
c.Set(service.OpsUpstreamErrorDetailKey, "stale upstream detail")
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
Stage: string(service.GatewayFailureStageAccountAuth),
UpstreamStatusCode: http.StatusUnauthorized,
Message: "stale auth failure",
}})
c.JSON(http.StatusNotFound, gin.H{
"error": gin.H{
"type": "model_not_found",
"message": "Model \"gpt-missing\" is not supported by any configured account in this group",
},
})
})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
router.ServeHTTP(w, req)
require.Equal(t, http.StatusNotFound, w.Code)
require.JSONEq(t, `{"error":{"type":"model_not_found","message":"Model \"gpt-missing\" is not supported by any configured account in this group"}}`, w.Body.String())
job := <-opsErrorLogQueue
require.Equal(t, http.StatusNotFound, job.entry.StatusCode)
require.Equal(t, "routing", job.entry.ErrorPhase)
require.True(t, job.entry.IsBusinessLimited)
require.Equal(t, "platform", job.entry.ErrorOwner)
require.Equal(t, "gateway", job.entry.ErrorSource)
require.Nil(t, job.entry.AccountID)
require.Nil(t, job.entry.UpstreamStatusCode)
require.Nil(t, job.entry.UpstreamErrors)
require.Nil(t, job.entry.UpstreamErrorMessage)
require.Nil(t, job.entry.UpstreamErrorDetail)
require.Empty(t, job.entry.UpstreamModel)
require.Empty(t, job.entry.UpstreamEndpoint)
}
func TestClassifyOpsAuthClientErrorsExcludedFromSLA(t *testing.T) {
tests := []struct {
name string
+73 -1
View File
@@ -1323,6 +1323,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
@@ -1374,6 +1381,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:
@@ -1386,6 +1395,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 {
@@ -1396,9 +1465,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
@@ -285,8 +285,13 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
}
// Route to platform-specific test method
if account.IsCNProvider() && account.GetAPIProtocol() == APIProtocolChatCompletions {
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() {
@@ -3063,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)
@@ -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))
}
@@ -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, `<html>not an Anthropic stream</html>`),
)
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"`))
}
@@ -0,0 +1,227 @@
//go:build unit
package service
import (
"bytes"
"context"
"errors"
"net/http"
"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"
)
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
}
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}`)
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 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}`)
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","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{
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())
require.Equal(t, int64(32), gjson.GetBytes(upstream.lastBody, "max_output_tokens").Int())
require.False(t, gjson.GetBytes(upstream.lastBody, "instructions").Exists())
}
func TestFixedCNChatProtocolOverridesStaleResponsesMode(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"] = APIProtocolChatCompletions
account.Credentials["base_url"] = "http://chat.example"
account.Extra = map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
}
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())
})
}
}
+70 -8
View File
@@ -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)))
@@ -64,6 +64,7 @@ const (
APIProtocolChatCompletions = domain.APIProtocolChatCompletions
APIProtocolAnthropic = domain.APIProtocolAnthropic
APIProtocolResponses = domain.APIProtocolResponses
APIProtocolAdaptive = domain.APIProtocolAdaptive
)
// 国产 OpenAI 兼容供应商各模式的默认 base_url。
@@ -125,9 +125,10 @@ 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 路径。
if account.GetAPIProtocol() == APIProtocolResponses {
// 例外: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{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
openai_compat.ExtraKeyResponsesSupported: true,
@@ -135,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
@@ -49,6 +49,46 @@ func TestProbeOpenAIAPIKeyResponsesSupportUsesCodexProbeHeaders(t *testing.T) {
require.Equal(t, true, updates[openai_compat.ExtraKeyResponsesSupported])
}
func TestProbeOpenAIAPIKeyResponsesSupportCNProviders(t *testing.T) {
tests := []struct {
name string
id int64
platform string
protocol string
wantSupport bool
wantMode string
}{
{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 {
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": tc.protocol},
Extra: map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceResponses),
},
}
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])
require.Equal(t, tc.wantMode, 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"}]}`)
@@ -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,
@@ -89,6 +89,39 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}
// 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() {
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 端点,
// CC 入站请求经 CC→Responses→Anthropic 转换链直通该端点。必须先于
// ShouldUseResponsesAPI 分流:该类账号经 probe 落标
@@ -97,9 +130,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)
}
@@ -134,11 +167,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
@@ -130,6 +130,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)
@@ -329,7 +331,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))
}
@@ -509,7 +511,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:
@@ -1167,9 +1169,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) {
@@ -1188,6 +1203,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 {
@@ -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"
@@ -42,15 +41,13 @@ 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)
}
// 入口分流: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)
}
@@ -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)
@@ -48,13 +48,14 @@ const (
// ensureForwardErrorResponse 检查此 key,为 true 时跳过兜底写入,避免在已完成的 JSON 后追加 SSE。
ResponseCommittedKey = "response_committed"
OpsClientBusinessLimitedKey = "ops_client_business_limited"
OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason"
OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction"
OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable = "api_key_group_unavailable"
OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned = "api_key_group_unassigned"
OpsClientBusinessLimitedReasonLocalFeatureGate = "local_feature_gate"
OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied"
OpsClientBusinessLimitedKey = "ops_client_business_limited"
OpsClientBusinessLimitedReasonKey = "ops_client_business_limited_reason"
OpsClientBusinessLimitedReasonIPRestriction = "api_key_ip_restriction"
OpsClientBusinessLimitedReasonAPIKeyGroupUnavailable = "api_key_group_unavailable"
OpsClientBusinessLimitedReasonAPIKeyGroupUnassigned = "api_key_group_unassigned"
OpsClientBusinessLimitedReasonLocalFeatureGate = "local_feature_gate"
OpsClientBusinessLimitedReasonLocalPolicyDenied = "local_policy_denied"
OpsClientBusinessLimitedReasonLocalModelConfiguration = "local_model_configuration"
)
func MarkResponseCommitted(c *gin.Context) { c.Set(ResponseCommittedKey, true) }
@@ -117,6 +118,18 @@ func HasOpsClientBusinessLimited(c *gin.Context) bool {
return marked
}
func OpsClientBusinessLimitedReason(c *gin.Context) string {
if c == nil {
return ""
}
v, ok := c.Get(OpsClientBusinessLimitedReasonKey)
if !ok {
return ""
}
reason, _ := v.(string)
return strings.TrimSpace(reason)
}
// OpsStreamError 描述网关在「响应状态已固化为 200」之后(keepalive ping 或部分数据
// 已 flush)就地以 SSE error 帧形式返回的错误。由于 HTTP 状态码停留在 200,
// 而 ops_error_logger 以 status>=400 为采集触发条件,这类流内失败
@@ -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)。
@@ -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,
@@ -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
}>()
@@ -537,7 +537,7 @@
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon :name="opt.value === 'anthropic' ? 'sparkles' : opt.value === 'responses' ? 'terminal' : 'chat'" size="sm" />
<Icon :name="opt.value === 'adaptive' ? 'swap' : opt.value === 'anthropic' ? 'sparkles' : opt.value === 'responses' ? 'terminal' : 'chat'" size="sm" />
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t(`admin.accounts.cnProviders.apiProtocol.${opt.labelKey}`) }}</span>
@@ -1250,7 +1250,7 @@
<!-- API Key input (only for apikey type, excluding Antigravity which has its own fields) -->
<div v-if="form.type === 'apikey' && form.platform !== 'antigravity'" class="space-y-4">
<div>
<div v-if="!isCNPlatform || apiProtocol !== 'adaptive'">
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input
v-model="apiKeyBaseUrl"
@@ -1274,6 +1274,25 @@
@select="onCnPresetSelect"
/>
</div>
<div v-else>
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.endpoints') }}</label>
<div class="mt-2 space-y-3">
<div v-for="item in cnAdaptiveProtocolOptions" :key="item.value">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t(`admin.accounts.cnProviders.apiProtocol.${item.labelKey}`) }}
</label>
<input
v-model="adaptiveBaseUrls[item.value]"
type="text"
class="input"
:data-testid="`cn-adaptive-base-url-${item.value}`"
/>
</div>
</div>
<p v-if="form.platform !== 'deepseek'" class="input-hint">
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
</p>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.apiKeyRequired') }}</label>
<input
@@ -3746,11 +3765,13 @@ import {
applyAntigravityProjectID,
applyHeaderOverride,
applyInterceptWarmup,
defaultCNAdaptiveBaseUrls,
defaultCNBaseUrl,
isHeaderOverrideCapable,
validateHeaderOverrideRows,
type CnAccountMode,
type CnApiProtocol,
type CnNativeApiProtocol,
type HeaderOverrideRow
} from '@/components/account/credentialsBuilder'
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
@@ -3932,7 +3953,12 @@ const upstreamBillingAutoProbeEnabled = ref(true)
const accountMode = ref<CnAccountMode>('payg')
// API 协议决定转发端点与格式:cc=现有转换链,anthropic=原生直通(Claude Code),
// responses=deepseek 原生 Responses 端点(Codex)。与账号类型正交。
const apiProtocol = ref<CnApiProtocol>('chat_completions')
const apiProtocol = ref<CnApiProtocol>('adaptive')
const adaptiveBaseUrls = ref<Record<CnNativeApiProtocol, string>>({
chat_completions: '',
anthropic: '',
responses: ''
})
const isCNPlatform = computed(
() => form.platform === 'kimi' || form.platform === 'zhipu' || form.platform === 'deepseek'
)
@@ -3947,6 +3973,7 @@ const cnPresetPlatform = computed<'kimi' | 'zhipu' | 'deepseek'>(() => {
// 当前平台可选的协议档(responses 仅 deepseek)。
const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: string }>>(() => {
const opts: Array<{ value: CnApiProtocol; labelKey: string }> = [
{ value: 'adaptive', labelKey: 'adaptive' },
{ value: 'chat_completions', labelKey: 'chatCompletions' },
{ value: 'anthropic', labelKey: 'anthropic' }
]
@@ -3955,6 +3982,18 @@ const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: strin
}
return opts
})
const cnAdaptiveProtocolOptions = computed<Array<{ value: CnNativeApiProtocol; labelKey: string }>>(() => {
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 +4020,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、账号类型与协议。
@@ -4010,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
}
})
@@ -4956,7 +5018,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 +5488,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<string, string> = {}
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
}
}
@@ -28,7 +28,7 @@
<!-- API Key fields (only for apikey type) -->
<div v-if="account.type === 'apikey'" class="space-y-4">
<div>
<div v-if="!isCNApiKeyAccount || editApiProtocol !== 'adaptive'">
<label class="input-label">{{ t('admin.accounts.baseUrl') }}</label>
<input
v-model="editBaseUrl"
@@ -62,6 +62,20 @@
@select="onCnPresetSelect"
/>
</div>
<div v-else>
<label class="input-label">{{ t('admin.accounts.cnProviders.apiProtocol.endpoints') }}</label>
<div class="mt-2 space-y-3">
<div v-for="item in editAdaptiveProtocolOptions" :key="item.value">
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
{{ t(`admin.accounts.cnProviders.apiProtocol.${item.labelKey}`) }}
</label>
<input v-model="editAdaptiveBaseUrls[item.value]" type="text" class="input" />
</div>
</div>
<p v-if="account.platform !== 'deepseek'" class="input-hint">
{{ t('admin.accounts.cnProviders.apiProtocol.responsesFallbackDesc') }}
</p>
</div>
<!-- Account Mode Selection (CN providers) -->
<div v-if="isCNApiKeyAccount">
<label class="input-label">{{ t('admin.accounts.cnProviders.accountMode.title') }}</label>
@@ -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<CnApiProtocol>('chat_completions')
const editApiProtocol = ref<CnApiProtocol>('adaptive')
const editAccountMode = ref<CnAccountMode>('payg')
const editAdaptiveBaseUrls = ref<Record<CnNativeApiProtocol, string>>({
chat_completions: '',
anthropic: '',
responses: ''
})
// 回填窗口标志:syncFormFromAccount 会同步改写 editAccountMode / editApiProtocol,
// 而 watcher(pre-flush)在同步代码执行完之后才触发——若不抑制,会把刚恢复的
// 存储版 base_url(可能是用户自定义/中转地址)覆盖为官方预设并在下次保存时持久化。
@@ -2930,6 +2951,7 @@ const cnAccountModeOptions = computed<Array<{ value: CnAccountMode; labelKey: 'p
)
const cnProtocolOptions = computed<Array<{ value: CnApiProtocol; labelKey: string }>>(() => {
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<Array<{ value: CnApiProtocol; labelKey: strin
}
return opts
})
watch(editApiProtocol, (protocol) => {
const editAdaptiveProtocolOptions = computed<Array<{ value: CnNativeApiProtocol; labelKey: string }>>(() => {
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,10 +3831,46 @@ 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 === 'chat_completions' ||
storedProtocol === 'anthropic' ||
storedProtocol === 'responses'
? storedProtocol
: '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<string, unknown> | undefined) || {}
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<CnNativeApiProtocol, string> = {
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 =
newAccount.platform === 'openai'
@@ -3791,7 +3884,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<string, unknown> | undefined)
@@ -4463,6 +4558,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<string, string> = {}
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
@@ -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: '<div data-testid="model-whitelist-selector" />',
})
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,
},
},
@@ -269,6 +283,43 @@ 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 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('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')
@@ -343,6 +343,178 @@ 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.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 = {
@@ -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'
})
})
})
@@ -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<CnApiProtocol, 'adaptive'>
export interface CnBaseUrlPreset {
mode: CnAccountMode
@@ -321,6 +322,18 @@ export function defaultCNBaseUrl(
}
}
/** 返回自适应模式下需要配置的原生协议及其默认端点。 */
export function defaultCNAdaptiveBaseUrls(
platform: 'kimi' | 'zhipu' | 'deepseek',
mode: CnAccountMode
): Record<CnNativeApiProtocol, string> {
return {
chat_completions: defaultCNBaseUrl(platform, mode, 'chat_completions'),
anthropic: defaultCNBaseUrl(platform, mode, 'anthropic'),
responses: platform === 'deepseek' ? defaultCNBaseUrl(platform, mode, 'responses') : ''
}
}
// ===== 国产供应商用量单元格可见性(单一事实源) =====
// CNProviderQuotaCell / CNProviderBalanceCell 与 AccountUsageCell 的占位符判定
// 共用,避免多处复制条件后一处改另一处漏改。
@@ -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',
@@ -321,6 +321,10 @@ export default {
},
apiProtocol: {
title: 'API 协议',
adaptive: '自适应',
adaptiveDesc: '按入站协议优先使用供应商原生端点,仅在没有对应端点时转换。',
endpoints: '协议端点',
responsesFallbackDesc: '该供应商没有原生 Responses 端点,Responses 请求将转换为 Chat Completions。',
chatCompletions: 'Chat Completions',
chatCompletionsDesc: '标准 OpenAI 兼容端点,其他格式请求将被转换。',
anthropic: 'Anthropic',