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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user