This commit is contained in:
Satyam Tiwari
2026-02-12 20:25:31 +05:30
11 changed files with 369 additions and 150 deletions

21
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: build
on:
push:
branches: ["main"]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Build
run: make build-all

View File

@@ -2,6 +2,7 @@
"agents": { "agents": {
"defaults": { "defaults": {
"workspace": "~/.picoclaw/workspace", "workspace": "~/.picoclaw/workspace",
"restrict_to_workspace": true,
"model": "glm-4.7", "model": "glm-4.7",
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.7, "temperature": 0.7,
@@ -12,6 +13,7 @@
"telegram": { "telegram": {
"enabled": false, "enabled": false,
"token": "YOUR_TELEGRAM_BOT_TOKEN", "token": "YOUR_TELEGRAM_BOT_TOKEN",
"proxy": "",
"allow_from": ["YOUR_USER_ID"] "allow_from": ["YOUR_USER_ID"]
}, },
"discord": { "discord": {
@@ -79,6 +81,15 @@
"vllm": { "vllm": {
"api_key": "", "api_key": "",
"api_base": "" "api_base": ""
},
"nvidia": {
"api_key": "nvapi-xxx",
"api_base": "",
"proxy": "http://127.0.0.1:7890"
},
"moonshot": {
"api_key": "sk-xxx",
"api_base": ""
} }
}, },
"tools": { "tools": {

View File

@@ -189,6 +189,17 @@ func (cb *ContextBuilder) BuildMessages(history []providers.Message, summary str
systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary systemPrompt += "\n\n## Summary of Previous Conversation\n\n" + summary
} }
//This fix prevents the session memory from LLM failure due to elimination of toolu_IDs required from LLM
// --- INICIO DEL FIX ---
//Diegox-17
for len(history) > 0 && (history[0].Role == "tool") {
logger.DebugCF("agent", "Removing orphaned tool message from history to prevent LLM error",
map[string]interface{}{"role": history[0].Role})
history = history[1:]
}
//Diegox-17
// --- FIN DEL FIX ---
messages = append(messages, providers.Message{ messages = append(messages, providers.Message{
Role: "system", Role: "system",
Content: systemPrompt, Content: systemPrompt,

View File

@@ -55,11 +55,13 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
workspace := cfg.WorkspacePath() workspace := cfg.WorkspacePath()
os.MkdirAll(workspace, 0755) os.MkdirAll(workspace, 0755)
restrict := cfg.Agents.Defaults.RestrictToWorkspace
toolsRegistry := tools.NewToolRegistry() toolsRegistry := tools.NewToolRegistry()
toolsRegistry.Register(&tools.ReadFileTool{}) toolsRegistry.Register(tools.NewReadFileTool(workspace, restrict))
toolsRegistry.Register(&tools.WriteFileTool{}) toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict))
toolsRegistry.Register(&tools.ListDirTool{}) toolsRegistry.Register(tools.NewListDirTool(workspace, restrict))
toolsRegistry.Register(tools.NewExecTool(workspace)) toolsRegistry.Register(tools.NewExecTool(workspace, restrict))
braveAPIKey := cfg.Tools.Web.Search.APIKey braveAPIKey := cfg.Tools.Web.Search.APIKey
toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults)) toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults))
@@ -83,8 +85,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
toolsRegistry.Register(spawnTool) toolsRegistry.Register(spawnTool)
// Register edit file tool // Register edit file tool
editFileTool := tools.NewEditFileTool(workspace) editFileTool := tools.NewEditFileTool(workspace, restrict)
toolsRegistry.Register(editFileTool) toolsRegistry.Register(editFileTool)
toolsRegistry.Register(tools.NewAppendFileTool(workspace, restrict))
sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions")) sessionsManager := session.NewSessionManager(filepath.Join(workspace, "sessions"))

View File

@@ -3,6 +3,7 @@ package channels
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
) )
@@ -47,8 +48,18 @@ func (c *BaseChannel) IsAllowed(senderID string) bool {
return true return true
} }
// Extract parts from compound senderID like "123456|username"
idPart := senderID
userPart := ""
if idx := strings.Index(senderID, "|"); idx > 0 {
idPart = senderID[:idx]
userPart = senderID[idx+1:]
}
for _, allowed := range c.allowList { for _, allowed := range c.allowList {
if senderID == allowed { // Strip leading "@" from allowed value for username matching
trimmed := strings.TrimPrefix(allowed, "@")
if senderID == allowed || idPart == allowed || senderID == trimmed || idPart == trimmed || (userPart != "" && (userPart == allowed || userPart == trimmed)) {
return true return true
} }
} }

View File

@@ -3,6 +3,8 @@ package channels
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"net/url"
"os" "os"
"regexp" "regexp"
"strings" "strings"
@@ -40,7 +42,21 @@ func (c *thinkingCancel) Cancel() {
} }
func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) { func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*TelegramChannel, error) {
bot, err := telego.NewBot(cfg.Token) var opts []telego.BotOption
if cfg.Proxy != "" {
proxyURL, parseErr := url.Parse(cfg.Proxy)
if parseErr != nil {
return nil, fmt.Errorf("invalid proxy URL %q: %w", cfg.Proxy, parseErr)
}
opts = append(opts, telego.WithHTTPClient(&http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(proxyURL),
},
}))
}
bot, err := telego.NewBot(cfg.Token, opts...)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create telegram bot: %w", err) return nil, fmt.Errorf("failed to create telegram bot: %w", err)
} }

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
@@ -9,6 +10,39 @@ import (
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
) )
// FlexibleStringSlice is a []string that also accepts JSON numbers,
// so allow_from can contain both "123" and 123.
type FlexibleStringSlice []string
func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error {
// Try []string first
var ss []string
if err := json.Unmarshal(data, &ss); err == nil {
*f = ss
return nil
}
// Try []interface{} to handle mixed types
var raw []interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
result := make([]string, 0, len(raw))
for _, v := range raw {
switch val := v.(type) {
case string:
result = append(result, val)
case float64:
result = append(result, fmt.Sprintf("%.0f", val))
default:
result = append(result, fmt.Sprintf("%v", val))
}
}
*f = result
return nil
}
type Config struct { type Config struct {
Agents AgentsConfig `json:"agents"` Agents AgentsConfig `json:"agents"`
Channels ChannelsConfig `json:"channels"` Channels ChannelsConfig `json:"channels"`
@@ -24,6 +58,7 @@ type AgentsConfig struct {
type AgentDefaults struct { type AgentDefaults struct {
Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"`
RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"`
Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"`
Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"` Model string `json:"model" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL"`
MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"`
@@ -43,57 +78,58 @@ type ChannelsConfig struct {
} }
type WhatsAppConfig struct { type WhatsAppConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_WHATSAPP_ENABLED"`
BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"` BridgeURL string `json:"bridge_url" env:"PICOCLAW_CHANNELS_WHATSAPP_BRIDGE_URL"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"`
} }
type TelegramConfig struct { type TelegramConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_TELEGRAM_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_TELEGRAM_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"` Proxy string `json:"proxy" env:"PICOCLAW_CHANNELS_TELEGRAM_PROXY"`
AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_TELEGRAM_ALLOW_FROM"`
} }
type FeishuConfig struct { type FeishuConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_FEISHU_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_FEISHU_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_FEISHU_APP_SECRET"`
EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"` EncryptKey string `json:"encrypt_key" env:"PICOCLAW_CHANNELS_FEISHU_ENCRYPT_KEY"`
VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"` VerificationToken string `json:"verification_token" env:"PICOCLAW_CHANNELS_FEISHU_VERIFICATION_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_FEISHU_ALLOW_FROM"`
} }
type DiscordConfig struct { type DiscordConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DISCORD_ENABLED"`
Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"` Token string `json:"token" env:"PICOCLAW_CHANNELS_DISCORD_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DISCORD_ALLOW_FROM"`
} }
type MaixCamConfig struct { type MaixCamConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_MAIXCAM_ENABLED"`
Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"` Host string `json:"host" env:"PICOCLAW_CHANNELS_MAIXCAM_HOST"`
Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"` Port int `json:"port" env:"PICOCLAW_CHANNELS_MAIXCAM_PORT"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_MAIXCAM_ALLOW_FROM"`
} }
type QQConfig struct { type QQConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_QQ_ENABLED"`
AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"` AppID string `json:"app_id" env:"PICOCLAW_CHANNELS_QQ_APP_ID"`
AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"` AppSecret string `json:"app_secret" env:"PICOCLAW_CHANNELS_QQ_APP_SECRET"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_QQ_ALLOW_FROM"`
} }
type DingTalkConfig struct { type DingTalkConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_DINGTALK_ENABLED"`
ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"` ClientID string `json:"client_id" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_ID"`
ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"` ClientSecret string `json:"client_secret" env:"PICOCLAW_CHANNELS_DINGTALK_CLIENT_SECRET"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_DINGTALK_ALLOW_FROM"`
} }
type SlackConfig struct { type SlackConfig struct {
Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"` Enabled bool `json:"enabled" env:"PICOCLAW_CHANNELS_SLACK_ENABLED"`
BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"` BotToken string `json:"bot_token" env:"PICOCLAW_CHANNELS_SLACK_BOT_TOKEN"`
AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"` AppToken string `json:"app_token" env:"PICOCLAW_CHANNELS_SLACK_APP_TOKEN"`
AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"` AllowFrom []string `json:"allow_from" env:"PICOCLAW_CHANNELS_SLACK_ALLOW_FROM"`
} }
@@ -105,11 +141,14 @@ type ProvidersConfig struct {
Zhipu ProviderConfig `json:"zhipu"` Zhipu ProviderConfig `json:"zhipu"`
VLLM ProviderConfig `json:"vllm"` VLLM ProviderConfig `json:"vllm"`
Gemini ProviderConfig `json:"gemini"` Gemini ProviderConfig `json:"gemini"`
Nvidia ProviderConfig `json:"nvidia"`
Moonshot ProviderConfig `json:"moonshot"`
} }
type ProviderConfig struct { type ProviderConfig struct {
APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"` APIKey string `json:"api_key" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_KEY"`
APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"` APIBase string `json:"api_base" env:"PICOCLAW_PROVIDERS_{{.Name}}_API_BASE"`
Proxy string `json:"proxy,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_PROXY"`
AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"` AuthMethod string `json:"auth_method,omitempty" env:"PICOCLAW_PROVIDERS_{{.Name}}_AUTH_METHOD"`
} }
@@ -136,6 +175,7 @@ func DefaultConfig() *Config {
Agents: AgentsConfig{ Agents: AgentsConfig{
Defaults: AgentDefaults{ Defaults: AgentDefaults{
Workspace: "~/.picoclaw/workspace", Workspace: "~/.picoclaw/workspace",
RestrictToWorkspace: true,
Provider: "", Provider: "",
Model: "glm-4.7", Model: "glm-4.7",
MaxTokens: 8192, MaxTokens: 8192,
@@ -147,12 +187,12 @@ func DefaultConfig() *Config {
WhatsApp: WhatsAppConfig{ WhatsApp: WhatsAppConfig{
Enabled: false, Enabled: false,
BridgeURL: "ws://localhost:3001", BridgeURL: "ws://localhost:3001",
AllowFrom: []string{}, AllowFrom: FlexibleStringSlice{},
}, },
Telegram: TelegramConfig{ Telegram: TelegramConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",
AllowFrom: []string{}, AllowFrom: FlexibleStringSlice{},
}, },
Feishu: FeishuConfig{ Feishu: FeishuConfig{
Enabled: false, Enabled: false,
@@ -160,30 +200,30 @@ func DefaultConfig() *Config {
AppSecret: "", AppSecret: "",
EncryptKey: "", EncryptKey: "",
VerificationToken: "", VerificationToken: "",
AllowFrom: []string{}, AllowFrom: FlexibleStringSlice{},
}, },
Discord: DiscordConfig{ Discord: DiscordConfig{
Enabled: false, Enabled: false,
Token: "", Token: "",
AllowFrom: []string{}, AllowFrom: FlexibleStringSlice{},
}, },
MaixCam: MaixCamConfig{ MaixCam: MaixCamConfig{
Enabled: false, Enabled: false,
Host: "0.0.0.0", Host: "0.0.0.0",
Port: 18790, Port: 18790,
AllowFrom: []string{}, AllowFrom: FlexibleStringSlice{},
}, },
QQ: QQConfig{ QQ: QQConfig{
Enabled: false, Enabled: false,
AppID: "", AppID: "",
AppSecret: "", AppSecret: "",
AllowFrom: []string{}, AllowFrom: FlexibleStringSlice{},
}, },
DingTalk: DingTalkConfig{ DingTalk: DingTalkConfig{
Enabled: false, Enabled: false,
ClientID: "", ClientID: "",
ClientSecret: "", ClientSecret: "",
AllowFrom: []string{}, AllowFrom: FlexibleStringSlice{},
}, },
Slack: SlackConfig{ Slack: SlackConfig{
Enabled: false, Enabled: false,
@@ -200,6 +240,8 @@ func DefaultConfig() *Config {
Zhipu: ProviderConfig{}, Zhipu: ProviderConfig{},
VLLM: ProviderConfig{}, VLLM: ProviderConfig{},
Gemini: ProviderConfig{}, Gemini: ProviderConfig{},
Nvidia: ProviderConfig{},
Moonshot: ProviderConfig{},
}, },
Gateway: GatewayConfig{ Gateway: GatewayConfig{
Host: "0.0.0.0", Host: "0.0.0.0",

View File

@@ -13,6 +13,7 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/auth"
@@ -25,13 +26,24 @@ type HTTPProvider struct {
httpClient *http.Client httpClient *http.Client
} }
func NewHTTPProvider(apiKey, apiBase string) *HTTPProvider { func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider {
client := &http.Client{
Timeout: 0,
}
if proxy != "" {
proxyURL, err := url.Parse(proxy)
if err == nil {
client.Transport = &http.Transport{
Proxy: http.ProxyURL(proxyURL),
}
}
}
return &HTTPProvider{ return &HTTPProvider{
apiKey: apiKey, apiKey: apiKey,
apiBase: apiBase, apiBase: apiBase,
httpClient: &http.Client{ httpClient: client,
Timeout: 0,
},
} }
} }
@@ -40,6 +52,14 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
return nil, fmt.Errorf("API base not configured") 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{}{ requestBody := map[string]interface{}{
"model": model, "model": model,
"messages": messages, "messages": messages,
@@ -60,7 +80,13 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too
} }
if temperature, ok := options["temperature"].(float64); ok { if temperature, ok := options["temperature"].(float64); ok {
requestBody["temperature"] = temperature 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) jsonData, err := json.Marshal(requestBody)
@@ -196,7 +222,7 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
model := cfg.Agents.Defaults.Model model := cfg.Agents.Defaults.Model
providerName := strings.ToLower(cfg.Agents.Defaults.Provider) providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
var apiKey, apiBase string var apiKey, apiBase, proxy string
lowerModel := strings.ToLower(model) lowerModel := strings.ToLower(model)
@@ -268,72 +294,97 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
// Fallback: detect provider from model name // Fallback: detect provider from model name
if apiKey == "" && apiBase == "" { if apiKey == "" && apiBase == "" {
switch { 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/"): switch {
apiKey = cfg.Providers.OpenRouter.APIKey case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
if cfg.Providers.OpenRouter.APIBase != "" { apiKey = cfg.Providers.Moonshot.APIKey
apiBase = cfg.Providers.OpenRouter.APIBase apiBase = cfg.Providers.Moonshot.APIBase
} else { proxy = cfg.Providers.Moonshot.Proxy
apiBase = "https://openrouter.ai/api/v1" if apiBase == "" {
} apiBase = "https://api.moonshot.cn/v1"
}
case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""): 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/"):
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 (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
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
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
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
if apiBase == "" {
apiBase = "https://api.groq.com/openai/v1"
}
case cfg.Providers.VLLM.APIBase != "":
apiKey = cfg.Providers.VLLM.APIKey
apiBase = cfg.Providers.VLLM.APIBase
default:
if cfg.Providers.OpenRouter.APIKey != "" {
apiKey = cfg.Providers.OpenRouter.APIKey apiKey = cfg.Providers.OpenRouter.APIKey
proxy = cfg.Providers.OpenRouter.Proxy
if cfg.Providers.OpenRouter.APIBase != "" { if cfg.Providers.OpenRouter.APIBase != "" {
apiBase = cfg.Providers.OpenRouter.APIBase apiBase = cfg.Providers.OpenRouter.APIBase
} else { } else {
apiBase = "https://openrouter.ai/api/v1" apiBase = "https://openrouter.ai/api/v1"
} }
} else {
return nil, fmt.Errorf("no API key configured for model: %s", model) 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)
}
} }
} }
}
if apiKey == "" && !strings.HasPrefix(model, "bedrock/") { if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
@@ -343,5 +394,5 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) {
return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
} }
return NewHTTPProvider(apiKey, apiBase), nil return NewHTTPProvider(apiKey, apiBase, proxy), nil
} }

View File

@@ -4,20 +4,21 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
) )
// EditFileTool edits a file by replacing old_text with new_text. // EditFileTool edits a file by replacing old_text with new_text.
// The old_text must exist exactly in the file. // The old_text must exist exactly in the file.
type EditFileTool struct { type EditFileTool struct {
allowedDir string // Optional directory restriction for security allowedDir string
restrict bool
} }
// NewEditFileTool creates a new EditFileTool with optional directory restriction. // NewEditFileTool creates a new EditFileTool with optional directory restriction.
func NewEditFileTool(allowedDir string) *EditFileTool { func NewEditFileTool(allowedDir string, restrict bool) *EditFileTool {
return &EditFileTool{ return &EditFileTool{
allowedDir: allowedDir, allowedDir: allowedDir,
restrict: restrict,
} }
} }
@@ -66,27 +67,9 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
return "", fmt.Errorf("new_text is required") return "", fmt.Errorf("new_text is required")
} }
// Resolve path and enforce directory restriction if configured resolvedPath, err := validatePath(path, t.allowedDir, t.restrict)
resolvedPath := path if err != nil {
if filepath.IsAbs(path) { return "", err
resolvedPath = filepath.Clean(path)
} else {
abs, err := filepath.Abs(path)
if err != nil {
return "", fmt.Errorf("failed to resolve path: %w", err)
}
resolvedPath = abs
}
// Check directory restriction
if t.allowedDir != "" {
allowedAbs, err := filepath.Abs(t.allowedDir)
if err != nil {
return "", fmt.Errorf("failed to resolve allowed directory: %w", err)
}
if !strings.HasPrefix(resolvedPath, allowedAbs) {
return "", fmt.Errorf("path %s is outside allowed directory %s", path, t.allowedDir)
}
} }
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) { if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
@@ -118,10 +101,13 @@ func (t *EditFileTool) Execute(ctx context.Context, args map[string]interface{})
return fmt.Sprintf("Successfully edited %s", path), nil return fmt.Sprintf("Successfully edited %s", path), nil
} }
type AppendFileTool struct{} type AppendFileTool struct {
workspace string
restrict bool
}
func NewAppendFileTool() *AppendFileTool { func NewAppendFileTool(workspace string, restrict bool) *AppendFileTool {
return &AppendFileTool{} return &AppendFileTool{workspace: workspace, restrict: restrict}
} }
func (t *AppendFileTool) Name() string { func (t *AppendFileTool) Name() string {
@@ -160,9 +146,12 @@ func (t *AppendFileTool) Execute(ctx context.Context, args map[string]interface{
return "", fmt.Errorf("content is required") return "", fmt.Errorf("content is required")
} }
filePath := filepath.Clean(path) resolvedPath, err := validatePath(path, t.workspace, t.restrict)
if err != nil {
return "", err
}
f, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) f, err := os.OpenFile(resolvedPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to open file: %w", err) return "", fmt.Errorf("failed to open file: %w", err)
} }

View File

@@ -5,9 +5,45 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
) )
type ReadFileTool struct{} // validatePath ensures the given path is within the workspace if restrict is true.
func validatePath(path, workspace string, restrict bool) (string, error) {
if workspace == "" {
return path, nil
}
absWorkspace, err := filepath.Abs(workspace)
if err != nil {
return "", fmt.Errorf("failed to resolve workspace path: %w", err)
}
var absPath string
if filepath.IsAbs(path) {
absPath = filepath.Clean(path)
} else {
absPath, err = filepath.Abs(filepath.Join(absWorkspace, path))
if err != nil {
return "", fmt.Errorf("failed to resolve file path: %w", err)
}
}
if restrict && !strings.HasPrefix(absPath, absWorkspace) {
return "", fmt.Errorf("access denied: path is outside the workspace")
}
return absPath, nil
}
type ReadFileTool struct {
workspace string
restrict bool
}
func NewReadFileTool(workspace string, restrict bool) *ReadFileTool {
return &ReadFileTool{workspace: workspace, restrict: restrict}
}
func (t *ReadFileTool) Name() string { func (t *ReadFileTool) Name() string {
return "read_file" return "read_file"
@@ -36,7 +72,12 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{})
return "", fmt.Errorf("path is required") return "", fmt.Errorf("path is required")
} }
content, err := os.ReadFile(path) resolvedPath, err := validatePath(path, t.workspace, t.restrict)
if err != nil {
return "", err
}
content, err := os.ReadFile(resolvedPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read file: %w", err) return "", fmt.Errorf("failed to read file: %w", err)
} }
@@ -44,7 +85,14 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]interface{})
return string(content), nil return string(content), nil
} }
type WriteFileTool struct{} type WriteFileTool struct {
workspace string
restrict bool
}
func NewWriteFileTool(workspace string, restrict bool) *WriteFileTool {
return &WriteFileTool{workspace: workspace, restrict: restrict}
}
func (t *WriteFileTool) Name() string { func (t *WriteFileTool) Name() string {
return "write_file" return "write_file"
@@ -82,19 +130,31 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]interface{}
return "", fmt.Errorf("content is required") return "", fmt.Errorf("content is required")
} }
dir := filepath.Dir(path) resolvedPath, err := validatePath(path, t.workspace, t.restrict)
if err != nil {
return "", err
}
dir := filepath.Dir(resolvedPath)
if err := os.MkdirAll(dir, 0755); err != nil { if err := os.MkdirAll(dir, 0755); err != nil {
return "", fmt.Errorf("failed to create directory: %w", err) return "", fmt.Errorf("failed to create directory: %w", err)
} }
if err := os.WriteFile(path, []byte(content), 0644); err != nil { if err := os.WriteFile(resolvedPath, []byte(content), 0644); err != nil {
return "", fmt.Errorf("failed to write file: %w", err) return "", fmt.Errorf("failed to write file: %w", err)
} }
return "File written successfully", nil return "File written successfully", nil
} }
type ListDirTool struct{} type ListDirTool struct {
workspace string
restrict bool
}
func NewListDirTool(workspace string, restrict bool) *ListDirTool {
return &ListDirTool{workspace: workspace, restrict: restrict}
}
func (t *ListDirTool) Name() string { func (t *ListDirTool) Name() string {
return "list_dir" return "list_dir"
@@ -123,7 +183,12 @@ func (t *ListDirTool) Execute(ctx context.Context, args map[string]interface{})
path = "." path = "."
} }
entries, err := os.ReadDir(path) resolvedPath, err := validatePath(path, t.workspace, t.restrict)
if err != nil {
return "", err
}
entries, err := os.ReadDir(resolvedPath)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read directory: %w", err) return "", fmt.Errorf("failed to read directory: %w", err)
} }

View File

@@ -22,14 +22,14 @@ type ExecTool struct {
restrictToWorkspace bool restrictToWorkspace bool
} }
func NewExecTool(workingDir string) *ExecTool { func NewExecTool(workingDir string, restrict bool) *ExecTool {
denyPatterns := []*regexp.Regexp{ denyPatterns := []*regexp.Regexp{
regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`), regexp.MustCompile(`\brm\s+-[rf]{1,2}\b`),
regexp.MustCompile(`\bdel\s+/[fq]\b`), regexp.MustCompile(`\bdel\s+/[fq]\b`),
regexp.MustCompile(`\brmdir\s+/s\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(`\b(format|mkfs|diskpart)\b\s`), // Match disk wiping commands (must be followed by space/args)
regexp.MustCompile(`\bdd\s+if=`), regexp.MustCompile(`\bdd\s+if=`),
regexp.MustCompile(`>\s*/dev/sd[a-z]\b`), // Block writes to disk devices (but allow /dev/null) 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(`\b(shutdown|reboot|poweroff)\b`),
regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`), regexp.MustCompile(`:\(\)\s*\{.*\};\s*:`),
} }
@@ -39,7 +39,7 @@ func NewExecTool(workingDir string) *ExecTool {
timeout: 60 * time.Second, timeout: 60 * time.Second,
denyPatterns: denyPatterns, denyPatterns: denyPatterns,
allowPatterns: nil, allowPatterns: nil,
restrictToWorkspace: false, restrictToWorkspace: restrict,
} }
} }
@@ -99,7 +99,6 @@ func (t *ExecTool) Execute(ctx context.Context, args map[string]interface{}) (st
} else { } else {
cmd = exec.CommandContext(cmdCtx, "sh", "-c", command) cmd = exec.CommandContext(cmdCtx, "sh", "-c", command)
} }
if cwd != "" { if cwd != "" {
cmd.Dir = cwd cmd.Dir = cwd
} }