diff --git a/cmd/clawdroid/main.go b/cmd/clawdroid/main.go index 8947b79e4..7013cc6b6 100644 --- a/cmd/clawdroid/main.go +++ b/cmd/clawdroid/main.go @@ -23,7 +23,6 @@ import ( "github.com/chzyer/readline" "github.com/KarakuriAgent/clawdroid/pkg/agent" - "github.com/KarakuriAgent/clawdroid/pkg/auth" "github.com/KarakuriAgent/clawdroid/pkg/bus" "github.com/KarakuriAgent/clawdroid/pkg/channels" "github.com/KarakuriAgent/clawdroid/pkg/config" @@ -32,7 +31,6 @@ import ( "github.com/KarakuriAgent/clawdroid/pkg/health" "github.com/KarakuriAgent/clawdroid/pkg/heartbeat" "github.com/KarakuriAgent/clawdroid/pkg/logger" - "github.com/KarakuriAgent/clawdroid/pkg/migrate" "github.com/KarakuriAgent/clawdroid/pkg/providers" "github.com/KarakuriAgent/clawdroid/pkg/skills" "github.com/KarakuriAgent/clawdroid/pkg/state" @@ -136,10 +134,6 @@ func main() { gatewayCmd() case "status": statusCmd() - case "migrate": - migrateCmd() - case "auth": - authCmd() case "cron": cronCmd() case "skills": @@ -202,11 +196,9 @@ func printHelp() { fmt.Println("Commands:") fmt.Println(" onboard Initialize clawdroid configuration and workspace") fmt.Println(" agent Interact with the agent directly") - fmt.Println(" auth Manage authentication (login, logout, status)") fmt.Println(" gateway Start clawdroid gateway") fmt.Println(" status Show clawdroid status") fmt.Println(" cron Manage scheduled tasks") - fmt.Println(" migrate Migrate from OpenClaw to ClawDroid") fmt.Println(" skills Manage skills (install, list, remove)") fmt.Println(" version Show version information") } @@ -297,76 +289,6 @@ func createWorkspaceTemplates(workspace string) { } } -func migrateCmd() { - if len(os.Args) > 2 && (os.Args[2] == "--help" || os.Args[2] == "-h") { - migrateHelp() - return - } - - opts := migrate.Options{} - - args := os.Args[2:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--dry-run": - opts.DryRun = true - case "--config-only": - opts.ConfigOnly = true - case "--workspace-only": - opts.WorkspaceOnly = true - case "--force": - opts.Force = true - case "--refresh": - opts.Refresh = true - case "--openclaw-home": - if i+1 < len(args) { - opts.OpenClawHome = args[i+1] - i++ - } - case "--clawdroid-home": - if i+1 < len(args) { - opts.ClawDroidHome = args[i+1] - i++ - } - default: - fmt.Printf("Unknown flag: %s\n", args[i]) - migrateHelp() - os.Exit(1) - } - } - - result, err := migrate.Run(opts) - if err != nil { - fmt.Printf("Error: %v\n", err) - os.Exit(1) - } - - if !opts.DryRun { - migrate.PrintSummary(result) - } -} - -func migrateHelp() { - fmt.Println("\nMigrate from OpenClaw to ClawDroid") - fmt.Println() - fmt.Println("Usage: clawdroid migrate [options]") - fmt.Println() - fmt.Println("Options:") - fmt.Println(" --dry-run Show what would be migrated without making changes") - fmt.Println(" --refresh Re-sync workspace files from OpenClaw (repeatable)") - fmt.Println(" --config-only Only migrate config, skip workspace files") - fmt.Println(" --workspace-only Only migrate workspace files, skip config") - fmt.Println(" --force Skip confirmation prompts") - fmt.Println(" --openclaw-home Override OpenClaw home directory (default: ~/.openclaw)") - fmt.Println(" --clawdroid-home Override ClawDroid home directory (default: ~/.clawdroid)") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" clawdroid migrate Detect and migrate from OpenClaw") - fmt.Println(" clawdroid migrate --dry-run Show what would be migrated") - fmt.Println(" clawdroid migrate --refresh Re-sync workspace files") - fmt.Println(" clawdroid migrate --force Migrate without confirmation") -} - func agentCmd() { message := "" sessionKey := "cli:default" @@ -597,8 +519,8 @@ func gatewayCmd() { agentLoop.SetChannelManager(channelManager) var transcriber *voice.GroqTranscriber - if cfg.Providers.Groq.APIKey != "" { - transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) + if cfg.STT.APIKey != "" { + transcriber = voice.NewGroqTranscriber(cfg.STT.APIKey) logger.InfoC("voice", "Groq voice transcription enabled") } @@ -718,265 +640,17 @@ func statusCmd() { } if _, err := os.Stat(configPath); err == nil { - fmt.Printf("Model: %s\n", cfg.Agents.Defaults.Model) - - hasOpenRouter := cfg.Providers.OpenRouter.APIKey != "" - hasAnthropic := cfg.Providers.Anthropic.APIKey != "" - hasOpenAI := cfg.Providers.OpenAI.APIKey != "" - hasGemini := cfg.Providers.Gemini.APIKey != "" - hasZhipu := cfg.Providers.Zhipu.APIKey != "" - hasGroq := cfg.Providers.Groq.APIKey != "" - hasVLLM := cfg.Providers.VLLM.APIBase != "" - - status := func(enabled bool) string { - if enabled { - return "✓" - } - return "not set" - } - fmt.Println("OpenRouter API:", status(hasOpenRouter)) - fmt.Println("Anthropic API:", status(hasAnthropic)) - fmt.Println("OpenAI API:", status(hasOpenAI)) - fmt.Println("Gemini API:", status(hasGemini)) - fmt.Println("Zhipu API:", status(hasZhipu)) - fmt.Println("Groq API:", status(hasGroq)) - if hasVLLM { - fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase) + fmt.Printf("Model: %s\n", cfg.LLM.Model) + if cfg.LLM.APIKey != "" { + fmt.Println("API Key: ✓") } else { - fmt.Println("vLLM/Local: not set") + fmt.Println("API Key: not set") } - - store, _ := auth.LoadStore() - if store != nil && len(store.Credentials) > 0 { - fmt.Println("\nOAuth/Token Auth:") - for provider, cred := range store.Credentials { - status := "authenticated" - if cred.IsExpired() { - status = "expired" - } else if cred.NeedsRefresh() { - status = "needs refresh" - } - fmt.Printf(" %s (%s): %s\n", provider, cred.AuthMethod, status) - } + if cfg.LLM.BaseURL != "" { + fmt.Printf("Base URL: %s\n", cfg.LLM.BaseURL) } - } -} - -func authCmd() { - if len(os.Args) < 3 { - authHelp() - return - } - - switch os.Args[2] { - case "login": - authLoginCmd() - case "logout": - authLogoutCmd() - case "status": - authStatusCmd() - default: - fmt.Printf("Unknown auth command: %s\n", os.Args[2]) - authHelp() - } -} - -func authHelp() { - fmt.Println("\nAuth commands:") - fmt.Println(" login Login via OAuth or paste token") - fmt.Println(" logout Remove stored credentials") - fmt.Println(" status Show current auth status") - fmt.Println() - fmt.Println("Login options:") - fmt.Println(" --provider Provider to login with (openai, anthropic)") - fmt.Println(" --device-code Use device code flow (for headless environments)") - fmt.Println() - fmt.Println("Examples:") - fmt.Println(" clawdroid auth login --provider openai") - fmt.Println(" clawdroid auth login --provider openai --device-code") - fmt.Println(" clawdroid auth login --provider anthropic") - fmt.Println(" clawdroid auth logout --provider openai") - fmt.Println(" clawdroid auth status") -} - -func authLoginCmd() { - provider := "" - useDeviceCode := false - - args := os.Args[3:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--provider", "-p": - if i+1 < len(args) { - provider = args[i+1] - i++ - } - case "--device-code": - useDeviceCode = true - } - } - - if provider == "" { - fmt.Println("Error: --provider is required") - fmt.Println("Supported providers: openai, anthropic") - return - } - - switch provider { - case "openai": - authLoginOpenAI(useDeviceCode) - case "anthropic": - authLoginPasteToken(provider) - default: - fmt.Printf("Unsupported provider: %s\n", provider) - fmt.Println("Supported providers: openai, anthropic") - } -} - -func authLoginOpenAI(useDeviceCode bool) { - cfg := auth.OpenAIOAuthConfig() - - var cred *auth.AuthCredential - var err error - - if useDeviceCode { - cred, err = auth.LoginDeviceCode(cfg) - } else { - cred, err = auth.LoginBrowser(cfg) - } - - if err != nil { - fmt.Printf("Login failed: %v\n", err) - os.Exit(1) - } - - if err := auth.SetCredential("openai", cred); err != nil { - fmt.Printf("Failed to save credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - appCfg.Providers.OpenAI.AuthMethod = "oauth" - if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { - fmt.Printf("Warning: could not update config: %v\n", err) - } - } - - fmt.Println("Login successful!") - if cred.AccountID != "" { - fmt.Printf("Account: %s\n", cred.AccountID) - } -} - -func authLoginPasteToken(provider string) { - cred, err := auth.LoginPasteToken(provider, os.Stdin) - if err != nil { - fmt.Printf("Login failed: %v\n", err) - os.Exit(1) - } - - if err := auth.SetCredential(provider, cred); err != nil { - fmt.Printf("Failed to save credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - switch provider { - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "token" - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "token" - } - if err := config.SaveConfig(getConfigPath(), appCfg); err != nil { - fmt.Printf("Warning: could not update config: %v\n", err) - } - } - - fmt.Printf("Token saved for %s!\n", provider) -} - -func authLogoutCmd() { - provider := "" - - args := os.Args[3:] - for i := 0; i < len(args); i++ { - switch args[i] { - case "--provider", "-p": - if i+1 < len(args) { - provider = args[i+1] - i++ - } - } - } - - if provider != "" { - if err := auth.DeleteCredential(provider); err != nil { - fmt.Printf("Failed to remove credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - switch provider { - case "openai": - appCfg.Providers.OpenAI.AuthMethod = "" - case "anthropic": - appCfg.Providers.Anthropic.AuthMethod = "" - } - config.SaveConfig(getConfigPath(), appCfg) - } - - fmt.Printf("Logged out from %s\n", provider) - } else { - if err := auth.DeleteAllCredentials(); err != nil { - fmt.Printf("Failed to remove credentials: %v\n", err) - os.Exit(1) - } - - appCfg, err := loadConfig() - if err == nil { - appCfg.Providers.OpenAI.AuthMethod = "" - appCfg.Providers.Anthropic.AuthMethod = "" - config.SaveConfig(getConfigPath(), appCfg) - } - - fmt.Println("Logged out from all providers") - } -} - -func authStatusCmd() { - store, err := auth.LoadStore() - if err != nil { - fmt.Printf("Error loading auth store: %v\n", err) - return - } - - if len(store.Credentials) == 0 { - fmt.Println("No authenticated providers.") - fmt.Println("Run: clawdroid auth login --provider ") - return - } - - fmt.Println("\nAuthenticated Providers:") - fmt.Println("------------------------") - for provider, cred := range store.Credentials { - status := "active" - if cred.IsExpired() { - status = "expired" - } else if cred.NeedsRefresh() { - status = "needs refresh" - } - - fmt.Printf(" %s:\n", provider) - fmt.Printf(" Method: %s\n", cred.AuthMethod) - fmt.Printf(" Status: %s\n", status) - if cred.AccountID != "" { - fmt.Printf(" Account: %s\n", cred.AccountID) - } - if !cred.ExpiresAt.IsZero() { - fmt.Printf(" Expires: %s\n", cred.ExpiresAt.Format("2006-01-02 15:04")) + if cfg.STT.APIKey != "" { + fmt.Println("STT API Key: ✓") } } } diff --git a/config/config.example.json b/config/config.example.json index f938a138f..765dfbfc9 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -1,10 +1,17 @@ { + "llm": { + "model": "zhipu/glm-4.7", + "api_key": "", + "base_url": "" + }, + "stt": { + "api_key": "" + }, "agents": { "defaults": { "workspace": "~/.clawdroid/workspace", "data_dir": "~/.clawdroid/data", "restrict_to_workspace": true, - "model": "glm-4.7", "max_tokens": 8192, "context_window": 128000, "temperature": 0.7, @@ -85,62 +92,6 @@ "allow_from": [] } }, - "providers": { - "anthropic": { - "api_key": "", - "api_base": "" - }, - "openai": { - "api_key": "", - "api_base": "" - }, - "openrouter": { - "api_key": "sk-or-v1-xxx", - "api_base": "" - }, - "groq": { - "api_key": "gsk_xxx", - "api_base": "" - }, - "zhipu": { - "api_key": "YOUR_ZHIPU_API_KEY", - "api_base": "" - }, - "gemini": { - "api_key": "", - "api_base": "" - }, - "vllm": { - "api_key": "", - "api_base": "" - }, - "nvidia": { - "api_key": "nvapi-xxx", - "api_base": "", - "proxy": "http://127.0.0.1:7890" - }, - "moonshot": { - "api_key": "sk-xxx", - "api_base": "" - }, - "ollama": { - "api_key": "", - "api_base": "http://localhost:11434/v1" - }, - "deepseek": { - "api_key": "", - "api_base": "" - }, - "shengsuanyun": { - "api_key": "", - "api_base": "" - }, - "github_copilot": { - "api_key": "", - "api_base": "", - "connect_mode": "stdio" - } - }, "tools": { "exec": { "enabled": false diff --git a/go.mod b/go.mod index bc2e8a240..a4ec08f1e 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.25.6 require ( github.com/adhocore/gronx v1.19.6 - github.com/anthropics/anthropic-sdk-go v1.22.1 github.com/bwmarrin/discordgo v0.29.0 github.com/caarlos0/env/v11 v11.3.1 github.com/chzyer/readline v1.5.1 @@ -12,9 +11,9 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/modelcontextprotocol/go-sdk v1.3.1 + github.com/mozilla-ai/any-llm-go v0.8.0 github.com/mymmrac/telego v1.6.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 - github.com/openai/openai-go/v3 v3.22.0 github.com/slack-go/slack v0.17.3 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 @@ -31,18 +30,32 @@ require ( ) require ( + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go/compute/metadata v0.5.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect + github.com/anthropics/anthropic-sdk-go v1.21.0 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/github/copilot-sdk/go v0.1.23 github.com/go-resty/resty/v2 v2.17.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect + github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/grbit/go-json v0.11.0 // indirect github.com/klauspost/compress v1.18.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/ollama/ollama v0.15.4 // indirect + github.com/openai/openai-go v1.12.0 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/tidwall/gjson v1.18.0 // indirect github.com/tidwall/match v1.2.0 // indirect github.com/tidwall/pretty v1.2.1 // indirect @@ -51,9 +64,16 @@ require ( github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.69.0 // indirect github.com/valyala/fastjson v1.6.7 // indirect + github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect + go.opencensus.io v0.24.0 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.50.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genai v1.45.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.66.2 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index 4658d7bdf..70562477c 100644 --- a/go.sum +++ b/go.sum @@ -1,10 +1,22 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= +cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= +cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/anthropics/anthropic-sdk-go v1.22.1 h1:xbsc3vJKCX/ELDZSpTNfz9wCgrFsamwFewPb1iI0Xh0= -github.com/anthropics/anthropic-sdk-go v1.22.1/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.21.0 h1:sn2iMiUODSMtJTN5nGMOn+ayEpNMuL5khElzltSrEcE= +github.com/anthropics/anthropic-sdk-go v1.21.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= +github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= @@ -15,6 +27,7 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM= @@ -23,17 +36,21 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/github/copilot-sdk/go v0.1.23 h1:uExtO/inZQndCZMiSAA1hvXINiz9tqo/MZgQzFzurxw= -github.com/github/copilot-sdk/go v0.1.23/go.mod h1:GdwwBfMbm9AABLEM3x5IZKw4ZfwCYxZ1BgyytmZenQ0= github.com/go-redis/redis/v8 v8.11.4/go.mod h1:2Z2wHZXdQpCDXEGzqMockDpNyYvi2l4Pxt6RJr792+w= github.com/go-resty/resty/v2 v2.6.0/go.mod h1:PwvJS6hvaPkjtjNg9ph+VrSD92bi5Zq73w/BIH7cC3Q= github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= @@ -45,18 +62,29 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -64,9 +92,14 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -74,6 +107,7 @@ github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/ad github.com/grbit/go-json v0.11.0 h1:bAbyMdYrYl/OjYsSqLH99N2DyQ291mHy726Mx+sYrnc= github.com/grbit/go-json v0.11.0/go.mod h1:IYpHsdybQ386+6g3VE6AXQ3uTGa5mquBme5/ZWmtzek= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= @@ -82,20 +116,27 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= +github.com/mozilla-ai/any-llm-go v0.8.0 h1:QNM2yeMaFp3TnIX7+pJ1oxakfA2bbQtyH7pchQfSe+E= +github.com/mozilla-ai/any-llm-go v0.8.0/go.mod h1:hfidShiFrygKCzyMTMJWAUv6S5q7ZP/1qWK3Azc6RLU= github.com/mymmrac/telego v1.6.0 h1:Zc8rgyHozvd/7ZgyrigyHdAF9koHYMfilYfyB6wlFC0= github.com/mymmrac/telego v1.6.0/go.mod h1:xt6ZWA8zi8KmuzryE1ImEdl9JSwjHNpM4yhC7D8hU4Y= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/ollama/ollama v0.15.4 h1:y841GH5lsi5j5BTFyX/E+UOC3Yiw+JBfdjBVRGw+I0M= +github.com/ollama/ollama v0.15.4/go.mod h1:4Yn3jw2hZ4VqyJ1XciYawDRE8bzv4RT3JiVZR1kCfwE= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= @@ -104,14 +145,16 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y github.com/onsi/gomega v1.16.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 h1:Lb/Uzkiw2Ugt2Xf03J5wmv81PdkYOiWbI8CNBi1boC8= github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1/go.mod h1:ln3IqPYYocZbYvl9TAOrG/cxGR9xcn4pnZRLdCTEGEU= -github.com/openai/openai-go/v3 v3.22.0 h1:6MEoNoV8sbjOVmXdvhmuX3BjVbVdcExbVyGixiyJ8ys= -github.com/openai/openai-go/v3 v3.22.0/go.mod h1:cdufnVK14cWcT9qA1rRtrXx4FTRsgbDPW7Ia7SS5cZo= +github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= +github.com/openai/openai-go v1.12.0/go.mod h1:g461MYGXEXBVdV5SaR/5tNzNbSfwTBBefwc+LlDCK0Y= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= @@ -152,6 +195,8 @@ github.com/valyala/fasthttp v1.69.0 h1:fNLLESD2SooWeh2cidsuFtOcrEi4uB4m1mPrkJMZy github.com/valyala/fasthttp v1.69.0/go.mod h1:4wA4PfAraPlAsJ5jMSqCE2ug5tqUPwKXxVj8oNECGcw= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= +github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -159,6 +204,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= @@ -171,16 +218,25 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= @@ -190,10 +246,12 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -201,6 +259,7 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -227,6 +286,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -234,29 +295,56 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genai v1.45.0 h1:s80ZpS42XW0zu/ogiOtenCio17nJ7reEFJjoCftukpA= +google.golang.org/genai v1.45.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo= +google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -271,3 +359,5 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 42c10228d..e7c8a05b1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -36,7 +36,8 @@ type AgentLoop struct { provider providers.LLMProvider workspace string model string - maxTokens int // Maximum tokens for API response + maxTokens int // Maximum tokens for API response + temperature float64 // Temperature for LLM (0 = not sent) contextWindow int // Maximum context window size in tokens (for summarization) maxIterations int sessions *session.SessionManager @@ -162,7 +163,7 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers toolsRegistry := createToolRegistry(workspace, restrict, cfg, msgBus, dataDir) // Create subagent manager with its own tool registry - subagentManager := tools.NewSubagentManager(provider, cfg.Agents.Defaults.Model, workspace, msgBus) + subagentManager := tools.NewSubagentManager(provider, cfg.LLM.Model, workspace, msgBus) subagentTools := createToolRegistry(workspace, restrict, cfg, msgBus, dataDir) // Subagent doesn't need spawn/subagent tools to avoid recursion subagentManager.SetTools(subagentTools) @@ -236,8 +237,9 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers bus: msgBus, provider: provider, workspace: workspace, - model: cfg.Agents.Defaults.Model, + model: cfg.LLM.Model, maxTokens: cfg.Agents.Defaults.MaxTokens, + temperature: cfg.Agents.Defaults.Temperature, contextWindow: cfg.Agents.Defaults.ContextWindow, maxIterations: cfg.Agents.Defaults.MaxToolIterations, sessions: sessionsManager, @@ -709,7 +711,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M "messages_count": len(messages), "tools_count": len(providerToolDefs), "max_tokens": al.maxTokens, - "temperature": 0.7, + "temperature": al.temperature, "system_prompt_len": len(messages[0].Content), }) @@ -727,10 +729,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M // Retry loop for context/token errors maxRetries := 2 for retry := 0; retry <= maxRetries; retry++ { - response, err = al.provider.Chat(ctx, messages, providerToolDefs, al.model, map[string]interface{}{ - "max_tokens": al.maxTokens, - "temperature": 0.7, - }) + llmOpts := map[string]interface{}{ + "max_tokens": al.maxTokens, + } + if al.temperature > 0 { + llmOpts["temperature"] = al.temperature + } + response, err = al.provider.Chat(ctx, messages, providerToolDefs, al.model, llmOpts) if err == nil { break // Success @@ -1290,8 +1295,7 @@ func (al *AgentLoop) summarizeSession(sessionKey string) { // Merge them mergePrompt := fmt.Sprintf("Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2) resp, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: mergePrompt}}, nil, al.model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, + "max_tokens": 1024, }) if err == nil { finalSummary = resp.Content @@ -1327,8 +1331,7 @@ func (al *AgentLoop) summarizeBatch(ctx context.Context, batch []providers.Messa } response, err := al.provider.Chat(ctx, []providers.Message{{Role: "user", Content: prompt}}, nil, al.model, map[string]interface{}{ - "max_tokens": 1024, - "temperature": 0.3, + "max_tokens": 1024, }) if err != nil { return "", err diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index 2c1947a7c..dbe862929 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -39,11 +39,13 @@ func TestRecordLastChannel(t *testing.T) { // Create test config cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -86,11 +88,13 @@ func TestRecordLastChatID(t *testing.T) { // Create test config cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -133,11 +137,13 @@ func TestNewAgentLoop_StateInitialized(t *testing.T) { // Create test config cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -171,11 +177,13 @@ func TestToolRegistry_ToolRegistration(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -219,11 +227,13 @@ func TestToolContext_Updates(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -252,11 +262,13 @@ func TestToolRegistry_GetDefinitions(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -298,11 +310,13 @@ func TestAgentLoop_GetStartupInfo(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -347,11 +361,13 @@ func TestCreateToolRegistry_ExecDisabled(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -388,11 +404,13 @@ func TestCreateToolRegistry_ExecEnabled(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -434,11 +452,13 @@ func TestCreateToolRegistry_I2CDisabled(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -473,11 +493,13 @@ func TestCreateToolRegistry_I2CEnabled(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -517,11 +539,13 @@ func TestCreateToolRegistry_SPIDisabled(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -556,11 +580,13 @@ func TestCreateToolRegistry_SPIEnabled(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -600,11 +626,13 @@ func TestAgentLoop_Stop(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -724,11 +752,13 @@ func TestToolResult_SilentToolDoesNotSendUserMessage(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -768,11 +798,13 @@ func TestToolResult_UserFacingToolDoesSendMessage(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -835,11 +867,13 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -932,11 +966,13 @@ func TestRetryLoop_CancelledContextSkipsCompression(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -995,11 +1031,13 @@ func TestForceCompression_ToolGroupBoundary(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -1082,11 +1120,13 @@ func TestForceCompression_MidOnAssistantWithToolCalls(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, @@ -1170,11 +1210,13 @@ func TestForceCompression_NoteUsesUserRole(t *testing.T) { defer os.RemoveAll(tmpDir) cfg := &config.Config{ + LLM: config.LLMConfig{ + Model: "test-model", + }, Agents: config.AgentsConfig{ Defaults: config.AgentDefaults{ Workspace: tmpDir, DataDir: tmpDir, - Model: "test-model", MaxTokens: 4096, ContextWindow: 128000, MaxToolIterations: 10, diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go deleted file mode 100644 index f43032b3a..000000000 --- a/pkg/auth/oauth.go +++ /dev/null @@ -1,461 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os/exec" - "runtime" - "strconv" - "strings" - "time" -) - -type OAuthProviderConfig struct { - Issuer string - ClientID string - Scopes string - Originator string - Port int -} - -func OpenAIOAuthConfig() OAuthProviderConfig { - return OAuthProviderConfig{ - Issuer: "https://auth.openai.com", - ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", - Scopes: "openid profile email offline_access", - Originator: "codex_cli_rs", - Port: 1455, - } -} - -func generateState() (string, error) { - buf := make([]byte, 32) - if _, err := rand.Read(buf); err != nil { - return "", err - } - return hex.EncodeToString(buf), nil -} - -func LoginBrowser(cfg OAuthProviderConfig) (*AuthCredential, error) { - pkce, err := GeneratePKCE() - if err != nil { - return nil, fmt.Errorf("generating PKCE: %w", err) - } - - state, err := generateState() - if err != nil { - return nil, fmt.Errorf("generating state: %w", err) - } - - redirectURI := fmt.Sprintf("http://localhost:%d/auth/callback", cfg.Port) - - authURL := buildAuthorizeURL(cfg, pkce, state, redirectURI) - - resultCh := make(chan callbackResult, 1) - - mux := http.NewServeMux() - mux.HandleFunc("/auth/callback", func(w http.ResponseWriter, r *http.Request) { - if r.URL.Query().Get("state") != state { - resultCh <- callbackResult{err: fmt.Errorf("state mismatch")} - http.Error(w, "State mismatch", http.StatusBadRequest) - return - } - - code := r.URL.Query().Get("code") - if code == "" { - errMsg := r.URL.Query().Get("error") - resultCh <- callbackResult{err: fmt.Errorf("no code received: %s", errMsg)} - http.Error(w, "No authorization code received", http.StatusBadRequest) - return - } - - w.Header().Set("Content-Type", "text/html") - fmt.Fprint(w, "

Authentication successful!

You can close this window.

") - resultCh <- callbackResult{code: code} - }) - - listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cfg.Port)) - if err != nil { - return nil, fmt.Errorf("starting callback server on port %d: %w", cfg.Port, err) - } - - server := &http.Server{Handler: mux} - go server.Serve(listener) - defer func() { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - server.Shutdown(ctx) - }() - - fmt.Printf("Open this URL to authenticate:\n\n%s\n\n", authURL) - - if err := openBrowser(authURL); err != nil { - fmt.Printf("Could not open browser automatically.\nPlease open this URL manually:\n\n%s\n\n", authURL) - } - - fmt.Println("If you're running in a headless environment, use: clawdroid auth login --provider openai --device-code") - fmt.Println("Waiting for authentication in browser...") - - select { - case result := <-resultCh: - if result.err != nil { - return nil, result.err - } - return exchangeCodeForTokens(cfg, result.code, pkce.CodeVerifier, redirectURI) - case <-time.After(5 * time.Minute): - return nil, fmt.Errorf("authentication timed out after 5 minutes") - } -} - -type callbackResult struct { - code string - err error -} - -type deviceCodeResponse struct { - DeviceAuthID string - UserCode string - Interval int -} - -func parseDeviceCodeResponse(body []byte) (deviceCodeResponse, error) { - var raw struct { - DeviceAuthID string `json:"device_auth_id"` - UserCode string `json:"user_code"` - Interval json.RawMessage `json:"interval"` - } - - if err := json.Unmarshal(body, &raw); err != nil { - return deviceCodeResponse{}, err - } - - interval, err := parseFlexibleInt(raw.Interval) - if err != nil { - return deviceCodeResponse{}, err - } - - return deviceCodeResponse{ - DeviceAuthID: raw.DeviceAuthID, - UserCode: raw.UserCode, - Interval: interval, - }, nil -} - -func parseFlexibleInt(raw json.RawMessage) (int, error) { - if len(raw) == 0 || string(raw) == "null" { - return 0, nil - } - - var interval int - if err := json.Unmarshal(raw, &interval); err == nil { - return interval, nil - } - - var intervalStr string - if err := json.Unmarshal(raw, &intervalStr); err == nil { - intervalStr = strings.TrimSpace(intervalStr) - if intervalStr == "" { - return 0, nil - } - return strconv.Atoi(intervalStr) - } - - return 0, fmt.Errorf("invalid integer value: %s", string(raw)) -} - -func LoginDeviceCode(cfg OAuthProviderConfig) (*AuthCredential, error) { - reqBody, _ := json.Marshal(map[string]string{ - "client_id": cfg.ClientID, - }) - - resp, err := http.Post( - cfg.Issuer+"/api/accounts/deviceauth/usercode", - "application/json", - strings.NewReader(string(reqBody)), - ) - if err != nil { - return nil, fmt.Errorf("requesting device code: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("device code request failed: %s", string(body)) - } - - deviceResp, err := parseDeviceCodeResponse(body) - if err != nil { - return nil, fmt.Errorf("parsing device code response: %w", err) - } - - if deviceResp.Interval < 1 { - deviceResp.Interval = 5 - } - - fmt.Printf("\nTo authenticate, open this URL in your browser:\n\n %s/codex/device\n\nThen enter this code: %s\n\nWaiting for authentication...\n", - cfg.Issuer, deviceResp.UserCode) - - deadline := time.After(15 * time.Minute) - ticker := time.NewTicker(time.Duration(deviceResp.Interval) * time.Second) - defer ticker.Stop() - - for { - select { - case <-deadline: - return nil, fmt.Errorf("device code authentication timed out after 15 minutes") - case <-ticker.C: - cred, err := pollDeviceCode(cfg, deviceResp.DeviceAuthID, deviceResp.UserCode) - if err != nil { - continue - } - if cred != nil { - return cred, nil - } - } - } -} - -func pollDeviceCode(cfg OAuthProviderConfig, deviceAuthID, userCode string) (*AuthCredential, error) { - reqBody, _ := json.Marshal(map[string]string{ - "device_auth_id": deviceAuthID, - "user_code": userCode, - }) - - resp, err := http.Post( - cfg.Issuer+"/api/accounts/deviceauth/token", - "application/json", - strings.NewReader(string(reqBody)), - ) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("pending") - } - - body, _ := io.ReadAll(resp.Body) - - var tokenResp struct { - AuthorizationCode string `json:"authorization_code"` - CodeChallenge string `json:"code_challenge"` - CodeVerifier string `json:"code_verifier"` - } - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, err - } - - redirectURI := cfg.Issuer + "/deviceauth/callback" - return exchangeCodeForTokens(cfg, tokenResp.AuthorizationCode, tokenResp.CodeVerifier, redirectURI) -} - -func RefreshAccessToken(cred *AuthCredential, cfg OAuthProviderConfig) (*AuthCredential, error) { - if cred.RefreshToken == "" { - return nil, fmt.Errorf("no refresh token available") - } - - data := url.Values{ - "client_id": {cfg.ClientID}, - "grant_type": {"refresh_token"}, - "refresh_token": {cred.RefreshToken}, - "scope": {"openid profile email"}, - } - - resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data) - if err != nil { - return nil, fmt.Errorf("refreshing token: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("token refresh failed: %s", string(body)) - } - - refreshed, err := parseTokenResponse(body, cred.Provider) - if err != nil { - return nil, err - } - if refreshed.RefreshToken == "" { - refreshed.RefreshToken = cred.RefreshToken - } - if refreshed.AccountID == "" { - refreshed.AccountID = cred.AccountID - } - return refreshed, nil -} - -func BuildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { - return buildAuthorizeURL(cfg, pkce, state, redirectURI) -} - -func buildAuthorizeURL(cfg OAuthProviderConfig, pkce PKCECodes, state, redirectURI string) string { - params := url.Values{ - "response_type": {"code"}, - "client_id": {cfg.ClientID}, - "redirect_uri": {redirectURI}, - "scope": {cfg.Scopes}, - "code_challenge": {pkce.CodeChallenge}, - "code_challenge_method": {"S256"}, - "id_token_add_organizations": {"true"}, - "codex_cli_simplified_flow": {"true"}, - "state": {state}, - } - if strings.Contains(strings.ToLower(cfg.Issuer), "auth.openai.com") { - params.Set("originator", "clawdroid") - } - if cfg.Originator != "" { - params.Set("originator", cfg.Originator) - } - return cfg.Issuer + "/oauth/authorize?" + params.Encode() -} - -func exchangeCodeForTokens(cfg OAuthProviderConfig, code, codeVerifier, redirectURI string) (*AuthCredential, error) { - data := url.Values{ - "grant_type": {"authorization_code"}, - "code": {code}, - "redirect_uri": {redirectURI}, - "client_id": {cfg.ClientID}, - "code_verifier": {codeVerifier}, - } - - resp, err := http.PostForm(cfg.Issuer+"/oauth/token", data) - if err != nil { - return nil, fmt.Errorf("exchanging code for tokens: %w", err) - } - defer resp.Body.Close() - - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("token exchange failed: %s", string(body)) - } - - return parseTokenResponse(body, "openai") -} - -func parseTokenResponse(body []byte, provider string) (*AuthCredential, error) { - var tokenResp struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` - IDToken string `json:"id_token"` - } - if err := json.Unmarshal(body, &tokenResp); err != nil { - return nil, fmt.Errorf("parsing token response: %w", err) - } - - if tokenResp.AccessToken == "" { - return nil, fmt.Errorf("no access token in response") - } - - var expiresAt time.Time - if tokenResp.ExpiresIn > 0 { - expiresAt = time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second) - } - - cred := &AuthCredential{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - ExpiresAt: expiresAt, - Provider: provider, - AuthMethod: "oauth", - } - - if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { - cred.AccountID = accountID - } else if accountID := extractAccountID(tokenResp.AccessToken); accountID != "" { - cred.AccountID = accountID - } else if accountID := extractAccountID(tokenResp.IDToken); accountID != "" { - // Recent OpenAI OAuth responses may only include chatgpt_account_id in id_token claims. - cred.AccountID = accountID - } - - return cred, nil -} - -func extractAccountID(token string) string { - claims, err := parseJWTClaims(token) - if err != nil { - return "" - } - - if accountID, ok := claims["chatgpt_account_id"].(string); ok && accountID != "" { - return accountID - } - - if accountID, ok := claims["https://api.openai.com/auth.chatgpt_account_id"].(string); ok && accountID != "" { - return accountID - } - - if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok { - if accountID, ok := authClaim["chatgpt_account_id"].(string); ok && accountID != "" { - return accountID - } - } - - if orgs, ok := claims["organizations"].([]interface{}); ok { - for _, org := range orgs { - if orgMap, ok := org.(map[string]interface{}); ok { - if accountID, ok := orgMap["id"].(string); ok && accountID != "" { - return accountID - } - } - } - } - - return "" -} - -func parseJWTClaims(token string) (map[string]interface{}, error) { - parts := strings.Split(token, ".") - if len(parts) < 2 { - return nil, fmt.Errorf("token is not a JWT") - } - - payload := parts[1] - switch len(payload) % 4 { - case 2: - payload += "==" - case 3: - payload += "=" - } - - decoded, err := base64URLDecode(payload) - if err != nil { - return nil, err - } - - var claims map[string]interface{} - if err := json.Unmarshal(decoded, &claims); err != nil { - return nil, err - } - - return claims, nil -} - -func base64URLDecode(s string) ([]byte, error) { - s = strings.NewReplacer("-", "+", "_", "/").Replace(s) - return base64.StdEncoding.DecodeString(s) -} - -func openBrowser(url string) error { - switch runtime.GOOS { - case "darwin": - return exec.Command("open", url).Start() - case "linux": - return exec.Command("xdg-open", url).Start() - case "windows": - return exec.Command("cmd", "/c", "start", url).Start() - default: - return fmt.Errorf("unsupported platform: %s", runtime.GOOS) - } -} diff --git a/pkg/auth/oauth_test.go b/pkg/auth/oauth_test.go deleted file mode 100644 index 5deb17805..000000000 --- a/pkg/auth/oauth_test.go +++ /dev/null @@ -1,373 +0,0 @@ -package auth - -import ( - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" -) - -func makeJWTForClaims(t *testing.T, claims map[string]interface{}) string { - t.Helper() - - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) - payloadJSON, err := json.Marshal(claims) - if err != nil { - t.Fatalf("marshal claims: %v", err) - } - payload := base64.RawURLEncoding.EncodeToString(payloadJSON) - return header + "." + payload + ".sig" -} - -func TestBuildAuthorizeURL(t *testing.T) { - cfg := OAuthProviderConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client-id", - Scopes: "openid profile", - Originator: "codex_cli_rs", - Port: 1455, - } - pkce := PKCECodes{ - CodeVerifier: "test-verifier", - CodeChallenge: "test-challenge", - } - - u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback") - - if !strings.HasPrefix(u, "https://auth.example.com/oauth/authorize?") { - t.Errorf("URL does not start with expected prefix: %s", u) - } - if !strings.Contains(u, "client_id=test-client-id") { - t.Error("URL missing client_id") - } - if !strings.Contains(u, "code_challenge=test-challenge") { - t.Error("URL missing code_challenge") - } - if !strings.Contains(u, "code_challenge_method=S256") { - t.Error("URL missing code_challenge_method") - } - if !strings.Contains(u, "state=test-state") { - t.Error("URL missing state") - } - if !strings.Contains(u, "response_type=code") { - t.Error("URL missing response_type") - } - if !strings.Contains(u, "id_token_add_organizations=true") { - t.Error("URL missing id_token_add_organizations") - } - if !strings.Contains(u, "codex_cli_simplified_flow=true") { - t.Error("URL missing codex_cli_simplified_flow") - } - if !strings.Contains(u, "originator=codex_cli_rs") { - t.Error("URL missing originator") - } -} - -func TestBuildAuthorizeURLOpenAIExtras(t *testing.T) { - cfg := OpenAIOAuthConfig() - pkce := PKCECodes{CodeVerifier: "test-verifier", CodeChallenge: "test-challenge"} - - u := BuildAuthorizeURL(cfg, pkce, "test-state", "http://localhost:1455/auth/callback") - parsed, err := url.Parse(u) - if err != nil { - t.Fatalf("url.Parse() error: %v", err) - } - q := parsed.Query() - - if q.Get("id_token_add_organizations") != "true" { - t.Errorf("id_token_add_organizations = %q, want true", q.Get("id_token_add_organizations")) - } - if q.Get("codex_cli_simplified_flow") != "true" { - t.Errorf("codex_cli_simplified_flow = %q, want true", q.Get("codex_cli_simplified_flow")) - } - if q.Get("originator") != "codex_cli_rs" { - t.Errorf("originator = %q, want codex_cli_rs", q.Get("originator")) - } -} - -func TestParseTokenResponse(t *testing.T) { - resp := map[string]interface{}{ - "access_token": "test-access-token", - "refresh_token": "test-refresh-token", - "expires_in": 3600, - "id_token": "test-id-token", - } - body, _ := json.Marshal(resp) - - cred, err := parseTokenResponse(body, "openai") - if err != nil { - t.Fatalf("parseTokenResponse() error: %v", err) - } - - if cred.AccessToken != "test-access-token" { - t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "test-access-token") - } - if cred.RefreshToken != "test-refresh-token" { - t.Errorf("RefreshToken = %q, want %q", cred.RefreshToken, "test-refresh-token") - } - if cred.Provider != "openai" { - t.Errorf("Provider = %q, want %q", cred.Provider, "openai") - } - if cred.AuthMethod != "oauth" { - t.Errorf("AuthMethod = %q, want %q", cred.AuthMethod, "oauth") - } - if cred.ExpiresAt.IsZero() { - t.Error("ExpiresAt should not be zero") - } -} - -func TestParseTokenResponseExtractsAccountIDFromIDToken(t *testing.T) { - idToken := makeJWTForClaims(t, map[string]interface{}{"chatgpt_account_id": "acc-id-from-id-token"}) - resp := map[string]interface{}{ - "access_token": "opaque-access-token", - "refresh_token": "test-refresh-token", - "expires_in": 3600, - "id_token": idToken, - } - body, _ := json.Marshal(resp) - - cred, err := parseTokenResponse(body, "openai") - if err != nil { - t.Fatalf("parseTokenResponse() error: %v", err) - } - if cred.AccountID != "acc-id-from-id-token" { - t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-id-from-id-token") - } -} - -func TestExtractAccountIDFromOrganizationsFallback(t *testing.T) { - token := makeJWTForClaims(t, map[string]interface{}{ - "organizations": []interface{}{ - map[string]interface{}{"id": "org_from_orgs"}, - }, - }) - - if got := extractAccountID(token); got != "org_from_orgs" { - t.Errorf("extractAccountID() = %q, want %q", got, "org_from_orgs") - } -} - -func TestParseTokenResponseNoAccessToken(t *testing.T) { - body := []byte(`{"refresh_token": "test"}`) - _, err := parseTokenResponse(body, "openai") - if err == nil { - t.Error("expected error for missing access_token") - } -} - -func TestParseTokenResponseAccountIDFromIDToken(t *testing.T) { - idToken := makeJWTWithAccountID("acc-from-id") - resp := map[string]interface{}{ - "access_token": "not-a-jwt", - "refresh_token": "test-refresh-token", - "expires_in": 3600, - "id_token": idToken, - } - body, _ := json.Marshal(resp) - - cred, err := parseTokenResponse(body, "openai") - if err != nil { - t.Fatalf("parseTokenResponse() error: %v", err) - } - - if cred.AccountID != "acc-from-id" { - t.Errorf("AccountID = %q, want %q", cred.AccountID, "acc-from-id") - } -} - -func makeJWTWithAccountID(accountID string) string { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) - payload := base64.RawURLEncoding.EncodeToString([]byte(`{"https://api.openai.com/auth":{"chatgpt_account_id":"` + accountID + `"}}`)) - return header + "." + payload + ".sig" -} - -func TestExchangeCodeForTokens(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/oauth/token" { - http.Error(w, "not found", http.StatusNotFound) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - r.ParseForm() - if r.FormValue("grant_type") != "authorization_code" { - http.Error(w, "invalid grant_type", http.StatusBadRequest) - return - } - - resp := map[string]interface{}{ - "access_token": "mock-access-token", - "refresh_token": "mock-refresh-token", - "expires_in": 3600, - } - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - cfg := OAuthProviderConfig{ - Issuer: server.URL, - ClientID: "test-client", - Scopes: "openid", - Port: 1455, - } - - cred, err := exchangeCodeForTokens(cfg, "test-code", "test-verifier", "http://localhost:1455/auth/callback") - if err != nil { - t.Fatalf("exchangeCodeForTokens() error: %v", err) - } - - if cred.AccessToken != "mock-access-token" { - t.Errorf("AccessToken = %q, want %q", cred.AccessToken, "mock-access-token") - } -} - -func TestRefreshAccessToken(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/oauth/token" { - http.Error(w, "not found", http.StatusNotFound) - return - } - - r.ParseForm() - if r.FormValue("grant_type") != "refresh_token" { - http.Error(w, "invalid grant_type", http.StatusBadRequest) - return - } - - resp := map[string]interface{}{ - "access_token": "refreshed-access-token", - "refresh_token": "refreshed-refresh-token", - "expires_in": 3600, - } - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - cfg := OAuthProviderConfig{ - Issuer: server.URL, - ClientID: "test-client", - } - - cred := &AuthCredential{ - AccessToken: "old-token", - RefreshToken: "old-refresh-token", - Provider: "openai", - AuthMethod: "oauth", - } - - refreshed, err := RefreshAccessToken(cred, cfg) - if err != nil { - t.Fatalf("RefreshAccessToken() error: %v", err) - } - - if refreshed.AccessToken != "refreshed-access-token" { - t.Errorf("AccessToken = %q, want %q", refreshed.AccessToken, "refreshed-access-token") - } - if refreshed.RefreshToken != "refreshed-refresh-token" { - t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "refreshed-refresh-token") - } -} - -func TestRefreshAccessTokenNoRefreshToken(t *testing.T) { - cfg := OpenAIOAuthConfig() - cred := &AuthCredential{ - AccessToken: "old-token", - Provider: "openai", - AuthMethod: "oauth", - } - - _, err := RefreshAccessToken(cred, cfg) - if err == nil { - t.Error("expected error for missing refresh token") - } -} - -func TestRefreshAccessTokenPreservesRefreshAndAccountID(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - resp := map[string]interface{}{ - "access_token": "new-access-token-only", - "expires_in": 3600, - } - json.NewEncoder(w).Encode(resp) - })) - defer server.Close() - - cfg := OAuthProviderConfig{Issuer: server.URL, ClientID: "test-client"} - cred := &AuthCredential{ - AccessToken: "old-access", - RefreshToken: "existing-refresh", - AccountID: "acc_existing", - Provider: "openai", - AuthMethod: "oauth", - } - - refreshed, err := RefreshAccessToken(cred, cfg) - if err != nil { - t.Fatalf("RefreshAccessToken() error: %v", err) - } - if refreshed.RefreshToken != "existing-refresh" { - t.Errorf("RefreshToken = %q, want %q", refreshed.RefreshToken, "existing-refresh") - } - if refreshed.AccountID != "acc_existing" { - t.Errorf("AccountID = %q, want %q", refreshed.AccountID, "acc_existing") - } -} - -func TestOpenAIOAuthConfig(t *testing.T) { - cfg := OpenAIOAuthConfig() - if cfg.Issuer != "https://auth.openai.com" { - t.Errorf("Issuer = %q, want %q", cfg.Issuer, "https://auth.openai.com") - } - if cfg.ClientID == "" { - t.Error("ClientID is empty") - } - if cfg.Port != 1455 { - t.Errorf("Port = %d, want 1455", cfg.Port) - } -} - -func TestParseDeviceCodeResponseIntervalAsNumber(t *testing.T) { - body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":5}`) - - resp, err := parseDeviceCodeResponse(body) - if err != nil { - t.Fatalf("parseDeviceCodeResponse() error: %v", err) - } - - if resp.DeviceAuthID != "abc" { - t.Errorf("DeviceAuthID = %q, want %q", resp.DeviceAuthID, "abc") - } - if resp.UserCode != "DEF-1234" { - t.Errorf("UserCode = %q, want %q", resp.UserCode, "DEF-1234") - } - if resp.Interval != 5 { - t.Errorf("Interval = %d, want %d", resp.Interval, 5) - } -} - -func TestParseDeviceCodeResponseIntervalAsString(t *testing.T) { - body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"5"}`) - - resp, err := parseDeviceCodeResponse(body) - if err != nil { - t.Fatalf("parseDeviceCodeResponse() error: %v", err) - } - - if resp.Interval != 5 { - t.Errorf("Interval = %d, want %d", resp.Interval, 5) - } -} - -func TestParseDeviceCodeResponseInvalidInterval(t *testing.T) { - body := []byte(`{"device_auth_id":"abc","user_code":"DEF-1234","interval":"abc"}`) - - if _, err := parseDeviceCodeResponse(body); err == nil { - t.Fatal("expected error for invalid interval") - } -} diff --git a/pkg/auth/pkce.go b/pkg/auth/pkce.go deleted file mode 100644 index 499daf872..000000000 --- a/pkg/auth/pkce.go +++ /dev/null @@ -1,29 +0,0 @@ -package auth - -import ( - "crypto/rand" - "crypto/sha256" - "encoding/base64" -) - -type PKCECodes struct { - CodeVerifier string - CodeChallenge string -} - -func GeneratePKCE() (PKCECodes, error) { - buf := make([]byte, 64) - if _, err := rand.Read(buf); err != nil { - return PKCECodes{}, err - } - - verifier := base64.RawURLEncoding.EncodeToString(buf) - - hash := sha256.Sum256([]byte(verifier)) - challenge := base64.RawURLEncoding.EncodeToString(hash[:]) - - return PKCECodes{ - CodeVerifier: verifier, - CodeChallenge: challenge, - }, nil -} diff --git a/pkg/auth/pkce_test.go b/pkg/auth/pkce_test.go deleted file mode 100644 index 74ed573f1..000000000 --- a/pkg/auth/pkce_test.go +++ /dev/null @@ -1,51 +0,0 @@ -package auth - -import ( - "crypto/sha256" - "encoding/base64" - "testing" -) - -func TestGeneratePKCE(t *testing.T) { - codes, err := GeneratePKCE() - if err != nil { - t.Fatalf("GeneratePKCE() error: %v", err) - } - - if codes.CodeVerifier == "" { - t.Fatal("CodeVerifier is empty") - } - if codes.CodeChallenge == "" { - t.Fatal("CodeChallenge is empty") - } - - verifierBytes, err := base64.RawURLEncoding.DecodeString(codes.CodeVerifier) - if err != nil { - t.Fatalf("CodeVerifier is not valid base64url: %v", err) - } - if len(verifierBytes) != 64 { - t.Errorf("CodeVerifier decoded length = %d, want 64", len(verifierBytes)) - } - - hash := sha256.Sum256([]byte(codes.CodeVerifier)) - expectedChallenge := base64.RawURLEncoding.EncodeToString(hash[:]) - if codes.CodeChallenge != expectedChallenge { - t.Errorf("CodeChallenge = %q, want SHA256 of verifier = %q", codes.CodeChallenge, expectedChallenge) - } -} - -func TestGeneratePKCEUniqueness(t *testing.T) { - codes1, err := GeneratePKCE() - if err != nil { - t.Fatalf("GeneratePKCE() error: %v", err) - } - - codes2, err := GeneratePKCE() - if err != nil { - t.Fatalf("GeneratePKCE() error: %v", err) - } - - if codes1.CodeVerifier == codes2.CodeVerifier { - t.Error("two GeneratePKCE() calls produced identical verifiers") - } -} diff --git a/pkg/auth/store.go b/pkg/auth/store.go deleted file mode 100644 index 64fe92f5b..000000000 --- a/pkg/auth/store.go +++ /dev/null @@ -1,112 +0,0 @@ -package auth - -import ( - "encoding/json" - "os" - "path/filepath" - "time" -) - -type AuthCredential struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token,omitempty"` - AccountID string `json:"account_id,omitempty"` - ExpiresAt time.Time `json:"expires_at,omitempty"` - Provider string `json:"provider"` - AuthMethod string `json:"auth_method"` -} - -type AuthStore struct { - Credentials map[string]*AuthCredential `json:"credentials"` -} - -func (c *AuthCredential) IsExpired() bool { - if c.ExpiresAt.IsZero() { - return false - } - return time.Now().After(c.ExpiresAt) -} - -func (c *AuthCredential) NeedsRefresh() bool { - if c.ExpiresAt.IsZero() { - return false - } - return time.Now().Add(5 * time.Minute).After(c.ExpiresAt) -} - -func authFilePath() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".clawdroid", "auth.json") -} - -func LoadStore() (*AuthStore, error) { - path := authFilePath() - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return &AuthStore{Credentials: make(map[string]*AuthCredential)}, nil - } - return nil, err - } - - var store AuthStore - if err := json.Unmarshal(data, &store); err != nil { - return nil, err - } - if store.Credentials == nil { - store.Credentials = make(map[string]*AuthCredential) - } - return &store, nil -} - -func SaveStore(store *AuthStore) error { - path := authFilePath() - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - - data, err := json.MarshalIndent(store, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0600) -} - -func GetCredential(provider string) (*AuthCredential, error) { - store, err := LoadStore() - if err != nil { - return nil, err - } - cred, ok := store.Credentials[provider] - if !ok { - return nil, nil - } - return cred, nil -} - -func SetCredential(provider string, cred *AuthCredential) error { - store, err := LoadStore() - if err != nil { - return err - } - store.Credentials[provider] = cred - return SaveStore(store) -} - -func DeleteCredential(provider string) error { - store, err := LoadStore() - if err != nil { - return err - } - delete(store.Credentials, provider) - return SaveStore(store) -} - -func DeleteAllCredentials() error { - path := authFilePath() - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - return err - } - return nil -} diff --git a/pkg/auth/store_test.go b/pkg/auth/store_test.go deleted file mode 100644 index e2f935907..000000000 --- a/pkg/auth/store_test.go +++ /dev/null @@ -1,189 +0,0 @@ -package auth - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestAuthCredentialIsExpired(t *testing.T) { - tests := []struct { - name string - expiresAt time.Time - want bool - }{ - {"zero time", time.Time{}, false}, - {"future", time.Now().Add(time.Hour), false}, - {"past", time.Now().Add(-time.Hour), true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &AuthCredential{ExpiresAt: tt.expiresAt} - if got := c.IsExpired(); got != tt.want { - t.Errorf("IsExpired() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestAuthCredentialNeedsRefresh(t *testing.T) { - tests := []struct { - name string - expiresAt time.Time - want bool - }{ - {"zero time", time.Time{}, false}, - {"far future", time.Now().Add(time.Hour), false}, - {"within 5 min", time.Now().Add(3 * time.Minute), true}, - {"already expired", time.Now().Add(-time.Minute), true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &AuthCredential{ExpiresAt: tt.expiresAt} - if got := c.NeedsRefresh(); got != tt.want { - t.Errorf("NeedsRefresh() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestStoreRoundtrip(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - cred := &AuthCredential{ - AccessToken: "test-access-token", - RefreshToken: "test-refresh-token", - AccountID: "acct-123", - ExpiresAt: time.Now().Add(time.Hour).Truncate(time.Second), - Provider: "openai", - AuthMethod: "oauth", - } - - if err := SetCredential("openai", cred); err != nil { - t.Fatalf("SetCredential() error: %v", err) - } - - loaded, err := GetCredential("openai") - if err != nil { - t.Fatalf("GetCredential() error: %v", err) - } - if loaded == nil { - t.Fatal("GetCredential() returned nil") - } - if loaded.AccessToken != cred.AccessToken { - t.Errorf("AccessToken = %q, want %q", loaded.AccessToken, cred.AccessToken) - } - if loaded.RefreshToken != cred.RefreshToken { - t.Errorf("RefreshToken = %q, want %q", loaded.RefreshToken, cred.RefreshToken) - } - if loaded.Provider != cred.Provider { - t.Errorf("Provider = %q, want %q", loaded.Provider, cred.Provider) - } -} - -func TestStoreFilePermissions(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - cred := &AuthCredential{ - AccessToken: "secret-token", - Provider: "openai", - AuthMethod: "oauth", - } - if err := SetCredential("openai", cred); err != nil { - t.Fatalf("SetCredential() error: %v", err) - } - - path := filepath.Join(tmpDir, ".clawdroid", "auth.json") - info, err := os.Stat(path) - if err != nil { - t.Fatalf("Stat() error: %v", err) - } - perm := info.Mode().Perm() - if perm != 0600 { - t.Errorf("file permissions = %o, want 0600", perm) - } -} - -func TestStoreMultiProvider(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - openaiCred := &AuthCredential{AccessToken: "openai-token", Provider: "openai", AuthMethod: "oauth"} - anthropicCred := &AuthCredential{AccessToken: "anthropic-token", Provider: "anthropic", AuthMethod: "token"} - - if err := SetCredential("openai", openaiCred); err != nil { - t.Fatalf("SetCredential(openai) error: %v", err) - } - if err := SetCredential("anthropic", anthropicCred); err != nil { - t.Fatalf("SetCredential(anthropic) error: %v", err) - } - - loaded, err := GetCredential("openai") - if err != nil { - t.Fatalf("GetCredential(openai) error: %v", err) - } - if loaded.AccessToken != "openai-token" { - t.Errorf("openai token = %q, want %q", loaded.AccessToken, "openai-token") - } - - loaded, err = GetCredential("anthropic") - if err != nil { - t.Fatalf("GetCredential(anthropic) error: %v", err) - } - if loaded.AccessToken != "anthropic-token" { - t.Errorf("anthropic token = %q, want %q", loaded.AccessToken, "anthropic-token") - } -} - -func TestDeleteCredential(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - cred := &AuthCredential{AccessToken: "to-delete", Provider: "openai", AuthMethod: "oauth"} - if err := SetCredential("openai", cred); err != nil { - t.Fatalf("SetCredential() error: %v", err) - } - - if err := DeleteCredential("openai"); err != nil { - t.Fatalf("DeleteCredential() error: %v", err) - } - - loaded, err := GetCredential("openai") - if err != nil { - t.Fatalf("GetCredential() error: %v", err) - } - if loaded != nil { - t.Error("expected nil after delete") - } -} - -func TestLoadStoreEmpty(t *testing.T) { - tmpDir := t.TempDir() - origHome := os.Getenv("HOME") - t.Setenv("HOME", tmpDir) - defer os.Setenv("HOME", origHome) - - store, err := LoadStore() - if err != nil { - t.Fatalf("LoadStore() error: %v", err) - } - if store == nil { - t.Fatal("LoadStore() returned nil") - } - if len(store.Credentials) != 0 { - t.Errorf("expected empty credentials, got %d", len(store.Credentials)) - } -} diff --git a/pkg/auth/token.go b/pkg/auth/token.go deleted file mode 100644 index a5a13ff03..000000000 --- a/pkg/auth/token.go +++ /dev/null @@ -1,43 +0,0 @@ -package auth - -import ( - "bufio" - "fmt" - "io" - "strings" -) - -func LoginPasteToken(provider string, r io.Reader) (*AuthCredential, error) { - fmt.Printf("Paste your API key or session token from %s:\n", providerDisplayName(provider)) - fmt.Print("> ") - - scanner := bufio.NewScanner(r) - if !scanner.Scan() { - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("reading token: %w", err) - } - return nil, fmt.Errorf("no input received") - } - - token := strings.TrimSpace(scanner.Text()) - if token == "" { - return nil, fmt.Errorf("token cannot be empty") - } - - return &AuthCredential{ - AccessToken: token, - Provider: provider, - AuthMethod: "token", - }, nil -} - -func providerDisplayName(provider string) string { - switch provider { - case "anthropic": - return "console.anthropic.com" - case "openai": - return "platform.openai.com" - default: - return provider - } -} diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go index 0acc2cdd3..291176be7 100644 --- a/pkg/channels/telegram_commands.go +++ b/pkg/channels/telegram_commands.go @@ -78,9 +78,8 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error { var response string switch args { case "model": - response = fmt.Sprintf("Current Model: %s (Provider: %s)", - c.config.Agents.Defaults.Model, - c.config.Agents.Defaults.Provider) + response = fmt.Sprintf("Current Model: %s", + c.config.LLM.Model) case "channel": response = "Current Channel: telegram" default: @@ -112,12 +111,8 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { var response string switch args { case "models": - provider := c.config.Agents.Defaults.Provider - if provider == "" { - provider = "configured default" - } - response = fmt.Sprintf("Configured Model: %s\nProvider: %s\n\nTo change models, update config.yaml", - c.config.Agents.Defaults.Model, provider) + response = fmt.Sprintf("Configured Model: %s\n\nTo change models, update config.json", + c.config.LLM.Model) case "channels": var enabled []string diff --git a/pkg/config/config.go b/pkg/config/config.go index f95f8a225..957df36b1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -43,10 +43,21 @@ func (f *FlexibleStringSlice) UnmarshalJSON(data []byte) error { return nil } +type LLMConfig struct { + Model string `json:"model" env:"CLAWDROID_LLM_MODEL"` + APIKey string `json:"api_key" env:"CLAWDROID_LLM_API_KEY"` + BaseURL string `json:"base_url" env:"CLAWDROID_LLM_BASE_URL"` +} + +type STTConfig struct { + APIKey string `json:"api_key" env:"CLAWDROID_STT_API_KEY"` +} + type Config struct { + LLM LLMConfig `json:"llm"` + STT STTConfig `json:"stt"` Agents AgentsConfig `json:"agents"` Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers"` Gateway GatewayConfig `json:"gateway"` Tools ToolsConfig `json:"tools"` Heartbeat HeartbeatConfig `json:"heartbeat"` @@ -63,8 +74,6 @@ type AgentDefaults struct { Workspace string `json:"workspace" env:"CLAWDROID_AGENTS_DEFAULTS_WORKSPACE"` DataDir string `json:"data_dir" env:"CLAWDROID_AGENTS_DEFAULTS_DATA_DIR"` RestrictToWorkspace bool `json:"restrict_to_workspace" env:"CLAWDROID_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - Provider string `json:"provider" env:"CLAWDROID_AGENTS_DEFAULTS_PROVIDER"` - Model string `json:"model" env:"CLAWDROID_AGENTS_DEFAULTS_MODEL"` MaxTokens int `json:"max_tokens" env:"CLAWDROID_AGENTS_DEFAULTS_MAX_TOKENS"` ContextWindow int `json:"context_window" env:"CLAWDROID_AGENTS_DEFAULTS_CONTEXT_WINDOW"` Temperature float64 `json:"temperature" env:"CLAWDROID_AGENTS_DEFAULTS_TEMPERATURE"` @@ -183,30 +192,6 @@ type RateLimitsConfig struct { MaxRequestsPerMinute int `json:"max_requests_per_minute" env:"CLAWDROID_RATE_LIMITS_MAX_REQUESTS_PER_MINUTE"` // 0 = unlimited } -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"` -} - -type ProviderConfig struct { - APIKey string `json:"api_key" env:"CLAWDROID_PROVIDERS_{{.Name}}_API_KEY"` - APIBase string `json:"api_base" env:"CLAWDROID_PROVIDERS_{{.Name}}_API_BASE"` - Proxy string `json:"proxy,omitempty" env:"CLAWDROID_PROVIDERS_{{.Name}}_PROXY"` - AuthMethod string `json:"auth_method,omitempty" env:"CLAWDROID_PROVIDERS_{{.Name}}_AUTH_METHOD"` - ConnectMode string `json:"connect_mode,omitempty" env:"CLAWDROID_PROVIDERS_{{.Name}}_CONNECT_MODE"` //only for Github Copilot, `stdio` or `grpc` -} - type GatewayConfig struct { Host string `json:"host" env:"CLAWDROID_GATEWAY_HOST"` Port int `json:"port" env:"CLAWDROID_GATEWAY_PORT"` @@ -274,16 +259,18 @@ type ToolsConfig struct { func DefaultConfig() *Config { return &Config{ + LLM: LLMConfig{ + Model: "zhipu/glm-4.7", + }, + STT: STTConfig{}, Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: "~/.clawdroid/workspace", DataDir: "~/.clawdroid/data", RestrictToWorkspace: true, - Provider: "", - Model: "glm-4.7", MaxTokens: 8192, ContextWindow: 128000, - Temperature: 0.7, + Temperature: 0, MaxToolIterations: 20, }, }, @@ -360,18 +347,6 @@ func DefaultConfig() *Config { AllowFrom: FlexibleStringSlice{}, }, }, - Providers: ProvidersConfig{ - Anthropic: ProviderConfig{}, - OpenAI: ProviderConfig{}, - OpenRouter: ProviderConfig{}, - Groq: ProviderConfig{}, - Zhipu: ProviderConfig{}, - VLLM: ProviderConfig{}, - Gemini: ProviderConfig{}, - Nvidia: ProviderConfig{}, - Moonshot: ProviderConfig{}, - ShengSuanYun: ProviderConfig{}, - }, Gateway: GatewayConfig{ Host: "127.0.0.1", Port: 18790, @@ -470,54 +445,6 @@ func (c *Config) DataPath() string { return expandHome(c.Agents.Defaults.DataDir) } -func (c *Config) GetAPIKey() string { - c.mu.RLock() - defer c.mu.RUnlock() - if c.Providers.OpenRouter.APIKey != "" { - return c.Providers.OpenRouter.APIKey - } - if c.Providers.Anthropic.APIKey != "" { - return c.Providers.Anthropic.APIKey - } - if c.Providers.OpenAI.APIKey != "" { - return c.Providers.OpenAI.APIKey - } - if c.Providers.Gemini.APIKey != "" { - return c.Providers.Gemini.APIKey - } - if c.Providers.Zhipu.APIKey != "" { - return c.Providers.Zhipu.APIKey - } - if c.Providers.Groq.APIKey != "" { - return c.Providers.Groq.APIKey - } - if c.Providers.VLLM.APIKey != "" { - return c.Providers.VLLM.APIKey - } - if c.Providers.ShengSuanYun.APIKey != "" { - return c.Providers.ShengSuanYun.APIKey - } - return "" -} - -func (c *Config) GetAPIBase() string { - c.mu.RLock() - defer c.mu.RUnlock() - if c.Providers.OpenRouter.APIKey != "" { - if c.Providers.OpenRouter.APIBase != "" { - return c.Providers.OpenRouter.APIBase - } - return "https://openrouter.ai/api/v1" - } - if c.Providers.Zhipu.APIKey != "" { - return c.Providers.Zhipu.APIBase - } - if c.Providers.VLLM.APIKey != "" && c.Providers.VLLM.APIBase != "" { - return c.Providers.VLLM.APIBase - } - return "" -} - func expandHome(path string) string { if path == "" { return path diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 264bcc5ba..8e48d61e4 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -31,8 +31,8 @@ func TestDefaultConfig_WorkspacePath(t *testing.T) { func TestDefaultConfig_Model(t *testing.T) { cfg := DefaultConfig() - if cfg.Agents.Defaults.Model == "" { - t.Error("Model should not be empty") + if cfg.LLM.Model == "" { + t.Error("LLM.Model should not be empty") } } @@ -75,31 +75,18 @@ func TestDefaultConfig_Gateway(t *testing.T) { } } -// TestDefaultConfig_Providers verifies provider structure -func TestDefaultConfig_Providers(t *testing.T) { +// TestDefaultConfig_LLM verifies LLM config defaults +func TestDefaultConfig_LLM(t *testing.T) { cfg := DefaultConfig() - // Verify all providers are empty by default - if cfg.Providers.Anthropic.APIKey != "" { - t.Error("Anthropic API key should be empty by default") + if cfg.LLM.APIKey != "" { + t.Error("LLM API key should be empty by default") } - if cfg.Providers.OpenAI.APIKey != "" { - t.Error("OpenAI API key should be empty by default") + if cfg.LLM.BaseURL != "" { + t.Error("LLM BaseURL should be empty by default") } - if cfg.Providers.OpenRouter.APIKey != "" { - t.Error("OpenRouter API key should be empty by default") - } - if cfg.Providers.Groq.APIKey != "" { - t.Error("Groq API key should be empty by default") - } - if cfg.Providers.Zhipu.APIKey != "" { - t.Error("Zhipu API key should be empty by default") - } - if cfg.Providers.VLLM.APIKey != "" { - t.Error("VLLM API key should be empty by default") - } - if cfg.Providers.Gemini.APIKey != "" { - t.Error("Gemini API key should be empty by default") + if cfg.LLM.Model != "zhipu/glm-4.7" { + t.Errorf("LLM Model = %q, want %q", cfg.LLM.Model, "zhipu/glm-4.7") } } @@ -234,8 +221,8 @@ func TestConfig_Complete(t *testing.T) { if cfg.Agents.Defaults.Workspace == "" { t.Error("Workspace should not be empty") } - if cfg.Agents.Defaults.Model == "" { - t.Error("Model should not be empty") + if cfg.LLM.Model == "" { + t.Error("LLM.Model should not be empty") } if cfg.Agents.Defaults.Temperature == 0 { t.Error("Temperature should have default value") diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go deleted file mode 100644 index 0dbcdd8f4..000000000 --- a/pkg/migrate/config.go +++ /dev/null @@ -1,382 +0,0 @@ -package migrate - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "unicode" - - "github.com/KarakuriAgent/clawdroid/pkg/config" -) - -var supportedProviders = map[string]bool{ - "anthropic": true, - "openai": true, - "openrouter": true, - "groq": true, - "zhipu": true, - "vllm": true, - "gemini": true, -} - -var supportedChannels = map[string]bool{ - "telegram": true, - "discord": true, - "whatsapp": true, - "feishu": true, - "qq": true, - "dingtalk": true, - "maixcam": true, -} - -func findOpenClawConfig(openclawHome string) (string, error) { - candidates := []string{ - filepath.Join(openclawHome, "openclaw.json"), - filepath.Join(openclawHome, "config.json"), - } - for _, p := range candidates { - if _, err := os.Stat(p); err == nil { - return p, nil - } - } - return "", fmt.Errorf("no config file found in %s (tried openclaw.json, config.json)", openclawHome) -} - -func LoadOpenClawConfig(configPath string) (map[string]interface{}, error) { - data, err := os.ReadFile(configPath) - if err != nil { - return nil, fmt.Errorf("reading OpenClaw config: %w", err) - } - - var raw map[string]interface{} - if err := json.Unmarshal(data, &raw); err != nil { - return nil, fmt.Errorf("parsing OpenClaw config: %w", err) - } - - converted := convertKeysToSnake(raw) - result, ok := converted.(map[string]interface{}) - if !ok { - return nil, fmt.Errorf("unexpected config format") - } - return result, nil -} - -func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error) { - cfg := config.DefaultConfig() - var warnings []string - - if agents, ok := getMap(data, "agents"); ok { - if defaults, ok := getMap(agents, "defaults"); ok { - if v, ok := getString(defaults, "model"); ok { - cfg.Agents.Defaults.Model = v - } - if v, ok := getFloat(defaults, "max_tokens"); ok { - cfg.Agents.Defaults.MaxTokens = int(v) - } - if v, ok := getFloat(defaults, "temperature"); ok { - cfg.Agents.Defaults.Temperature = v - } - if v, ok := getFloat(defaults, "max_tool_iterations"); ok { - cfg.Agents.Defaults.MaxToolIterations = int(v) - } - if v, ok := getString(defaults, "workspace"); ok { - cfg.Agents.Defaults.Workspace = rewriteWorkspacePath(v) - } - } - } - - if providers, ok := getMap(data, "providers"); ok { - for name, val := range providers { - pMap, ok := val.(map[string]interface{}) - if !ok { - continue - } - apiKey, _ := getString(pMap, "api_key") - apiBase, _ := getString(pMap, "api_base") - - if !supportedProviders[name] { - if apiKey != "" || apiBase != "" { - warnings = append(warnings, fmt.Sprintf("Provider '%s' not supported in ClawDroid, skipping", name)) - } - continue - } - - pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase} - switch name { - case "anthropic": - cfg.Providers.Anthropic = pc - case "openai": - cfg.Providers.OpenAI = pc - case "openrouter": - cfg.Providers.OpenRouter = pc - case "groq": - cfg.Providers.Groq = pc - case "zhipu": - cfg.Providers.Zhipu = pc - case "vllm": - cfg.Providers.VLLM = pc - case "gemini": - cfg.Providers.Gemini = pc - } - } - } - - if channels, ok := getMap(data, "channels"); ok { - for name, val := range channels { - cMap, ok := val.(map[string]interface{}) - if !ok { - continue - } - if !supportedChannels[name] { - warnings = append(warnings, fmt.Sprintf("Channel '%s' not supported in ClawDroid, skipping", name)) - continue - } - enabled, _ := getBool(cMap, "enabled") - allowFrom := getStringSlice(cMap, "allow_from") - - switch name { - case "telegram": - cfg.Channels.Telegram.Enabled = enabled - cfg.Channels.Telegram.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { - cfg.Channels.Telegram.Token = v - } - case "discord": - cfg.Channels.Discord.Enabled = enabled - cfg.Channels.Discord.AllowFrom = allowFrom - if v, ok := getString(cMap, "token"); ok { - cfg.Channels.Discord.Token = v - } - case "whatsapp": - cfg.Channels.WhatsApp.Enabled = enabled - cfg.Channels.WhatsApp.AllowFrom = allowFrom - if v, ok := getString(cMap, "bridge_url"); ok { - cfg.Channels.WhatsApp.BridgeURL = v - } - case "feishu": - cfg.Channels.Feishu.Enabled = enabled - cfg.Channels.Feishu.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { - cfg.Channels.Feishu.AppID = v - } - if v, ok := getString(cMap, "app_secret"); ok { - cfg.Channels.Feishu.AppSecret = v - } - if v, ok := getString(cMap, "encrypt_key"); ok { - cfg.Channels.Feishu.EncryptKey = v - } - if v, ok := getString(cMap, "verification_token"); ok { - cfg.Channels.Feishu.VerificationToken = v - } - case "qq": - cfg.Channels.QQ.Enabled = enabled - cfg.Channels.QQ.AllowFrom = allowFrom - if v, ok := getString(cMap, "app_id"); ok { - cfg.Channels.QQ.AppID = v - } - if v, ok := getString(cMap, "app_secret"); ok { - cfg.Channels.QQ.AppSecret = v - } - case "dingtalk": - cfg.Channels.DingTalk.Enabled = enabled - cfg.Channels.DingTalk.AllowFrom = allowFrom - if v, ok := getString(cMap, "client_id"); ok { - cfg.Channels.DingTalk.ClientID = v - } - if v, ok := getString(cMap, "client_secret"); ok { - cfg.Channels.DingTalk.ClientSecret = v - } - case "maixcam": - cfg.Channels.MaixCam.Enabled = enabled - cfg.Channels.MaixCam.AllowFrom = allowFrom - if v, ok := getString(cMap, "host"); ok { - cfg.Channels.MaixCam.Host = v - } - if v, ok := getFloat(cMap, "port"); ok { - cfg.Channels.MaixCam.Port = int(v) - } - } - } - } - - if gateway, ok := getMap(data, "gateway"); ok { - if v, ok := getString(gateway, "host"); ok { - cfg.Gateway.Host = v - } - if v, ok := getFloat(gateway, "port"); ok { - cfg.Gateway.Port = int(v) - } - } - - if tools, ok := getMap(data, "tools"); ok { - if web, ok := getMap(tools, "web"); ok { - // Migrate old "search" config to "brave" if api_key is present - if search, ok := getMap(web, "search"); ok { - if v, ok := getString(search, "api_key"); ok { - cfg.Tools.Web.Brave.APIKey = v - if v != "" { - cfg.Tools.Web.Brave.Enabled = true - } - } - if v, ok := getFloat(search, "max_results"); ok { - cfg.Tools.Web.Brave.MaxResults = int(v) - cfg.Tools.Web.DuckDuckGo.MaxResults = int(v) - } - } - } - } - - return cfg, warnings, nil -} - -func MergeConfig(existing, incoming *config.Config) *config.Config { - if existing.Providers.Anthropic.APIKey == "" { - existing.Providers.Anthropic = incoming.Providers.Anthropic - } - if existing.Providers.OpenAI.APIKey == "" { - existing.Providers.OpenAI = incoming.Providers.OpenAI - } - if existing.Providers.OpenRouter.APIKey == "" { - existing.Providers.OpenRouter = incoming.Providers.OpenRouter - } - if existing.Providers.Groq.APIKey == "" { - existing.Providers.Groq = incoming.Providers.Groq - } - if existing.Providers.Zhipu.APIKey == "" { - existing.Providers.Zhipu = incoming.Providers.Zhipu - } - if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" { - existing.Providers.VLLM = incoming.Providers.VLLM - } - if existing.Providers.Gemini.APIKey == "" { - existing.Providers.Gemini = incoming.Providers.Gemini - } - - if !existing.Channels.Telegram.Enabled && incoming.Channels.Telegram.Enabled { - existing.Channels.Telegram = incoming.Channels.Telegram - } - if !existing.Channels.Discord.Enabled && incoming.Channels.Discord.Enabled { - existing.Channels.Discord = incoming.Channels.Discord - } - if !existing.Channels.WhatsApp.Enabled && incoming.Channels.WhatsApp.Enabled { - existing.Channels.WhatsApp = incoming.Channels.WhatsApp - } - if !existing.Channels.Feishu.Enabled && incoming.Channels.Feishu.Enabled { - existing.Channels.Feishu = incoming.Channels.Feishu - } - if !existing.Channels.QQ.Enabled && incoming.Channels.QQ.Enabled { - existing.Channels.QQ = incoming.Channels.QQ - } - if !existing.Channels.DingTalk.Enabled && incoming.Channels.DingTalk.Enabled { - existing.Channels.DingTalk = incoming.Channels.DingTalk - } - if !existing.Channels.MaixCam.Enabled && incoming.Channels.MaixCam.Enabled { - existing.Channels.MaixCam = incoming.Channels.MaixCam - } - - if existing.Tools.Web.Brave.APIKey == "" { - existing.Tools.Web.Brave = incoming.Tools.Web.Brave - } - - return existing -} - -func camelToSnake(s string) string { - var result strings.Builder - for i, r := range s { - if unicode.IsUpper(r) { - if i > 0 { - prev := rune(s[i-1]) - if unicode.IsLower(prev) || unicode.IsDigit(prev) { - result.WriteRune('_') - } else if unicode.IsUpper(prev) && i+1 < len(s) && unicode.IsLower(rune(s[i+1])) { - result.WriteRune('_') - } - } - result.WriteRune(unicode.ToLower(r)) - } else { - result.WriteRune(r) - } - } - return result.String() -} - -func convertKeysToSnake(data interface{}) interface{} { - switch v := data.(type) { - case map[string]interface{}: - result := make(map[string]interface{}, len(v)) - for key, val := range v { - result[camelToSnake(key)] = convertKeysToSnake(val) - } - return result - case []interface{}: - result := make([]interface{}, len(v)) - for i, val := range v { - result[i] = convertKeysToSnake(val) - } - return result - default: - return data - } -} - -func rewriteWorkspacePath(path string) string { - path = strings.Replace(path, ".openclaw", ".clawdroid", 1) - return path -} - -func getMap(data map[string]interface{}, key string) (map[string]interface{}, bool) { - v, ok := data[key] - if !ok { - return nil, false - } - m, ok := v.(map[string]interface{}) - return m, ok -} - -func getString(data map[string]interface{}, key string) (string, bool) { - v, ok := data[key] - if !ok { - return "", false - } - s, ok := v.(string) - return s, ok -} - -func getFloat(data map[string]interface{}, key string) (float64, bool) { - v, ok := data[key] - if !ok { - return 0, false - } - f, ok := v.(float64) - return f, ok -} - -func getBool(data map[string]interface{}, key string) (bool, bool) { - v, ok := data[key] - if !ok { - return false, false - } - b, ok := v.(bool) - return b, ok -} - -func getStringSlice(data map[string]interface{}, key string) []string { - v, ok := data[key] - if !ok { - return []string{} - } - arr, ok := v.([]interface{}) - if !ok { - return []string{} - } - result := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - result = append(result, s) - } - } - return result -} diff --git a/pkg/migrate/migrate.go b/pkg/migrate/migrate.go deleted file mode 100644 index 34ca5ba7d..000000000 --- a/pkg/migrate/migrate.go +++ /dev/null @@ -1,394 +0,0 @@ -package migrate - -import ( - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "github.com/KarakuriAgent/clawdroid/pkg/config" -) - -type ActionType int - -const ( - ActionCopy ActionType = iota - ActionSkip - ActionBackup - ActionConvertConfig - ActionCreateDir - ActionMergeConfig -) - -type Options struct { - DryRun bool - ConfigOnly bool - WorkspaceOnly bool - Force bool - Refresh bool - OpenClawHome string - ClawDroidHome string -} - -type Action struct { - Type ActionType - Source string - Destination string - Description string -} - -type Result struct { - FilesCopied int - FilesSkipped int - BackupsCreated int - ConfigMigrated bool - DirsCreated int - Warnings []string - Errors []error -} - -func Run(opts Options) (*Result, error) { - if opts.ConfigOnly && opts.WorkspaceOnly { - return nil, fmt.Errorf("--config-only and --workspace-only are mutually exclusive") - } - - if opts.Refresh { - opts.WorkspaceOnly = true - } - - openclawHome, err := resolveOpenClawHome(opts.OpenClawHome) - if err != nil { - return nil, err - } - - clawDroidHome, err := resolveClawDroidHome(opts.ClawDroidHome) - if err != nil { - return nil, err - } - - if _, err := os.Stat(openclawHome); os.IsNotExist(err) { - return nil, fmt.Errorf("OpenClaw installation not found at %s", openclawHome) - } - - actions, warnings, err := Plan(opts, openclawHome, clawDroidHome) - if err != nil { - return nil, err - } - - fmt.Println("Migrating from OpenClaw to ClawDroid") - fmt.Printf(" Source: %s\n", openclawHome) - fmt.Printf(" Destination: %s\n", clawDroidHome) - fmt.Println() - - if opts.DryRun { - PrintPlan(actions, warnings) - return &Result{Warnings: warnings}, nil - } - - if !opts.Force { - PrintPlan(actions, warnings) - if !Confirm() { - fmt.Println("Aborted.") - return &Result{Warnings: warnings}, nil - } - fmt.Println() - } - - result := Execute(actions, openclawHome, clawDroidHome) - result.Warnings = warnings - return result, nil -} - -func Plan(opts Options, openclawHome, clawDroidHome string) ([]Action, []string, error) { - var actions []Action - var warnings []string - - force := opts.Force || opts.Refresh - - if !opts.WorkspaceOnly { - configPath, err := findOpenClawConfig(openclawHome) - if err != nil { - if opts.ConfigOnly { - return nil, nil, err - } - warnings = append(warnings, fmt.Sprintf("Config migration skipped: %v", err)) - } else { - actions = append(actions, Action{ - Type: ActionConvertConfig, - Source: configPath, - Destination: filepath.Join(clawDroidHome, "config.json"), - Description: "convert OpenClaw config to ClawDroid format", - }) - - data, err := LoadOpenClawConfig(configPath) - if err == nil { - _, configWarnings, _ := ConvertConfig(data) - warnings = append(warnings, configWarnings...) - } - } - } - - if !opts.ConfigOnly { - srcWorkspace := resolveWorkspace(openclawHome) - dstWorkspace := resolveWorkspace(clawDroidHome) - - if _, err := os.Stat(srcWorkspace); err == nil { - wsActions, err := PlanWorkspaceMigration(srcWorkspace, dstWorkspace, force) - if err != nil { - return nil, nil, fmt.Errorf("planning workspace migration: %w", err) - } - actions = append(actions, wsActions...) - } else { - warnings = append(warnings, "OpenClaw workspace directory not found, skipping workspace migration") - } - } - - return actions, warnings, nil -} - -func Execute(actions []Action, openclawHome, clawDroidHome string) *Result { - result := &Result{} - - for _, action := range actions { - switch action.Type { - case ActionConvertConfig: - if err := executeConfigMigration(action.Source, action.Destination, clawDroidHome); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("config migration: %w", err)) - fmt.Printf(" ✗ Config migration failed: %v\n", err) - } else { - result.ConfigMigrated = true - fmt.Printf(" ✓ Converted config: %s\n", action.Destination) - } - case ActionCreateDir: - if err := os.MkdirAll(action.Destination, 0755); err != nil { - result.Errors = append(result.Errors, err) - } else { - result.DirsCreated++ - } - case ActionBackup: - bakPath := action.Destination + ".bak" - if err := copyFile(action.Destination, bakPath); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("backup %s: %w", action.Destination, err)) - fmt.Printf(" ✗ Backup failed: %s\n", action.Destination) - continue - } - result.BackupsCreated++ - fmt.Printf(" ✓ Backed up %s -> %s.bak\n", filepath.Base(action.Destination), filepath.Base(action.Destination)) - - if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { - result.Errors = append(result.Errors, err) - continue - } - if err := copyFile(action.Source, action.Destination); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) - fmt.Printf(" ✗ Copy failed: %s\n", action.Source) - } else { - result.FilesCopied++ - fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) - } - case ActionCopy: - if err := os.MkdirAll(filepath.Dir(action.Destination), 0755); err != nil { - result.Errors = append(result.Errors, err) - continue - } - if err := copyFile(action.Source, action.Destination); err != nil { - result.Errors = append(result.Errors, fmt.Errorf("copy %s: %w", action.Source, err)) - fmt.Printf(" ✗ Copy failed: %s\n", action.Source) - } else { - result.FilesCopied++ - fmt.Printf(" ✓ Copied %s\n", relPath(action.Source, openclawHome)) - } - case ActionSkip: - result.FilesSkipped++ - } - } - - return result -} - -func executeConfigMigration(srcConfigPath, dstConfigPath, clawDroidHome string) error { - data, err := LoadOpenClawConfig(srcConfigPath) - if err != nil { - return err - } - - incoming, _, err := ConvertConfig(data) - if err != nil { - return err - } - - if _, err := os.Stat(dstConfigPath); err == nil { - existing, err := config.LoadConfig(dstConfigPath) - if err != nil { - return fmt.Errorf("loading existing ClawDroid config: %w", err) - } - incoming = MergeConfig(existing, incoming) - } - - if err := os.MkdirAll(filepath.Dir(dstConfigPath), 0755); err != nil { - return err - } - return config.SaveConfig(dstConfigPath, incoming) -} - -func Confirm() bool { - fmt.Print("Proceed with migration? (y/n): ") - var response string - fmt.Scanln(&response) - return strings.ToLower(strings.TrimSpace(response)) == "y" -} - -func PrintPlan(actions []Action, warnings []string) { - fmt.Println("Planned actions:") - copies := 0 - skips := 0 - backups := 0 - configCount := 0 - - for _, action := range actions { - switch action.Type { - case ActionConvertConfig: - fmt.Printf(" [config] %s -> %s\n", action.Source, action.Destination) - configCount++ - case ActionCopy: - fmt.Printf(" [copy] %s\n", filepath.Base(action.Source)) - copies++ - case ActionBackup: - fmt.Printf(" [backup] %s (exists, will backup and overwrite)\n", filepath.Base(action.Destination)) - backups++ - copies++ - case ActionSkip: - if action.Description != "" { - fmt.Printf(" [skip] %s (%s)\n", filepath.Base(action.Source), action.Description) - } - skips++ - case ActionCreateDir: - fmt.Printf(" [mkdir] %s\n", action.Destination) - } - } - - if len(warnings) > 0 { - fmt.Println() - fmt.Println("Warnings:") - for _, w := range warnings { - fmt.Printf(" - %s\n", w) - } - } - - fmt.Println() - fmt.Printf("%d files to copy, %d configs to convert, %d backups needed, %d skipped\n", - copies, configCount, backups, skips) -} - -func PrintSummary(result *Result) { - fmt.Println() - parts := []string{} - if result.FilesCopied > 0 { - parts = append(parts, fmt.Sprintf("%d files copied", result.FilesCopied)) - } - if result.ConfigMigrated { - parts = append(parts, "1 config converted") - } - if result.BackupsCreated > 0 { - parts = append(parts, fmt.Sprintf("%d backups created", result.BackupsCreated)) - } - if result.FilesSkipped > 0 { - parts = append(parts, fmt.Sprintf("%d files skipped", result.FilesSkipped)) - } - - if len(parts) > 0 { - fmt.Printf("Migration complete! %s.\n", strings.Join(parts, ", ")) - } else { - fmt.Println("Migration complete! No actions taken.") - } - - if len(result.Errors) > 0 { - fmt.Println() - fmt.Printf("%d errors occurred:\n", len(result.Errors)) - for _, e := range result.Errors { - fmt.Printf(" - %v\n", e) - } - } -} - -func resolveOpenClawHome(override string) (string, error) { - if override != "" { - return expandHome(override), nil - } - if envHome := os.Getenv("OPENCLAW_HOME"); envHome != "" { - return expandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, ".openclaw"), nil -} - -func resolveClawDroidHome(override string) (string, error) { - if override != "" { - return expandHome(override), nil - } - if envHome := os.Getenv("CLAWDROID_HOME"); envHome != "" { - return expandHome(envHome), nil - } - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("resolving home directory: %w", err) - } - return filepath.Join(home, ".clawdroid"), nil -} - -func resolveWorkspace(homeDir string) string { - return filepath.Join(homeDir, "workspace") -} - -func expandHome(path string) string { - if path == "" { - return path - } - if path[0] == '~' { - home, _ := os.UserHomeDir() - if len(path) > 1 && path[1] == '/' { - return home + path[1:] - } - return home - } - return path -} - -func backupFile(path string) error { - bakPath := path + ".bak" - return copyFile(path, bakPath) -} - -func copyFile(src, dst string) error { - srcFile, err := os.Open(src) - if err != nil { - return err - } - defer srcFile.Close() - - info, err := srcFile.Stat() - if err != nil { - return err - } - - dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode()) - if err != nil { - return err - } - defer dstFile.Close() - - _, err = io.Copy(dstFile, srcFile) - return err -} - -func relPath(path, base string) string { - rel, err := filepath.Rel(base, path) - if err != nil { - return filepath.Base(path) - } - return rel -} diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go deleted file mode 100644 index bbb7d47fc..000000000 --- a/pkg/migrate/migrate_test.go +++ /dev/null @@ -1,854 +0,0 @@ -package migrate - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/KarakuriAgent/clawdroid/pkg/config" -) - -func TestCamelToSnake(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - {"simple", "apiKey", "api_key"}, - {"two words", "apiBase", "api_base"}, - {"three words", "maxToolIterations", "max_tool_iterations"}, - {"already snake", "api_key", "api_key"}, - {"single word", "enabled", "enabled"}, - {"all lower", "model", "model"}, - {"consecutive caps", "apiURL", "api_url"}, - {"starts upper", "Model", "model"}, - {"bridge url", "bridgeUrl", "bridge_url"}, - {"client id", "clientId", "client_id"}, - {"app secret", "appSecret", "app_secret"}, - {"verification token", "verificationToken", "verification_token"}, - {"allow from", "allowFrom", "allow_from"}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := camelToSnake(tt.input) - if got != tt.want { - t.Errorf("camelToSnake(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestConvertKeysToSnake(t *testing.T) { - input := map[string]interface{}{ - "apiKey": "test-key", - "apiBase": "https://example.com", - "nested": map[string]interface{}{ - "maxTokens": float64(8192), - "allowFrom": []interface{}{"user1", "user2"}, - "deeperLevel": map[string]interface{}{ - "clientId": "abc", - }, - }, - } - - result := convertKeysToSnake(input) - m, ok := result.(map[string]interface{}) - if !ok { - t.Fatal("expected map[string]interface{}") - } - - if _, ok := m["api_key"]; !ok { - t.Error("expected key 'api_key' after conversion") - } - if _, ok := m["api_base"]; !ok { - t.Error("expected key 'api_base' after conversion") - } - - nested, ok := m["nested"].(map[string]interface{}) - if !ok { - t.Fatal("expected nested map") - } - if _, ok := nested["max_tokens"]; !ok { - t.Error("expected key 'max_tokens' in nested map") - } - if _, ok := nested["allow_from"]; !ok { - t.Error("expected key 'allow_from' in nested map") - } - - deeper, ok := nested["deeper_level"].(map[string]interface{}) - if !ok { - t.Fatal("expected deeper_level map") - } - if _, ok := deeper["client_id"]; !ok { - t.Error("expected key 'client_id' in deeper level") - } -} - -func TestLoadOpenClawConfig(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "openclaw.json") - - openclawConfig := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-ant-test123", - "apiBase": "https://api.anthropic.com", - }, - }, - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ - "maxTokens": float64(4096), - "model": "claude-3-opus", - }, - }, - } - - data, err := json.Marshal(openclawConfig) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(configPath, data, 0644); err != nil { - t.Fatal(err) - } - - result, err := LoadOpenClawConfig(configPath) - if err != nil { - t.Fatalf("LoadOpenClawConfig: %v", err) - } - - providers, ok := result["providers"].(map[string]interface{}) - if !ok { - t.Fatal("expected providers map") - } - anthropic, ok := providers["anthropic"].(map[string]interface{}) - if !ok { - t.Fatal("expected anthropic map") - } - if anthropic["api_key"] != "sk-ant-test123" { - t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"]) - } - - agents, ok := result["agents"].(map[string]interface{}) - if !ok { - t.Fatal("expected agents map") - } - defaults, ok := agents["defaults"].(map[string]interface{}) - if !ok { - t.Fatal("expected defaults map") - } - if defaults["max_tokens"] != float64(4096) { - t.Errorf("max_tokens = %v, want 4096", defaults["max_tokens"]) - } -} - -func TestConvertConfig(t *testing.T) { - t.Run("providers mapping", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "api_key": "sk-ant-test", - "api_base": "https://api.anthropic.com", - }, - "openrouter": map[string]interface{}{ - "api_key": "sk-or-test", - }, - "groq": map[string]interface{}{ - "api_key": "gsk-test", - }, - }, - } - - cfg, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 0 { - t.Errorf("expected no warnings, got %v", warnings) - } - if cfg.Providers.Anthropic.APIKey != "sk-ant-test" { - t.Errorf("Anthropic.APIKey = %q, want %q", cfg.Providers.Anthropic.APIKey, "sk-ant-test") - } - if cfg.Providers.OpenRouter.APIKey != "sk-or-test" { - t.Errorf("OpenRouter.APIKey = %q, want %q", cfg.Providers.OpenRouter.APIKey, "sk-or-test") - } - if cfg.Providers.Groq.APIKey != "gsk-test" { - t.Errorf("Groq.APIKey = %q, want %q", cfg.Providers.Groq.APIKey, "gsk-test") - } - }) - - t.Run("unsupported provider warning", func(t *testing.T) { - data := map[string]interface{}{ - "providers": map[string]interface{}{ - "deepseek": map[string]interface{}{ - "api_key": "sk-deep-test", - }, - }, - } - - _, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d", len(warnings)) - } - if warnings[0] != "Provider 'deepseek' not supported in ClawDroid, skipping" { - t.Errorf("unexpected warning: %s", warnings[0]) - } - }) - - t.Run("channels mapping", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ - "enabled": true, - "token": "tg-token-123", - "allow_from": []interface{}{"user1"}, - }, - "discord": map[string]interface{}{ - "enabled": true, - "token": "disc-token-456", - }, - }, - } - - cfg, _, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if !cfg.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled") - } - if cfg.Channels.Telegram.Token != "tg-token-123" { - t.Errorf("Telegram.Token = %q, want %q", cfg.Channels.Telegram.Token, "tg-token-123") - } - if len(cfg.Channels.Telegram.AllowFrom) != 1 || cfg.Channels.Telegram.AllowFrom[0] != "user1" { - t.Errorf("Telegram.AllowFrom = %v, want [user1]", cfg.Channels.Telegram.AllowFrom) - } - if !cfg.Channels.Discord.Enabled { - t.Error("Discord should be enabled") - } - }) - - t.Run("unsupported channel warning", func(t *testing.T) { - data := map[string]interface{}{ - "channels": map[string]interface{}{ - "email": map[string]interface{}{ - "enabled": true, - }, - }, - } - - _, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 1 { - t.Fatalf("expected 1 warning, got %d", len(warnings)) - } - if warnings[0] != "Channel 'email' not supported in ClawDroid, skipping" { - t.Errorf("unexpected warning: %s", warnings[0]) - } - }) - - t.Run("agent defaults", func(t *testing.T) { - data := map[string]interface{}{ - "agents": map[string]interface{}{ - "defaults": map[string]interface{}{ - "model": "claude-3-opus", - "max_tokens": float64(4096), - "temperature": 0.5, - "max_tool_iterations": float64(10), - "workspace": "~/.openclaw/workspace", - }, - }, - } - - cfg, _, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if cfg.Agents.Defaults.Model != "claude-3-opus" { - t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "claude-3-opus") - } - if cfg.Agents.Defaults.MaxTokens != 4096 { - t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096) - } - if cfg.Agents.Defaults.Temperature != 0.5 { - t.Errorf("Temperature = %f, want %f", cfg.Agents.Defaults.Temperature, 0.5) - } - if cfg.Agents.Defaults.Workspace != "~/.clawdroid/workspace" { - t.Errorf("Workspace = %q, want %q", cfg.Agents.Defaults.Workspace, "~/.clawdroid/workspace") - } - }) - - t.Run("empty config", func(t *testing.T) { - data := map[string]interface{}{} - - cfg, warnings, err := ConvertConfig(data) - if err != nil { - t.Fatalf("ConvertConfig: %v", err) - } - if len(warnings) != 0 { - t.Errorf("expected no warnings, got %v", warnings) - } - if cfg.Agents.Defaults.Model != "glm-4.7" { - t.Errorf("default model should be glm-4.7, got %q", cfg.Agents.Defaults.Model) - } - }) -} - -func TestMergeConfig(t *testing.T) { - t.Run("fills empty fields", func(t *testing.T) { - existing := config.DefaultConfig() - incoming := config.DefaultConfig() - incoming.Providers.Anthropic.APIKey = "sk-ant-incoming" - incoming.Providers.OpenRouter.APIKey = "sk-or-incoming" - - result := MergeConfig(existing, incoming) - if result.Providers.Anthropic.APIKey != "sk-ant-incoming" { - t.Errorf("Anthropic.APIKey = %q, want %q", result.Providers.Anthropic.APIKey, "sk-ant-incoming") - } - if result.Providers.OpenRouter.APIKey != "sk-or-incoming" { - t.Errorf("OpenRouter.APIKey = %q, want %q", result.Providers.OpenRouter.APIKey, "sk-or-incoming") - } - }) - - t.Run("preserves existing non-empty fields", func(t *testing.T) { - existing := config.DefaultConfig() - existing.Providers.Anthropic.APIKey = "sk-ant-existing" - - incoming := config.DefaultConfig() - incoming.Providers.Anthropic.APIKey = "sk-ant-incoming" - incoming.Providers.OpenAI.APIKey = "sk-oai-incoming" - - result := MergeConfig(existing, incoming) - if result.Providers.Anthropic.APIKey != "sk-ant-existing" { - t.Errorf("Anthropic.APIKey should be preserved, got %q", result.Providers.Anthropic.APIKey) - } - if result.Providers.OpenAI.APIKey != "sk-oai-incoming" { - t.Errorf("OpenAI.APIKey should be filled, got %q", result.Providers.OpenAI.APIKey) - } - }) - - t.Run("merges enabled channels", func(t *testing.T) { - existing := config.DefaultConfig() - incoming := config.DefaultConfig() - incoming.Channels.Telegram.Enabled = true - incoming.Channels.Telegram.Token = "tg-token" - - result := MergeConfig(existing, incoming) - if !result.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled after merge") - } - if result.Channels.Telegram.Token != "tg-token" { - t.Errorf("Telegram.Token = %q, want %q", result.Channels.Telegram.Token, "tg-token") - } - }) - - t.Run("preserves existing enabled channels", func(t *testing.T) { - existing := config.DefaultConfig() - existing.Channels.Telegram.Enabled = true - existing.Channels.Telegram.Token = "existing-token" - - incoming := config.DefaultConfig() - incoming.Channels.Telegram.Enabled = true - incoming.Channels.Telegram.Token = "incoming-token" - - result := MergeConfig(existing, incoming) - if result.Channels.Telegram.Token != "existing-token" { - t.Errorf("Telegram.Token should be preserved, got %q", result.Channels.Telegram.Token) - } - }) -} - -func TestPlanWorkspaceMigration(t *testing.T) { - t.Run("copies available files", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(srcDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(srcDir, "USER.md"), []byte("# User"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - copyCount := 0 - skipCount := 0 - for _, a := range actions { - if a.Type == ActionCopy { - copyCount++ - } - if a.Type == ActionSkip { - skipCount++ - } - } - if copyCount != 3 { - t.Errorf("expected 3 copies, got %d", copyCount) - } - if skipCount != 2 { - t.Errorf("expected 2 skips (TOOLS.md, HEARTBEAT.md), got %d", skipCount) - } - }) - - t.Run("plans backup for existing destination files", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing Agents"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - backupCount := 0 - for _, a := range actions { - if a.Type == ActionBackup && filepath.Base(a.Destination) == "AGENTS.md" { - backupCount++ - } - } - if backupCount != 1 { - t.Errorf("expected 1 backup action for AGENTS.md, got %d", backupCount) - } - }) - - t.Run("force skips backup", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - os.WriteFile(filepath.Join(srcDir, "AGENTS.md"), []byte("# Agents"), 0644) - os.WriteFile(filepath.Join(dstDir, "AGENTS.md"), []byte("# Existing"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, true) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - for _, a := range actions { - if a.Type == ActionBackup { - t.Error("expected no backup actions with force=true") - } - } - }) - - t.Run("handles memory directory", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - memDir := filepath.Join(srcDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - hasCopy := false - hasDir := false - for _, a := range actions { - if a.Type == ActionCopy && filepath.Base(a.Source) == "MEMORY.md" { - hasCopy = true - } - if a.Type == ActionCreateDir { - hasDir = true - } - } - if !hasCopy { - t.Error("expected copy action for memory/MEMORY.md") - } - if !hasDir { - t.Error("expected create dir action for memory/") - } - }) - - t.Run("handles skills directory", func(t *testing.T) { - srcDir := t.TempDir() - dstDir := t.TempDir() - - skillDir := filepath.Join(srcDir, "skills", "weather") - os.MkdirAll(skillDir, 0755) - os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte("# Weather"), 0644) - - actions, err := PlanWorkspaceMigration(srcDir, dstDir, false) - if err != nil { - t.Fatalf("PlanWorkspaceMigration: %v", err) - } - - hasCopy := false - for _, a := range actions { - if a.Type == ActionCopy && filepath.Base(a.Source) == "SKILL.md" { - hasCopy = true - } - } - if !hasCopy { - t.Error("expected copy action for skills/weather/SKILL.md") - } - }) -} - -func TestFindOpenClawConfig(t *testing.T) { - t.Run("finds openclaw.json", func(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(configPath, []byte("{}"), 0644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != configPath { - t.Errorf("found %q, want %q", found, configPath) - } - }) - - t.Run("falls back to config.json", func(t *testing.T) { - tmpDir := t.TempDir() - configPath := filepath.Join(tmpDir, "config.json") - os.WriteFile(configPath, []byte("{}"), 0644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != configPath { - t.Errorf("found %q, want %q", found, configPath) - } - }) - - t.Run("prefers openclaw.json over config.json", func(t *testing.T) { - tmpDir := t.TempDir() - openclawPath := filepath.Join(tmpDir, "openclaw.json") - os.WriteFile(openclawPath, []byte("{}"), 0644) - os.WriteFile(filepath.Join(tmpDir, "config.json"), []byte("{}"), 0644) - - found, err := findOpenClawConfig(tmpDir) - if err != nil { - t.Fatalf("findOpenClawConfig: %v", err) - } - if found != openclawPath { - t.Errorf("should prefer openclaw.json, got %q", found) - } - }) - - t.Run("error when no config found", func(t *testing.T) { - tmpDir := t.TempDir() - - _, err := findOpenClawConfig(tmpDir) - if err == nil { - t.Fatal("expected error when no config found") - } - }) -} - -func TestRewriteWorkspacePath(t *testing.T) { - tests := []struct { - name string - input string - want string - }{ - {"default path", "~/.openclaw/workspace", "~/.clawdroid/workspace"}, - {"custom path", "/custom/path", "/custom/path"}, - {"empty", "", ""}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := rewriteWorkspacePath(tt.input) - if got != tt.want { - t.Errorf("rewriteWorkspacePath(%q) = %q, want %q", tt.input, got, tt.want) - } - }) - } -} - -func TestRunDryRun(t *testing.T) { - openclawHome := t.TempDir() - clawDroidHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "test-key", - }, - }, - } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - DryRun: true, - OpenClawHome: openclawHome, - ClawDroidHome: clawDroidHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - clawWs := filepath.Join(clawDroidHome, "workspace") - if _, err := os.Stat(filepath.Join(clawWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("dry run should not create files") - } - if _, err := os.Stat(filepath.Join(clawDroidHome, "config.json")); !os.IsNotExist(err) { - t.Error("dry run should not create config") - } - - _ = result -} - -func TestRunFullMigration(t *testing.T) { - openclawHome := t.TempDir() - clawDroidHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "AGENTS.md"), []byte("# Agents from OpenClaw"), 0644) - os.WriteFile(filepath.Join(wsDir, "USER.md"), []byte("# User from OpenClaw"), 0644) - - memDir := filepath.Join(wsDir, "memory") - os.MkdirAll(memDir, 0755) - os.WriteFile(filepath.Join(memDir, "MEMORY.md"), []byte("# Memory notes"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-ant-migrate-test", - }, - "openrouter": map[string]interface{}{ - "apiKey": "sk-or-migrate-test", - }, - }, - "channels": map[string]interface{}{ - "telegram": map[string]interface{}{ - "enabled": true, - "token": "tg-migrate-test", - }, - }, - } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - Force: true, - OpenClawHome: openclawHome, - ClawDroidHome: clawDroidHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - clawWs := filepath.Join(clawDroidHome, "workspace") - - soulData, err := os.ReadFile(filepath.Join(clawWs, "SOUL.md")) - if err != nil { - t.Fatalf("reading SOUL.md: %v", err) - } - if string(soulData) != "# Soul from OpenClaw" { - t.Errorf("SOUL.md content = %q, want %q", string(soulData), "# Soul from OpenClaw") - } - - agentsData, err := os.ReadFile(filepath.Join(clawWs, "AGENTS.md")) - if err != nil { - t.Fatalf("reading AGENTS.md: %v", err) - } - if string(agentsData) != "# Agents from OpenClaw" { - t.Errorf("AGENTS.md content = %q", string(agentsData)) - } - - memData, err := os.ReadFile(filepath.Join(clawWs, "memory", "MEMORY.md")) - if err != nil { - t.Fatalf("reading memory/MEMORY.md: %v", err) - } - if string(memData) != "# Memory notes" { - t.Errorf("MEMORY.md content = %q", string(memData)) - } - - clawConfig, err := config.LoadConfig(filepath.Join(clawDroidHome, "config.json")) - if err != nil { - t.Fatalf("loading ClawDroid config: %v", err) - } - if clawConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" { - t.Errorf("Anthropic.APIKey = %q, want %q", clawConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test") - } - if clawConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" { - t.Errorf("OpenRouter.APIKey = %q, want %q", clawConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test") - } - if !clawConfig.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled") - } - if clawConfig.Channels.Telegram.Token != "tg-migrate-test" { - t.Errorf("Telegram.Token = %q, want %q", clawConfig.Channels.Telegram.Token, "tg-migrate-test") - } - - if result.FilesCopied < 3 { - t.Errorf("expected at least 3 files copied, got %d", result.FilesCopied) - } - if !result.ConfigMigrated { - t.Error("config should have been migrated") - } - if len(result.Errors) > 0 { - t.Errorf("expected no errors, got %v", result.Errors) - } -} - -func TestRunOpenClawNotFound(t *testing.T) { - opts := Options{ - OpenClawHome: "/nonexistent/path/to/openclaw", - ClawDroidHome: t.TempDir(), - } - - _, err := Run(opts) - if err == nil { - t.Fatal("expected error when OpenClaw not found") - } -} - -func TestRunMutuallyExclusiveFlags(t *testing.T) { - opts := Options{ - ConfigOnly: true, - WorkspaceOnly: true, - } - - _, err := Run(opts) - if err == nil { - t.Fatal("expected error for mutually exclusive flags") - } -} - -func TestBackupFile(t *testing.T) { - tmpDir := t.TempDir() - filePath := filepath.Join(tmpDir, "test.md") - os.WriteFile(filePath, []byte("original content"), 0644) - - if err := backupFile(filePath); err != nil { - t.Fatalf("backupFile: %v", err) - } - - bakPath := filePath + ".bak" - bakData, err := os.ReadFile(bakPath) - if err != nil { - t.Fatalf("reading backup: %v", err) - } - if string(bakData) != "original content" { - t.Errorf("backup content = %q, want %q", string(bakData), "original content") - } -} - -func TestCopyFile(t *testing.T) { - tmpDir := t.TempDir() - srcPath := filepath.Join(tmpDir, "src.md") - dstPath := filepath.Join(tmpDir, "dst.md") - - os.WriteFile(srcPath, []byte("file content"), 0644) - - if err := copyFile(srcPath, dstPath); err != nil { - t.Fatalf("copyFile: %v", err) - } - - data, err := os.ReadFile(dstPath) - if err != nil { - t.Fatalf("reading copy: %v", err) - } - if string(data) != "file content" { - t.Errorf("copy content = %q, want %q", string(data), "file content") - } -} - -func TestRunConfigOnly(t *testing.T) { - openclawHome := t.TempDir() - clawDroidHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-config-only", - }, - }, - } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - Force: true, - ConfigOnly: true, - OpenClawHome: openclawHome, - ClawDroidHome: clawDroidHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if !result.ConfigMigrated { - t.Error("config should have been migrated") - } - - clawWs := filepath.Join(clawDroidHome, "workspace") - if _, err := os.Stat(filepath.Join(clawWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("config-only should not copy workspace files") - } -} - -func TestRunWorkspaceOnly(t *testing.T) { - openclawHome := t.TempDir() - clawDroidHome := t.TempDir() - - wsDir := filepath.Join(openclawHome, "workspace") - os.MkdirAll(wsDir, 0755) - os.WriteFile(filepath.Join(wsDir, "SOUL.md"), []byte("# Soul"), 0644) - - configData := map[string]interface{}{ - "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-ws-only", - }, - }, - } - data, _ := json.Marshal(configData) - os.WriteFile(filepath.Join(openclawHome, "openclaw.json"), data, 0644) - - opts := Options{ - Force: true, - WorkspaceOnly: true, - OpenClawHome: openclawHome, - ClawDroidHome: clawDroidHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if result.ConfigMigrated { - t.Error("workspace-only should not migrate config") - } - - clawWs := filepath.Join(clawDroidHome, "workspace") - soulData, err := os.ReadFile(filepath.Join(clawWs, "SOUL.md")) - if err != nil { - t.Fatalf("reading SOUL.md: %v", err) - } - if string(soulData) != "# Soul" { - t.Errorf("SOUL.md content = %q", string(soulData)) - } -} diff --git a/pkg/migrate/workspace.go b/pkg/migrate/workspace.go deleted file mode 100644 index f45748fac..000000000 --- a/pkg/migrate/workspace.go +++ /dev/null @@ -1,106 +0,0 @@ -package migrate - -import ( - "os" - "path/filepath" -) - -var migrateableFiles = []string{ - "AGENTS.md", - "SOUL.md", - "USER.md", - "TOOLS.md", - "HEARTBEAT.md", -} - -var migrateableDirs = []string{ - "memory", - "skills", -} - -func PlanWorkspaceMigration(srcWorkspace, dstWorkspace string, force bool) ([]Action, error) { - var actions []Action - - for _, filename := range migrateableFiles { - src := filepath.Join(srcWorkspace, filename) - dst := filepath.Join(dstWorkspace, filename) - action := planFileCopy(src, dst, force) - if action.Type != ActionSkip || action.Description != "" { - actions = append(actions, action) - } - } - - for _, dirname := range migrateableDirs { - srcDir := filepath.Join(srcWorkspace, dirname) - if _, err := os.Stat(srcDir); os.IsNotExist(err) { - continue - } - dirActions, err := planDirCopy(srcDir, filepath.Join(dstWorkspace, dirname), force) - if err != nil { - return nil, err - } - actions = append(actions, dirActions...) - } - - return actions, nil -} - -func planFileCopy(src, dst string, force bool) Action { - if _, err := os.Stat(src); os.IsNotExist(err) { - return Action{ - Type: ActionSkip, - Source: src, - Destination: dst, - Description: "source file not found", - } - } - - _, dstExists := os.Stat(dst) - if dstExists == nil && !force { - return Action{ - Type: ActionBackup, - Source: src, - Destination: dst, - Description: "destination exists, will backup and overwrite", - } - } - - return Action{ - Type: ActionCopy, - Source: src, - Destination: dst, - Description: "copy file", - } -} - -func planDirCopy(srcDir, dstDir string, force bool) ([]Action, error) { - var actions []Action - - err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - - relPath, err := filepath.Rel(srcDir, path) - if err != nil { - return err - } - - dst := filepath.Join(dstDir, relPath) - - if info.IsDir() { - actions = append(actions, Action{ - Type: ActionCreateDir, - Destination: dst, - Description: "create directory", - }) - return nil - } - - action := planFileCopy(path, dst, force) - actions = append(actions, action) - return nil - }) - - return actions, err -} diff --git a/pkg/providers/anyllm_adapter.go b/pkg/providers/anyllm_adapter.go new file mode 100644 index 000000000..f6d8b2406 --- /dev/null +++ b/pkg/providers/anyllm_adapter.go @@ -0,0 +1,260 @@ +package providers + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + anyllm "github.com/mozilla-ai/any-llm-go" + "github.com/mozilla-ai/any-llm-go/providers/anthropic" + "github.com/mozilla-ai/any-llm-go/providers/deepseek" + "github.com/mozilla-ai/any-llm-go/providers/gemini" + "github.com/mozilla-ai/any-llm-go/providers/groq" + "github.com/mozilla-ai/any-llm-go/providers/llamacpp" + "github.com/mozilla-ai/any-llm-go/providers/llamafile" + "github.com/mozilla-ai/any-llm-go/providers/mistral" + "github.com/mozilla-ai/any-llm-go/providers/ollama" + "github.com/mozilla-ai/any-llm-go/providers/openai" +) + +// AnyLLMAdapter wraps an any-llm-go provider to implement LLMProvider. +type AnyLLMAdapter struct { + provider anyllm.Provider // any-llm-go Provider interface + defaultModel string // e.g. "openai/gpt-5.2-chat-latest" + modelName string // e.g. "gpt-5.2-chat-latest" (passed per-request) +} + +// parseModel splits "provider/model_name" at the first "/". +func parseModel(model string) (providerName, modelName string) { + idx := strings.Index(model, "/") + if idx == -1 { + return "", model + } + return model[:idx], model[idx+1:] +} + +// providerAliases maps convenience names to canonical provider names. +var providerAliases = map[string]string{ + "claude": "anthropic", + "google": "gemini", +} + +// NewAnyLLMAdapter creates an AnyLLMAdapter from a model string (provider/model_name), +// an API key, and an optional base URL override. +func NewAnyLLMAdapter(model, apiKey, baseURL string) (*AnyLLMAdapter, error) { + providerName, modelName := parseModel(model) + + // Apply aliases + if canonical, ok := providerAliases[providerName]; ok { + providerName = canonical + } + + if providerName == "" { + return nil, fmt.Errorf("model must be in provider/model_name format (e.g. openai/gpt-4): %s", model) + } + + p, err := createAnyLLMProvider(providerName, apiKey, baseURL) + if err != nil { + return nil, fmt.Errorf("creating provider %q: %w", providerName, err) + } + + return &AnyLLMAdapter{ + provider: p, + defaultModel: model, + modelName: modelName, + }, nil +} + +// createAnyLLMProvider creates the appropriate any-llm-go provider by name. +func createAnyLLMProvider(name, apiKey, baseURL string) (anyllm.Provider, error) { + var opts []anyllm.Option + if apiKey != "" { + opts = append(opts, anyllm.WithAPIKey(apiKey)) + } + if baseURL != "" { + opts = append(opts, anyllm.WithBaseURL(baseURL)) + } + + switch name { + case "anthropic": + return anthropic.New(opts...) + case "deepseek": + return deepseek.New(opts...) + case "gemini": + return gemini.New(opts...) + case "groq": + return groq.New(opts...) + case "llamacpp": + return llamacpp.New(opts...) + case "llamafile": + return llamafile.New(opts...) + case "mistral": + return mistral.New(opts...) + case "ollama": + return ollama.New(opts...) + case "openai": + return openai.New(opts...) + default: + return nil, fmt.Errorf("unsupported provider %q", name) + } +} + +// Chat implements LLMProvider. +func (a *AnyLLMAdapter) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { + params := anyllm.CompletionParams{ + Model: a.modelName, + Messages: convertMessagesToAnyLLM(messages), + Tools: convertToolsToAnyLLM(tools), + } + + switch v := options["max_tokens"].(type) { + case int: + params.MaxTokens = &v + case float64: + mt := int(v) + params.MaxTokens = &mt + } + if temperature, ok := options["temperature"].(float64); ok { + params.Temperature = &temperature + } + + result, err := a.provider.Completion(ctx, params) + if err != nil { + return nil, err + } + + return convertAnyLLMResult(result), nil +} + +// GetDefaultModel implements LLMProvider. +func (a *AnyLLMAdapter) GetDefaultModel() string { + return a.defaultModel +} + +// convertMessagesToAnyLLM converts internal messages to any-llm-go messages. +func convertMessagesToAnyLLM(messages []Message) []anyllm.Message { + result := make([]anyllm.Message, 0, len(messages)) + for _, msg := range messages { + m := anyllm.Message{ + Role: msg.Role, + } + + // Tool result messages + if msg.ToolCallID != "" { + m.Role = anyllm.RoleTool + m.Content = msg.Content + m.ToolCallID = msg.ToolCallID + result = append(result, m) + continue + } + + // Build content: plain text or multimodal + if len(msg.Media) > 0 { + // Multimodal: text + images as ContentPart slice + var parts []anyllm.ContentPart + if msg.Content != "" { + parts = append(parts, anyllm.ContentPart{ + Type: "text", + Text: msg.Content, + }) + } + for _, mediaURL := range msg.Media { + parts = append(parts, anyllm.ContentPart{ + Type: "image_url", + ImageURL: &anyllm.ImageURL{URL: mediaURL}, + }) + } + m.Content = parts + } else { + m.Content = msg.Content + } + + // Assistant messages with tool calls + if len(msg.ToolCalls) > 0 { + m.ToolCalls = make([]anyllm.ToolCall, 0, len(msg.ToolCalls)) + for _, tc := range msg.ToolCalls { + name := "" + args := "" + if tc.Function != nil { + name = tc.Function.Name + args = tc.Function.Arguments + } + if name == "" { + name = tc.Name + } + if args == "" && tc.Arguments != nil { + argsJSON, _ := json.Marshal(tc.Arguments) + args = string(argsJSON) + } + m.ToolCalls = append(m.ToolCalls, anyllm.ToolCall{ + ID: tc.ID, + Type: "function", + Function: anyllm.FunctionCall{ + Name: name, + Arguments: args, + }, + }) + } + } + + result = append(result, m) + } + return result +} + +// convertToolsToAnyLLM converts internal tool definitions to any-llm-go tools. +func convertToolsToAnyLLM(tools []ToolDefinition) []anyllm.Tool { + result := make([]anyllm.Tool, 0, len(tools)) + for _, t := range tools { + result = append(result, anyllm.Tool{ + Type: "function", + Function: anyllm.Function{ + Name: t.Function.Name, + Description: t.Function.Description, + Parameters: t.Function.Parameters, + }, + }) + } + return result +} + +// convertAnyLLMResult converts an any-llm-go ChatCompletion to our LLMResponse. +func convertAnyLLMResult(result *anyllm.ChatCompletion) *LLMResponse { + if len(result.Choices) == 0 { + return &LLMResponse{} + } + + choice := result.Choices[0] + resp := &LLMResponse{ + Content: choice.Message.ContentString(), + FinishReason: choice.FinishReason, + } + + // Convert tool calls + for _, tc := range choice.Message.ToolCalls { + var args map[string]interface{} + _ = json.Unmarshal([]byte(tc.Function.Arguments), &args) + + resp.ToolCalls = append(resp.ToolCalls, ToolCall{ + ID: tc.ID, + Name: tc.Function.Name, + Arguments: args, + Function: &FunctionCall{ + Name: tc.Function.Name, + Arguments: tc.Function.Arguments, + }, + }) + } + + // Convert usage + if result.Usage != nil { + resp.Usage = &UsageInfo{ + PromptTokens: result.Usage.PromptTokens, + CompletionTokens: result.Usage.CompletionTokens, + TotalTokens: result.Usage.TotalTokens, + } + } + + return resp +} diff --git a/pkg/providers/claude_cli_provider.go b/pkg/providers/claude_cli_provider.go deleted file mode 100644 index 58ba3647d..000000000 --- a/pkg/providers/claude_cli_provider.go +++ /dev/null @@ -1,221 +0,0 @@ -package providers - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "strings" -) - -// ClaudeCliProvider implements LLMProvider using the claude CLI as a subprocess. -type ClaudeCliProvider struct { - command string - workspace string -} - -// NewClaudeCliProvider creates a new Claude CLI provider. -func NewClaudeCliProvider(workspace string) *ClaudeCliProvider { - return &ClaudeCliProvider{ - command: "claude", - workspace: workspace, - } -} - -// Chat implements LLMProvider.Chat by executing the claude CLI. -func (p *ClaudeCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - systemPrompt := p.buildSystemPrompt(messages, tools) - prompt := p.messagesToPrompt(messages) - - args := []string{"-p", "--output-format", "json", "--dangerously-skip-permissions", "--no-chrome"} - if systemPrompt != "" { - args = append(args, "--system-prompt", systemPrompt) - } - if model != "" && model != "claude-code" { - args = append(args, "--model", model) - } - args = append(args, "-") // read from stdin - - cmd := exec.CommandContext(ctx, p.command, args...) - if p.workspace != "" { - cmd.Dir = p.workspace - } - cmd.Stdin = bytes.NewReader([]byte(prompt)) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - if stderrStr := stderr.String(); stderrStr != "" { - return nil, fmt.Errorf("claude cli error: %s", stderrStr) - } - return nil, fmt.Errorf("claude cli error: %w", err) - } - - return p.parseClaudeCliResponse(stdout.String()) -} - -// GetDefaultModel returns the default model identifier. -func (p *ClaudeCliProvider) GetDefaultModel() string { - return "claude-code" -} - -// messagesToPrompt converts messages to a CLI-compatible prompt string. -func (p *ClaudeCliProvider) messagesToPrompt(messages []Message) string { - var parts []string - - for _, msg := range messages { - switch msg.Role { - case "system": - // handled via --system-prompt flag - case "user": - parts = append(parts, "User: "+msg.Content) - case "assistant": - parts = append(parts, "Assistant: "+msg.Content) - case "tool": - parts = append(parts, fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) - } - } - - // Simplify single user message - if len(parts) == 1 && strings.HasPrefix(parts[0], "User: ") { - return strings.TrimPrefix(parts[0], "User: ") - } - - return strings.Join(parts, "\n") -} - -// buildSystemPrompt combines system messages and tool definitions. -func (p *ClaudeCliProvider) buildSystemPrompt(messages []Message, tools []ToolDefinition) string { - var parts []string - - for _, msg := range messages { - if msg.Role == "system" { - parts = append(parts, msg.Content) - } - } - - if len(tools) > 0 { - parts = append(parts, p.buildToolsPrompt(tools)) - } - - return strings.Join(parts, "\n\n") -} - -// buildToolsPrompt creates the tool definitions section for the system prompt. -func (p *ClaudeCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - paramsJSON, _ := json.Marshal(tool.Function.Parameters) - sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) - } - sb.WriteString("\n") - } - - return sb.String() -} - -// parseClaudeCliResponse parses the JSON output from the claude CLI. -func (p *ClaudeCliProvider) parseClaudeCliResponse(output string) (*LLMResponse, error) { - var resp claudeCliJSONResponse - if err := json.Unmarshal([]byte(output), &resp); err != nil { - return nil, fmt.Errorf("failed to parse claude cli response: %w", err) - } - - if resp.IsError { - return nil, fmt.Errorf("claude cli returned error: %s", resp.Result) - } - - toolCalls := p.extractToolCalls(resp.Result) - - finishReason := "stop" - content := resp.Result - if len(toolCalls) > 0 { - finishReason = "tool_calls" - content = p.stripToolCallsJSON(resp.Result) - } - - var usage *UsageInfo - if resp.Usage.InputTokens > 0 || resp.Usage.OutputTokens > 0 { - usage = &UsageInfo{ - PromptTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens, - CompletionTokens: resp.Usage.OutputTokens, - TotalTokens: resp.Usage.InputTokens + resp.Usage.CacheCreationInputTokens + resp.Usage.CacheReadInputTokens + resp.Usage.OutputTokens, - } - } - - return &LLMResponse{ - Content: strings.TrimSpace(content), - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, - }, nil -} - -// extractToolCalls delegates to the shared extractToolCallsFromText function. -func (p *ClaudeCliProvider) extractToolCalls(text string) []ToolCall { - return extractToolCallsFromText(text) -} - -// stripToolCallsJSON delegates to the shared stripToolCallsFromText function. -func (p *ClaudeCliProvider) stripToolCallsJSON(text string) string { - return stripToolCallsFromText(text) -} - -// findMatchingBrace finds the index after the closing brace matching the opening brace at pos. -func findMatchingBrace(text string, pos int) int { - depth := 0 - for i := pos; i < len(text); i++ { - if text[i] == '{' { - depth++ - } else if text[i] == '}' { - depth-- - if depth == 0 { - return i + 1 - } - } - } - return pos -} - -// claudeCliJSONResponse represents the JSON output from the claude CLI. -// Matches the real claude CLI v2.x output format. -type claudeCliJSONResponse struct { - Type string `json:"type"` - Subtype string `json:"subtype"` - IsError bool `json:"is_error"` - Result string `json:"result"` - SessionID string `json:"session_id"` - TotalCostUSD float64 `json:"total_cost_usd"` - DurationMS int `json:"duration_ms"` - DurationAPI int `json:"duration_api_ms"` - NumTurns int `json:"num_turns"` - Usage claudeCliUsageInfo `json:"usage"` -} - -// claudeCliUsageInfo represents token usage from the claude CLI response. -type claudeCliUsageInfo struct { - InputTokens int `json:"input_tokens"` - OutputTokens int `json:"output_tokens"` - CacheCreationInputTokens int `json:"cache_creation_input_tokens"` - CacheReadInputTokens int `json:"cache_read_input_tokens"` -} diff --git a/pkg/providers/claude_cli_provider_integration_test.go b/pkg/providers/claude_cli_provider_integration_test.go deleted file mode 100644 index 9d1131ac4..000000000 --- a/pkg/providers/claude_cli_provider_integration_test.go +++ /dev/null @@ -1,126 +0,0 @@ -//go:build integration - -package providers - -import ( - "context" - exec "os/exec" - "strings" - "testing" - "time" -) - -// TestIntegration_RealClaudeCLI tests the ClaudeCliProvider with a real claude CLI. -// Run with: go test -tags=integration ./pkg/providers/... -func TestIntegration_RealClaudeCLI(t *testing.T) { - // Check if claude CLI is available - path, err := exec.LookPath("claude") - if err != nil { - t.Skip("claude CLI not found in PATH, skipping integration test") - } - t.Logf("Using claude CLI at: %s", path) - - p := NewClaudeCliProvider(t.TempDir()) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - resp, err := p.Chat(ctx, []Message{ - {Role: "user", Content: "Respond with only the word 'pong'. Nothing else."}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() with real CLI error = %v", err) - } - - // Verify response structure - if resp.Content == "" { - t.Error("Content is empty") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage == nil { - t.Error("Usage should not be nil from real CLI") - } else { - if resp.Usage.PromptTokens == 0 { - t.Error("PromptTokens should be > 0") - } - if resp.Usage.CompletionTokens == 0 { - t.Error("CompletionTokens should be > 0") - } - t.Logf("Usage: prompt=%d, completion=%d, total=%d", - resp.Usage.PromptTokens, resp.Usage.CompletionTokens, resp.Usage.TotalTokens) - } - - t.Logf("Response content: %q", resp.Content) - - // Loose check - should contain "pong" somewhere (model might capitalize or add punctuation) - if !strings.Contains(strings.ToLower(resp.Content), "pong") { - t.Errorf("Content = %q, expected to contain 'pong'", resp.Content) - } -} - -func TestIntegration_RealClaudeCLI_WithSystemPrompt(t *testing.T) { - if _, err := exec.LookPath("claude"); err != nil { - t.Skip("claude CLI not found in PATH") - } - - p := NewClaudeCliProvider(t.TempDir()) - - ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) - defer cancel() - - resp, err := p.Chat(ctx, []Message{ - {Role: "system", Content: "You are a calculator. Only respond with numbers. No text."}, - {Role: "user", Content: "What is 2+2?"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - t.Logf("Response: %q", resp.Content) - - if !strings.Contains(resp.Content, "4") { - t.Errorf("Content = %q, expected to contain '4'", resp.Content) - } -} - -func TestIntegration_RealClaudeCLI_ParsesRealJSON(t *testing.T) { - if _, err := exec.LookPath("claude"); err != nil { - t.Skip("claude CLI not found in PATH") - } - - // Run claude directly and verify our parser handles real output - cmd := exec.Command("claude", "-p", "--output-format", "json", - "--dangerously-skip-permissions", "--no-chrome", "--no-session-persistence", "-") - cmd.Stdin = strings.NewReader("Say hi") - cmd.Dir = t.TempDir() - - output, err := cmd.Output() - if err != nil { - t.Fatalf("claude CLI failed: %v", err) - } - - t.Logf("Raw CLI output: %s", string(output)) - - // Verify our parser can handle real output - p := NewClaudeCliProvider("") - resp, err := p.parseClaudeCliResponse(string(output)) - if err != nil { - t.Fatalf("parseClaudeCliResponse() failed on real CLI output: %v", err) - } - - if resp.Content == "" { - t.Error("parsed Content is empty") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want stop", resp.FinishReason) - } - if resp.Usage == nil { - t.Error("Usage should not be nil") - } - - t.Logf("Parsed: content=%q, finish=%s, usage=%+v", resp.Content, resp.FinishReason, resp.Usage) -} diff --git a/pkg/providers/claude_cli_provider_test.go b/pkg/providers/claude_cli_provider_test.go deleted file mode 100644 index 33c05f38c..000000000 --- a/pkg/providers/claude_cli_provider_test.go +++ /dev/null @@ -1,981 +0,0 @@ -package providers - -import ( - "context" - "fmt" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "github.com/KarakuriAgent/clawdroid/pkg/config" -) - -// --- Compile-time interface check --- - -var _ LLMProvider = (*ClaudeCliProvider)(nil) - -// --- Helper: create mock CLI scripts --- - -// createMockCLI creates a temporary script that simulates the claude CLI. -// Uses files for stdout/stderr to avoid shell quoting issues with JSON. -func createMockCLI(t *testing.T, stdout, stderr string, exitCode int) string { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("mock CLI scripts not supported on Windows") - } - - dir := t.TempDir() - - if stdout != "" { - if err := os.WriteFile(filepath.Join(dir, "stdout.txt"), []byte(stdout), 0644); err != nil { - t.Fatal(err) - } - } - if stderr != "" { - if err := os.WriteFile(filepath.Join(dir, "stderr.txt"), []byte(stderr), 0644); err != nil { - t.Fatal(err) - } - } - - var sb strings.Builder - sb.WriteString("#!/bin/sh\n") - if stderr != "" { - sb.WriteString(fmt.Sprintf("cat '%s/stderr.txt' >&2\n", dir)) - } - if stdout != "" { - sb.WriteString(fmt.Sprintf("cat '%s/stdout.txt'\n", dir)) - } - sb.WriteString(fmt.Sprintf("exit %d\n", exitCode)) - - script := filepath.Join(dir, "claude") - if err := os.WriteFile(script, []byte(sb.String()), 0755); err != nil { - t.Fatal(err) - } - return script -} - -// createSlowMockCLI creates a script that sleeps before responding (for context cancellation tests). -func createSlowMockCLI(t *testing.T, sleepSeconds int) string { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("mock CLI scripts not supported on Windows") - } - - dir := t.TempDir() - script := filepath.Join(dir, "claude") - content := fmt.Sprintf("#!/bin/sh\nsleep %d\necho '{\"type\":\"result\",\"result\":\"late\"}'\n", sleepSeconds) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { - t.Fatal(err) - } - return script -} - -// createArgCaptureCLI creates a script that captures CLI args to a file, then outputs JSON. -func createArgCaptureCLI(t *testing.T, argsFile string) string { - t.Helper() - if runtime.GOOS == "windows" { - t.Skip("mock CLI scripts not supported on Windows") - } - - dir := t.TempDir() - script := filepath.Join(dir, "claude") - content := fmt.Sprintf(`#!/bin/sh -echo "$@" > '%s' -cat <<'EOFMOCK' -{"type":"result","result":"ok","session_id":"test"} -EOFMOCK -`, argsFile) - if err := os.WriteFile(script, []byte(content), 0755); err != nil { - t.Fatal(err) - } - return script -} - -// --- Constructor tests --- - -func TestNewClaudeCliProvider(t *testing.T) { - p := NewClaudeCliProvider("/test/workspace") - if p == nil { - t.Fatal("NewClaudeCliProvider returned nil") - } - if p.workspace != "/test/workspace" { - t.Errorf("workspace = %q, want %q", p.workspace, "/test/workspace") - } - if p.command != "claude" { - t.Errorf("command = %q, want %q", p.command, "claude") - } -} - -func TestNewClaudeCliProvider_EmptyWorkspace(t *testing.T) { - p := NewClaudeCliProvider("") - if p.workspace != "" { - t.Errorf("workspace = %q, want empty", p.workspace) - } -} - -// --- GetDefaultModel tests --- - -func TestClaudeCliProvider_GetDefaultModel(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - if got := p.GetDefaultModel(); got != "claude-code" { - t.Errorf("GetDefaultModel() = %q, want %q", got, "claude-code") - } -} - -// --- Chat() tests --- - -func TestChat_Success(t *testing.T) { - mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Hello from mock!","session_id":"sess_123","total_cost_usd":0.005,"duration_ms":200,"duration_api_ms":150,"num_turns":1,"usage":{"input_tokens":10,"output_tokens":5,"cache_creation_input_tokens":100,"cache_read_input_tokens":0}}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - resp, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - if resp.Content != "Hello from mock!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello from mock!") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if len(resp.ToolCalls) != 0 { - t.Errorf("ToolCalls len = %d, want 0", len(resp.ToolCalls)) - } - if resp.Usage == nil { - t.Fatal("Usage should not be nil") - } - if resp.Usage.PromptTokens != 110 { // 10 + 100 + 0 - t.Errorf("PromptTokens = %d, want 110", resp.Usage.PromptTokens) - } - if resp.Usage.CompletionTokens != 5 { - t.Errorf("CompletionTokens = %d, want 5", resp.Usage.CompletionTokens) - } - if resp.Usage.TotalTokens != 115 { // 110 + 5 - t.Errorf("TotalTokens = %d, want 115", resp.Usage.TotalTokens) - } -} - -func TestChat_IsErrorResponse(t *testing.T) { - mockJSON := `{"type":"result","subtype":"error","is_error":true,"result":"Rate limit exceeded","session_id":"s1","total_cost_usd":0}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error when is_error=true") - } - if !strings.Contains(err.Error(), "Rate limit exceeded") { - t.Errorf("error = %q, want to contain 'Rate limit exceeded'", err.Error()) - } -} - -func TestChat_WithToolCallsInResponse(t *testing.T) { - mockJSON := `{"type":"result","subtype":"success","is_error":false,"result":"Checking weather.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"NYC\\\"}\"}}]}","session_id":"s1","total_cost_usd":0.01,"usage":{"input_tokens":5,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - resp, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "What's the weather?"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - if resp.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") - } - if len(resp.ToolCalls) != 1 { - t.Fatalf("ToolCalls len = %d, want 1", len(resp.ToolCalls)) - } - if resp.ToolCalls[0].Name != "get_weather" { - t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "get_weather") - } - if resp.ToolCalls[0].Arguments["location"] != "NYC" { - t.Errorf("ToolCalls[0].Arguments[location] = %v, want NYC", resp.ToolCalls[0].Arguments["location"]) - } -} - -func TestChat_StderrError(t *testing.T) { - script := createMockCLI(t, "", "Error: rate limited", 1) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error") - } - if !strings.Contains(err.Error(), "rate limited") { - t.Errorf("error = %q, want to contain 'rate limited'", err.Error()) - } -} - -func TestChat_NonZeroExitNoStderr(t *testing.T) { - script := createMockCLI(t, "", "", 1) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error for non-zero exit") - } - if !strings.Contains(err.Error(), "claude cli error") { - t.Errorf("error = %q, want to contain 'claude cli error'", err.Error()) - } -} - -func TestChat_CommandNotFound(t *testing.T) { - p := NewClaudeCliProvider(t.TempDir()) - p.command = "/nonexistent/claude-binary-that-does-not-exist" - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error for missing command") - } -} - -func TestChat_InvalidResponseJSON(t *testing.T) { - script := createMockCLI(t, "not valid json at all", "", 0) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err == nil { - t.Fatal("Chat() expected error for invalid JSON") - } - if !strings.Contains(err.Error(), "failed to parse claude cli response") { - t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) - } -} - -func TestChat_ContextCancellation(t *testing.T) { - script := createSlowMockCLI(t, 2) // sleep 2s - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) - defer cancel() - - start := time.Now() - _, err := p.Chat(ctx, []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - elapsed := time.Since(start) - - if err == nil { - t.Fatal("Chat() expected error on context cancellation") - } - // Should fail well before the full 2s sleep completes - if elapsed > 3*time.Second { - t.Errorf("Chat() took %v, expected to fail faster via context cancellation", elapsed) - } -} - -func TestChat_PassesSystemPromptFlag(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "system", Content: "Be helpful."}, - {Role: "user", Content: "Hi"}, - }, nil, "", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, err := os.ReadFile(argsFile) - if err != nil { - t.Fatalf("failed to read args file: %v", err) - } - args := string(argsBytes) - if !strings.Contains(args, "--system-prompt") { - t.Errorf("CLI args missing --system-prompt, got: %s", args) - } -} - -func TestChat_PassesModelFlag(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hi"}, - }, nil, "claude-sonnet-4-5-20250929", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, _ := os.ReadFile(argsFile) - args := string(argsBytes) - if !strings.Contains(args, "--model") { - t.Errorf("CLI args missing --model, got: %s", args) - } - if !strings.Contains(args, "claude-sonnet-4-5-20250929") { - t.Errorf("CLI args missing model name, got: %s", args) - } -} - -func TestChat_SkipsModelFlagForClaudeCode(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hi"}, - }, nil, "claude-code", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, _ := os.ReadFile(argsFile) - args := string(argsBytes) - if strings.Contains(args, "--model") { - t.Errorf("CLI args should NOT contain --model for claude-code, got: %s", args) - } -} - -func TestChat_SkipsModelFlagForEmptyModel(t *testing.T) { - argsFile := filepath.Join(t.TempDir(), "args.txt") - script := createArgCaptureCLI(t, argsFile) - - p := NewClaudeCliProvider(t.TempDir()) - p.command = script - - _, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hi"}, - }, nil, "", nil) - if err != nil { - t.Fatalf("Chat() error = %v", err) - } - - argsBytes, _ := os.ReadFile(argsFile) - args := string(argsBytes) - if strings.Contains(args, "--model") { - t.Errorf("CLI args should NOT contain --model for empty model, got: %s", args) - } -} - -func TestChat_EmptyWorkspaceDoesNotSetDir(t *testing.T) { - mockJSON := `{"type":"result","result":"ok","session_id":"s"}` - script := createMockCLI(t, mockJSON, "", 0) - - p := NewClaudeCliProvider("") - p.command = script - - resp, err := p.Chat(context.Background(), []Message{ - {Role: "user", Content: "Hello"}, - }, nil, "", nil) - - if err != nil { - t.Fatalf("Chat() with empty workspace error = %v", err) - } - if resp.Content != "ok" { - t.Errorf("Content = %q, want %q", resp.Content, "ok") - } -} - -// --- CreateProvider factory tests --- - -func TestCreateProvider_ClaudeCli(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claude-cli" - cfg.Agents.Defaults.Workspace = "/test/ws" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-cli) error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("CreateProvider(claude-cli) returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "/test/ws" { - t.Errorf("workspace = %q, want %q", cliProvider.workspace, "/test/ws") - } -} - -func TestCreateProvider_ClaudeCode(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claude-code" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claude-code) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claude-code) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCodec(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claudecode" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider(claudecode) error = %v", err) - } - if _, ok := provider.(*ClaudeCliProvider); !ok { - t.Fatalf("CreateProvider(claudecode) returned %T, want *ClaudeCliProvider", provider) - } -} - -func TestCreateProvider_ClaudeCliDefaultWorkspace(t *testing.T) { - cfg := config.DefaultConfig() - cfg.Agents.Defaults.Provider = "claude-cli" - cfg.Agents.Defaults.Workspace = "" - - provider, err := CreateProvider(cfg) - if err != nil { - t.Fatalf("CreateProvider error = %v", err) - } - - cliProvider, ok := provider.(*ClaudeCliProvider) - if !ok { - t.Fatalf("returned %T, want *ClaudeCliProvider", provider) - } - if cliProvider.workspace != "." { - t.Errorf("workspace = %q, want %q (default)", cliProvider.workspace, ".") - } -} - -// --- messagesToPrompt tests --- - -func TestMessagesToPrompt_SingleUser(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "Hello"}, - } - got := p.messagesToPrompt(messages) - want := "Hello" - if got != want { - t.Errorf("messagesToPrompt() = %q, want %q", got, want) - } -} - -func TestMessagesToPrompt_Conversation(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "Hi"}, - {Role: "assistant", Content: "Hello!"}, - {Role: "user", Content: "How are you?"}, - } - got := p.messagesToPrompt(messages) - want := "User: Hi\nAssistant: Hello!\nUser: How are you?" - if got != want { - t.Errorf("messagesToPrompt() = %q, want %q", got, want) - } -} - -func TestMessagesToPrompt_WithSystemMessage(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Hello"}, - } - got := p.messagesToPrompt(messages) - want := "Hello" - if got != want { - t.Errorf("messagesToPrompt() = %q, want %q", got, want) - } -} - -func TestMessagesToPrompt_WithToolResults(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "What's the weather?"}, - {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_123"}, - } - got := p.messagesToPrompt(messages) - if !strings.Contains(got, "[Tool Result for call_123]") { - t.Errorf("messagesToPrompt() missing tool result marker, got %q", got) - } - if !strings.Contains(got, `{"temp": 72}`) { - t.Errorf("messagesToPrompt() missing tool result content, got %q", got) - } -} - -func TestMessagesToPrompt_EmptyMessages(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.messagesToPrompt(nil) - if got != "" { - t.Errorf("messagesToPrompt(nil) = %q, want empty", got) - } -} - -func TestMessagesToPrompt_OnlySystemMessages(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "System 1"}, - {Role: "system", Content: "System 2"}, - } - got := p.messagesToPrompt(messages) - if got != "" { - t.Errorf("messagesToPrompt() with only system msgs = %q, want empty", got) - } -} - -// --- buildSystemPrompt tests --- - -func TestBuildSystemPrompt_NoSystemNoTools(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "user", Content: "Hi"}, - } - got := p.buildSystemPrompt(messages, nil) - if got != "" { - t.Errorf("buildSystemPrompt() = %q, want empty", got) - } -} - -func TestBuildSystemPrompt_SystemOnly(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Hi"}, - } - got := p.buildSystemPrompt(messages, nil) - if got != "You are helpful." { - t.Errorf("buildSystemPrompt() = %q, want %q", got, "You are helpful.") - } -} - -func TestBuildSystemPrompt_MultipleSystemMessages(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "system", Content: "Be concise."}, - {Role: "user", Content: "Hi"}, - } - got := p.buildSystemPrompt(messages, nil) - if !strings.Contains(got, "You are helpful.") { - t.Error("missing first system message") - } - if !strings.Contains(got, "Be concise.") { - t.Error("missing second system message") - } - // Should be joined with double newline - want := "You are helpful.\n\nBe concise." - if got != want { - t.Errorf("buildSystemPrompt() = %q, want %q", got, want) - } -} - -func TestBuildSystemPrompt_WithTools(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - } - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get weather for a location", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "location": map[string]interface{}{"type": "string"}, - }, - }, - }, - }, - } - got := p.buildSystemPrompt(messages, tools) - if !strings.Contains(got, "You are helpful.") { - t.Error("buildSystemPrompt() missing system message") - } - if !strings.Contains(got, "get_weather") { - t.Error("buildSystemPrompt() missing tool definition") - } - if !strings.Contains(got, "Available Tools") { - t.Error("buildSystemPrompt() missing tools header") - } -} - -func TestBuildSystemPrompt_ToolsOnlyNoSystem(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "test_tool", - Description: "A test tool", - }, - }, - } - got := p.buildSystemPrompt(nil, tools) - if !strings.Contains(got, "test_tool") { - t.Error("should include tool definitions even without system messages") - } -} - -// --- buildToolsPrompt tests --- - -func TestBuildToolsPrompt_SkipsNonFunction(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - {Type: "other", Function: ToolFunctionDefinition{Name: "skip_me"}}, - {Type: "function", Function: ToolFunctionDefinition{Name: "include_me", Description: "Included"}}, - } - got := p.buildToolsPrompt(tools) - if strings.Contains(got, "skip_me") { - t.Error("buildToolsPrompt() should skip non-function tools") - } - if !strings.Contains(got, "include_me") { - t.Error("buildToolsPrompt() should include function tools") - } -} - -func TestBuildToolsPrompt_NoDescription(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - {Type: "function", Function: ToolFunctionDefinition{Name: "bare_tool"}}, - } - got := p.buildToolsPrompt(tools) - if !strings.Contains(got, "bare_tool") { - t.Error("should include tool name") - } - if strings.Contains(got, "Description:") { - t.Error("should not include Description: line when empty") - } -} - -func TestBuildToolsPrompt_NoParameters(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - tools := []ToolDefinition{ - {Type: "function", Function: ToolFunctionDefinition{ - Name: "no_params_tool", - Description: "A tool with no parameters", - }}, - } - got := p.buildToolsPrompt(tools) - if strings.Contains(got, "Parameters:") { - t.Error("should not include Parameters: section when nil") - } -} - -// --- parseClaudeCliResponse tests --- - -func TestParseClaudeCliResponse_TextOnly(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"Hello, world!","session_id":"abc123","total_cost_usd":0.01,"duration_ms":500,"usage":{"input_tokens":10,"output_tokens":20,"cache_creation_input_tokens":0,"cache_read_input_tokens":0}}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("parseClaudeCliResponse() error = %v", err) - } - if resp.Content != "Hello, world!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello, world!") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if len(resp.ToolCalls) != 0 { - t.Errorf("ToolCalls = %d, want 0", len(resp.ToolCalls)) - } - if resp.Usage == nil { - t.Fatal("Usage should not be nil") - } - if resp.Usage.PromptTokens != 10 { - t.Errorf("PromptTokens = %d, want 10", resp.Usage.PromptTokens) - } - if resp.Usage.CompletionTokens != 20 { - t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) - } -} - -func TestParseClaudeCliResponse_EmptyResult(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"","session_id":"abc"}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.Content != "" { - t.Errorf("Content = %q, want empty", resp.Content) - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } -} - -func TestParseClaudeCliResponse_IsError(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"error","is_error":true,"result":"Something went wrong","session_id":"abc"}` - - _, err := p.parseClaudeCliResponse(output) - if err == nil { - t.Fatal("expected error when is_error=true") - } - if !strings.Contains(err.Error(), "Something went wrong") { - t.Errorf("error = %q, want to contain 'Something went wrong'", err.Error()) - } -} - -func TestParseClaudeCliResponse_NoUsage(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"hi","session_id":"s"}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.Usage != nil { - t.Errorf("Usage should be nil when no tokens, got %+v", resp.Usage) - } -} - -func TestParseClaudeCliResponse_InvalidJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - _, err := p.parseClaudeCliResponse("not json") - if err == nil { - t.Fatal("expected error for invalid JSON") - } - if !strings.Contains(err.Error(), "failed to parse claude cli response") { - t.Errorf("error = %q, want to contain 'failed to parse claude cli response'", err.Error()) - } -} - -func TestParseClaudeCliResponse_WithToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":"Let me check.\n{\"tool_calls\":[{\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"location\\\":\\\"Tokyo\\\"}\"}}]}","session_id":"abc123","total_cost_usd":0.01}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") - } - if len(resp.ToolCalls) != 1 { - t.Fatalf("ToolCalls = %d, want 1", len(resp.ToolCalls)) - } - tc := resp.ToolCalls[0] - if tc.Name != "get_weather" { - t.Errorf("Name = %q, want %q", tc.Name, "get_weather") - } - if tc.Function == nil { - t.Fatal("Function is nil") - } - if tc.Function.Name != "get_weather" { - t.Errorf("Function.Name = %q, want %q", tc.Function.Name, "get_weather") - } - if tc.Arguments["location"] != "Tokyo" { - t.Errorf("Arguments[location] = %v, want Tokyo", tc.Arguments["location"]) - } - if strings.Contains(resp.Content, "tool_calls") { - t.Errorf("Content should not contain tool_calls JSON, got %q", resp.Content) - } - if resp.Content != "Let me check." { - t.Errorf("Content = %q, want %q", resp.Content, "Let me check.") - } -} - -func TestParseClaudeCliResponse_WhitespaceResult(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - output := `{"type":"result","subtype":"success","is_error":false,"result":" hello \n ","session_id":"s"}` - - resp, err := p.parseClaudeCliResponse(output) - if err != nil { - t.Fatalf("error = %v", err) - } - if resp.Content != "hello" { - t.Errorf("Content = %q, want %q (should be trimmed)", resp.Content, "hello") - } -} - -// --- extractToolCalls tests --- - -func TestExtractToolCalls_NoToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls("Just a regular response.") - if len(got) != 0 { - t.Errorf("extractToolCalls() = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_WithToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `Here's the result: -{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 1 { - t.Fatalf("extractToolCalls() = %d, want 1", len(got)) - } - if got[0].ID != "call_1" { - t.Errorf("ID = %q, want %q", got[0].ID, "call_1") - } - if got[0].Name != "test" { - t.Errorf("Name = %q, want %q", got[0].Name, "test") - } - if got[0].Type != "function" { - t.Errorf("Type = %q, want %q", got[0].Type, "function") - } -} - -func TestExtractToolCalls_InvalidJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls(`{"tool_calls":invalid}`) - if len(got) != 0 { - t.Errorf("extractToolCalls() with invalid JSON = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_MultipleToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"/tmp/out\",\"content\":\"hello\"}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 2 { - t.Fatalf("extractToolCalls() = %d, want 2", len(got)) - } - if got[0].Name != "read_file" { - t.Errorf("[0].Name = %q, want %q", got[0].Name, "read_file") - } - if got[1].Name != "write_file" { - t.Errorf("[1].Name = %q, want %q", got[1].Name, "write_file") - } - // Verify arguments were parsed - if got[0].Arguments["path"] != "/tmp/test" { - t.Errorf("[0].Arguments[path] = %v, want /tmp/test", got[0].Arguments["path"]) - } - if got[1].Arguments["content"] != "hello" { - t.Errorf("[1].Arguments[content] = %v, want hello", got[1].Arguments["content"]) - } -} - -func TestExtractToolCalls_UnmatchedBrace(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - got := p.extractToolCalls(`{"tool_calls":[{"id":"call_1"`) - if len(got) != 0 { - t.Errorf("extractToolCalls() with unmatched brace = %d, want 0", len(got)) - } -} - -func TestExtractToolCalls_ToolCallArgumentsParsing(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{\"num\":42,\"flag\":true,\"name\":\"test\"}"}}]}` - - got := p.extractToolCalls(text) - if len(got) != 1 { - t.Fatalf("len = %d, want 1", len(got)) - } - // Verify different argument types - if got[0].Arguments["num"] != float64(42) { - t.Errorf("Arguments[num] = %v (%T), want 42", got[0].Arguments["num"], got[0].Arguments["num"]) - } - if got[0].Arguments["flag"] != true { - t.Errorf("Arguments[flag] = %v, want true", got[0].Arguments["flag"]) - } - if got[0].Arguments["name"] != "test" { - t.Errorf("Arguments[name] = %v, want test", got[0].Arguments["name"]) - } - // Verify raw arguments string is preserved in FunctionCall - if got[0].Function.Arguments == "" { - t.Error("Function.Arguments should contain raw JSON string") - } -} - -// --- stripToolCallsJSON tests --- - -func TestStripToolCallsJSON(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `Let me check the weather. -{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"test","arguments":"{}"}}]} -Done.` - - got := p.stripToolCallsJSON(text) - if strings.Contains(got, "tool_calls") { - t.Errorf("should remove tool_calls JSON, got %q", got) - } - if !strings.Contains(got, "Let me check the weather.") { - t.Errorf("should keep text before, got %q", got) - } - if !strings.Contains(got, "Done.") { - t.Errorf("should keep text after, got %q", got) - } -} - -func TestStripToolCallsJSON_NoToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := "Just regular text." - got := p.stripToolCallsJSON(text) - if got != text { - t.Errorf("stripToolCallsJSON() = %q, want %q", got, text) - } -} - -func TestStripToolCallsJSON_OnlyToolCalls(t *testing.T) { - p := NewClaudeCliProvider("/workspace") - text := `{"tool_calls":[{"id":"c1","type":"function","function":{"name":"fn","arguments":"{}"}}]}` - got := p.stripToolCallsJSON(text) - if got != "" { - t.Errorf("stripToolCallsJSON() = %q, want empty", got) - } -} - -// --- findMatchingBrace tests --- - -func TestFindMatchingBrace(t *testing.T) { - tests := []struct { - text string - pos int - want int - }{ - {`{"a":1}`, 0, 7}, - {`{"a":{"b":2}}`, 0, 13}, - {`text {"a":1} more`, 5, 12}, - {`{unclosed`, 0, 0}, // no match returns pos - {`{}`, 0, 2}, // empty object - {`{{{}}}`, 0, 6}, // deeply nested - {`{"a":"b{c}d"}`, 0, 13}, // braces in strings (simplified matcher) - } - for _, tt := range tests { - got := findMatchingBrace(tt.text, tt.pos) - if got != tt.want { - t.Errorf("findMatchingBrace(%q, %d) = %d, want %d", tt.text, tt.pos, got, tt.want) - } - } -} diff --git a/pkg/providers/claude_provider.go b/pkg/providers/claude_provider.go deleted file mode 100644 index c435e7b4b..000000000 --- a/pkg/providers/claude_provider.go +++ /dev/null @@ -1,207 +0,0 @@ -package providers - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/anthropics/anthropic-sdk-go" - "github.com/anthropics/anthropic-sdk-go/option" - "github.com/KarakuriAgent/clawdroid/pkg/auth" -) - -type ClaudeProvider struct { - client *anthropic.Client - tokenSource func() (string, error) -} - -func NewClaudeProvider(token string) *ClaudeProvider { - client := anthropic.NewClient( - option.WithAuthToken(token), - option.WithBaseURL("https://api.anthropic.com"), - ) - return &ClaudeProvider{client: &client} -} - -func NewClaudeProviderWithTokenSource(token string, tokenSource func() (string, error)) *ClaudeProvider { - p := NewClaudeProvider(token) - p.tokenSource = tokenSource - return p -} - -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) - 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 -} - -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), - }, - } -} - -func createClaudeTokenSource() func() (string, error) { - return func() (string, error) { - cred, err := auth.GetCredential("anthropic") - if err != nil { - return "", fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return "", fmt.Errorf("no credentials for anthropic. Run: clawdroid auth login --provider anthropic") - } - return cred.AccessToken, nil - } -} diff --git a/pkg/providers/claude_provider_test.go b/pkg/providers/claude_provider_test.go deleted file mode 100644 index bbad2d269..000000000 --- a/pkg/providers/claude_provider_test.go +++ /dev/null @@ -1,210 +0,0 @@ -package providers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/anthropics/anthropic-sdk-go" - anthropicoption "github.com/anthropics/anthropic-sdk-go/option" -) - -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" { - 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 := NewClaudeProvider("test-token") - provider.client = 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 TestClaudeProvider_GetDefaultModel(t *testing.T) { - p := NewClaudeProvider("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/codex_cli_credentials.go b/pkg/providers/codex_cli_credentials.go deleted file mode 100644 index 7ad39ce8e..000000000 --- a/pkg/providers/codex_cli_credentials.go +++ /dev/null @@ -1,79 +0,0 @@ -package providers - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" -) - -// CodexCliAuth represents the ~/.codex/auth.json file structure. -type CodexCliAuth struct { - Tokens struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - AccountID string `json:"account_id"` - } `json:"tokens"` -} - -// ReadCodexCliCredentials reads OAuth tokens from the Codex CLI's auth.json file. -// Expiry is estimated as file modification time + 1 hour (same approach as moltbot). -func ReadCodexCliCredentials() (accessToken, accountID string, expiresAt time.Time, err error) { - authPath, err := resolveCodexAuthPath() - if err != nil { - return "", "", time.Time{}, err - } - - data, err := os.ReadFile(authPath) - if err != nil { - return "", "", time.Time{}, fmt.Errorf("reading %s: %w", authPath, err) - } - - var auth CodexCliAuth - if err := json.Unmarshal(data, &auth); err != nil { - return "", "", time.Time{}, fmt.Errorf("parsing %s: %w", authPath, err) - } - - if auth.Tokens.AccessToken == "" { - return "", "", time.Time{}, fmt.Errorf("no access_token in %s", authPath) - } - - stat, err := os.Stat(authPath) - if err != nil { - expiresAt = time.Now().Add(time.Hour) - } else { - expiresAt = stat.ModTime().Add(time.Hour) - } - - return auth.Tokens.AccessToken, auth.Tokens.AccountID, expiresAt, nil -} - -// CreateCodexCliTokenSource creates a token source that reads from ~/.codex/auth.json. -// This allows the existing CodexProvider to reuse Codex CLI credentials. -func CreateCodexCliTokenSource() func() (string, string, error) { - return func() (string, string, error) { - token, accountID, expiresAt, err := ReadCodexCliCredentials() - if err != nil { - return "", "", fmt.Errorf("reading codex cli credentials: %w", err) - } - - if time.Now().After(expiresAt) { - return "", "", fmt.Errorf("codex cli credentials expired (auth.json last modified > 1h ago). Run: codex login") - } - - return token, accountID, nil - } -} - -func resolveCodexAuthPath() (string, error) { - codexHome := os.Getenv("CODEX_HOME") - if codexHome == "" { - home, err := os.UserHomeDir() - if err != nil { - return "", fmt.Errorf("getting home dir: %w", err) - } - codexHome = filepath.Join(home, ".codex") - } - return filepath.Join(codexHome, "auth.json"), nil -} diff --git a/pkg/providers/codex_cli_credentials_test.go b/pkg/providers/codex_cli_credentials_test.go deleted file mode 100644 index 3267f2d16..000000000 --- a/pkg/providers/codex_cli_credentials_test.go +++ /dev/null @@ -1,181 +0,0 @@ -package providers - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestReadCodexCliCredentials_Valid(t *testing.T) { - tmpDir := t.TempDir() - authPath := filepath.Join(tmpDir, "auth.json") - - authJSON := `{ - "tokens": { - "access_token": "test-access-token", - "refresh_token": "test-refresh-token", - "account_id": "org-test123" - } - }` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { - t.Fatal(err) - } - - t.Setenv("CODEX_HOME", tmpDir) - - token, accountID, expiresAt, err := ReadCodexCliCredentials() - if err != nil { - t.Fatalf("ReadCodexCliCredentials() error: %v", err) - } - if token != "test-access-token" { - t.Errorf("token = %q, want %q", token, "test-access-token") - } - if accountID != "org-test123" { - t.Errorf("accountID = %q, want %q", accountID, "org-test123") - } - // Expiry should be within ~1 hour from now (file was just written) - if expiresAt.Before(time.Now()) { - t.Errorf("expiresAt = %v, should be in the future", expiresAt) - } - if expiresAt.After(time.Now().Add(2 * time.Hour)) { - t.Errorf("expiresAt = %v, should be within ~1 hour", expiresAt) - } -} - -func TestReadCodexCliCredentials_MissingFile(t *testing.T) { - tmpDir := t.TempDir() - t.Setenv("CODEX_HOME", tmpDir) - - _, _, _, err := ReadCodexCliCredentials() - if err == nil { - t.Fatal("expected error for missing auth.json") - } -} - -func TestReadCodexCliCredentials_EmptyToken(t *testing.T) { - tmpDir := t.TempDir() - authPath := filepath.Join(tmpDir, "auth.json") - - authJSON := `{"tokens": {"access_token": "", "refresh_token": "r", "account_id": "a"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { - t.Fatal(err) - } - - t.Setenv("CODEX_HOME", tmpDir) - - _, _, _, err := ReadCodexCliCredentials() - if err == nil { - t.Fatal("expected error for empty access_token") - } -} - -func TestReadCodexCliCredentials_InvalidJSON(t *testing.T) { - tmpDir := t.TempDir() - authPath := filepath.Join(tmpDir, "auth.json") - - if err := os.WriteFile(authPath, []byte("not json"), 0600); err != nil { - t.Fatal(err) - } - - t.Setenv("CODEX_HOME", tmpDir) - - _, _, _, err := ReadCodexCliCredentials() - if err == nil { - t.Fatal("expected error for invalid JSON") - } -} - -func TestReadCodexCliCredentials_NoAccountID(t *testing.T) { - tmpDir := t.TempDir() - authPath := filepath.Join(tmpDir, "auth.json") - - authJSON := `{"tokens": {"access_token": "tok123", "refresh_token": "ref456"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { - t.Fatal(err) - } - - t.Setenv("CODEX_HOME", tmpDir) - - token, accountID, _, err := ReadCodexCliCredentials() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if token != "tok123" { - t.Errorf("token = %q, want %q", token, "tok123") - } - if accountID != "" { - t.Errorf("accountID = %q, want empty", accountID) - } -} - -func TestReadCodexCliCredentials_CodexHomeEnv(t *testing.T) { - tmpDir := t.TempDir() - customDir := filepath.Join(tmpDir, "custom-codex") - if err := os.MkdirAll(customDir, 0755); err != nil { - t.Fatal(err) - } - - authJSON := `{"tokens": {"access_token": "custom-token", "refresh_token": "r"}}` - if err := os.WriteFile(filepath.Join(customDir, "auth.json"), []byte(authJSON), 0600); err != nil { - t.Fatal(err) - } - - t.Setenv("CODEX_HOME", customDir) - - token, _, _, err := ReadCodexCliCredentials() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if token != "custom-token" { - t.Errorf("token = %q, want %q", token, "custom-token") - } -} - -func TestCreateCodexCliTokenSource_Valid(t *testing.T) { - tmpDir := t.TempDir() - authPath := filepath.Join(tmpDir, "auth.json") - - authJSON := `{"tokens": {"access_token": "fresh-token", "refresh_token": "r", "account_id": "acc"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { - t.Fatal(err) - } - - t.Setenv("CODEX_HOME", tmpDir) - - source := CreateCodexCliTokenSource() - token, accountID, err := source() - if err != nil { - t.Fatalf("token source error: %v", err) - } - if token != "fresh-token" { - t.Errorf("token = %q, want %q", token, "fresh-token") - } - if accountID != "acc" { - t.Errorf("accountID = %q, want %q", accountID, "acc") - } -} - -func TestCreateCodexCliTokenSource_Expired(t *testing.T) { - tmpDir := t.TempDir() - authPath := filepath.Join(tmpDir, "auth.json") - - authJSON := `{"tokens": {"access_token": "old-token", "refresh_token": "r"}}` - if err := os.WriteFile(authPath, []byte(authJSON), 0600); err != nil { - t.Fatal(err) - } - - // Set file modification time to 2 hours ago - oldTime := time.Now().Add(-2 * time.Hour) - if err := os.Chtimes(authPath, oldTime, oldTime); err != nil { - t.Fatal(err) - } - - t.Setenv("CODEX_HOME", tmpDir) - - source := CreateCodexCliTokenSource() - _, _, err := source() - if err == nil { - t.Fatal("expected error for expired credentials") - } -} diff --git a/pkg/providers/codex_cli_provider.go b/pkg/providers/codex_cli_provider.go deleted file mode 100644 index 8886406b4..000000000 --- a/pkg/providers/codex_cli_provider.go +++ /dev/null @@ -1,251 +0,0 @@ -package providers - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "os/exec" - "strings" -) - -// CodexCliProvider implements LLMProvider by wrapping the codex CLI as a subprocess. -type CodexCliProvider struct { - command string - workspace string -} - -// NewCodexCliProvider creates a new Codex CLI provider. -func NewCodexCliProvider(workspace string) *CodexCliProvider { - return &CodexCliProvider{ - command: "codex", - workspace: workspace, - } -} - -// Chat implements LLMProvider.Chat by executing the codex CLI in non-interactive mode. -func (p *CodexCliProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - if p.command == "" { - return nil, fmt.Errorf("codex command not configured") - } - - prompt := p.buildPrompt(messages, tools) - - args := []string{ - "exec", - "--json", - "--dangerously-bypass-approvals-and-sandbox", - "--skip-git-repo-check", - "--color", "never", - } - if model != "" && model != "codex-cli" { - args = append(args, "-m", model) - } - if p.workspace != "" { - args = append(args, "-C", p.workspace) - } - args = append(args, "-") // read prompt from stdin - - cmd := exec.CommandContext(ctx, p.command, args...) - cmd.Stdin = bytes.NewReader([]byte(prompt)) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - // Parse JSONL from stdout even if exit code is non-zero, - // because codex writes diagnostic noise to stderr (e.g. rollout errors) - // but still produces valid JSONL output. - if stdoutStr := stdout.String(); stdoutStr != "" { - resp, parseErr := p.parseJSONLEvents(stdoutStr) - if parseErr == nil && resp != nil && (resp.Content != "" || len(resp.ToolCalls) > 0) { - return resp, nil - } - } - - if err != nil { - if ctx.Err() == context.Canceled { - return nil, ctx.Err() - } - if stderrStr := stderr.String(); stderrStr != "" { - return nil, fmt.Errorf("codex cli error: %s", stderrStr) - } - return nil, fmt.Errorf("codex cli error: %w", err) - } - - return p.parseJSONLEvents(stdout.String()) -} - -// GetDefaultModel returns the default model identifier. -func (p *CodexCliProvider) GetDefaultModel() string { - return "codex-cli" -} - -// buildPrompt converts messages to a prompt string for the Codex CLI. -// System messages are prepended as instructions since Codex CLI has no --system-prompt flag. -func (p *CodexCliProvider) buildPrompt(messages []Message, tools []ToolDefinition) string { - var systemParts []string - var conversationParts []string - - for _, msg := range messages { - switch msg.Role { - case "system": - systemParts = append(systemParts, msg.Content) - case "user": - conversationParts = append(conversationParts, msg.Content) - case "assistant": - conversationParts = append(conversationParts, "Assistant: "+msg.Content) - case "tool": - conversationParts = append(conversationParts, - fmt.Sprintf("[Tool Result for %s]: %s", msg.ToolCallID, msg.Content)) - } - } - - var sb strings.Builder - - if len(systemParts) > 0 { - sb.WriteString("## System Instructions\n\n") - sb.WriteString(strings.Join(systemParts, "\n\n")) - sb.WriteString("\n\n## Task\n\n") - } - - if len(tools) > 0 { - sb.WriteString(p.buildToolsPrompt(tools)) - sb.WriteString("\n\n") - } - - // Simplify single user message (no prefix) - if len(conversationParts) == 1 && len(systemParts) == 0 && len(tools) == 0 { - return conversationParts[0] - } - - sb.WriteString(strings.Join(conversationParts, "\n")) - return sb.String() -} - -// buildToolsPrompt creates a tool definitions section for the prompt. -func (p *CodexCliProvider) buildToolsPrompt(tools []ToolDefinition) string { - var sb strings.Builder - - sb.WriteString("## Available Tools\n\n") - sb.WriteString("When you need to use a tool, respond with ONLY a JSON object:\n\n") - sb.WriteString("```json\n") - sb.WriteString(`{"tool_calls":[{"id":"call_xxx","type":"function","function":{"name":"tool_name","arguments":"{...}"}}]}`) - sb.WriteString("\n```\n\n") - sb.WriteString("CRITICAL: The 'arguments' field MUST be a JSON-encoded STRING.\n\n") - sb.WriteString("### Tool Definitions:\n\n") - - for _, tool := range tools { - if tool.Type != "function" { - continue - } - sb.WriteString(fmt.Sprintf("#### %s\n", tool.Function.Name)) - if tool.Function.Description != "" { - sb.WriteString(fmt.Sprintf("Description: %s\n", tool.Function.Description)) - } - if len(tool.Function.Parameters) > 0 { - paramsJSON, _ := json.Marshal(tool.Function.Parameters) - sb.WriteString(fmt.Sprintf("Parameters:\n```json\n%s\n```\n", string(paramsJSON))) - } - sb.WriteString("\n") - } - - return sb.String() -} - -// codexEvent represents a single JSONL event from `codex exec --json`. -type codexEvent struct { - Type string `json:"type"` - ThreadID string `json:"thread_id,omitempty"` - Message string `json:"message,omitempty"` - Item *codexEventItem `json:"item,omitempty"` - Usage *codexUsage `json:"usage,omitempty"` - Error *codexEventErr `json:"error,omitempty"` -} - -type codexEventItem struct { - ID string `json:"id"` - Type string `json:"type"` - Text string `json:"text,omitempty"` - Command string `json:"command,omitempty"` - Status string `json:"status,omitempty"` - ExitCode *int `json:"exit_code,omitempty"` - Output string `json:"output,omitempty"` -} - -type codexUsage struct { - InputTokens int `json:"input_tokens"` - CachedInputTokens int `json:"cached_input_tokens"` - OutputTokens int `json:"output_tokens"` -} - -type codexEventErr struct { - Message string `json:"message"` -} - -// parseJSONLEvents processes the JSONL output from codex exec --json. -func (p *CodexCliProvider) parseJSONLEvents(output string) (*LLMResponse, error) { - var contentParts []string - var usage *UsageInfo - var lastError string - - scanner := bufio.NewScanner(strings.NewReader(output)) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" { - continue - } - - var event codexEvent - if err := json.Unmarshal([]byte(line), &event); err != nil { - continue // skip malformed lines - } - - switch event.Type { - case "item.completed": - if event.Item != nil && event.Item.Type == "agent_message" && event.Item.Text != "" { - contentParts = append(contentParts, event.Item.Text) - } - case "turn.completed": - if event.Usage != nil { - promptTokens := event.Usage.InputTokens + event.Usage.CachedInputTokens - usage = &UsageInfo{ - PromptTokens: promptTokens, - CompletionTokens: event.Usage.OutputTokens, - TotalTokens: promptTokens + event.Usage.OutputTokens, - } - } - case "error": - lastError = event.Message - case "turn.failed": - if event.Error != nil { - lastError = event.Error.Message - } - } - } - - if lastError != "" && len(contentParts) == 0 { - return nil, fmt.Errorf("codex cli: %s", lastError) - } - - content := strings.Join(contentParts, "\n") - - // Extract tool calls from response text (same pattern as ClaudeCliProvider) - toolCalls := extractToolCallsFromText(content) - - finishReason := "stop" - if len(toolCalls) > 0 { - finishReason = "tool_calls" - content = stripToolCallsFromText(content) - } - - return &LLMResponse{ - Content: strings.TrimSpace(content), - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, - }, nil -} diff --git a/pkg/providers/codex_cli_provider_test.go b/pkg/providers/codex_cli_provider_test.go deleted file mode 100644 index 567597cd8..000000000 --- a/pkg/providers/codex_cli_provider_test.go +++ /dev/null @@ -1,585 +0,0 @@ -package providers - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -// --- JSONL Event Parsing Tests --- - -func TestParseJSONLEvents_AgentMessage(t *testing.T) { - p := &CodexCliProvider{} - events := `{"type":"thread.started","thread_id":"abc-123"} -{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Hello from Codex!"}} -{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":50,"output_tokens":20}}` - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("parseJSONLEvents() error: %v", err) - } - if resp.Content != "Hello from Codex!" { - t.Errorf("Content = %q, want %q", resp.Content, "Hello from Codex!") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage == nil { - t.Fatal("Usage should not be nil") - } - if resp.Usage.PromptTokens != 150 { - t.Errorf("PromptTokens = %d, want 150", resp.Usage.PromptTokens) - } - if resp.Usage.CompletionTokens != 20 { - t.Errorf("CompletionTokens = %d, want 20", resp.Usage.CompletionTokens) - } - if resp.Usage.TotalTokens != 170 { - t.Errorf("TotalTokens = %d, want 170", resp.Usage.TotalTokens) - } - if len(resp.ToolCalls) != 0 { - t.Errorf("ToolCalls should be empty, got %d", len(resp.ToolCalls)) - } -} - -func TestParseJSONLEvents_ToolCallExtraction(t *testing.T) { - p := &CodexCliProvider{} - toolCallText := `Let me read that file. -{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"/tmp/test.txt\"}"}}]}` - // Build valid JSONL by marshaling the event - item := codexEvent{ - Type: "item.completed", - Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, - } - itemJSON, _ := json.Marshal(item) - usageEvt := `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":0,"output_tokens":20}}` - events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + usageEvt - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("parseJSONLEvents() error: %v", err) - } - if resp.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") - } - if len(resp.ToolCalls) != 1 { - t.Fatalf("ToolCalls count = %d, want 1", len(resp.ToolCalls)) - } - if resp.ToolCalls[0].Name != "read_file" { - t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") - } - if resp.ToolCalls[0].ID != "call_1" { - t.Errorf("ToolCalls[0].ID = %q, want %q", resp.ToolCalls[0].ID, "call_1") - } - if resp.ToolCalls[0].Function.Arguments != `{"path":"/tmp/test.txt"}` { - t.Errorf("ToolCalls[0].Function.Arguments = %q", resp.ToolCalls[0].Function.Arguments) - } - // Content should have the tool call JSON stripped - if strings.Contains(resp.Content, "tool_calls") { - t.Errorf("Content should not contain tool_calls JSON, got: %q", resp.Content) - } -} - -func TestParseJSONLEvents_MultipleToolCalls(t *testing.T) { - p := &CodexCliProvider{} - toolCallText := `{"tool_calls":[{"id":"call_1","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"a.txt\"}"}},{"id":"call_2","type":"function","function":{"name":"write_file","arguments":"{\"path\":\"b.txt\",\"content\":\"hello\"}"}}]}` - item := codexEvent{ - Type: "item.completed", - Item: &codexEventItem{ID: "item_1", Type: "agent_message", Text: toolCallText}, - } - itemJSON, _ := json.Marshal(item) - events := `{"type":"turn.started"}` + "\n" + string(itemJSON) + "\n" + `{"type":"turn.completed"}` - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("parseJSONLEvents() error: %v", err) - } - if len(resp.ToolCalls) != 2 { - t.Fatalf("ToolCalls count = %d, want 2", len(resp.ToolCalls)) - } - if resp.ToolCalls[0].Name != "read_file" { - t.Errorf("ToolCalls[0].Name = %q, want %q", resp.ToolCalls[0].Name, "read_file") - } - if resp.ToolCalls[1].Name != "write_file" { - t.Errorf("ToolCalls[1].Name = %q, want %q", resp.ToolCalls[1].Name, "write_file") - } - if resp.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "tool_calls") - } -} - -func TestParseJSONLEvents_MultipleMessages(t *testing.T) { - p := &CodexCliProvider{} - events := `{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"First part."}} -{"type":"item.completed","item":{"id":"item_2","type":"command_execution","command":"ls","status":"completed"}} -{"type":"item.completed","item":{"id":"item_3","type":"agent_message","text":"Second part."}} -{"type":"turn.completed"}` - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("parseJSONLEvents() error: %v", err) - } - if resp.Content != "First part.\nSecond part." { - t.Errorf("Content = %q, want %q", resp.Content, "First part.\nSecond part.") - } -} - -func TestParseJSONLEvents_ErrorEvent(t *testing.T) { - p := &CodexCliProvider{} - events := `{"type":"thread.started","thread_id":"abc"} -{"type":"turn.started"} -{"type":"error","message":"token expired"} -{"type":"turn.failed","error":{"message":"token expired"}}` - - _, err := p.parseJSONLEvents(events) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "token expired") { - t.Errorf("error = %q, want to contain 'token expired'", err.Error()) - } -} - -func TestParseJSONLEvents_TurnFailed(t *testing.T) { - p := &CodexCliProvider{} - events := `{"type":"turn.started"} -{"type":"turn.failed","error":{"message":"rate limit exceeded"}}` - - _, err := p.parseJSONLEvents(events) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "rate limit exceeded") { - t.Errorf("error = %q, want to contain 'rate limit exceeded'", err.Error()) - } -} - -func TestParseJSONLEvents_ErrorWithContent(t *testing.T) { - p := &CodexCliProvider{} - // If there's an error but also content, return the content (partial success) - events := `{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Partial result."}} -{"type":"error","message":"connection reset"} -{"type":"turn.failed","error":{"message":"connection reset"}}` - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("should not error when content exists: %v", err) - } - if resp.Content != "Partial result." { - t.Errorf("Content = %q, want %q", resp.Content, "Partial result.") - } -} - -func TestParseJSONLEvents_EmptyOutput(t *testing.T) { - p := &CodexCliProvider{} - resp, err := p.parseJSONLEvents("") - if err != nil { - t.Fatalf("empty output should not error: %v", err) - } - if resp.Content != "" { - t.Errorf("Content = %q, want empty", resp.Content) - } -} - -func TestParseJSONLEvents_MalformedLines(t *testing.T) { - p := &CodexCliProvider{} - events := `not json at all -{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Good line."}} -another bad line -{"type":"turn.completed","usage":{"input_tokens":10,"output_tokens":5}}` - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("should skip malformed lines: %v", err) - } - if resp.Content != "Good line." { - t.Errorf("Content = %q, want %q", resp.Content, "Good line.") - } - if resp.Usage == nil || resp.Usage.TotalTokens != 15 { - t.Errorf("Usage.TotalTokens = %v, want 15", resp.Usage) - } -} - -func TestParseJSONLEvents_CommandExecution(t *testing.T) { - p := &CodexCliProvider{} - events := `{"type":"turn.started"} -{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"in_progress"}} -{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"bash -lc ls","status":"completed","exit_code":0,"output":"file1.go\nfile2.go"}} -{"type":"item.completed","item":{"id":"item_2","type":"agent_message","text":"Found 2 files."}} -{"type":"turn.completed"}` - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("parseJSONLEvents() error: %v", err) - } - // command_execution items should be skipped; only agent_message text is returned - if resp.Content != "Found 2 files." { - t.Errorf("Content = %q, want %q", resp.Content, "Found 2 files.") - } -} - -func TestParseJSONLEvents_NoUsage(t *testing.T) { - p := &CodexCliProvider{} - events := `{"type":"turn.started"} -{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"No usage info."}} -{"type":"turn.completed"}` - - resp, err := p.parseJSONLEvents(events) - if err != nil { - t.Fatalf("parseJSONLEvents() error: %v", err) - } - if resp.Usage != nil { - t.Errorf("Usage should be nil when turn.completed has no usage, got %+v", resp.Usage) - } -} - -// --- Prompt Building Tests --- - -func TestBuildPrompt_SystemAsInstructions(t *testing.T) { - p := &CodexCliProvider{} - messages := []Message{ - {Role: "system", Content: "You are helpful."}, - {Role: "user", Content: "Hi there"}, - } - - prompt := p.buildPrompt(messages, nil) - - if !strings.Contains(prompt, "## System Instructions") { - t.Error("prompt should contain '## System Instructions'") - } - if !strings.Contains(prompt, "You are helpful.") { - t.Error("prompt should contain system content") - } - if !strings.Contains(prompt, "## Task") { - t.Error("prompt should contain '## Task'") - } - if !strings.Contains(prompt, "Hi there") { - t.Error("prompt should contain user message") - } -} - -func TestBuildPrompt_NoSystem(t *testing.T) { - p := &CodexCliProvider{} - messages := []Message{ - {Role: "user", Content: "Just a question"}, - } - - prompt := p.buildPrompt(messages, nil) - - if strings.Contains(prompt, "## System Instructions") { - t.Error("prompt should not contain system instructions header") - } - if prompt != "Just a question" { - t.Errorf("prompt = %q, want %q", prompt, "Just a question") - } -} - -func TestBuildPrompt_WithTools(t *testing.T) { - p := &CodexCliProvider{} - messages := []Message{ - {Role: "user", Content: "Get weather"}, - } - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get current weather", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, - }, - }, - }, - }, - } - - prompt := p.buildPrompt(messages, tools) - - if !strings.Contains(prompt, "## Available Tools") { - t.Error("prompt should contain tools section") - } - if !strings.Contains(prompt, "get_weather") { - t.Error("prompt should contain tool name") - } - if !strings.Contains(prompt, "Get current weather") { - t.Error("prompt should contain tool description") - } -} - -func TestBuildPrompt_MultipleMessages(t *testing.T) { - p := &CodexCliProvider{} - messages := []Message{ - {Role: "user", Content: "Hello"}, - {Role: "assistant", Content: "Hi! How can I help?"}, - {Role: "user", Content: "Tell me about Go"}, - } - - prompt := p.buildPrompt(messages, nil) - - if !strings.Contains(prompt, "Hello") { - t.Error("prompt should contain first user message") - } - if !strings.Contains(prompt, "Assistant: Hi! How can I help?") { - t.Error("prompt should contain assistant message with prefix") - } - if !strings.Contains(prompt, "Tell me about Go") { - t.Error("prompt should contain second user message") - } -} - -func TestBuildPrompt_ToolResults(t *testing.T) { - p := &CodexCliProvider{} - messages := []Message{ - {Role: "user", Content: "Weather?"}, - {Role: "tool", Content: `{"temp": 72}`, ToolCallID: "call_1"}, - } - - prompt := p.buildPrompt(messages, nil) - - if !strings.Contains(prompt, "[Tool Result for call_1]") { - t.Error("prompt should contain tool result") - } - if !strings.Contains(prompt, `{"temp": 72}`) { - t.Error("prompt should contain tool result content") - } -} - -func TestBuildPrompt_SystemAndTools(t *testing.T) { - p := &CodexCliProvider{} - messages := []Message{ - {Role: "system", Content: "Be concise."}, - {Role: "user", Content: "Do something"}, - } - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "my_tool", - Description: "A tool", - }, - }, - } - - prompt := p.buildPrompt(messages, tools) - - // System instructions should come first - sysIdx := strings.Index(prompt, "## System Instructions") - toolIdx := strings.Index(prompt, "## Available Tools") - taskIdx := strings.Index(prompt, "## Task") - - if sysIdx == -1 || toolIdx == -1 || taskIdx == -1 { - t.Fatal("prompt should contain all sections") - } - if sysIdx >= taskIdx { - t.Error("system instructions should come before task") - } - if taskIdx >= toolIdx { - t.Error("task section should come before tools in the output") - } -} - -// --- CLI Argument Tests --- - -func TestCodexCliProvider_GetDefaultModel(t *testing.T) { - p := NewCodexCliProvider("") - if got := p.GetDefaultModel(); got != "codex-cli" { - t.Errorf("GetDefaultModel() = %q, want %q", got, "codex-cli") - } -} - -// --- Mock CLI Integration Test --- - -func createMockCodexCLI(t *testing.T, events []string) string { - t.Helper() - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "codex") - - var sb strings.Builder - sb.WriteString("#!/bin/bash\n") - for _, event := range events { - sb.WriteString(fmt.Sprintf("echo '%s'\n", event)) - } - - if err := os.WriteFile(scriptPath, []byte(sb.String()), 0755); err != nil { - t.Fatal(err) - } - return scriptPath -} - -func TestCodexCliProvider_MockCLI_Success(t *testing.T) { - scriptPath := createMockCodexCLI(t, []string{ - `{"type":"thread.started","thread_id":"test-123"}`, - `{"type":"turn.started"}`, - `{"type":"item.completed","item":{"id":"item_1","type":"agent_message","text":"Mock response from Codex CLI"}}`, - `{"type":"turn.completed","usage":{"input_tokens":50,"cached_input_tokens":10,"output_tokens":15}}`, - }) - - p := &CodexCliProvider{ - command: scriptPath, - workspace: "", - } - - messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := p.Chat(context.Background(), messages, nil, "", nil) - if err != nil { - t.Fatalf("Chat() error: %v", err) - } - if resp.Content != "Mock response from Codex CLI" { - t.Errorf("Content = %q, want %q", resp.Content, "Mock response from Codex CLI") - } - if resp.Usage == nil { - t.Fatal("Usage should not be nil") - } - if resp.Usage.PromptTokens != 60 { - t.Errorf("PromptTokens = %d, want 60", resp.Usage.PromptTokens) - } - if resp.Usage.CompletionTokens != 15 { - t.Errorf("CompletionTokens = %d, want 15", resp.Usage.CompletionTokens) - } -} - -func TestCodexCliProvider_MockCLI_Error(t *testing.T) { - scriptPath := createMockCodexCLI(t, []string{ - `{"type":"thread.started","thread_id":"test-err"}`, - `{"type":"turn.started"}`, - `{"type":"error","message":"auth token expired"}`, - `{"type":"turn.failed","error":{"message":"auth token expired"}}`, - }) - - p := &CodexCliProvider{ - command: scriptPath, - workspace: "", - } - - messages := []Message{{Role: "user", Content: "Hello"}} - _, err := p.Chat(context.Background(), messages, nil, "", nil) - if err == nil { - t.Fatal("expected error") - } - if !strings.Contains(err.Error(), "auth token expired") { - t.Errorf("error = %q, want to contain 'auth token expired'", err.Error()) - } -} - -func TestCodexCliProvider_MockCLI_WithModel(t *testing.T) { - // Mock script that captures args to verify model flag is passed - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "codex") - script := `#!/bin/bash -# Write args to a file for verification -echo "$@" > "` + filepath.Join(tmpDir, "args.txt") + `" -echo '{"type":"item.completed","item":{"id":"1","type":"agent_message","text":"ok"}}' -echo '{"type":"turn.completed"}'` - - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatal(err) - } - - p := &CodexCliProvider{ - command: scriptPath, - workspace: "/tmp/test-workspace", - } - - messages := []Message{{Role: "user", Content: "test"}} - _, err := p.Chat(context.Background(), messages, nil, "gpt-5.2-codex", nil) - if err != nil { - t.Fatalf("Chat() error: %v", err) - } - - // Verify the args - argsData, err := os.ReadFile(filepath.Join(tmpDir, "args.txt")) - if err != nil { - t.Fatalf("reading args: %v", err) - } - args := string(argsData) - - if !strings.Contains(args, "-m gpt-5.2-codex") { - t.Errorf("args should contain model flag, got: %s", args) - } - if !strings.Contains(args, "-C /tmp/test-workspace") { - t.Errorf("args should contain workspace flag, got: %s", args) - } - if !strings.Contains(args, "--json") { - t.Errorf("args should contain --json, got: %s", args) - } - if !strings.Contains(args, "--dangerously-bypass-approvals-and-sandbox") { - t.Errorf("args should contain bypass flag, got: %s", args) - } -} - -func TestCodexCliProvider_MockCLI_ContextCancel(t *testing.T) { - // Script that sleeps forever - tmpDir := t.TempDir() - scriptPath := filepath.Join(tmpDir, "codex") - script := "#!/bin/bash\nsleep 60" - - if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { - t.Fatal(err) - } - - p := &CodexCliProvider{ - command: scriptPath, - workspace: "", - } - - ctx, cancel := context.WithCancel(context.Background()) - cancel() // cancel immediately - - messages := []Message{{Role: "user", Content: "test"}} - _, err := p.Chat(ctx, messages, nil, "", nil) - if err == nil { - t.Fatal("expected error on canceled context") - } -} - -func TestCodexCliProvider_EmptyCommand(t *testing.T) { - p := &CodexCliProvider{command: ""} - - messages := []Message{{Role: "user", Content: "test"}} - _, err := p.Chat(context.Background(), messages, nil, "", nil) - if err == nil { - t.Fatal("expected error for empty command") - } -} - -// --- Integration Test (requires real codex CLI with valid auth) --- - -func TestCodexCliProvider_Integration(t *testing.T) { - if os.Getenv("CLAWDROID_INTEGRATION_TESTS") == "" { - t.Skip("skipping integration test (set CLAWDROID_INTEGRATION_TESTS=1 to enable)") - } - - // Verify codex is available - codexPath, err := exec.LookPath("codex") - if err != nil { - t.Skip("codex CLI not found in PATH") - } - - p := &CodexCliProvider{ - command: codexPath, - workspace: "", - } - - messages := []Message{ - {Role: "user", Content: "Respond with just the word 'hello' and nothing else."}, - } - - resp, err := p.Chat(context.Background(), messages, nil, "", nil) - if err != nil { - t.Fatalf("Chat() error: %v", err) - } - - lower := strings.ToLower(strings.TrimSpace(resp.Content)) - if !strings.Contains(lower, "hello") { - t.Errorf("Content = %q, expected to contain 'hello'", resp.Content) - } -} diff --git a/pkg/providers/codex_provider.go b/pkg/providers/codex_provider.go deleted file mode 100644 index c5e11e180..000000000 --- a/pkg/providers/codex_provider.go +++ /dev/null @@ -1,367 +0,0 @@ -package providers - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - - "github.com/openai/openai-go/v3" - "github.com/openai/openai-go/v3/option" - "github.com/openai/openai-go/v3/responses" - "github.com/KarakuriAgent/clawdroid/pkg/auth" - "github.com/KarakuriAgent/clawdroid/pkg/logger" -) - -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) -} - -const defaultCodexInstructions = "You are Codex, a coding assistant." - -func NewCodexProvider(token, accountID string) *CodexProvider { - opts := []option.RequestOption{ - option.WithBaseURL("https://chatgpt.com/backend-api/codex"), - option.WithAPIKey(token), - option.WithHeader("originator", "codex_cli_rs"), - option.WithHeader("OpenAI-Beta", "responses=experimental"), - } - if accountID != "" { - opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) - } - client := openai.NewClient(opts...) - return &CodexProvider{ - client: &client, - accountID: accountID, - } -} - -func NewCodexProviderWithTokenSource(token, accountID string, tokenSource func() (string, string, error)) *CodexProvider { - p := NewCodexProvider(token, accountID) - p.tokenSource = tokenSource - return p -} - -func (p *CodexProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - var opts []option.RequestOption - accountID := p.accountID - resolvedModel, fallbackReason := resolveCodexModel(model) - if fallbackReason != "" { - logger.WarnCF("provider.codex", "Requested model is not compatible with Codex backend, using fallback", map[string]interface{}{ - "requested_model": model, - "resolved_model": resolvedModel, - "reason": fallbackReason, - }) - } - if p.tokenSource != nil { - tok, accID, err := p.tokenSource() - if err != nil { - return nil, fmt.Errorf("refreshing token: %w", err) - } - opts = append(opts, option.WithAPIKey(tok)) - if accID != "" { - accountID = accID - } - } - if accountID != "" { - opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accountID)) - } else { - logger.WarnCF("provider.codex", "No account id found for Codex request; backend may reject with 400", map[string]interface{}{ - "requested_model": model, - "resolved_model": resolvedModel, - }) - } - - params := buildCodexParams(messages, tools, resolvedModel, options) - - stream := p.client.Responses.NewStreaming(ctx, params, opts...) - defer stream.Close() - - var resp *responses.Response - for stream.Next() { - evt := stream.Current() - if evt.Type == "response.completed" || evt.Type == "response.failed" || evt.Type == "response.incomplete" { - evtResp := evt.Response - if evtResp.ID != "" { - copy := evtResp - resp = © - } - } - } - err := stream.Err() - if err != nil { - fields := map[string]interface{}{ - "requested_model": model, - "resolved_model": resolvedModel, - "messages_count": len(messages), - "tools_count": len(tools), - "account_id_present": accountID != "", - "error": err.Error(), - } - var apiErr *openai.Error - if errors.As(err, &apiErr) { - fields["status_code"] = apiErr.StatusCode - fields["api_type"] = apiErr.Type - fields["api_code"] = apiErr.Code - fields["api_param"] = apiErr.Param - fields["api_message"] = apiErr.Message - if apiErr.StatusCode == 400 { - fields["hint"] = "verify account id header and model compatibility for codex backend" - } - if apiErr.Response != nil { - fields["request_id"] = apiErr.Response.Header.Get("x-request-id") - } - } - logger.ErrorCF("provider.codex", "Codex API call failed", fields) - return nil, fmt.Errorf("codex API call: %w", err) - } - if resp == nil { - fields := map[string]interface{}{ - "requested_model": model, - "resolved_model": resolvedModel, - "messages_count": len(messages), - "tools_count": len(tools), - "account_id_present": accountID != "", - } - logger.ErrorCF("provider.codex", "Codex stream ended without completed response event", fields) - return nil, fmt.Errorf("codex API call: stream ended without completed response") - } - - return parseCodexResponse(resp), nil -} - -func (p *CodexProvider) GetDefaultModel() string { - return codexDefaultModel -} - -func resolveCodexModel(model string) (string, string) { - m := strings.ToLower(strings.TrimSpace(model)) - if m == "" { - return codexDefaultModel, "empty model" - } - - if strings.HasPrefix(m, "openai/") { - m = strings.TrimPrefix(m, "openai/") - } else if strings.Contains(m, "/") { - return codexDefaultModel, "non-openai model namespace" - } - - unsupportedPrefixes := []string{ - "glm", - "claude", - "anthropic", - "gemini", - "google", - "moonshot", - "kimi", - "qwen", - "deepseek", - "llama", - "meta-llama", - "mistral", - "grok", - "xai", - "zhipu", - } - for _, prefix := range unsupportedPrefixes { - if strings.HasPrefix(m, prefix) { - return codexDefaultModel, "unsupported model prefix" - } - } - - if strings.HasPrefix(m, "gpt-") || strings.HasPrefix(m, "o3") || strings.HasPrefix(m, "o4") { - return m, "" - } - - return codexDefaultModel, "unsupported model family" -} - -func buildCodexParams(messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) responses.ResponseNewParams { - var inputItems responses.ResponseInputParam - var instructions string - - for _, msg := range messages { - switch msg.Role { - case "system": - instructions = msg.Content - case "user": - if msg.ToolCallID != "" { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ - CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } else { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleUser, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - case "assistant": - if len(msg.ToolCalls) > 0 { - if msg.Content != "" { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleAssistant, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - for _, tc := range msg.ToolCalls { - argsJSON, _ := json.Marshal(tc.Arguments) - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCall: &responses.ResponseFunctionToolCallParam{ - CallID: tc.ID, - Name: tc.Name, - Arguments: string(argsJSON), - }, - }) - } - } else { - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfMessage: &responses.EasyInputMessageParam{ - Role: responses.EasyInputMessageRoleAssistant, - Content: responses.EasyInputMessageContentUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - case "tool": - inputItems = append(inputItems, responses.ResponseInputItemUnionParam{ - OfFunctionCallOutput: &responses.ResponseInputItemFunctionCallOutputParam{ - CallID: msg.ToolCallID, - Output: responses.ResponseInputItemFunctionCallOutputOutputUnionParam{OfString: openai.Opt(msg.Content)}, - }, - }) - } - } - - params := responses.ResponseNewParams{ - Model: model, - Input: responses.ResponseNewParamsInputUnion{ - OfInputItemList: inputItems, - }, - Instructions: openai.Opt(instructions), - Store: openai.Opt(false), - } - - if instructions != "" { - params.Instructions = openai.Opt(instructions) - } else { - // ChatGPT Codex backend requires instructions to be present. - 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) - } - - return params -} - -func translateToolsForCodex(tools []ToolDefinition) []responses.ToolUnionParam { - result := make([]responses.ToolUnionParam, 0, len(tools)) - for _, t := range tools { - ft := responses.FunctionToolParam{ - Name: t.Function.Name, - Parameters: t.Function.Parameters, - Strict: openai.Opt(false), - } - if t.Function.Description != "" { - ft.Description = openai.Opt(t.Function.Description) - } - result = append(result, responses.ToolUnionParam{OfFunction: &ft}) - } - return result -} - -func parseCodexResponse(resp *responses.Response) *LLMResponse { - var content strings.Builder - var toolCalls []ToolCall - - for _, item := range resp.Output { - switch item.Type { - case "message": - for _, c := range item.Content { - if c.Type == "output_text" { - content.WriteString(c.Text) - } - } - case "function_call": - var args map[string]interface{} - if err := json.Unmarshal([]byte(item.Arguments), &args); err != nil { - args = map[string]interface{}{"raw": item.Arguments} - } - toolCalls = append(toolCalls, ToolCall{ - ID: item.CallID, - Name: item.Name, - Arguments: args, - }) - } - } - - finishReason := "stop" - if len(toolCalls) > 0 { - finishReason = "tool_calls" - } - if resp.Status == "incomplete" { - finishReason = "length" - } - - var usage *UsageInfo - if resp.Usage.TotalTokens > 0 { - usage = &UsageInfo{ - PromptTokens: int(resp.Usage.InputTokens), - CompletionTokens: int(resp.Usage.OutputTokens), - TotalTokens: int(resp.Usage.TotalTokens), - } - } - - return &LLMResponse{ - Content: content.String(), - ToolCalls: toolCalls, - FinishReason: finishReason, - Usage: usage, - } -} - -func createCodexTokenSource() func() (string, string, error) { - return func() (string, string, error) { - cred, err := auth.GetCredential("openai") - if err != nil { - return "", "", fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return "", "", fmt.Errorf("no credentials for openai. Run: clawdroid auth login --provider openai") - } - - if cred.AuthMethod == "oauth" && cred.NeedsRefresh() && cred.RefreshToken != "" { - oauthCfg := auth.OpenAIOAuthConfig() - refreshed, err := auth.RefreshAccessToken(cred, oauthCfg) - if err != nil { - return "", "", fmt.Errorf("refreshing token: %w", err) - } - if refreshed.AccountID == "" { - refreshed.AccountID = cred.AccountID - } - if err := auth.SetCredential("openai", refreshed); err != nil { - return "", "", fmt.Errorf("saving refreshed token: %w", err) - } - return refreshed.AccessToken, refreshed.AccountID, nil - } - - return cred.AccessToken, cred.AccountID, nil - } -} diff --git a/pkg/providers/codex_provider_test.go b/pkg/providers/codex_provider_test.go deleted file mode 100644 index 317b1a5de..000000000 --- a/pkg/providers/codex_provider_test.go +++ /dev/null @@ -1,469 +0,0 @@ -package providers - -import ( - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/openai/openai-go/v3" - openaiopt "github.com/openai/openai-go/v3/option" - "github.com/openai/openai-go/v3/responses" -) - -func TestBuildCodexParams_BasicMessage(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "Hello"}, - } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{ - "max_tokens": 2048, - "temperature": 0.7, - }) - if params.Model != "gpt-4o" { - t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") - } - if !params.Instructions.Valid() { - t.Fatal("Instructions should be set") - } - if params.Instructions.Or("") != defaultCodexInstructions { - t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), defaultCodexInstructions) - } -} - -func TestBuildCodexParams_SystemAsInstructions(t *testing.T) { - messages := []Message{ - {Role: "system", Content: "You are helpful"}, - {Role: "user", Content: "Hi"}, - } - params := buildCodexParams(messages, nil, "gpt-4o", map[string]interface{}{}) - if !params.Instructions.Valid() { - t.Fatal("Instructions should be set") - } - if params.Instructions.Or("") != "You are helpful" { - t.Errorf("Instructions = %q, want %q", params.Instructions.Or(""), "You are helpful") - } -} - -func TestBuildCodexParams_ToolCallConversation(t *testing.T) { - messages := []Message{ - {Role: "user", Content: "What's the weather?"}, - { - Role: "assistant", - ToolCalls: []ToolCall{ - {ID: "call_1", Name: "get_weather", Arguments: map[string]interface{}{"city": "SF"}}, - }, - }, - {Role: "tool", Content: `{"temp": 72}`, 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.Errorf("len(Input items) = %d, want 3", len(params.Input.OfInputItemList)) - } -} - -func TestBuildCodexParams_WithTools(t *testing.T) { - tools := []ToolDefinition{ - { - Type: "function", - Function: ToolFunctionDefinition{ - Name: "get_weather", - Description: "Get weather", - Parameters: map[string]interface{}{ - "type": "object", - "properties": map[string]interface{}{ - "city": map[string]interface{}{"type": "string"}, - }, - }, - }, - }, - } - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, tools, "gpt-4o", map[string]interface{}{}) - if len(params.Tools) != 1 { - t.Fatalf("len(Tools) = %d, want 1", len(params.Tools)) - } - if params.Tools[0].OfFunction == nil { - t.Fatal("Tool should be a function tool") - } - if params.Tools[0].OfFunction.Name != "get_weather" { - t.Errorf("Tool name = %q, want %q", params.Tools[0].OfFunction.Name, "get_weather") - } -} - -func TestBuildCodexParams_StoreIsFalse(t *testing.T) { - params := buildCodexParams([]Message{{Role: "user", Content: "Hi"}}, nil, "gpt-4o", map[string]interface{}{}) - if !params.Store.Valid() || params.Store.Or(true) != false { - t.Error("Store should be explicitly set to false") - } -} - -func TestParseCodexResponse_TextOutput(t *testing.T) { - respJSON := `{ - "id": "resp_test", - "object": "response", - "status": "completed", - "output": [ - { - "id": "msg_1", - "type": "message", - "role": "assistant", - "status": "completed", - "content": [ - {"type": "output_text", "text": "Hello there!"} - ] - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 5, - "total_tokens": 15, - "input_tokens_details": {"cached_tokens": 0}, - "output_tokens_details": {"reasoning_tokens": 0} - } - }` - - var resp responses.Response - if err := json.Unmarshal([]byte(respJSON), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - result := parseCodexResponse(&resp) - if result.Content != "Hello there!" { - t.Errorf("Content = %q, want %q", result.Content, "Hello there!") - } - if result.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "stop") - } - if result.Usage.TotalTokens != 15 { - t.Errorf("TotalTokens = %d, want 15", result.Usage.TotalTokens) - } -} - -func TestParseCodexResponse_FunctionCall(t *testing.T) { - respJSON := `{ - "id": "resp_test", - "object": "response", - "status": "completed", - "output": [ - { - "id": "fc_1", - "type": "function_call", - "call_id": "call_abc", - "name": "get_weather", - "arguments": "{\"city\":\"SF\"}", - "status": "completed" - } - ], - "usage": { - "input_tokens": 10, - "output_tokens": 8, - "total_tokens": 18, - "input_tokens_details": {"cached_tokens": 0}, - "output_tokens_details": {"reasoning_tokens": 0} - } - }` - - var resp responses.Response - if err := json.Unmarshal([]byte(respJSON), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - result := parseCodexResponse(&resp) - if len(result.ToolCalls) != 1 { - t.Fatalf("len(ToolCalls) = %d, want 1", len(result.ToolCalls)) - } - tc := result.ToolCalls[0] - if tc.Name != "get_weather" { - t.Errorf("ToolCall.Name = %q, want %q", tc.Name, "get_weather") - } - if tc.ID != "call_abc" { - t.Errorf("ToolCall.ID = %q, want %q", tc.ID, "call_abc") - } - if tc.Arguments["city"] != "SF" { - t.Errorf("ToolCall.Arguments[city] = %v, want SF", tc.Arguments["city"]) - } - if result.FinishReason != "tool_calls" { - t.Errorf("FinishReason = %q, want %q", result.FinishReason, "tool_calls") - } -} - -func TestCodexProvider_ChatRoundTrip(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 - } - if r.Header.Get("Authorization") != "Bearer test-token" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Header.Get("Chatgpt-Account-Id") != "acc-123" { - http.Error(w, "missing account id", http.StatusBadRequest) - 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 reqBody["stream"] != true { - http.Error(w, "stream must be true", 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": 12, - "output_tokens": 6, - "total_tokens": 18, - "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.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{}{"max_tokens": 1024}) - 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!") - } - if resp.FinishReason != "stop" { - t.Errorf("FinishReason = %q, want %q", resp.FinishReason, "stop") - } - if resp.Usage.TotalTokens != 18 { - t.Errorf("TotalTokens = %d, want 18", resp.Usage.TotalTokens) - } -} - -func TestCodexProvider_ChatRoundTrip_TokenSourceFallbackAccountID(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 - } - if r.Header.Get("Authorization") != "Bearer refreshed-token" { - http.Error(w, "unauthorized", http.StatusUnauthorized) - return - } - if r.Header.Get("Chatgpt-Account-Id") != "acc-123" { - http.Error(w, "missing account id", http.StatusBadRequest) - 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["instructions"]; !ok { - http.Error(w, "missing instructions", http.StatusBadRequest) - return - } - if reqBody["instructions"] == "" { - http.Error(w, "instructions must not be empty", http.StatusBadRequest) - return - } - if _, ok := reqBody["temperature"]; ok { - http.Error(w, "temperature is not supported", http.StatusBadRequest) - return - } - if reqBody["stream"] != true { - http.Error(w, "stream must be true", 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": 8, - "output_tokens": 4, - "total_tokens": 12, - "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("stale-token", "acc-123") - provider.client = createOpenAITestClient(server.URL, "stale-token", "") - provider.tokenSource = func() (string, string, error) { - return "refreshed-token", "", nil - } - - messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-4o", map[string]interface{}{"temperature": 0.7}) - 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_ModelFallbackFromUnsupported(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 reqBody["model"] != codexDefaultModel { - http.Error(w, "unsupported model", http.StatusBadRequest) - return - } - if reqBody["stream"] != true { - http.Error(w, "stream must be true", http.StatusBadRequest) - return - } - if reqBody["instructions"] != codexDefaultInstructions { - http.Error(w, "missing default instructions", 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": 8, - "output_tokens": 4, - "total_tokens": 12, - "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.client = createOpenAITestClient(server.URL, "test-token", "acc-123") - - messages := []Message{{Role: "user", Content: "Hello"}} - resp, err := provider.Chat(t.Context(), messages, nil, "gpt-5.2", nil) - 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_GetDefaultModel(t *testing.T) { - p := NewCodexProvider("test-token", "") - if got := p.GetDefaultModel(); got != codexDefaultModel { - t.Errorf("GetDefaultModel() = %q, want %q", got, codexDefaultModel) - } -} - -func TestResolveCodexModel(t *testing.T) { - tests := []struct { - name string - input string - wantModel string - wantFallback bool - }{ - {name: "empty", input: "", wantModel: codexDefaultModel, wantFallback: true}, - {name: "unsupported namespace", input: "anthropic/claude-3.5", wantModel: codexDefaultModel, wantFallback: true}, - {name: "non-openai prefixed", input: "glm-4.7", wantModel: codexDefaultModel, wantFallback: true}, - {name: "openai prefix", input: "openai/gpt-5.2", wantModel: "gpt-5.2", wantFallback: false}, - {name: "direct gpt", input: "gpt-4o", wantModel: "gpt-4o", wantFallback: false}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotModel, reason := resolveCodexModel(tt.input) - if gotModel != tt.wantModel { - t.Fatalf("resolveCodexModel(%q) model = %q, want %q", tt.input, gotModel, tt.wantModel) - } - if tt.wantFallback && reason == "" { - t.Fatalf("resolveCodexModel(%q) expected fallback reason", tt.input) - } - if !tt.wantFallback && reason != "" { - t.Fatalf("resolveCodexModel(%q) unexpected fallback reason: %q", tt.input, reason) - } - }) - } -} - -func createOpenAITestClient(baseURL, token, accountID string) *openai.Client { - opts := []openaiopt.RequestOption{ - openaiopt.WithBaseURL(baseURL), - openaiopt.WithAPIKey(token), - } - if accountID != "" { - opts = append(opts, openaiopt.WithHeader("Chatgpt-Account-Id", accountID)) - } - c := openai.NewClient(opts...) - return &c -} - -func writeCompletedSSE(w http.ResponseWriter, response map[string]interface{}) { - event := map[string]interface{}{ - "type": "response.completed", - "sequence_number": 1, - "response": response, - } - b, _ := json.Marshal(event) - w.Header().Set("Content-Type", "text/event-stream") - fmt.Fprintf(w, "event: response.completed\n") - fmt.Fprintf(w, "data: %s\n\n", string(b)) - fmt.Fprintf(w, "data: [DONE]\n\n") -} diff --git a/pkg/providers/github_copilot_provider.go b/pkg/providers/github_copilot_provider.go deleted file mode 100644 index 5058819f5..000000000 --- a/pkg/providers/github_copilot_provider.go +++ /dev/null @@ -1,82 +0,0 @@ -package providers - -import ( - "context" - "fmt" - - json "encoding/json" - - copilot "github.com/github/copilot-sdk/go" -) - -type GitHubCopilotProvider struct { - uri string - connectMode string // `stdio` or `grpc`` - - session *copilot.Session -} - -func NewGitHubCopilotProvider(uri string, connectMode string, model string) (*GitHubCopilotProvider, error) { - - var session *copilot.Session - if connectMode == "" { - connectMode = "grpc" - } - switch connectMode { - - case "stdio": - //todo - case "grpc": - client := copilot.NewClient(&copilot.ClientOptions{ - CLIUrl: uri, - }) - if err := client.Start(context.Background()); err != nil { - return nil, fmt.Errorf("Can't connect to Github Copilot, https://github.com/github/copilot-sdk/blob/main/docs/getting-started.md#connecting-to-an-external-cli-server for details") - } - defer client.Stop() - session, _ = client.CreateSession(context.Background(), &copilot.SessionConfig{ - Model: model, - Hooks: &copilot.SessionHooks{}, - }) - - } - - return &GitHubCopilotProvider{ - uri: uri, - connectMode: connectMode, - session: session, - }, nil -} - -// Chat sends a chat request to GitHub Copilot -func (p *GitHubCopilotProvider) Chat(ctx context.Context, messages []Message, tools []ToolDefinition, model string, options map[string]interface{}) (*LLMResponse, error) { - type tempMessage struct { - Role string `json:"role"` - Content string `json:"content"` - } - out := make([]tempMessage, 0, len(messages)) - - for _, msg := range messages { - out = append(out, tempMessage{ - Role: msg.Role, - Content: msg.Content, - }) - } - - fullcontent, _ := json.Marshal(out) - - content, _ := p.session.Send(ctx, copilot.MessageOptions{ - Prompt: string(fullcontent), - }) - - return &LLMResponse{ - FinishReason: "stop", - Content: content, - }, nil - -} - -func (p *GitHubCopilotProvider) GetDefaultModel() string { - - return "gpt-4.1" -} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go deleted file mode 100644 index acca15538..000000000 --- a/pkg/providers/http_provider.go +++ /dev/null @@ -1,498 +0,0 @@ -// ClawDroid - Ultra-lightweight personal AI agent -// Inspired by and based on nanobot: https://github.com/HKUDS/nanobot -// License: MIT -// -// Copyright (c) 2026 ClawDroid contributors - -package providers - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "time" - - "github.com/KarakuriAgent/clawdroid/pkg/auth" - "github.com/KarakuriAgent/clawdroid/pkg/config" -) - -type HTTPProvider struct { - apiKey string - apiBase string - providerName string - httpClient *http.Client -} - -func NewHTTPProvider(apiKey, apiBase, proxy, providerName string) *HTTPProvider { - client := &http.Client{ - Timeout: 120 * time.Second, - } - - 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, "/"), - providerName: providerName, - httpClient: client, - } -} - -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, groq/openai/gpt-oss-120b -> openai/gpt-oss-120b, ollama/qwen2.5:14b -> qwen2.5:14b) - if idx := strings.Index(model, "/"); idx != -1 { - prefix := model[:idx] - if prefix == "moonshot" || prefix == "nvidia" || prefix == "groq" || prefix == "ollama" { - model = model[idx+1:] - } - } - - requestBody := map[string]interface{}{ - "model": model, - "messages": p.buildAPIMessages(messages), - } - - if len(tools) > 0 { - requestBody["tools"] = tools - requestBody["tool_choice"] = "auto" - } - - if maxTokens, ok := options["max_tokens"].(int); ok { - if p.providerName == "openai" || p.providerName == "zhipu" || p.providerName == "glm" { - requestBody["max_completion_tokens"] = maxTokens - } else { - requestBody["max_tokens"] = maxTokens - } - } - - if temperature, ok := options["temperature"].(float64); ok { - lowerModel := strings.ToLower(model) - // OpenAI reasoning models (o1/o3/o4, gpt-5.x) and Kimi k2 only support temperature=1 - if p.providerName == "openai" || (strings.Contains(lowerModel, "kimi") && strings.Contains(lowerModel, "k2")) { - // Don't send temperature; let the API use its default - } 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 p.parseResponse(body) -} - -// buildAPIMessages converts internal Message slice to OpenAI API format. -// If a user message has Media, content becomes an array of text + image_url objects. -func (p *HTTPProvider) buildAPIMessages(messages []Message) []map[string]interface{} { - result := make([]map[string]interface{}, 0, len(messages)) - - for _, msg := range messages { - m := map[string]interface{}{ - "role": msg.Role, - } - - // Messages with media get the array-style content (user or tool) - if len(msg.Media) > 0 && (msg.Role == "user" || msg.Role == "tool") { - parts := make([]map[string]interface{}, 0, 1+len(msg.Media)) - if msg.Content != "" { - parts = append(parts, map[string]interface{}{ - "type": "text", - "text": msg.Content, - }) - } - for _, dataURL := range msg.Media { - parts = append(parts, map[string]interface{}{ - "type": "image_url", - "image_url": map[string]string{ - "url": dataURL, - }, - }) - } - m["content"] = parts - } else { - m["content"] = msg.Content - } - - if len(msg.ToolCalls) > 0 { - m["tool_calls"] = msg.ToolCalls - } - if msg.ToolCallID != "" { - m["tool_call_id"] = msg.ToolCallID - } - - result = append(result, m) - } - - return result -} - -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 -} - -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) - } - if cred == nil { - return nil, fmt.Errorf("no credentials for anthropic. Run: clawdroid 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: clawdroid auth login --provider openai") - } - return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil -} - -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" - } - } - case "openai", "gpt": - if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" { - if cfg.Providers.OpenAI.AuthMethod == "codex-cli" { - return NewCodexProviderWithTokenSource("", "", CreateCodexCliTokenSource()), nil - } - 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.WorkspacePath() - if workspace == "" { - workspace = "." - } - return NewClaudeCliProvider(workspace), nil - case "codex-cli", "codex-code": - workspace := cfg.WorkspacePath() - if workspace == "" { - workspace = "." - } - return NewCodexCliProvider(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) - - } - - } - - // 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 - providerName = "openai" - 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 - providerName = "zhipu" - 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 (strings.Contains(lowerModel, "ollama") || strings.HasPrefix(model, "ollama/")) && cfg.Providers.Ollama.APIKey != "": - fmt.Println("Ollama provider selected based on model name prefix") - apiKey = cfg.Providers.Ollama.APIKey - apiBase = cfg.Providers.Ollama.APIBase - proxy = cfg.Providers.Ollama.Proxy - if apiBase == "" { - apiBase = "http://localhost:11434/v1" - } - fmt.Println("Ollama apiBase:", apiBase) - 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/") { - return nil, fmt.Errorf("no API key configured for provider (model: %s)", model) - } - - if apiBase == "" { - return nil, fmt.Errorf("no API base configured for provider (model: %s)", model) - } - - return NewHTTPProvider(apiKey, apiBase, proxy, providerName), nil -} diff --git a/pkg/providers/provider.go b/pkg/providers/provider.go new file mode 100644 index 000000000..864b693b4 --- /dev/null +++ b/pkg/providers/provider.go @@ -0,0 +1,12 @@ +package providers + +import ( + "github.com/KarakuriAgent/clawdroid/pkg/config" +) + +// CreateProvider is the single entry point for constructing an LLMProvider. +// When replacing the underlying LLM library, modify only this function +// and the adapter it delegates to (currently AnyLLMAdapter). +func CreateProvider(cfg *config.Config) (LLMProvider, error) { + return NewAnyLLMAdapter(cfg.LLM.Model, cfg.LLM.APIKey, cfg.LLM.BaseURL) +} diff --git a/pkg/providers/tool_call_extract.go b/pkg/providers/tool_call_extract.go deleted file mode 100644 index 97a219283..000000000 --- a/pkg/providers/tool_call_extract.go +++ /dev/null @@ -1,72 +0,0 @@ -package providers - -import ( - "encoding/json" - "strings" -) - -// extractToolCallsFromText parses tool call JSON from response text. -// Both ClaudeCliProvider and CodexCliProvider use this to extract -// tool calls that the model outputs in its response text. -func extractToolCallsFromText(text string) []ToolCall { - start := strings.Index(text, `{"tool_calls"`) - if start == -1 { - return nil - } - - end := findMatchingBrace(text, start) - if end == start { - return nil - } - - jsonStr := text[start:end] - - var wrapper struct { - 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"` - } - - if err := json.Unmarshal([]byte(jsonStr), &wrapper); err != nil { - return nil - } - - var result []ToolCall - for _, tc := range wrapper.ToolCalls { - var args map[string]interface{} - json.Unmarshal([]byte(tc.Function.Arguments), &args) - - result = append(result, ToolCall{ - ID: tc.ID, - Type: tc.Type, - Name: tc.Function.Name, - Arguments: args, - Function: &FunctionCall{ - Name: tc.Function.Name, - Arguments: tc.Function.Arguments, - }, - }) - } - - return result -} - -// stripToolCallsFromText removes tool call JSON from response text. -func stripToolCallsFromText(text string) string { - start := strings.Index(text, `{"tool_calls"`) - if start == -1 { - return text - } - - end := findMatchingBrace(text, start) - if end == start { - return text - } - - return strings.TrimSpace(text[:start] + text[end:]) -} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index effa87a34..876e6e1a4 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -132,7 +132,6 @@ After completing the task, provide a clear summary of what was done.` MaxIterations: maxIter, LLMOptions: map[string]any{ "max_tokens": 4096, - "temperature": 0.7, }, }, messages, task.OriginChannel, task.OriginChatID) @@ -290,7 +289,6 @@ func (t *SubagentTool) Execute(ctx context.Context, args map[string]interface{}) MaxIterations: maxIter, LLMOptions: map[string]any{ "max_tokens": 4096, - "temperature": 0.7, }, }, messages, t.originChannel, t.originChatID) diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 2f3838e67..e26217f55 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -56,8 +56,7 @@ func RunToolLoop(ctx context.Context, config ToolLoopConfig, messages []provider llmOpts := config.LLMOptions if llmOpts == nil { llmOpts = map[string]any{ - "max_tokens": 4096, - "temperature": 0.7, + "max_tokens": 4096, } }