| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548 |
- package handler
- import (
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
- "github.com/Wei-Shaw/sub2api/internal/pkg/logger"
- "github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
- "github.com/Wei-Shaw/sub2api/internal/service"
- "github.com/gin-gonic/gin"
- "github.com/google/uuid"
- "github.com/tidwall/gjson"
- "go.uber.org/zap"
- )
- const (
- defaultGrokWebSearchResults = 5
- maxGrokWebSearchResults = 20
- )
- func (h *GatewayHandler) WebSearch(c *gin.Context) {
- isXSearch := c.GetBool("grok_x_search_endpoint")
- var req grokStandaloneSearchRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
- "type": "invalid_request_error",
- "message": err.Error(),
- }})
- return
- }
- query := strings.TrimSpace(req.Query)
- if query == "" {
- query = strings.TrimSpace(req.Input)
- }
- if query == "" {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
- "type": "invalid_request_error",
- "message": "query is required",
- }})
- return
- }
- req.Query = query
- maxResults := 0
- if req.MaxResults != nil {
- maxResults = *req.MaxResults
- }
- maxResults = normalizeGrokWebSearchMaxResults(maxResults)
- searchModel := resolveGrokStandaloneSearchModel()
- searchLabel := "web_search"
- if isXSearch {
- searchLabel = "x_search"
- }
- apiKey, ok := middleware2.GetAPIKeyFromContext(c)
- if !ok || apiKey == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{
- "type": "authentication_error",
- "message": "API key required",
- }})
- return
- }
- if apiKey.Group == nil || apiKey.Group.Platform != "grok" {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
- "type": "invalid_request_error",
- "message": searchLabel + " is only supported for grok groups",
- }})
- return
- }
- // Billing eligibility (same as other requests)
- subscription, _ := middleware2.GetSubscriptionFromContext(c)
- if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
- status, code, message, retryAfter := billingErrorDetails(err)
- if retryAfter > 0 {
- c.Header("Retry-After", strconv.Itoa(retryAfter))
- }
- c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}})
- return
- }
- subject, _ := middleware2.GetAuthSubjectFromContext(c)
- reqLog := requestLogger(c, "handler.gateway.web_search")
- // Audit user search query before upstream Grok web_search traffic.
- auditBody, _ := json.Marshal(map[string]any{
- "messages": []map[string]any{{
- "role": "user", "content": req.Query,
- }},
- })
- if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, searchModel, auditBody); decision != nil && !decision.AllowNextStage {
- status := decision.HTTPStatus
- if status == 0 {
- status = http.StatusForbidden
- }
- code := decision.ErrorCode
- if code == "" {
- code = "content_policy_violation"
- }
- msg := decision.ClientMessage
- if msg == "" {
- msg = "Request blocked by content policy"
- }
- c.JSON(status, gin.H{"error": gin.H{"type": code, "message": msg}})
- return
- }
- // Use exactly the same scheduling as other requests (SelectAccountWithLoadAwareness handles load, rate limit, sticky, etc.)
- groupID := apiKey.GroupID
- if groupID == nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
- "type": "invalid_request_error",
- "message": "group required",
- }})
- return
- }
- failedAccounts := make(map[int64]struct{})
- var account *service.Account
- var accountReleaseFunc func()
- var nativeResp *websearch.SearchResponse
- var providerName string
- var err error
- // Acquire + release holder for the whole handler (including failover retries).
- defer func() {
- if accountReleaseFunc != nil {
- accountReleaseFunc()
- }
- }()
- // First attempt + up to 3 failover accounts (max 4 total).
- for attempt := 0; attempt < 4; attempt++ {
- selected, selectErr := h.gatewayService.SelectAccountWithLoadAwareness(
- c.Request.Context(), groupID, "", searchModel, failedAccounts, "", 0,
- )
- if selectErr != nil {
- if attempt == 0 {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
- "type": "scheduling_error",
- "message": selectErr.Error(),
- }})
- return
- }
- break
- }
- if selected == nil || selected.Account == nil {
- if attempt == 0 {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
- "type": "scheduling_error",
- "message": "No available accounts",
- }})
- return
- }
- break
- }
- release, acquireOK, acquireErr := h.acquireWebSearchAccountSlot(c, selected)
- if !acquireOK {
- // First hop: surface concurrency errors; later hops try another account.
- if attempt == 0 && acquireErr != nil {
- h.handleConcurrencyError(c, acquireErr, "account", false)
- return
- }
- failedAccounts[selected.Account.ID] = struct{}{}
- continue
- }
- account = selected.Account
- accountReleaseFunc = release
- if isXSearch {
- nativeResp, providerName, err = h.doGrokNativeXSearch(c.Request.Context(), c, account, req, searchModel, maxResults)
- } else {
- nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, maxResults, searchModel)
- }
- if err == nil {
- break
- }
- var failoverErr *service.UpstreamFailoverError
- if !errors.As(err, &failoverErr) || !failoverErr.ShouldRetryNextAccount() {
- break
- }
- failedAccounts[account.ID] = struct{}{}
- if accountReleaseFunc != nil {
- accountReleaseFunc()
- accountReleaseFunc = nil
- }
- account = nil
- }
- if err != nil || nativeResp == nil {
- msg := "web search failed"
- if err != nil {
- msg = err.Error()
- }
- c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "web_search_error", "message": msg}})
- return
- }
- if account == nil {
- c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
- "type": "scheduling_error",
- "message": "No available accounts",
- }})
- return
- }
- userAgent := c.GetHeader("User-Agent")
- clientIP := ip.GetClientIP(c)
- inboundEndpoint := GetInboundEndpoint(c)
- upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
- requestPayloadHash := service.HashUsageRequestPayload([]byte(req.Query))
- quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
- // Request IDs are billing idempotency keys, so they must be unique per invocation.
- // Query/IP/UA hashes would collapse repeated identical searches into one charge.
- searchRequestID := searchLabel + ":" + uuid.NewString()
- if apiKey.Group != nil {
- if p := apiKey.Group.GetSearchPricePer1k(); p != nil && *p == 0 {
- logger.L().With(
- zap.String("component", "handler.gateway.web_search"),
- zap.Int64("group_id", apiKey.Group.ID),
- ).Info("gateway.web_search.search_price_per_1k_explicit_free")
- }
- }
- h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
- if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
- Result: &service.ForwardResult{
- RequestID: searchRequestID,
- Model: "grok-" + strings.ReplaceAll(searchLabel, "_", "-"),
- SearchCount: 1,
- Duration: 0,
- },
- APIKey: apiKey,
- User: apiKey.User,
- Account: account,
- Subscription: subscription,
- InboundEndpoint: inboundEndpoint,
- UpstreamEndpoint: upstreamEndpoint,
- UserAgent: userAgent,
- IPAddress: clientIP,
- RequestPayloadHash: requestPayloadHash,
- APIKeyService: h.apiKeyService,
- QuotaPlatform: quotaPlatform,
- }); err != nil {
- logger.L().With(
- zap.String("component", "handler.gateway.web_search"),
- zap.Int64("user_id", apiKey.User.ID),
- zap.Int64("api_key_id", apiKey.ID),
- zap.Int64("account_id", account.ID),
- ).Error("gateway.web_search.record_usage_failed", zap.Error(err))
- }
- })
- c.JSON(http.StatusOK, gin.H{
- "query": req.Query,
- "results": nativeResp.Results,
- "provider": providerName,
- "max_results": maxResults,
- })
- }
- // acquireWebSearchAccountSlot resolves an immediate slot or WaitPlan wait.
- // On failure returns (nil, false, err); err is non-nil for concurrency acquire
- // failures so the first hop can map them to HTTP. Wait-queue full returns
- // (nil, false, nil) so failover can try another account.
- func (h *GatewayHandler) acquireWebSearchAccountSlot(
- c *gin.Context,
- selected *service.AccountSelectionResult,
- ) (release func(), ok bool, acquireErr error) {
- if selected == nil || selected.Account == nil {
- return nil, false, nil
- }
- if selected.Acquired {
- return selected.ReleaseFunc, true, nil
- }
- if selected.WaitPlan == nil || h.concurrencyHelper == nil {
- return nil, false, nil
- }
- account := selected.Account
- accountWaitCounted := false
- canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selected.WaitPlan.MaxWaiting)
- if waitErr != nil {
- logger.L().Warn("gateway.web_search.account_wait_counter_increment_failed",
- zap.Int64("account_id", account.ID),
- zap.Error(waitErr),
- )
- // Best-effort wait without counter (same as first-hop legacy path).
- } else if !canWait {
- return nil, false, nil
- } else {
- accountWaitCounted = true
- }
- releaseWait := func() {
- if accountWaitCounted {
- h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
- accountWaitCounted = false
- }
- }
- streamStarted := false
- slotRelease, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(
- c,
- account.ID,
- selected.WaitPlan.MaxConcurrency,
- selected.WaitPlan.Timeout,
- false,
- &streamStarted,
- )
- releaseWait()
- if err != nil {
- return nil, false, err
- }
- return slotRelease, true, nil
- }
- // doGrokNativeWebSearch executes web search using the Grok account's native capability
- // by calling the responses endpoint with web_search tool, then normalizes sources to unified format.
- func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int, model string) (*websearch.SearchResponse, string, error) {
- maxResults = normalizeGrokWebSearchMaxResults(maxResults)
- // Build a minimal responses request that triggers Grok web search tool.
- // Ask for structured metadata because xAI action.sources commonly contains URLs only.
- searchBody := map[string]any{
- "model": xai.ResolveDefaultTextModel(model),
- "input": buildGrokWebSearchPrompt(query, maxResults),
- "tools": []map[string]any{{"type": "web_search"}},
- "include": []string{"web_search_call.action.sources"},
- "store": false,
- "stream": false,
- }
- bodyBytes, _ := json.Marshal(searchBody)
- respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
- if err != nil {
- return nil, "", err
- }
- // Extract sources from Grok responses output.
- // Prefer web_search_call.action.sources (standardized), fallback to annotations or text links.
- results := extractGrokWebSearchSources(respBytes, maxResults)
- return &websearch.SearchResponse{
- Results: results,
- Query: query,
- }, "grok-native", nil
- }
- func (h *GatewayHandler) doGrokNativeXSearch(ctx context.Context, c *gin.Context, account *service.Account, req grokStandaloneSearchRequest, model string, maxResults int) (*websearch.SearchResponse, string, error) {
- maxResults = normalizeGrokWebSearchMaxResults(maxResults)
- bodyBytes, err := buildGrokXSearchResponsesBody(req, model)
- if err != nil {
- return nil, "", err
- }
- respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
- if err != nil {
- return nil, "", err
- }
- results := extractGrokWebSearchSources(respBytes, maxResults)
- return &websearch.SearchResponse{
- Results: results,
- Query: req.Query,
- }, "grok-native", nil
- }
- func normalizeGrokWebSearchMaxResults(maxResults int) int {
- if maxResults <= 0 {
- return defaultGrokWebSearchResults
- }
- if maxResults > maxGrokWebSearchResults {
- return maxGrokWebSearchResults
- }
- return maxResults
- }
- func buildGrokWebSearchPrompt(query string, maxResults int) string {
- return fmt.Sprintf(`Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown.
- User query:
- %s`, normalizeGrokWebSearchMaxResults(maxResults), query)
- }
- // extractGrokWebSearchSources returns model-enriched results only when their URLs
- // are present in the actual web_search sources, then falls back to raw sources.
- func extractGrokWebSearchSources(body []byte, maxResults int) []websearch.SearchResult {
- if len(body) == 0 || !gjson.ValidBytes(body) {
- return nil
- }
- maxResults = normalizeGrokWebSearchMaxResults(maxResults)
- sources := make(map[string]websearch.SearchResult)
- var sourceOrder []string
- addSource := func(rawURL, title, snippet string) {
- key, ok := normalizeGrokWebSearchURL(rawURL)
- if !ok {
- return
- }
- result, exists := sources[key]
- if !exists {
- result.URL = strings.TrimSpace(rawURL)
- sourceOrder = append(sourceOrder, key)
- }
- if result.Title == "" {
- result.Title = usableGrokWebSearchTitle(title, result.URL)
- }
- if result.Snippet == "" {
- result.Snippet = strings.TrimSpace(snippet)
- }
- sources[key] = result
- }
- output := gjson.GetBytes(body, "output")
- output.ForEach(func(_, item gjson.Result) bool {
- callType := item.Get("type").String()
- if callType == "web_search_call" || callType == "x_search_call" {
- sources := item.Get("action.sources")
- if sources.IsArray() {
- sources.ForEach(func(_, src gjson.Result) bool {
- addSource(src.Get("url").String(), src.Get("title").String(), src.Get("snippet").String())
- return true
- })
- }
- }
- if item.Get("type").String() == "message" {
- item.Get("content").ForEach(func(_, part gjson.Result) bool {
- if part.Get("type").String() != "output_text" {
- return true
- }
- part.Get("annotations").ForEach(func(_, ann gjson.Result) bool {
- if ann.Get("type").String() == "url_citation" || ann.Get("type").String() == "web" {
- addSource(ann.Get("url").String(), ann.Get("title").String(), "")
- }
- return true
- })
- return true
- })
- }
- return true
- })
- var out []websearch.SearchResult
- seen := make(map[string]bool)
- output.ForEach(func(_, item gjson.Result) bool {
- if item.Get("type").String() != "message" {
- return true
- }
- item.Get("content").ForEach(func(_, part gjson.Result) bool {
- if part.Get("type").String() != "output_text" || len(out) >= maxResults {
- return true
- }
- for _, result := range parseGrokWebSearchStructuredResults(part.Get("text").String()) {
- key, ok := normalizeGrokWebSearchURL(result.URL)
- if !ok || seen[key] {
- continue
- }
- source, allowed := sources[key]
- if !allowed {
- continue
- }
- seen[key] = true
- result.URL = source.URL
- result.Title = usableGrokWebSearchTitle(result.Title, result.URL)
- if result.Title == "" {
- result.Title = source.Title
- }
- result.Snippet = strings.TrimSpace(result.Snippet)
- if result.Snippet == "" {
- result.Snippet = source.Snippet
- }
- out = append(out, result)
- if len(out) >= maxResults {
- break
- }
- }
- return true
- })
- return len(out) < maxResults
- })
- for _, key := range sourceOrder {
- if len(out) >= maxResults {
- break
- }
- if seen[key] {
- continue
- }
- result := sources[key]
- if result.Title == "" {
- result.Title = grokWebSearchTitleFromURL(result.URL)
- }
- seen[key] = true
- out = append(out, result)
- }
- return out
- }
- func parseGrokWebSearchStructuredResults(text string) []websearch.SearchResult {
- text = strings.TrimSpace(text)
- start := strings.IndexByte(text, '{')
- end := strings.LastIndexByte(text, '}')
- if start < 0 || end < start {
- return nil
- }
- var payload struct {
- Results []websearch.SearchResult `json:"results"`
- }
- if err := json.Unmarshal([]byte(text[start:end+1]), &payload); err != nil {
- return nil
- }
- return payload.Results
- }
- func normalizeGrokWebSearchURL(rawURL string) (string, bool) {
- u, err := url.Parse(strings.TrimSpace(rawURL))
- if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
- return "", false
- }
- u.Scheme = strings.ToLower(u.Scheme)
- u.Host = strings.ToLower(u.Host)
- u.Fragment = ""
- if u.Path == "" {
- u.Path = "/"
- }
- return u.String(), true
- }
- func usableGrokWebSearchTitle(title, rawURL string) string {
- title = strings.TrimSpace(title)
- if title == "" || title == rawURL {
- return ""
- }
- if _, err := strconv.Atoi(title); err == nil {
- return ""
- }
- return title
- }
- func grokWebSearchTitleFromURL(rawURL string) string {
- u, err := url.Parse(rawURL)
- if err != nil || u.Host == "" {
- return rawURL
- }
- return strings.TrimPrefix(strings.ToLower(u.Host), "www.")
- }
|