fix(openai): bypass responses for unknown third-party API keys

This commit is contained in:
Your Name
2026-05-21 12:20:41 +08:00
parent 70ca4eb72c
commit 0d9df842e4
6 changed files with 239 additions and 33 deletions

View File

@@ -10,7 +10,6 @@ import (
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -291,13 +290,12 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
}
// resolveRawCCUpstreamEndpoint returns the actual upstream endpoint for
// OpenAI Chat Completions requests. For APIKey accounts whose upstream
// has been probed to not support the Responses API, the request is
// forwarded directly to /v1/chat/completions — not through the default
// CC→Responses conversion path.
// OpenAI Chat Completions requests. For APIKey accounts that should bypass
// the Responses path (explicitly unsupported, or unknown support with a
// third-party custom base_url), the request is forwarded directly to
// /v1/chat/completions.
func resolveRawCCUpstreamEndpoint(c *gin.Context, account *service.Account) string {
if account != nil && account.Type == service.AccountTypeAPIKey &&
!openai_compat.ShouldUseResponsesAPI(account.Extra) {
if account != nil && account.ShouldUseRawOpenAIChatCompletions() {
return "/v1/chat/completions"
}
return GetUpstreamEndpoint(c, account.Platform)

View File

@@ -6,6 +6,7 @@ import (
"errors"
"hash/fnv"
"log/slog"
"net/url"
"reflect"
"sort"
"strconv"
@@ -14,6 +15,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
)
type Account struct {
@@ -733,6 +735,32 @@ func (a *Account) GetBaseURL() string {
return baseURL
}
func (a *Account) ShouldUseRawOpenAIChatCompletions() bool {
if a == nil || a.Platform != PlatformOpenAI || a.Type != AccountTypeAPIKey {
return false
}
if v, ok := a.Extra[openai_compat.ExtraKeyResponsesSupported].(bool); ok {
return !v
}
return a.hasCustomThirdPartyOpenAIBaseURL()
}
func (a *Account) hasCustomThirdPartyOpenAIBaseURL() bool {
baseURL := strings.TrimSpace(a.GetCredential("base_url"))
if baseURL == "" {
return false
}
parsed, err := url.Parse(baseURL)
if err != nil {
return true
}
host := strings.ToLower(parsed.Hostname())
if host == "" {
return true
}
return host != "api.openai.com"
}
// GetGeminiBaseURL 返回 Gemini 兼容端点的 base URL。
// Antigravity 平台的 APIKey 账号自动拼接 /antigravity。
func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {

View File

@@ -51,8 +51,8 @@ func openaiResponsesProbePayload(modelID string) []byte {
//
// 探测策略(参见包文档 internal/pkg/openai_compat
// - 上游 404 / 405 → 不支持,写 false
// - 上游 2xx / 其他 4xx401/422/400 等)/ 5xx → 支持,写 true
// - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
// - 上游 2xx / 明确业务层 4xx401/403/422/400 等)→ 支持,写 true
// - 上游 5xx / 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
// (后续请求仍按"现状即证据"默认走 Responses
//
// 该方法是幂等的:重复调用会以最新探测结果覆盖标记。
@@ -115,13 +115,14 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
_ = resp.Body.Close()
}()
supported := isResponsesEndpointSupportedByStatus(resp.StatusCode)
if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
openai_compat.ExtraKeyResponsesSupported: supported,
}); err != nil {
logger.LegacyPrintf("service.openai_probe", "probe_persist_failed: account_id=%d supported=%v err=%v", accountID, supported, err)
return
supported, persist := resolveOpenAIResponsesProbeSupportDecision(resp.StatusCode)
if persist {
if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
openai_compat.ExtraKeyResponsesSupported: supported,
}); err != nil {
logger.LegacyPrintf("service.openai_probe", "probe_persist_failed: account_id=%d supported=%v err=%v", accountID, supported, err)
return
}
}
logger.LegacyPrintf("service.openai_probe",
@@ -130,20 +131,44 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
)
}
// isResponsesEndpointSupportedByStatus 根据探测响应的 HTTP 状态码判定上游
// 是否暴露 /v1/responses 端点
// resolveOpenAIResponsesProbeSupportDecision 根据探测响应的 HTTP 状态码判
// 是否应将 /v1/responses 支持性持久化到账号 extra
//
// 关键观察:第三方 OpenAI 兼容上游DeepSeek/Kimi 等)对未知端点统一返回 404
// 或 405而 OpenAI 官方/有 Responses 实现的上游会因为请求体最简(缺字段)
// 返回 400/422 等业务错误,但端点本身存在。
//
// 因此:仅 404 和 405 视为"端点不存在",其他 status 视为"端点存在"。
//
// 5xx 也视为"端点存在"——上游偶发故障不应误判为不支持。
func isResponsesEndpointSupportedByStatus(status int) bool {
// 因此:
// - 404 / 405可明确判定为不支持持久化 false
// - 2xx / 明确业务层 4xx可明确判定为支持持久化 true
// - 5xx保持 unknown避免把临时故障误持久化为 true
func resolveOpenAIResponsesProbeSupportDecision(status int) (supported bool, persist bool) {
switch status {
case http.StatusNotFound, http.StatusMethodNotAllowed:
return false
return false, true
case http.StatusOK,
http.StatusCreated,
http.StatusAccepted,
http.StatusNoContent,
http.StatusBadRequest,
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusUnprocessableEntity:
return true, true
case http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout:
return false, false
}
return true
if status >= 200 && status < 300 {
return true, true
}
if status >= 400 && status < 500 {
return true, true
}
if status >= 500 {
return false, false
}
return false, false
}

View File

@@ -0,0 +1,59 @@
package service
import (
"net/http"
"testing"
)
func TestResolveOpenAIResponsesProbeSupportDecision_SupportedStatuses(t *testing.T) {
tests := []int{
http.StatusOK,
http.StatusBadRequest,
http.StatusUnauthorized,
http.StatusForbidden,
http.StatusUnprocessableEntity,
}
for _, status := range tests {
supported, persist := resolveOpenAIResponsesProbeSupportDecision(status)
if !persist {
t.Fatalf("status %d persist = false, want true", status)
}
if !supported {
t.Fatalf("status %d supported = false, want true", status)
}
}
}
func TestResolveOpenAIResponsesProbeSupportDecision_NotFoundStatuses(t *testing.T) {
tests := []int{http.StatusNotFound, http.StatusMethodNotAllowed}
for _, status := range tests {
supported, persist := resolveOpenAIResponsesProbeSupportDecision(status)
if !persist {
t.Fatalf("status %d persist = false, want true", status)
}
if supported {
t.Fatalf("status %d supported = true, want false", status)
}
}
}
func TestResolveOpenAIResponsesProbeSupportDecision_5xxDoesNotPersist(t *testing.T) {
tests := []int{
http.StatusInternalServerError,
http.StatusBadGateway,
http.StatusServiceUnavailable,
http.StatusGatewayTimeout,
}
for _, status := range tests {
supported, persist := resolveOpenAIResponsesProbeSupportDecision(status)
if persist {
t.Fatalf("status %d persist = true, want false", status)
}
if supported {
t.Fatalf("status %d supported = true, want false when persist=false", status)
}
}
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"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"
@@ -48,11 +47,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
// 转换路径(保留旧行为,存量未探测账号零兼容破坏)
// 当前路由策略:
// - APIKey 账号 + 明确不支持 Responses,或未探测但带第三方 custom base_url
// → 走 forwardAsRawChatCompletions 直转上游 /v1/chat/completions不做协议转换
// - 其他情况OAuth、官方 OpenAI APIKey 探测/确认支持
// → 走原有 CC→Responses 转换路径
func (s *OpenAIGatewayService) ForwardAsChatCompletions(
ctx context.Context,
c *gin.Context,
@@ -61,9 +60,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
promptCacheKey string,
defaultMappedModel string,
) (*OpenAIForwardResult, error) {
// 入口分流APIKey 账号 + 已探测且确认上游不支持 Responses走 CC 直转。
// 标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径
if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
// 入口分流APIKey 账号 + 明确不支持 Responses或未探测但自定义 third-party
// base_url 时,直接走 CC 原生转发,避免误打 /v1/responses。
if account.ShouldUseRawOpenAIChatCompletions() {
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
}

View File

@@ -11,12 +11,109 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestForwardAsChatCompletions_APIKeyUnknownResponsesSupportWithCustomBaseURLUsesRawChatCompletions(t *testing.T) {
supported, persist := resolveOpenAIResponsesProbeSupportDecision(http.StatusServiceUnavailable)
require.False(t, supported)
require.False(t, persist)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"deepseek-v4-pro","messages":[{"role":"user","content":"hello"}],"stream":false}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid_probe_5xx_unknown_route"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"type":"invalid_request_error","message":"stop after route capture"}}`)),
}}
svc := &OpenAIGatewayService{cfg: &config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{Enabled: false, AllowInsecureHTTP: true},
},
}, httpUpstream: upstream}
account := &Account{
ID: 501,
Name: "openai-apikey-unknown-support",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "http://upstream.example",
},
Extra: nil,
}
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.Error(t, err)
require.Nil(t, result)
require.NotNil(t, upstream.lastReq)
require.Equal(t, "http://upstream.example/v1/chat/completions", upstream.lastReq.URL.String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input").Exists(), "custom third-party base_url should bypass Responses conversion")
require.True(t, gjson.GetBytes(upstream.lastBody, "messages").Exists(), "raw chat-completions path should preserve messages field")
require.Equal(t, http.StatusBadRequest, rec.Code)
}
func TestAccountShouldUseRawOpenAIChatCompletions(t *testing.T) {
tests := []struct {
name string
account *Account
want bool
}{
{
name: "explicitly unsupported responses",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{"openai_responses_supported": false},
},
want: true,
},
{
name: "unknown support with third-party base_url",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://relay.example.com"},
},
want: true,
},
{
name: "unknown support with official openai host",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://api.openai.com/v1"},
},
want: false,
},
{
name: "unknown support without custom base_url",
account: &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, tt.account.ShouldUseRawOpenAIChatCompletions())
})
}
}
type openAIChatFailingWriter struct {
gin.ResponseWriter
failAfter int