Merge pull request #4459 from Wei-Shaw/fix/openai-alpha-search-apikey-scheduling

fix(openai): restore APIKey account scheduling for alpha/search
This commit is contained in:
Wesley Liddick
2026-07-17 09:04:17 +08:00
committed by GitHub
5 changed files with 182 additions and 10 deletions
+5 -4
View File
@@ -1425,10 +1425,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
// 配置集校验。
capability = OpenAIEndpointCapabilityChatCompletions
case OpenAIEndpointCapabilityAlphaSearch:
// Codex alpha/search 是 ChatGPT/Codex 后端工具端点,必须使用
// OAuth/PAT/AgentIdentity 这类 ChatGPT 账号凭据;API key 被发往
// chatgpt.com/backend-api/codex/alpha/search 会稳定 401。
if a.Type != AccountTypeOAuth {
// alpha/search 的转发按账号类型分流:OAuth/PAT 走
// chatgpt.com/backend-api/codex/alpha/search,API key 走
// {base_url}/v1/alpha/search(见 openAIAlphaSearchURL),两类账号
// 都可承接独立搜索请求。上游不支持该端点时由转发层 failover 兜底。
if a.Type != AccountTypeOAuth && a.Type != AccountTypeAPIKey {
return false
}
case OpenAIEndpointCapabilityEmbeddings:
@@ -668,6 +668,53 @@ func TestOpenAIGatewayService_SelectAccountWithScheduler_ResponsesCapabilityExcl
})
}
// alpha/search 调度必须同时放行 OAuth 与 APIKey 账号:v0.1.157 曾因 OAuth-only
// 门控把 APIKey 账号从候选池剔除,纯 APIKey 分组的独立搜索请求在选号阶段就
// 报无可用账号,Codex 网页搜索整体失效(转发层其实一直支持 APIKey 路径)。
func TestOpenAIGatewayService_SelectAccountWithScheduler_AlphaSearchAllowsAPIKeyAccount(t *testing.T) {
resetOpenAIAdvancedSchedulerSettingCacheForTest()
ctx := context.Background()
groupID := int64(10125)
accounts := []Account{
{
ID: 38001,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Status: StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 0,
},
}
cfg := &config.Config{}
cfg.Gateway.Scheduling.LoadBatchEnabled = false
svc := &OpenAIGatewayService{
accountRepo: schedulerTestOpenAIAccountRepo{accounts: accounts},
cache: &schedulerTestGatewayCache{},
cfg: cfg,
concurrencyService: NewConcurrencyService(schedulerTestConcurrencyCache{}),
}
selection, _, err := svc.SelectAccountWithSchedulerForCapability(
ctx,
&groupID,
"",
"",
"gpt-5.6-sol",
nil,
OpenAIUpstreamTransportHTTPSSE,
OpenAIEndpointCapabilityAlphaSearch,
false,
false,
false,
)
require.NoError(t, err)
require.NotNil(t, selection)
require.NotNil(t, selection.Account)
require.Equal(t, int64(38001), selection.Account.ID)
}
func TestOpenAIGatewayService_SelectAccountWithScheduler_DefaultDisabled_AllowsGrokChatAccount(t *testing.T) {
resetOpenAIAdvancedSchedulerSettingCacheForTest()
@@ -87,7 +87,8 @@ func (s *OpenAIGatewayService) ForwardAlphaSearch(ctx context.Context, c *gin.Co
if resp.StatusCode >= http.StatusBadRequest {
upstreamMessage := sanitizeUpstreamErrorMessage(strings.TrimSpace(extractUpstreamErrorMessage(respBody)))
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) {
if s.shouldFailoverOpenAIUpstreamResponse(resp.StatusCode, upstreamMessage, respBody) ||
isOpenAIAlphaSearchEndpointUnsupported(account, resp.StatusCode) {
resp.Body = io.NopCloser(bytes.NewReader(respBody))
// alpha/search 是独立的工具端点,单次 401 不能证明账号的模型调用
// 凭据全局失效。若沿用通用 401 逻辑,PAT 会因没有 refresh_token
@@ -505,8 +506,29 @@ func (s *OpenAIGatewayService) ensureOpenAIAlphaSearchAuthMetadata(ctx context.C
return nil
}
// isOpenAIAlphaSearchEndpointUnsupported 识别「API key 上游没有实现
// /v1/alpha/search 端点」的响应。404/405 不在通用 failover 状态集里(模型
// 调用中的 404 通常是用户请求问题),但对这个独立工具端点而言,它几乎只
// 意味着所选上游(官方平台或第三方中转)不提供该端点——应换号重试,而
// 不是把 404 透传给客户端,否则混合分组里 OAuth 账号明明可以承接搜索,
// 请求却可能死在先被选中的 API key 账号上。
func isOpenAIAlphaSearchEndpointUnsupported(account *Account, statusCode int) bool {
if account == nil || account.Type != AccountTypeAPIKey {
return false
}
return statusCode == http.StatusNotFound || statusCode == http.StatusMethodNotAllowed
}
func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
return statusCode != http.StatusUnauthorized
switch statusCode {
case http.StatusUnauthorized, http.StatusNotFound, http.StatusMethodNotAllowed:
// 401:工具端点的 access enforcement 不代表凭据全局失效;
// 404/405:端点不存在只说明该上游不支持独立搜索,账号本身健康。
// 两类都只换号,不写账号错误状态。
return false
default:
return true
}
}
func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
@@ -393,8 +393,101 @@ func TestForwardAlphaSearchPATResponsesFallbackUnauthorizedDoesNotMarkAccountErr
require.False(t, c.Writer.Written())
}
// API key 上游(官方平台或第三方中转)不提供 /v1/alpha/search 时返回的
// 404/405 必须触发换号而不是把错误透传给客户端:混合分组里 OAuth 账号可以
// 承接搜索,请求不能死在先被选中的 API key 账号上。端点缺失也不能写账号
// 错误状态——账号本身是健康的。
func TestForwardAlphaSearchAPIKeyEndpointNotFoundFailsOver(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusNotFound,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Not Found"}}`)),
}}
repo := &alphaSearchAccountStateRepo{}
cfg := &config.Config{}
service := &OpenAIGatewayService{
cfg: cfg,
httpUpstream: upstream,
accountRepo: repo,
rateLimitService: NewRateLimitService(repo, nil, cfg, nil, nil),
}
account := &Account{
ID: 9,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "https://relay.example",
},
}
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
require.Nil(t, result)
var failoverErr *UpstreamFailoverError
require.ErrorAs(t, err, &failoverErr)
require.Equal(t, http.StatusNotFound, failoverErr.StatusCode)
require.Zero(t, repo.setErrorCalls)
require.Empty(t, repo.lastError)
require.False(t, c.Writer.Written())
require.Empty(t, recorder.Body.String())
}
// OAuth 账号的 chatgpt.com 端点固定存在,404 保持原有透传行为不变。
func TestForwardAlphaSearchOAuthNotFoundPassesThrough(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"id":"search-session","model":"gpt-5.6-sol","commands":{"search_query":[{"q":"news"}]}}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
upstreamBody := `{"detail":"Not Found"}`
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusNotFound,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{
ID: 10,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
},
}
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
require.NoError(t, err)
require.Nil(t, result)
require.Equal(t, http.StatusNotFound, recorder.Code)
require.JSONEq(t, upstreamBody, recorder.Body.String())
}
func TestShouldApplyOpenAIAlphaSearchAccountErrorSideEffects(t *testing.T) {
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusUnauthorized))
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusNotFound))
require.False(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusMethodNotAllowed))
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusForbidden))
require.True(t, shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(http.StatusTooManyRequests))
}
func TestIsOpenAIAlphaSearchEndpointUnsupported(t *testing.T) {
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusNotFound))
require.True(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusMethodNotAllowed))
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(apiKey, http.StatusBadRequest))
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(oauth, http.StatusNotFound))
require.False(t, isOpenAIAlphaSearchEndpointUnsupported(nil, http.StatusNotFound))
}
+13 -4
View File
@@ -475,7 +475,7 @@ func TestAccountSupportsOpenAIImageCapability_EmptyRequirementDoesNotRejectGrok(
}
func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
t.Run("OpenAI APIKey 默认兼容 chat 和 embeddings", func(t *testing.T) {
t.Run("OpenAI APIKey 默认兼容 chat、embeddings 和 alpha search", func(t *testing.T) {
account := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
@@ -483,6 +483,7 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
})
t.Run("OpenAI OAuth 默认仅兼容 chat", func(t *testing.T) {
@@ -496,7 +497,9 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
})
t.Run("alpha search 仅允许 OpenAI OAuth/PAT 类账号", func(t *testing.T) {
t.Run("alpha search 允许 OpenAI OAuth/PAT 与 APIKey 账号,拒绝 Grok", func(t *testing.T) {
// OAuth/PAT 走 chatgpt.com Codex 端点,APIKey 走 {base_url}/v1/alpha/search,
// 两类都能承接独立搜索(APIKey 被排除曾导致纯 APIKey 分组搜索失效的回归)。
apiKey := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
@@ -505,9 +508,14 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
}
grok := &Account{
Platform: PlatformGrok,
Type: AccountTypeAPIKey,
}
require.False(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
require.True(t, apiKey.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
require.True(t, oauth.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
require.False(t, grok.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
})
t.Run("显式列表支持同时声明 chat 和 embeddings", func(t *testing.T) {
@@ -533,7 +541,8 @@ func TestAccountSupportsOpenAIEndpointCapability(t *testing.T) {
}
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityChatCompletions))
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
// chat 能力隐含放行 alpha search(OAuth/APIKey 语义一致)。
require.True(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityAlphaSearch))
require.False(t, account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilityEmbeddings))
})