fix(openai): emulate web_search on /v1/messages for OpenAI API Key accounts

Chat Completions conversion drops Anthropic web_search tools, so Claude
Code's WebSearch on OpenAI-platform groups (e.g. DeepSeek upstreams)
got no web_search_tool_result and reported 0 results. Run the existing
search emulation in ForwardAsAnthropic and let OpenAI API Key accounts
opt in via extra.web_search_emulation.
This commit is contained in:
SipengXie2024
2026-10-14 07:02:52 +00:00
parent 9c2cca9e62
commit 26e55cf332
6 changed files with 89 additions and 13 deletions
+4 -1
View File
@@ -2316,7 +2316,10 @@ const (
// 三态:default(跟随渠道)/ enabled(强制开启)/ disabled(强制关闭)。
// 兼容旧 bool 值:true→enabled, false→default(并记录 debug 日志)。
func (a *Account) GetWebSearchEmulationMode() string {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
// OpenAI API Key accounts reach this via /v1/messages; their upstreams
// (DeepSeek etc. over Chat Completions) have no web_search tool.
if a == nil || a.Type != AccountTypeAPIKey || a.Extra == nil ||
(a.Platform != PlatformAnthropic && a.Platform != PlatformOpenAI) {
return WebSearchModeDefault
}
raw := a.Extra[featureKeyWebSearchEmulation]
@@ -86,12 +86,30 @@ func TestGetWebSearchEmulationMode_MissingField(t *testing.T) {
require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode())
}
func TestGetWebSearchEmulationMode_NonAnthropicPlatform(t *testing.T) {
func TestGetWebSearchEmulationMode_OpenAIAPIKey(t *testing.T) {
a := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"},
}
require.Equal(t, WebSearchModeEnabled, a.GetWebSearchEmulationMode())
}
func TestGetWebSearchEmulationMode_OpenAIOAuth(t *testing.T) {
a := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"},
}
require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode())
}
func TestGetWebSearchEmulationMode_OtherPlatform(t *testing.T) {
a := &Account{
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"},
}
require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode())
}
@@ -52,13 +52,22 @@ func getWebSearchManager() *websearch.Manager {
// Judgment chain: manager exists → only web_search tool → global enabled → account/channel enabled.
// Account-level mode: "enabled" (force on), "disabled" (force off), "default" (follow channel).
func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Account, groupID *int64, body []byte) bool {
if getWebSearchManager() == nil {
return shouldEmulateWebSearchFor(ctx, s.settingService, s.channelService, account, groupID, body)
}
// shouldEmulateWebSearchFor is shared by the Anthropic gateway and the OpenAI
// gateway's /v1/messages path.
func shouldEmulateWebSearchFor(
ctx context.Context, settingService *SettingService, channelService *ChannelService,
account *Account, groupID *int64, body []byte,
) bool {
if getWebSearchManager() == nil || account == nil || settingService == nil {
return false
}
if !isOnlyWebSearchToolInBody(body) {
return false
}
if !s.settingService.IsWebSearchEmulationEnabled(ctx) {
if !settingService.IsWebSearchEmulationEnabled(ctx) {
return false
}
@@ -69,10 +78,10 @@ func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Ac
case WebSearchModeDisabled:
return false
default: // "default" → follow channel config
if groupID == nil || s.channelService == nil {
if groupID == nil || channelService == nil {
return false
}
ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
ch, err := channelService.GetChannelForGroup(ctx, *groupID)
if err != nil || ch == nil {
return false
}
@@ -143,14 +152,21 @@ func extractWebSearchTextFromContent(content gjson.Result) string {
func (s *GatewayService) handleWebSearchEmulation(
ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest,
) (*ForwardResult, error) {
startTime := time.Now()
// Release the serial queue lock immediately — we don't need upstream.
if parsed.OnUpstreamAccepted != nil {
parsed.OnUpstreamAccepted()
}
return writeEmulatedWebSearch(ctx, c, account, parsed.Body.Bytes(), parsed.Model, parsed.Stream)
}
query := extractSearchQueryFromBody(parsed.Body.Bytes())
// writeEmulatedWebSearch runs the search for the last user message and writes
// an Anthropic Messages response (SSE when stream is true).
func writeEmulatedWebSearch(
ctx context.Context, c *gin.Context, account *Account, body []byte, model string, stream bool,
) (*ForwardResult, error) {
startTime := time.Now()
query := extractSearchQueryFromBody(body)
if query == "" {
return nil, fmt.Errorf("web search emulation: no query found in messages")
}
@@ -173,12 +189,11 @@ func (s *GatewayService) handleWebSearchEmulation(
slog.Info("web search emulation: search completed",
"provider", providerName, "results_count", len(resp.Results))
model := parsed.Model
if model == "" {
model = defaultWebSearchModel
}
if parsed.Stream {
if stream {
return writeWebSearchStreamResponse(c, query, resp, model, startTime)
}
return writeWebSearchNonStreamResponse(c, query, resp, model, startTime)
@@ -378,3 +378,26 @@ func TestShouldEmulateWebSearch_DefaultMode_NilChannelService(t *testing.T) {
// nil channelService + default mode → returns false
require.False(t, svc.shouldEmulateWebSearch(context.Background(), account, &groupID, webSearchToolBody))
}
func TestShouldEmulateWebSearchFor_OpenAIAPIKeyAccountEnabled(t *testing.T) {
mgr := websearch.NewManager([]websearch.ProviderConfig{{Type: "brave", APIKey: "k"}}, nil)
SetWebSearchManager(mgr)
defer SetWebSearchManager(nil)
setGlobalWebSearchConfig(&WebSearchEmulationConfig{
Enabled: true,
Providers: []WebSearchProviderConfig{{Type: "brave", APIKey: "k"}},
})
defer clearGlobalWebSearchConfig()
settingSvc := newSettingServiceForWebSearchTest(true)
account := &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: WebSearchModeEnabled},
}
groupID := int64(0)
require.True(t, shouldEmulateWebSearchFor(context.Background(), settingSvc, nil, account, &groupID, webSearchToolBody))
require.False(t, shouldEmulateWebSearchFor(context.Background(), settingSvc, nil, account, &groupID, nonWebSearchToolBody))
}
@@ -45,6 +45,16 @@ func (s *OpenAIGatewayService) ForwardAsAnthropic(
rememberOpenCodeInboundBody(c, body)
beginUpstreamResponseModelObservation(c)
ClearActualOpenAIUpstreamEndpoint(c)
// Web Search 模拟:Chat Completions 转换会丢掉 web_search 工具,纯 web_search
// 请求在此直接调用搜索 API 构造 Anthropic 响应,不访问上游。
if groupID := getOpenAIGroupIDFromContext(c); shouldEmulateWebSearchFor(ctx, s.settingService, s.channelService, account, &groupID, body) {
stream := gjson.GetBytes(body, "stream").Bool()
res, err := writeEmulatedWebSearch(ctx, c, account, body, gjson.GetBytes(body, "model").String(), stream)
if err != nil {
return nil, err
}
return &OpenAIForwardResult{Model: res.Model, Stream: stream, Duration: res.Duration}, nil
}
if shouldForwardOpenAIResponsesViaRawChatCompletions(account) {
SetActualOpenAIUpstreamEndpoint(c, "/v1/chat/completions")
}
@@ -2248,9 +2248,9 @@
</div>
</div>
<!-- Anthropic API Key: Web Search Emulation (hidden when global disabled) -->
<!-- Anthropic / OpenAI API Key: Web Search Emulation (hidden when global disabled) -->
<div
v-if="account?.platform === 'anthropic' && account?.type === 'apikey' && webSearchGlobalEnabled"
v-if="(account?.platform === 'anthropic' || account?.platform === 'openai') && account?.type === 'apikey' && webSearchGlobalEnabled"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
@@ -4425,6 +4425,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
anthropicAPIKeyAuthScheme.value = extra?.anthropic_apikey_auth_scheme === 'authorization_bearer'
? 'authorization_bearer'
: 'x_api_key'
}
if ((newAccount.platform === 'anthropic' || newAccount.platform === 'openai') && newAccount.type === 'apikey') {
// 三态:string "default"/"enabled"/"disabled",向后兼容旧 bool
const wsVal = extra?.web_search_emulation
if (wsVal === 'enabled' || wsVal === 'disabled') {
@@ -5932,6 +5934,11 @@ const handleSubmit = async () => {
newExtra.images_url_to_b64_json = true
} else {
delete newExtra.images_url_to_b64_json
}
if (webSearchEmulationMode.value === 'default') {
delete newExtra.web_search_emulation
} else {
newExtra.web_search_emulation = webSearchEmulationMode.value
}
}
if (autoPause5hThreshold.value != null && autoPause5hThreshold.value > 0) {