From 82856bc57aa0af52dc8c3f9b563b90af2d030126 Mon Sep 17 00:00:00 2001 From: yinwm Date: Sun, 15 Feb 2026 18:41:39 +0800 Subject: [PATCH 01/31] feat(cron): add configurable execution timeout for cron jobs Add a new configuration option `exec_timeout_minutes` under the `tools.cron` section to control the maximum execution time for cron jobs. The default timeout is set to 5 minutes, which is appropriate for LLM operations. The configuration can be set in the config file or via the `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES` environment variable. A value of 0 disables the timeout entirely. This change improves system reliability by preventing cron jobs from running indefinitely in case of unexpected failures or hanging processes. --- README.ja.md | 6 ++++++ README.md | 3 +++ README.zh.md | 6 ++++++ cmd/picoclaw/main.go | 6 +++--- config/config.example.json | 3 +++ pkg/config/config.go | 10 +++++++++- pkg/tools/cron.go | 8 ++++++-- 7 files changed, 36 insertions(+), 6 deletions(-) diff --git a/README.ja.md b/README.ja.md index 48105ce2f..5e4e49411 100644 --- a/README.ja.md +++ b/README.ja.md @@ -195,6 +195,9 @@ picoclaw onboard "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { @@ -646,6 +649,9 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る "search": { "apiKey": "BSA..." } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/README.md b/README.md index 2ba70881b..1b7537fc9 100644 --- a/README.md +++ b/README.md @@ -697,6 +697,9 @@ picoclaw agent -m "Hello" "search": { "api_key": "BSA..." } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/README.zh.md b/README.zh.md index f2c9bf780..877cb0f5d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -217,6 +217,9 @@ picoclaw onboard "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } } } @@ -625,6 +628,9 @@ picoclaw agent -m "你好" "search": { "api_key": "BSA..." } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 21246cf41..8225931c8 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -669,7 +669,7 @@ func gatewayCmd() { }) // Setup cron tool and service - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath()) + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes)*time.Minute) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -1069,14 +1069,14 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService { +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, execTimeout time.Duration) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, execTimeout) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/config/config.example.json b/config/config.example.json index c71587a04..d56596f24 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -98,6 +98,9 @@ "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 } + }, + "cron": { + "exec_timeout_minutes": 5 } }, "heartbeat": { diff --git a/pkg/config/config.go b/pkg/config/config.go index 391120e2d..9acbcce8c 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -173,8 +173,13 @@ type WebToolsConfig struct { Search WebSearchConfig `json:"search"` } +type CronToolsConfig struct { + ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout +} + type ToolsConfig struct { - Web WebToolsConfig `json:"web"` + Web WebToolsConfig `json:"web"` + Cron CronToolsConfig `json:"cron"` } func DefaultConfig() *Config { @@ -262,6 +267,9 @@ func DefaultConfig() *Config { MaxResults: 5, }, }, + Cron: CronToolsConfig{ + ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 0ef745e2b..8632b07b9 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -28,12 +28,16 @@ type CronTool struct { } // NewCronTool creates a new CronTool -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string) *CronTool { +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, execTimeout time.Duration) *CronTool { + execTool := NewExecTool(workspace, false) + if execTimeout > 0 { + execTool.SetTimeout(execTimeout) + } return &CronTool{ cronService: cronService, executor: executor, msgBus: msgBus, - execTool: NewExecTool(workspace, false), + execTool: execTool, } } From a6e885bb473a20d671ed1dab5e8e8ea9bb8cd399 Mon Sep 17 00:00:00 2001 From: Jared Mahotiere Date: Sun, 15 Feb 2026 08:04:07 -0500 Subject: [PATCH 02/31] refactor(providers): extract protocol factory and openai-compat transport --- pkg/providers/factory.go | 291 ++++++++++++ pkg/providers/factory_test.go | 150 ++++++ pkg/providers/http_provider.go | 473 ++++--------------- pkg/providers/openai_compat/provider.go | 230 +++++++++ pkg/providers/openai_compat/provider_test.go | 149 ++++++ 5 files changed, 905 insertions(+), 388 deletions(-) create mode 100644 pkg/providers/factory.go create mode 100644 pkg/providers/factory_test.go create mode 100644 pkg/providers/openai_compat/provider.go create mode 100644 pkg/providers/openai_compat/provider_test.go diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go new file mode 100644 index 000000000..84dcd9aaa --- /dev/null +++ b/pkg/providers/factory.go @@ -0,0 +1,291 @@ +package providers + +import ( + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/auth" + "github.com/sipeed/picoclaw/pkg/config" +) + +type providerType int + +const ( + providerTypeHTTPCompat providerType = iota + providerTypeClaudeAuth + providerTypeCodexAuth + providerTypeClaudeCLI + providerTypeGitHubCopilot +) + +type providerSelection struct { + providerType providerType + apiKey string + apiBase string + proxy string + model string + workspace string + connectMode string +} + +func createClaudeAuthProvider() (LLMProvider, error) { + cred, err := auth.GetCredential("anthropic") + if err != nil { + return nil, fmt.Errorf("loading auth credentials: %w", err) + } + if cred == nil { + return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") + } + return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil +} + +func createCodexAuthProvider() (LLMProvider, error) { + cred, err := auth.GetCredential("openai") + if err != nil { + return nil, fmt.Errorf("loading auth credentials: %w", err) + } + if cred == nil { + return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") + } + return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil +} + +func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { + model := cfg.Agents.Defaults.Model + providerName := strings.ToLower(cfg.Agents.Defaults.Provider) + lowerModel := strings.ToLower(model) + + sel := providerSelection{ + providerType: providerTypeHTTPCompat, + model: model, + } + + // First, prefer explicit provider configuration. + if providerName != "" { + switch providerName { + case "groq": + if cfg.Providers.Groq.APIKey != "" { + sel.apiKey = cfg.Providers.Groq.APIKey + sel.apiBase = cfg.Providers.Groq.APIBase + if sel.apiBase == "" { + sel.apiBase = "https://api.groq.com/openai/v1" + } + } + case "openai", "gpt": + if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { + if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { + sel.providerType = providerTypeCodexAuth + return sel, nil + } + sel.apiKey = cfg.Providers.OpenAI.APIKey + sel.apiBase = cfg.Providers.OpenAI.APIBase + if sel.apiBase == "" { + sel.apiBase = "https://api.openai.com/v1" + } + } + case "anthropic", "claude": + if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.providerType = providerTypeClaudeAuth + return sel, nil + } + sel.apiKey = cfg.Providers.Anthropic.APIKey + sel.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = "https://api.anthropic.com/v1" + } + } + case "openrouter": + if cfg.Providers.OpenRouter.APIKey != "" { + sel.apiKey = cfg.Providers.OpenRouter.APIKey + if cfg.Providers.OpenRouter.APIBase != "" { + sel.apiBase = cfg.Providers.OpenRouter.APIBase + } else { + sel.apiBase = "https://openrouter.ai/api/v1" + } + } + case "zhipu", "glm": + if cfg.Providers.Zhipu.APIKey != "" { + sel.apiKey = cfg.Providers.Zhipu.APIKey + sel.apiBase = cfg.Providers.Zhipu.APIBase + if sel.apiBase == "" { + sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" + } + } + case "gemini", "google": + if cfg.Providers.Gemini.APIKey != "" { + sel.apiKey = cfg.Providers.Gemini.APIKey + sel.apiBase = cfg.Providers.Gemini.APIBase + if sel.apiBase == "" { + sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" + } + } + case "vllm": + if cfg.Providers.VLLM.APIBase != "" { + sel.apiKey = cfg.Providers.VLLM.APIKey + sel.apiBase = cfg.Providers.VLLM.APIBase + } + case "shengsuanyun": + if cfg.Providers.ShengSuanYun.APIKey != "" { + sel.apiKey = cfg.Providers.ShengSuanYun.APIKey + sel.apiBase = cfg.Providers.ShengSuanYun.APIBase + if sel.apiBase == "" { + sel.apiBase = "https://router.shengsuanyun.com/api/v1" + } + } + case "claude-cli", "claude-code", "claudecode": + workspace := cfg.Agents.Defaults.Workspace + if workspace == "" { + workspace = "." + } + sel.providerType = providerTypeClaudeCLI + sel.workspace = workspace + return sel, nil + case "deepseek": + if cfg.Providers.DeepSeek.APIKey != "" { + sel.apiKey = cfg.Providers.DeepSeek.APIKey + sel.apiBase = cfg.Providers.DeepSeek.APIBase + if sel.apiBase == "" { + sel.apiBase = "https://api.deepseek.com/v1" + } + if model != "deepseek-chat" && model != "deepseek-reasoner" { + sel.model = "deepseek-chat" + } + } + case "github_copilot", "copilot": + sel.providerType = providerTypeGitHubCopilot + if cfg.Providers.GitHubCopilot.APIBase != "" { + sel.apiBase = cfg.Providers.GitHubCopilot.APIBase + } else { + sel.apiBase = "localhost:4321" + } + sel.connectMode = cfg.Providers.GitHubCopilot.ConnectMode + return sel, nil + } + } + + // Fallback: infer provider from model and configured keys. + if sel.apiKey == "" && sel.apiBase == "" { + switch { + case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": + sel.apiKey = cfg.Providers.Moonshot.APIKey + sel.apiBase = cfg.Providers.Moonshot.APIBase + sel.proxy = cfg.Providers.Moonshot.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.moonshot.cn/v1" + } + case strings.HasPrefix(model, "openrouter/") || + strings.HasPrefix(model, "anthropic/") || + strings.HasPrefix(model, "openai/") || + strings.HasPrefix(model, "meta-llama/") || + strings.HasPrefix(model, "deepseek/") || + strings.HasPrefix(model, "google/"): + sel.apiKey = cfg.Providers.OpenRouter.APIKey + sel.proxy = cfg.Providers.OpenRouter.Proxy + if cfg.Providers.OpenRouter.APIBase != "" { + sel.apiBase = cfg.Providers.OpenRouter.APIBase + } else { + sel.apiBase = "https://openrouter.ai/api/v1" + } + case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && + (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): + if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.providerType = providerTypeClaudeAuth + return sel, nil + } + sel.apiKey = cfg.Providers.Anthropic.APIKey + sel.apiBase = cfg.Providers.Anthropic.APIBase + sel.proxy = cfg.Providers.Anthropic.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.anthropic.com/v1" + } + case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && + (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): + if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { + sel.providerType = providerTypeCodexAuth + return sel, nil + } + sel.apiKey = cfg.Providers.OpenAI.APIKey + sel.apiBase = cfg.Providers.OpenAI.APIBase + sel.proxy = cfg.Providers.OpenAI.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.openai.com/v1" + } + case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": + sel.apiKey = cfg.Providers.Gemini.APIKey + sel.apiBase = cfg.Providers.Gemini.APIBase + sel.proxy = cfg.Providers.Gemini.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" + } + case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": + sel.apiKey = cfg.Providers.Zhipu.APIKey + sel.apiBase = cfg.Providers.Zhipu.APIBase + sel.proxy = cfg.Providers.Zhipu.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" + } + case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": + sel.apiKey = cfg.Providers.Groq.APIKey + sel.apiBase = cfg.Providers.Groq.APIBase + sel.proxy = cfg.Providers.Groq.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://api.groq.com/openai/v1" + } + case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": + sel.apiKey = cfg.Providers.Nvidia.APIKey + sel.apiBase = cfg.Providers.Nvidia.APIBase + sel.proxy = cfg.Providers.Nvidia.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://integrate.api.nvidia.com/v1" + } + case cfg.Providers.VLLM.APIBase != "": + sel.apiKey = cfg.Providers.VLLM.APIKey + sel.apiBase = cfg.Providers.VLLM.APIBase + sel.proxy = cfg.Providers.VLLM.Proxy + default: + if cfg.Providers.OpenRouter.APIKey != "" { + sel.apiKey = cfg.Providers.OpenRouter.APIKey + sel.proxy = cfg.Providers.OpenRouter.Proxy + if cfg.Providers.OpenRouter.APIBase != "" { + sel.apiBase = cfg.Providers.OpenRouter.APIBase + } else { + sel.apiBase = "https://openrouter.ai/api/v1" + } + } else { + return providerSelection{}, fmt.Errorf("no API key configured for model: %s", model) + } + } + } + + if sel.providerType == providerTypeHTTPCompat { + if sel.apiKey == "" && !strings.HasPrefix(model, "bedrock/") { + return providerSelection{}, fmt.Errorf("no API key configured for provider (model: %s)", model) + } + if sel.apiBase == "" { + return providerSelection{}, fmt.Errorf("no API base configured for provider (model: %s)", model) + } + } + + return sel, nil +} + +func CreateProvider(cfg *config.Config) (LLMProvider, error) { + sel, err := resolveProviderSelection(cfg) + if err != nil { + return nil, err + } + + switch sel.providerType { + case providerTypeClaudeAuth: + return createClaudeAuthProvider() + case providerTypeCodexAuth: + return createCodexAuthProvider() + case providerTypeClaudeCLI: + return NewClaudeCliProvider(sel.workspace), nil + case providerTypeGitHubCopilot: + return NewGitHubCopilotProvider(sel.apiBase, sel.connectMode, sel.model) + default: + return NewHTTPProvider(sel.apiKey, sel.apiBase, sel.proxy), nil + } +} diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go new file mode 100644 index 000000000..f894b292a --- /dev/null +++ b/pkg/providers/factory_test.go @@ -0,0 +1,150 @@ +package providers + +import ( + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" +) + +func TestResolveProviderSelection(t *testing.T) { + tests := []struct { + name string + setup func(*config.Config) + wantType providerType + wantAPIBase string + wantProxy string + wantErrSubstr string + }{ + { + name: "explicit claude-cli provider routes to cli provider type", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "claude-cli" + cfg.Agents.Defaults.Workspace = "/tmp/ws" + }, + wantType: providerTypeClaudeCLI, + }, + { + name: "explicit copilot provider routes to github copilot type", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "copilot" + }, + wantType: providerTypeGitHubCopilot, + wantAPIBase: "localhost:4321", + }, + { + name: "openrouter model uses openrouter defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "openrouter/auto" + cfg.Providers.OpenRouter.APIKey = "sk-or-test" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://openrouter.ai/api/v1", + }, + { + name: "anthropic oauth routes to claude auth provider", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "claude-sonnet-4-5-20250929" + cfg.Providers.Anthropic.AuthMethod = "oauth" + }, + wantType: providerTypeClaudeAuth, + }, + { + name: "openai oauth routes to codex auth provider", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "gpt-4o" + cfg.Providers.OpenAI.AuthMethod = "oauth" + }, + wantType: providerTypeCodexAuth, + }, + { + name: "zhipu model uses zhipu base default", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "glm-4.7" + cfg.Providers.Zhipu.APIKey = "zhipu-key" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://open.bigmodel.cn/api/paas/v4", + }, + { + name: "groq model uses groq base default", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "groq/llama-3.3-70b" + cfg.Providers.Groq.APIKey = "gsk-key" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.groq.com/openai/v1", + }, + { + name: "moonshot model keeps proxy and default base", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "moonshot/kimi-k2.5" + cfg.Providers.Moonshot.APIKey = "moonshot-key" + cfg.Providers.Moonshot.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.moonshot.cn/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "missing keys returns model config error", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "custom-model" + }, + wantErrSubstr: "no API key configured for model", + }, + { + name: "openrouter prefix without key returns provider key error", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Model = "openrouter/auto" + }, + wantErrSubstr: "no API key configured for provider", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := config.DefaultConfig() + tt.setup(cfg) + + got, err := resolveProviderSelection(cfg) + if tt.wantErrSubstr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tt.wantErrSubstr) + } + if !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErrSubstr) + } + return + } + + if err != nil { + t.Fatalf("resolveProviderSelection() error = %v", err) + } + if got.providerType != tt.wantType { + t.Fatalf("providerType = %v, want %v", got.providerType, tt.wantType) + } + if tt.wantAPIBase != "" && got.apiBase != tt.wantAPIBase { + t.Fatalf("apiBase = %q, want %q", got.apiBase, tt.wantAPIBase) + } + if tt.wantProxy != "" && got.proxy != tt.wantProxy { + t.Fatalf("proxy = %q, want %q", got.proxy, tt.wantProxy) + } + }) + } +} + +func TestCreateProviderReturnsHTTPProviderForOpenRouter(t *testing.T) { + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Model = "openrouter/auto" + cfg.Providers.OpenRouter.APIKey = "sk-or-test" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("provider type = %T, want *HTTPProvider", provider) + } +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 17eb6214c..0f7f646d8 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -7,427 +7,124 @@ package providers import ( - "bytes" "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/sipeed/picoclaw/pkg/auth" - "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers/openai_compat" ) type HTTPProvider struct { - apiKey string - apiBase string - httpClient *http.Client + delegate *openai_compat.Provider } -func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { - client := &http.Client{ - Timeout: 120 * time.Second, +func NewHTTPProvider(apiKey, apiBase string, proxy ...string) *HTTPProvider { + proxyURL := "" + if len(proxy) > 0 { + proxyURL = proxy[0] } - - if proxy != "" { - proxyURL, err := url.Parse(proxy) - if err == nil { - client.Transport = &http.Transport{ - Proxy: http.ProxyURL(proxyURL), - } - } - } - return &HTTPProvider{ - apiKey: apiKey, - apiBase: strings.TrimRight(apiBase, "/"), - httpClient: client, + delegate: openai_compat.NewProvider(apiKey, apiBase, proxyURL), } } func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - if p.apiBase == "" { - return nil, fmt.Errorf("API base not configured") - } - - // 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" { - model = model[idx+1:] - } - } - - requestBody := map[string]interface{}{ - "model": model, - "messages": messages, - } - - if len(tools) > 0 { - requestBody["tools"] = tools - requestBody["tool_choice"] = "auto" - } - - if maxTokens, ok := options["max_tokens"].(int); ok { - lowerModel := strings.ToLower(model) - if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { - requestBody["max_completion_tokens"] = maxTokens - } else { - requestBody["max_tokens"] = maxTokens - } - } - - if temperature, ok := options["temperature"].(float64); ok { - lowerModel := strings.ToLower(model) - // Kimi k2 models only support temperature=1 - if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { - requestBody["temperature"] = 1.0 - } else { - requestBody["temperature"] = temperature - } - } - - jsonData, err := json.Marshal(requestBody) + compatResp, err := p.delegate.Chat(ctx, toOpenAICompatMessages(messages), toOpenAICompatTools(tools), model, options) if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) + return nil, err } - - req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - if p.apiKey != "" { - req.Header.Set("Authorization", "Bearer "+p.apiKey) - } - - resp, err := p.httpClient.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body)) - } - - return p.parseResponse(body) -} - -func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) { - var apiResponse struct { - Choices []struct { - Message struct { - Content string `json:"content"` - ToolCalls []struct { - ID string `json:"id"` - Type string `json:"type"` - Function *struct { - Name string `json:"name"` - Arguments string `json:"arguments"` - } `json:"function"` - } `json:"tool_calls"` - } `json:"message"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Usage *UsageInfo `json:"usage"` - } - - if err := json.Unmarshal(body, &apiResponse); err != nil { - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - if len(apiResponse.Choices) == 0 { - return &LLMResponse{ - Content: "", - FinishReason: "stop", - }, nil - } - - choice := apiResponse.Choices[0] - - toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) - for _, tc := range choice.Message.ToolCalls { - arguments := make(map[string]interface{}) - name := "" - - // Handle OpenAI format with nested function object - if tc.Type == "function" && tc.Function != nil { - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - arguments["raw"] = tc.Function.Arguments - } - } - } else if tc.Function != nil { - // Legacy format without type field - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - arguments["raw"] = tc.Function.Arguments - } - } - } - - toolCalls = append(toolCalls, ToolCall{ - ID: tc.ID, - Name: name, - Arguments: arguments, - }) - } - - return &LLMResponse{ - Content: choice.Message.Content, - ToolCalls: toolCalls, - FinishReason: choice.FinishReason, - Usage: apiResponse.Usage, - }, nil + return fromOpenAICompatResponse(compatResp), nil } func (p *HTTPProvider) GetDefaultModel() string { return "" } -func createClaudeAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("anthropic") - if err != nil { - return nil, fmt.Errorf("loading auth credentials: %w", err) +func toOpenAICompatMessages(messages []Message) []openai_compat.Message { + out := make([]openai_compat.Message, 0, len(messages)) + for _, msg := range messages { + out = append(out, openai_compat.Message{ + Role: msg.Role, + Content: msg.Content, + ToolCalls: toOpenAICompatToolCalls(msg.ToolCalls), + ToolCallID: msg.ToolCallID, + }) } - if cred == nil { - return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") - } - return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil + return out } -func createCodexAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("openai") - if err != nil { - return nil, fmt.Errorf("loading auth credentials: %w", err) +func toOpenAICompatTools(tools []ToolDefinition) []openai_compat.ToolDefinition { + out := make([]openai_compat.ToolDefinition, 0, len(tools)) + for _, t := range tools { + out = append(out, openai_compat.ToolDefinition{ + Type: t.Type, + Function: openai_compat.ToolFunctionDefinition{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }, + }) } - if cred == nil { - return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") - } - return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil + return out } -func CreateProvider(cfg *config.Config) (LLMProvider, error) { - model := cfg.Agents.Defaults.Model - providerName := strings.ToLower(cfg.Agents.Defaults.Provider) - - var apiKey, apiBase, proxy string - - lowerModel := strings.ToLower(model) - - // First, try to use explicitly configured provider - if providerName != "" { - switch providerName { - case "groq": - if cfg.Providers.Groq.APIKey != "" { - apiKey = cfg.Providers.Groq.APIKey - apiBase = cfg.Providers.Groq.APIBase - if apiBase == "" { - apiBase = "https://api.groq.com/openai/v1" - } +func toOpenAICompatToolCalls(toolCalls []ToolCall) []openai_compat.ToolCall { + out := make([]openai_compat.ToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + var fn *openai_compat.FunctionCall + if tc.Function != nil { + fn = &openai_compat.FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, } - case "openai", "gpt": - if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() - } - apiKey = cfg.Providers.OpenAI.APIKey - apiBase = cfg.Providers.OpenAI.APIBase - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - } - case "anthropic", "claude": - if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - return createClaudeAuthProvider() - } - apiKey = cfg.Providers.Anthropic.APIKey - apiBase = cfg.Providers.Anthropic.APIBase - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } - } - case "openrouter": - if cfg.Providers.OpenRouter.APIKey != "" { - apiKey = cfg.Providers.OpenRouter.APIKey - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - } - case "zhipu", "glm": - if cfg.Providers.Zhipu.APIKey != "" { - apiKey = cfg.Providers.Zhipu.APIKey - apiBase = cfg.Providers.Zhipu.APIBase - if apiBase == "" { - apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - } - case "gemini", "google": - if cfg.Providers.Gemini.APIKey != "" { - apiKey = cfg.Providers.Gemini.APIKey - apiBase = cfg.Providers.Gemini.APIBase - if apiBase == "" { - apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - } - case "vllm": - if cfg.Providers.VLLM.APIBase != "" { - apiKey = cfg.Providers.VLLM.APIKey - apiBase = cfg.Providers.VLLM.APIBase - } - case "shengsuanyun": - if cfg.Providers.ShengSuanYun.APIKey != "" { - apiKey = cfg.Providers.ShengSuanYun.APIKey - apiBase = cfg.Providers.ShengSuanYun.APIBase - if apiBase == "" { - apiBase = "https://router.shengsuanyun.com/api/v1" - } - } - case "claude-cli", "claudecode", "claude-code": - workspace := cfg.Agents.Defaults.Workspace - if workspace == "" { - workspace = "." - } - return NewClaudeCliProvider(workspace), nil - case "deepseek": - if cfg.Providers.DeepSeek.APIKey != "" { - apiKey = cfg.Providers.DeepSeek.APIKey - apiBase = cfg.Providers.DeepSeek.APIBase - if apiBase == "" { - apiBase = "https://api.deepseek.com/v1" - } - if model != "deepseek-chat" && model != "deepseek-reasoner" { - model = "deepseek-chat" - } - } - case "github_copilot", "copilot": - if cfg.Providers.GitHubCopilot.APIBase != "" { - apiBase = cfg.Providers.GitHubCopilot.APIBase - } else { - apiBase = "localhost:4321" - } - return NewGitHubCopilotProvider(apiBase, cfg.Providers.GitHubCopilot.ConnectMode, model) - } + out = append(out, openai_compat.ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: fn, + Name: tc.Name, + Arguments: tc.Arguments, + }) + } + return out +} +func fromOpenAICompatResponse(resp *openai_compat.LLMResponse) *LLMResponse { + if resp == nil { + return &LLMResponse{} } - // Fallback: detect provider from model name - if apiKey == "" && apiBase == "" { - switch { - case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "": - apiKey = cfg.Providers.Moonshot.APIKey - apiBase = cfg.Providers.Moonshot.APIBase - proxy = cfg.Providers.Moonshot.Proxy - if apiBase == "" { - apiBase = "https://api.moonshot.cn/v1" - } - - case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): - apiKey = cfg.Providers.OpenRouter.APIKey - proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - - case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): - if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { - return createClaudeAuthProvider() - } - apiKey = cfg.Providers.Anthropic.APIKey - apiBase = cfg.Providers.Anthropic.APIBase - proxy = cfg.Providers.Anthropic.Proxy - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } - - case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() - } - apiKey = cfg.Providers.OpenAI.APIKey - apiBase = cfg.Providers.OpenAI.APIBase - proxy = cfg.Providers.OpenAI.Proxy - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - - case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": - apiKey = cfg.Providers.Gemini.APIKey - apiBase = cfg.Providers.Gemini.APIBase - proxy = cfg.Providers.Gemini.Proxy - if apiBase == "" { - apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - - case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": - apiKey = cfg.Providers.Zhipu.APIKey - apiBase = cfg.Providers.Zhipu.APIBase - proxy = cfg.Providers.Zhipu.Proxy - if apiBase == "" { - apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - - case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": - apiKey = cfg.Providers.Groq.APIKey - apiBase = cfg.Providers.Groq.APIBase - proxy = cfg.Providers.Groq.Proxy - if apiBase == "" { - apiBase = "https://api.groq.com/openai/v1" - } - - case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "": - apiKey = cfg.Providers.Nvidia.APIKey - apiBase = cfg.Providers.Nvidia.APIBase - proxy = cfg.Providers.Nvidia.Proxy - if apiBase == "" { - apiBase = "https://integrate.api.nvidia.com/v1" - } - - case cfg.Providers.VLLM.APIBase != "": - apiKey = cfg.Providers.VLLM.APIKey - apiBase = cfg.Providers.VLLM.APIBase - proxy = cfg.Providers.VLLM.Proxy - - default: - if cfg.Providers.OpenRouter.APIKey != "" { - apiKey = cfg.Providers.OpenRouter.APIKey - proxy = cfg.Providers.OpenRouter.Proxy - if cfg.Providers.OpenRouter.APIBase != "" { - apiBase = cfg.Providers.OpenRouter.APIBase - } else { - apiBase = "https://openrouter.ai/api/v1" - } - } else { - return nil, fmt.Errorf("no API key configured for model: %s", model) - } + var usage *UsageInfo + if resp.Usage != nil { + usage = &UsageInfo{ + PromptTokens: resp.Usage.PromptTokens, + CompletionTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, } } - if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { - return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) + return &LLMResponse{ + Content: resp.Content, + ToolCalls: fromOpenAICompatToolCalls(resp.ToolCalls), + FinishReason: resp.FinishReason, + Usage: usage, } - - if apiBase == "" { - return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) - } - - return NewHTTPProvider(apiKey, apiBase, proxy), nil +} + +func fromOpenAICompatToolCalls(toolCalls []openai_compat.ToolCall) []ToolCall { + out := make([]ToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + var fn *FunctionCall + if tc.Function != nil { + fn = &FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + } + out = append(out, ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: fn, + Name: tc.Name, + Arguments: tc.Arguments, + }) + } + return out } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go new file mode 100644 index 000000000..4aef1389a --- /dev/null +++ b/pkg/providers/openai_compat/provider.go @@ -0,0 +1,230 @@ +package openai_compat + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type LLMResponse struct { + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + Usage *UsageInfo `json:"usage,omitempty"` +} + +type UsageInfo struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolDefinition struct { + Type string `json:"type"` + Function ToolFunctionDefinition `json:"function"` +} + +type ToolFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +type Provider struct { + apiKey string + apiBase string + httpClient *http.Client +} + +func NewProvider(apiKey, apiBase string, proxy ...string) *Provider { + proxyURL := "" + if len(proxy) > 0 { + proxyURL = proxy[0] + } + client := &http.Client{ + Timeout: 120 * time.Second, + } + + if proxyURL != "" { + parsed, err := url.Parse(proxyURL) + if err == nil { + client.Transport = &http.Transport{ + Proxy: http.ProxyURL(parsed), + } + } + } + + return &Provider{ + apiKey: apiKey, + apiBase: strings.TrimRight(apiBase, "/"), + httpClient: client, + } +} + +func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + // Strip provider prefix (moonshot/kimi-*, nvidia/*) for OpenAI-compatible backends. + if idx := strings.Index(model, "/"); idx != -1 { + prefix := model[:idx] + if prefix == "moonshot" || prefix == "nvidia" { + model = model[idx+1:] + } + } + + requestBody := map[string]interface{}{ + "model": model, + "messages": messages, + } + + if len(tools) > 0 { + requestBody["tools"] = tools + requestBody["tool_choice"] = "auto" + } + + if maxTokens, ok := options["max_tokens"].(int); ok { + lowerModel := strings.ToLower(model) + if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { + requestBody["max_completion_tokens"] = maxTokens + } else { + requestBody["max_tokens"] = maxTokens + } + } + + if temperature, ok := options["temperature"].(float64); ok { + lowerModel := strings.ToLower(model) + // Kimi k2 models only support temperature=1. + if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { + requestBody["temperature"] = 1.0 + } else { + requestBody["temperature"] = temperature + } + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/chat/completions", bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + req.Header.Set("Authorization", "Bearer "+p.apiKey) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed:\n Status: %d\n Body: %s", resp.StatusCode, string(body)) + } + + return parseResponse(body) +} + +func parseResponse(body []byte) (*LLMResponse, error) { + var apiResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + ToolCalls []struct { + ID string `json:"id"` + Type string `json:"type"` + Function *struct { + Name string `json:"name"` + Arguments string `json:"arguments"` + } `json:"function"` + } `json:"tool_calls"` + } `json:"message"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Usage *UsageInfo `json:"usage"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(apiResponse.Choices) == 0 { + return &LLMResponse{ + Content: "", + FinishReason: "stop", + }, nil + } + + choice := apiResponse.Choices[0] + toolCalls := make([]ToolCall, 0, len(choice.Message.ToolCalls)) + for _, tc := range choice.Message.ToolCalls { + arguments := make(map[string]interface{}) + name := "" + + if tc.Type == "function" && tc.Function != nil { + name = tc.Function.Name + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { + arguments["raw"] = tc.Function.Arguments + } + } + } else if tc.Function != nil { + name = tc.Function.Name + if tc.Function.Arguments != "" { + if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { + arguments["raw"] = tc.Function.Arguments + } + } + } + + toolCalls = append(toolCalls, ToolCall{ + ID: tc.ID, + Name: name, + Arguments: arguments, + }) + } + + return &LLMResponse{ + Content: choice.Message.Content, + ToolCalls: toolCalls, + FinishReason: choice.FinishReason, + Usage: apiResponse.Usage, + }, nil +} diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go new file mode 100644 index 000000000..7c5f1c63c --- /dev/null +++ b/pkg/providers/openai_compat/provider_test.go @@ -0,0 +1,149 @@ +package openai_compat + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { + var requestBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/chat/completions" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL) + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234}) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if _, ok := requestBody["max_completion_tokens"]; !ok { + t.Fatalf("expected max_completion_tokens in request body") + } + if _, ok := requestBody["max_tokens"]; ok { + t.Fatalf("did not expect max_tokens key for glm model") + } +} + +func TestProviderChat_ParsesToolCalls(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{ + "content": "", + "tool_calls": []map[string]interface{}{ + { + "id": "call_1", + "type": "function", + "function": map[string]interface{}{ + "name": "get_weather", + "arguments": "{\"city\":\"SF\"}", + }, + }, + }, + }, + "finish_reason": "tool_calls", + }, + }, + "usage": map[string]interface{}{ + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL) + out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + if len(out.ToolCalls) != 1 { + t.Fatalf("len(ToolCalls) = %d, want 1", len(out.ToolCalls)) + } + if out.ToolCalls[0].Name != "get_weather" { + t.Fatalf("ToolCalls[0].Name = %q, want %q", out.ToolCalls[0].Name, "get_weather") + } + if out.ToolCalls[0].Arguments["city"] != "SF" { + t.Fatalf("ToolCalls[0].Arguments[city] = %v, want SF", out.ToolCalls[0].Arguments["city"]) + } +} + +func TestProviderChat_HTTPError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "bad request", http.StatusBadRequest) + })) + defer server.Close() + + p := NewProvider("key", server.URL) + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testing.T) { + var requestBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL) + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "moonshot/kimi-k2.5", + map[string]interface{}{"temperature": 0.3}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["model"] != "kimi-k2.5" { + t.Fatalf("model = %v, want kimi-k2.5", requestBody["model"]) + } + if requestBody["temperature"] != 1.0 { + t.Fatalf("temperature = %v, want 1.0", requestBody["temperature"]) + } +} From 762565b0d4406aee7fb617d0b5c46d85014ab04e Mon Sep 17 00:00:00 2001 From: Jared Mahotiere Date: Sun, 15 Feb 2026 08:04:12 -0500 Subject: [PATCH 03/31] refactor(providers): move anthropic logic to protocol package --- pkg/providers/anthropic/provider.go | 241 +++++++++++++++++++ pkg/providers/anthropic/provider_test.go | 208 +++++++++++++++++ pkg/providers/claude_provider.go | 281 +++++++++-------------- pkg/providers/claude_provider_test.go | 137 +---------- 4 files changed, 565 insertions(+), 302 deletions(-) create mode 100644 pkg/providers/anthropic/provider.go create mode 100644 pkg/providers/anthropic/provider_test.go diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go new file mode 100644 index 000000000..ca72f0180 --- /dev/null +++ b/pkg/providers/anthropic/provider.go @@ -0,0 +1,241 @@ +package anthropicprovider + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/anthropics/anthropic-sdk-go" + "github.com/anthropics/anthropic-sdk-go/option" +) + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type LLMResponse struct { + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + Usage *UsageInfo `json:"usage,omitempty"` +} + +type UsageInfo struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolDefinition struct { + Type string `json:"type"` + Function ToolFunctionDefinition `json:"function"` +} + +type ToolFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} + +type Provider struct { + client *anthropic.Client + tokenSource func() (string, error) +} + +func NewProvider(token string) *Provider { + client := anthropic.NewClient( + option.WithAuthToken(token), + option.WithBaseURL("https://api.anthropic.com"), + ) + return &Provider{client: &client} +} + +func NewProviderWithClient(client *anthropic.Client) *Provider { + return &Provider{client: client} +} + +func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider { + p := NewProvider(token) + p.tokenSource = tokenSource + return p +} + +func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + var opts []option.RequestOption + if p.tokenSource != nil { + tok, err := p.tokenSource() + if err != nil { + return nil, fmt.Errorf("refreshing token: %w", err) + } + opts = append(opts, option.WithAuthToken(tok)) + } + + params, err := buildParams(messages, tools, model, options) + if err != nil { + return nil, err + } + + resp, err := p.client.Messages.New(ctx, params, opts...) + if err != nil { + return nil, fmt.Errorf("claude API call: %w", err) + } + + return parseResponse(resp), nil +} + +func (p *Provider) GetDefaultModel() string { + return "claude-sonnet-4-5-20250929" +} + +func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { + var system []anthropic.TextBlockParam + var anthropicMessages []anthropic.MessageParam + + for _, msg := range messages { + switch msg.Role { + case "system": + system = append(system, anthropic.TextBlockParam{Text: msg.Content}) + case "user": + if msg.ToolCallID != "" { + anthropicMessages = append(anthropicMessages, + anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), + ) + } else { + anthropicMessages = append(anthropicMessages, + anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), + ) + } + case "assistant": + if len(msg.ToolCalls) > 0 { + var blocks []anthropic.ContentBlockParamUnion + if msg.Content != "" { + blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) + } + for _, tc := range msg.ToolCalls { + blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) + } + anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) + } else { + anthropicMessages = append(anthropicMessages, + anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), + ) + } + case "tool": + anthropicMessages = append(anthropicMessages, + anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), + ) + } + } + + maxTokens := int64(4096) + if mt, ok := options["max_tokens"].(int); ok { + maxTokens = int64(mt) + } + + params := anthropic.MessageNewParams{ + Model: anthropic.Model(model), + Messages: anthropicMessages, + MaxTokens: maxTokens, + } + + if len(system) > 0 { + params.System = system + } + + if temp, ok := options["temperature"].(float64); ok { + params.Temperature = anthropic.Float(temp) + } + + if len(tools) > 0 { + params.Tools = translateTools(tools) + } + + return params, nil +} + +func translateTools(tools []ToolDefinition) []anthropic.ToolUnionParam { + result := make([]anthropic.ToolUnionParam, 0, len(tools)) + for _, t := range tools { + tool := anthropic.ToolParam{ + Name: t.Function.Name, + InputSchema: anthropic.ToolInputSchemaParam{ + Properties: t.Function.Parameters["properties"], + }, + } + if desc := t.Function.Description; desc != "" { + tool.Description = anthropic.String(desc) + } + if req, ok := t.Function.Parameters["required"].([]interface{}); ok { + required := make([]string, 0, len(req)) + for _, r := range req { + if s, ok := r.(string); ok { + required = append(required, s) + } + } + tool.InputSchema.Required = required + } + result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) + } + return result +} + +func parseResponse(resp *anthropic.Message) *LLMResponse { + var content string + var toolCalls []ToolCall + + for _, block := range resp.Content { + switch block.Type { + case "text": + tb := block.AsText() + content += tb.Text + case "tool_use": + tu := block.AsToolUse() + var args map[string]interface{} + if err := json.Unmarshal(tu.Input, &args); err != nil { + args = map[string]interface{}{"raw": string(tu.Input)} + } + toolCalls = append(toolCalls, ToolCall{ + ID: tu.ID, + Name: tu.Name, + Arguments: args, + }) + } + } + + finishReason := "stop" + switch resp.StopReason { + case anthropic.StopReasonToolUse: + finishReason = "tool_calls" + case anthropic.StopReasonMaxTokens: + finishReason = "length" + case anthropic.StopReasonEndTurn: + finishReason = "stop" + } + + return &LLMResponse{ + Content: content, + ToolCalls: toolCalls, + FinishReason: finishReason, + Usage: &UsageInfo{ + PromptTokens: int(resp.Usage.InputTokens), + CompletionTokens: int(resp.Usage.OutputTokens), + TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), + }, + } +} diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go new file mode 100644 index 000000000..01b4fe663 --- /dev/null +++ b/pkg/providers/anthropic/provider_test.go @@ -0,0 +1,208 @@ +package anthropicprovider + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/anthropics/anthropic-sdk-go" + anthropicoption "github.com/anthropics/anthropic-sdk-go/option" +) + +func TestBuildParams_BasicMessage(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "Hello"}, + } + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{ + "max_tokens": 1024, + }) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if string(params.Model) != "claude-sonnet-4-5-20250929" { + t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929") + } + if params.MaxTokens != 1024 { + t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens) + } + if len(params.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) + } +} + +func TestBuildParams_SystemMessage(t *testing.T) { + messages := []Message{ + {Role: "system", Content: "You are helpful"}, + {Role: "user", Content: "Hi"}, + } + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if len(params.System) != 1 { + t.Fatalf("len(System) = %d, want 1", len(params.System)) + } + if params.System[0].Text != "You are helpful" { + t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful") + } + if len(params.Messages) != 1 { + t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) + } +} + +func TestBuildParams_ToolCallMessage(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "What's the weather?"}, + { + Role: "assistant", + Content: "", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Name: "get_weather", + Arguments: map[string]interface{}{"city": "SF"}, + }, + }, + }, + {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, + } + params, err := buildParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if len(params.Messages) != 3 { + t.Fatalf("len(Messages) = %d, want 3", len(params.Messages)) + } +} + +func TestBuildParams_WithTools(t *testing.T) { + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "get_weather", + Description: "Get weather for a city", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "city": map[string]interface{}{"type": "string"}, + }, + "required": []interface{}{"city"}, + }, + }, + }, + } + params, err := buildParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("buildParams() error: %v", err) + } + if len(params.Tools) != 1 { + t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) + } +} + +func TestParseResponse_TextOnly(t *testing.T) { + resp := &anthropic.Message{ + Content: []anthropic.ContentBlockUnion{}, + Usage: anthropic.Usage{ + InputTokens: 10, + OutputTokens: 20, + }, + } + result := parseResponse(resp) + if result.Usage.PromptTokens != 10 { + t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) + } + if result.Usage.CompletionTokens != 20 { + t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens) + } + if result.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") + } +} + +func TestParseResponse_StopReasons(t *testing.T) { + tests := []struct { + stopReason anthropic.StopReason + want string + }{ + {anthropic.StopReasonEndTurn, "stop"}, + {anthropic.StopReasonMaxTokens, "length"}, + {anthropic.StopReasonToolUse, "tool_calls"}, + } + for _, tt := range tests { + resp := &anthropic.Message{ + StopReason: tt.stopReason, + } + result := parseResponse(resp) + if result.FinishReason != tt.want { + t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want) + } + } +} + +func TestProvider_ChatRoundTrip(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + http.Error(w, "not found", http.StatusNotFound) + return + } + if r.Header.Get("Authorization") != "Bearer test-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + + resp := map[string]interface{}{ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": reqBody["model"], + "stop_reason": "end_turn", + "content": []map[string]interface{}{ + {"type": "text", "text": "Hello! How can I help you?"}, + }, + "usage": map[string]interface{}{ + "input_tokens": 15, + "output_tokens": 8, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + provider := NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hello! How can I help you?" { + t.Errorf("Content = %q, want %q", resp.Content, "Hello! How can I help you?") + } + if resp.FinishReason != "stop" { + t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") + } + if resp.Usage.PromptTokens != 15 { + t.Errorf("PromptTokens = %d, want 15", resp.Usage.PromptTokens) + } +} + +func TestProvider_GetDefaultModel(t *testing.T) { + p := NewProvider("test-token") + if got := p.GetDefaultModel(); got != "claude-sonnet-4-5-20250929" { + t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-sonnet-4-5-20250929") + } +} + +func createAnthropicTestClient(baseURL, token string) *anthropic.Client { + c := anthropic.NewClient( + anthropicoption.WithAuthToken(token), + anthropicoption.WithBaseURL(baseURL), + ) + return &c +} diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index ae6aca96d..16f1884c5 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -2,195 +2,48 @@ package providers import ( "context" - "encoding/json" "fmt" - "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/option" "github.com/sipeed/picoclaw/pkg/auth" + anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) type ClaudeProvider struct { - client *anthropic.Client - tokenSource func() (string, error) + delegate *anthropicprovider.Provider } func NewClaudeProvider(token string) *ClaudeProvider { - client := anthropic.NewClient( - option.WithAuthToken(token), - option.WithBaseURL("https://api.anthropic.com"), - ) - return &ClaudeProvider{client: &client} + return &ClaudeProvider{ + delegate: anthropicprovider.NewProvider(token), + } } func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { - p := NewClaudeProvider(token) - p.tokenSource = tokenSource - return p + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource), + } +} + +func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider { + return &ClaudeProvider{delegate: delegate} } func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - var opts []option.RequestOption - if p.tokenSource != nil { - tok, err := p.tokenSource() - if err != nil { - return nil, fmt.Errorf("refreshing token: %w", err) - } - opts = append(opts, option.WithAuthToken(tok)) - } - - params, err := buildClaudeParams(messages, tools, model, options) + resp, err := p.delegate.Chat( + ctx, + toAnthropicProviderMessages(messages), + toAnthropicProviderTools(tools), + model, + options, + ) if err != nil { return nil, err } - - resp, err := p.client.Messages.New(ctx, params, opts...) - if err != nil { - return nil, fmt.Errorf("claude API call: %w", err) - } - - return parseClaudeResponse(resp), nil + return fromAnthropicProviderResponse(resp), nil } func (p *ClaudeProvider) GetDefaultModel() string { - return "claude-sonnet-4-5-20250929" -} - -func buildClaudeParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { - var system []anthropic.TextBlockParam - var anthropicMessages []anthropic.MessageParam - - for _, msg := range messages { - switch msg.Role { - case "system": - system = append(system, anthropic.TextBlockParam{Text: msg.Content}) - case "user": - if msg.ToolCallID != "" { - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), - ) - } else { - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewTextBlock(msg.Content)), - ) - } - case "assistant": - if len(msg.ToolCalls) > 0 { - var blocks []anthropic.ContentBlockParamUnion - if msg.Content != "" { - blocks = append(blocks, anthropic.NewTextBlock(msg.Content)) - } - for _, tc := range msg.ToolCalls { - blocks = append(blocks, anthropic.NewToolUseBlock(tc.ID, tc.Arguments, tc.Name)) - } - anthropicMessages = append(anthropicMessages, anthropic.NewAssistantMessage(blocks...)) - } else { - anthropicMessages = append(anthropicMessages, - anthropic.NewAssistantMessage(anthropic.NewTextBlock(msg.Content)), - ) - } - case "tool": - anthropicMessages = append(anthropicMessages, - anthropic.NewUserMessage(anthropic.NewToolResultBlock(msg.ToolCallID, msg.Content, false)), - ) - } - } - - maxTokens := int64(4096) - if mt, ok := options["max_tokens"].(int); ok { - maxTokens = int64(mt) - } - - params := anthropic.MessageNewParams{ - Model: anthropic.Model(model), - Messages: anthropicMessages, - MaxTokens: maxTokens, - } - - if len(system) > 0 { - params.System = system - } - - if temp, ok := options["temperature"].(float64); ok { - params.Temperature = anthropic.Float(temp) - } - - if len(tools) > 0 { - params.Tools = translateToolsForClaude(tools) - } - - return params, nil -} - -func translateToolsForClaude(tools []ToolDefinition) []anthropic.ToolUnionParam { - result := make([]anthropic.ToolUnionParam, 0, len(tools)) - for _, t := range tools { - tool := anthropic.ToolParam{ - Name: t.Function.Name, - InputSchema: anthropic.ToolInputSchemaParam{ - Properties: t.Function.Parameters["properties"], - }, - } - if desc := t.Function.Description; desc != "" { - tool.Description = anthropic.String(desc) - } - if req, ok := t.Function.Parameters["required"].([]interface{}); ok { - required := make([]string, 0, len(req)) - for _, r := range req { - if s, ok := r.(string); ok { - required = append(required, s) - } - } - tool.InputSchema.Required = required - } - result = append(result, anthropic.ToolUnionParam{OfTool: &tool}) - } - return result -} - -func parseClaudeResponse(resp *anthropic.Message) *LLMResponse { - var content string - var toolCalls []ToolCall - - for _, block := range resp.Content { - switch block.Type { - case "text": - tb := block.AsText() - content += tb.Text - case "tool_use": - tu := block.AsToolUse() - var args map[string]interface{} - if err := json.Unmarshal(tu.Input, &args); err != nil { - args = map[string]interface{}{"raw": string(tu.Input)} - } - toolCalls = append(toolCalls, ToolCall{ - ID: tu.ID, - Name: tu.Name, - Arguments: args, - }) - } - } - - finishReason := "stop" - switch resp.StopReason { - case anthropic.StopReasonToolUse: - finishReason = "tool_calls" - case anthropic.StopReasonMaxTokens: - finishReason = "length" - case anthropic.StopReasonEndTurn: - finishReason = "stop" - } - - return &LLMResponse{ - Content: content, - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: &UsageInfo{ - PromptTokens: int(resp.Usage.InputTokens), - CompletionTokens: int(resp.Usage.OutputTokens), - TotalTokens: int(resp.Usage.InputTokens + resp.Usage.OutputTokens), - }, - } + return p.delegate.GetDefaultModel() } func createClaudeTokenSource() func() (string, error) { @@ -205,3 +58,95 @@ func createClaudeTokenSource() func() (string, error) { return cred.AccessToken, nil } } + +func toAnthropicProviderMessages(messages []Message) []anthropicprovider.Message { + out := make([]anthropicprovider.Message, 0, len(messages)) + for _, msg := range messages { + out = append(out, anthropicprovider.Message{ + Role: msg.Role, + Content: msg.Content, + ToolCalls: toAnthropicProviderToolCalls(msg.ToolCalls), + ToolCallID: msg.ToolCallID, + }) + } + return out +} + +func toAnthropicProviderTools(tools []ToolDefinition) []anthropicprovider.ToolDefinition { + out := make([]anthropicprovider.ToolDefinition, 0, len(tools)) + for _, t := range tools { + out = append(out, anthropicprovider.ToolDefinition{ + Type: t.Type, + Function: anthropicprovider.ToolFunctionDefinition{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }, + }) + } + return out +} + +func toAnthropicProviderToolCalls(toolCalls []ToolCall) []anthropicprovider.ToolCall { + out := make([]anthropicprovider.ToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + var fn *anthropicprovider.FunctionCall + if tc.Function != nil { + fn = &anthropicprovider.FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + } + out = append(out, anthropicprovider.ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: fn, + Name: tc.Name, + Arguments: tc.Arguments, + }) + } + return out +} + +func fromAnthropicProviderResponse(resp *anthropicprovider.LLMResponse) *LLMResponse { + if resp == nil { + return &LLMResponse{} + } + + var usage *UsageInfo + if resp.Usage != nil { + usage = &UsageInfo{ + PromptTokens: resp.Usage.PromptTokens, + CompletionTokens: resp.Usage.CompletionTokens, + TotalTokens: resp.Usage.TotalTokens, + } + } + + return &LLMResponse{ + Content: resp.Content, + ToolCalls: fromAnthropicProviderToolCalls(resp.ToolCalls), + FinishReason: resp.FinishReason, + Usage: usage, + } +} + +func fromAnthropicProviderToolCalls(toolCalls []anthropicprovider.ToolCall) []ToolCall { + out := make([]ToolCall, 0, len(toolCalls)) + for _, tc := range toolCalls { + var fn *FunctionCall + if tc.Function != nil { + fn = &FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + } + } + out = append(out, ToolCall{ + ID: tc.ID, + Type: tc.Type, + Function: fn, + Name: tc.Name, + Arguments: tc.Arguments, + }) + } + return out +} diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go index bbad2d269..13bbde1fc 100644 --- a/pkg/providers/claude_provider_test.go +++ b/pkg/providers/claude_provider_test.go @@ -8,140 +8,9 @@ import ( "github.com/anthropics/anthropic-sdk-go" anthropicoption "github.com/anthropics/anthropic-sdk-go/option" + anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) -func TestBuildClaudeParams_BasicMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "Hello"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{ - "max_tokens": 1024, - }) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if string(params.Model) != "claude-sonnet-4-5-20250929" { - t.Errorf("Model = %q, want %q", params.Model, "claude-sonnet-4-5-20250929") - } - if params.MaxTokens != 1024 { - t.Errorf("MaxTokens = %d, want 1024", params.MaxTokens) - } - if len(params.Messages) != 1 { - t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) - } -} - -func TestBuildClaudeParams_SystemMessage(t *testing.T) { - messages := []Message{ - {Role: "system", Content: "You are helpful"}, - {Role: "user", Content: "Hi"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.System) != 1 { - t.Fatalf("len(System) = %d, want 1", len(params.System)) - } - if params.System[0].Text != "You are helpful" { - t.Errorf("System[0].Text = %q, want %q", params.System[0].Text, "You are helpful") - } - if len(params.Messages) != 1 { - t.Fatalf("len(Messages) = %d, want 1", len(params.Messages)) - } -} - -func TestBuildClaudeParams_ToolCallMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "What's the weather?"}, - { - Role: "assistant", - Content: "", - ToolCalls: []ToolCall{ - { - ID: "call_1", - Name: "get_weather", - Arguments: map[string]interface{}{"city": "SF"}, - }, - }, - }, - {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, - } - params, err := buildClaudeParams(messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.Messages) != 3 { - t.Fatalf("len(Messages) = %d, want 3", len(params.Messages)) - } -} - -func TestBuildClaudeParams_WithTools(t *testing.T) { - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get weather for a city", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, - }, - "required": []interface{}{"city"}, - }, - }, - }, - } - params, err := buildClaudeParams([]Message{{Role: "user", Content: "Hi"}}, tools, "claude-sonnet-4-5-20250929", map[string]interface{}{}) - if err != nil { - t.Fatalf("buildClaudeParams() error: %v", err) - } - if len(params.Tools) != 1 { - t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) - } -} - -func TestParseClaudeResponse_TextOnly(t *testing.T) { - resp := &anthropic.Message{ - Content: []anthropic.ContentBlockUnion{}, - Usage: anthropic.Usage{ - InputTokens: 10, - OutputTokens: 20, - }, - } - result := parseClaudeResponse(resp) - if result.Usage.PromptTokens != 10 { - t.Errorf("PromptTokens = %d, want 10", result.Usage.PromptTokens) - } - if result.Usage.CompletionTokens != 20 { - t.Errorf("CompletionTokens = %d, want 20", result.Usage.CompletionTokens) - } - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") - } -} - -func TestParseClaudeResponse_StopReasons(t *testing.T) { - tests := []struct { - stopReason anthropic.StopReason - want string - }{ - {anthropic.StopReasonEndTurn, "stop"}, - {anthropic.StopReasonMaxTokens, "length"}, - {anthropic.StopReasonToolUse, "tool_calls"}, - } - for _, tt := range tests { - resp := &anthropic.Message{ - StopReason: tt.stopReason, - } - result := parseClaudeResponse(resp) - if result.FinishReason != tt.want { - t.Errorf("StopReason %q: FinishReason = %q, want %q", tt.stopReason, result.FinishReason, tt.want) - } - } -} - func TestClaudeProvider_ChatRoundTrip(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/v1/messages" { @@ -175,8 +44,8 @@ func TestClaudeProvider_ChatRoundTrip(t *testing.T) { })) defer server.Close() - provider := NewClaudeProvider("test-token") - provider.client = createAnthropicTestClient(server.URL, "test-token") + delegate := anthropicprovider.NewProviderWithClient(createAnthropicTestClient(server.URL, "test-token")) + provider := newClaudeProviderWithDelegate(delegate) messages := []Message{{Role: "user", Content: "Hello"}} resp, err := provider.Chat(t.Context(), messages, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{"max_tokens": 1024}) From 362c49a69d0465b711153e1ab14eeaaeb779eee6 Mon Sep 17 00:00:00 2001 From: Jared Mahotiere Date: Sun, 15 Feb 2026 08:04:16 -0500 Subject: [PATCH 04/31] docs(test): document protocol architecture and migration compatibility --- README.md | 10 ++++++++++ pkg/migrate/migrate_test.go | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/README.md b/README.md index 091af2811..25c6d9863 100644 --- a/README.md +++ b/README.md @@ -662,6 +662,16 @@ The subagent has access to tools (message, web_search, etc.) and can communicate | `deepseek(To be tested)` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | +### Provider Architecture + +PicoClaw routes providers by protocol family: + +- OpenAI-compatible protocol: OpenRouter, OpenAI-compatible gateways, Groq, Zhipu, and vLLM-style endpoints. +- Anthropic protocol: Claude-native API behavior. +- Codex/OAuth path: OpenAI OAuth/token authentication route. + +This keeps the runtime lightweight while making new OpenAI-compatible backends mostly a config operation (`api_base` + `api_key`). +
Zhipu diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index be2360aac..e930d45f4 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -299,6 +299,24 @@ func TestConvertConfig(t *testing.T) { }) } +func TestSupportedProvidersCompatibility(t *testing.T) { + expected := []string{ + "anthropic", + "openai", + "openrouter", + "groq", + "zhipu", + "vllm", + "gemini", + } + + for _, provider := range expected { + if !supportedProviders[provider] { + t.Fatalf("supportedProviders missing expected key %q", provider) + } + } +} + func TestMergeConfig(t *testing.T) { t.Run("fills empty fields", func(t *testing.T) { existing := config.DefaultConfig() From 97bf4ff3fddd99c5a6a1d9a74a4e7637f34d7063 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Sun, 15 Feb 2026 23:56:13 +0900 Subject: [PATCH 05/31] Fix Japanese translation --- README.ja.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.ja.md b/README.ja.md index e33b312f9..706af2c75 100644 --- a/README.ja.md +++ b/README.ja.md @@ -3,7 +3,7 @@

PicoClaw: Go で書かれた超効率 AI アシスタント

-

$10 ハードウェア · 10MB RAM · 1秒起動 · 皮皮虾,我们走!

+

$10 ハードウェア · 10MB RAM · 1秒起動 · 行くぜ、シャコ!

@@ -39,7 +39,7 @@ ## 📢 ニュース -2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 皮皮虾,我们走! +2026-02-09 🎉 PicoClaw リリース!$10 ハードウェアで 10MB 未満の RAM で動く AI エージェントを 1 日で構築。🦐 行くぜ、シャコ! ## ✨ 特徴 @@ -729,7 +729,7 @@ Discord: https://discord.gg/V4sAZ9XWpN ## 🐛 トラブルシューティング -### Web 検索で「API 配置问题」と表示される +### Web 検索で「API 設定の問題」と表示される 検索 API キーをまだ設定していない場合、これは正常です。PicoClaw は手動検索用の便利なリンクを提供します。 From 7ce5b75178356d4c81044faa6d2ea06cd69ec507 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Mon, 16 Feb 2026 00:47:17 +0900 Subject: [PATCH 06/31] Fix shadowing field runnnig --- pkg/channels/maixcam.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/channels/maixcam.go b/pkg/channels/maixcam.go index 5fc19adbe..01e570b25 100644 --- a/pkg/channels/maixcam.go +++ b/pkg/channels/maixcam.go @@ -18,7 +18,6 @@ type MaixCamChannel struct { listener net.Listener clients map[net.Conn]bool clientsMux sync.RWMutex - running bool } type MaixCamMessage struct { @@ -35,7 +34,6 @@ func NewMaixCamChannel(cfg config.MaixCamConfig, bus *bus.MessageBus) (*MaixCamC BaseChannel: base, config: cfg, clients: make(map[net.Conn]bool), - running: false, }, nil } From ff3c875b3fad1116a7ea7e22a10034a741b38f18 Mon Sep 17 00:00:00 2001 From: Humaid Koreshi Date: Tue, 17 Feb 2026 02:15:59 +0600 Subject: [PATCH 07/31] docs: add missing Chinese language link to Japanese README --- README.ja.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.ja.md b/README.ja.md index e33b312f9..c6babf510 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-**日本語** | [English](README.md) +[中文](README.zh.md) | **日本語** | [English](README.md) From 57dac394c517615b542d545008ad611252eadeb9 Mon Sep 17 00:00:00 2001 From: zepan Date: Tue, 17 Feb 2026 09:30:30 +0800 Subject: [PATCH 08/31] update pr template --- .github/pull_request_template.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..d2773e27d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,32 @@ +## 📝 Description +## 🗣️ Type of Change +- [ ] 🐞 Bug fix (non-breaking change which fixes an issue) +- [ ] ✨ New feature (non-breaking change which adds functionality) +- [ ] 📖 Documentation update +- [ ] ⚡ Code refactoring (no functional changes, no api changes) + + +## 🔗 Linked Issue +## 📚 Technical Context (Skip for Docs) +* **Reference:** [URL] +* **Reasoning:** ... + + +## 🧪 Test Environment & Hardware +- **Hardware:** [e.g. Raspberry Pi 5, Orange Pi, PC] +- **OS:** [e.g. Debian 12, Ubuntu 22.04] +- **Model/Provider:** [e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3] +- **Channels:** [e.g. Discord, Telegram, Feishu, ...] + + +## 📸 Proof of Work (Optional for Docs) +
+Click to view Logs/Screenshots + +
+ + +## ☑️ Checklist +- [ ] My code/docs follow the style of this project. +- [ ] I have performed a self-review of my own changes. +- [ ] I have updated the documentation accordingly. \ No newline at end of file From 75fb728a1161a6a92e17f3470f9b28508c0daada Mon Sep 17 00:00:00 2001 From: AlbertBui010 Date: Tue, 17 Feb 2026 09:17:03 +0700 Subject: [PATCH 09/31] docs: add Vietnamese README (README.vi.md) - Add full Vietnamese translation of README.md - Update language selector links in README.md, README.zh.md, README.ja.md --- README.ja.md | 2 +- README.md | 2 +- README.vi.md | 859 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.zh.md | 2 +- 4 files changed, 862 insertions(+), 3 deletions(-) create mode 100644 README.vi.md diff --git a/README.ja.md b/README.ja.md index e33b312f9..fa4eae69a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-**日本語** | [English](README.md) +**日本語** | [Tiếng Việt](README.vi.md) | [English](README.md) diff --git a/README.md b/README.md index 0a9dacce6..6ec28a315 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | **English** + [中文](README.zh.md) | [日本語](README.ja.md) | [Tiếng Việt](README.vi.md) | **English** --- diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 000000000..533ef7607 --- /dev/null +++ b/README.vi.md @@ -0,0 +1,859 @@ +
+PicoClaw + +

PicoClaw: Trợ lý AI Siêu Nhẹ viết bằng Go

+ +

Phần cứng $10 · RAM 10MB · Khởi động 1 giây · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Tiếng Việt** +
+ +--- + +🦐 **PicoClaw** là trợ lý AI cá nhân siêu nhẹ, lấy cảm hứng từ [nanobot](https://github.com/HKUDS/nanobot), được viết lại hoàn toàn bằng **Go** thông qua quá trình "tự khởi tạo" (self-bootstrapping) — nơi chính AI Agent đã tự dẫn dắt toàn bộ quá trình chuyển đổi kiến trúc và tối ưu hóa mã nguồn. + +⚡️ **Cực kỳ nhẹ:** Chạy trên phần cứng chỉ **$10** với RAM **<10MB**. Tiết kiệm 99% bộ nhớ so với OpenClaw và rẻ hơn 98% so với Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 TUYÊN BỐ BẢO MẬT & KÊNH CHÍNH THỨC** +> +> * **KHÔNG CÓ CRYPTO:** PicoClaw **KHÔNG** có bất kỳ token/coin chính thức nào. Mọi thông tin trên `pump.fun` hoặc các sàn giao dịch khác đều là **LỪA ĐẢO**. +> * **DOMAIN CHÍNH THỨC:** Website chính thức **DUY NHẤT** là **[picoclaw.io](https://picoclaw.io)**, website công ty là **[sipeed.com](https://sipeed.com)**. +> * **Cảnh báo:** Nhiều tên miền `.ai/.org/.com/.net/...` đã bị bên thứ ba đăng ký, không phải của chúng tôi. +> * **Cảnh báo:** PicoClaw đang trong giai đoạn phát triển sớm và có thể còn các vấn đề bảo mật mạng chưa được giải quyết. Không nên triển khai lên môi trường production trước phiên bản v1.0. +> * **Lưu ý:** PicoClaw gần đây đã merge nhiều PR, dẫn đến bộ nhớ sử dụng có thể lớn hơn (10–20MB) ở các phiên bản mới nhất. Chúng tôi sẽ ưu tiên tối ưu tài nguyên khi bộ tính năng đã ổn định. + + +## 📢 Tin tức + +2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](doc/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn! + +2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw. +🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần. + +2026-02-09 🎉 PicoClaw chính thức ra mắt! Được xây dựng trong 1 ngày để mang AI Agent đến phần cứng $10 với RAM <10MB. 🦐 PicoClaw, Lên Đường! + +## ✨ Tính năng nổi bật + +🪶 **Siêu nhẹ**: Bộ nhớ sử dụng <10MB — nhỏ hơn 99% so với Clawdbot (chức năng cốt lõi). + +💰 **Chi phí tối thiểu**: Đủ hiệu quả để chạy trên phần cứng $10 — rẻ hơn 98% so với Mac mini. + +⚡️ **Khởi động siêu nhanh**: Nhanh gấp 400 lần, khởi động trong 1 giây ngay cả trên CPU đơn nhân 0.6GHz. + +🌍 **Di động thực sự**: Một file binary duy nhất chạy trên RISC-V, ARM và x86. Một click là chạy! + +🤖 **AI tự xây dựng**: Triển khai Go-native tự động — 95% mã nguồn cốt lõi được Agent tạo ra, với sự tinh chỉnh của con người. + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Ngôn ngữ** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **Thời gian khởi động**
(CPU 0.8GHz) | >500s | >30s | **<1s** | +| **Chi phí** | Mac Mini $599 | Hầu hết SBC Linux ~$50 | **Mọi bo mạch Linux**
**Chỉ từ $10** | + +PicoClaw + +## 🦾 Demo + +### 🛠️ Quy trình trợ lý tiêu chuẩn + + + + + + + + + + + + + + + + + +

🧩 Lập trình Full-Stack

🗂️ Quản lý Nhật ký & Kế hoạch

🔎 Tìm kiếm Web & Học hỏi

Phát triển • Triển khai • Mở rộngLên lịch • Tự động hóa • Ghi nhớKhám phá • Phân tích • Xu hướng
+ +### 🐜 Triển khai sáng tạo trên phần cứng tối thiểu + +PicoClaw có thể triển khai trên hầu hết mọi thiết bị Linux! + +* $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) phiên bản E (Ethernet) hoặc W (WiFi6), dùng làm Trợ lý Gia đình tối giản. +* $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), hoặc $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html), dùng cho quản trị Server tự động. +* $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) hoặc $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera), dùng cho Giám sát thông minh. + +https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 + +🌟 Nhiều hình thức triển khai hơn đang chờ bạn khám phá! + +## 📦 Cài đặt + +### Cài đặt bằng binary biên dịch sẵn + +Tải file binary cho nền tảng của bạn từ [trang Release](https://github.com/sipeed/picoclaw/releases). + +### Cài đặt từ mã nguồn (có tính năng mới nhất, khuyên dùng cho phát triển) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build (không cần cài đặt) +make build + +# Build cho nhiều nền tảng +make build-all + +# Build và cài đặt +make install +``` + +## 🐳 Docker Compose + +Bạn cũng có thể chạy PicoClaw bằng Docker Compose mà không cần cài đặt gì trên máy. + +```bash +# 1. Clone repo +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Thiết lập API Key +cp config/config.example.json config/config.json +vim config/config.json # Thiết lập DISCORD_BOT_TOKEN, API keys, v.v. + +# 3. Build & Khởi động +docker compose --profile gateway up -d + +# 4. Xem logs +docker compose logs -f picoclaw-gateway + +# 5. Dừng +docker compose --profile gateway down +``` + +### Chế độ Agent (chạy một lần) + +```bash +# Đặt câu hỏi +docker compose run --rm picoclaw-agent -m "2+2 bằng mấy?" + +# Chế độ tương tác +docker compose run --rm picoclaw-agent +``` + +### Build lại + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 Bắt đầu nhanh + +> [!TIP] +> Thiết lập API key trong `~/.picoclaw/config.json`. +> Lấy API key: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> Tìm kiếm web là **tùy chọn** — lấy [Brave Search API](https://brave.com/search/api) miễn phí (2000 truy vấn/tháng) hoặc dùng tính năng auto fallback tích hợp sẵn. + +**1. Khởi tạo** + +```bash +picoclaw onboard +``` + +**2. Cấu hình** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +**3. Lấy API Key** + +* **Nhà cung cấp LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Tìm kiếm Web** (tùy chọn): [Brave Search](https://brave.com/search/api) — Có gói miễn phí (2000 truy vấn/tháng) + +> **Lưu ý**: Xem `config.example.json` để có mẫu cấu hình đầy đủ. + +**4. Trò chuyện** + +```bash +picoclaw agent -m "Xin chào, bạn là ai?" +``` + +Vậy là xong! Bạn đã có một trợ lý AI hoạt động chỉ trong 2 phút. + +--- + +## 💬 Tích hợp ứng dụng Chat + +Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE. + +| Kênh | Mức độ thiết lập | +| --- | --- | +| **Telegram** | Dễ (chỉ cần token) | +| **Discord** | Dễ (bot token + intents) | +| **QQ** | Dễ (AppID + AppSecret) | +| **DingTalk** | Trung bình (app credentials) | +| **LINE** | Trung bình (credentials + webhook URL) | + +
+Telegram (Khuyên dùng) + +**1. Tạo bot** + +* Mở Telegram, tìm `@BotFather` +* Gửi `/newbot`, làm theo hướng dẫn +* Sao chép token + +**2. Cấu hình** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +> Lấy User ID từ `@userinfobot` trên Telegram. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+Discord + +**1. Tạo bot** + +* Truy cập +* Create an application → Bot → Add Bot +* Sao chép bot token + +**2. Bật Intents** + +* Trong phần Bot settings, bật **MESSAGE CONTENT INTENT** +* (Tùy chọn) Bật **SERVER MEMBERS INTENT** nếu muốn dùng danh sách cho phép theo thông tin thành viên + +**3. Lấy User ID** + +* Discord Settings → Advanced → bật **Developer Mode** +* Click chuột phải vào avatar → **Copy User ID** + +**4. Cấu hình** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Mời bot vào server** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Mở URL mời được tạo và thêm bot vào server của bạn + +**6. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Tạo bot** + +* Truy cập [QQ Open Platform](https://q.qq.com/#) +* Tạo ứng dụng → Lấy **AppID** và **AppSecret** + +**2. Cấu hình** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định số QQ để giới hạn quyền truy cập. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Tạo bot** + +* Truy cập [Open Platform](https://open.dingtalk.com/) +* Tạo ứng dụng nội bộ +* Sao chép Client ID và Client Secret + +**2. Cấu hình** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Để `allow_from` trống để cho phép tất cả người dùng, hoặc chỉ định ID để giới hạn quyền truy cập. + +**3. Chạy** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. Tạo tài khoản LINE Official** + +- Truy cập [LINE Developers Console](https://developers.line.biz/) +- Tạo provider → Tạo Messaging API channel +- Sao chép **Channel Secret** và **Channel Access Token** + +**2. Cấu hình** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Thiết lập Webhook URL** + +LINE yêu cầu HTTPS cho webhook. Sử dụng reverse proxy hoặc tunnel: + +```bash +# Ví dụ với ngrok +ngrok http 18791 +``` + +Sau đó cài đặt Webhook URL trong LINE Developers Console thành `https://your-domain/webhook/line` và bật **Use webhook**. + +**4. Chạy** + +```bash +picoclaw gateway +``` + +> Trong nhóm chat, bot chỉ phản hồi khi được @mention. Các câu trả lời sẽ trích dẫn tin nhắn gốc. + +> **Docker Compose**: Thêm `ports: ["18791:18791"]` vào service `picoclaw-gateway` để mở port webhook. + +
+ +## ClawdChat Tham gia Mạng xã hội Agent + +Kết nối PicoClaw với Mạng xã hội Agent chỉ bằng cách gửi một tin nhắn qua CLI hoặc bất kỳ ứng dụng Chat nào đã tích hợp. + +**Đọc `https://clawdchat.ai/skill.md` và làm theo hướng dẫn để tham gia [ClawdChat.ai](https://clawdchat.ai)** + +## ⚙️ Cấu hình chi tiết + +File cấu hình: `~/.picoclaw/config.json` + +### Cấu trúc Workspace + +PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Phiên hội thoại và lịch sử +├── memory/ # Bộ nhớ dài hạn (MEMORY.md) +├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.) +├── cron/ # Cơ sở dữ liệu tác vụ định kỳ +├── skills/ # Kỹ năng tùy chỉnh +├── AGENTS.md # Hướng dẫn hành vi Agent +├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút) +├── IDENTITY.md # Danh tính Agent +├── SOUL.md # Tâm hồn/Tính cách Agent +├── TOOLS.md # Mô tả công cụ +└── USER.md # Tùy chọn người dùng +``` + +### 🔒 Hộp cát bảo mật (Security Sandbox) + +PicoClaw chạy trong môi trường sandbox theo mặc định. Agent chỉ có thể truy cập file và thực thi lệnh trong phạm vi workspace. + +#### Cấu hình mặc định + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Tùy chọn | Mặc định | Mô tả | +|----------|---------|-------| +| `workspace` | `~/.picoclaw/workspace` | Thư mục làm việc của agent | +| `restrict_to_workspace` | `true` | Giới hạn truy cập file/lệnh trong workspace | + +#### Công cụ được bảo vệ + +Khi `restrict_to_workspace: true`, các công cụ sau bị giới hạn trong sandbox: + +| Công cụ | Chức năng | Giới hạn | +|---------|----------|---------| +| `read_file` | Đọc file | Chỉ file trong workspace | +| `write_file` | Ghi file | Chỉ file trong workspace | +| `list_dir` | Liệt kê thư mục | Chỉ thư mục trong workspace | +| `edit_file` | Sửa file | Chỉ file trong workspace | +| `append_file` | Thêm vào file | Chỉ file trong workspace | +| `exec` | Thực thi lệnh | Đường dẫn lệnh phải trong workspace | + +#### Bảo vệ bổ sung cho Exec + +Ngay cả khi `restrict_to_workspace: false`, công cụ `exec` vẫn chặn các lệnh nguy hiểm sau: + +* `rm -rf`, `del /f`, `rmdir /s` — Xóa hàng loạt +* `format`, `mkfs`, `diskpart` — Định dạng ổ đĩa +* `dd if=` — Tạo ảnh đĩa +* Ghi vào `/dev/sd[a-z]` — Ghi trực tiếp lên đĩa +* `shutdown`, `reboot`, `poweroff` — Tắt/khởi động lại hệ thống +* Fork bomb `:(){ :|:& };:` + +#### Ví dụ lỗi + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Tắt giới hạn (Rủi ro bảo mật) + +Nếu bạn cần agent truy cập đường dẫn ngoài workspace: + +**Cách 1: File cấu hình** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Cách 2: Biến môi trường** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Cảnh báo**: Tắt giới hạn này cho phép agent truy cập mọi đường dẫn trên hệ thống. Chỉ sử dụng cẩn thận trong môi trường được kiểm soát. + +#### Tính nhất quán của ranh giới bảo mật + +Cài đặt `restrict_to_workspace` áp dụng nhất quán trên mọi đường thực thi: + +| Đường thực thi | Ranh giới bảo mật | +|----------------|-------------------| +| Agent chính | `restrict_to_workspace` ✅ | +| Subagent / Spawn | Kế thừa cùng giới hạn ✅ | +| Tác vụ Heartbeat | Kế thừa cùng giới hạn ✅ | + +Tất cả đường thực thi chia sẻ cùng giới hạn workspace — không có cách nào vượt qua ranh giới bảo mật thông qua subagent hoặc tác vụ định kỳ. + +### Heartbeat (Tác vụ định kỳ) + +PicoClaw có thể tự động thực hiện các tác vụ định kỳ. Tạo file `HEARTBEAT.md` trong workspace: + +```markdown +# Tác vụ định kỳ + +- Kiểm tra email xem có tin nhắn quan trọng không +- Xem lại lịch cho các sự kiện sắp tới +- Kiểm tra dự báo thời tiết +``` + +Agent sẽ đọc file này mỗi 30 phút (có thể cấu hình) và thực hiện các tác vụ bằng công cụ có sẵn. + +#### Tác vụ bất đồng bộ với Spawn + +Đối với các tác vụ chạy lâu (tìm kiếm web, gọi API), sử dụng công cụ `spawn` để tạo **subagent**: + +```markdown +# Tác vụ định kỳ + +## Tác vụ nhanh (trả lời trực tiếp) +- Báo cáo thời gian hiện tại + +## Tác vụ lâu (dùng spawn cho async) +- Tìm kiếm tin tức AI trên web và tóm tắt +- Kiểm tra email và báo cáo tin nhắn quan trọng +``` + +**Hành vi chính:** + +| Tính năng | Mô tả | +|-----------|-------| +| **spawn** | Tạo subagent bất đồng bộ, không chặn heartbeat | +| **Context độc lập** | Subagent có context riêng, không có lịch sử phiên | +| **message tool** | Subagent giao tiếp trực tiếp với người dùng qua công cụ message | +| **Không chặn** | Sau khi spawn, heartbeat tiếp tục tác vụ tiếp theo | + +#### Cách Subagent giao tiếp + +``` +Heartbeat kích hoạt + ↓ +Agent đọc HEARTBEAT.md + ↓ +Tác vụ lâu: spawn subagent + ↓ ↓ +Tiếp tục tác vụ tiếp theo Subagent làm việc độc lập + ↓ ↓ +Tất cả tác vụ hoàn thành Subagent dùng công cụ "message" + ↓ ↓ +Phản hồi HEARTBEAT_OK Người dùng nhận kết quả trực tiếp +``` + +Subagent có quyền truy cập các công cụ (message, web_search, v.v.) và có thể giao tiếp với người dùng một cách độc lập mà không cần thông qua agent chính. + +**Cấu hình:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Tùy chọn | Mặc định | Mô tả | +|----------|---------|-------| +| `enabled` | `true` | Bật/tắt heartbeat | +| `interval` | `30` | Khoảng thời gian kiểm tra (phút, tối thiểu: 5) | + +**Biến môi trường:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` để tắt +* `PICOCLAW_HEARTBEAT_INTERVAL=60` để thay đổi khoảng thời gian + +### Nhà cung cấp (Providers) + +> [!NOTE] +> Groq cung cấp dịch vụ chuyển giọng nói thành văn bản miễn phí qua Whisper. Nếu đã cấu hình Groq, tin nhắn thoại trên Telegram sẽ được tự động chuyển thành văn bản. + +| Nhà cung cấp | Mục đích | Lấy API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini trực tiếp) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu trực tiếp) | [bigmodel.cn](bigmodel.cn) | +| `openrouter` (Đang thử nghiệm) | LLM (khuyên dùng, truy cập mọi model) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` (Đang thử nghiệm) | LLM (Claude trực tiếp) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` (Đang thử nghiệm) | LLM (GPT trực tiếp) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` (Đang thử nghiệm) | LLM (DeepSeek trực tiếp) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Chuyển giọng nói** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+Cấu hình Zhipu + +**1. Lấy API key** + +* Lấy [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Cấu hình** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Your API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Chạy** + +```bash +picoclaw agent -m "Xin chào" +``` + +
+ +
+Ví dụ cấu hình đầy đủ + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## Tham chiếu CLI + +| Lệnh | Mô tả | +| --- | --- | +| `picoclaw onboard` | Khởi tạo cấu hình & workspace | +| `picoclaw agent -m "..."` | Trò chuyện với agent | +| `picoclaw agent` | Chế độ chat tương tác | +| `picoclaw gateway` | Khởi động gateway (cho bot chat) | +| `picoclaw status` | Hiển thị trạng thái | +| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ | +| `picoclaw cron add ...` | Thêm tác vụ định kỳ | + +### Tác vụ định kỳ / Nhắc nhở + +PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`: + +* **Nhắc nhở một lần**: "Remind me in 10 minutes" (Nhắc tôi sau 10 phút) → kích hoạt một lần sau 10 phút +* **Tác vụ lặp lại**: "Remind me every 2 hours" (Nhắc tôi mỗi 2 giờ) → kích hoạt mỗi 2 giờ +* **Biểu thức Cron**: "Remind me at 9am daily" (Nhắc tôi lúc 9 giờ sáng mỗi ngày) → sử dụng biểu thức cron + +Các tác vụ được lưu trong `~/.picoclaw/workspace/cron/` và được xử lý tự động. + +## 🤝 Đóng góp & Lộ trình + +Chào đón mọi PR! Mã nguồn được thiết kế nhỏ gọn và dễ đọc. 🤗 + +Lộ trình sắp được công bố... + +Nhóm phát triển đang được xây dựng. Điều kiện tham gia: Ít nhất 1 PR đã được merge. + +Nhóm người dùng: + +Discord: + +PicoClaw + +## 🐛 Xử lý sự cố + +### Tìm kiếm web hiện "API 配置问题" + +Điều này là bình thường nếu bạn chưa cấu hình API key cho tìm kiếm. PicoClaw sẽ cung cấp các liên kết hữu ích để tìm kiếm thủ công. + +Để bật tìm kiếm web: + +1. **Tùy chọn 1 (Khuyên dùng)**: Lấy API key miễn phí tại [https://brave.com/search/api](https://brave.com/search/api) (2000 truy vấn miễn phí/tháng) để có kết quả tốt nhất. +2. **Tùy chọn 2 (Không cần thẻ tín dụng)**: Nếu không có key, hệ thống tự động chuyển sang dùng **DuckDuckGo** (không cần key). + +Thêm key vào `~/.picoclaw/config.json` nếu dùng Brave: + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +### Gặp lỗi lọc nội dung (Content Filtering) + +Một số nhà cung cấp (như Zhipu) có bộ lọc nội dung nghiêm ngặt. Thử diễn đạt lại câu hỏi hoặc sử dụng model khác. + +### Telegram bot báo "Conflict: terminated by other getUpdates" + +Điều này xảy ra khi có một instance bot khác đang chạy. Đảm bảo chỉ có một tiến trình `picoclaw gateway` chạy tại một thời điểm. + +--- + +## 📝 So sánh API Key + +| Dịch vụ | Gói miễn phí | Trường hợp sử dụng | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/tháng | Đa model (Claude, GPT-4, v.v.) | +| **Zhipu** | 200K tokens/tháng | Tốt nhất cho người dùng Trung Quốc | +| **Brave Search** | 2000 truy vấn/tháng | Chức năng tìm kiếm web | +| **Groq** | Có gói miễn phí | Suy luận siêu nhanh (Llama, Mixtral) | diff --git a/README.zh.md b/README.zh.md index 2ca2987bb..ceddb170c 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,7 @@ Twitter

- **中文** | [日本語](README.ja.md) | [English](README.md) + **中文** | [日本語](README.ja.md) | [Tiếng Việt](README.vi.md) | [English](README.md) --- From a961a2df878342af8522aead61361534663fc73f Mon Sep 17 00:00:00 2001 From: Guoguo <16666742+imguoguo@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:32:51 +0800 Subject: [PATCH 10/31] fix(ci): use env var for release tag (#342) Signed-off-by: Guoguo --- .github/workflows/release.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f9987b35f..9fe3a684e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,11 +32,13 @@ jobs: - name: Create and push tag shell: bash + env: + RELEASE_TAG: ${{ inputs.tag }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git tag -a "${{ inputs.tag }}" -m "Release ${{ inputs.tag }}" - git push origin "${{ inputs.tag }}" + git tag -a "$RELEASE_TAG" -m "Release $RELEASE_TAG" + git push origin "$RELEASE_TAG" release: name: GoReleaser Release From 0fadbcd340dfa7dc9b5fde7dfba413ba1d5831d0 Mon Sep 17 00:00:00 2001 From: zepan Date: Tue, 17 Feb 2026 16:03:07 +0800 Subject: [PATCH 11/31] 1. add roadmap.md --- ROADMAP.md | 116 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 ROADMAP.md diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 000000000..8c5c0e252 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,116 @@ + +# 🦐 PicoClaw Roadmap + +> **Vision**: To build the ultimate lightweight, secure, and fully autonomous AI Agent infrastructure.automate the mundane, unleash your creativity + +--- + +## 🚀 1. Core Optimization: Extreme Lightweight + +*Our defining characteristic. We fight software bloat to ensure PicoClaw runs smoothly on the smallest embedded devices.* + +* [**Memory Footprint Reduction**](https://github.com/sipeed/picoclaw/issues/346) + * **Goal**: Run smoothly on 64MB RAM embedded boards (e.g., low-end RISC-V SBCs) with the core process consuming < 20MB. + * **Context**: RAM is expensive and scarce on edge devices. Memory optimization takes precedence over storage size. + * **Action**: Analyze memory growth between releases, remove redundant dependencies, and optimize data structures. + + +## 🛡️ 2. Security Hardening: Defense in Depth + +*Paying off early technical debt. We invite security experts to help build a "Secure-by-Default" agent.* + +* **Input Defense & Permission Control** + * **Prompt Injection Defense**: Harden JSON extraction logic to prevent LLM manipulation. + * **Tool Abuse Prevention**: Strict parameter validation to ensure generated commands stay within safe boundaries. + * **SSRF Protection**: Built-in blocklists for network tools to prevent accessing internal IPs (LAN/Metadata services). + + +* **Sandboxing & Isolation** + * **Filesystem Sandbox**: Restrict file R/W operations to specific directories only. + * **Context Isolation**: Prevent data leakage between different user sessions or channels. + * **Privacy Redaction**: Auto-redact sensitive info (API Keys, PII) from logs and standard outputs. + + +* **Authentication & Secrets** + * **Crypto Upgrade**: Adopt modern algorithms like `ChaCha20-Poly1305` for secret storage. + * **OAuth 2.0 Flow**: Deprecate hardcoded API keys in the CLI; move to secure OAuth flows. + + + +## 🔌 3. Connectivity: Protocol-First Architecture + +*Connect every model, reach every platform.* + +* **Provider** + * [**Architecture Upgrade**](https://github.com/sipeed/picoclaw/issues/283): Refactor from "Vendor-based" to "Protocol-based" classification (e.g., OpenAI-compatible, Ollama-compatible). *(Status: In progress by @Daming, ETA 5 days)* + * **Local Models**: Deep integration with **Ollama**, **vLLM**, **LM Studio**, and **Mistral** (local inference). + * **Online Models**: Continued support for frontier closed-source models. + + +* **Channel** + * **IM Matrix**: QQ, WeChat (Work), DingTalk, Feishu (Lark), Telegram, Discord, WhatsApp, LINE, Slack, Email, KOOK, Signal, ... + * **Standards**: Support for the **OneBot** protocol. + * [**attachment**](https://github.com/sipeed/picoclaw/issues/348): Native handling of images, audio, and video attachments. + + +* **Skill Marketplace** + * [**Discovery skills**](https://github.com/sipeed/picoclaw/issues/287): Implement `find_skill` to automatically discover and install skills from the [GitHub Skills Repo] or other registries. + + + +## 🧠 4. Advanced Capabilities: From Chatbot to Agentic AI + +*Beyond conversation—focusing on action and collaboration.* + +* **Operations** + * [**MCP Support**](https://github.com/sipeed/picoclaw/issues/290): Native support for the **Model Context Protocol (MCP)**. + * [**Browser Automation**](https://github.com/sipeed/picoclaw/issues/293): Headless browser control via CDP (Chrome DevTools Protocol) or ActionBook. + * [**Mobile Operation**](https://github.com/sipeed/picoclaw/issues/292): Android device control (similar to BotDrop). + + +* **Multi-Agent Collaboration** + * [**Basic Multi-Agent**](https://github.com/sipeed/picoclaw/issues/294) implement + * [**Model Routing**](https://github.com/sipeed/picoclaw/issues/295): "Smart Routing" — dispatch simple tasks to small/local models (fast/cheap) and complex tasks to SOTA models (smart). + * [**Swarm Mode**](https://github.com/sipeed/picoclaw/issues/284): Collaboration between multiple PicoClaw instances on the same network. + * [**AIEOS**](https://github.com/sipeed/picoclaw/issues/296): Exploring AI-Native Operating System interaction paradigms. + + + +## 📚 5. Developer Experience (DevEx) & Documentation + +*Lowering the barrier to entry so anyone can deploy in minutes.* + +* [**QuickGuide (Zero-Config Start)**](https://github.com/sipeed/picoclaw/issues/350) + * Interactive CLI Wizard: If launched without config, automatically detect the environment and guide the user through Token/Network setup step-by-step. + + +* **Comprehensive Documentation** + * **Platform Guides**: Dedicated guides for Windows, macOS, Linux, and Android. + * **Step-by-Step Tutorials**: "Babysitter-level" guides for configuring Providers and Channels. + * **AI-Assisted Docs**: Using AI to auto-generate API references and code comments (with human verification to prevent hallucinations). + + + +## 🤖 6. Engineering: AI-Powered Open Source + +*Born from Vibe Coding, we continue to use AI to accelerate development.* + +* **AI-Enhanced CI/CD** + * Integrate AI for automated Code Review, Linting, and PR Labeling. + * **Bot Noise Reduction**: Optimize bot interactions to keep PR timelines clean. + * **Issue Triage**: AI agents to analyze incoming issues and suggest preliminary fixes. + + + +## 🎨 7. Brand & Community + +* [**Logo Design**](https://github.com/sipeed/picoclaw/issues/297): We are looking for a **Mantis Shrimp (Stomatopoda)** logo design! + * *Concept*: Needs to reflect "Small but Mighty" and "Lightning Fast Strikes." + + + +--- + +### 🤝 Call for Contributions + +We welcome community contributions to any item on this roadmap! Please comment on the relevant Issue or submit a PR. Let's build the best Edge AI Agent together! \ No newline at end of file From ac4b16dfb4bc961507b0385d32b089ee955ca7a6 Mon Sep 17 00:00:00 2001 From: zepan Date: Tue, 17 Feb 2026 16:51:38 +0800 Subject: [PATCH 12/31] 1. rename doc to docs --- README.md | 2 +- README.zh.md | 2 +- {doc => docs}/picoclaw_community_roadmap_260216.md | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename {doc => docs}/picoclaw_community_roadmap_260216.md (100%) diff --git a/README.md b/README.md index 0a9dacce6..29fddb7e3 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ ## 📢 News -2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](doc/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board! +2026-02-16 🎉 PicoClaw hit 12K stars in one week! Thank you all for your support! PicoClaw is growing faster than we ever imagined. Given the high volume of PRs, we urgently need community maintainers. Our volunteer roles and roadmap are officially posted [here](docs/picoclaw_community_roadmap_260216.md) —we can’t wait to have you on board! 2026-02-13 🎉 PicoClaw hit 5000 stars in 4days! Thank you for the community! There are so many PRs&issues come in (during Chinese New Year holidays), we are finalizing the Project Roadmap and setting up the Developer Group to accelerate PicoClaw's development. 🚀 Call to Action: Please submit your feature requests in GitHub Discussions. We will review and prioritize them during our upcoming weekly meeting. diff --git a/README.zh.md b/README.zh.md index 2ca2987bb..8b59effa3 100644 --- a/README.zh.md +++ b/README.zh.md @@ -50,7 +50,7 @@ ## 📢 新闻 (News) -2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](doc/picoclaw_community_roadmap_260216.md), 期待你的参与! +2026-02-16 🎉 PicoClaw 在一周内突破了12K star! 感谢大家的关注!PicoClaw 的成长速度超乎我们预期. 由于PR数量的快速膨胀,我们亟需社区开发者参与维护. 我们需要的志愿者角色和roadmap已经发布到了[这里](docs/picoclaw_community_roadmap_260216.md), 期待你的参与! 2026-02-13 🎉 **PicoClaw 在 4 天内突破 5000 Stars!** 感谢社区的支持!由于正值中国春节假期,PR 和 Issue 涌入较多,我们正在利用这段时间敲定 **项目路线图 (Roadmap)** 并组建 **开发者群组**,以便加速 PicoClaw 的开发。 🚀 **行动号召:** 请在 GitHub Discussions 中提交您的功能请求 (Feature Requests)。我们将在接下来的周会上进行审查和优先级排序。 diff --git a/doc/picoclaw_community_roadmap_260216.md b/docs/picoclaw_community_roadmap_260216.md similarity index 100% rename from doc/picoclaw_community_roadmap_260216.md rename to docs/picoclaw_community_roadmap_260216.md From 951b05d2550202f8ebbdf89eb39e582991fffb97 Mon Sep 17 00:00:00 2001 From: zepan Date: Tue, 17 Feb 2026 17:15:40 +0800 Subject: [PATCH 13/31] 1. add AI Code Generation selection in pr template --- .github/pull_request_template.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d2773e27d..7910cb1e2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -5,6 +5,11 @@ - [ ] 📖 Documentation update - [ ] ⚡ Code refactoring (no functional changes, no api changes) +## 🤖 AI Code Generation +- [ ] 🤖 Fully AI-generated (100% AI, 0% Human) +- [ ] 🛠️ Mostly AI-generated (AI draft, Human verified/modified) +- [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none) + ## 🔗 Linked Issue ## 📚 Technical Context (Skip for Docs) From 5fb2721d22d3e8d45d5969d5219e76dd34ff8ec6 Mon Sep 17 00:00:00 2001 From: zepan Date: Tue, 17 Feb 2026 18:01:39 +0800 Subject: [PATCH 14/31] 1. add android phone termux quick guide --- README.md | 14 ++++++++++++++ README.zh.md | 17 +++++++++++++++++ assets/termux.jpg | Bin 0 -> 99784 bytes 3 files changed, 31 insertions(+) create mode 100644 assets/termux.jpg diff --git a/README.md b/README.md index 29fddb7e3..a6f421e9d 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,20 @@ +### 📱 Run on old Android Phones +Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. Quick Start: +1. **Install Termux** (Available on F-Droid or Google Play). +2. **Execute cmds** +```bash +# Note: Replace v0.1.1 with the latest version from the Releases page +wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 +chmod +x picoclaw-linux-arm64 +pkg install proot +termux-chroot ./picoclaw-linux-arm64 onboard +``` +And then follow the instructions in the "Quick Start" section to complete the configuration! +PicoClaw + ### 🐜 Innovative Low-Footprint Deploy PicoClaw can be deployed on almost any Linux device! diff --git a/README.zh.md b/README.zh.md index 8b59effa3..b09adf74a 100644 --- a/README.zh.md +++ b/README.zh.md @@ -100,6 +100,23 @@ +### 📱 在手机上轻松运行 +picoclaw 可以将你10年前的老旧手机废物利用,变身成为你的AI助理!快速指南: +1. 先去应用商店下载安装Termux +2. 打开后执行指令 +```bash +# 注意: 下面的v0.1.1 可以换为你实际看到的最新版本 +wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 +chmod +x picoclaw-linux-arm64 +pkg install proot +termux-chroot ./picoclaw-linux-arm64 onboard +``` +然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! +PicoClaw + + + + ### 🐜 创新的低占用部署 PicoClaw 几乎可以部署在任何 Linux 设备上! diff --git a/assets/termux.jpg b/assets/termux.jpg new file mode 100644 index 0000000000000000000000000000000000000000..30c724a2054885569cca76286d7d5c81e9f88a5d GIT binary patch literal 99784 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 Date: Tue, 17 Feb 2026 18:03:02 +0800 Subject: [PATCH 15/31] 1. fix typo --- README.zh.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.zh.md b/README.zh.md index b09adf74a..48d7677f0 100644 --- a/README.zh.md +++ b/README.zh.md @@ -111,7 +111,7 @@ chmod +x picoclaw-linux-arm64 pkg install proot termux-chroot ./picoclaw-linux-arm64 onboard ``` -然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! +然后跟随下面的“快速开始”章节继续配置picoclaw即可使用! PicoClaw From f929268ab263934759d61f6e054d11c660f03c48 Mon Sep 17 00:00:00 2001 From: Hua Audio <161028864+Huaaudio@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:02:56 +0100 Subject: [PATCH 16/31] feat: Add Perplexity search provider integration (#138) * feat: Add Perplexity search provider integration - Add PerplexityConfig struct to config package - Add PerplexitySearchProvider implementing SearchProvider interface - Update WebSearchTool to support Perplexity with priority system (Perplexity > Brave > DuckDuckGo) - Update agent loop to pass Perplexity config options - Update config.example.json with Perplexity configuration template - Uses Perplexity's 'sonar' model for web search capabilities * Edit config example * make fmt --------- Co-authored-by: Hua --- config/config.example.json | 14 +++++-- pkg/agent/loop.go | 3 ++ pkg/config/config.go | 12 ++++++ pkg/tools/web.go | 77 +++++++++++++++++++++++++++++++++++++- 4 files changed, 101 insertions(+), 5 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 3c9158e9c..62ad2c5fe 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -14,7 +14,9 @@ "enabled": false, "token": "YOUR_TELEGRAM_BOT_TOKEN", "proxy": "", - "allow_from": ["YOUR_USER_ID"] + "allow_from": [ + "YOUR_USER_ID" + ] }, "discord": { "enabled": false, @@ -115,9 +117,15 @@ }, "tools": { "web": { - "search": { + "brave": { + "enabled": false, "api_key": "YOUR_BRAVE_API_KEY", "max_results": 5 + }, + "perplexity": { + "enabled": false, + "api_key": "pplx-xxx", + "max_results": 5 } } }, @@ -133,4 +141,4 @@ "host": "0.0.0.0", "port": 18790 } -} +} \ No newline at end of file diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index cd4276155..d3afa298e 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -79,6 +79,9 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg BraveEnabled: cfg.Tools.Web.Brave.Enabled, DuckDuckGoMaxResults: cfg.Tools.Web.DuckDuckGo.MaxResults, DuckDuckGoEnabled: cfg.Tools.Web.DuckDuckGo.Enabled, + PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey, + PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults, + PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled, }); searchTool != nil { registry.Register(searchTool) } diff --git a/pkg/config/config.go b/pkg/config/config.go index d189ff00b..558bf6a93 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -206,9 +206,16 @@ type DuckDuckGoConfig struct { MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_DUCKDUCKGO_MAX_RESULTS"` } +type PerplexityConfig struct { + Enabled bool `json:"enabled" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_ENABLED"` + APIKey string `json:"api_key" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_API_KEY"` + MaxResults int `json:"max_results" env:"PICOCLAW_TOOLS_WEB_PERPLEXITY_MAX_RESULTS"` +} + type WebToolsConfig struct { Brave BraveConfig `json:"brave"` DuckDuckGo DuckDuckGoConfig `json:"duckduckgo"` + Perplexity PerplexityConfig `json:"perplexity"` } type ToolsConfig struct { @@ -321,6 +328,11 @@ func DefaultConfig() *Config { Enabled: true, MaxResults: 5, }, + Perplexity: PerplexityConfig{ + Enabled: false, + APIKey: "", + MaxResults: 5, + }, }, }, Heartbeat: HeartbeatConfig{ diff --git a/pkg/tools/web.go b/pkg/tools/web.go index ccd995842..6a6d40ecf 100644 --- a/pkg/tools/web.go +++ b/pkg/tools/web.go @@ -176,6 +176,71 @@ func stripTags(content string) string { return re.ReplaceAllString(content, "") } +type PerplexitySearchProvider struct { + apiKey string +} + +func (p *PerplexitySearchProvider) Search(ctx context.Context, query string, count int) (string, error) { + searchURL := "https://api.perplexity.ai/chat/completions" + + payload := map[string]interface{}{ + "model": "sonar", + "messages": []map[string]string{ + {"role": "system", "content": "You are a search assistant. Provide concise search results with titles, URLs, and brief descriptions in the following format:\n1. Title\n URL\n Description\n\nDo not add extra commentary."}, + {"role": "user", "content": fmt.Sprintf("Search for: %s. Provide up to %d relevant results.", query, count)}, + }, + "max_tokens": 1000, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return "", fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", searchURL, strings.NewReader(string(payloadBytes))) + 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) + req.Header.Set("User-Agent", userAgent) + + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("Perplexity API error: %s", string(body)) + } + + var searchResp struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` + } + + if err := json.Unmarshal(body, &searchResp); err != nil { + return "", fmt.Errorf("failed to parse response: %w", err) + } + + if len(searchResp.Choices) == 0 { + return fmt.Sprintf("No results for: %s", query), nil + } + + return fmt.Sprintf("Results for: %s (via Perplexity)\n%s", query, searchResp.Choices[0].Message.Content), nil +} + type WebSearchTool struct { provider SearchProvider maxResults int @@ -187,14 +252,22 @@ type WebSearchToolOptions struct { BraveEnabled bool DuckDuckGoMaxResults int DuckDuckGoEnabled bool + PerplexityAPIKey string + PerplexityMaxResults int + PerplexityEnabled bool } func NewWebSearchTool(opts WebSearchToolOptions) *WebSearchTool { var provider SearchProvider maxResults := 5 - // Priority: Brave > DuckDuckGo - if opts.BraveEnabled && opts.BraveAPIKey != "" { + // Priority: Perplexity > Brave > DuckDuckGo + if opts.PerplexityEnabled && opts.PerplexityAPIKey != "" { + provider = &PerplexitySearchProvider{apiKey: opts.PerplexityAPIKey} + if opts.PerplexityMaxResults > 0 { + maxResults = opts.PerplexityMaxResults + } + } else if opts.BraveEnabled && opts.BraveAPIKey != "" { provider = &BraveSearchProvider{apiKey: opts.BraveAPIKey} if opts.BraveMaxResults > 0 { maxResults = opts.BraveMaxResults From 881999aceb5a8d63742691e2e1bcc81d98ef30c7 Mon Sep 17 00:00:00 2001 From: yinwm Date: Tue, 17 Feb 2026 21:10:20 +0800 Subject: [PATCH 17/31] refactor(shell): interpret zero timeout as unlimited execution Replace unconditional WithTimeout usage with conditional context creation based on timeout configuration. Zero values now bypass timeout enforcement, using WithCancel for graceful cancellation while preserving existing timeout behavior for positive values. Simplifies CronTool initialization by removing unnecessary conditional timeout assignment. --- pkg/tools/cron.go | 5 ++--- pkg/tools/shell.go | 9 ++++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index af23dba00..21bee42ef 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -28,11 +28,10 @@ type CronTool struct { } // NewCronTool creates a new CronTool +// execTimeout: 0 means no timeout, >0 sets the timeout duration func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool { execTool := NewExecTool(workspace, restrict) - if execTimeout > 0 { - execTool.SetTimeout(execTimeout) - } + execTool.SetTimeout(execTimeout) // 0 means no timeout return &CronTool{ cronService: cronService, executor: executor, diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 1ca3fc35a..713850f97 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -89,7 +89,14 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) *To return ErrorResult(guardError) } - cmdCtx, cancel := context.WithTimeout(ctx, t.timeout) + // timeout == 0 means no timeout + var cmdCtx context.Context + var cancel context.CancelFunc + if t.timeout > 0 { + cmdCtx, cancel = context.WithTimeout(ctx, t.timeout) + } else { + cmdCtx, cancel = context.WithCancel(ctx) + } defer cancel() var cmd *exec.Cmd From ad747e8e8925cb9cb48cfc232f40156b0905b613 Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 14:27:03 +0100 Subject: [PATCH 18/31] fix(Makefile): update LDFLAGS and GOFLAGS for optimized build size Signed-off-by: Boris Bliznioukov --- Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 9786b30bb..c3f889f8f 100644 --- a/Makefile +++ b/Makefile @@ -11,11 +11,11 @@ VERSION?=$(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") GIT_COMMIT=$(shell git rev-parse --short=8 HEAD 2>/dev/null || echo "dev") BUILD_TIME=$(shell date +%FT%T%z) GO_VERSION=$(shell $(GO) version | awk '{print $$3}') -LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION)" +LDFLAGS=-ldflags "-X main.version=$(VERSION) -X main.gitCommit=$(GIT_COMMIT) -X main.buildTime=$(BUILD_TIME) -X main.goVersion=$(GO_VERSION) -s -w" # Go variables GO?=go -GOFLAGS?=-v +GOFLAGS?=-v -tags stdjson # Installation INSTALL_PREFIX?=$(HOME)/.local From 920e30a241313544ad735f78e0d5590f1a0b9c1f Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Tue, 17 Feb 2026 21:31:54 +0800 Subject: [PATCH 19/31] fix:pr-272 reverted the changes from pr-227 (#361) --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index 9786b30bb..bb31243dd 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,8 @@ ifeq ($(UNAME_S),Linux) ARCH=amd64 else ifeq ($(UNAME_M),aarch64) ARCH=arm64 + else ifeq ($(UNAME_M),loongarch64) + ARCH=loong64 else ifeq ($(UNAME_M),riscv64) ARCH=riscv64 else @@ -84,6 +86,7 @@ build-all: generate @mkdir -p $(BUILD_DIR) GOOS=linux GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./$(CMD_DIR) GOOS=linux GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) + GOOS=linux GOARCH=loong64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) GOOS=darwin GOARCH=arm64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) From 2d758d714faf8d4cc7fe48d7886bb8f3a2971a8b Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 14:55:37 +0100 Subject: [PATCH 20/31] feat(goreleaser): add 'stdjson' tag to picoclaw build configuration Signed-off-by: Boris Bliznioukov --- .goreleaser.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 368a0f06b..0354928f3 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -11,6 +11,8 @@ builds: - id: picoclaw env: - CGO_ENABLED=0 + tags: + - stdjson goos: - linux - windows From 2d876eaa9809d3a958ffc2e73c8eed8b8d760531 Mon Sep 17 00:00:00 2001 From: Boris Bliznioukov Date: Tue, 17 Feb 2026 15:00:06 +0100 Subject: [PATCH 21/31] feat(goreleaser): enhance build flags with versioning and commit info Signed-off-by: Boris Bliznioukov --- .github/workflows/release.yml | 2 ++ .goreleaser.yaml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9fe3a684e..4e9399128 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,7 @@ jobs: ref: ${{ inputs.tag }} - name: Setup Go from go.mod + id: setup-go uses: actions/setup-go@v5 with: go-version-file: go.mod @@ -89,6 +90,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }} DOCKERHUB_IMAGE_NAME: ${{ vars.DOCKERHUB_REPOSITORY }} + GOVERSION: ${{ steps.setup-go.outputs.go-version }} - name: Apply release flags shell: bash diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0354928f3..2c47f7d86 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -13,6 +13,12 @@ builds: - CGO_ENABLED=0 tags: - stdjson + ldflags: + - -s -w + - -X main.version={{ .Version }} + - -X main.gitCommit={{ .ShortCommit }} + - -X main.buildTime={{ .Date }} + - -X main.goVersion={{ .Env.GOVERSION }} goos: - linux - windows From 4cd3f99dd6f2ddfd3378b269d6f295d4a5ecc763 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Mon, 16 Feb 2026 12:49:11 +0900 Subject: [PATCH 22/31] fix: remove max_tokens --- pkg/providers/codex_provider.go | 4 ---- pkg/providers/codex_provider_test.go | 11 +++++++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 6dff3a52e..9e36217ae 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -260,10 +260,6 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, params.Instructions = openai.Opt(defaultCodexInstructions) } - if maxTokens, ok := options["max_tokens"].(int); ok { - params.MaxOutputTokens = openai.Opt(int64(maxTokens)) - } - if len(tools) > 0 { params.Tools = translateToolsForCodex(tools) } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 317b1a5de..c34593e7b 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -29,6 +29,9 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { if params.Instructions.Or("") != defaultCodexInstructions { t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions) } + if params.MaxOutputTokens.Valid() { + t.Fatalf("MaxOutputTokens should not be set for Codex backend") + } } func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { @@ -214,6 +217,10 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { http.Error(w, "stream must be true", http.StatusBadRequest) return } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } resp := map[string]interface{}{ "id": "resp_test", @@ -293,6 +300,10 @@ func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) http.Error(w, "temperature is not supported", http.StatusBadRequest) return } + if _, ok := reqBody["max_output_tokens"]; ok { + http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) + return + } if reqBody["stream"] != true { http.Error(w, "stream must be true", http.StatusBadRequest) return From 0d16525fab81f1010bf6ddafd5ea68d975613a88 Mon Sep 17 00:00:00 2001 From: "zenix.huang" Date: Mon, 16 Feb 2026 13:08:37 +0900 Subject: [PATCH 23/31] fix: codex tool call --- pkg/providers/codex_provider.go | 36 ++++++++++++++++++++++--- pkg/providers/codex_provider_test.go | 39 ++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 9e36217ae..7617bf716 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -217,12 +217,18 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, }) } for _, tc := range msg.ToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) + name, args, ok := resolveCodexToolCall(tc) + if !ok { + logger.WarnCF("provider.codex", "Skipping invalid tool call in history", map[string]interface{}{ + "call_id": tc.ID, + }) + continue + } inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ OfFunctionCall: &responses.ResponseFunctionToolCallParam{ CallID: tc.ID, - Name: tc.Name, - Arguments: string(argsJSON), + Name: name, + Arguments: args, }, }) } @@ -267,6 +273,30 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, return params } +func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) { + name = tc.Name + if name == "" && tc.Function != nil { + name = tc.Function.Name + } + if name == "" { + return "", "", false + } + + if len(tc.Arguments) > 0 { + argsJSON, err := json.Marshal(tc.Arguments) + if err != nil { + return "", "", false + } + return name, string(argsJSON), true + } + + if tc.Function != nil && tc.Function.Arguments != "" { + return name, tc.Function.Arguments, true + } + + return name, "{}", true +} + func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { result := make([]responses.ToolUnionParam, 0, len(tools)) for _, t := range tools { diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index c34593e7b..8406760c4 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -68,6 +68,45 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { } } +func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { + messages := []Message{ + {Role: "user", Content: "Read a file"}, + { + Role: "assistant", + ToolCalls: []ToolCall{ + { + ID: "call_1", + Type: "function", + Function: &FunctionCall{ + Name: "read_file", + Arguments: `{"path":"README.md"}`, + }, + }, + }, + }, + {Role: "tool", Content: "ok", ToolCallID: "call_1"}, + } + + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + if params.Input.OfInputItemList == nil { + t.Fatal("Input.OfInputItemList should not be nil") + } + if len(params.Input.OfInputItemList) != 3 { + t.Fatalf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) + } + + fc := params.Input.OfInputItemList[1].OfFunctionCall + if fc == nil { + t.Fatal("assistant tool call should be converted to function_call input item") + } + if fc.Name != "read_file" { + t.Errorf("Function call name = %q, want %q", fc.Name, "read_file") + } + if fc.Arguments != `{"path":"README.md"}` { + t.Errorf("Function call arguments = %q, want %q", fc.Arguments, `{"path":"README.md"}`) + } +} + func TestBuildCodexParams_WithTools(t *testing.T) { tools := []ToolDefinition{ { From c4cbb5fb35374d0ff917baff9196746f843b99fa Mon Sep 17 00:00:00 2001 From: Jared Mahotiere Date: Tue, 17 Feb 2026 11:13:10 -0500 Subject: [PATCH 24/31] providers: finalize PR213 review fixes Phase 1: centralize protocol message/tool/response types in protocoltypes and keep compatibility aliases in providers and protocol packages. Phase 1: preserve HTTPProvider constructor compatibility and route Anthropic api_base through factory auth/provider constructors with base URL normalization. Phase 2: expand provider routing/auth tests (deepseek/nvidia/shengsuanyun, codex/claude oauth/codex-cli) and add openai_compat + anthropic coverage for proxy transport, model normalization, numeric option coercion, token-source refresh, and base URL behavior. Phase 3: apply gofmt and validate with Dockerized tests (go test ./pkg/providers/... ./pkg/migrate and go test ./...). --- pkg/providers/anthropic/provider.go | 99 +++++++------- pkg/providers/anthropic/provider_test.go | 57 ++++++++ pkg/providers/claude_provider.go | 118 ++-------------- pkg/providers/factory.go | 47 ++++++- pkg/providers/factory_test.go | 95 +++++++++++++ pkg/providers/http_provider.go | 106 +-------------- pkg/providers/openai_compat/provider.go | 136 ++++++++++--------- pkg/providers/openai_compat/provider_test.go | 85 +++++++++++- pkg/providers/protocoltypes/types.go | 45 ++++++ pkg/providers/types.go | 54 ++------ 10 files changed, 468 insertions(+), 374 deletions(-) create mode 100644 pkg/providers/protocoltypes/types.go diff --git a/pkg/providers/anthropic/provider.go b/pkg/providers/anthropic/provider.go index ca72f0180..8f46aa70c 100644 --- a/pkg/providers/anthropic/provider.go +++ b/pkg/providers/anthropic/provider.go @@ -4,74 +4,59 @@ import ( "context" "encoding/json" "fmt" + "log" + "strings" "github.com/anthropics/anthropic-sdk-go" "github.com/anthropics/anthropic-sdk-go/option" + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` -} +type ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -type LLMResponse struct { - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - Usage *UsageInfo `json:"usage,omitempty"` -} - -type UsageInfo struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -type ToolDefinition struct { - Type string `json:"type"` - Function ToolFunctionDefinition `json:"function"` -} - -type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} +const defaultBaseURL = "https://api.anthropic.com" type Provider struct { client *anthropic.Client tokenSource func() (string, error) + baseURL string } func NewProvider(token string) *Provider { + return NewProviderWithBaseURL(token, "") +} + +func NewProviderWithBaseURL(token, apiBase string) *Provider { + baseURL := normalizeBaseURL(apiBase) client := anthropic.NewClient( option.WithAuthToken(token), - option.WithBaseURL("https://api.anthropic.com"), + option.WithBaseURL(baseURL), ) - return &Provider{client: &client} + return &Provider{ + client: &client, + baseURL: baseURL, + } } func NewProviderWithClient(client *anthropic.Client) *Provider { - return &Provider{client: client} + return &Provider{ + client: client, + baseURL: defaultBaseURL, + } } func NewProviderWithTokenSource(token string, tokenSource func() (string, error)) *Provider { - p := NewProvider(token) + return NewProviderWithTokenSourceAndBaseURL(token, tokenSource, "") +} + +func NewProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *Provider { + p := NewProviderWithBaseURL(token, apiBase) p.tokenSource = tokenSource return p } @@ -103,6 +88,10 @@ func (p *Provider) GetDefaultModel() string { return "claude-sonnet-4-5-20250929" } +func (p *Provider) BaseURL() string { + return p.baseURL +} + func buildParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (anthropic.MessageNewParams, error) { var system []anthropic.TextBlockParam var anthropicMessages []anthropic.MessageParam @@ -208,6 +197,7 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { tu := block.AsToolUse() var args map[string]interface{} if err := json.Unmarshal(tu.Input, &args); err != nil { + log.Printf("anthropic: failed to decode tool call input for %q: %v", tu.Name, err) args = map[string]interface{}{"raw": string(tu.Input)} } toolCalls = append(toolCalls, ToolCall{ @@ -239,3 +229,20 @@ func parseResponse(resp *anthropic.Message) *LLMResponse { }, } } + +func normalizeBaseURL(apiBase string) string { + base := strings.TrimSpace(apiBase) + if base == "" { + return defaultBaseURL + } + + base = strings.TrimRight(base, "/") + if strings.HasSuffix(base, "/v1") { + base = strings.TrimSuffix(base, "/v1") + } + if base == "" { + return defaultBaseURL + } + + return base +} diff --git a/pkg/providers/anthropic/provider_test.go b/pkg/providers/anthropic/provider_test.go index 01b4fe663..6a1dabafb 100644 --- a/pkg/providers/anthropic/provider_test.go +++ b/pkg/providers/anthropic/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "sync/atomic" "testing" "github.com/anthropics/anthropic-sdk-go" @@ -199,6 +200,62 @@ func TestProvider_GetDefaultModel(t *testing.T) { } } +func TestProvider_NewProviderWithBaseURL_NormalizesV1Suffix(t *testing.T) { + p := NewProviderWithBaseURL("token", "https://api.anthropic.com/v1/") + if got := p.BaseURL(); got != "https://api.anthropic.com" { + t.Fatalf("BaseURL() = %q, want %q", got, "https://api.anthropic.com") + } +} + +func TestProvider_ChatUsesTokenSource(t *testing.T) { + var requests int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/messages" { + http.Error(w, "not found", http.StatusNotFound) + return + } + atomic.AddInt32(&requests, 1) + + if got := r.Header.Get("Authorization"); got != "Bearer refreshed-token" { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + var reqBody map[string]interface{} + json.NewDecoder(r.Body).Decode(&reqBody) + + resp := map[string]interface{}{ + "id": "msg_test", + "type": "message", + "role": "assistant", + "model": reqBody["model"], + "stop_reason": "end_turn", + "content": []map[string]interface{}{ + {"type": "text", "text": "ok"}, + }, + "usage": map[string]interface{}{ + "input_tokens": 1, + "output_tokens": 1, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProviderWithTokenSourceAndBaseURL("stale-token", func() (string, error) { + return "refreshed-token", nil + }, server.URL) + + _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hello"}}, nil, "claude-sonnet-4-5-20250929", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if got := atomic.LoadInt32(&requests); got != 1 { + t.Fatalf("requests = %d, want 1", got) + } +} + func createAnthropicTestClient(baseURL, token string) *anthropic.Client { c := anthropic.NewClient( anthropicoption.WithAuthToken(token), diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go index 16f1884c5..c72f5b0ef 100644 --- a/pkg/providers/claude_provider.go +++ b/pkg/providers/claude_provider.go @@ -3,8 +3,6 @@ package providers import ( "context" "fmt" - - "github.com/sipeed/picoclaw/pkg/auth" anthropicprovider "github.com/sipeed/picoclaw/pkg/providers/anthropic" ) @@ -18,28 +16,34 @@ func NewClaudeProvider(token string) *ClaudeProvider { } } +func NewClaudeProviderWithBaseURL(token, apiBase string) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithBaseURL(token, apiBase), + } +} + func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { return &ClaudeProvider{ delegate: anthropicprovider.NewProviderWithTokenSource(token, tokenSource), } } +func NewClaudeProviderWithTokenSourceAndBaseURL(token string, tokenSource func() (string, error), apiBase string) *ClaudeProvider { + return &ClaudeProvider{ + delegate: anthropicprovider.NewProviderWithTokenSourceAndBaseURL(token, tokenSource, apiBase), + } +} + func newClaudeProviderWithDelegate(delegate *anthropicprovider.Provider) *ClaudeProvider { return &ClaudeProvider{delegate: delegate} } func (p *ClaudeProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - resp, err := p.delegate.Chat( - ctx, - toAnthropicProviderMessages(messages), - toAnthropicProviderTools(tools), - model, - options, - ) + resp, err := p.delegate.Chat(ctx, messages, tools, model, options) if err != nil { return nil, err } - return fromAnthropicProviderResponse(resp), nil + return resp, nil } func (p *ClaudeProvider) GetDefaultModel() string { @@ -48,7 +52,7 @@ func (p *ClaudeProvider) GetDefaultModel() string { func createClaudeTokenSource() func() (string, error) { return func() (string, error) { - cred, err := auth.GetCredential("anthropic") + cred, err := getCredential("anthropic") if err != nil { return "", fmt.Errorf("loading auth credentials: %w", err) } @@ -58,95 +62,3 @@ func createClaudeTokenSource() func() (string, error) { return cred.AccessToken, nil } } - -func toAnthropicProviderMessages(messages []Message) []anthropicprovider.Message { - out := make([]anthropicprovider.Message, 0, len(messages)) - for _, msg := range messages { - out = append(out, anthropicprovider.Message{ - Role: msg.Role, - Content: msg.Content, - ToolCalls: toAnthropicProviderToolCalls(msg.ToolCalls), - ToolCallID: msg.ToolCallID, - }) - } - return out -} - -func toAnthropicProviderTools(tools []ToolDefinition) []anthropicprovider.ToolDefinition { - out := make([]anthropicprovider.ToolDefinition, 0, len(tools)) - for _, t := range tools { - out = append(out, anthropicprovider.ToolDefinition{ - Type: t.Type, - Function: anthropicprovider.ToolFunctionDefinition{ - Name: t.Function.Name, - Description: t.Function.Description, - Parameters: t.Function.Parameters, - }, - }) - } - return out -} - -func toAnthropicProviderToolCalls(toolCalls []ToolCall) []anthropicprovider.ToolCall { - out := make([]anthropicprovider.ToolCall, 0, len(toolCalls)) - for _, tc := range toolCalls { - var fn *anthropicprovider.FunctionCall - if tc.Function != nil { - fn = &anthropicprovider.FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - } - } - out = append(out, anthropicprovider.ToolCall{ - ID: tc.ID, - Type: tc.Type, - Function: fn, - Name: tc.Name, - Arguments: tc.Arguments, - }) - } - return out -} - -func fromAnthropicProviderResponse(resp *anthropicprovider.LLMResponse) *LLMResponse { - if resp == nil { - return &LLMResponse{} - } - - var usage *UsageInfo - if resp.Usage != nil { - usage = &UsageInfo{ - PromptTokens: resp.Usage.PromptTokens, - CompletionTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, - } - } - - return &LLMResponse{ - Content: resp.Content, - ToolCalls: fromAnthropicProviderToolCalls(resp.ToolCalls), - FinishReason: resp.FinishReason, - Usage: usage, - } -} - -func fromAnthropicProviderToolCalls(toolCalls []anthropicprovider.ToolCall) []ToolCall { - out := make([]ToolCall, 0, len(toolCalls)) - for _, tc := range toolCalls { - var fn *FunctionCall - if tc.Function != nil { - fn = &FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - } - } - out = append(out, ToolCall{ - ID: tc.ID, - Type: tc.Type, - Function: fn, - Name: tc.Name, - Arguments: tc.Arguments, - }) - } - return out -} diff --git a/pkg/providers/factory.go b/pkg/providers/factory.go index 28609c4b3..67a347721 100644 --- a/pkg/providers/factory.go +++ b/pkg/providers/factory.go @@ -8,6 +8,10 @@ import ( "github.com/sipeed/picoclaw/pkg/config" ) +const defaultAnthropicAPIBase = "https://api.anthropic.com/v1" + +var getCredential = auth.GetCredential + type providerType int const ( @@ -30,19 +34,22 @@ type providerSelection struct { connectMode string } -func createClaudeAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("anthropic") +func createClaudeAuthProvider(apiBase string) (LLMProvider, error) { + if apiBase == "" { + apiBase = defaultAnthropicAPIBase + } + cred, err := getCredential("anthropic") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") } - return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil + return NewClaudeProviderWithTokenSourceAndBaseURL(cred.AccessToken, createClaudeTokenSource(), apiBase), nil } func createCodexAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("openai") + cred, err := getCredential("openai") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) } @@ -69,6 +76,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if cfg.Providers.Groq.APIKey != "" { sel.apiKey = cfg.Providers.Groq.APIKey sel.apiBase = cfg.Providers.Groq.APIBase + sel.proxy = cfg.Providers.Groq.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.groq.com/openai/v1" } @@ -85,6 +93,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { } sel.apiKey = cfg.Providers.OpenAI.APIKey sel.apiBase = cfg.Providers.OpenAI.APIBase + sel.proxy = cfg.Providers.OpenAI.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.openai.com/v1" } @@ -92,18 +101,24 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { case "anthropic", "claude": if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" { if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } sel.providerType = providerTypeClaudeAuth return sel, nil } sel.apiKey = cfg.Providers.Anthropic.APIKey sel.apiBase = cfg.Providers.Anthropic.APIBase + sel.proxy = cfg.Providers.Anthropic.Proxy if sel.apiBase == "" { - sel.apiBase = "https://api.anthropic.com/v1" + sel.apiBase = defaultAnthropicAPIBase } } case "openrouter": if cfg.Providers.OpenRouter.APIKey != "" { sel.apiKey = cfg.Providers.OpenRouter.APIKey + sel.proxy = cfg.Providers.OpenRouter.Proxy if cfg.Providers.OpenRouter.APIBase != "" { sel.apiBase = cfg.Providers.OpenRouter.APIBase } else { @@ -114,6 +129,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if cfg.Providers.Zhipu.APIKey != "" { sel.apiKey = cfg.Providers.Zhipu.APIKey sel.apiBase = cfg.Providers.Zhipu.APIBase + sel.proxy = cfg.Providers.Zhipu.Proxy if sel.apiBase == "" { sel.apiBase = "https://open.bigmodel.cn/api/paas/v4" } @@ -122,6 +138,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if cfg.Providers.Gemini.APIKey != "" { sel.apiKey = cfg.Providers.Gemini.APIKey sel.apiBase = cfg.Providers.Gemini.APIBase + sel.proxy = cfg.Providers.Gemini.Proxy if sel.apiBase == "" { sel.apiBase = "https://generativelanguage.googleapis.com/v1beta" } @@ -130,15 +147,26 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if cfg.Providers.VLLM.APIBase != "" { sel.apiKey = cfg.Providers.VLLM.APIKey sel.apiBase = cfg.Providers.VLLM.APIBase + sel.proxy = cfg.Providers.VLLM.Proxy } case "shengsuanyun": if cfg.Providers.ShengSuanYun.APIKey != "" { sel.apiKey = cfg.Providers.ShengSuanYun.APIKey sel.apiBase = cfg.Providers.ShengSuanYun.APIBase + sel.proxy = cfg.Providers.ShengSuanYun.Proxy if sel.apiBase == "" { sel.apiBase = "https://router.shengsuanyun.com/api/v1" } } + case "nvidia": + if cfg.Providers.Nvidia.APIKey != "" { + sel.apiKey = cfg.Providers.Nvidia.APIKey + sel.apiBase = cfg.Providers.Nvidia.APIBase + sel.proxy = cfg.Providers.Nvidia.Proxy + if sel.apiBase == "" { + sel.apiBase = "https://integrate.api.nvidia.com/v1" + } + } case "claude-cli", "claude-code", "claudecode": workspace := cfg.WorkspacePath() if workspace == "" { @@ -159,6 +187,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { if cfg.Providers.DeepSeek.APIKey != "" { sel.apiKey = cfg.Providers.DeepSeek.APIKey sel.apiBase = cfg.Providers.DeepSeek.APIBase + sel.proxy = cfg.Providers.DeepSeek.Proxy if sel.apiBase == "" { sel.apiBase = "https://api.deepseek.com/v1" } @@ -204,6 +233,10 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" { + sel.apiBase = cfg.Providers.Anthropic.APIBase + if sel.apiBase == "" { + sel.apiBase = defaultAnthropicAPIBase + } sel.providerType = providerTypeClaudeAuth return sel, nil } @@ -211,7 +244,7 @@ func resolveProviderSelection(cfg *config.Config) (providerSelection, error) { sel.apiBase = cfg.Providers.Anthropic.APIBase sel.proxy = cfg.Providers.Anthropic.Proxy if sel.apiBase == "" { - sel.apiBase = "https://api.anthropic.com/v1" + sel.apiBase = defaultAnthropicAPIBase } case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): @@ -303,7 +336,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { switch sel.providerType { case providerTypeClaudeAuth: - return createClaudeAuthProvider() + return createClaudeAuthProvider(sel.apiBase) case providerTypeCodexAuth: return createCodexAuthProvider() case providerTypeCodexCLIToken: diff --git a/pkg/providers/factory_test.go b/pkg/providers/factory_test.go index c1f14291d..e31737eb9 100644 --- a/pkg/providers/factory_test.go +++ b/pkg/providers/factory_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -32,6 +33,40 @@ func TestResolveProviderSelection(t *testing.T) { wantType: providerTypeGitHubCopilot, wantAPIBase: "localhost:4321", }, + { + name: "explicit deepseek provider uses deepseek defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "deepseek" + cfg.Agents.Defaults.Model = "deepseek/deepseek-chat" + cfg.Providers.DeepSeek.APIKey = "deepseek-key" + cfg.Providers.DeepSeek.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://api.deepseek.com/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "explicit shengsuanyun provider uses defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "shengsuanyun" + cfg.Providers.ShengSuanYun.APIKey = "ssy-key" + cfg.Providers.ShengSuanYun.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://router.shengsuanyun.com/api/v1", + wantProxy: "http://127.0.0.1:7890", + }, + { + name: "explicit nvidia provider uses defaults", + setup: func(cfg *config.Config) { + cfg.Agents.Defaults.Provider = "nvidia" + cfg.Providers.Nvidia.APIKey = "nvapi-test" + cfg.Providers.Nvidia.Proxy = "http://127.0.0.1:7890" + }, + wantType: providerTypeHTTPCompat, + wantAPIBase: "https://integrate.api.nvidia.com/v1", + wantProxy: "http://127.0.0.1:7890", + }, { name: "openrouter model uses openrouter defaults", setup: func(cfg *config.Config) { @@ -202,3 +237,63 @@ func TestCreateProviderReturnsCodexProviderForCodexCliAuthMethod(t *testing.T) { t.Fatalf("provider type = %T, want *CodexProvider", provider) } } + +func TestCreateProviderReturnsClaudeProviderForAnthropicOAuth(t *testing.T) { + originalGetCredential := getCredential + t.Cleanup(func() { getCredential = originalGetCredential }) + + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "anthropic" { + t.Fatalf("provider = %q, want anthropic", provider) + } + return &auth.AuthCredential{ + AccessToken: "anthropic-token", + }, nil + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "anthropic" + cfg.Providers.Anthropic.AuthMethod = "oauth" + cfg.Providers.Anthropic.APIBase = "https://proxy.example.com/v1" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + claudeProvider, ok := provider.(*ClaudeProvider) + if !ok { + t.Fatalf("provider type = %T, want *ClaudeProvider", provider) + } + if got := claudeProvider.delegate.BaseURL(); got != "https://proxy.example.com" { + t.Fatalf("anthropic baseURL = %q, want %q", got, "https://proxy.example.com") + } +} + +func TestCreateProviderReturnsCodexProviderForOpenAIOAuth(t *testing.T) { + originalGetCredential := getCredential + t.Cleanup(func() { getCredential = originalGetCredential }) + + getCredential = func(provider string) (*auth.AuthCredential, error) { + if provider != "openai" { + t.Fatalf("provider = %q, want openai", provider) + } + return &auth.AuthCredential{ + AccessToken: "openai-token", + AccountID: "acct_123", + }, nil + } + + cfg := config.DefaultConfig() + cfg.Agents.Defaults.Provider = "openai" + cfg.Providers.OpenAI.AuthMethod = "oauth" + + provider, err := CreateProvider(cfg) + if err != nil { + t.Fatalf("CreateProvider() error = %v", err) + } + + if _, ok := provider.(*CodexProvider); !ok { + t.Fatalf("provider type = %T, want *CodexProvider", provider) + } +} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 0f7f646d8..e39a19e90 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -15,116 +15,16 @@ type HTTPProvider struct { delegate *openai_compat.Provider } -func NewHTTPProvider(apiKey, apiBase string, proxy ...string) *HTTPProvider { - proxyURL := "" - if len(proxy) > 0 { - proxyURL = proxy[0] - } +func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { return &HTTPProvider{ - delegate: openai_compat.NewProvider(apiKey, apiBase, proxyURL), + delegate: openai_compat.NewProvider(apiKey, apiBase, proxy), } } func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - compatResp, err := p.delegate.Chat(ctx, toOpenAICompatMessages(messages), toOpenAICompatTools(tools), model, options) - if err != nil { - return nil, err - } - return fromOpenAICompatResponse(compatResp), nil + return p.delegate.Chat(ctx, messages, tools, model, options) } func (p *HTTPProvider) GetDefaultModel() string { return "" } - -func toOpenAICompatMessages(messages []Message) []openai_compat.Message { - out := make([]openai_compat.Message, 0, len(messages)) - for _, msg := range messages { - out = append(out, openai_compat.Message{ - Role: msg.Role, - Content: msg.Content, - ToolCalls: toOpenAICompatToolCalls(msg.ToolCalls), - ToolCallID: msg.ToolCallID, - }) - } - return out -} - -func toOpenAICompatTools(tools []ToolDefinition) []openai_compat.ToolDefinition { - out := make([]openai_compat.ToolDefinition, 0, len(tools)) - for _, t := range tools { - out = append(out, openai_compat.ToolDefinition{ - Type: t.Type, - Function: openai_compat.ToolFunctionDefinition{ - Name: t.Function.Name, - Description: t.Function.Description, - Parameters: t.Function.Parameters, - }, - }) - } - return out -} - -func toOpenAICompatToolCalls(toolCalls []ToolCall) []openai_compat.ToolCall { - out := make([]openai_compat.ToolCall, 0, len(toolCalls)) - for _, tc := range toolCalls { - var fn *openai_compat.FunctionCall - if tc.Function != nil { - fn = &openai_compat.FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - } - } - out = append(out, openai_compat.ToolCall{ - ID: tc.ID, - Type: tc.Type, - Function: fn, - Name: tc.Name, - Arguments: tc.Arguments, - }) - } - return out -} - -func fromOpenAICompatResponse(resp *openai_compat.LLMResponse) *LLMResponse { - if resp == nil { - return &LLMResponse{} - } - - var usage *UsageInfo - if resp.Usage != nil { - usage = &UsageInfo{ - PromptTokens: resp.Usage.PromptTokens, - CompletionTokens: resp.Usage.CompletionTokens, - TotalTokens: resp.Usage.TotalTokens, - } - } - - return &LLMResponse{ - Content: resp.Content, - ToolCalls: fromOpenAICompatToolCalls(resp.ToolCalls), - FinishReason: resp.FinishReason, - Usage: usage, - } -} - -func fromOpenAICompatToolCalls(toolCalls []openai_compat.ToolCall) []ToolCall { - out := make([]ToolCall, 0, len(toolCalls)) - for _, tc := range toolCalls { - var fn *FunctionCall - if tc.Function != nil { - fn = &FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - } - } - out = append(out, ToolCall{ - ID: tc.ID, - Type: tc.Type, - Function: fn, - Name: tc.Name, - Arguments: tc.Arguments, - }) - } - return out -} diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 7bc8e26be..9b404dd77 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -6,55 +6,22 @@ import ( "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "strings" "time" + + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" ) -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` -} - -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -type LLMResponse struct { - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - Usage *UsageInfo `json:"usage,omitempty"` -} - -type UsageInfo struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} - -type ToolDefinition struct { - Type string `json:"type"` - Function ToolFunctionDefinition `json:"function"` -} - -type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} +type ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition type Provider struct { apiKey string @@ -62,21 +29,19 @@ type Provider struct { httpClient *http.Client } -func NewProvider(apiKey, apiBase string, proxy ...string) *Provider { - proxyURL := "" - if len(proxy) > 0 { - proxyURL = proxy[0] - } +func NewProvider(apiKey, apiBase, proxy string) *Provider { client := &http.Client{ Timeout: 120 * time.Second, } - if proxyURL != "" { - parsed, err := url.Parse(proxyURL) + if proxy != "" { + parsed, err := url.Parse(proxy) if err == nil { client.Transport = &http.Transport{ Proxy: http.ProxyURL(parsed), } + } else { + log.Printf("openai_compat: invalid proxy URL %q: %v", proxy, err) } } @@ -92,13 +57,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef return nil, fmt.Errorf("API base not configured") } - // Strip provider prefix for OpenAI-compatible backends. - if idx := strings.Index(model, "/"); idx != -1 { - prefix := model[:idx] - if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" { - model = model[idx+1:] - } - } + model = normalizeModel(model, p.apiBase) requestBody := map[string]interface{}{ "model": model, @@ -110,7 +69,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef requestBody["tool_choice"] = "auto" } - if maxTokens, ok := options["max_tokens"].(int); ok { + if maxTokens, ok := asInt(options["max_tokens"]); ok { lowerModel := strings.ToLower(model) if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { requestBody["max_completion_tokens"] = maxTokens @@ -119,7 +78,7 @@ func (p *Provider) Chat(ctx context.Context, messages []Message, tools []ToolDef } } - if temperature, ok := options["temperature"].(float64); ok { + if temperature, ok := asFloat(options["temperature"]); ok { lowerModel := strings.ToLower(model) // Kimi k2 models only support temperature=1. if strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2") { @@ -198,17 +157,11 @@ func parseResponse(body []byte) (*LLMResponse, error) { arguments := make(map[string]interface{}) name := "" - if tc.Type == "function" && tc.Function != nil { - name = tc.Function.Name - if tc.Function.Arguments != "" { - if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { - arguments["raw"] = tc.Function.Arguments - } - } - } else if tc.Function != nil { + if tc.Function != nil { name = tc.Function.Name if tc.Function.Arguments != "" { if err := json.Unmarshal([]byte(tc.Function.Arguments), &arguments); err != nil { + log.Printf("openai_compat: failed to decode tool call arguments for %q: %v", name, err) arguments["raw"] = tc.Function.Arguments } } @@ -228,3 +181,52 @@ func parseResponse(body []byte) (*LLMResponse, error) { Usage: apiResponse.Usage, }, nil } + +func normalizeModel(model, apiBase string) string { + idx := strings.Index(model, "/") + if idx == -1 { + return model + } + + if strings.Contains(strings.ToLower(apiBase), "openrouter.ai") { + return model + } + + prefix := strings.ToLower(model[:idx]) + switch prefix { + case "moonshot", "nvidia", "groq", "ollama", "deepseek", "google", "openrouter", "zhipu": + return model[idx+1:] + default: + return model + } +} + +func asInt(v interface{}) (int, bool) { + switch val := v.(type) { + case int: + return val, true + case int64: + return int(val), true + case float64: + return int(val), true + case float32: + return int(val), true + default: + return 0, false + } +} + +func asFloat(v interface{}) (float64, bool) { + switch val := v.(type) { + case float64: + return val, true + case float32: + return float64(val), true + case int: + return float64(val), true + case int64: + return float64(val), true + default: + return 0, false + } +} diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index e5926458b..94779b39c 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" "testing" ) @@ -32,7 +33,7 @@ func TestProviderChat_UsesMaxCompletionTokensForGLM(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "glm-4.7", map[string]interface{}{"max_tokens": 1234}) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -78,7 +79,7 @@ func TestProviderChat_ParsesToolCalls(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + p := NewProvider("key", server.URL, "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -100,7 +101,7 @@ func TestProviderChat_HTTPError(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "gpt-4o", nil) if err == nil { t.Fatal("expected error, got nil") @@ -128,7 +129,7 @@ func TestProviderChat_StripsMoonshotPrefixAndNormalizesKimiTemperature(t *testin })) defer server.Close() - p := NewProvider("key", server.URL) + p := NewProvider("key", server.URL, "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, @@ -164,6 +165,11 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { input: "ollama/qwen2.5:14b", wantModel: "qwen2.5:14b", }, + { + name: "strips deepseek prefix", + input: "deepseek/deepseek-chat", + wantModel: "deepseek-chat", + }, } for _, tt := range tests { @@ -188,7 +194,7 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { })) defer server.Close() - p := NewProvider("key", server.URL) + p := NewProvider("key", server.URL, "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, tt.input, nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -200,3 +206,72 @@ func TestProviderChat_StripsGroqAndOllamaPrefixes(t *testing.T) { }) } } + +func TestProvider_ProxyConfigured(t *testing.T) { + proxyURL := "http://127.0.0.1:8080" + p := NewProvider("key", "https://example.com", proxyURL) + + transport, ok := p.httpClient.Transport.(*http.Transport) + if !ok || transport == nil { + t.Fatalf("expected http transport with proxy, got %T", p.httpClient.Transport) + } + + req := &http.Request{URL: &url.URL{Scheme: "https", Host: "api.example.com"}} + gotProxy, err := transport.Proxy(req) + if err != nil { + t.Fatalf("proxy function returned error: %v", err) + } + if gotProxy == nil || gotProxy.String() != proxyURL { + t.Fatalf("proxy = %v, want %s", gotProxy, proxyURL) + } +} + +func TestProviderChat_AcceptsNumericOptionTypes(t *testing.T) { + var requestBody map[string]interface{} + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]interface{}{ + "choices": []map[string]interface{}{ + { + "message": map[string]interface{}{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "gpt-4o", + map[string]interface{}{"max_tokens": float64(512), "temperature": 1}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if requestBody["max_tokens"] != float64(512) { + t.Fatalf("max_tokens = %v, want 512", requestBody["max_tokens"]) + } + if requestBody["temperature"] != float64(1) { + t.Fatalf("temperature = %v, want 1", requestBody["temperature"]) + } +} + +func TestNormalizeModel_UsesAPIBase(t *testing.T) { + if got := normalizeModel("deepseek/deepseek-chat", "https://api.deepseek.com/v1"); got != "deepseek-chat" { + t.Fatalf("normalizeModel(deepseek) = %q, want %q", got, "deepseek-chat") + } + if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { + t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") + } +} diff --git a/pkg/providers/protocoltypes/types.go b/pkg/providers/protocoltypes/types.go new file mode 100644 index 000000000..6b33ae734 --- /dev/null +++ b/pkg/providers/protocoltypes/types.go @@ -0,0 +1,45 @@ +package protocoltypes + +type ToolCall struct { + ID string `json:"id"` + Type string `json:"type,omitempty"` + Function *FunctionCall `json:"function,omitempty"` + Name string `json:"name,omitempty"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +type FunctionCall struct { + Name string `json:"name"` + Arguments string `json:"arguments"` +} + +type LLMResponse struct { + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + FinishReason string `json:"finish_reason"` + Usage *UsageInfo `json:"usage,omitempty"` +} + +type UsageInfo struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls,omitempty"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolDefinition struct { + Type string `json:"type"` + Function ToolFunctionDefinition `json:"function"` +} + +type ToolFunctionDefinition struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters map[string]interface{} `json:"parameters"` +} diff --git a/pkg/providers/types.go b/pkg/providers/types.go index 88b62e975..221a842fa 100644 --- a/pkg/providers/types.go +++ b/pkg/providers/types.go @@ -1,52 +1,20 @@ package providers -import "context" +import ( + "context" -type ToolCall struct { - ID string `json:"id"` - Type string `json:"type,omitempty"` - Function *FunctionCall `json:"function,omitempty"` - Name string `json:"name,omitempty"` - Arguments map[string]interface{} `json:"arguments,omitempty"` -} + "github.com/sipeed/picoclaw/pkg/providers/protocoltypes" +) -type FunctionCall struct { - Name string `json:"name"` - Arguments string `json:"arguments"` -} - -type LLMResponse struct { - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - FinishReason string `json:"finish_reason"` - Usage *UsageInfo `json:"usage,omitempty"` -} - -type UsageInfo struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -type Message struct { - Role string `json:"role"` - Content string `json:"content"` - ToolCalls []ToolCall `json:"tool_calls,omitempty"` - ToolCallID string `json:"tool_call_id,omitempty"` -} +type ToolCall = protocoltypes.ToolCall +type FunctionCall = protocoltypes.FunctionCall +type LLMResponse = protocoltypes.LLMResponse +type UsageInfo = protocoltypes.UsageInfo +type Message = protocoltypes.Message +type ToolDefinition = protocoltypes.ToolDefinition +type ToolFunctionDefinition = protocoltypes.ToolFunctionDefinition type LLMProvider interface { Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) GetDefaultModel() string } - -type ToolDefinition struct { - Type string `json:"type"` - Function ToolFunctionDefinition `json:"function"` -} - -type ToolFunctionDefinition struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters map[string]interface{} `json:"parameters"` -} From b83304845ea53631e4c97cffb3d2f717d477e986 Mon Sep 17 00:00:00 2001 From: AlbertBui010 Date: Tue, 17 Feb 2026 23:39:17 +0700 Subject: [PATCH 25/31] docs: resolve conflict in README.ja.md --- README.ja.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.ja.md b/README.ja.md index fa4eae69a..709cee0ca 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-**日本語** | [Tiếng Việt](README.vi.md) | [English](README.md) +**日本語** | [中文](README.zh.md) | [Tiếng Việt](README.vi.md) | [English](README.md) From 8428446d69c64459018d737345f157960b56b90e Mon Sep 17 00:00:00 2001 From: AlbertBui010 Date: Tue, 17 Feb 2026 23:58:10 +0700 Subject: [PATCH 26/31] docs: fix allow_from typo in config examples --- README.ja.md | 8 ++++---- README.md | 4 ++-- README.vi.md | 8 ++++---- README.zh.md | 4 ++-- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.ja.md b/README.ja.md index c0a2b0de0..cbfffdd8f 100644 --- a/README.ja.md +++ b/README.ja.md @@ -253,7 +253,7 @@ Telegram、Discord、QQ、DingTalk、LINE で PicoClaw と会話できます "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -293,7 +293,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -676,7 +676,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る "telegram": { "enabled": true, "token": "123456:ABC...", - "allowFrom": ["123456789"] + "allow_from": ["123456789"] }, "discord": { "enabled": true, @@ -692,7 +692,7 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る "appSecret": "xxx", "encryptKey": "", "verificationToken": "", - "allowFrom": [] + "allow_from": [] } }, "tools": { diff --git a/README.md b/README.md index 9c95dfde8..b5ce651e0 100644 --- a/README.md +++ b/README.md @@ -283,7 +283,7 @@ Talk to your picoclaw through Telegram, Discord, DingTalk, or LINE "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -326,7 +326,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } diff --git a/README.vi.md b/README.vi.md index 533ef7607..e629eaa9b 100644 --- a/README.vi.md +++ b/README.vi.md @@ -14,7 +14,7 @@
Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Tiếng Việt** +**Tiếng Việt** | [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) --- @@ -50,7 +50,7 @@ ## 📢 Tin tức -2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](doc/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn! +2026-02-16 🎉 PicoClaw đạt 12K stars chỉ trong một tuần! Cảm ơn tất cả mọi người! PicoClaw đang phát triển nhanh hơn chúng tôi tưởng tượng. Do số lượng PR tăng cao, chúng tôi cấp thiết cần maintainer từ cộng đồng. Các vai trò tình nguyện viên và roadmap đã được công bố [tại đây](docs/picoclaw_community_roadmap_260216.md) — rất mong đón nhận sự tham gia của bạn! 2026-02-13 🎉 PicoClaw đạt 5000 stars trong 4 ngày! Cảm ơn cộng đồng! Chúng tôi đang hoàn thiện **Lộ trình dự án (Roadmap)** và thiết lập **Nhóm phát triển** để đẩy nhanh tốc độ phát triển PicoClaw. 🚀 **Kêu gọi hành động:** Vui lòng gửi yêu cầu tính năng tại GitHub Discussions. Chúng tôi sẽ xem xét và ưu tiên trong cuộc họp hàng tuần. @@ -270,7 +270,7 @@ Trò chuyện với PicoClaw qua Telegram, Discord, DingTalk hoặc LINE. "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -313,7 +313,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } diff --git a/README.zh.md b/README.zh.md index f6c495d67..9aad5859d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -291,7 +291,7 @@ picoclaw agent -m "2+2 等于几?" "telegram": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } @@ -336,7 +336,7 @@ picoclaw gateway "discord": { "enabled": true, "token": "YOUR_BOT_TOKEN", - "allowFrom": ["YOUR_USER_ID"] + "allow_from": ["YOUR_USER_ID"] } } } From f820da42d7a63b05bef448839fd7fc5e13527d3b Mon Sep 17 00:00:00 2001 From: Leandro Barbosa Date: Tue, 17 Feb 2026 17:52:28 -0300 Subject: [PATCH 27/31] docs: add Brazilian Portuguese README (README.pt-br.md) Add complete pt-BR translation of the README and update language navigation links across all existing READMEs (English, Chinese, Japanese) to include the Portuguese option. --- README.ja.md | 2 +- README.md | 2 +- README.pt-br.md | 881 ++++++++++++++++++++++++++++++++++++++++++++++++ README.zh.md | 2 +- 4 files changed, 884 insertions(+), 3 deletions(-) create mode 100644 README.pt-br.md diff --git a/README.ja.md b/README.ja.md index b86d636ac..0da84571a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -12,7 +12,7 @@ License

-[中文](README.zh.md) | **日本語** | [English](README.md) +[中文](README.zh.md) | **日本語** | [Português](README.pt-br.md) | [English](README.md) diff --git a/README.md b/README.md index e80e2213c..59b9bea7c 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Twitter

- [中文](README.zh.md) | [日本語](README.ja.md) | **English** + [中文](README.zh.md) | [日本語](README.ja.md) | [Português](README.pt-br.md) | **English** --- diff --git a/README.pt-br.md b/README.pt-br.md new file mode 100644 index 000000000..d250cc956 --- /dev/null +++ b/README.pt-br.md @@ -0,0 +1,881 @@ +
+PicoClaw + +

PicoClaw: Assistente de IA Ultra-Eficiente em Go

+ +

Hardware de $10 · 10MB de RAM · Boot em 1s · 皮皮虾,我们走!

+ +

+ Go + Hardware + License +
+ Website + Twitter +

+ + [中文](README.zh.md) | [日本語](README.ja.md) | [English](README.md) | **Português** +
+ +--- + +🦐 **PicoClaw** é um assistente pessoal de IA ultra-leve inspirado no [nanobot](https://github.com/HKUDS/nanobot), reescrito do zero em **Go** por meio de um processo de "auto-inicialização" (self-bootstrapping) — onde o próprio agente de IA conduziu toda a migração de arquitetura e otimização de código. + +⚡️ **Extremamente leve:** Roda em hardware de apenas **$10** com **<10MB** de RAM. Isso é 99% menos memória que o OpenClaw e 98% mais barato que um Mac mini! + + + + + + +
+

+ +

+
+

+ +

+
+ +> [!CAUTION] +> **🚨 DECLARACAO DE SEGURANCA & CANAIS OFICIAIS** +> +> * **SEM CRIPTOMOEDAS:** O PicoClaw **NAO** possui nenhum token/moeda oficial. Todas as alegacoes no `pump.fun` ou outras plataformas de negociacao sao **GOLPES**. +> * **DOMINIO OFICIAL:** O **UNICO** site oficial e **[picoclaw.io](https://picoclaw.io)**, e o site da empresa e **[sipeed.com](https://sipeed.com)**. +> * **Aviso:** Muitos dominios `.ai/.org/.com/.net/...` foram registrados por terceiros, nao sao nossos. +> * **Aviso:** O PicoClaw esta em fase inicial de desenvolvimento e pode ter problemas de seguranca de rede nao resolvidos. Nao implante em ambientes de producao antes da versao v1.0. +> * **Nota:** O PicoClaw recentemente fez merge de muitos PRs, o que pode resultar em maior consumo de memoria (10-20MB) nas versoes mais recentes. Planejamos priorizar a otimizacao de recursos assim que o conjunto de funcionalidades estiver estavel. + + +## 📢 Novidades + +2026-02-16 🎉 PicoClaw atingiu 12K stars em uma semana! Obrigado a todos pelo apoio! O PicoClaw esta crescendo mais rapido do que jamais imaginamos. Dado o alto volume de PRs, precisamos urgentemente de maintainers da comunidade. Nossos papeis de voluntarios e roadmap foram publicados oficialmente [aqui](docs/picoclaw_community_roadmap_260216.md) — estamos ansiosos para ter voce a bordo! + +2026-02-13 🎉 PicoClaw atingiu 5000 stars em 4 dias! Obrigado a comunidade! Estamos finalizando o **Roadmap do Projeto** e configurando o **Grupo de Desenvolvedores** para acelerar o desenvolvimento do PicoClaw. +🚀 **Chamada para Acao:** Envie suas solicitacoes de funcionalidades nas GitHub Discussions. Revisaremos e priorizaremos na proxima reuniao semanal. + +2026-02-09 🎉 PicoClaw lancado oficialmente! Construido em 1 dia para trazer Agentes de IA para hardware de $10 com <10MB de RAM. 🦐 PicoClaw, Partiu! + +## ✨ Funcionalidades + +🪶 **Ultra-Leve**: Consumo de memoria <10MB — 99% menor que o Clawdbot para funcionalidades essenciais. + +💰 **Custo Minimo**: Eficiente o suficiente para rodar em hardware de $10 — 98% mais barato que um Mac mini. + +⚡️ **Inicializacao Relampago**: Tempo de inicializacao 400X mais rapido, boot em 1 segundo mesmo em CPU single-core de 0.6GHz. + +🌍 **Portabilidade Real**: Um unico binario auto-contido para RISC-V, ARM e x86. Um clique e ja era! + +🤖 **Auto-Construido por IA**: Implementacao nativa em Go de forma autonoma — 95% do nucleo gerado pelo Agente com refinamento humano no loop. + +| | OpenClaw | NanoBot | **PicoClaw** | +| ----------------------------- | ------------- | ------------------------ | ----------------------------------------- | +| **Linguagem** | TypeScript | Python | **Go** | +| **RAM** | >1GB | >100MB | **< 10MB** | +| **Inicializacao**
(CPU 0.8GHz) | >500s | >30s | **<1s** | +| **Custo** | Mac Mini $599 | Maioria dos SBC Linux
~$50 | **Qualquer placa Linux**
**A partir de $10** | + +PicoClaw + +## 🦾 Demonstracao + +### 🛠️ Fluxos de Trabalho Padrao do Assistente + + + + + + + + + + + + + + + + + +

🧩 Engenharia Full-Stack

🗂️ Gerenciamento de Logs & Planejamento

🔎 Busca Web & Aprendizado

Desenvolver • Implantar • EscalarAgendar • Automatizar • MemorizarDescobrir • Analisar • Tendencias
+ +### 📱 Rode em celulares Android antigos + +De uma segunda vida ao seu celular de dez anos atras! Transforme-o em um assistente de IA inteligente com o PicoClaw. Inicio rapido: + +1. **Instale o Termux** (Disponivel no F-Droid ou Google Play). +2. **Execute os comandos** + +```bash +# Nota: Substitua v0.1.1 pela versao mais recente da pagina de Releases +wget https://github.com/sipeed/picoclaw/releases/download/v0.1.1/picoclaw-linux-arm64 +chmod +x picoclaw-linux-arm64 +pkg install proot +termux-chroot ./picoclaw-linux-arm64 onboard +``` + +Depois siga as instrucoes na secao "Inicio Rapido" para completar a configuracao! + +PicoClaw + +### 🐜 Implantacao Inovadora com Baixo Consumo + +O PicoClaw pode ser implantado em praticamente qualquer dispositivo Linux! + +- $9.9 [LicheeRV-Nano](https://www.aliexpress.com/item/1005006519668532.html) versao E (Ethernet) ou W (WiFi6), para Assistente Domestico Minimalista +- $30~50 [NanoKVM](https://www.aliexpress.com/item/1005007369816019.html), ou $100 [NanoKVM-Pro](https://www.aliexpress.com/item/1005010048471263.html) para Manutencao Automatizada de Servidores +- $50 [MaixCAM](https://www.aliexpress.com/item/1005008053333693.html) ou $100 [MaixCAM2](https://www.kickstarter.com/projects/zepan/maixcam2-build-your-next-gen-4k-ai-camera) para Monitoramento Inteligente + +https://private-user-images.githubusercontent.com/83055338/547056448-e7b031ff-d6f5-4468-bcca-5726b6fecb5c.mp4 + +🌟 Mais cenarios de implantacao aguardam voce! + +## 📦 Instalacao + +### Instalar com binario pre-compilado + +Baixe o binario para sua plataforma na pagina de [releases](https://github.com/sipeed/picoclaw/releases). + +### Instalar a partir do codigo-fonte (funcionalidades mais recentes, recomendado para desenvolvimento) + +```bash +git clone https://github.com/sipeed/picoclaw.git + +cd picoclaw +make deps + +# Build, sem necessidade de instalar +make build + +# Build para multiplas plataformas +make build-all + +# Build e Instalar +make install +``` + +## 🐳 Docker Compose + +Voce tambem pode rodar o PicoClaw usando Docker Compose sem instalar nada localmente. + +```bash +# 1. Clone este repositorio +git clone https://github.com/sipeed/picoclaw.git +cd picoclaw + +# 2. Configure suas API keys +cp config/config.example.json config/config.json +vim config/config.json # Configure DISCORD_BOT_TOKEN, API keys, etc. + +# 3. Build & Iniciar +docker compose --profile gateway up -d + +# 4. Ver logs +docker compose logs -f picoclaw-gateway + +# 5. Parar +docker compose --profile gateway down +``` + +### Modo Agente (Execucao unica) + +```bash +# Fazer uma pergunta +docker compose run --rm picoclaw-agent -m "Quanto e 2+2?" + +# Modo interativo +docker compose run --rm picoclaw-agent +``` + +### Rebuild + +```bash +docker compose --profile gateway build --no-cache +docker compose --profile gateway up -d +``` + +### 🚀 Inicio Rapido + +> [!TIP] +> Configure sua API key em `~/.picoclaw/config.json`. +> Obtenha API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) +> Busca web e **opcional** — obtenha a [Brave Search API](https://brave.com/search/api) gratuita (2000 consultas gratis/mes) ou use o fallback automatico integrado. + +**1. Inicializar** + +```bash +picoclaw onboard +``` + +**2. Configurar** (`~/.picoclaw/config.json`) + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "openrouter": { + "api_key": "xxx", + "api_base": "https://openrouter.ai/api/v1" + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +**3. Obter API Keys** + +* **Provedor de LLM**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) +* **Busca Web** (opcional): [Brave Search](https://brave.com/search/api) - Plano gratuito disponivel (2000 consultas/mes) + +> **Nota**: Veja `config.example.json` para um modelo de configuracao completo. + +**4. Conversar** + +```bash +picoclaw agent -m "Quanto e 2+2?" +``` + +Pronto! Voce tem um assistente de IA funcionando em 2 minutos. + +--- + +## 💬 Integracao com Apps de Chat + +Converse com seu PicoClaw via Telegram, Discord, DingTalk ou LINE. + +| Canal | Nivel de Configuracao | +| --- | --- | +| **Telegram** | Facil (apenas um token) | +| **Discord** | Facil (bot token + intents) | +| **QQ** | Facil (AppID + AppSecret) | +| **DingTalk** | Medio (credenciais do app) | +| **LINE** | Medio (credenciais + webhook URL) | + +
+Telegram (Recomendado) + +**1. Criar o bot** + +* Abra o Telegram, busque `@BotFather` +* Envie `/newbot`, siga as instrucoes +* Copie o token + +**2. Configurar** + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +> Obtenha seu User ID pelo `@userinfobot` no Telegram. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+Discord + +**1. Criar o bot** + +* Acesse +* Crie um aplicativo → Bot → Add Bot +* Copie o token do bot + +**2. Habilitar Intents** + +* Nas configuracoes do Bot, habilite **MESSAGE CONTENT INTENT** +* (Opcional) Habilite **SERVER MEMBERS INTENT** se quiser usar lista de permissoes baseada em dados dos membros + +**3. Obter seu User ID** + +* Configuracoes do Discord → Avancado → habilite **Modo Desenvolvedor** +* Clique com botao direito no seu avatar → **Copiar ID do Usuario** + +**4. Configurar** + +```json +{ + "channels": { + "discord": { + "enabled": true, + "token": "YOUR_BOT_TOKEN", + "allowFrom": ["YOUR_USER_ID"] + } + } +} +``` + +**5. Convidar o bot** + +* OAuth2 → URL Generator +* Scopes: `bot` +* Bot Permissions: `Send Messages`, `Read Message History` +* Abra a URL de convite gerada e adicione o bot ao seu servidor + +**6. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+QQ + +**1. Criar o bot** + +- Acesse a [QQ Open Platform](https://q.qq.com/#) +- Crie um aplicativo → Obtenha **AppID** e **AppSecret** + +**2. Configurar** + +```json +{ + "channels": { + "qq": { + "enabled": true, + "app_id": "YOUR_APP_ID", + "app_secret": "YOUR_APP_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique numeros QQ para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+DingTalk + +**1. Criar o bot** + +* Acesse a [Open Platform](https://open.dingtalk.com/) +* Crie um app interno +* Copie o Client ID e Client Secret + +**2. Configurar** + +```json +{ + "channels": { + "dingtalk": { + "enabled": true, + "client_id": "YOUR_CLIENT_ID", + "client_secret": "YOUR_CLIENT_SECRET", + "allow_from": [] + } + } +} +``` + +> Deixe `allow_from` vazio para permitir todos os usuarios, ou especifique IDs para restringir o acesso. + +**3. Executar** + +```bash +picoclaw gateway +``` + +
+ +
+LINE + +**1. Criar uma Conta Oficial LINE** + +- Acesse o [LINE Developers Console](https://developers.line.biz/) +- Crie um provider → Crie um canal Messaging API +- Copie o **Channel Secret** e o **Channel Access Token** + +**2. Configurar** + +```json +{ + "channels": { + "line": { + "enabled": true, + "channel_secret": "YOUR_CHANNEL_SECRET", + "channel_access_token": "YOUR_CHANNEL_ACCESS_TOKEN", + "webhook_host": "0.0.0.0", + "webhook_port": 18791, + "webhook_path": "/webhook/line", + "allow_from": [] + } + } +} +``` + +**3. Configurar URL do Webhook** + +O LINE requer HTTPS para webhooks. Use um reverse proxy ou tunnel: + +```bash +# Exemplo com ngrok +ngrok http 18791 +``` + +Em seguida, configure a Webhook URL no LINE Developers Console para `https://seu-dominio/webhook/line` e habilite **Use webhook**. + +**4. Executar** + +```bash +picoclaw gateway +``` + +> Em chats de grupo, o bot responde apenas quando mencionado com @. As respostas citam a mensagem original. + +> **Docker Compose**: Adicione `ports: ["18791:18791"]` ao servico `picoclaw-gateway` para expor a porta do webhook. + +
+ +## ClawdChat Junte-se a Rede Social de Agentes + +Conecte o PicoClaw a Rede Social de Agentes simplesmente enviando uma unica mensagem via CLI ou qualquer App de Chat integrado. + +**Leia `https://clawdchat.ai/skill.md` e siga as instrucoes para entrar no [ClawdChat.ai](https://clawdchat.ai)** + +## ⚙️ Configuracao Detalhada + +Arquivo de configuracao: `~/.picoclaw/config.json` + +### Estrutura do Workspace + +O PicoClaw armazena dados no workspace configurado (padrao: `~/.picoclaw/workspace`): + +``` +~/.picoclaw/workspace/ +├── sessions/ # Sessoes de conversa e historico +├── memory/ # Memoria de longo prazo (MEMORY.md) +├── state/ # Estado persistente (ultimo canal, etc.) +├── cron/ # Banco de dados de tarefas agendadas +├── skills/ # Skills personalizadas +├── AGENTS.md # Guia de comportamento do Agente +├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min) +├── IDENTITY.md # Identidade do Agente +├── SOUL.md # Alma do Agente +├── TOOLS.md # Descricao das ferramentas +└── USER.md # Preferencias do usuario +``` + +### 🔒 Sandbox de Seguranca + +O PicoClaw roda em um ambiente sandbox por padrao. O agente so pode acessar arquivos e executar comandos dentro do workspace configurado. + +#### Configuracao Padrao + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "restrict_to_workspace": true + } + } +} +``` + +| Opcao | Padrao | Descricao | +|-------|--------|-----------| +| `workspace` | `~/.picoclaw/workspace` | Diretorio de trabalho do agente | +| `restrict_to_workspace` | `true` | Restringir acesso de arquivos/comandos ao workspace | + +#### Ferramentas Protegidas + +Quando `restrict_to_workspace: true`, as seguintes ferramentas sao restritas ao sandbox: + +| Ferramenta | Funcao | Restricao | +|------------|--------|-----------| +| `read_file` | Ler arquivos | Apenas arquivos dentro do workspace | +| `write_file` | Escrever arquivos | Apenas arquivos dentro do workspace | +| `list_dir` | Listar diretorios | Apenas diretorios dentro do workspace | +| `edit_file` | Editar arquivos | Apenas arquivos dentro do workspace | +| `append_file` | Adicionar a arquivos | Apenas arquivos dentro do workspace | +| `exec` | Executar comandos | Caminhos dos comandos devem estar dentro do workspace | + +#### Protecao Adicional do Exec + +Mesmo com `restrict_to_workspace: false`, a ferramenta `exec` bloqueia estes comandos perigosos: + +* `rm -rf`, `del /f`, `rmdir /s` — Exclusao em massa +* `format`, `mkfs`, `diskpart` — Formatacao de disco +* `dd if=` — Criacao de imagem de disco +* Escrita em `/dev/sd[a-z]` — Escrita direta no disco +* `shutdown`, `reboot`, `poweroff` — Desligamento do sistema +* Fork bomb `:(){ :|:& };:` + +#### Exemplos de Erro + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (path outside working dir)} +``` + +``` +[ERROR] tool: Tool execution failed +{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} +``` + +#### Desabilitar Restricoes (Risco de Seguranca) + +Se voce precisa que o agente acesse caminhos fora do workspace: + +**Metodo 1: Arquivo de configuracao** + +```json +{ + "agents": { + "defaults": { + "restrict_to_workspace": false + } + } +} +``` + +**Metodo 2: Variavel de ambiente** + +```bash +export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false +``` + +> ⚠️ **Aviso**: Desabilitar esta restricao permite que o agente acesse qualquer caminho no seu sistema. Use com cuidado apenas em ambientes controlados. + +#### Consistencia do Limite de Seguranca + +A configuracao `restrict_to_workspace` se aplica consistentemente em todos os caminhos de execucao: + +| Caminho de Execucao | Limite de Seguranca | +|----------------------|---------------------| +| Agente Principal | `restrict_to_workspace` ✅ | +| Subagente / Spawn | Herda a mesma restricao ✅ | +| Tarefas Heartbeat | Herda a mesma restricao ✅ | + +Todos os caminhos compartilham a mesma restricao de workspace — nao ha como contornar o limite de seguranca por meio de subagentes ou tarefas agendadas. + +### Heartbeat (Tarefas Periodicas) + +O PicoClaw pode executar tarefas periodicas automaticamente. Crie um arquivo `HEARTBEAT.md` no seu workspace: + +```markdown +# Tarefas Periodicas + +- Verificar meu email para mensagens importantes +- Revisar minha agenda para proximos eventos +- Verificar a previsao do tempo +``` + +O agente lera este arquivo a cada 30 minutos (configuravel) e executara as tarefas usando as ferramentas disponiveis. + +#### Tarefas Assincronas com Spawn + +Para tarefas de longa duracao (busca web, chamadas de API), use a ferramenta `spawn` para criar um **subagente**: + +```markdown +# Tarefas Periodicas + +## Tarefas Rapidas (resposta direta) +- Informar hora atual + +## Tarefas Longas (usar spawn para async) +- Buscar noticias de IA na web e resumir +- Verificar email e reportar mensagens importantes +``` + +**Comportamentos principais:** + +| Funcionalidade | Descricao | +|----------------|-----------| +| **spawn** | Cria subagente assincrono, nao bloqueia o heartbeat | +| **Contexto independente** | Subagente tem seu proprio contexto, sem historico de sessao | +| **Ferramenta message** | Subagente se comunica diretamente com o usuario via ferramenta message | +| **Nao-bloqueante** | Apos o spawn, o heartbeat continua para a proxima tarefa | + +#### Como Funciona a Comunicacao do Subagente + +``` +Heartbeat dispara + ↓ +Agente le HEARTBEAT.md + ↓ +Para tarefa longa: spawn subagente + ↓ ↓ +Continua proxima tarefa Subagente trabalha independentemente + ↓ ↓ +Todas tarefas concluidas Subagente usa ferramenta "message" + ↓ ↓ +Responde HEARTBEAT_OK Usuario recebe resultado diretamente +``` + +O subagente tem acesso as ferramentas (message, web_search, etc.) e pode se comunicar com o usuario independentemente sem passar pelo agente principal. + +**Configuracao:** + +```json +{ + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +| Opcao | Padrao | Descricao | +|-------|--------|-----------| +| `enabled` | `true` | Habilitar/desabilitar heartbeat | +| `interval` | `30` | Intervalo de verificacao em minutos (min: 5) | + +**Variaveis de ambiente:** + +* `PICOCLAW_HEARTBEAT_ENABLED=false` para desabilitar +* `PICOCLAW_HEARTBEAT_INTERVAL=60` para alterar o intervalo + +### Provedores + +> [!NOTE] +> O Groq fornece transcricao de voz gratuita via Whisper. Se configurado, mensagens de voz do Telegram serao automaticamente transcritas. + +| Provedor | Finalidade | Obter API Key | +| --- | --- | --- | +| `gemini` | LLM (Gemini direto) | [aistudio.google.com](https://aistudio.google.com) | +| `zhipu` | LLM (Zhipu direto) | [bigmodel.cn](bigmodel.cn) | +| `openrouter` (Em teste) | LLM (recomendado, acesso a todos os modelos) | [openrouter.ai](https://openrouter.ai) | +| `anthropic` (Em teste) | LLM (Claude direto) | [console.anthropic.com](https://console.anthropic.com) | +| `openai` (Em teste) | LLM (GPT direto) | [platform.openai.com](https://platform.openai.com) | +| `deepseek` (Em teste) | LLM (DeepSeek direto) | [platform.deepseek.com](https://platform.deepseek.com) | +| `groq` | LLM + **Transcricao de voz** (Whisper) | [console.groq.com](https://console.groq.com) | + +
+Configuracao Zhipu + +**1. Obter API key** + +* Obtenha a [API key](https://bigmodel.cn/usercenter/proj-mgmt/apikeys) + +**2. Configurar** + +```json +{ + "agents": { + "defaults": { + "workspace": "~/.picoclaw/workspace", + "model": "glm-4.7", + "max_tokens": 8192, + "temperature": 0.7, + "max_tool_iterations": 20 + } + }, + "providers": { + "zhipu": { + "api_key": "Sua API Key", + "api_base": "https://open.bigmodel.cn/api/paas/v4" + } + } +} +``` + +**3. Executar** + +```bash +picoclaw agent -m "Ola, como vai?" +``` + +
+ +
+Exemplo de configuracao completa + +```json +{ + "agents": { + "defaults": { + "model": "anthropic/claude-opus-4-5" + } + }, + "providers": { + "openrouter": { + "api_key": "sk-or-v1-xxx" + }, + "groq": { + "api_key": "gsk_xxx" + } + }, + "channels": { + "telegram": { + "enabled": true, + "token": "123456:ABC...", + "allow_from": ["123456789"] + }, + "discord": { + "enabled": true, + "token": "", + "allow_from": [""] + }, + "whatsapp": { + "enabled": false + }, + "feishu": { + "enabled": false, + "app_id": "cli_xxx", + "app_secret": "xxx", + "encrypt_key": "", + "verification_token": "", + "allow_from": [] + }, + "qq": { + "enabled": false, + "app_id": "", + "app_secret": "", + "allow_from": [] + } + }, + "tools": { + "web": { + "brave": { + "enabled": false, + "api_key": "BSA...", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + }, + "cron": { + "exec_timeout_minutes": 5 + } + }, + "heartbeat": { + "enabled": true, + "interval": 30 + } +} +``` + +
+ +## Referencia CLI + +| Comando | Descricao | +| --- | --- | +| `picoclaw onboard` | Inicializar configuracao & workspace | +| `picoclaw agent -m "..."` | Conversar com o agente | +| `picoclaw agent` | Modo de chat interativo | +| `picoclaw gateway` | Iniciar o gateway (para bots de chat) | +| `picoclaw status` | Mostrar status | +| `picoclaw cron list` | Listar todas as tarefas agendadas | +| `picoclaw cron add ...` | Adicionar uma tarefa agendada | + +### Tarefas Agendadas / Lembretes + +O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`: + +* **Lembretes unicos**: "Remind me in 10 minutes" (Me lembre em 10 minutos) → dispara uma vez apos 10min +* **Tarefas recorrentes**: "Remind me every 2 hours" (Me lembre a cada 2 horas) → dispara a cada 2 horas +* **Expressoes Cron**: "Remind me at 9am daily" (Me lembre as 9h todos os dias) → usa expressao cron + +As tarefas sao armazenadas em `~/.picoclaw/workspace/cron/` e processadas automaticamente. + +## 🤝 Contribuir & Roadmap + +PRs sao bem-vindos! O codigo-fonte e intencionalmente pequeno e legivel. 🤗 + +Roadmap em breve... + +Grupo de desenvolvedores em formacao. Requisito de entrada: Pelo menos 1 PR com merge. + +Grupos de usuarios: + +Discord: + +PicoClaw + +## 🐛 Solucao de Problemas + +### Busca web mostra "API 配置问题" + +Isso e normal se voce ainda nao configurou uma API key de busca. O PicoClaw fornecera links uteis para busca manual. + +Para habilitar a busca web: + +1. **Opcao 1 (Recomendado)**: Obtenha uma API key gratuita em [https://brave.com/search/api](https://brave.com/search/api) (2000 consultas gratis/mes) para os melhores resultados. +2. **Opcao 2 (Sem Cartao de Credito)**: Se voce nao tem uma key, o sistema automaticamente usa o **DuckDuckGo** como fallback (sem necessidade de key). + +Adicione a key em `~/.picoclaw/config.json` se usar o Brave: + +```json +{ + "tools": { + "web": { + "brave": { + "enabled": true, + "api_key": "YOUR_BRAVE_API_KEY", + "max_results": 5 + }, + "duckduckgo": { + "enabled": true, + "max_results": 5 + } + } + } +} +``` + +### Erros de filtragem de conteudo + +Alguns provedores (como Zhipu) possuem filtragem de conteudo. Tente reformular sua pergunta ou use um modelo diferente. + +### Bot do Telegram diz "Conflict: terminated by other getUpdates" + +Isso acontece quando outra instancia do bot esta rodando. Certifique-se de que apenas um `picoclaw gateway` esteja rodando por vez. + +--- + +## 📝 Comparacao de API Keys + +| Servico | Plano Gratuito | Caso de Uso | +| --- | --- | --- | +| **OpenRouter** | 200K tokens/mes | Multiplos modelos (Claude, GPT-4, etc.) | +| **Zhipu** | 200K tokens/mes | Melhor para usuarios chineses | +| **Brave Search** | 2000 consultas/mes | Funcionalidade de busca web | +| **Groq** | Plano gratuito disponivel | Inferencia ultra-rapida (Llama, Mixtral) | diff --git a/README.zh.md b/README.zh.md index e7dc8d769..6c87ba785 100644 --- a/README.zh.md +++ b/README.zh.md @@ -14,7 +14,7 @@ Twitter

- **中文** | [日本語](README.ja.md) | [English](README.md) + **中文** | [日本語](README.ja.md) | [Português](README.pt-br.md) | [English](README.md) --- From 01d694b9985a66c3d7119fc9f74ce8ed4f0f21b5 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 18 Feb 2026 15:33:34 +0800 Subject: [PATCH 28/31] fix: Add comprehensive command injection and system abuse prevention patterns (#401) * Add comprehensive command injection and system abuse prevention patterns * fix: Container running as root --- Dockerfile | 9 ++++++++- docker-compose.yml | 8 ++++---- pkg/tools/shell.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index dd98ec0bd..0360cfda6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,7 +29,14 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Copy binary COPY --from=builder /src/build/picoclaw /usr/local/bin/picoclaw -# Create picoclaw home directory +# Create non-root user and group +RUN addgroup -g 1000 picoclaw && \ + adduser -D -u 1000 -G picoclaw picoclaw + +# Switch to non-root user +USER picoclaw + +# Run onboard to create initial directories and config RUN /usr/local/bin/picoclaw onboard ENTRYPOINT ["picoclaw"] diff --git a/docker-compose.yml b/docker-compose.yml index 48769627c..32e8ee339 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,8 +11,8 @@ services: profiles: - agent volumes: - - ./config/config.json:/root/.picoclaw/config.json:ro - - picoclaw-workspace:/root/.picoclaw/workspace + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace entrypoint: ["picoclaw", "agent"] stdin_open: true tty: true @@ -31,9 +31,9 @@ services: - gateway volumes: # Configuration file - - ./config/config.json:/root/.picoclaw/config.json:ro + - ./config/config.json:/home/picoclaw/.picoclaw/config.json:ro # Persistent workspace (sessions, memory, logs) - - picoclaw-workspace:/root/.picoclaw/workspace + - picoclaw-workspace:/home/picoclaw/.picoclaw/workspace command: ["gateway"] volumes: diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 713850f97..9c82b2748 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -31,6 +31,40 @@ func NewExecTool(workingDir string, restrict bool) *ExecTool { regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), } return &ExecTool{ From 193fbcab11fe3c448f982f43e7837585e843acea Mon Sep 17 00:00:00 2001 From: lxowalle Date: Wed, 18 Feb 2026 16:01:41 +0800 Subject: [PATCH 29/31] docs: update PR template --- .github/pull_request_template.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 7910cb1e2..c96b7da12 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,7 @@ ## 📝 Description + + + ## 🗣️ Type of Change - [ ] 🐞 Bug fix (non-breaking change which fixes an issue) - [ ] ✨ New feature (non-breaking change which adds functionality) @@ -11,25 +14,28 @@ - [ ] 👨‍💻 Mostly Human-written (Human lead, AI assisted or none) -## 🔗 Linked Issue +## 🔗 Related Issue + + + ## 📚 Technical Context (Skip for Docs) -* **Reference:** [URL] -* **Reasoning:** ... +- **Reference URL:** +- **Reasoning:** + +## 🧪 Test Environment +- **Hardware:** +- **OS:** +- **Model/Provider:** +- **Channels:** -## 🧪 Test Environment & Hardware -- **Hardware:** [e.g. Raspberry Pi 5, Orange Pi, PC] -- **OS:** [e.g. Debian 12, Ubuntu 22.04] -- **Model/Provider:** [e.g. OpenAI GPT-4o, Kimi k2, DeepSeek-V3] -- **Channels:** [e.g. Discord, Telegram, Feishu, ...] - - -## 📸 Proof of Work (Optional for Docs) +## 📸 Evidence (Optional)
Click to view Logs/Screenshots -
+ +
## ☑️ Checklist - [ ] My code/docs follow the style of this project. From 3390576eeacb97bdd3da95b156e226ab72ee0929 Mon Sep 17 00:00:00 2001 From: Zenix Date: Wed, 18 Feb 2026 17:30:30 +0900 Subject: [PATCH 30/31] Feature/websearch OpenAI (#118) * feature: add web search for codex models * fix: use more elegant way to solve the issue. --- config/config.example.json | 5 +- pkg/config/config.go | 33 ++++--- pkg/config/config_test.go | 39 ++++++++ pkg/migrate/config.go | 12 ++- pkg/providers/codex_provider.go | 37 +++++--- pkg/providers/codex_provider_test.go | 129 +++++++++++++++++++++++++-- pkg/providers/http_provider.go | 14 +-- 7 files changed, 230 insertions(+), 39 deletions(-) diff --git a/config/config.example.json b/config/config.example.json index 7cd0ab8c6..37c2bcd81 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -79,7 +79,8 @@ }, "openai": { "api_key": "", - "api_base": "" + "api_base": "", + "web_search": true }, "openrouter": { "api_key": "sk-or-v1-xxx", @@ -144,4 +145,4 @@ "host": "0.0.0.0", "port": 18790 } -} \ No newline at end of file +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d34f56f3..92a4a5862 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -167,19 +167,19 @@ type DevicesConfig struct { } type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` - OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` - Nvidia ProviderConfig `json:"nvidia"` - Ollama ProviderConfig `json:"ollama"` - Moonshot ProviderConfig `json:"moonshot"` - ShengSuanYun ProviderConfig `json:"shengsuanyun"` - DeepSeek ProviderConfig `json:"deepseek"` - GitHubCopilot ProviderConfig `json:"github_copilot"` + Anthropic ProviderConfig `json:"anthropic"` + OpenAI OpenAIProviderConfig `json:"openai"` + OpenRouter ProviderConfig `json:"openrouter"` + Groq ProviderConfig `json:"groq"` + Zhipu ProviderConfig `json:"zhipu"` + VLLM ProviderConfig `json:"vllm"` + Gemini ProviderConfig `json:"gemini"` + Nvidia ProviderConfig `json:"nvidia"` + Ollama ProviderConfig `json:"ollama"` + Moonshot ProviderConfig `json:"moonshot"` + ShengSuanYun ProviderConfig `json:"shengsuanyun"` + DeepSeek ProviderConfig `json:"deepseek"` + GitHubCopilot ProviderConfig `json:"github_copilot"` } type ProviderConfig struct { @@ -190,6 +190,11 @@ type ProviderConfig struct { ConnectMode string `json:"connect_mode,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` } +type OpenAIProviderConfig struct { + ProviderConfig + WebSearch bool `json:"web_search" env:"PICOCLAW_PROVIDERS_OPENAI_WEB_SEARCH"` +} + type GatewayConfig struct { Host string `json:"host" env:"PICOCLAW_GATEWAY_HOST"` Port int `json:"port" env:"PICOCLAW_GATEWAY_PORT"` @@ -308,7 +313,7 @@ func DefaultConfig() *Config { }, Providers: ProvidersConfig{ Anthropic: ProviderConfig{}, - OpenAI: ProviderConfig{}, + OpenAI: OpenAIProviderConfig{WebSearch: true}, OpenRouter: ProviderConfig{}, Groq: ProviderConfig{}, Zhipu: ProviderConfig{}, diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index febfd0456..a1f73f0b3 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -204,3 +204,42 @@ func TestConfig_Complete(t *testing.T) { t.Error("Heartbeat should be enabled by default") } } + +func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("DefaultConfig().Providers.OpenAI.WebSearch should be true") + } +} + +func TestLoadConfig_OpenAIWebSearchDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"api_base":""}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should remain true when unset in config file") + } +} + +func TestLoadConfig_OpenAIWebSearchCanBeDisabled(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"providers":{"openai":{"web_search":false}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Providers.OpenAI.WebSearch { + t.Fatal("OpenAI codex web search should be false when disabled in config file") + } +} diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index 9c1e36359..57032e566 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -108,7 +108,10 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error case "anthropic": cfg.Providers.Anthropic = pc case "openai": - cfg.Providers.OpenAI = pc + cfg.Providers.OpenAI = config.OpenAIProviderConfig{ + ProviderConfig: pc, + WebSearch: getBoolOrDefault(pMap, "web_search", true), + } case "openrouter": cfg.Providers.OpenRouter = pc case "groq": @@ -363,6 +366,13 @@ func getBool(data map[string]interface{}, key string) (bool, bool) { return b, ok } +func getBoolOrDefault(data map[string]interface{}, key string, defaultVal bool) bool { + if v, ok := getBool(data, key); ok { + return v + } + return defaultVal +} + func getStringSlice(data map[string]interface{}, key string) []string { v, ok := data[key] if !ok { diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go index 7617bf716..e3526cfb5 100644 --- a/pkg/providers/codex_provider.go +++ b/pkg/providers/codex_provider.go @@ -18,9 +18,10 @@ const codexDefaultModel = "gpt-5.2" const codexDefaultInstructions = "You are Codex, a coding assistant." type CodexProvider struct { - client *openai.Client - accountID string - tokenSource func() (string, string, error) + client *openai.Client + accountID string + tokenSource func() (string, string, error) + enableWebSearch bool } const defaultCodexInstructions = "You are Codex, a coding assistant." @@ -37,8 +38,9 @@ func NewCodexProvider(token, accountID string) *CodexProvider { } client := openai.NewClient(opts...) return &CodexProvider{ - client: &client, - accountID: accountID, + client: &client, + accountID: accountID, + enableWebSearch: true, } } @@ -78,7 +80,7 @@ func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []To }) } - params := buildCodexParams(messages, tools, resolvedModel, options) + params := buildCodexParams(messages, tools, resolvedModel, options, p.enableWebSearch) stream := p.client.Responses.NewStreaming(ctx, params, opts...) defer stream.Close() @@ -182,7 +184,7 @@ func resolveCodexModel(model string) (string, string) { return codexDefaultModel, "unsupported model family" } -func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams { +func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}, enableWebSearch bool) responses.ResponseNewParams { var inputItems responses.ResponseInputParam var instructions string @@ -266,8 +268,8 @@ func buildCodexParams(messages []Message, tools []ToolDefinition, model string, params.Instructions = openai.Opt(defaultCodexInstructions) } - if len(tools) > 0 { - params.Tools = translateToolsForCodex(tools) + if len(tools) > 0 || enableWebSearch { + params.Tools = translateToolsForCodex(tools, enableWebSearch) } return params @@ -297,9 +299,19 @@ func resolveCodexToolCall(tc ToolCall) (name string, arguments string, ok bool) return name, "{}", true } -func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { - result := make([]responses.ToolUnionParam, 0, len(tools)) +func translateToolsForCodex(tools []ToolDefinition, enableWebSearch bool) []responses.ToolUnionParam { + capHint := len(tools) + if enableWebSearch { + capHint++ + } + result := make([]responses.ToolUnionParam, 0, capHint) for _, t := range tools { + if t.Type != "function" { + continue + } + if enableWebSearch && strings.EqualFold(t.Function.Name, "web_search") { + continue + } ft := responses.FunctionToolParam{ Name: t.Function.Name, Parameters: t.Function.Parameters, @@ -310,6 +322,9 @@ func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { } result = append(result, responses.ToolUnionParam{OfFunction: &ft}) } + if enableWebSearch { + result = append(result, responses.ToolParamOfWebSearch(responses.WebSearchToolTypeWebSearch)) + } return result } diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go index 8406760c4..92e276165 100644 --- a/pkg/providers/codex_provider_test.go +++ b/pkg/providers/codex_provider_test.go @@ -19,7 +19,7 @@ func TestBuildCodexParams_BasicMessage(t *testing.T) { params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{ "max_tokens": 2048, "temperature": 0.7, - }) + }, true) if params.Model != "gpt-4o" { t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") } @@ -39,7 +39,7 @@ func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { {Role: "system", Content: "You are helpful"}, {Role: "user", Content: "Hi"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, true) if !params.Instructions.Valid() { t.Fatal("Instructions should be set") } @@ -59,7 +59,7 @@ func TestBuildCodexParams_ToolCallConversation(t *testing.T) { }, {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -87,7 +87,7 @@ func TestBuildCodexParams_ToolCallFunctionFallback(t *testing.T) { {Role: "tool", Content: "ok", ToolCallID: "call_1"}, } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}, false) if params.Input.OfInputItemList == nil { t.Fatal("Input.OfInputItemList should not be nil") } @@ -123,7 +123,7 @@ func TestBuildCodexParams_WithTools(t *testing.T) { }, }, } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, false) if len(params.Tools) != 1 { t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) } @@ -136,12 +136,61 @@ func TestBuildCodexParams_WithTools(t *testing.T) { } func TestBuildCodexParams_StoreIsFalse(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}) + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, false) if !params.Store.Valid() || params.Store.Or(true) != false { t.Error("Store should be explicitly set to false") } } +func TestBuildCodexParams_DefaultWebSearchEnabled(t *testing.T) { + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 1 { + t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) + } + if params.Tools[0].OfWebSearch == nil { + t.Fatal("Tool should include built-in web_search") + } + if params.Tools[0].OfWebSearch.Type != responses.WebSearchToolTypeWebSearch { + t.Errorf("Web search tool type = %q, want %q", params.Tools[0].OfWebSearch.Type, responses.WebSearchToolTypeWebSearch) + } +} + +func TestBuildCodexParams_WebSearchFunctionReplacedWithBuiltin(t *testing.T) { + tools := []ToolDefinition{ + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "web_search", + Description: "local web search", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + { + Type: "function", + Function: ToolFunctionDefinition{ + Name: "read_file", + Description: "read file", + Parameters: map[string]interface{}{ + "type": "object", + }, + }, + }, + } + + params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}, true) + if len(params.Tools) != 2 { + t.Fatalf("len(Tools) = %d, want 2", len(params.Tools)) + } + if params.Tools[0].OfFunction == nil || params.Tools[0].OfFunction.Name != "read_file" { + t.Fatalf("first tool should be function read_file, got %#v", params.Tools[0]) + } + if params.Tools[1].OfWebSearch == nil { + t.Fatalf("second tool should be built-in web_search, got %#v", params.Tools[1]) + } +} + func TestParseCodexResponse_TextOutput(t *testing.T) { respJSON := `{ "id": "resp_test", @@ -260,6 +309,16 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { http.Error(w, "max_output_tokens is not supported", http.StatusBadRequest) return } + toolsAny, ok := reqBody["tools"].([]interface{}) + if !ok || len(toolsAny) != 1 { + http.Error(w, "missing default web search tool", http.StatusBadRequest) + return + } + toolObj, ok := toolsAny[0].(map[string]interface{}) + if !ok || toolObj["type"] != "web_search" { + http.Error(w, "expected web_search tool", http.StatusBadRequest) + return + } resp := map[string]interface{}{ "id": "resp_test", @@ -307,6 +366,64 @@ func TestCodexProvider_ChatRoundTrip(t *testing.T) { } } +func TestCodexProvider_ChatRoundTrip_WebSearchDisabled(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/responses" { + http.Error(w, "not found: "+r.URL.Path, http.StatusNotFound) + return + } + + var reqBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&reqBody); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if _, ok := reqBody["tools"]; ok { + http.Error(w, "tools should be absent when web search disabled", http.StatusBadRequest) + return + } + + resp := map[string]interface{}{ + "id": "resp_test", + "object": "response", + "status": "completed", + "output": []map[string]interface{}{ + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": []map[string]interface{}{ + {"type": "output_text", "text": "Hi from Codex!"}, + }, + }, + }, + "usage": map[string]interface{}{ + "input_tokens": 4, + "output_tokens": 3, + "total_tokens": 7, + "input_tokens_details": map[string]interface{}{"cached_tokens": 0}, + "output_tokens_details": map[string]interface{}{"reasoning_tokens": 0}, + }, + } + writeCompletedSSE(w, resp) + })) + defer server.Close() + + provider := NewCodexProvider("test-token", "acc-123") + provider.enableWebSearch = false + provider.client = createOpenAITestClient(server.URL, "test-token", "acc-123") + + messages := []Message{{Role: "user", Content: "Hello"}} + resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{}) + if err != nil { + t.Fatalf("Chat() error: %v", err) + } + if resp.Content != "Hi from Codex!" { + t.Errorf("Content = %q, want %q", resp.Content, "Hi from Codex!") + } +} + func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/responses" { diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 4cf2c6db2..946aa29d2 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -208,7 +208,7 @@ func createClaudeAuthProvider() (LLMProvider, error) { return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } -func createCodexAuthProvider() (LLMProvider, error) { +func createCodexAuthProvider(enableWebSearch bool) (LLMProvider, error) { cred, err := auth.GetCredential("openai") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) @@ -216,7 +216,9 @@ func createCodexAuthProvider() (LLMProvider, error) { if cred == nil { return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") } - return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil + p := NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()) + p.enableWebSearch = enableWebSearch + return p, nil } func CreateProvider(cfg *config.Config) (LLMProvider, error) { @@ -241,10 +243,12 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { case "openai", "gpt": if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - return NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()), nil + c := NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()) + c.enableWebSearch = cfg.Providers.OpenAI.WebSearch + return c, nil } if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() + return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch) } apiKey = cfg.Providers.OpenAI.APIKey apiBase = cfg.Providers.OpenAI.APIBase @@ -369,7 +373,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() + return createCodexAuthProvider(cfg.Providers.OpenAI.WebSearch) } apiKey = cfg.Providers.OpenAI.APIKey apiBase = cfg.Providers.OpenAI.APIBase From eda6e373323861fea99630013946d7aeea94ade0 Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Wed, 18 Feb 2026 19:31:15 +0800 Subject: [PATCH 31/31] feat: Support modifying the command filtering list of the exec tool (#410) --- cmd/picoclaw/main.go | 6 +- docs/tools_configuration.md | 122 ++++++++++++++++++++++++++++++++++++ pkg/agent/loop.go | 2 +- pkg/config/config.go | 9 +++ pkg/tools/cron.go | 7 ++- pkg/tools/shell.go | 119 ++++++++++++++++++++++------------- 6 files changed, 215 insertions(+), 50 deletions(-) create mode 100644 docs/tools_configuration.md diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index fd7ec484a..128f8c421 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -563,7 +563,7 @@ func gatewayCmd() { // Setup cron tool and service execTimeout := time.Duration(cfg.Tools.Cron.ExecTimeoutMinutes) * time.Minute - cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout) + cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath(), cfg.Agents.Defaults.RestrictToWorkspace, execTimeout, cfg) heartbeatService := heartbeat.NewHeartbeatService( cfg.WorkspacePath(), @@ -988,14 +988,14 @@ func getConfigPath() string { return filepath.Join(home, ".picoclaw", "config.json") } -func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *cron.CronService { +func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") // Create cron service cronService := cron.NewCronService(cronStorePath, nil) // Create and register CronTool - cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout) + cronTool := tools.NewCronTool(cronService, agentLoop, msgBus, workspace, restrict, execTimeout, config) agentLoop.RegisterTool(cronTool) // Set the onJob handler diff --git a/docs/tools_configuration.md b/docs/tools_configuration.md new file mode 100644 index 000000000..8777ddbd6 --- /dev/null +++ b/docs/tools_configuration.md @@ -0,0 +1,122 @@ +# Tools Configuration + +PicoClaw's tools configuration is located in the `tools` field of `config.json`. + +## Directory Structure + +```json +{ + "tools": { + "web": { ... }, + "exec": { ... }, + "approval": { ... }, + "cron": { ... } + } +} +``` + +## Web Tools + +Web tools are used for web search and fetching. + +### Brave + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Brave search | +| `api_key` | string | - | Brave Search API key | +| `max_results` | int | 5 | Maximum number of results | + +### DuckDuckGo + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable DuckDuckGo search | +| `max_results` | int | 5 | Maximum number of results | + +### Perplexity + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | false | Enable Perplexity search | +| `api_key` | string | - | Perplexity API key | +| `max_results` | int | 5 | Maximum number of results | + +## Exec Tool + +The exec tool is used to execute shell commands. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enable_deny_patterns` | bool | true | Enable default dangerous command blocking | +| `custom_deny_patterns` | array | [] | Custom deny patterns (regular expressions) | + +### Functionality + +- **`enable_deny_patterns`**: Set to `false` to completely disable the default dangerous command blocking patterns +- **`custom_deny_patterns`**: Add custom deny regex patterns; commands matching these will be blocked + +### Default Blocked Command Patterns + +By default, PicoClaw blocks the following dangerous commands: + +- Delete commands: `rm -rf`, `del /f/q`, `rmdir /s` +- Disk operations: `format`, `mkfs`, `diskpart`, `dd if=`, writing to `/dev/sd*` +- System operations: `shutdown`, `reboot`, `poweroff` +- Command substitution: `$()`, `${}`, backticks +- Pipe to shell: `| sh`, `| bash` +- Privilege escalation: `sudo`, `chmod`, `chown` +- Process control: `pkill`, `killall`, `kill -9` +- Remote operations: `curl | sh`, `wget | sh`, `ssh` +- Package management: `apt`, `yum`, `dnf`, `npm install -g`, `pip install --user` +- Containers: `docker run`, `docker exec` +- Git: `git push`, `git force` +- Other: `eval`, `source *.sh` + +### Configuration Example + +```json +{ + "tools": { + "exec": { + "enable_deny_patterns": true, + "custom_deny_patterns": [ + "\\brm\\s+-r\\b", + "\\bkillall\\s+python" + ], + } + } +} +``` + +## Approval Tool + +The approval tool controls permissions for dangerous operations. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `enabled` | bool | true | Enable approval functionality | +| `write_file` | bool | true | Require approval for file writes | +| `edit_file` | bool | true | Require approval for file edits | +| `append_file` | bool | true | Require approval for file appends | +| `exec` | bool | true | Require approval for command execution | +| `timeout_minutes` | int | 5 | Approval timeout in minutes | + +## Cron Tool + +The cron tool is used for scheduling periodic tasks. + +| Config | Type | Default | Description | +|--------|------|---------|-------------| +| `exec_timeout_minutes` | int | 5 | Execution timeout in minutes, 0 means no limit | + +## Environment Variables + +All configuration options can be overridden via environment variables with the format `PICOCLAW_TOOLS_
_`: + +For example: +- `PICOCLAW_TOOLS_WEB_BRAVE_ENABLED=true` +- `PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS=false` +- `PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES=10` + +Note: Array-type environment variables are not currently supported and must be set via the config file. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index d3afa298e..8c6c58c96 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -71,7 +71,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg registry.Register(tools.NewAppendFileTool(workspace, restrict)) // Shell execution - registry.Register(tools.NewExecTool(workspace, restrict)) + registry.Register(tools.NewExecToolWithConfig(workspace, restrict, cfg)) if searchTool := tools.NewWebSearchTool(tools.WebSearchToolOptions{ BraveAPIKey: cfg.Tools.Web.Brave.APIKey, diff --git a/pkg/config/config.go b/pkg/config/config.go index 92a4a5862..a1cc978b6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -227,9 +227,15 @@ type CronToolsConfig struct { ExecTimeoutMinutes int `json:"exec_timeout_minutes" env:"PICOCLAW_TOOLS_CRON_EXEC_TIMEOUT_MINUTES"` // 0 means no timeout } +type ExecConfig struct { + EnableDenyPatterns bool `json:"enable_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_ENABLE_DENY_PATTERNS"` + CustomDenyPatterns []string `json:"custom_deny_patterns" env:"PICOCLAW_TOOLS_EXEC_CUSTOM_DENY_PATTERNS"` +} + type ToolsConfig struct { Web WebToolsConfig `json:"web"` Cron CronToolsConfig `json:"cron"` + Exec ExecConfig `json:"exec"` } func DefaultConfig() *Config { @@ -347,6 +353,9 @@ func DefaultConfig() *Config { Cron: CronToolsConfig{ ExecTimeoutMinutes: 5, // default 5 minutes for LLM operations }, + Exec: ExecConfig{ + EnableDenyPatterns: true, + }, }, Heartbeat: HeartbeatConfig{ Enabled: true, diff --git a/pkg/tools/cron.go b/pkg/tools/cron.go index 21bee42ef..e2764d8ac 100644 --- a/pkg/tools/cron.go +++ b/pkg/tools/cron.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/cron" "github.com/sipeed/picoclaw/pkg/utils" ) @@ -29,9 +30,9 @@ type CronTool struct { // NewCronTool creates a new CronTool // execTimeout: 0 means no timeout, >0 sets the timeout duration -func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration) *CronTool { - execTool := NewExecTool(workspace, restrict) - execTool.SetTimeout(execTimeout) // 0 means no timeout +func NewCronTool(cronService *cron.CronService, executor JobExecutor, msgBus *bus.MessageBus, workspace string, restrict bool, execTimeout time.Duration, config *config.Config) *CronTool { + execTool := NewExecToolWithConfig(workspace, restrict, config) + execTool.SetTimeout(execTimeout) return &CronTool{ cronService: cronService, executor: executor, diff --git a/pkg/tools/shell.go b/pkg/tools/shell.go index 9c82b2748..bd612d9ae 100644 --- a/pkg/tools/shell.go +++ b/pkg/tools/shell.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "fmt" + "github.com/sipeed/picoclaw/pkg/config" "os" "os/exec" "path/filepath" @@ -21,50 +22,82 @@ type ExecTool struct { restrictToWorkspace bool } +var defaultDenyPatterns = []*regexp.Regexp{ + regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), + regexp.MustCompile(`\bdel\s+/[fq]\b`), + regexp.MustCompile(`\brmdir\s+/s\b`), + regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) + regexp.MustCompile(`\bdd\s+if=`), + regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) + regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), + regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), + regexp.MustCompile(`\$\([^)]+\)`), + regexp.MustCompile(`\$\{[^}]+\}`), + regexp.MustCompile("`[^`]+`"), + regexp.MustCompile(`\|\s*sh\b`), + regexp.MustCompile(`\|\s*bash\b`), + regexp.MustCompile(`;\s*rm\s+-[rf]`), + regexp.MustCompile(`&&\s*rm\s+-[rf]`), + regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), + regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), + regexp.MustCompile(`<<\s*EOF`), + regexp.MustCompile(`\$\(\s*cat\s+`), + regexp.MustCompile(`\$\(\s*curl\s+`), + regexp.MustCompile(`\$\(\s*wget\s+`), + regexp.MustCompile(`\$\(\s*which\s+`), + regexp.MustCompile(`\bsudo\b`), + regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), + regexp.MustCompile(`\bchown\b`), + regexp.MustCompile(`\bpkill\b`), + regexp.MustCompile(`\bkillall\b`), + regexp.MustCompile(`\bkill\s+-[9]\b`), + regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), + regexp.MustCompile(`\bnpm\s+install\s+-g\b`), + regexp.MustCompile(`\bpip\s+install\s+--user\b`), + regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), + regexp.MustCompile(`\byum\s+(install|remove)\b`), + regexp.MustCompile(`\bdnf\s+(install|remove)\b`), + regexp.MustCompile(`\bdocker\s+run\b`), + regexp.MustCompile(`\bdocker\s+exec\b`), + regexp.MustCompile(`\bgit\s+push\b`), + regexp.MustCompile(`\bgit\s+force\b`), + regexp.MustCompile(`\bssh\b.*@`), + regexp.MustCompile(`\beval\b`), + regexp.MustCompile(`\bsource\s+.*\.sh\b`), +} + func NewExecTool(workingDir string, restrict bool) *ExecTool { - denyPatterns := []*regexp.Regexp{ - regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), - regexp.MustCompile(`\bdel\s+/[fq]\b`), - regexp.MustCompile(`\brmdir\s+/s\b`), - regexp.MustCompile(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args) - regexp.MustCompile(`\bdd\s+if=`), - regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) - regexp.MustCompile(`\b(shutdown|reboot|poweroff)\b`), - regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), - regexp.MustCompile(`\$\([^)]+\)`), - regexp.MustCompile(`\$\{[^}]+\}`), - regexp.MustCompile("`[^`]+`"), - regexp.MustCompile(`\|\s*sh\b`), - regexp.MustCompile(`\|\s*bash\b`), - regexp.MustCompile(`;\s*rm\s+-[rf]`), - regexp.MustCompile(`&&\s*rm\s+-[rf]`), - regexp.MustCompile(`\|\|\s*rm\s+-[rf]`), - regexp.MustCompile(`>\s*/dev/null\s*>&?\s*\d?`), - regexp.MustCompile(`<<\s*EOF`), - regexp.MustCompile(`\$\(\s*cat\s+`), - regexp.MustCompile(`\$\(\s*curl\s+`), - regexp.MustCompile(`\$\(\s*wget\s+`), - regexp.MustCompile(`\$\(\s*which\s+`), - regexp.MustCompile(`\bsudo\b`), - regexp.MustCompile(`\bchmod\s+[0-7]{3,4}\b`), - regexp.MustCompile(`\bchown\b`), - regexp.MustCompile(`\bpkill\b`), - regexp.MustCompile(`\bkillall\b`), - regexp.MustCompile(`\bkill\s+-[9]\b`), - regexp.MustCompile(`\bcurl\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bwget\b.*\|\s*(sh|bash)`), - regexp.MustCompile(`\bnpm\s+install\s+-g\b`), - regexp.MustCompile(`\bpip\s+install\s+--user\b`), - regexp.MustCompile(`\bapt\s+(install|remove|purge)\b`), - regexp.MustCompile(`\byum\s+(install|remove)\b`), - regexp.MustCompile(`\bdnf\s+(install|remove)\b`), - regexp.MustCompile(`\bdocker\s+run\b`), - regexp.MustCompile(`\bdocker\s+exec\b`), - regexp.MustCompile(`\bgit\s+push\b`), - regexp.MustCompile(`\bgit\s+force\b`), - regexp.MustCompile(`\bssh\b.*@`), - regexp.MustCompile(`\beval\b`), - regexp.MustCompile(`\bsource\s+.*\.sh\b`), + return NewExecToolWithConfig(workingDir, restrict, nil) +} + +func NewExecToolWithConfig(workingDir string, restrict bool, config *config.Config) *ExecTool { + denyPatterns := make([]*regexp.Regexp, 0) + + enableDenyPatterns := true + if config != nil { + execConfig := config.Tools.Exec + enableDenyPatterns = execConfig.EnableDenyPatterns + if enableDenyPatterns { + if len(execConfig.CustomDenyPatterns) > 0 { + fmt.Printf("Using custom deny patterns: %v\n", execConfig.CustomDenyPatterns) + for _, pattern := range execConfig.CustomDenyPatterns { + re, err := regexp.Compile(pattern) + if err != nil { + fmt.Printf("Invalid custom deny pattern %q: %v\n", pattern, err) + continue + } + denyPatterns = append(denyPatterns, re) + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) + } + } else { + // If deny patterns are disabled, we won't add any patterns, allowing all commands. + fmt.Println("Warning: deny patterns are disabled. All commands will be allowed.") + } + } else { + denyPatterns = append(denyPatterns, defaultDenyPatterns...) } return &ExecTool{