From d2b080e88b9f2e2082058e230b6f0abf14914e12 Mon Sep 17 00:00:00 2001 From: shaw Date: Fri, 17 Jul 2026 08:44:23 +0800 Subject: [PATCH] fix(openai): restore APIKey account scheduling for alpha/search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #4394 (776f3f0de) 在新增 alpha_search 调度能力时加入了 OAuth-only 门控,把 APIKey 账号从 /alpha/search 候选池整体剔除;但转发层自 52071d391 起就支持 APIKey 走 {base_url}/v1/alpha/search,门控注释中 「API key 会被发往 chatgpt.com 导致 401」与实际路由不符。结果是纯 APIKey 分组自 v0.1.157 起独立搜索在选号阶段即失败(无可用账号)。 修复: - SupportsOpenAIEndpointCapability 的 alpha_search 门控放行 AccountTypeAPIKey(OAuth/APIKey 均可,Grok 等仍拒绝);显式能力集 语义不变,chat_completions 继续隐含放行 alpha_search。 - ForwardAlphaSearch 对 APIKey 账号的 404/405 按端点级 failover 处理 (换号、不写账号错误状态),避免混合分组里请求死在不支持该端点的 APIKey 上游;OAuth 账号 404 透传行为保持不变。 - 更新固化旧门控行为的用例,并在调度层新增 APIKey 放行回归锁。 --- backend/internal/service/account.go | 9 +- .../service/openai_account_scheduler_test.go | 47 ++++++++++ .../internal/service/openai_alpha_search.go | 26 +++++- .../service/openai_alpha_search_test.go | 93 +++++++++++++++++++ .../internal/service/openai_images_test.go | 17 +++- 5 files changed, 182 insertions(+), 10 deletions(-) diff --git a/backend/internal/service/account.go b/backend/internal/service/account.go index 701e14d74..fbf955dcc 100644 --- a/backend/internal/service/account.go +++ b/backend/internal/service/account.go @@ -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: diff --git a/backend/internal/service/openai_account_scheduler_test.go b/backend/internal/service/openai_account_scheduler_test.go index f95261702..25b563281 100644 --- a/backend/internal/service/openai_account_scheduler_test.go +++ b/backend/internal/service/openai_account_scheduler_test.go @@ -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() diff --git a/backend/internal/service/openai_alpha_search.go b/backend/internal/service/openai_alpha_search.go index d220cb7a1..f11a21958 100644 --- a/backend/internal/service/openai_alpha_search.go +++ b/backend/internal/service/openai_alpha_search.go @@ -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) { diff --git a/backend/internal/service/openai_alpha_search_test.go b/backend/internal/service/openai_alpha_search_test.go index 6b0135601..e31fd9e01 100644 --- a/backend/internal/service/openai_alpha_search_test.go +++ b/backend/internal/service/openai_alpha_search_test.go @@ -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)) +} diff --git a/backend/internal/service/openai_images_test.go b/backend/internal/service/openai_images_test.go index d3579e44d..82f9c3add 100644 --- a/backend/internal/service/openai_images_test.go +++ b/backend/internal/service/openai_images_test.go @@ -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)) })