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"
|
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
"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"
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -291,13 +290,12 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveRawCCUpstreamEndpoint returns the actual upstream endpoint for
|
// resolveRawCCUpstreamEndpoint returns the actual upstream endpoint for
|
||||||
// OpenAI Chat Completions requests. For APIKey accounts whose upstream
|
// OpenAI Chat Completions requests. For APIKey accounts that should bypass
|
||||||
// has been probed to not support the Responses API, the request is
|
// the Responses path (explicitly unsupported, or unknown support with a
|
||||||
// forwarded directly to /v1/chat/completions — not through the default
|
// third-party custom base_url), the request is forwarded directly to
|
||||||
// CC→Responses conversion path.
|
// /v1/chat/completions.
|
||||||
func resolveRawCCUpstreamEndpoint(c *gin.Context, account *service.Account) string {
|
func resolveRawCCUpstreamEndpoint(c *gin.Context, account *service.Account) string {
|
||||||
if account != nil && account.Type == service.AccountTypeAPIKey &&
|
if account != nil && account.ShouldUseRawOpenAIChatCompletions() {
|
||||||
!openai_compat.ShouldUseResponsesAPI(account.Extra) {
|
|
||||||
return "/v1/chat/completions"
|
return "/v1/chat/completions"
|
||||||
}
|
}
|
||||||
return GetUpstreamEndpoint(c, account.Platform)
|
return GetUpstreamEndpoint(c, account.Platform)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Account struct {
|
type Account struct {
|
||||||
@@ -733,6 +735,32 @@ func (a *Account) GetBaseURL() string {
|
|||||||
return baseURL
|
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。
|
// GetGeminiBaseURL 返回 Gemini 兼容端点的 base URL。
|
||||||
// Antigravity 平台的 APIKey 账号自动拼接 /antigravity。
|
// Antigravity 平台的 APIKey 账号自动拼接 /antigravity。
|
||||||
func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
|
func (a *Account) GetGeminiBaseURL(defaultBaseURL string) string {
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ func openaiResponsesProbePayload(modelID string) []byte {
|
|||||||
//
|
//
|
||||||
// 探测策略(参见包文档 internal/pkg/openai_compat):
|
// 探测策略(参见包文档 internal/pkg/openai_compat):
|
||||||
// - 上游 404 / 405 → 不支持,写 false
|
// - 上游 404 / 405 → 不支持,写 false
|
||||||
// - 上游 2xx / 其他 4xx(401/422/400 等)/ 5xx → 支持,写 true
|
// - 上游 2xx / 明确业务层 4xx(401/403/422/400 等)→ 支持,写 true
|
||||||
// - 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
|
// - 上游 5xx / 网络层失败(连接错误、超时)→ 不写标记,保持 unknown
|
||||||
// (后续请求仍按"现状即证据"默认走 Responses)
|
// (后续请求仍按"现状即证据"默认走 Responses)
|
||||||
//
|
//
|
||||||
// 该方法是幂等的:重复调用会以最新探测结果覆盖标记。
|
// 该方法是幂等的:重复调用会以最新探测结果覆盖标记。
|
||||||
@@ -115,13 +115,14 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
|
|||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
supported := isResponsesEndpointSupportedByStatus(resp.StatusCode)
|
supported, persist := resolveOpenAIResponsesProbeSupportDecision(resp.StatusCode)
|
||||||
|
if persist {
|
||||||
if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
|
if err := s.accountRepo.UpdateExtra(ctx, accountID, map[string]any{
|
||||||
openai_compat.ExtraKeyResponsesSupported: supported,
|
openai_compat.ExtraKeyResponsesSupported: supported,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
logger.LegacyPrintf("service.openai_probe", "probe_persist_failed: account_id=%d supported=%v err=%v", accountID, supported, err)
|
logger.LegacyPrintf("service.openai_probe", "probe_persist_failed: account_id=%d supported=%v err=%v", accountID, supported, err)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.LegacyPrintf("service.openai_probe",
|
logger.LegacyPrintf("service.openai_probe",
|
||||||
@@ -130,20 +131,44 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// isResponsesEndpointSupportedByStatus 根据探测响应的 HTTP 状态码判定上游
|
// resolveOpenAIResponsesProbeSupportDecision 根据探测响应的 HTTP 状态码判断
|
||||||
// 是否暴露 /v1/responses 端点。
|
// 是否应将 /v1/responses 支持性持久化到账号 extra。
|
||||||
//
|
//
|
||||||
// 关键观察:第三方 OpenAI 兼容上游(DeepSeek/Kimi 等)对未知端点统一返回 404
|
// 关键观察:第三方 OpenAI 兼容上游(DeepSeek/Kimi 等)对未知端点统一返回 404
|
||||||
// 或 405;而 OpenAI 官方/有 Responses 实现的上游会因为请求体最简(缺字段)
|
// 或 405;而 OpenAI 官方/有 Responses 实现的上游会因为请求体最简(缺字段)
|
||||||
// 返回 400/422 等业务错误,但端点本身存在。
|
// 返回 400/422 等业务错误,但端点本身存在。
|
||||||
//
|
//
|
||||||
// 因此:仅 404 和 405 视为"端点不存在",其他 status 视为"端点存在"。
|
// 因此:
|
||||||
//
|
// - 404 / 405:可明确判定为不支持,持久化 false
|
||||||
// 5xx 也视为"端点存在"——上游偶发故障不应误判为不支持。
|
// - 2xx / 明确业务层 4xx:可明确判定为支持,持久化 true
|
||||||
func isResponsesEndpointSupportedByStatus(status int) bool {
|
// - 5xx:保持 unknown,避免把临时故障误持久化为 true
|
||||||
|
func resolveOpenAIResponsesProbeSupportDecision(status int) (supported bool, persist bool) {
|
||||||
switch status {
|
switch status {
|
||||||
case http.StatusNotFound, http.StatusMethodNotAllowed:
|
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/apicompat"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
"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/Wei-Shaw/sub2api/internal/util/responseheaders"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
@@ -48,11 +47,11 @@ var cursorResponsesUnsupportedFields = []string{
|
|||||||
// 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂:
|
// 正确的,但 sub2api 接入 DeepSeek/Kimi/GLM 等第三方 OpenAI 兼容上游后假设破裂:
|
||||||
// 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。
|
// 这些上游普遍只支持 /v1/chat/completions,无 /v1/responses 端点。
|
||||||
//
|
//
|
||||||
// 当前路由策略(基于账号探测标记,详见 openai_compat.ShouldUseResponsesAPI):
|
// 当前路由策略:
|
||||||
// - APIKey 账号 + 探测确认不支持 Responses → 走 forwardAsRawChatCompletions
|
// - APIKey 账号 + 明确不支持 Responses,或未探测但带第三方 custom base_url
|
||||||
// 直转上游 /v1/chat/completions,不做协议转换
|
// → 走 forwardAsRawChatCompletions 直转上游 /v1/chat/completions,不做协议转换
|
||||||
// - 其他所有情况(OAuth、APIKey 探测确认支持、未探测)→ 走原有 CC→Responses
|
// - 其他情况(OAuth、官方 OpenAI APIKey 未探测/确认支持)
|
||||||
// 转换路径(保留旧行为,存量未探测账号零兼容破坏)
|
// → 走原有 CC→Responses 转换路径
|
||||||
func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
c *gin.Context,
|
c *gin.Context,
|
||||||
@@ -61,9 +60,9 @@ func (s *OpenAIGatewayService) ForwardAsChatCompletions(
|
|||||||
promptCacheKey string,
|
promptCacheKey string,
|
||||||
defaultMappedModel string,
|
defaultMappedModel string,
|
||||||
) (*OpenAIForwardResult, error) {
|
) (*OpenAIForwardResult, error) {
|
||||||
// 入口分流:APIKey 账号 + 已探测且确认上游不支持 Responses,走 CC 直转。
|
// 入口分流:APIKey 账号 + 明确不支持 Responses,或未探测但自定义 third-party
|
||||||
// 标记缺失(未探测)按"现状即证据"原则继续走下方原 Responses 转换路径。
|
// base_url 时,直接走 CC 原生转发,避免误打 /v1/responses。
|
||||||
if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
|
if account.ShouldUseRawOpenAIChatCompletions() {
|
||||||
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
return s.forwardAsRawChatCompletions(ctx, c, account, body, defaultMappedModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,109 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/tidwall/gjson"
|
"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 {
|
type openAIChatFailingWriter struct {
|
||||||
gin.ResponseWriter
|
gin.ResponseWriter
|
||||||
failAfter int
|
failAfter int
|
||||||
|
|||||||
Reference in New Issue
Block a user