mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-05-25 16:00:35 +00:00
feat(providers): add gemini web search provider (#2763)
* add gemini web search provider * fix(web): prefer free providers before Gemini in auto mode * fix(web): expose gemini api key and model settings * fix(web): prefer configured providers before Gemini in auto mode * fix(web): satisfy gemini lint checks * fix(web): address gemini provider review feedback * test(web): align auto-provider expectations * fix(web): let gemini ignore search range
This commit is contained in:
committed by
GitHub
parent
eb0653074b
commit
794eb04f32
@@ -493,6 +493,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too
|
|||||||
| Search Engine | API Key | Free Tier | Link |
|
| Search Engine | API Key | Free Tier | Link |
|
||||||
|--------------|---------|-----------|------|
|
|--------------|---------|-----------|------|
|
||||||
| DuckDuckGo | Not needed | Unlimited | Built-in fallback |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [Brave Search](https://brave.com/search/api) | Required | 2000 queries/month | Fast and private |
|
||||||
|
|||||||
@@ -291,6 +291,12 @@
|
|||||||
"enabled": false,
|
"enabled": false,
|
||||||
"max_results": 5
|
"max_results": 5
|
||||||
},
|
},
|
||||||
|
"gemini": {
|
||||||
|
"enabled": false,
|
||||||
|
"api_key": "",
|
||||||
|
"model": "gemini-2.5-flash",
|
||||||
|
"max_results": 5
|
||||||
|
},
|
||||||
"perplexity": {
|
"perplexity": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"api_key": "pplx-xxx",
|
"api_key": "pplx-xxx",
|
||||||
|
|||||||
@@ -66,6 +66,32 @@ General settings for fetching and processing webpage content.
|
|||||||
| `enabled` | bool | true | Enable DuckDuckGo search |
|
| `enabled` | bool | true | Enable DuckDuckGo search |
|
||||||
| `max_results` | int | 5 | Maximum number of results |
|
| `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
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
+18
-10
@@ -847,6 +847,13 @@ type SogouConfig struct {
|
|||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_SOGOU_MAX_RESULTS"`
|
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 {
|
type PerplexityConfig struct {
|
||||||
Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"`
|
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"`
|
APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEYS"`
|
||||||
@@ -890,16 +897,17 @@ type BaiduSearchConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"`
|
||||||
Brave BraveConfig `yaml:"brave,omitempty" json:"brave"`
|
Brave BraveConfig `yaml:"brave,omitempty" json:"brave"`
|
||||||
Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"`
|
Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"`
|
||||||
Sogou SogouConfig `yaml:"-" json:"sogou"`
|
Sogou SogouConfig `yaml:"-" json:"sogou"`
|
||||||
DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"`
|
DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"`
|
||||||
Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"`
|
Gemini GeminiSearchConfig `yaml:"gemini,omitempty" json:"gemini"`
|
||||||
SearXNG SearXNGConfig `yaml:"-" json:"searxng"`
|
Perplexity PerplexityConfig `yaml:"perplexity,omitempty" json:"perplexity"`
|
||||||
GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"`
|
SearXNG SearXNGConfig `yaml:"-" json:"searxng"`
|
||||||
BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"`
|
GLMSearch GLMSearchConfig `yaml:"glm_search,omitempty" json:"glm_search"`
|
||||||
Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"`
|
BaiduSearch BaiduSearchConfig `yaml:"baidu_search,omitempty" json:"baidu_search"`
|
||||||
|
Provider string `yaml:"-" json:"provider,omitempty" env:"PICOCLAW_TOOLS_WEB_PROVIDER"`
|
||||||
// PreferNative controls whether to use provider-native web search when
|
// PreferNative controls whether to use provider-native web search when
|
||||||
// the active LLM supports it (e.g. OpenAI web_search_preview). When true,
|
// the active LLM supports it (e.g. OpenAI web_search_preview). When true,
|
||||||
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
|
// the client-side web_search tool is hidden to avoid duplicate search surfaces,
|
||||||
|
|||||||
@@ -341,6 +341,11 @@ func DefaultConfig() *Config {
|
|||||||
Enabled: false,
|
Enabled: false,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
|
Gemini: GeminiSearchConfig{
|
||||||
|
Enabled: false,
|
||||||
|
Model: "gemini-2.5-flash",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
Perplexity: PerplexityConfig{
|
Perplexity: PerplexityConfig{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
|
|||||||
@@ -472,6 +472,113 @@ type SogouSearchProvider struct {
|
|||||||
client *http.Client
|
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(
|
func (p *SogouSearchProvider) Search(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
query string,
|
query string,
|
||||||
@@ -1072,6 +1179,10 @@ type WebSearchToolOptions struct {
|
|||||||
SogouEnabled bool
|
SogouEnabled bool
|
||||||
DuckDuckGoMaxResults int
|
DuckDuckGoMaxResults int
|
||||||
DuckDuckGoEnabled bool
|
DuckDuckGoEnabled bool
|
||||||
|
GeminiAPIKey string
|
||||||
|
GeminiModel string
|
||||||
|
GeminiMaxResults int
|
||||||
|
GeminiEnabled bool
|
||||||
PerplexityAPIKeys []string
|
PerplexityAPIKeys []string
|
||||||
PerplexityMaxResults int
|
PerplexityMaxResults int
|
||||||
PerplexityEnabled bool
|
PerplexityEnabled bool
|
||||||
@@ -1104,6 +1215,10 @@ func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions {
|
|||||||
SogouEnabled: cfg.Tools.Web.Sogou.Enabled,
|
SogouEnabled: cfg.Tools.Web.Sogou.Enabled,
|
||||||
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
||||||
DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
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(),
|
PerplexityAPIKeys: cfg.Tools.Web.Perplexity.APIKeys.Values(),
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
@@ -1135,6 +1250,7 @@ var (
|
|||||||
knownWebSearchProviders = []string{
|
knownWebSearchProviders = []string{
|
||||||
"sogou",
|
"sogou",
|
||||||
"duckduckgo",
|
"duckduckgo",
|
||||||
|
"gemini",
|
||||||
"brave",
|
"brave",
|
||||||
"tavily",
|
"tavily",
|
||||||
"perplexity",
|
"perplexity",
|
||||||
@@ -1142,7 +1258,7 @@ var (
|
|||||||
"glm_search",
|
"glm_search",
|
||||||
"baidu_search",
|
"baidu_search",
|
||||||
}
|
}
|
||||||
autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily"}
|
autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily", "gemini"}
|
||||||
autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"}
|
autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1162,6 +1278,8 @@ func (opts WebSearchToolOptions) providerReady(name string) bool {
|
|||||||
return opts.SogouEnabled
|
return opts.SogouEnabled
|
||||||
case "duckduckgo":
|
case "duckduckgo":
|
||||||
return opts.DuckDuckGoEnabled
|
return opts.DuckDuckGoEnabled
|
||||||
|
case "gemini":
|
||||||
|
return opts.GeminiEnabled && strings.TrimSpace(opts.GeminiAPIKey) != ""
|
||||||
case "brave":
|
case "brave":
|
||||||
return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0
|
return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0
|
||||||
case "tavily":
|
case "tavily":
|
||||||
@@ -1195,14 +1313,15 @@ func (opts WebSearchToolOptions) resolveProviderName(query string) (string, erro
|
|||||||
return providerName, nil
|
return providerName, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sogouReady := opts.providerReady("sogou")
|
||||||
|
duckReady := opts.providerReady("duckduckgo")
|
||||||
|
|
||||||
for _, name := range autoPrimaryWebSearchProviders {
|
for _, name := range autoPrimaryWebSearchProviders {
|
||||||
if opts.providerReady(name) {
|
if opts.providerReady(name) {
|
||||||
return name, nil
|
return name, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sogouReady := opts.providerReady("sogou")
|
|
||||||
duckReady := opts.providerReady("duckduckgo")
|
|
||||||
if sogouReady && duckReady {
|
if sogouReady && duckReady {
|
||||||
if prefersDuckDuckGoQuery(query) {
|
if prefersDuckDuckGoQuery(query) {
|
||||||
return "duckduckgo", nil
|
return "duckduckgo", nil
|
||||||
@@ -1279,6 +1398,24 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in
|
|||||||
proxy: opts.Proxy,
|
proxy: opts.Proxy,
|
||||||
client: client,
|
client: client,
|
||||||
}, maxResults, nil
|
}, 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":
|
case "searxng":
|
||||||
if !opts.providerReady("searxng") {
|
if !opts.providerReady("searxng") {
|
||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
|
|||||||
@@ -1853,6 +1853,191 @@ func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeSogou(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebTool_AutoProviderPrefersConfiguredProvidersBeforeGemini(t *testing.T) {
|
||||||
|
opts := 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err := ResolveWebSearchProviderName(opts, "best robotics companies")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
|
||||||
|
}
|
||||||
|
if name != "brave" {
|
||||||
|
t.Fatalf("provider = %q, want brave", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
name, err = ResolveWebSearchProviderName(opts, "今天上海天气")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveWebSearchProviderName() error: %v", err)
|
||||||
|
}
|
||||||
|
if name != "brave" {
|
||||||
|
t.Fatalf("provider = %q, want brave", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 TestGeminiSearchProvider_SearchSuccess(t *testing.T) {
|
||||||
|
provider := &GeminiSearchProvider{
|
||||||
|
apiKey: "google-key",
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
t.Fatalf("method = %s, want POST", req.Method)
|
||||||
|
}
|
||||||
|
if got := req.Header.Get("X-Goog-Api-Key"); got != "google-key" {
|
||||||
|
t.Fatalf("X-Goog-Api-Key = %q, want google-key", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(req.URL.String(), "/models/gemini-2.5-flash:generateContent") {
|
||||||
|
t.Fatalf("unexpected URL: %s", req.URL.String())
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(rec, `{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{"text": "Answer paragraph one."},
|
||||||
|
{"text": "Answer paragraph two."}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"groundingMetadata": {
|
||||||
|
"groundingChunks": [
|
||||||
|
{"web": {"uri": "https://example.com/a", "title": "Result A"}},
|
||||||
|
{"web": {"uri": "https://example.com/b", "title": "Result B"}},
|
||||||
|
{"web": {"uri": "https://example.com/c", "title": "Result C"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
return rec.Result(), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := provider.Search(context.Background(), "robotics", 2, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search() error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Results for: robotics (via Gemini Google Search)") {
|
||||||
|
t.Fatalf("missing header in output: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Answer paragraph one.") || !strings.Contains(out, "Answer paragraph two.") {
|
||||||
|
t.Fatalf("missing response text in output: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "1. Result A") || !strings.Contains(out, "2. Result B") {
|
||||||
|
t.Fatalf("missing citations in output: %s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "Result C") {
|
||||||
|
t.Fatalf("expected citations to be limited to count=2, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiSearchProvider_SearchIgnoresRange(t *testing.T) {
|
||||||
|
provider := &GeminiSearchProvider{
|
||||||
|
apiKey: "google-key",
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(rec, `{
|
||||||
|
"candidates": [
|
||||||
|
{
|
||||||
|
"content": {
|
||||||
|
"parts": [
|
||||||
|
{"text": "Recent robotics result."}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`)
|
||||||
|
return rec.Result(), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := provider.Search(context.Background(), "robotics", 2, "d")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search() error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Recent robotics result.") {
|
||||||
|
t.Fatalf("missing response text in output: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiSearchProvider_SearchAPIError(t *testing.T) {
|
||||||
|
provider := &GeminiSearchProvider{
|
||||||
|
apiKey: "google-key",
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
fmt.Fprint(rec, `{"error":"quota exceeded"}`)
|
||||||
|
return rec.Result(), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := provider.Search(context.Background(), "robotics", 2, "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "status 429") {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiSearchProvider_SearchEmptyCandidates(t *testing.T) {
|
||||||
|
provider := &GeminiSearchProvider{
|
||||||
|
apiKey: "google-key",
|
||||||
|
model: "gemini-2.5-flash",
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(rec, `{"candidates":[]}`)
|
||||||
|
return rec.Result(), nil
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := provider.Search(context.Background(), "robotics", 2, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Search() error: %v", err)
|
||||||
|
}
|
||||||
|
if out != "No results for: robotics" {
|
||||||
|
t.Fatalf("output = %q, want %q", out, "No results for: robotics")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) {
|
func TestWebTool_ExplicitProviderFallsBackWhenMissingCredentials(t *testing.T) {
|
||||||
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
Provider: "brave",
|
Provider: "brave",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type (
|
|||||||
TavilySearchProvider = integrationtools.TavilySearchProvider
|
TavilySearchProvider = integrationtools.TavilySearchProvider
|
||||||
SogouSearchProvider = integrationtools.SogouSearchProvider
|
SogouSearchProvider = integrationtools.SogouSearchProvider
|
||||||
DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider
|
DuckDuckGoSearchProvider = integrationtools.DuckDuckGoSearchProvider
|
||||||
|
GeminiSearchProvider = integrationtools.GeminiSearchProvider
|
||||||
PerplexitySearchProvider = integrationtools.PerplexitySearchProvider
|
PerplexitySearchProvider = integrationtools.PerplexitySearchProvider
|
||||||
SearXNGSearchProvider = integrationtools.SearXNGSearchProvider
|
SearXNGSearchProvider = integrationtools.SearXNGSearchProvider
|
||||||
GLMSearchProvider = integrationtools.GLMSearchProvider
|
GLMSearchProvider = integrationtools.GLMSearchProvider
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type webSearchProviderConfig struct {
|
|||||||
BaseURL string `json:"base_url,omitempty"`
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
APIKey string `json:"api_key,omitempty"`
|
APIKey string `json:"api_key,omitempty"`
|
||||||
APIKeys []string `json:"api_keys,omitempty"`
|
APIKeys []string `json:"api_keys,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
APIKeySet bool `json:"api_key_set,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.Enabled = settings.Enabled
|
||||||
cfg.Tools.Web.DuckDuckGo.MaxResults = settings.MaxResults
|
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 {
|
if settings, ok := req.Settings["brave"]; ok {
|
||||||
cfg.Tools.Web.Brave.Enabled = settings.Enabled
|
cfg.Tools.Web.Brave.Enabled = settings.Enabled
|
||||||
cfg.Tools.Web.Brave.MaxResults = settings.MaxResults
|
cfg.Tools.Web.Brave.MaxResults = settings.MaxResults
|
||||||
@@ -505,7 +514,7 @@ func normalizeWebSearchProvider(provider string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||||
case "", "auto":
|
case "", "auto":
|
||||||
return "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))
|
return strings.ToLower(strings.TrimSpace(provider))
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
@@ -549,6 +558,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
|||||||
Enabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
Enabled: cfg.Tools.Web.DuckDuckGo.Enabled,
|
||||||
MaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults,
|
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": {
|
"brave": {
|
||||||
Enabled: cfg.Tools.Web.Brave.Enabled,
|
Enabled: cfg.Tools.Web.Brave.Enabled,
|
||||||
MaxResults: cfg.Tools.Web.Brave.MaxResults,
|
MaxResults: cfg.Tools.Web.Brave.MaxResults,
|
||||||
@@ -604,6 +619,13 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse {
|
|||||||
Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"),
|
Configured: picotools.WebSearchProviderReady(opts, "duckduckgo"),
|
||||||
Current: current == "duckduckgo",
|
Current: current == "duckduckgo",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "gemini",
|
||||||
|
Label: "Gemini (Google Search)",
|
||||||
|
Configured: picotools.WebSearchProviderReady(opts, "gemini"),
|
||||||
|
Current: current == "gemini",
|
||||||
|
RequiresAuth: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "brave",
|
ID: "brave",
|
||||||
Label: "Brave Search",
|
Label: "Brave Search",
|
||||||
|
|||||||
@@ -540,7 +540,7 @@ func TestHandleUpdateWebSearchConfig_PreservesAndReplacesMultiKeys(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersBeforeSogou(t *testing.T) {
|
func TestResolveCurrentWebSearchProvider_PrefersConfiguredProvidersInAutoMode(t *testing.T) {
|
||||||
cfg := config.DefaultConfig()
|
cfg := config.DefaultConfig()
|
||||||
cfg.Tools.Web.Provider = "auto"
|
cfg.Tools.Web.Provider = "auto"
|
||||||
cfg.Tools.Web.Sogou.Enabled = true
|
cfg.Tools.Web.Sogou.Enabled = true
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface WebSearchProviderConfig {
|
|||||||
max_results: number
|
max_results: number
|
||||||
base_url?: string
|
base_url?: string
|
||||||
api_key?: string
|
api_key?: string
|
||||||
|
model?: string
|
||||||
api_key_set?: boolean
|
api_key_set?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,10 +30,13 @@ const apiKeyProviders = new Set([
|
|||||||
"brave",
|
"brave",
|
||||||
"tavily",
|
"tavily",
|
||||||
"perplexity",
|
"perplexity",
|
||||||
|
"gemini",
|
||||||
"glm_search",
|
"glm_search",
|
||||||
"baidu_search",
|
"baidu_search",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const modelProviders = new Set(["gemini"])
|
||||||
|
|
||||||
export function WebSearchProviderSettings({
|
export function WebSearchProviderSettings({
|
||||||
providerLabelMap,
|
providerLabelMap,
|
||||||
settings,
|
settings,
|
||||||
@@ -226,6 +229,27 @@ function ProviderCard({
|
|||||||
/>
|
/>
|
||||||
</ProviderField>
|
</ProviderField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{modelProviders.has(providerId) && (
|
||||||
|
<ProviderField
|
||||||
|
label={t("pages.agent.tools.web_search.model", "Model")}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
value={settings.model ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateSettings((current) => ({
|
||||||
|
...current,
|
||||||
|
model: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={t(
|
||||||
|
"pages.agent.tools.web_search.model_placeholder",
|
||||||
|
"Optional model override",
|
||||||
|
)}
|
||||||
|
className="bg-muted/40 hover:bg-muted/60 focus:bg-background focus:ring-primary/20 h-10 rounded-xl border-transparent shadow-none transition-colors"
|
||||||
|
/>
|
||||||
|
</ProviderField>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user