feat: add Mistral AI provider, Firecrawl and SerpAPI tools

Added new LLM provider:
- Mistral AI (https://api.mistral.ai/v1)
  Supports models: mistral-tiny, mistral-small, mistral-medium, etc.

Added new tools:
- firecrawl: Advanced web scraping with markdown extraction, screenshots,
  structured data extraction using LLM. Supports Firecrawl API.
- serp_api: Google search via SerpAPI with organic results, knowledge graph,
  related questions, and more.

Changes:
- pkg/config/config.go: Added Mistral provider config and tool configs
- pkg/providers/http_provider.go: Added Mistral routing and auto-detection
- pkg/agent/loop.go: Registered Firecrawl and SerpAPI tools
- pkg/tools/firecrawl.go: New tool implementation with tests
- pkg/tools/serpapi.go: New tool implementation with tests
- config/config.example.json: Added configuration examples
- pkg/providers/mistral_test.go: Integration test for Mistral provider
This commit is contained in:
mrbeandev 2026-02-15 11:58:05 +05:30
parent 9a3f3611c3
commit f29d0615a5
9 changed files with 661 additions and 3 deletions

View file

@ -107,14 +107,33 @@
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
},
"mistral": {
"api_key": "YOUR_MISTRAL_API_KEY",
"api_base": "https://api.mistral.ai/v1"
}
},
"tools": {
"web": {
"search": {
"brave": {
"enabled": false,
"api_key": "YOUR_BRAVE_API_KEY",
"max_results": 5
},
"duckduckgo": {
"enabled": true,
"max_results": 5
}
},
"firecrawl": {
"enabled": false,
"api_key": "YOUR_FIRECRAWL_API_KEY",
"api_base": "https://api.firecrawl.dev/v1"
},
"serpapi": {
"enabled": false,
"api_key": "YOUR_SERPAPI_KEY",
"max_results": 10
}
},
"heartbeat": {

View file

@ -82,6 +82,16 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
}
registry.Register(tools.NewWebFetchTool(50000))
// Firecrawl tool for advanced web scraping
if cfg.Tools.Firecrawl.Enabled && cfg.Tools.Firecrawl.APIKey != "" {
registry.Register(tools.NewFirecrawlTool(cfg.Tools.Firecrawl.APIKey, cfg.Tools.Firecrawl.APIBase))
}
// SerpAPI tool for Google search results
if cfg.Tools.SerpAPI.Enabled && cfg.Tools.SerpAPI.APIKey != "" {
registry.Register(tools.NewSerpAPITool(cfg.Tools.SerpAPI.APIKey, cfg.Tools.SerpAPI.MaxResults))
}
// Hardware tools (I2C, SPI) - Linux only, returns error on other platforms
registry.Register(tools.NewI2CTool())
registry.Register(tools.NewSPITool())

View file

@ -179,6 +179,7 @@ type ProvidersConfig struct {
ShengSuanYun ProviderConfig `json:"shengsuanyun"`
DeepSeek ProviderConfig `json:"deepseek"`
GitHubCopilot ProviderConfig `json:"github_copilot"`
Mistral ProviderConfig `json:"mistral"`
}
type ProviderConfig struct {
@ -210,8 +211,22 @@ type WebToolsConfig struct {
DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"`
}
type FirecrawlConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_FIRECRAWL_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_FIRECRAWL_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_TOOLS_FIRECRAWL_API_BASE"`
}
type SerpAPIConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_SERPAPI_ENABLED"`
APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_SERPAPI_API_KEY"`
MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_SERPAPI_MAX_RESULTS"`
}
type ToolsConfig struct {
Web WebToolsConfig `json:"web"`
Web WebToolsConfig `json:"web"`
Firecrawl FirecrawlConfig `json:"firecrawl"`
SerpAPI SerpAPIConfig `json:"serpapi"`
}
func DefaultConfig() *Config {
@ -304,6 +319,7 @@ func DefaultConfig() *Config {
Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
ShengSuanYun: ProviderConfig{},
Mistral: ProviderConfig{},
},
Gateway: GatewayConfig{
Host: "0.0.0.0",
@ -321,6 +337,16 @@ func DefaultConfig() *Config {
MaxResults: 5,
},
},
Firecrawl: FirecrawlConfig{
Enabled: false,
APIKey: "",
APIBase: "https://api.firecrawl.dev/v1",
},
SerpAPI: SerpAPIConfig{
Enabled: false,
APIKey: "",
MaxResults: 10,
},
},
Heartbeat: HeartbeatConfig{
Enabled: true,

View file

@ -56,7 +56,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
// Strip provider prefix from model name (e.g., moonshot/kimi-k2.5 -> kimi-k2.5)
if idx := strings.Index(model, "/"); idx != -1 {
prefix := model[:idx]
if prefix == "moonshot" || prefix == "nvidia" {
if prefix == "moonshot" || prefix == "nvidia" || prefix == "mistral" {
model = model[idx+1:]
}
}
@ -322,6 +322,15 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiBase = "localhost:4321"
}
return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model)
case "mistral":
if cfg.Providers.Mistral.APIKey != "" {
apiKey = cfg.Providers.Mistral.APIKey
apiBase = cfg.Providers.Mistral.APIBase
proxy = cfg.Providers.Mistral.Proxy
if apiBase == "" {
apiBase = "https://api.mistral.ai/v1"
}
}
}
@ -406,6 +415,14 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
apiBase = cfg.Providers.VLLM.APIBase
proxy = cfg.Providers.VLLM.Proxy
case (strings.Contains(lowerModel, "mistral") || strings.HasPrefix(model, "mistral/")) && cfg.Providers.Mistral.APIKey != "":
apiKey = cfg.Providers.Mistral.APIKey
apiBase = cfg.Providers.Mistral.APIBase
proxy = cfg.Providers.Mistral.Proxy
if apiBase == "" {
apiBase = "https://api.mistral.ai/v1"
}
default:
if cfg.Providers.OpenRouter.APIKey != "" {
apiKey = cfg.Providers.OpenRouter.APIKey

View file

@ -0,0 +1,34 @@
package providers
import (
"context"
"os"
"testing"
)
func TestMistralProvider_Integration(t *testing.T) {
apiKey := os.Getenv("MISTRAL_API_KEY")
if apiKey == "" {
t.Skip("MISTRAL_API_KEY not set")
}
provider := NewHTTPProvider(apiKey, "https://api.mistral.ai/v1", "")
messages := []Message{
{Role: "user", Content: "Say 'Hello from Mistral' in exactly 3 words"},
}
resp, err := provider.Chat(context.Background(), messages, nil, "mistral-tiny", map[string]interface{}{
"max_tokens": 50,
})
if err != nil {
t.Fatalf("Mistral chat failed: %v", err)
}
if resp.Content == "" {
t.Errorf("Expected non-empty response")
}
t.Logf("Mistral response: %s", resp.Content)
}

201
pkg/tools/firecrawl.go Normal file
View file

@ -0,0 +1,201 @@
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type FirecrawlTool struct {
apiKey string
apiBase string
}
func NewFirecrawlTool(apiKey, apiBase string) *FirecrawlTool {
if apiBase == "" {
apiBase = "https://api.firecrawl.dev/v1"
}
return &FirecrawlTool{
apiKey: apiKey,
apiBase: apiBase,
}
}
func (t *FirecrawlTool) Name() string {
return "firecrawl"
}
func (t *FirecrawlTool) Description() string {
return "Scrape web pages and extract structured data using Firecrawl. Supports markdown extraction, screenshot capture, and structured data extraction with LLM."
}
func (t *FirecrawlTool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"url": map[string]interface{}{
"type": "string",
"description": "URL to scrape",
},
"formats": map[string]interface{}{
"type": "array",
"description": "Output formats (markdown, html, screenshot, links, extract)",
"items": map[string]interface{}{
"type": "string",
},
},
"only_main_content": map[string]interface{}{
"type": "boolean",
"description": "Extract only main content, excluding navigation, headers, footers",
},
"extract": map[string]interface{}{
"type": "object",
"description": "Schema for structured data extraction using LLM",
"properties": map[string]interface{}{
"prompt": map[string]interface{}{
"type": "string",
"description": "Prompt describing what to extract",
},
"schema": map[string]interface{}{
"type": "object",
"description": "JSON schema for structured output",
},
},
},
},
"required": []string{"url"},
}
}
func (t *FirecrawlTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
if t.apiKey == "" {
return ErrorResult("Firecrawl API key not configured")
}
url, ok := args["url"].(string)
if !ok || url == "" {
return ErrorResult("url is required")
}
// Build request body
requestBody := map[string]interface{}{
"url": url,
}
if formats, ok := args["formats"].([]interface{}); ok && len(formats) > 0 {
requestBody["formats"] = formats
} else {
// Default to markdown
requestBody["formats"] = []string{"markdown"}
}
if onlyMain, ok := args["only_main_content"].(bool); ok {
requestBody["onlyMainContent"] = onlyMain
}
if extract, ok := args["extract"].(map[string]interface{}); ok {
requestBody["extract"] = extract
}
jsonData, err := json.Marshal(requestBody)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to marshal request: %v", err))
}
req, err := http.NewRequestWithContext(ctx, "POST", t.apiBase+"/scrape", bytes.NewReader(jsonData))
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+t.apiKey)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
}
if resp.StatusCode != http.StatusOK {
return ErrorResult(fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(body)))
}
var result struct {
Success bool `json:"success"`
Data struct {
Markdown string `json:"markdown"`
HTML string `json:"html"`
Metadata map[string]interface{} `json:"metadata"`
Extract map[string]interface{} `json:"extract"`
Screenshot string `json:"screenshot"`
Links []string `json:"links"`
} `json:"data"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &result); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
}
if !result.Success {
return ErrorResult(fmt.Sprintf("Firecrawl error: %s", result.Error))
}
// Build response
var output strings.Builder
output.WriteString(fmt.Sprintf("# Scraped: %s\n\n", url))
if result.Data.Metadata != nil {
if title, ok := result.Data.Metadata["title"].(string); ok && title != "" {
output.WriteString(fmt.Sprintf("**Title:** %s\n\n", title))
}
if description, ok := result.Data.Metadata["description"].(string); ok && description != "" {
output.WriteString(fmt.Sprintf("**Description:** %s\n\n", description))
}
}
if result.Data.Markdown != "" {
output.WriteString("## Content (Markdown)\n\n")
output.WriteString(result.Data.Markdown)
output.WriteString("\n\n")
}
if result.Data.Extract != nil && len(result.Data.Extract) > 0 {
output.WriteString("## Extracted Data\n\n")
extractJSON, _ := json.MarshalIndent(result.Data.Extract, "", " ")
output.WriteString("```json\n")
output.WriteString(string(extractJSON))
output.WriteString("\n```\n\n")
}
if len(result.Data.Links) > 0 {
output.WriteString(fmt.Sprintf("## Links Found: %d\n\n", len(result.Data.Links)))
for i, link := range result.Data.Links {
if i >= 20 { // Limit to 20 links
output.WriteString(fmt.Sprintf("\n... and %d more links\n", len(result.Data.Links)-20))
break
}
output.WriteString(fmt.Sprintf("- %s\n", link))
}
}
if result.Data.Screenshot != "" {
output.WriteString("\n**Screenshot:** [Base64 encoded image available]\n")
}
return &ToolResult{
ForLLM: output.String(),
ForUser: output.String(),
}
}

View file

@ -0,0 +1,27 @@
package tools
import (
"context"
"os"
"testing"
)
func TestFirecrawlTool_Integration(t *testing.T) {
apiKey := os.Getenv("FIRECRAWL_API_KEY")
if apiKey == "" {
t.Skip("FIRECRAWL_API_KEY not set")
}
tool := NewFirecrawlTool(apiKey, "")
result := tool.Execute(context.Background(), map[string]interface{}{
"url": "https://example.com",
"formats": []string{"markdown"},
})
if result.IsError {
t.Errorf("Firecrawl execution failed: %s", result.ForLLM)
}
t.Logf("Firecrawl result:\n%s", result.ForUser)
}

296
pkg/tools/serpapi.go Normal file
View file

@ -0,0 +1,296 @@
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type SerpAPITool struct {
apiKey string
maxResults int
}
func NewSerpAPITool(apiKey string, maxResults int) *SerpAPITool {
if maxResults <= 0 {
maxResults = 10
}
return &SerpAPITool{
apiKey: apiKey,
maxResults: maxResults,
}
}
func (t *SerpAPITool) Name() string {
return "serp_api"
}
func (t *SerpAPITool) Description() string {
return "Search Google and other search engines using SerpAPI. Returns organic results, knowledge graph, related questions, and more."
}
func (t *SerpAPITool) Parameters() map[string]interface{} {
return map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"query": map[string]interface{}{
"type": "string",
"description": "Search query",
},
"engine": map[string]interface{}{
"type": "string",
"description": "Search engine (google, bing, yahoo, duckduckgo, yandex)",
"enum": []string{"google", "bing", "yahoo", "duckduckgo", "yandex"},
},
"location": map[string]interface{}{
"type": "string",
"description": "Location for localized results (e.g., 'Austin, Texas, United States')",
},
"hl": map[string]interface{}{
"type": "string",
"description": "Language code (e.g., 'en', 'es', 'fr')",
},
"gl": map[string]interface{}{
"type": "string",
"description": "Country code (e.g., 'us', 'uk', 'ca')",
},
"num": map[string]interface{}{
"type": "integer",
"description": "Number of results (1-100)",
"minimum": 1,
"maximum": 100,
},
},
"required": []string{"query"},
}
}
func (t *SerpAPITool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult {
if t.apiKey == "" {
return ErrorResult("SerpAPI key not configured. Get one at https://serpapi.com/")
}
query, ok := args["query"].(string)
if !ok || query == "" {
return ErrorResult("query is required")
}
// Build query parameters
params := url.Values{}
params.Set("q", query)
params.Set("api_key", t.apiKey)
params.Set("output", "json")
// Set engine (default to google)
engine := "google"
if e, ok := args["engine"].(string); ok && e != "" {
engine = e
}
params.Set("engine", engine)
// Set number of results
num := t.maxResults
if n, ok := args["num"].(float64); ok && n > 0 {
num = int(n)
if num > 100 {
num = 100
}
}
params.Set("num", fmt.Sprintf("%d", num))
// Optional parameters
if location, ok := args["location"].(string); ok && location != "" {
params.Set("location", location)
}
if hl, ok := args["hl"].(string); ok && hl != "" {
params.Set("hl", hl)
}
if gl, ok := args["gl"].(string); ok && gl != "" {
params.Set("gl", gl)
}
// Make request
apiURL := fmt.Sprintf("https://serpapi.com/search?%s", params.Encode())
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to create request: %v", err))
}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ErrorResult(fmt.Sprintf("request failed: %v", err))
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return ErrorResult(fmt.Sprintf("failed to read response: %v", err))
}
if resp.StatusCode != http.StatusOK {
return ErrorResult(fmt.Sprintf("API error (status %d): %s", resp.StatusCode, string(body)))
}
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {
return ErrorResult(fmt.Sprintf("failed to parse response: %v", err))
}
// Check for SerpAPI error
if errMsg, ok := result["error"].(string); ok && errMsg != "" {
return ErrorResult(fmt.Sprintf("SerpAPI error: %s", errMsg))
}
// Build output
var output strings.Builder
output.WriteString(fmt.Sprintf("# Search Results for: %s\n\n", query))
// Search metadata
if searchMetadata, ok := result["search_metadata"].(map[string]interface{}); ok {
if status, ok := searchMetadata["status"].(string); ok {
output.WriteString(fmt.Sprintf("**Status:** %s\n", status))
}
if totalTime, ok := searchMetadata["total_time_taken"].(float64); ok {
output.WriteString(fmt.Sprintf("**Time:** %.2fs\n", totalTime))
}
}
// Search parameters info
if searchParams, ok := result["search_parameters"].(map[string]interface{}); ok {
if engine, ok := searchParams["engine"].(string); ok {
output.WriteString(fmt.Sprintf("**Engine:** %s\n", engine))
}
if location, ok := searchParams["location"].(string); ok && location != "" {
output.WriteString(fmt.Sprintf("**Location:** %s\n", location))
}
}
output.WriteString("\n")
// Organic results
if organicResults, ok := result["organic_results"].([]interface{}); ok && len(organicResults) > 0 {
output.WriteString(fmt.Sprintf("## Organic Results (%d)\n\n", len(organicResults)))
for i, r := range organicResults {
if i >= t.maxResults {
break
}
result, ok := r.(map[string]interface{})
if !ok {
continue
}
position := i + 1
if pos, ok := result["position"].(float64); ok {
position = int(pos)
}
title := ""
if t, ok := result["title"].(string); ok {
title = t
}
link := ""
if l, ok := result["link"].(string); ok {
link = l
}
snippet := ""
if s, ok := result["snippet"].(string); ok {
snippet = s
}
output.WriteString(fmt.Sprintf("### %d. %s\n", position, title))
output.WriteString(fmt.Sprintf("**URL:** %s\n", link))
if snippet != "" {
output.WriteString(fmt.Sprintf("%s\n", snippet))
}
output.WriteString("\n")
}
}
// Knowledge Graph
if kg, ok := result["knowledge_graph"].(map[string]interface{}); ok && len(kg) > 0 {
output.WriteString("## Knowledge Graph\n\n")
if title, ok := kg["title"].(string); ok {
output.WriteString(fmt.Sprintf("**Title:** %s\n", title))
}
if description, ok := kg["description"].(string); ok {
output.WriteString(fmt.Sprintf("**Description:** %s\n", description))
}
if link, ok := kg["knowledge_graph_search_link"].(string); ok {
output.WriteString(fmt.Sprintf("**More Info:** %s\n", link))
}
// Additional attributes
for key, value := range kg {
if key == "title" || key == "description" || key == "knowledge_graph_search_link" {
continue
}
if strVal, ok := value.(string); ok {
output.WriteString(fmt.Sprintf("- **%s:** %s\n", strings.Title(key), strVal))
}
}
output.WriteString("\n")
}
// Related Questions (People Also Ask)
if relatedQuestions, ok := result["related_questions"].([]interface{}); ok && len(relatedQuestions) > 0 {
output.WriteString(fmt.Sprintf("## People Also Ask (%d)\n\n", len(relatedQuestions)))
for i, q := range relatedQuestions {
if i >= 5 { // Limit to 5 questions
break
}
question, ok := q.(map[string]interface{})
if !ok {
continue
}
if questionText, ok := question["question"].(string); ok {
output.WriteString(fmt.Sprintf("**Q:** %s\n", questionText))
}
if snippet, ok := question["snippet"].(string); ok {
output.WriteString(fmt.Sprintf("**A:** %s\n", snippet))
}
if link, ok := question["link"].(string); ok {
output.WriteString(fmt.Sprintf("*Source: %s*\n", link))
}
output.WriteString("\n")
}
}
// Related Searches
if relatedSearches, ok := result["related_searches"].([]interface{}); ok && len(relatedSearches) > 0 {
output.WriteString("## Related Searches\n\n")
for i, s := range relatedSearches {
if i >= 10 { // Limit to 10
break
}
search, ok := s.(map[string]interface{})
if !ok {
continue
}
if query, ok := search["query"].(string); ok {
output.WriteString(fmt.Sprintf("- %s\n", query))
}
}
output.WriteString("\n")
}
return &ToolResult{
ForLLM: output.String(),
ForUser: output.String(),
}
}

28
pkg/tools/serpapi_test.go Normal file
View file

@ -0,0 +1,28 @@
package tools
import (
"context"
"os"
"testing"
)
func TestSerpAPITool_Integration(t *testing.T) {
apiKey := os.Getenv("SERPAPI_API_KEY")
if apiKey == "" {
t.Skip("SERPAPI_API_KEY not set")
}
tool := NewSerpAPITool(apiKey, 5)
result := tool.Execute(context.Background(), map[string]interface{}{
"query": "golang programming",
"engine": "google",
"num": float64(3),
})
if result.IsError {
t.Errorf("SerpAPI execution failed: %s", result.ForLLM)
}
t.Logf("SerpAPI result:\n%s", result.ForUser)
}