feat: add Exa AI search provider

Add Exa (https://exa.ai) as a new web search provider option, slotting
into the priority chain between Perplexity and Brave. Configurable via
config.json or PICOCLAW_TOOLS_WEB_EXA_* environment variables.

Results are capped to the requested count for consistency with other
search providers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
I Putu Eddy Irawan
2026-03-02 22:29:26 +07:00
parent f2ab1a74da
commit 4b7e8d9cb9
4 changed files with 98 additions and 1 deletions
+3
View File
@@ -112,6 +112,9 @@ func registerSharedTools(
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
ExaAPIKey: cfg.Tools.Web.Exa.APIKey,
ExaMaxResults: cfg.Tools.Web.Exa.MaxResults,
ExaEnabled: cfg.Tools.Web.Exa.Enabled,
Proxy: cfg.Tools.Web.Proxy,
})
if err != nil {
+7
View File
@@ -530,11 +530,18 @@ type PerplexityConfig struct {
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
}
type ExaConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_EXA_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_EXA_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_EXA_MAX_RESULTS"`
}
type WebToolsConfig struct {
Brave BraveConfig `json:"brave"`
Tavily TavilyConfig `json:"tavily"`
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
Perplexity PerplexityConfig `json:"perplexity"`
Exa ExaConfig `json:"exa"`
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
+5
View File
@@ -341,6 +341,11 @@ func DefaultConfig() *Config {
APIKey: "",
MaxResults: 5,
},
Exa: ExaConfig{
Enabled: false,
APIKey: "",
MaxResults: 5,
},
},
Cron: CronToolsConfig{
ExecTimeoutMinutes: 5,
+83 -1
View File
@@ -409,6 +409,9 @@ type WebSearchToolOptions struct {
PerplexityAPIKey string
PerplexityMaxResults int
PerplexityEnabled bool
ExaAPIKey string
ExaMaxResults int
ExaEnabled bool
Proxy string
}
@@ -416,7 +419,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
var provider SearchProvider
maxResults := 5
// Priority: Perplexity > Brave > Tavily > DuckDuckGo
// Priority: Perplexity > Exa > Brave > Tavily > DuckDuckGo
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
if err != nil {
@@ -426,6 +429,15 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
if opts.PerplexityMaxResults > 0 {
maxResults = opts.PerplexityMaxResults
}
} else if opts.ExaEnabled && opts.ExaAPIKey != "" {
client, err := createHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP client for Exa: %w", err)
}
provider = &ExaSearchProvider{apiKey: opts.ExaAPIKey, proxy: opts.Proxy, client: client}
if opts.ExaMaxResults > 0 {
maxResults = opts.ExaMaxResults
}
} else if opts.BraveEnabled && opts.BraveAPIKey != "" {
client, err := createHTTPClient(opts.Proxy, searchTimeout)
if err != nil {
@@ -705,3 +717,73 @@ func (t *WebFetchTool) extractText(htmlContent string) string {
return strings.Join(cleanLines, "\n")
}
// ExaSearchProvider uses the Exa AI search API (https://exa.ai).
type ExaSearchProvider struct {
apiKey string
proxy string
client *http.Client
}
func (p *ExaSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
reqBody := map[string]any{
"query": query,
"num_results": count,
"type": "neural",
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("exa: marshal error: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", "https://api.exa.ai/search", bytes.NewReader(jsonData))
if err != nil {
return "", fmt.Errorf("exa: request error: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("x-api-key", p.apiKey)
resp, err := p.client.Do(req)
if err != nil {
return "", fmt.Errorf("exa: search failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("exa: read error: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("exa: API error %d: %s", resp.StatusCode, string(body))
}
var result struct {
Results []struct {
Title string `json:"title"`
URL string `json:"url"`
Text string `json:"text"`
} `json:"results"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("exa: parse error: %w", err)
}
var sb strings.Builder
maxResults := count
if maxResults > len(result.Results) {
maxResults = len(result.Results)
}
for i, r := range result.Results[:maxResults] {
sb.WriteString(fmt.Sprintf("%d. %s\n URL: %s\n", i+1, r.Title, r.URL))
if r.Text != "" {
snippet := r.Text
if len(snippet) > 200 {
snippet = snippet[:200] + "..."
}
sb.WriteString(fmt.Sprintf(" %s\n", snippet))
}
sb.WriteString("\n")
}
return sb.String(), nil
}