fix(openai): bypass responses for unknown third-party API keys
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user