From 0d9df842e4a032caee0827bbd9d6f4b1e9068dca Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 21 May 2026 12:20:41 +0800 Subject: [PATCH] fix(openai): bypass responses for unknown third-party API keys --- .../handler/openai_chat_completions.go | 12 +-- backend/internal/service/account.go | 28 ++++++ .../service/openai_apikey_responses_probe.go | 59 +++++++---- .../openai_apikey_responses_probe_test.go | 59 +++++++++++ .../openai_gateway_chat_completions.go | 17 ++-- .../openai_gateway_chat_completions_test.go | 97 +++++++++++++++++++ 6 files changed, 239 insertions(+), 33 deletions(-) create mode 100644 backend/internal/service/openai_apikey_responses_probe_test.go diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go index de384710..944336a8 100644 --- a/backend/internal/handler/openai_chat_completions.go +++ b/backend/internal/handler/openai_chat_completions.go @@ -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) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index cd06ffa3..1ce7a203 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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 { diff --git a/backend/internal/service/openai_apikey_responses_probe.go b/backend/internal/service/openai_apikey_responses_probe.go index a4eb9252..daf4a6cd 100644 --- a/backend/internal/service/openai_apikey_responses_probe.go +++ b/backend/internal/service/openai_apikey_responses_probe.go @@ -51,8 +51,8 @@ func openaiResponsesProbePayload(modelID string) []byte { // // 探测策略(参见包文档 internal/pkg/openai_compat): // - 上游 404 / 405 → 不支持,写 false -// - 上游 2xx / 其他 4xx(401/422/400 等)/ 5xx → 支持,写 true -// - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown +// - 上游 2xx / 明确业务层 4xx(401/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 } diff --git a/backend/internal/service/openai_apikey_responses_probe_test.go b/backend/internal/service/openai_apikey_responses_probe_test.go new file mode 100644 index 00000000..c16fdb7f --- /dev/null +++ b/backend/internal/service/openai_apikey_responses_probe_test.go @@ -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) + } + } +} diff --git a/backend/internal/service/openai_gateway_chat_completions.go b/backend/internal/service/openai_gateway_chat_completions.go index 84d85c74..ff199021 100644 --- a/backend/internal/service/openai_gateway_chat_completions.go +++ b/backend/internal/service/openai_gateway_chat_completions.go @@ -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) } diff --git a/backend/internal/service/openai_gateway_chat_completions_test.go b/backend/internal/service/openai_gateway_chat_completions_test.go index b35ab6d1..a0da2fa8 100644 --- a/backend/internal/service/openai_gateway_chat_completions_test.go +++ b/backend/internal/service/openai_gateway_chat_completions_test.go @@ -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