mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 00:07:33 +00:00
Merge remote-tracking branch 'upstream/main' into fix/openai-responses-compatibility
# Conflicts: # backend/internal/handler/ops_error_logger.go
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user