add gemini web search provider
This commit is contained in:
parent
d2c0b69243
commit
3aab686b03
10 changed files with 251 additions and 11 deletions
|
|
@ -485,6 +485,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
|||
| Search Engine | API Key | Free Tier | Link |
|
||||
|--------------|---------|-----------|------|
|
||||
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
||||
| [Gemini Google Search](https://aistudio.google.com/apikey) | Required | Varies | Gemini with Google Search grounding |
|
||||
| [Baidu Search](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5) | Required | 1500/month (daily allocation) | AI-powered, China-optimized |
|
||||
| [Tavily](https://tavily.com) | Required | 1000 queries/month | Optimized for AI Agents |
|
||||
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||
|
|
|
|||
|
|
@ -282,6 +282,12 @@
|
|||
"enabled": false,
|
||||
"max_results": 5
|
||||
},
|
||||
"gemini": {
|
||||
"enabled": false,
|
||||
"api_key": "",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_results": 5
|
||||
},
|
||||
"perplexity": {
|
||||
"enabled": false,
|
||||
"api_key": "pplx-xxx",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,32 @@ General settings for fetching and processing webpage content.
|
|||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||
| `max_results` | int | 5 | Maximum number of results |
|
||||
|
||||
### Gemini Google Search
|
||||
|
||||
Gemini search uses Gemini with Google Search grounding. It returns an AI-synthesized answer with citations from Google Search.
|
||||
|
||||
| Config | Type | Default | Description |
|
||||
|---------------|--------|----------------------|-----------------------------------|
|
||||
| `enabled` | bool | false | Enable Gemini Google Search |
|
||||
| `api_key` | string | - | Google Gemini API key |
|
||||
| `model` | string | `gemini-2.5-flash` | Gemini model used for search |
|
||||
| `max_results` | int | 5 | Maximum number of citations |
|
||||
|
||||
```json
|
||||
{
|
||||
"tools": {
|
||||
"web": {
|
||||
"gemini": {
|
||||
"enabled": true,
|
||||
"api_key": "YOUR_GEMINI_API_KEY",
|
||||
"model": "gemini-2.5-flash",
|
||||
"max_results": 5
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Baidu Search
|
||||
|
||||
Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfan-api/s/Wmbq4z7e5), which is AI-powered and optimized for Chinese-language queries.
|
||||
|
|
|
|||
|
|
@ -846,6 +846,13 @@ type SogouConfig struct {
|
|||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SOGOU_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type GeminiSearchConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_ENABLED"`
|
||||
APIKey SecureString `json:"api_key,omitzero" yaml:"api_key,omitempty" env:"PICOCLAW_TOOLS_WEB_GEMINI_API_KEY"`
|
||||
Model string `json:"model" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_MODEL"`
|
||||
MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_GEMINI_MAX_RESULTS"`
|
||||
}
|
||||
|
||||
type PerplexityConfig struct {
|
||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
||||
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
|
||||
|
|
@ -894,6 +901,7 @@ type WebToolsConfig struct {
|
|||
Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"`
|
||||
Sogou SogouConfig `yaml:"-" json:"sogou"`
|
||||
DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"`
|
||||
Gemini GeminiSearchConfig `yaml:"gemini,omitempty" json:"gemini"`
|
||||
Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"`
|
||||
SearXNG SearXNGConfig `yaml:"-" json:"searxng"`
|
||||
GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"`
|
||||
|
|
|
|||
|
|
@ -341,6 +341,11 @@ func DefaultConfig() *Config {
|
|||
Enabled: false,
|
||||
MaxResults: 5,
|
||||
},
|
||||
Gemini: GeminiSearchConfig{
|
||||
Enabled: false,
|
||||
Model: "gemini-2.5-flash",
|
||||
MaxResults: 5,
|
||||
},
|
||||
Perplexity: PerplexityConfig{
|
||||
Enabled: false,
|
||||
MaxResults: 5,
|
||||
|
|
|
|||
|
|
@ -472,6 +472,110 @@ type SogouSearchProvider struct {
|
|||
client *http.Client
|
||||
}
|
||||
|
||||
type GeminiSearchProvider struct {
|
||||
apiKey string
|
||||
model string
|
||||
proxy string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func (p *GeminiSearchProvider) Search(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
count int,
|
||||
rangeCode string,
|
||||
) (string, error) {
|
||||
if strings.TrimSpace(p.apiKey) == "" {
|
||||
return "", errors.New("no API key provided")
|
||||
}
|
||||
model := strings.TrimSpace(p.model)
|
||||
if model == "" {
|
||||
model = "gemini-2.5-flash"
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"contents": []map[string]any{{
|
||||
"parts": []map[string]string{{"text": query}},
|
||||
}},
|
||||
"tools": []map[string]any{{"google_search": map[string]any{}}},
|
||||
}
|
||||
bodyBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent", url.PathEscape(model))
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(bodyBytes))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("x-goog-api-key", p.apiKey)
|
||||
req.Header.Set("User-Agent", fmt.Sprintf(userAgentHonest, config.Version))
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("gemini search api error (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var searchResp struct {
|
||||
Candidates []struct {
|
||||
Content struct {
|
||||
Parts []struct {
|
||||
Text string `json:"text"`
|
||||
} `json:"parts"`
|
||||
} `json:"content"`
|
||||
GroundingMetadata struct {
|
||||
GroundingChunks []struct {
|
||||
Web struct {
|
||||
URI string `json:"uri"`
|
||||
Title string `json:"title"`
|
||||
} `json:"web"`
|
||||
} `json:"groundingChunks"`
|
||||
} `json:"groundingMetadata"`
|
||||
} `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
if len(searchResp.Candidates) == 0 {
|
||||
return fmt.Sprintf("No results for: %s", query), nil
|
||||
}
|
||||
|
||||
candidate := searchResp.Candidates[0]
|
||||
lines := []string{fmt.Sprintf("Results for: %s (via Gemini Google Search)", query)}
|
||||
for _, part := range candidate.Content.Parts {
|
||||
if strings.TrimSpace(part.Text) != "" {
|
||||
lines = append(lines, strings.TrimSpace(part.Text))
|
||||
}
|
||||
}
|
||||
citationCount := 0
|
||||
for _, chunk := range candidate.GroundingMetadata.GroundingChunks {
|
||||
if strings.TrimSpace(chunk.Web.URI) == "" {
|
||||
continue
|
||||
}
|
||||
citationCount++
|
||||
title := strings.TrimSpace(chunk.Web.Title)
|
||||
if title == "" {
|
||||
title = chunk.Web.URI
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%d. %s\n %s", citationCount, title, chunk.Web.URI))
|
||||
if citationCount >= count {
|
||||
break
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n"), nil
|
||||
}
|
||||
|
||||
func (p *SogouSearchProvider) Search(
|
||||
ctx context.Context,
|
||||
query string,
|
||||
|
|
@ -1072,6 +1176,10 @@ type WebSearchToolOptions struct {
|
|||
SogouEnabled bool
|
||||
DuckDuckGoMaxResults int
|
||||
DuckDuckGoEnabled bool
|
||||
GeminiAPIKey string
|
||||
GeminiModel string
|
||||
GeminiMaxResults int
|
||||
GeminiEnabled bool
|
||||
PerplexityAPIKeys []string
|
||||
PerplexityMaxResults int
|
||||
PerplexityEnabled bool
|
||||
|
|
@ -1104,6 +1212,10 @@ func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
|
|||
SogouEnabled: cfg.Tools.Web.Sogou.Enabled,
|
||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
GeminiAPIKey: cfg.Tools.Web.Gemini.APIKey.String(),
|
||||
GeminiModel: cfg.Tools.Web.Gemini.Model,
|
||||
GeminiMaxResults: cfg.Tools.Web.Gemini.MaxResults,
|
||||
GeminiEnabled: cfg.Tools.Web.Gemini.Enabled,
|
||||
PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
|
||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||
|
|
@ -1135,6 +1247,7 @@ var (
|
|||
knownWebSearchProviders = []string{
|
||||
"sogou",
|
||||
"duckduckgo",
|
||||
"gemini",
|
||||
"brave",
|
||||
"tavily",
|
||||
"perplexity",
|
||||
|
|
@ -1142,7 +1255,7 @@ var (
|
|||
"glm_search",
|
||||
"baidu_search",
|
||||
}
|
||||
autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"}
|
||||
autoPrimaryWebSearchProviders = []string{"gemini", "perplexity", "brave", "searxng", "tavily"}
|
||||
autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"}
|
||||
)
|
||||
|
||||
|
|
@ -1162,6 +1275,8 @@ func (opts WebSearchToolOptions) providerReady(name string) bool {
|
|||
return opts.SogouEnabled
|
||||
case "duckduckgo":
|
||||
return opts.DuckDuckGoEnabled
|
||||
case "gemini":
|
||||
return opts.GeminiEnabled && strings.TrimSpace(opts.GeminiAPIKey) != ""
|
||||
case "brave":
|
||||
return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0
|
||||
case "tavily":
|
||||
|
|
@ -1279,6 +1394,24 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in
|
|||
proxy: opts.Proxy,
|
||||
client: client,
|
||||
}, maxResults, nil
|
||||
case "gemini":
|
||||
if !opts.providerReady("gemini") {
|
||||
return nil, 0, nil
|
||||
}
|
||||
client, err := utils.CreateHTTPClient(opts.Proxy, searchTimeout)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to create HTTP client for Gemini: %w", err)
|
||||
}
|
||||
maxResults := 10
|
||||
if opts.GeminiMaxResults > 0 {
|
||||
maxResults = min(opts.GeminiMaxResults, 10)
|
||||
}
|
||||
return &GeminiSearchProvider{
|
||||
apiKey: opts.GeminiAPIKey,
|
||||
model: opts.GeminiModel,
|
||||
proxy: opts.Proxy,
|
||||
client: client,
|
||||
}, maxResults, nil
|
||||
case "searxng":
|
||||
if !opts.providerReady("searxng") {
|
||||
return nil, 0, nil
|
||||
|
|
|
|||
|
|
@ -1853,6 +1853,43 @@ func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestWebTool_AutoProviderPrefersGeminiBeforeOtherConfiguredProviders(t *testing.T) {
|
||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||
GeminiEnabled: true,
|
||||
GeminiAPIKey: "google-key",
|
||||
GeminiModel: "gemini-2.5-flash",
|
||||
GeminiMaxResults: 5,
|
||||
BraveEnabled: true,
|
||||
BraveAPIKeys: []string{"brave-key"},
|
||||
BraveMaxResults: 5,
|
||||
SogouEnabled: true,
|
||||
SogouMaxResults: 5,
|
||||
DuckDuckGoEnabled: true,
|
||||
DuckDuckGoMaxResults: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||
}
|
||||
if _, ok := tool.provider.(*GeminiSearchProvider); !ok {
|
||||
t.Fatalf("expected GeminiSearchProvider, got %T", tool.provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTool_GeminiRequiresAPIKey(t *testing.T) {
|
||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||
Provider: "gemini",
|
||||
GeminiEnabled: true,
|
||||
SogouEnabled: true,
|
||||
SogouMaxResults: 5,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||
}
|
||||
if _, ok := tool.provider.(*SogouSearchProvider); !ok {
|
||||
t.Fatalf("expected SogouSearchProvider after missing Gemini API key fallback, got %T", tool.provider)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) {
|
||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||
Provider: "brave",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type (
|
|||
TavilySearchProvider = integrationtools.TavilySearchProvider
|
||||
SogouSearchProvider = integrationtools.SogouSearchProvider
|
||||
DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider
|
||||
GeminiSearchProvider = integrationtools.GeminiSearchProvider
|
||||
PerplexitySearchProvider = integrationtools.PerplexitySearchProvider
|
||||
SearXNGSearchProvider = integrationtools.SearXNGSearchProvider
|
||||
GLMSearchProvider = integrationtools.GLMSearchProvider
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ type webSearchProviderConfig struct {
|
|||
BaseURL string `json:"base_url,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
APIKeys []string `json:"api_keys,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
APIKeySet bool `json:"api_key_set,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -446,6 +447,14 @@ func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Req
|
|||
cfg.Tools.Web.DuckDuckGo.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults
|
||||
}
|
||||
if settings, ok := req.Settings["gemini"]; ok {
|
||||
cfg.Tools.Web.Gemini.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Gemini.MaxResults = settings.MaxResults
|
||||
cfg.Tools.Web.Gemini.Model = strings.TrimSpace(settings.Model)
|
||||
if key := strings.TrimSpace(settings.APIKey); key != "" {
|
||||
cfg.Tools.Web.Gemini.APIKey = *config.NewSecureString(key)
|
||||
}
|
||||
}
|
||||
if settings, ok := req.Settings["brave"]; ok {
|
||||
cfg.Tools.Web.Brave.Enabled = settings.Enabled
|
||||
cfg.Tools.Web.Brave.MaxResults = settings.MaxResults
|
||||
|
|
@ -505,7 +514,7 @@ func normalizeWebSearchProvider(provider string) string {
|
|||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "", "auto":
|
||||
return "auto"
|
||||
case "sogou", "brave", "tavily", "duckduckgo", "perplexity", "searxng", "glm_search", "baidu_search":
|
||||
case "sogou", "brave", "tavily", "duckduckgo", "gemini", "perplexity", "searxng", "glm_search", "baidu_search":
|
||||
return strings.ToLower(strings.TrimSpace(provider))
|
||||
default:
|
||||
return ""
|
||||
|
|
@ -549,6 +558,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
|||
Enabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||
MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||
},
|
||||
"gemini": {
|
||||
Enabled: cfg.Tools.Web.Gemini.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Gemini.MaxResults,
|
||||
Model: cfg.Tools.Web.Gemini.Model,
|
||||
APIKeySet: cfg.Tools.Web.Gemini.APIKey.String() != "",
|
||||
},
|
||||
"brave": {
|
||||
Enabled: cfg.Tools.Web.Brave.Enabled,
|
||||
MaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||
|
|
@ -604,6 +619,13 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
|||
Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"),
|
||||
Current: current == "duckduckgo",
|
||||
},
|
||||
{
|
||||
ID: "gemini",
|
||||
Label: "Gemini (Google Search)",
|
||||
Configured: picotools.WebSearchProviderReady(opts, "gemini"),
|
||||
Current: current == "gemini",
|
||||
RequiresAuth: true,
|
||||
},
|
||||
{
|
||||
ID: "brave",
|
||||
Label: "Brave Search",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export interface WebSearchProviderConfig {
|
|||
max_results: number
|
||||
base_url?: string
|
||||
api_key?: string
|
||||
model?: string
|
||||
api_key_set?: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue