feat(tools): add GLM Search (智谱) web search provider (#1057)
* feat(config): add GLMSearchConfig for GLM Search provider Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test(tools): add failing tests for GLM Search provider Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(tools): add GLMSearchProvider for web search Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(agent): wire GLM Search config into web search tool registration Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
df1b53fdf9
commit
b82bb9acc0
5 changed files with 262 additions and 1 deletions
|
|
@ -118,6 +118,11 @@ func registerSharedTools(
|
||||||
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
|
||||||
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
|
||||||
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
|
||||||
|
GLMSearchAPIKey: cfg.Tools.Web.GLMSearch.APIKey,
|
||||||
|
GLMSearchBaseURL: cfg.Tools.Web.GLMSearch.BaseURL,
|
||||||
|
GLMSearchEngine: cfg.Tools.Web.GLMSearch.SearchEngine,
|
||||||
|
GLMSearchMaxResults: cfg.Tools.Web.GLMSearch.MaxResults,
|
||||||
|
GLMSearchEnabled: cfg.Tools.Web.GLMSearch.Enabled,
|
||||||
Proxy: cfg.Tools.Web.Proxy,
|
Proxy: cfg.Tools.Web.Proxy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -547,11 +547,22 @@ type PerplexityConfig struct {
|
||||||
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GLMSearchConfig struct {
|
||||||
|
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_GLM_ENABLED"`
|
||||||
|
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_GLM_API_KEY"`
|
||||||
|
BaseURL string `json:"base_url" env:"PICOCLAW_TOOLS_WEB_GLM_BASE_URL"`
|
||||||
|
// SearchEngine specifies the search backend: "search_std" (default),
|
||||||
|
// "search_pro", "search_pro_sogou", or "search_pro_quark".
|
||||||
|
SearchEngine string `json:"search_engine" env:"PICOCLAW_TOOLS_WEB_GLM_SEARCH_ENGINE"`
|
||||||
|
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_GLM_MAX_RESULTS"`
|
||||||
|
}
|
||||||
|
|
||||||
type WebToolsConfig struct {
|
type WebToolsConfig struct {
|
||||||
Brave BraveConfig `json:"brave"`
|
Brave BraveConfig `json:"brave"`
|
||||||
Tavily TavilyConfig `json:"tavily"`
|
Tavily TavilyConfig `json:"tavily"`
|
||||||
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
|
||||||
Perplexity PerplexityConfig `json:"perplexity"`
|
Perplexity PerplexityConfig `json:"perplexity"`
|
||||||
|
GLMSearch GLMSearchConfig `json:"glm_search"`
|
||||||
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
// Proxy is an optional proxy URL for web tools (http/https/socks5/socks5h).
|
||||||
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
// For authenticated proxies, prefer HTTP_PROXY/HTTPS_PROXY env vars instead of embedding credentials in config.
|
||||||
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_TOOLS_WEB_PROXY"`
|
||||||
|
|
|
||||||
|
|
@ -343,6 +343,13 @@ func DefaultConfig() *Config {
|
||||||
APIKey: "",
|
APIKey: "",
|
||||||
MaxResults: 5,
|
MaxResults: 5,
|
||||||
},
|
},
|
||||||
|
GLMSearch: GLMSearchConfig{
|
||||||
|
Enabled: false,
|
||||||
|
APIKey: "",
|
||||||
|
BaseURL: "https://open.bigmodel.cn/api/paas/v4/web_search",
|
||||||
|
SearchEngine: "search_std",
|
||||||
|
MaxResults: 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Cron: CronToolsConfig{
|
Cron: CronToolsConfig{
|
||||||
ExecTimeoutMinutes: 5,
|
ExecTimeoutMinutes: 5,
|
||||||
|
|
|
||||||
108
pkg/tools/web.go
108
pkg/tools/web.go
|
|
@ -395,6 +395,88 @@ func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, cou
|
||||||
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GLMSearchProvider struct {
|
||||||
|
apiKey string
|
||||||
|
baseURL string
|
||||||
|
searchEngine string
|
||||||
|
proxy string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GLMSearchProvider) Search(ctx context.Context, query string, count int) (string, error) {
|
||||||
|
searchURL := p.baseURL
|
||||||
|
if searchURL == "" {
|
||||||
|
searchURL = "https://open.bigmodel.cn/api/paas/v4/web_search"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"search_query": query,
|
||||||
|
"search_engine": p.searchEngine,
|
||||||
|
"search_intent": false,
|
||||||
|
"count": count,
|
||||||
|
"content_size": "medium",
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "POST", searchURL, bytes.NewReader(bodyBytes))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+p.apiKey)
|
||||||
|
|
||||||
|
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, 1<<20))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("GLM Search API error (status %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchResp struct {
|
||||||
|
SearchResult []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Link string `json:"link"`
|
||||||
|
} `json:"search_result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &searchResp); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := searchResp.SearchResult
|
||||||
|
if len(results) == 0 {
|
||||||
|
return fmt.Sprintf("No results for: %s", query), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
lines = append(lines, fmt.Sprintf("Results for: %s (via GLM Search)", query))
|
||||||
|
for i, item := range results {
|
||||||
|
if i >= count {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%d. %s\n %s", i+1, item.Title, item.Link))
|
||||||
|
if item.Content != "" {
|
||||||
|
lines = append(lines, fmt.Sprintf(" %s", item.Content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
type WebSearchTool struct {
|
type WebSearchTool struct {
|
||||||
provider SearchProvider
|
provider SearchProvider
|
||||||
maxResults int
|
maxResults int
|
||||||
|
|
@ -413,6 +495,11 @@ type WebSearchToolOptions struct {
|
||||||
PerplexityAPIKey string
|
PerplexityAPIKey string
|
||||||
PerplexityMaxResults int
|
PerplexityMaxResults int
|
||||||
PerplexityEnabled bool
|
PerplexityEnabled bool
|
||||||
|
GLMSearchAPIKey string
|
||||||
|
GLMSearchBaseURL string
|
||||||
|
GLMSearchEngine string
|
||||||
|
GLMSearchMaxResults int
|
||||||
|
GLMSearchEnabled bool
|
||||||
Proxy string
|
Proxy string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -420,7 +507,7 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
var provider SearchProvider
|
var provider SearchProvider
|
||||||
maxResults := 5
|
maxResults := 5
|
||||||
|
|
||||||
// Priority: Perplexity > Brave > Tavily > DuckDuckGo
|
// Priority: Perplexity > Brave > Tavily > DuckDuckGo > GLM Search
|
||||||
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" {
|
||||||
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
client, err := createHTTPClient(opts.Proxy, perplexityTimeout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -462,6 +549,25 @@ func NewWebSearchTool(opts WebSearchToolOptions) (*WebSearchTool, error) {
|
||||||
if opts.DuckDuckGoMaxResults > 0 {
|
if opts.DuckDuckGoMaxResults > 0 {
|
||||||
maxResults = opts.DuckDuckGoMaxResults
|
maxResults = opts.DuckDuckGoMaxResults
|
||||||
}
|
}
|
||||||
|
} else if opts.GLMSearchEnabled && opts.GLMSearchAPIKey != "" {
|
||||||
|
client, err := createHTTPClient(opts.Proxy, searchTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create HTTP client for GLM Search: %w", err)
|
||||||
|
}
|
||||||
|
searchEngine := opts.GLMSearchEngine
|
||||||
|
if searchEngine == "" {
|
||||||
|
searchEngine = "search_std"
|
||||||
|
}
|
||||||
|
provider = &GLMSearchProvider{
|
||||||
|
apiKey: opts.GLMSearchAPIKey,
|
||||||
|
baseURL: opts.GLMSearchBaseURL,
|
||||||
|
searchEngine: searchEngine,
|
||||||
|
proxy: opts.Proxy,
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
if opts.GLMSearchMaxResults > 0 {
|
||||||
|
maxResults = opts.GLMSearchMaxResults
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -681,3 +681,135 @@ func TestWebTool_TavilySearch_Success(t *testing.T) {
|
||||||
t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
|
t.Errorf("Expected 'via Tavily' in output, got: %s", result.ForUser)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWebTool_GLMSearch_Success(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
t.Errorf("Expected POST request, got %s", r.Method)
|
||||||
|
}
|
||||||
|
if r.Header.Get("Content-Type") != "application/json" {
|
||||||
|
t.Errorf("Expected Content-Type application/json, got %s", r.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if r.Header.Get("Authorization") != "Bearer test-glm-key" {
|
||||||
|
t.Errorf("Expected Authorization Bearer test-glm-key, got %s", r.Header.Get("Authorization"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]any
|
||||||
|
json.NewDecoder(r.Body).Decode(&payload)
|
||||||
|
if payload["search_query"] != "test query" {
|
||||||
|
t.Errorf("Expected search_query 'test query', got %v", payload["search_query"])
|
||||||
|
}
|
||||||
|
if payload["search_engine"] != "search_std" {
|
||||||
|
t.Errorf("Expected search_engine 'search_std', got %v", payload["search_engine"])
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]any{
|
||||||
|
"id": "web-search-test",
|
||||||
|
"created": 1709568000,
|
||||||
|
"search_result": []map[string]any{
|
||||||
|
{
|
||||||
|
"title": "Test GLM Result",
|
||||||
|
"content": "GLM search snippet",
|
||||||
|
"link": "https://example.com/glm",
|
||||||
|
"media": "Example",
|
||||||
|
"publish_date": "2026-03-04",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
GLMSearchEnabled: true,
|
||||||
|
GLMSearchAPIKey: "test-glm-key",
|
||||||
|
GLMSearchBaseURL: server.URL,
|
||||||
|
GLMSearchEngine: "search_std",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"query": "test query",
|
||||||
|
})
|
||||||
|
|
||||||
|
if result.IsError {
|
||||||
|
t.Errorf("Expected success, got IsError=true: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForUser, "Test GLM Result") {
|
||||||
|
t.Errorf("Expected 'Test GLM Result' in output, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForUser, "https://example.com/glm") {
|
||||||
|
t.Errorf("Expected URL in output, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForUser, "via GLM Search") {
|
||||||
|
t.Errorf("Expected 'via GLM Search' in output, got: %s", result.ForUser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebTool_GLMSearch_APIError(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
w.Write([]byte(`{"error":"invalid api key"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
GLMSearchEnabled: true,
|
||||||
|
GLMSearchAPIKey: "bad-key",
|
||||||
|
GLMSearchBaseURL: server.URL,
|
||||||
|
GLMSearchEngine: "search_std",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := tool.Execute(context.Background(), map[string]any{
|
||||||
|
"query": "test query",
|
||||||
|
})
|
||||||
|
|
||||||
|
if !result.IsError {
|
||||||
|
t.Errorf("Expected IsError=true for 401 response")
|
||||||
|
}
|
||||||
|
if !strings.Contains(result.ForLLM, "status 401") {
|
||||||
|
t.Errorf("Expected status 401 in error, got: %s", result.ForLLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebTool_GLMSearch_Priority(t *testing.T) {
|
||||||
|
// GLM Search should only be selected when all other providers are disabled
|
||||||
|
tool, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
DuckDuckGoEnabled: true,
|
||||||
|
DuckDuckGoMaxResults: 5,
|
||||||
|
GLMSearchEnabled: true,
|
||||||
|
GLMSearchAPIKey: "test-key",
|
||||||
|
GLMSearchBaseURL: "https://example.com",
|
||||||
|
GLMSearchEngine: "search_std",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuckDuckGo should win over GLM Search
|
||||||
|
if _, ok := tool.provider.(*DuckDuckGoSearchProvider); !ok {
|
||||||
|
t.Errorf("Expected DuckDuckGoSearchProvider when both enabled, got %T", tool.provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With DuckDuckGo disabled, GLM Search should be selected
|
||||||
|
tool2, err := NewWebSearchTool(WebSearchToolOptions{
|
||||||
|
DuckDuckGoEnabled: false,
|
||||||
|
GLMSearchEnabled: true,
|
||||||
|
GLMSearchAPIKey: "test-key",
|
||||||
|
GLMSearchBaseURL: "https://example.com",
|
||||||
|
GLMSearchEngine: "search_std",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewWebSearchTool() error: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := tool2.provider.(*GLMSearchProvider); !ok {
|
||||||
|
t.Errorf("Expected GLMSearchProvider when only GLM enabled, got %T", tool2.provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue