mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
Add native Kagi web search provider
This commit is contained in:
@@ -509,6 +509,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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": "<b>first</b> 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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user