gateway_websearch_emulation.go 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. package service
  2. import (
  3. "context"
  4. "encoding/json"
  5. "errors"
  6. "fmt"
  7. "log/slog"
  8. "net/http"
  9. "strings"
  10. "sync/atomic"
  11. "time"
  12. "github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
  13. "github.com/gin-gonic/gin"
  14. "github.com/google/uuid"
  15. "github.com/tidwall/gjson"
  16. )
  17. // Web search emulation constants
  18. const (
  19. toolTypeWebSearchPrefix = "web_search"
  20. toolTypeGoogleSearch = "google_search"
  21. toolNameWebSearch = "web_search"
  22. toolNameGoogleSearch = "google_search"
  23. toolNameWebSearch2025 = "web_search_20250305"
  24. webSearchDefaultMaxResults = 5
  25. defaultWebSearchModel = "claude-sonnet-4-6"
  26. webSearchMsgIDPrefix = "msg_ws_"
  27. webSearchToolUseIDPrefix = "srvtoolu_ws_"
  28. tokenEstimateDivisor = 4
  29. // featureKeyWebSearchEmulation is the key used in Account.Extra and Channel.FeaturesConfig.
  30. featureKeyWebSearchEmulation = "web_search_emulation"
  31. )
  32. // webSearchManagerPtr stores *websearch.Manager atomically for concurrent safety.
  33. var webSearchManagerPtr atomic.Pointer[websearch.Manager]
  34. // SetWebSearchManager wires the websearch.Manager into the gateway (goroutine-safe).
  35. func SetWebSearchManager(m *websearch.Manager) {
  36. webSearchManagerPtr.Store(m)
  37. }
  38. func getWebSearchManager() *websearch.Manager {
  39. return webSearchManagerPtr.Load()
  40. }
  41. // shouldEmulateWebSearch checks whether a request should be intercepted.
  42. //
  43. // Judgment chain: manager exists → only web_search tool → global enabled → account/channel enabled.
  44. // Account-level mode: "enabled" (force on), "disabled" (force off), "default" (follow channel).
  45. func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Account, groupID *int64, body []byte) bool {
  46. if getWebSearchManager() == nil {
  47. return false
  48. }
  49. if !isOnlyWebSearchToolInBody(body) {
  50. return false
  51. }
  52. if !s.settingService.IsWebSearchEmulationEnabled(ctx) {
  53. return false
  54. }
  55. mode := account.GetWebSearchEmulationMode()
  56. switch mode {
  57. case WebSearchModeEnabled:
  58. return true
  59. case WebSearchModeDisabled:
  60. return false
  61. default: // "default" → follow channel config
  62. if groupID == nil || s.channelService == nil {
  63. return false
  64. }
  65. ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
  66. if err != nil || ch == nil {
  67. return false
  68. }
  69. return ch.IsWebSearchEmulationEnabled(account.Platform)
  70. }
  71. }
  72. // isOnlyWebSearchToolInBody checks if the body contains exactly one web_search tool.
  73. func isOnlyWebSearchToolInBody(body []byte) bool {
  74. tools := gjson.GetBytes(body, "tools")
  75. if !tools.IsArray() {
  76. return false
  77. }
  78. arr := tools.Array()
  79. if len(arr) != 1 {
  80. return false
  81. }
  82. return isWebSearchToolJSON(arr[0])
  83. }
  84. func isWebSearchToolJSON(tool gjson.Result) bool {
  85. toolType := tool.Get("type").String()
  86. if strings.HasPrefix(toolType, toolTypeWebSearchPrefix) || toolType == toolTypeGoogleSearch {
  87. return true
  88. }
  89. switch tool.Get("name").String() {
  90. case toolNameWebSearch, toolNameGoogleSearch, toolNameWebSearch2025:
  91. return true
  92. }
  93. return false
  94. }
  95. // extractSearchQueryFromBody extracts the last user message text as the search query.
  96. func extractSearchQueryFromBody(body []byte) string {
  97. messages := gjson.GetBytes(body, "messages")
  98. if !messages.IsArray() {
  99. return ""
  100. }
  101. arr := messages.Array()
  102. if len(arr) == 0 {
  103. return ""
  104. }
  105. lastMsg := arr[len(arr)-1]
  106. if lastMsg.Get("role").String() != "user" {
  107. return ""
  108. }
  109. return extractWebSearchTextFromContent(lastMsg.Get("content"))
  110. }
  111. func extractWebSearchTextFromContent(content gjson.Result) string {
  112. if content.Type == gjson.String {
  113. return content.String()
  114. }
  115. if content.IsArray() {
  116. for _, block := range content.Array() {
  117. if block.Get("type").String() == "text" {
  118. if text := block.Get("text").String(); text != "" {
  119. return text
  120. }
  121. }
  122. }
  123. }
  124. return ""
  125. }
  126. // handleWebSearchEmulation intercepts a web-search-only request,
  127. // calls a third-party search API, and constructs an Anthropic-format response.
  128. func (s *GatewayService) handleWebSearchEmulation(
  129. ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest,
  130. ) (*ForwardResult, error) {
  131. startTime := time.Now()
  132. // Release the serial queue lock immediately — we don't need upstream.
  133. if parsed.OnUpstreamAccepted != nil {
  134. parsed.OnUpstreamAccepted()
  135. }
  136. query := extractSearchQueryFromBody(parsed.Body.Bytes())
  137. if query == "" {
  138. return nil, fmt.Errorf("web search emulation: no query found in messages")
  139. }
  140. slog.Info("web search emulation: executing search",
  141. "account_id", account.ID, "account_name", account.Name, "query", query)
  142. resp, providerName, err := doWebSearch(ctx, account, query)
  143. if err != nil {
  144. // Proxy unavailable → trigger account switch via UpstreamFailoverError
  145. if errors.Is(err, websearch.ErrProxyUnavailable) {
  146. return nil, &UpstreamFailoverError{
  147. StatusCode: http.StatusBadGateway,
  148. ResponseBody: []byte(err.Error()),
  149. }
  150. }
  151. return nil, err
  152. }
  153. slog.Info("web search emulation: search completed",
  154. "provider", providerName, "results_count", len(resp.Results))
  155. model := parsed.Model
  156. if model == "" {
  157. model = defaultWebSearchModel
  158. }
  159. if parsed.Stream {
  160. return writeWebSearchStreamResponse(c, query, resp, model, startTime)
  161. }
  162. return writeWebSearchNonStreamResponse(c, query, resp, model, startTime)
  163. }
  164. func doWebSearch(ctx context.Context, account *Account, query string) (*websearch.SearchResponse, string, error) {
  165. proxyURL := resolveAccountProxyURL(account)
  166. mgr := getWebSearchManager()
  167. if mgr == nil {
  168. return nil, "", fmt.Errorf("web search emulation: manager not initialized")
  169. }
  170. resp, providerName, err := mgr.SearchWithBestProvider(ctx, websearch.SearchRequest{
  171. Query: query, MaxResults: webSearchDefaultMaxResults, ProxyURL: proxyURL,
  172. })
  173. if err != nil {
  174. slog.Error("web search emulation: search failed", "error", err)
  175. return nil, "", fmt.Errorf("web search emulation: %w", err)
  176. }
  177. return resp, providerName, nil
  178. }
  179. func resolveAccountProxyURL(account *Account) string {
  180. if account.ProxyID != nil && account.Proxy != nil {
  181. return account.Proxy.URL()
  182. }
  183. return ""
  184. }
  185. // --- SSE streaming response ---
  186. func writeWebSearchStreamResponse(
  187. c *gin.Context, query string, resp *websearch.SearchResponse, model string, startTime time.Time,
  188. ) (*ForwardResult, error) {
  189. msgID := webSearchMsgIDPrefix + uuid.New().String()
  190. toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16]
  191. textSummary := buildTextSummary(query, resp.Results)
  192. setSSEHeaders(c)
  193. w := c.Writer
  194. for _, fn := range []func() error{
  195. func() error { return writeSSEMessageStart(w, msgID, model) },
  196. func() error { return writeSSEServerToolUse(w, toolUseID, query, 0) },
  197. func() error { return writeSSEToolResult(w, toolUseID, resp.Results, 1) },
  198. func() error { return writeSSETextBlock(w, textSummary, 2) },
  199. func() error { return writeSSEMessageEnd(w, len(textSummary)/tokenEstimateDivisor) },
  200. } {
  201. if err := fn(); err != nil {
  202. slog.Warn("web search emulation: SSE write failed, stopping", "error", err)
  203. break
  204. }
  205. }
  206. w.Flush()
  207. return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil
  208. }
  209. func setSSEHeaders(c *gin.Context) {
  210. c.Writer.Header().Set("Content-Type", "text/event-stream")
  211. c.Writer.Header().Set("Cache-Control", "no-cache")
  212. c.Writer.Header().Set("Connection", "keep-alive")
  213. c.Writer.Header().Set("X-Accel-Buffering", "no")
  214. c.Writer.WriteHeader(http.StatusOK)
  215. }
  216. func writeSSEMessageStart(w http.ResponseWriter, msgID, model string) error {
  217. evt := map[string]any{
  218. "type": "message_start",
  219. "message": map[string]any{
  220. "id": msgID, "type": "message", "role": "assistant", "model": model,
  221. "content": []any{}, "stop_reason": nil, "stop_sequence": nil,
  222. "usage": map[string]int{"input_tokens": 0, "output_tokens": 0},
  223. },
  224. }
  225. return flushSSEJSON(w, "message_start", evt)
  226. }
  227. func writeSSEServerToolUse(w http.ResponseWriter, toolUseID, query string, index int) error {
  228. start := map[string]any{
  229. "type": "content_block_start", "index": index,
  230. "content_block": map[string]any{
  231. "type": "server_tool_use", "id": toolUseID,
  232. "name": toolNameWebSearch, "input": map[string]string{"query": query},
  233. },
  234. }
  235. if err := flushSSEJSON(w, "content_block_start", start); err != nil {
  236. return err
  237. }
  238. return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
  239. }
  240. func writeSSEToolResult(w http.ResponseWriter, toolUseID string, results []websearch.SearchResult, index int) error {
  241. start := map[string]any{
  242. "type": "content_block_start", "index": index,
  243. "content_block": map[string]any{
  244. "type": "web_search_tool_result", "tool_use_id": toolUseID,
  245. "content": buildSearchResultBlocks(results),
  246. },
  247. }
  248. if err := flushSSEJSON(w, "content_block_start", start); err != nil {
  249. return err
  250. }
  251. return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
  252. }
  253. func writeSSETextBlock(w http.ResponseWriter, text string, index int) error {
  254. if err := flushSSEJSON(w, "content_block_start", map[string]any{
  255. "type": "content_block_start", "index": index,
  256. "content_block": map[string]any{"type": "text", "text": ""},
  257. }); err != nil {
  258. return err
  259. }
  260. if err := flushSSEJSON(w, "content_block_delta", map[string]any{
  261. "type": "content_block_delta", "index": index,
  262. "delta": map[string]string{"type": "text_delta", "text": text},
  263. }); err != nil {
  264. return err
  265. }
  266. return flushSSEJSON(w, "content_block_stop", map[string]any{"type": "content_block_stop", "index": index})
  267. }
  268. func writeSSEMessageEnd(w http.ResponseWriter, outputTokens int) error {
  269. if err := flushSSEJSON(w, "message_delta", map[string]any{
  270. "type": "message_delta",
  271. "delta": map[string]any{"stop_reason": "end_turn", "stop_sequence": nil},
  272. "usage": map[string]int{"output_tokens": outputTokens},
  273. }); err != nil {
  274. return err
  275. }
  276. return flushSSEJSON(w, "message_stop", map[string]string{"type": "message_stop"})
  277. }
  278. // flushSSEJSON marshals data to JSON and writes an SSE event.
  279. func flushSSEJSON(w http.ResponseWriter, event string, data any) error {
  280. b, err := json.Marshal(data)
  281. if err != nil {
  282. return fmt.Errorf("marshal: %w", err)
  283. }
  284. if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, b); err != nil {
  285. return fmt.Errorf("write: %w", err)
  286. }
  287. if f, ok := w.(http.Flusher); ok {
  288. f.Flush()
  289. }
  290. return nil
  291. }
  292. // --- Non-streaming JSON response ---
  293. func writeWebSearchNonStreamResponse(
  294. c *gin.Context, query string, resp *websearch.SearchResponse, model string, startTime time.Time,
  295. ) (*ForwardResult, error) {
  296. msgID := webSearchMsgIDPrefix + uuid.New().String()
  297. toolUseID := webSearchToolUseIDPrefix + uuid.New().String()[:16]
  298. textSummary := buildTextSummary(query, resp.Results)
  299. msg := map[string]any{
  300. "id": msgID, "type": "message", "role": "assistant", "model": model,
  301. "content": []any{
  302. map[string]any{
  303. "type": "server_tool_use", "id": toolUseID,
  304. "name": toolNameWebSearch, "input": map[string]string{"query": query},
  305. },
  306. map[string]any{
  307. "type": "web_search_tool_result", "tool_use_id": toolUseID,
  308. "content": buildSearchResultBlocks(resp.Results),
  309. },
  310. map[string]any{"type": "text", "text": textSummary},
  311. },
  312. "stop_reason": "end_turn", "stop_sequence": nil,
  313. "usage": map[string]int{"input_tokens": 0, "output_tokens": len(textSummary) / tokenEstimateDivisor},
  314. }
  315. body, err := json.Marshal(msg)
  316. if err != nil {
  317. return nil, fmt.Errorf("web search emulation: marshal response: %w", err)
  318. }
  319. c.Data(http.StatusOK, "application/json", body)
  320. return &ForwardResult{Model: model, Duration: time.Since(startTime), Usage: ClaudeUsage{}}, nil
  321. }
  322. // --- Helpers ---
  323. func buildSearchResultBlocks(results []websearch.SearchResult) []map[string]string {
  324. blocks := make([]map[string]string, 0, len(results))
  325. for _, r := range results {
  326. block := map[string]string{
  327. "type": "web_search_result",
  328. "url": r.URL,
  329. "title": r.Title,
  330. }
  331. if r.Snippet != "" {
  332. block["page_content"] = r.Snippet
  333. }
  334. if r.PageAge != "" {
  335. block["page_age"] = r.PageAge
  336. }
  337. blocks = append(blocks, block)
  338. }
  339. return blocks
  340. }
  341. func buildTextSummary(query string, results []websearch.SearchResult) string {
  342. if len(results) == 0 {
  343. return "No search results found for: " + query
  344. }
  345. var sb strings.Builder
  346. fmt.Fprintf(&sb, "Here are the search results for \"%s\":\n\n", query)
  347. for i, r := range results {
  348. fmt.Fprintf(&sb, "%d. **%s**\n %s\n %s\n\n", i+1, r.Title, r.URL, r.Snippet)
  349. }
  350. return sb.String()
  351. }