diff --git a/README.md b/README.md index b40da1744..697af4182 100644 --- a/README.md +++ b/README.md @@ -511,6 +511,7 @@ PicoClaw can search the web to provide up-to-date information. Configure in `too | [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 | +| [Kagi Search](https://help.kagi.com/kagi/api/search.html) | Required | Paid/limited by API setup | Premium search results | | [Perplexity](https://www.perplexity.ai) | Required | Paid | AI-powered search | | [SearXNG](https://github.com/searxng/searxng) | Not needed | Self-hosted | Free metasearch engine | | [GLM Search](https://open.bigmodel.cn/) | Required | Varies | Zhipu web search | diff --git a/config/config.example.json b/config/config.example.json index 28fb9fa8d..129b5bffd 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -329,6 +329,13 @@ "base_url": "", "max_results": 0 }, + "kagi": { + "enabled": false, + "api_key": "", + "api_keys": ["YOUR_KAGI_API_KEY"], + "base_url": "https://kagi.com/api/v1/search", + "max_results": 5 + }, "provider": "auto", "sogou": { "enabled": true, diff --git a/docs/reference/tools_configuration.md b/docs/reference/tools_configuration.md index d92f58476..602fcb138 100644 --- a/docs/reference/tools_configuration.md +++ b/docs/reference/tools_configuration.md @@ -126,6 +126,44 @@ Baidu Search uses the [Qianfan AI Search API](https://cloud.baidu.com/doc/qianfa | `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | | `max_results` | int | 5 | Maximum number of results | +### Kagi Search + +Kagi Search uses the official Kagi OpenAPI client for `POST /search` and returns normal web results from `data.search`. + +| Config | Type | Default | Description | +|---------------|----------|---------------------------------------|------------------------------------------------| +| `enabled` | bool | false | Enable Kagi Search | +| `api_key` | string | - | Kagi API key | +| `api_keys` | string[] | - | Multiple API keys for rotation (takes priority over `api_key`) | +| `base_url` | string | `https://kagi.com/api/v1/search` | Kagi Search API endpoint | +| `max_results` | int | 5 | Maximum number of results | + +```json +{ + "tools": { + "web": { + "provider": "kagi", + "kagi": { + "enabled": true, + "max_results": 5, + "base_url": "https://kagi.com/api/v1/search" + } + } + } +} +``` + +Store Kagi API keys in `.security.yml`: + +```yaml +web: + kagi: + api_keys: + - "YOUR_KAGI_API_KEY" +``` + +Kagi API usage may be billed or limited separately from a normal Kagi subscription, depending on your account and API setup. + ### Tavily | Config | Type | Default | Description | @@ -171,6 +209,7 @@ At runtime, the `web_search` tool accepts the following parameters: | `range` | string | no | Optional time filter: `d` (day), `w` (week), `m` (month), `y` (year) | If `range` is omitted, PicoClaw performs an unrestricted search. +For Kagi, `d`, `w`, and `m` map to Kagi lens `time_relative`; `y` maps to a lens `time_after` date one year before the current day. ### Example `web_search` Call diff --git a/docs/security/security_configuration.md b/docs/security/security_configuration.md index 5edf5c3f6..4b5e4fe61 100644 --- a/docs/security/security_configuration.md +++ b/docs/security/security_configuration.md @@ -248,13 +248,16 @@ channel_list: ### Web Tools -**Brave, Tavily, Perplexity:** +**Brave, Tavily, Perplexity, Kagi:** ```yaml web: brave: api_keys: - "key-1" - "key-2" + kagi: + api_keys: + - "your-kagi-api-key" ``` - Use `api_keys` (plural) array format @@ -315,16 +318,19 @@ model_list: - **Rate limit management**: Distribute usage across multiple keys - **High availability**: Reduce downtime during API provider issues -### Web Tools (Brave/Tavily/Perplexity) - Single key +### Web Tools (Brave/Tavily/Perplexity/Kagi) - Single key ```yaml web: brave: api_keys: - "BSA-your-key" + kagi: + api_keys: + - "your-kagi-api-key" ``` -### Web Tools (Brave/Tavily/Perplexity) - Multiple keys +### Web Tools (Brave/Tavily/Perplexity/Kagi) - Multiple keys ```yaml web: @@ -332,6 +338,10 @@ web: api_keys: - "BSA-key-1" - "BSA-key-2" + kagi: + api_keys: + - "kagi-key-1" + - "kagi-key-2" ``` ### Web Tool (GLMSearch/BaiduSearch) - Single key only @@ -558,7 +568,7 @@ go test ./pkg/config -run TestSecurityConfig - Ensure you're using `api_keys` (plural) in `.security.yml` for models and web tools (except GLMSearch/BaiduSearch) - Check that the array format is correct in YAML (proper indentation with dashes) -- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) +- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format) - GLMSearch and BaiduSearch MUST use `api_key` (single string format) ### Load Balancing/Failover Issues diff --git a/go.mod b/go.mod index 45e5535e9..74979e6a5 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/h2non/filetype v1.1.3 + github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 github.com/larksuite/oapi-sdk-go/v3 v3.9.4 github.com/line/line-bot-sdk-go/v8 v8.20.0 github.com/mdp/qrterminal/v3 v3.2.1 diff --git a/go.sum b/go.sum index eb5bfb4b1..4f7540a27 100644 --- a/go.sum +++ b/go.sum @@ -183,6 +183,8 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62 h1:nyUi7Wel3KlVSa5ArgX/snlizqfaxU48qtvXS/JK5GE= +github.com/kagisearch/kagi-openapi-golang v0.0.0-20260526215348-96575e864d62/go.mod h1:vONkS+clG730HSKOw3nZVa22TjB21r6csKYzYt0a9zI= github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= diff --git a/pkg/config/config.go b/pkg/config/config.go index bb3b446d2..c71217e5b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -874,6 +874,31 @@ func (c *TavilyConfig) SetAPIKeys(keys []string) { } } +type KagiConfig struct { + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_ENABLED"` + APIKeys SecureStrings `json:"api_keys,omitzero" yaml:"api_keys,omitempty" env:"PICOCLAW_TOOLS_WEB_KAGI_API_KEYS"` + BaseURL string `json:"base_url" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_BASE_URL"` + MaxResults int `json:"max_results" yaml:"-" env:"PICOCLAW_TOOLS_WEB_KAGI_MAX_RESULTS"` +} + +// APIKey returns the Kagi API key +func (c *KagiConfig) APIKey() string { + if len(c.APIKeys) == 0 { + return "" + } + return c.APIKeys[0].String() +} + +// SetAPIKey sets the Kagi API key +func (c *KagiConfig) SetAPIKey(key string) { + c.APIKeys = SimpleSecureStrings(key) +} + +// SetAPIKeys sets the Kagi API keys +func (c *KagiConfig) SetAPIKeys(keys []string) { + c.APIKeys = SimpleSecureStrings(keys...) +} + type DuckDuckGoConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_ENABLED"` MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` @@ -937,6 +962,7 @@ type WebToolsConfig struct { ToolConfig ` yaml:"-" envPrefix:"PICOCLAW_TOOLS_WEB_"` Brave BraveConfig `yaml:"brave,omitempty" json:"brave"` Tavily TavilyConfig `yaml:"tavily,omitempty" json:"tavily"` + Kagi KagiConfig `yaml:"kagi,omitempty" json:"kagi"` Sogou SogouConfig `yaml:"-" json:"sogou"` DuckDuckGo DuckDuckGoConfig `yaml:"-" json:"duckduckgo"` Gemini GeminiSearchConfig `yaml:"gemini,omitempty" json:"gemini"` diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index d06463719..a7f9c759c 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -333,6 +333,11 @@ func DefaultConfig() *Config { Enabled: false, MaxResults: 5, }, + Kagi: KagiConfig{ + Enabled: false, + BaseURL: "https://kagi.com/api/v1/search", + MaxResults: 5, + }, Sogou: SogouConfig{ Enabled: true, MaxResults: 5, diff --git a/pkg/config/example_security_usage.go b/pkg/config/example_security_usage.go index 42a1831b0..de80f541a 100644 --- a/pkg/config/example_security_usage.go +++ b/pkg/config/example_security_usage.go @@ -51,7 +51,7 @@ channels: token: "your-discord-bot-token" # Web Tool Keys -# Brave, Tavily, Perplexity: Use 'api_keys' array +# Brave, Tavily, Perplexity, Kagi: Use 'api_keys' array # GLMSearch, BaiduSearch: Use 'api_key' single string web: @@ -65,6 +65,9 @@ web: perplexity: api_keys: - "pplx-your-perplexity-api-key" # Single key in array format + kagi: + api_keys: + - "your-kagi-api-key" # Single key in array format glm_search: api_key: "your-glm-search-api-key" # Single key (not array) baidu_search: @@ -239,7 +242,7 @@ channels: ## Web Tool API Keys -**Brave, Tavily, Perplexity:** +**Brave, Tavily, Perplexity, Kagi:** ```yaml web: @@ -253,6 +256,9 @@ web: perplexity: api_keys: - "pplx-key" + kagi: + api_keys: + - "kagi-key" ``` Use `api_keys` (plural) array format. @@ -443,7 +449,7 @@ web: ## Single Key Format -**Models, Brave, Tavily, Perplexity:** +**Models, Brave, Tavily, Perplexity, Kagi:** ```yaml model_list: @@ -565,7 +571,7 @@ and .security.yml values. ## Multiple API Keys Not Working - Ensure you're using `api_keys` (plural) in .security.yml for models and web tools (except GLMSearch/BaiduSearch) - Check that the array format is correct in YAML (proper indentation with dashes) -- Remember: Models, Brave, Tavily, Perplexity MUST use `api_keys` (array format) +- Remember: Models, Brave, Tavily, Perplexity, Kagi MUST use `api_keys` (array format) - GLMSearch and BaiduSearch MUST use `api_key` (single string format) ## Keys Not Being Applied diff --git a/pkg/config/security_integration_test.go b/pkg/config/security_integration_test.go index 8fc2f167c..cac769090 100644 --- a/pkg/config/security_integration_test.go +++ b/pkg/config/security_integration_test.go @@ -191,6 +191,10 @@ func TestAllSecurityKeysAccessible(t *testing.T) { err = os.WriteFile(perplexityAPIKeyFile, []byte("pplx-perplexity-from-file-22222"), 0o600) require.NoError(t, err) + kagiAPIKeyFile := filepath.Join(tmpDir, "kagi_api_key.txt") + err = os.WriteFile(kagiAPIKeyFile, []byte("kagi-from-file-33333"), 0o600) + require.NoError(t, err) + githubTokenFile := filepath.Join(tmpDir, "github_token.txt") err = os.WriteFile(githubTokenFile, []byte("ghp-github-from-file-abc123"), 0o600) require.NoError(t, err) @@ -270,6 +274,9 @@ func TestAllSecurityKeysAccessible(t *testing.T) { "perplexity": { "enabled": true }, + "kagi": { + "enabled": true + }, "glm_search": { "enabled": true } @@ -331,6 +338,9 @@ web: perplexity: api_keys: - "file://perplexity_api_key.txt" + kagi: + api_keys: + - "file://kagi_api_key.txt" glm_search: api_key: "glm-test-glm-search-key" @@ -456,6 +466,9 @@ skills: assert.Equal(t, "pplx-perplexity-from-file-22222", cfg.Tools.Web.Perplexity.APIKey()) t.Logf("Perplexity APIKey(): %s", cfg.Tools.Web.Perplexity.APIKey()) + assert.Equal(t, "kagi-from-file-33333", cfg.Tools.Web.Kagi.APIKey()) + t.Logf("Kagi APIKey(): %s", cfg.Tools.Web.Kagi.APIKey()) + // GLM Search - Note: GLM uses SetAPIKey (lowercase) internally t.Logf("GLMSearch APIKey(): %s", cfg.Tools.Web.GLMSearch.APIKey.String()) assert.Equal(t, "glm-test-glm-search-key", cfg.Tools.Web.GLMSearch.APIKey.String()) diff --git a/pkg/tools/integration/web.go b/pkg/tools/integration/web.go index 0568ba8dc..4a50d3e55 100644 --- a/pkg/tools/integration/web.go +++ b/pkg/tools/integration/web.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "html" "io" "mime" "net" @@ -17,6 +18,8 @@ import ( "time" "unicode" + kagiopenapi "github.com/kagisearch/kagi-openapi-golang" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" @@ -101,9 +104,10 @@ type SearchProvider interface { } type SearchResultItem struct { - Title string - URL string - Snippet string + Title string + URL string + Snippet string + Published string } func extractSogouURL(href string) string { @@ -248,6 +252,23 @@ func mapBaiduRecencyFilter(rangeCode string) string { } } +func mapKagiLensTimeFilter(rangeCode string, now time.Time) *kagiopenapi.SearchRequestLens { + lens := kagiopenapi.NewSearchRequestLens() + switch rangeCode { + case "d": + lens.SetTimeRelative("day") + case "w": + lens.SetTimeRelative("week") + case "m": + lens.SetTimeRelative("month") + case "y": + lens.SetTimeAfter(now.AddDate(-1, 0, 0).Format("2006-01-02")) + default: + return nil + } + return lens +} + type BraveSearchProvider struct { keyPool *APIKeyPool proxy string @@ -467,6 +488,269 @@ func (p *TavilySearchProvider) Search( return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) } +type KagiSearchProvider struct { + keyPool *APIKeyPool + baseURL string + proxy string + client *http.Client +} + +func (p *KagiSearchProvider) Search( + ctx context.Context, + query string, + count int, + rangeCode string, +) (string, error) { + if p.keyPool == nil || len(p.keyPool.keys) == 0 { + return "", errors.New("no API key provided") + } + + client := p.client + if client == nil { + client = &http.Client{Timeout: searchTimeout} + } + + apiClient := newKagiAPIClient(client, p.baseURL) + searchReq := kagiopenapi.NewSearchRequest(query) + searchReq.SetLimit(int32(count)) + if lens := mapKagiLensTimeFilter(rangeCode, time.Now().UTC()); lens != nil { + searchReq.SetLens(*lens) + } + + var lastErr error + iter := p.keyPool.NewIterator() + + for { + apiKey, ok := iter.Next() + if !ok { + break + } + + authCtx := context.WithValue(ctx, kagiopenapi.ContextAccessToken, apiKey) + searchResp, httpResp, err := apiClient.SearchAPI.Search(authCtx).SearchRequest(*searchReq).Execute() + if httpResp != nil && httpResp.Body != nil { + defer httpResp.Body.Close() + } + if err != nil { + if httpResp != nil { + if httpResp.StatusCode >= 200 && httpResp.StatusCode < 300 { + results, parseErr := fallbackKagiSearchResults(httpResp, count) + if parseErr != nil { + return "", parseErr + } + return formatKagiSearchResults(query, results), nil + } + lastErr = kagiStatusError(httpResp.StatusCode) + if httpResp.StatusCode == http.StatusTooManyRequests || + httpResp.StatusCode == http.StatusUnauthorized || + httpResp.StatusCode == http.StatusForbidden || + httpResp.StatusCode >= 500 { + continue + } + return "", lastErr + } + lastErr = fmt.Errorf("request failed: %w", err) + continue + } + + results := kagiSearchResults(searchResp, count) + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + return formatKagiSearchResults(query, results), nil + } + + return "", fmt.Errorf("all api keys failed, last error: %w", lastErr) +} + +func newKagiAPIClient(client *http.Client, baseURL string) *kagiopenapi.APIClient { + cfg := kagiopenapi.NewConfiguration() + cfg.UserAgent = fmt.Sprintf(userAgentHonest, config.Version) + cfg.HTTPClient = client + cfg.Servers = kagiopenapi.ServerConfigurations{{ + URL: kagiServerURL(baseURL), + Description: "Kagi Search API endpoint", + }} + return kagiopenapi.NewAPIClient(cfg) +} + +func formatKagiSearchResults(query string, results []SearchResultItem) string { + if len(results) == 0 { + return fmt.Sprintf("No results for: %s", query) + } + lines := []string{fmt.Sprintf("Results for: %s (via Kagi)", query)} + for i, item := range results { + title := item.Title + if title == "" { + title = item.URL + } + lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, title, item.URL)) + if item.Published != "" { + lines = append(lines, fmt.Sprintf(" Published: %s", item.Published)) + } + if item.Snippet != "" { + lines = append(lines, fmt.Sprintf(" %s", item.Snippet)) + } + } + return strings.Join(lines, "\n") +} + +func kagiServerURL(baseURL string) string { + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return "https://kagi.com/api/v1" + } + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return strings.TrimSuffix(baseURL, "/") + } + parsed.RawQuery = "" + parsed.Fragment = "" + parsed.Path = strings.TrimRight(parsed.Path, "/") + if strings.HasSuffix(parsed.Path, "/search") { + parsed.Path = strings.TrimSuffix(parsed.Path, "/search") + } + if parsed.Path == "" { + parsed.Path = "/" + } + return strings.TrimRight(parsed.String(), "/") +} + +func kagiStatusError(statusCode int) error { + switch statusCode { + case http.StatusUnauthorized: + return fmt.Errorf("Kagi Search API authentication failed (status %d)", statusCode) + case http.StatusForbidden: + return fmt.Errorf("Kagi Search API request forbidden (status %d)", statusCode) + case http.StatusTooManyRequests: + return fmt.Errorf("Kagi Search API rate limited (status %d)", statusCode) + default: + if statusCode >= 500 { + return fmt.Errorf("Kagi Search API server error (status %d)", statusCode) + } + return fmt.Errorf("Kagi Search API error (status %d)", statusCode) + } +} + +type kagiFallbackResult struct { + Type int `json:"t"` + URL string `json:"url"` + Title string `json:"title"` + Snippet string `json:"snippet"` + Time string `json:"time"` + Published string `json:"published"` +} + +func fallbackKagiSearchResults(resp *http.Response, count int) ([]SearchResultItem, error) { + if resp == nil || resp.Body == nil { + return nil, fmt.Errorf("failed to parse response: empty response body") + } + body, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20)) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + return parseFallbackKagiSearchResults(body, count) +} + +func parseFallbackKagiSearchResults(body []byte, count int) ([]SearchResultItem, error) { + if count <= 0 { + count = 10 + } + var envelope struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + data := bytes.TrimSpace(envelope.Data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + return nil, nil + } + + results := make([]SearchResultItem, 0, count) + switch data[0] { + case '{': + var modern struct { + Search []kagiFallbackResult `json:"search"` + } + if err := json.Unmarshal(data, &modern); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + appendFallbackKagiResults(&results, modern.Search, count, false) + case '[': + var legacy []kagiFallbackResult + if err := json.Unmarshal(data, &legacy); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + appendFallbackKagiResults(&results, legacy, count, true) + default: + return nil, fmt.Errorf("failed to parse response: unexpected data shape") + } + return results, nil +} + +func appendFallbackKagiResults( + results *[]SearchResultItem, + items []kagiFallbackResult, + count int, + requireLegacyType bool, +) { + for _, item := range items { + if len(*results) >= count { + return + } + if requireLegacyType && item.Type != 0 { + continue + } + urlStr := strings.TrimSpace(item.URL) + if urlStr == "" { + continue + } + published := strings.TrimSpace(item.Published) + if published == "" { + published = strings.TrimSpace(item.Time) + } + *results = append(*results, SearchResultItem{ + Title: cleanSearchText(item.Title), + URL: urlStr, + Snippet: cleanSearchText(item.Snippet), + Published: published, + }) + } +} + +func kagiSearchResults(searchResp *kagiopenapi.Search200Response, count int) []SearchResultItem { + if count <= 0 { + count = 10 + } + if searchResp == nil || searchResp.Data == nil { + return nil + } + + results := make([]SearchResultItem, 0, count) + for _, item := range searchResp.Data.Search { + if len(results) >= count { + break + } + urlStr := strings.TrimSpace(item.GetUrl()) + if urlStr == "" { + continue + } + results = append(results, SearchResultItem{ + Title: cleanSearchText(item.GetTitle()), + URL: urlStr, + Snippet: cleanSearchText(item.GetSnippet()), + Published: strings.TrimSpace(item.GetTime()), + }) + } + return results +} + +func cleanSearchText(content string) string { + return strings.TrimSpace(html.UnescapeString(stripTags(content))) +} + type SogouSearchProvider struct { proxy string client *http.Client @@ -1175,6 +1459,10 @@ type WebSearchToolOptions struct { TavilyBaseURL string TavilyMaxResults int TavilyEnabled bool + KagiAPIKeys []string + KagiBaseURL string + KagiMaxResults int + KagiEnabled bool SogouMaxResults int SogouEnabled bool DuckDuckGoMaxResults int @@ -1211,6 +1499,10 @@ func WebSearchToolOptionsFromConfig(cfg *config.Config) WebSearchToolOptions { TavilyBaseURL: cfg.Tools.Web.Tavily.BaseURL, TavilyMaxResults: cfg.Tools.Web.Tavily.MaxResults, TavilyEnabled: cfg.Tools.Web.Tavily.Enabled, + KagiAPIKeys: cfg.Tools.Web.Kagi.APIKeys.Values(), + KagiBaseURL: cfg.Tools.Web.Kagi.BaseURL, + KagiMaxResults: cfg.Tools.Web.Kagi.MaxResults, + KagiEnabled: cfg.Tools.Web.Kagi.Enabled, SogouMaxResults: cfg.Tools.Web.Sogou.MaxResults, SogouEnabled: cfg.Tools.Web.Sogou.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, @@ -1253,12 +1545,13 @@ var ( "gemini", "brave", "tavily", + "kagi", "perplexity", "searxng", "glm_search", "baidu_search", } - autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "searxng", "tavily", "gemini"} + autoPrimaryWebSearchProviders = []string{"perplexity", "brave", "kagi", "searxng", "tavily", "gemini"} autoFallbackWebSearchProviders = []string{"baidu_search", "glm_search"} ) @@ -1284,6 +1577,8 @@ func (opts WebSearchToolOptions) providerReady(name string) bool { return opts.BraveEnabled && len(opts.BraveAPIKeys) > 0 case "tavily": return opts.TavilyEnabled && len(opts.TavilyAPIKeys) > 0 + case "kagi": + return opts.KagiEnabled && len(opts.KagiAPIKeys) > 0 case "perplexity": return opts.PerplexityEnabled && len(opts.PerplexityAPIKeys) > 0 case "searxng": @@ -1451,6 +1746,24 @@ func (opts WebSearchToolOptions) providerByName(name string) (SearchProvider, in proxy: opts.Proxy, client: client, }, maxResults, nil + case "kagi": + if !opts.providerReady("kagi") { + 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 Kagi: %w", err) + } + maxResults := 10 + if opts.KagiMaxResults > 0 { + maxResults = min(opts.KagiMaxResults, 10) + } + return &KagiSearchProvider{ + keyPool: NewAPIKeyPool(opts.KagiAPIKeys), + baseURL: opts.KagiBaseURL, + proxy: opts.Proxy, + client: client, + }, maxResults, nil case "duckduckgo": if !opts.providerReady("duckduckgo") { return nil, 0, nil diff --git a/pkg/tools/integration/web_test.go b/pkg/tools/integration/web_test.go index 436413085..c06a37254 100644 --- a/pkg/tools/integration/web_test.go +++ b/pkg/tools/integration/web_test.go @@ -1259,6 +1259,212 @@ func TestWebTool_TavilySearch_RangeMapping(t *testing.T) { } } +func TestWebTool_KagiSearch_SuccessRequestAndParsing(t *testing.T) { + var sawRequest bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sawRequest = true + if r.Method != http.MethodPost { + t.Fatalf("method = %s, want POST", r.Method) + } + if r.URL.Path != "/search" { + t.Fatalf("path = %s, want /search", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Fatalf("Authorization = %q, want Bearer test-key", got) + } + + var payload map[string]any + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode payload: %v", err) + } + if payload["query"] != "test query" { + t.Fatalf("query = %v, want test query", payload["query"]) + } + if payload["workflow"] != "search" { + t.Fatalf("workflow = %v, want search", payload["workflow"]) + } + if payload["limit"] != float64(2) { + t.Fatalf("limit = %v, want 2", payload["limit"]) + } + lens, ok := payload["lens"].(map[string]any) + if !ok || lens["time_relative"] != "week" { + t.Fatalf("lens = %v, want time_relative=week", payload["lens"]) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "data": { + "search": [ + { + "title": "Kagi Result 1", + "url": "https://example.com/1", + "snippet": "first snippet", + "time": "2026-01-02T03:04:05Z" + }, + { + "title": "Kagi Result 2", + "url": "https://example.com/2", + "snippet": "second snippet" + }, + { + "title": "Kagi Result 3", + "url": "https://example.com/3", + "snippet": "third snippet" + } + ] + } + }`)) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + KagiEnabled: true, + KagiAPIKeys: []string{"test-key"}, + KagiBaseURL: server.URL, + KagiMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{ + "query": "test query", + "count": float64(2), + "range": "w", + }) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } + if !sawRequest { + t.Fatal("server did not receive request") + } + if !strings.Contains(result.ForUser, "via Kagi") || + !strings.Contains(result.ForUser, "Kagi Result 1") || + !strings.Contains(result.ForUser, "https://example.com/1") || + !strings.Contains(result.ForUser, "first snippet") || + !strings.Contains(result.ForUser, "Published: 2026-01-02T03:04:05Z") { + t.Fatalf("expected Kagi result fields in output, got: %s", result.ForUser) + } + if strings.Contains(result.ForUser, "Kagi Result 3") { + t.Fatalf("expected output truncated to requested count, got: %s", result.ForUser) + } +} + +func TestWebTool_KagiSearch_NoApiKey(t *testing.T) { + tool, err := NewWebSearchTool(WebSearchToolOptions{ + KagiEnabled: true, + KagiMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + if tool != nil { + t.Fatal("expected nil tool when Kagi is enabled without API keys") + } +} + +func TestWebTool_KagiSearch_AuthErrorDoesNotLeakKey(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer invalid-kagi-key" { + t.Fatalf("Authorization = %q, want Bearer invalid-kagi-key", got) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + KagiEnabled: true, + KagiAPIKeys: []string{"invalid-kagi-key"}, + KagiBaseURL: server.URL + "/search", + KagiMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"query": "test query"}) + if !result.IsError { + t.Fatal("expected auth error") + } + if !strings.Contains(result.ForLLM, "authentication failed") { + t.Fatalf("unexpected error message: %s", result.ForLLM) + } + if strings.Contains(result.ForLLM, "invalid-kagi-key") || strings.Contains(result.ForUser, "invalid-kagi-key") { + t.Fatalf("error leaked API key: %+v", result) + } +} + +func TestWebTool_KagiSearch_Non200Response(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error":"temporary failure"}`)) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + KagiEnabled: true, + KagiAPIKeys: []string{"test-key"}, + KagiBaseURL: server.URL, + KagiMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"query": "test query"}) + if !result.IsError { + t.Fatal("expected non-200 error") + } + if !strings.Contains(result.ForLLM, "server error (status 500)") { + t.Fatalf("unexpected error message: %s", result.ForLLM) + } +} + +func TestWebTool_KagiSearch_SkipsUnsupportedAndMalformedResults(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{ + "data": { + "image": [ + {"title": "Image Result", "url": "https://images.example.com/1"} + ], + "search": [ + {"title": "Missing URL"}, + {"title": "Usable Result", "url": "https://example.com/usable", "snippet": "usable snippet", "extra": "ignored"} + ] + } + }`)) + })) + defer server.Close() + + tool, err := NewWebSearchTool(WebSearchToolOptions{ + KagiEnabled: true, + KagiAPIKeys: []string{"test-key"}, + KagiBaseURL: server.URL, + KagiMaxResults: 5, + }) + if err != nil { + t.Fatalf("NewWebSearchTool() error: %v", err) + } + + result := tool.Execute(context.Background(), map[string]any{"query": "test query"}) + if result.IsError { + t.Fatalf("expected success, got %s", result.ForLLM) + } + if !strings.Contains(result.ForUser, "Usable Result") || + !strings.Contains(result.ForUser, "https://example.com/usable") { + t.Fatalf("expected usable result, got: %s", result.ForUser) + } + if strings.Contains(result.ForUser, "Image Result") || strings.Contains(result.ForUser, "Missing URL") { + t.Fatalf("unsupported or malformed result was included: %s", result.ForUser) + } +} + // TestWebFetchTool_CloudflareChallenge_RetryWithHonestUA verifies that a 403 response // with cf-mitigated: challenge triggers a retry using the honest picoclaw User-Agent, // and that the retry response is returned when it succeeds. diff --git a/web/backend/api/tools.go b/web/backend/api/tools.go index c6a9d4254..f75192598 100644 --- a/web/backend/api/tools.go +++ b/web/backend/api/tools.go @@ -470,6 +470,14 @@ func (h *Handler) handleUpdateWebSearchConfig(w http.ResponseWriter, r *http.Req cfg.Tools.Web.Tavily.SetAPIKeys(keys) } } + if settings, ok := req.Settings["kagi"]; ok { + cfg.Tools.Web.Kagi.Enabled = settings.Enabled + cfg.Tools.Web.Kagi.MaxResults = settings.MaxResults + cfg.Tools.Web.Kagi.BaseURL = strings.TrimSpace(settings.BaseURL) + if keys, ok := normalizeWebSearchAPIKeys(settings.APIKeys, settings.APIKey); ok { + cfg.Tools.Web.Kagi.SetAPIKeys(keys) + } + } if settings, ok := req.Settings["perplexity"]; ok { cfg.Tools.Web.Perplexity.Enabled = settings.Enabled cfg.Tools.Web.Perplexity.MaxResults = settings.MaxResults @@ -514,7 +522,16 @@ func normalizeWebSearchProvider(provider string) string { switch strings.ToLower(strings.TrimSpace(provider)) { case "", "auto": return "auto" - case "sogou", "brave", "tavily", "duckduckgo", "gemini", "perplexity", "searxng", "glm_search", "baidu_search": + case "sogou", + "brave", + "tavily", + "kagi", + "duckduckgo", + "gemini", + "perplexity", + "searxng", + "glm_search", + "baidu_search": return strings.ToLower(strings.TrimSpace(provider)) default: return "" @@ -575,6 +592,12 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { BaseURL: cfg.Tools.Web.Tavily.BaseURL, APIKeySet: len(cfg.Tools.Web.Tavily.APIKeys.Values()) > 0, }, + "kagi": { + Enabled: cfg.Tools.Web.Kagi.Enabled, + MaxResults: cfg.Tools.Web.Kagi.MaxResults, + BaseURL: cfg.Tools.Web.Kagi.BaseURL, + APIKeySet: len(cfg.Tools.Web.Kagi.APIKeys.Values()) > 0, + }, "perplexity": { Enabled: cfg.Tools.Web.Perplexity.Enabled, MaxResults: cfg.Tools.Web.Perplexity.MaxResults, @@ -640,6 +663,13 @@ func buildWebSearchConfigResponse(cfg *config.Config) webSearchConfigResponse { Current: current == "tavily", RequiresAuth: true, }, + { + ID: "kagi", + Label: "Kagi Search", + Configured: picotools.WebSearchProviderReady(opts, "kagi"), + Current: current == "kagi", + RequiresAuth: true, + }, { ID: "perplexity", Label: "Perplexity", diff --git a/web/frontend/src/api/tools.ts b/web/frontend/src/api/tools.ts index c7501b188..298b42d31 100644 --- a/web/frontend/src/api/tools.ts +++ b/web/frontend/src/api/tools.ts @@ -30,6 +30,7 @@ export interface WebSearchProviderConfig { max_results: number base_url?: string api_key?: string + api_keys?: string[] model?: string api_key_set?: boolean } diff --git a/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx index 8dcec359b..23bad4ad8 100644 --- a/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx +++ b/web/frontend/src/components/agent/tools/web-search-provider-settings.tsx @@ -21,6 +21,7 @@ interface WebSearchProviderSettingsProps { const baseUrlProviders = new Set([ "tavily", + "kagi", "searxng", "glm_search", "baidu_search", @@ -29,6 +30,7 @@ const baseUrlProviders = new Set([ const apiKeyProviders = new Set([ "brave", "tavily", + "kagi", "perplexity", "gemini", "glm_search",