Files
viettranx 30a40bbe59 feat(tools): add Exa + Tavily web search providers with ranked ordering
Port Exa and Tavily provider clients from PR #825 into the tenant-aware
overlay architecture (builtin_tool_tenant_configs.settings).

- Add ExaConfig + TavilyConfig to WebToolsConfig with provider_order
- Add Exa HTTP client (api.exa.ai/search, x-api-key auth)
- Add Tavily HTTP client (api.tavily.com/search, Bearer auth)
- Extend Brave + DDG constructors with per-provider maxResults
- Add config_secrets plumbing for exa/tavily API keys (apply/collect/mask/strip)
- Refactor gateway_setup.go to use WebSearchConfigFromConfig
- Add NormalizeWebSearchProviderOrder (DDG always last, dedup, unknown skip)
- Extract web_search_config.go (builder, normalizer, shared helpers)
- 11 new unit tests for provider order, builder, clamp, normalize

Credit: @kaitranntt (PR #825) for the original Exa + Tavily implementation.
2026-04-12 11:09:32 +07:00

98 lines
2.3 KiB
Go

package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// --- Brave Search Provider ---
type braveSearchProvider struct {
apiKey string
maxResults int
client *http.Client
}
func newBraveSearchProvider(apiKey string, maxResults int) *braveSearchProvider {
return &braveSearchProvider{
apiKey: apiKey,
maxResults: normalizeProviderMaxResults(maxResults),
client: &http.Client{Timeout: time.Duration(searchTimeoutSeconds) * time.Second},
}
}
func (p *braveSearchProvider) Name() string { return searchProviderBrave }
func (p *braveSearchProvider) Search(ctx context.Context, params searchParams) ([]searchResult, error) {
count := clampProviderResultCount(params.Count, p.maxResults)
q := url.Values{}
q.Set("q", params.Query)
q.Set("count", fmt.Sprintf("%d", count))
if params.Country != "" {
q.Set("country", params.Country)
}
if params.SearchLang != "" {
q.Set("search_lang", params.SearchLang)
}
if params.UILang != "" {
q.Set("ui_lang", params.UILang)
}
if f := normalizeFreshness(params.Freshness); f != "" {
q.Set("freshness", f)
}
reqURL := braveSearchEndpoint + "?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-Subscription-Token", p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("brave API returned %d: %s", resp.StatusCode, truncateStr(string(body), 200))
}
var braveResp struct {
Web struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Description string `json:"description"`
} `json:"results"`
} `json:"web"`
}
if err := json.Unmarshal(body, &braveResp); err != nil {
return nil, fmt.Errorf("parse response: %w", err)
}
results := make([]searchResult, 0, len(braveResp.Web.Results))
for _, r := range braveResp.Web.Results {
results = append(results, searchResult{
Title: r.Title,
URL: r.URL,
Description: r.Description,
})
}
return results, nil
}