mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-16 23:53:33 +08:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user