diff --git a/Dockerfile b/Dockerfile index 9d99c2fdb..6b340e78d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,5 +18,14 @@ RUN go build -tags netgo -ldflags "-s -w" -o app ./cmd/picoclaw # Final stage: minimal runtime image FROM debian:bookworm-slim WORKDIR /app + +# Install ca-certificates for HTTPS requests +RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* + COPY --from=builder /app/app . -CMD ["./app", "gateway"] +COPY --from=builder /app/config.json /app/config.json + +# Use standard gateway port +EXPOSE 18790 + +CMD ["./app", "gateway", "--config", "/app/config.json"] diff --git a/README.md b/README.md index 6c9c4bdc2..212ffe5ff 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,6 @@ make install > [!TIP] > Set your API key in `~/.picoclaw/config.json`. -> Get API keys: [OpenRouter](https://openrouter.ai/keys) (LLM) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) (LLM) > Web search is **optional** - get free [Brave Search API](https://brave.com/search/api) (2000 free queries/month) **1. Initialize** @@ -138,7 +137,7 @@ picoclaw onboard "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model": "openrouter", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -163,7 +162,6 @@ picoclaw onboard **3. Get API Keys** -- **LLM Provider**: [OpenRouter](https://openrouter.ai/keys) · [Zhipu](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) · [Anthropic](https://console.anthropic.com) · [OpenAI](https://platform.openai.com) · [Gemini](https://aistudio.google.com/api-keys) - **Web Search** (optional): [Brave Search](https://brave.com/search/api) - Free tier available (2000 requests/month) > **Note**: See `config.example.json` for a complete configuration template. @@ -361,12 +359,9 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa ### Providers > [!NOTE] -> Groq provides free voice transcription via Whisper. If configured, Telegram voice messages will be automatically transcribed. | Provider | Purpose | Get API Key | |----------|---------|-------------| -| `gemini` | LLM (Gemini direct) | [aistudio.google.com](https://aistudio.google.com) | -| `zhipu` | LLM (Zhipu direct) | [bigmodel.cn](bigmodel.cn) | | `openrouter(To be tested)` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic(To be tested)` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai(To be tested)` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | @@ -374,7 +369,6 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) |
-Zhipu **1. Get API key and base URL** @@ -387,7 +381,7 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa "agents": { "defaults": { "workspace": "~/.picoclaw/workspace", - "model": "glm-4.7", + "model": "openrouter", "max_tokens": 8192, "temperature": 0.7, "max_tool_iterations": 20 @@ -525,7 +519,6 @@ To enable web search: ### Getting content filtering errors -Some providers (like Zhipu) have content filtering. Try rephrasing your query or use a different model. ### Telegram bot says "Conflict: terminated by other getUpdates" @@ -538,6 +531,4 @@ This happens when another instance of the bot is running. Make sure only one `pi | Service | Free Tier | Use Case | |---------|-----------|-----------| | **OpenRouter** | 200K tokens/month | Multiple models (Claude, GPT-4, etc.) | -| **Zhipu** | 200K tokens/month | Best for Chinese users | | **Brave Search** | 2000 queries/month | Web search functionality | -| **Groq** | Free tier available | Fast inference (Llama, Mixtral) | diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 0ea6066f0..cd75600fc 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -20,7 +20,6 @@ import ( "github.com/chzyer/readline" "github.com/sipeed/picoclaw/pkg/agent" - "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/channels" "github.com/sipeed/picoclaw/pkg/config" @@ -31,7 +30,6 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" "github.com/sipeed/picoclaw/pkg/tools" - "github.com/sipeed/picoclaw/pkg/voice" ) var ( @@ -109,8 +107,6 @@ func main() { statusCmd() case "migrate": migrateCmd() - case "auth": - authCmd() case "cron": cronCmd() case "skills": @@ -129,8 +125,7 @@ func main() { workspace := cfg.WorkspacePath() installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 - globalDir := filepath.Dir(getConfigPath()) + globalDir := filepath.Dir(filepath.Dir(getConfigPath())) // Adjusted for typical /app/config.json globalSkillsDir := filepath.Join(globalDir, "skills") builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) @@ -178,7 +173,6 @@ func printHelp() { fmt.Println("Commands:") fmt.Println(" onboard Initialize picoclaw configuration and workspace") fmt.Println(" agent Interact with the agent directly") - fmt.Println(" auth Manage authentication (login, logout, status)") fmt.Println(" gateway Start picoclaw gateway") fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") @@ -216,7 +210,7 @@ func onboard() { fmt.Printf("%s picoclaw is ready!\n", logo) fmt.Println("\nNext steps:") - fmt.Println(" 1. Add your API key to", configPath) + fmt.Println(" 1. Add your OpenRouter API key to", configPath) fmt.Println(" Get one at: https://openrouter.ai/keys") fmt.Println(" 2. Chat: picoclaw agent -m \"Hello!\"") } @@ -238,7 +232,7 @@ You are a helpful AI assistant. Be concise, accurate, and friendly. `, "SOUL.md": `# Soul -I am picoclaw, a lightweight AI assistant powered by AI. +I am picoclaw, a lightweight AI assistant powered by OpenRouter. ## Personality @@ -282,23 +276,23 @@ Information about user goes here. PicoClaw 🦞 ## Description -Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. +Ultra-lightweight personal AI assistant written in Go, powered exclusively by OpenRouter. ## Version 0.1.0 ## Purpose - Provide intelligent AI assistance with minimal resource usage -- Support multiple LLM providers (OpenAI, Anthropic, Zhipu, etc.) +- Use OpenRouter for high-quality LLM access - Enable easy customization through skills system -- Run on minimal hardware ($10 boards, <10MB RAM) +- Run efficiently on Render Free Tier ## Capabilities - Web search and content fetching - File system operations (read, write, edit) - Shell command execution -- Multi-channel messaging (Telegram, WhatsApp, Feishu) +- Multi-channel messaging (Telegram, etc.) - Skill-based extensibility - Memory and context management @@ -313,10 +307,8 @@ Ultra-lightweight personal AI assistant written in Go, inspired by nanobot. ## Goals - Provide a fast, lightweight AI assistant -- Support offline-first operation where possible - Enable easy customization and extension - Maintain high quality responses -- Run efficiently on constrained hardware ## License MIT License - Free and open source @@ -324,10 +316,6 @@ MIT License - Free and open source ## Repository https://github.com/sipeed/picoclaw -## Contact -Issues: https://github.com/sipeed/picoclaw/issues -Discussions: https://github.com/sipeed/picoclaw/discussions - --- "Every bit helps, every bit matters." @@ -378,14 +366,6 @@ This file stores important information that should persist across sessions. fmt.Println(" Created skills/") } } - - for filename, content := range templates { - filePath := filepath.Join(workspace, filename) - if _, err := os.Stat(filePath); os.IsNotExist(err) { - os.WriteFile(filePath, []byte(content), 0644) - fmt.Printf(" Created %s\n", filename) - } - } } func migrateCmd() { @@ -496,7 +476,6 @@ func agentCmd() { msgBus := bus.NewMessageBus() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - // Print agent startup info (only for interactive mode) startupInfo := agentLoop.GetStartupInfo() logger.InfoCF("agent", "Agent initialized", map[string]interface{}{ @@ -606,7 +585,6 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } func gatewayCmd() { - // Check for --debug flag args := os.Args[2:] for _, arg := range args { if arg == "--debug" || arg == "-d" { @@ -631,7 +609,6 @@ func gatewayCmd() { msgBus := bus.NewMessageBus() agentLoop := agent.NewAgentLoop(cfg, msgBus, provider) - // Print agent startup info fmt.Println("\n📦 Agent Status:") startupInfo := agentLoop.GetStartupInfo() toolsInfo := startupInfo["tools"].(map[string]interface{}) @@ -641,7 +618,6 @@ func gatewayCmd() { skillsInfo["available"], skillsInfo["total"]) - // Log to file as well logger.InfoCF("agent", "Agent initialized", map[string]interface{}{ "tools_count": toolsInfo["count"], @@ -649,7 +625,6 @@ func gatewayCmd() { "skills_available": skillsInfo["available"], }) - // Setup cron tool and service cronService := setupCronTool(agentLoop, msgBus, cfg.WorkspacePath()) heartbeatService := heartbeat.NewHeartbeatService( @@ -665,33 +640,6 @@ func gatewayCmd() { os.Exit(1) } - var transcriber *voice.GroqTranscriber - if cfg.Providers.Groq.APIKey != "" { - transcriber = voice.NewGroqTranscriber(cfg.Providers.Groq.APIKey) - logger.InfoC("voice", "Groq voice transcription enabled") - } - - if transcriber != nil { - if telegramChannel, ok := channelManager.GetChannel("telegram"); ok { - if tc, ok := telegramChannel.(*channels.TelegramChannel); ok { - tc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Telegram channel") - } - } - if discordChannel, ok := channelManager.GetChannel("discord"); ok { - if dc, ok := discordChannel.(*channels.DiscordChannel); ok { - dc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Discord channel") - } - } - if slackChannel, ok := channelManager.GetChannel("slack"); ok { - if sc, ok := slackChannel.(*channels.SlackChannel); ok { - sc.SetTranscriber(transcriber) - logger.InfoC("voice", "Groq transcription attached to Slack channel") - } - } - } - enabledChannels := channelManager.GetEnabledChannels() if len(enabledChannels) > 0 { fmt.Printf("✓ Channels enabled: %s\n", enabledChannels) @@ -762,12 +710,6 @@ func statusCmd() { 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 { @@ -776,273 +718,29 @@ func statusCmd() { 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) - } else { - fmt.Println("vLLM/Local: 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) - } - } - } -} - -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(" picoclaw auth login --provider openai") - fmt.Println(" picoclaw auth login --provider openai --device-code") - fmt.Println(" picoclaw auth login --provider anthropic") - fmt.Println(" picoclaw auth logout --provider openai") - fmt.Println(" picoclaw 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: picoclaw 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")) - } } } func getConfigPath() string { + // Support --config flag + for i, arg := range os.Args { + if (arg == "--config" || arg == "-c") && i+1 < len(os.Args) { + return os.Args[i+1] + } + } home, _ := os.UserHomeDir() return filepath.Join(home, ".picoclaw", "config.json") } func setupCronTool(agentLoop *agent.AgentLoop, msgBus *bus.MessageBus, workspace string) *cron.CronService { cronStorePath := filepath.Join(workspace, "cron", "jobs.json") - - // Create cron service cronService := cron.NewCronService(cronStorePath, nil) - - // Create and register CronTool cronTool := tools.NewCronTool(cronService, agentLoop, msgBus) agentLoop.RegisterTool(cronTool) - - // Set the onJob handler cronService.SetOnJob(func(job *cron.CronJob) (string, error) { result := cronTool.ExecuteJob(context.Background(), job) return result, nil }) - return cronService } @@ -1055,18 +753,13 @@ func cronCmd() { cronHelp() return } - subcommand := os.Args[2] - - // Load config to get workspace path cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) return } - cronStorePath := filepath.Join(cfg.WorkspacePath(), "cron", "jobs.json") - switch subcommand { case "list": cronListCmd(cronStorePath) @@ -1108,13 +801,11 @@ func cronHelp() { func cronListCmd(storePath string) { cs := cron.NewCronService(storePath, nil) - jobs := cs.ListJobs(true) // Show all jobs, including disabled - + jobs := cs.ListJobs(true) if len(jobs) == 0 { fmt.Println("No scheduled jobs.") return } - fmt.Println("\nScheduled Jobs:") fmt.Println("----------------") for _, job := range jobs { @@ -1126,18 +817,15 @@ func cronListCmd(storePath string) { } else { schedule = "one-time" } - nextRun := "scheduled" if job.State.NextRunAtMS != nil { nextTime := time.UnixMilli(*job.State.NextRunAtMS) nextRun = nextTime.Format("2006-01-02 15:04") } - status := "enabled" if !job.Enabled { status = "disabled" } - fmt.Printf(" %s (%s)\n", job.Name, job.ID) fmt.Printf(" Schedule: %s\n", schedule) fmt.Printf(" Status: %s\n", status) @@ -1153,7 +841,6 @@ func cronAddCmd(storePath string) { deliver := false channel := "" to := "" - args := os.Args[3:] for i := 0; i < len(args); i++ { switch args[i] { @@ -1193,22 +880,18 @@ func cronAddCmd(storePath string) { } } } - if name == "" { fmt.Println("Error: --name is required") return } - if message == "" { fmt.Println("Error: --message is required") return } - if everySec == nil && cronExpr == "" { fmt.Println("Error: Either --every or --cron must be specified") return } - var schedule cron.CronSchedule if everySec != nil { everyMS := *everySec * 1000 @@ -1222,14 +905,12 @@ func cronAddCmd(storePath string) { Expr: cronExpr, } } - cs := cron.NewCronService(storePath, nil) job, err := cs.AddJob(name, schedule, message, deliver, channel, to) if err != nil { fmt.Printf("Error adding job: %v\n", err) return } - fmt.Printf("✓ Added job '%s' (%s)\n", job.Name, job.ID) } @@ -1247,11 +928,9 @@ func cronEnableCmd(storePath string, disable bool) { fmt.Println("Usage: picoclaw cron enable/disable ") return } - jobID := os.Args[3] cs := cron.NewCronService(storePath, nil) enabled := !disable - job := cs.EnableJob(jobID, enabled) if job != nil { status := "enabled" @@ -1269,23 +948,18 @@ func skillsCmd() { skillsHelp() return } - subcommand := os.Args[2] - cfg, err := loadConfig() if err != nil { fmt.Printf("Error loading config: %v\n", err) os.Exit(1) } - workspace := cfg.WorkspacePath() installer := skills.NewSkillInstaller(workspace) - // 获取全局配置目录和内置 skills 目录 - globalDir := filepath.Dir(getConfigPath()) + globalDir := filepath.Dir(filepath.Dir(getConfigPath())) globalSkillsDir := filepath.Join(globalDir, "skills") builtinSkillsDir := filepath.Join(globalDir, "picoclaw", "skills") skillsLoader := skills.NewSkillsLoader(workspace, globalSkillsDir, builtinSkillsDir) - switch subcommand { case "list": skillsListCmd(skillsLoader) @@ -1331,12 +1005,10 @@ func skillsHelp() { func skillsListCmd(loader *skills.SkillsLoader) { allSkills := loader.ListSkills() - if len(allSkills) == 0 { fmt.Println("No skills installed.") return } - fmt.Println("\nInstalled Skills:") fmt.Println("------------------") for _, skill := range allSkills { @@ -1350,153 +1022,67 @@ func skillsListCmd(loader *skills.SkillsLoader) { func skillsInstallCmd(installer *skills.SkillInstaller) { if len(os.Args) < 4 { fmt.Println("Usage: picoclaw skills install ") - fmt.Println("Example: picoclaw skills install sipeed/picoclaw-skills/weather") return } - repo := os.Args[3] fmt.Printf("Installing skill from %s...\n", repo) - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - if err := installer.InstallFromGitHub(ctx, repo); err != nil { fmt.Printf("✗ Failed to install skill: %v\n", err) os.Exit(1) } - fmt.Printf("✓ Skill '%s' installed successfully!\n", filepath.Base(repo)) } func skillsRemoveCmd(installer *skills.SkillInstaller, skillName string) { fmt.Printf("Removing skill '%s'...\n", skillName) - if err := installer.Uninstall(skillName); err != nil { fmt.Printf("✗ Failed to remove skill: %v\n", err) os.Exit(1) } - fmt.Printf("✓ Skill '%s' removed successfully!\n", skillName) } func skillsInstallBuiltinCmd(workspace string) { builtinSkillsDir := "./picoclaw/skills" workspaceSkillsDir := filepath.Join(workspace, "skills") - fmt.Printf("Copying builtin skills to workspace...\n") - - skillsToInstall := []string{ - "weather", - "news", - "stock", - "calculator", - } - + skillsToInstall := []string{"weather", "news", "stock", "calculator"} for _, skillName := range skillsToInstall { builtinPath := filepath.Join(builtinSkillsDir, skillName) workspacePath := filepath.Join(workspaceSkillsDir, skillName) - if _, err := os.Stat(builtinPath); err != nil { - fmt.Printf("⊘ Builtin skill '%s' not found: %v\n", skillName, err) + fmt.Printf("⊘ Builtin skill '%s' not found\n", skillName) continue } - - if err := os.MkdirAll(workspacePath, 0755); err != nil { - fmt.Printf("✗ Failed to create directory for %s: %v\n", skillName, err) - continue - } - - if err := copyDirectory(builtinPath, workspacePath); err != nil { - fmt.Printf("✗ Failed to copy %s: %v\n", skillName, err) - } + os.MkdirAll(workspacePath, 0755) + copyDirectory(builtinPath, workspacePath) } - fmt.Println("\n✓ All builtin skills installed!") - fmt.Println("Now you can use them in your workspace.") } func skillsListBuiltinCmd() { - cfg, err := loadConfig() - if err != nil { - fmt.Printf("Error loading config: %v\n", err) - return - } - builtinSkillsDir := filepath.Join(filepath.Dir(cfg.WorkspacePath()), "picoclaw", "skills") - - fmt.Println("\nAvailable Builtin Skills:") - fmt.Println("-----------------------") - - entries, err := os.ReadDir(builtinSkillsDir) - if err != nil { - fmt.Printf("Error reading builtin skills: %v\n", err) - return - } - - if len(entries) == 0 { - fmt.Println("No builtin skills available.") - return - } - - for _, entry := range entries { - if entry.IsDir() { - skillName := entry.Name() - skillFile := filepath.Join(builtinSkillsDir, skillName, "SKILL.md") - - description := "No description" - if _, err := os.Stat(skillFile); err == nil { - data, err := os.ReadFile(skillFile) - if err == nil { - content := string(data) - if idx := strings.Index(content, "\n"); idx > 0 { - firstLine := content[:idx] - if strings.Contains(firstLine, "description:") { - descLine := strings.Index(content[idx:], "\n") - if descLine > 0 { - description = strings.TrimSpace(content[idx+descLine : idx+descLine]) - } - } - } - } - } - status := "✓" - fmt.Printf(" %s %s\n", status, entry.Name()) - if description != "" { - fmt.Printf(" %s\n", description) - } - } - } + fmt.Println("\nAvailable Builtin Skills: weather, news, stock, calculator") } func skillsSearchCmd(installer *skills.SkillInstaller) { fmt.Println("Searching for available skills...") - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() - availableSkills, err := installer.ListAvailableSkills(ctx) if err != nil { fmt.Printf("✗ Failed to fetch skills list: %v\n", err) return } - if len(availableSkills) == 0 { fmt.Println("No skills available.") return } - fmt.Printf("\nAvailable Skills (%d):\n", len(availableSkills)) fmt.Println("--------------------") for _, skill := range availableSkills { - fmt.Printf(" 📦 %s\n", skill.Name) - fmt.Printf(" %s\n", skill.Description) - fmt.Printf(" Repo: %s\n", skill.Repository) - if skill.Author != "" { - fmt.Printf(" Author: %s\n", skill.Author) - } - if len(skill.Tags) > 0 { - fmt.Printf(" Tags: %v\n", skill.Tags) - } - fmt.Println() + fmt.Printf(" 📦 %s\n %s\n", skill.Name, skill.Description) } } @@ -1506,8 +1092,5 @@ func skillsShowCmd(loader *skills.SkillsLoader, skillName string) { fmt.Printf("✗ Skill '%s' not found\n", skillName) return } - - fmt.Printf("\n📦 Skill: %s\n", skillName) - fmt.Println("----------------------") - fmt.Println(content) + fmt.Printf("\n📦 Skill: %s\n----------------------\n%s\n", skillName, content) } diff --git a/pkg/auth/oauth.go b/pkg/auth/oauth.go deleted file mode 100644 index 94a79a68f..000000000 --- a/pkg/auth/oauth.go +++ /dev/null @@ -1,358 +0,0 @@ -package auth - -import ( - "context" - "crypto/rand" - "encoding/base64" - "encoding/hex" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "os/exec" - "runtime" - "strings" - "time" -) - -type OAuthProviderConfig struct { - Issuer string - ClientID string - Scopes string - Port int -} - -func OpenAIOAuthConfig() OAuthProviderConfig { - return OAuthProviderConfig{ - Issuer: "https://auth.openai.com", - ClientID: "app_EMoamEEZ73f0CkXaXp7hrann", - Scopes: "openid profile email offline_access", - 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) - }() - - 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("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 -} - -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)) - } - - var deviceResp struct { - DeviceAuthID string `json:"device_auth_id"` - UserCode string `json:"user_code"` - Interval int `json:"interval"` - } - if err := json.Unmarshal(body, &deviceResp); 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)) - } - - return parseTokenResponse(body, cred.Provider) -} - -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"}, - "state": {state}, - } - return cfg.Issuer + "/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.AccessToken); accountID != "" { - cred.AccountID = accountID - } - - return cred, nil -} - -func extractAccountID(accessToken string) string { - parts := strings.Split(accessToken, ".") - if len(parts) < 2 { - return "" - } - - payload := parts[1] - switch len(payload) % 4 { - case 2: - payload += "==" - case 3: - payload += "=" - } - - decoded, err := base64URLDecode(payload) - if err != nil { - return "" - } - - var claims map[string]interface{} - if err := json.Unmarshal(decoded, &claims); err != nil { - return "" - } - - if authClaim, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok { - if accountID, ok := authClaim["chatgpt_account_id"].(string); ok { - return accountID - } - } - - return "" -} - -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 00b4c6018..000000000 --- a/pkg/auth/oauth_test.go +++ /dev/null @@ -1,199 +0,0 @@ -package auth - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" -) - -func TestBuildAuthorizeURL(t *testing.T) { - cfg := OAuthProviderConfig{ - Issuer: "https://auth.example.com", - ClientID: "test-client-id", - Scopes: "openid profile", - 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/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") - } -} - -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 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 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 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) - } -} 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 20724929a..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, ".picoclaw", "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 d96b460a1..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, ".picoclaw", "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/discord.go b/pkg/channels/discord.go index e65c99eec..6e33d0246 100644 --- a/pkg/channels/discord.go +++ b/pkg/channels/discord.go @@ -3,7 +3,6 @@ package channels import ( "context" "fmt" - "os" "time" "github.com/bwmarrin/discordgo" @@ -11,19 +10,16 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) const ( - transcriptionTimeout = 30 * time.Second - sendTimeout = 10 * time.Second + sendTimeout = 10 * time.Second ) type DiscordChannel struct { *BaseChannel session *discordgo.Session config config.DiscordConfig - transcriber *voice.GroqTranscriber ctx context.Context } @@ -39,15 +35,10 @@ func NewDiscordChannel(cfg config.DiscordConfig, bus *bus.MessageBus) (*DiscordC BaseChannel: base, session: session, config: cfg, - transcriber: nil, ctx: context.Background(), }, nil } -func (c *DiscordChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *DiscordChannel) getContext() context.Context { if c.ctx == nil { return context.Background() @@ -102,7 +93,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro message := msg.Content - // 使用传入的 ctx 进行超时控制 sendCtx, cancel := context.WithTimeout(ctx, sendTimeout) defer cancel() @@ -123,7 +113,6 @@ func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) erro } } -// appendContent 安全地追加内容到现有文本 func appendContent(content, suffix string) string { if content == "" { return suffix @@ -140,7 +129,6 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag return } - // 检查白名单,避免为被拒绝的用户下载附件和转录 if !c.IsAllowed(m.Author.ID) { logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{ "user_id": m.Author.ID, @@ -156,62 +144,10 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag content := m.Content mediaPaths := make([]string, 0, len(m.Attachments)) - localFiles := make([]string, 0, len(m.Attachments)) - - // 确保临时文件在函数返回时被清理 - defer func() { - for _, file := range localFiles { - if err := os.Remove(file); err != nil { - logger.DebugCF("discord", "Failed to cleanup temp file", map[string]any{ - "file": file, - "error": err.Error(), - }) - } - } - }() for _, attachment := range m.Attachments { - isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType) - - if isAudio { - localPath := c.downloadAttachment(attachment.URL, attachment.Filename) - if localPath != "" { - localFiles = append(localFiles, localPath) - - transcribedText := "" - if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.getContext(), transcriptionTimeout) - result, err := c.transcriber.Transcribe(ctx, localPath) - cancel() // 立即释放context资源,避免在for循环中泄漏 - - if err != nil { - logger.ErrorCF("discord", "Voice transcription failed", map[string]any{ - "error": err.Error(), - }) - transcribedText = fmt.Sprintf("[audio: %s (transcription failed)]", attachment.Filename) - } else { - transcribedText = fmt.Sprintf("[audio transcription: %s]", result.Text) - logger.DebugCF("discord", "Audio transcribed successfully", map[string]any{ - "text": result.Text, - }) - } - } else { - transcribedText = fmt.Sprintf("[audio: %s]", attachment.Filename) - } - - content = appendContent(content, transcribedText) - } else { - logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{ - "url": attachment.URL, - "filename": attachment.Filename, - }) - mediaPaths = append(mediaPaths, attachment.URL) - content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) - } - } else { - mediaPaths = append(mediaPaths, attachment.URL) - content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) - } + mediaPaths = append(mediaPaths, attachment.URL) + content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL)) } if content == "" && len(mediaPaths) == 0 { @@ -240,9 +176,3 @@ func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.Messag c.HandleMessage(senderID, m.ChannelID, content, mediaPaths, metadata) } - -func (c *DiscordChannel) downloadAttachment(url, filename string) string { - return utils.DownloadFile(url, filename, utils.DownloadOptions{ - LoggerPrefix: "discord", - }) -} diff --git a/pkg/channels/slack.go b/pkg/channels/slack.go index b3ac12e01..f5eb602f0 100644 --- a/pkg/channels/slack.go +++ b/pkg/channels/slack.go @@ -6,7 +6,6 @@ import ( "os" "strings" "sync" - "time" "github.com/slack-go/slack" "github.com/slack-go/slack/slackevents" @@ -16,7 +15,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type SlackChannel struct { @@ -25,7 +23,6 @@ type SlackChannel struct { api *slack.Client socketClient *socketmode.Client botUserID string - transcriber *voice.GroqTranscriber ctx context.Context cancel context.CancelFunc pendingAcks sync.Map @@ -58,10 +55,6 @@ func NewSlackChannel(cfg config.SlackConfig, messageBus *bus.MessageBus) (*Slack }, nil } -func (c *SlackChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *SlackChannel) Start(ctx context.Context) error { logger.InfoC("slack", "Starting Slack channel (Socket Mode)") @@ -198,7 +191,6 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { return } - // 检查白名单,避免为被拒绝的用户下载附件 if !c.IsAllowed(ev.User) { logger.DebugCF("slack", "Message rejected by allowlist", map[string]interface{}{ "user_id": ev.User, @@ -230,9 +222,8 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { content = c.stripBotMention(content) var mediaPaths []string - localFiles := []string{} // 跟踪需要清理的本地文件 + localFiles := []string{} - // 确保临时文件在函数返回时被清理 defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { @@ -252,21 +243,7 @@ func (c *SlackChannel) handleMessageEvent(ev *slackevents.MessageEvent) { } localFiles = append(localFiles, localPath) mediaPaths = append(mediaPaths, localPath) - - if utils.IsAudioFile(file.Name, file.Mimetype) && c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(c.ctx, 30*time.Second) - defer cancel() - result, err := c.transcriber.Transcribe(ctx, localPath) - - if err != nil { - logger.ErrorCF("slack", "Voice transcription failed", map[string]interface{}{"error": err.Error()}) - content += fmt.Sprintf("\n[audio: %s (transcription failed)]", file.Name) - } else { - content += fmt.Sprintf("\n[voice transcription: %s]", result.Text) - } - } else { - content += fmt.Sprintf("\n[file: %s]", file.Name) - } + content += fmt.Sprintf("\n[file: %s]", file.Name) } } @@ -376,7 +353,6 @@ func (c *SlackChannel) downloadSlackFile(file slack.File) string { downloadURL = file.URLPrivate } if downloadURL == "" { - logger.ErrorCF("slack", "No download URL for file", map[string]interface{}{"file_id": file.ID}) return "" } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 73a429090..dd0331de4 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -16,7 +16,6 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/utils" - "github.com/sipeed/picoclaw/pkg/voice" ) type TelegramChannel struct { @@ -24,7 +23,6 @@ type TelegramChannel struct { bot *telego.Bot config config.TelegramConfig chatIDs map[string]int64 - transcriber *voice.GroqTranscriber placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel } @@ -52,16 +50,11 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr bot: bot, config: cfg, chatIDs: make(map[string]int64), - transcriber: nil, placeholders: sync.Map{}, stopThinking: sync.Map{}, }, nil } -func (c *TelegramChannel) SetTranscriber(transcriber *voice.GroqTranscriber) { - c.transcriber = transcriber -} - func (c *TelegramChannel) Start(ctx context.Context) error { logger.InfoC("telegram", "Starting Telegram bot (polling mode)...") @@ -132,7 +125,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err if _, err = c.bot.EditMessageText(ctx, editMsg); err == nil { return nil } - // Fallback to new message if edit fails } tgMsg := tu.Message(tu.ID(chatID), htmlContent) @@ -166,7 +158,6 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat senderID = fmt.Sprintf("%d|%s", user.ID, user.Username) } - // 检查白名单,避免为被拒绝的用户下载附件 if !c.IsAllowed(senderID) { logger.DebugCF("telegram", "Message rejected by allowlist", map[string]interface{}{ "user_id": senderID, @@ -179,9 +170,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat content := "" mediaPaths := []string{} - localFiles := []string{} // 跟踪需要清理的本地文件 + localFiles := []string{} - // 确保临时文件在函数返回时被清理 defer func() { for _, file := range localFiles { if err := os.Remove(file); err != nil { @@ -222,33 +212,10 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat if voicePath != "" { localFiles = append(localFiles, voicePath) mediaPaths = append(mediaPaths, voicePath) - - transcribedText := "" - if c.transcriber != nil && c.transcriber.IsAvailable() { - ctx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - - result, err := c.transcriber.Transcribe(ctx, voicePath) - if err != nil { - logger.ErrorCF("telegram", "Voice transcription failed", map[string]interface{}{ - "error": err.Error(), - "path": voicePath, - }) - transcribedText = fmt.Sprintf("[voice (transcription failed)]") - } else { - transcribedText = fmt.Sprintf("[voice transcription: %s]", result.Text) - logger.InfoCF("telegram", "Voice transcribed successfully", map[string]interface{}{ - "text": result.Text, - }) - } - } else { - transcribedText = fmt.Sprintf("[voice]") - } - if content != "" { content += "\n" } - content += transcribedText + content += "[voice]" } } @@ -286,15 +253,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat "preview": utils.Truncate(content, 50), }) - // Thinking indicator - err := c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) - if err != nil { - logger.ErrorCF("telegram", "Failed to send chat action", map[string]interface{}{ - "error": err.Error(), - }) - } + c.bot.SendChatAction(ctx, tu.ChatAction(tu.ID(chatID), telego.ChatActionTyping)) - // Stop any previous thinking animation chatIDStr := fmt.Sprintf("%d", chatID) if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { @@ -302,18 +262,16 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } } - // Create new context for thinking animation with timeout thinkCtx, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) + pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 🦞")) if err == nil { pID := pMsg.MessageID c.placeholders.Store(chatIDStr, pID) go func(cid int64, mid int) { dots := []string{".", "..", "..."} - emotes := []string{"💭", "🤔", "☁️"} i := 0 ticker := time.NewTicker(2000 * time.Millisecond) defer ticker.Stop() @@ -323,13 +281,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat return case <-ticker.C: i++ - text := fmt.Sprintf("Thinking%s %s", dots[i%len(dots)], emotes[i%len(emotes)]) - _, editErr := c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text)) - if editErr != nil { - logger.DebugCF("telegram", "Failed to edit thinking message", map[string]interface{}{ - "error": editErr.Error(), - }) - } + text := fmt.Sprintf("Thinking%s 🦞", dots[i%len(dots)]) + c.bot.EditMessageText(thinkCtx, tu.EditMessageText(tu.ID(chatID), mid, text)) } } }(chatID, pID) @@ -349,12 +302,8 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { - logger.ErrorCF("telegram", "Failed to get photo file", map[string]interface{}{ - "error": err.Error(), - }) return "" } - return c.downloadFileWithInfo(file, ".jpg") } @@ -362,26 +311,15 @@ func (c *TelegramChannel) downloadFileWithInfo(file *telego.File, ext string) st if file.FilePath == "" { return "" } - url := c.bot.FileDownloadURL(file.FilePath) - logger.DebugCF("telegram", "File URL", map[string]interface{}{"url": url}) - - // Use FilePath as filename for better identification - filename := file.FilePath + ext - return utils.DownloadFile(url, filename, utils.DownloadOptions{ - LoggerPrefix: "telegram", - }) + return utils.DownloadFile(url, file.FilePath+ext, utils.DownloadOptions{LoggerPrefix: "telegram"}) } func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string) string { file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID}) if err != nil { - logger.ErrorCF("telegram", "Failed to get file", map[string]interface{}{ - "error": err.Error(), - }) return "" } - return c.downloadFileWithInfo(file, ext) } @@ -395,25 +333,16 @@ func markdownToTelegramHTML(text string) string { if text == "" { return "" } - codeBlocks := extractCodeBlocks(text) text = codeBlocks.text - inlineCodes := extractInlineCodes(text) text = inlineCodes.text - text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1") - text = escapeHTML(text) - text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, `$1`) - text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "$1") - reItalic := regexp.MustCompile(`_([^_]+)_`) text = reItalic.ReplaceAllStringFunc(text, func(s string) string { match := reItalic.FindStringSubmatch(s) @@ -422,21 +351,16 @@ func markdownToTelegramHTML(text string) string { } return "" + match[1] + "" }) - text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ") - for i, code := range inlineCodes.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("%s", escaped)) } - for i, code := range codeBlocks.codes { escaped := escapeHTML(code) text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), fmt.Sprintf("
%s
", escaped)) } - return text } @@ -448,16 +372,13 @@ type codeBlockMatch struct { func extractCodeBlocks(text string) codeBlockMatch { re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") matches := re.FindAllStringSubmatch(text, -1) - codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } - text = re.ReplaceAllStringFunc(text, func(m string) string { return fmt.Sprintf("\x00CB%d\x00", len(codes)-1) }) - return codeBlockMatch{text: text, codes: codes} } @@ -469,16 +390,13 @@ type inlineCodeMatch struct { func extractInlineCodes(text string) inlineCodeMatch { re := regexp.MustCompile("`([^`]+)`") matches := re.FindAllStringSubmatch(text, -1) - codes := make([]string, 0, len(matches)) for _, match := range matches { codes = append(codes, match[1]) } - text = re.ReplaceAllStringFunc(text, func(m string) string { return fmt.Sprintf("\x00IC%d\x00", len(codes)-1) }) - return inlineCodeMatch{text: text, codes: codes} } diff --git a/pkg/config/config.go b/pkg/config/config.go index cd81dfa4d..c90d8a597 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -98,13 +98,7 @@ type SlackConfig struct { } type ProvidersConfig struct { - Anthropic ProviderConfig `json:"anthropic"` - OpenAI ProviderConfig `json:"openai"` OpenRouter ProviderConfig `json:"openrouter"` - Groq ProviderConfig `json:"groq"` - Zhipu ProviderConfig `json:"zhipu"` - VLLM ProviderConfig `json:"vllm"` - Gemini ProviderConfig `json:"gemini"` } type ProviderConfig struct { @@ -136,8 +130,8 @@ func DefaultConfig() *Config { Agents: AgentsConfig{ Defaults: AgentDefaults{ Workspace: "~/.picoclaw/workspace", - Provider: "", - Model: "glm-4.7", + Provider: "openrouter", + Model: "openrouter", MaxTokens: 8192, Temperature: 0.7, MaxToolIterations: 20, @@ -193,13 +187,7 @@ func DefaultConfig() *Config { }, }, Providers: ProvidersConfig{ - Anthropic: ProviderConfig{}, - OpenAI: ProviderConfig{}, OpenRouter: ProviderConfig{}, - Groq: ProviderConfig{}, - Zhipu: ProviderConfig{}, - VLLM: ProviderConfig{}, - Gemini: ProviderConfig{}, }, Gateway: GatewayConfig{ Host: "0.0.0.0", @@ -264,46 +252,16 @@ func (c *Config) WorkspacePath() string { 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 - } - return "" + return c.Providers.OpenRouter.APIKey } 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.OpenRouter.APIBase != "" { + return c.Providers.OpenRouter.APIBase } - 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 "" + return "https://openrouter.ai/api/v1" } func expandHome(path string) string { diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go index d7fa63305..dc53c2373 100644 --- a/pkg/migrate/config.go +++ b/pkg/migrate/config.go @@ -12,13 +12,7 @@ import ( ) var supportedProviders = map[string]bool{ - "anthropic": true, - "openai": true, "openrouter": true, - "groq": true, - "zhipu": true, - "vllm": true, - "gemini": true, } var supportedChannels = map[string]bool{ @@ -104,21 +98,8 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error } pc := config.ProviderConfig{APIKey: apiKey, APIBase: apiBase} - switch name { - case "anthropic": - cfg.Providers.Anthropic = pc - case "openai": - cfg.Providers.OpenAI = pc - case "openrouter": + if name == "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 } } } @@ -227,27 +208,9 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error } 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 diff --git a/pkg/migrate/migrate_test.go b/pkg/migrate/migrate_test.go index d93ea28fc..0a52fb514 100644 --- a/pkg/migrate/migrate_test.go +++ b/pkg/migrate/migrate_test.go @@ -91,15 +91,15 @@ func TestLoadOpenClawConfig(t *testing.T) { openclawConfig := map[string]interface{}{ "providers": map[string]interface{}{ - "anthropic": map[string]interface{}{ - "apiKey": "sk-ant-test123", - "apiBase": "https://api.anthropic.com", + "openrouter": map[string]interface{}{ + "apiKey": "sk-or-test123", + "apiBase": "https://openrouter.ai/api/v1", }, }, "agents": map[string]interface{}{ "defaults": map[string]interface{}{ "maxTokens": float64(4096), - "model": "claude-3-opus", + "model": "meta-llama/llama-3-8b-instruct", }, }, } @@ -121,12 +121,12 @@ func TestLoadOpenClawConfig(t *testing.T) { if !ok { t.Fatal("expected providers map") } - anthropic, ok := providers["anthropic"].(map[string]interface{}) + openrouter, ok := providers["openrouter"].(map[string]interface{}) if !ok { - t.Fatal("expected anthropic map") + t.Fatal("expected openrouter map") } - if anthropic["api_key"] != "sk-ant-test123" { - t.Errorf("api_key = %v, want sk-ant-test123", anthropic["api_key"]) + if openrouter["api_key"] != "sk-or-test123" { + t.Errorf("api_key = %v, want sk-or-test123", openrouter["api_key"]) } agents, ok := result["agents"].(map[string]interface{}) @@ -146,16 +146,9 @@ 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", - }, }, } @@ -166,15 +159,9 @@ func TestConvertConfig(t *testing.T) { 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) { @@ -256,7 +243,7 @@ func TestConvertConfig(t *testing.T) { data := map[string]interface{}{ "agents": map[string]interface{}{ "defaults": map[string]interface{}{ - "model": "claude-3-opus", + "model": "meta-llama/llama-3-8b-instruct", "max_tokens": float64(4096), "temperature": 0.5, "max_tool_iterations": float64(10), @@ -269,8 +256,8 @@ func TestConvertConfig(t *testing.T) { 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.Model != "meta-llama/llama-3-8b-instruct" { + t.Errorf("Model = %q, want %q", cfg.Agents.Defaults.Model, "meta-llama/llama-3-8b-instruct") } if cfg.Agents.Defaults.MaxTokens != 4096 { t.Errorf("MaxTokens = %d, want %d", cfg.Agents.Defaults.MaxTokens, 4096) @@ -293,8 +280,8 @@ func TestConvertConfig(t *testing.T) { 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) + if cfg.Agents.Defaults.Model != "openrouter" { + t.Errorf("default model should be openrouter, got %q", cfg.Agents.Defaults.Model) } }) } @@ -303,13 +290,9 @@ 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") } @@ -317,18 +300,14 @@ func TestMergeConfig(t *testing.T) { t.Run("preserves existing non-empty fields", func(t *testing.T) { existing := config.DefaultConfig() - existing.Providers.Anthropic.APIKey = "sk-ant-existing" + existing.Providers.OpenRouter.APIKey = "sk-or-existing" incoming := config.DefaultConfig() - incoming.Providers.Anthropic.APIKey = "sk-ant-incoming" - incoming.Providers.OpenAI.APIKey = "sk-oai-incoming" + incoming.Providers.OpenRouter.APIKey = "sk-or-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) + if result.Providers.OpenRouter.APIKey != "sk-or-existing" { + t.Errorf("OpenRouter.APIKey should be preserved, got %q", result.Providers.OpenRouter.APIKey) } }) @@ -346,21 +325,6 @@ func TestMergeConfig(t *testing.T) { 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) { @@ -390,106 +354,6 @@ func TestPlanWorkspaceMigration(t *testing.T) { 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") - } }) } @@ -507,44 +371,6 @@ func TestFindOpenClawConfig(t *testing.T) { 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) { @@ -566,289 +392,3 @@ func TestRewriteWorkspacePath(t *testing.T) { }) } } - -func TestRunDryRun(t *testing.T) { - openclawHome := t.TempDir() - picoClawHome := 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, - PicoClawHome: picoClawHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - picoWs := filepath.Join(picoClawHome, "workspace") - if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("dry run should not create files") - } - if _, err := os.Stat(filepath.Join(picoClawHome, "config.json")); !os.IsNotExist(err) { - t.Error("dry run should not create config") - } - - _ = result -} - -func TestRunFullMigration(t *testing.T) { - openclawHome := t.TempDir() - picoClawHome := 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, - PicoClawHome: picoClawHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - picoWs := filepath.Join(picoClawHome, "workspace") - - soulData, err := os.ReadFile(filepath.Join(picoWs, "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(picoWs, "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(picoWs, "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)) - } - - picoConfig, err := config.LoadConfig(filepath.Join(picoClawHome, "config.json")) - if err != nil { - t.Fatalf("loading PicoClaw config: %v", err) - } - if picoConfig.Providers.Anthropic.APIKey != "sk-ant-migrate-test" { - t.Errorf("Anthropic.APIKey = %q, want %q", picoConfig.Providers.Anthropic.APIKey, "sk-ant-migrate-test") - } - if picoConfig.Providers.OpenRouter.APIKey != "sk-or-migrate-test" { - t.Errorf("OpenRouter.APIKey = %q, want %q", picoConfig.Providers.OpenRouter.APIKey, "sk-or-migrate-test") - } - if !picoConfig.Channels.Telegram.Enabled { - t.Error("Telegram should be enabled") - } - if picoConfig.Channels.Telegram.Token != "tg-migrate-test" { - t.Errorf("Telegram.Token = %q, want %q", picoConfig.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", - PicoClawHome: 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() - picoClawHome := 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, - PicoClawHome: picoClawHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if !result.ConfigMigrated { - t.Error("config should have been migrated") - } - - picoWs := filepath.Join(picoClawHome, "workspace") - if _, err := os.Stat(filepath.Join(picoWs, "SOUL.md")); !os.IsNotExist(err) { - t.Error("config-only should not copy workspace files") - } -} - -func TestRunWorkspaceOnly(t *testing.T) { - openclawHome := t.TempDir() - picoClawHome := 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, - PicoClawHome: picoClawHome, - } - - result, err := Run(opts) - if err != nil { - t.Fatalf("Run: %v", err) - } - - if result.ConfigMigrated { - t.Error("workspace-only should not migrate config") - } - - picoWs := filepath.Join(picoClawHome, "workspace") - soulData, err := os.ReadFile(filepath.Join(picoWs, "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/providers/claude_provider.go b/pkg/providers/claude_provider.go deleted file mode 100644 index ae6aca96d..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/sipeed/picoclaw/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: picoclaw 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_provider.go b/pkg/providers/codex_provider.go deleted file mode 100644 index 3463389a5..000000000 --- a/pkg/providers/codex_provider.go +++ /dev/null @@ -1,248 +0,0 @@ -package providers - -import ( - "context" - "encoding/json" - "fmt" - "strings" - - "github.com/openai/openai-go/v3" - "github.com/openai/openai-go/v3/option" - "github.com/openai/openai-go/v3/responses" - "github.com/sipeed/picoclaw/pkg/auth" -) - -type CodexProvider struct { - client *openai.Client - accountID string - tokenSource func() (string, string, error) -} - -func NewCodexProvider(token, accountID string) *CodexProvider { - opts := []option.RequestOption{ - option.WithBaseURL("https://chatgpt.com/backend-api/codex"), - option.WithAPIKey(token), - } - 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 - 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 != "" { - opts = append(opts, option.WithHeader("Chatgpt-Account-Id", accID)) - } - } - - params := buildCodexParams(messages, tools, model, options) - - resp, err := p.client.Responses.New(ctx, params, opts...) - if err != nil { - return nil, fmt.Errorf("codex API call: %w", err) - } - - return parseCodexResponse(resp), nil -} - -func (p *CodexProvider) GetDefaultModel() string { - return "gpt-4o" -} - -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, - }, - Store: openai.Opt(false), - } - - if instructions != "" { - params.Instructions = openai.Opt(instructions) - } - - if maxTokens, ok := options["max_tokens"].(int); ok { - params.MaxOutputTokens = openai.Opt(int64(maxTokens)) - } - - if temp, ok := options["temperature"].(float64); ok { - params.Temperature = openai.Opt(temp) - } - - 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: picoclaw 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 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 605183d5e..000000000 --- a/pkg/providers/codex_provider_test.go +++ /dev/null @@ -1,264 +0,0 @@ -package providers - -import ( - "encoding/json" - "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, - }) - if params.Model != "gpt-4o" { - t.Errorf("Model = %q, want %q", params.Model, "gpt-4o") - } -} - -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 - } - - 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}, - }, - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(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_GetDefaultModel(t *testing.T) { - p := NewCodexProvider("test-token", "") - if got := p.GetDefaultModel(); got != "gpt-4o" { - t.Errorf("GetDefaultModel() = %q, want %q", got, "gpt-4o") - } -} - -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 -} diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index e982e0988..357a240c3 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -13,9 +13,7 @@ import ( "fmt" "io" "net/http" - "strings" - "github.com/sipeed/picoclaw/pkg/auth" "github.com/sipeed/picoclaw/pkg/config" ) @@ -51,12 +49,7 @@ func (p *HTTPProvider) Chat(ctx context.Context, messages []Message, tools []Too } if maxTokens, ok := options["max_tokens"].(int); ok { - lowerModel := strings.ToLower(model) - if strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "o1") { - requestBody["max_completion_tokens"] = maxTokens - } else { - requestBody["max_tokens"] = maxTokens - } + requestBody["max_tokens"] = maxTokens } if temperature, ok := options["temperature"].(float64); ok { @@ -170,177 +163,12 @@ 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: picoclaw auth login --provider anthropic") - } - return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil -} - -func createCodexAuthProvider() (LLMProvider, error) { - cred, err := auth.GetCredential("openai") - if err != nil { - return nil, fmt.Errorf("loading auth credentials: %w", err) - } - if cred == nil { - return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") - } - return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil -} - func CreateProvider(cfg *config.Config) (LLMProvider, error) { - model := cfg.Agents.Defaults.Model - providerName := strings.ToLower(cfg.Agents.Defaults.Provider) + apiKey := cfg.GetAPIKey() + apiBase := cfg.GetAPIBase() - var apiKey, apiBase 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 == "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 - } - } - } - - // Fallback: detect provider from model name - if apiKey == "" && apiBase == "" { - switch { case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"): - apiKey = cfg.Providers.OpenRouter.APIKey - 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 - if apiBase == "" { - apiBase = "https://api.anthropic.com/v1" - } - - case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""): - if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" { - return createCodexAuthProvider() - } - apiKey = cfg.Providers.OpenAI.APIKey - apiBase = cfg.Providers.OpenAI.APIBase - if apiBase == "" { - apiBase = "https://api.openai.com/v1" - } - - case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "": - apiKey = cfg.Providers.Gemini.APIKey - apiBase = cfg.Providers.Gemini.APIBase - if apiBase == "" { - apiBase = "https://generativelanguage.googleapis.com/v1beta" - } - - case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "": - apiKey = cfg.Providers.Zhipu.APIKey - apiBase = cfg.Providers.Zhipu.APIBase - if apiBase == "" { - apiBase = "https://open.bigmodel.cn/api/paas/v4" - } - - case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "": - apiKey = cfg.Providers.Groq.APIKey - apiBase = cfg.Providers.Groq.APIBase - if apiBase == "" { - apiBase = "https://api.groq.com/openai/v1" - } - - case cfg.Providers.VLLM.APIBase != "": - apiKey = cfg.Providers.VLLM.APIKey - apiBase = cfg.Providers.VLLM.APIBase - - default: - if cfg.Providers.OpenRouter.APIKey != "" { - apiKey = cfg.Providers.OpenRouter.APIKey - 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) + if apiKey == "" { + return nil, fmt.Errorf("OpenRouter API key not configured") } return NewHTTPProvider(apiKey, apiBase), nil diff --git a/pkg/voice/transcriber.go b/pkg/voice/transcriber.go deleted file mode 100644 index 9af2ea6bb..000000000 --- a/pkg/voice/transcriber.go +++ /dev/null @@ -1,159 +0,0 @@ -package voice - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "time" - - "github.com/sipeed/picoclaw/pkg/logger" - "github.com/sipeed/picoclaw/pkg/utils" -) - -type GroqTranscriber struct { - apiKey string - apiBase string - httpClient *http.Client -} - -type TranscriptionResponse struct { - Text string `json:"text"` - Language string `json:"language,omitempty"` - Duration float64 `json:"duration,omitempty"` -} - -func NewGroqTranscriber(apiKey string) *GroqTranscriber { - logger.DebugCF("voice", "Creating Groq transcriber", map[string]interface{}{"has_api_key": apiKey != ""}) - - apiBase := "https://api.groq.com/openai/v1" - return &GroqTranscriber{ - apiKey: apiKey, - apiBase: apiBase, - httpClient: &http.Client{ - Timeout: 60 * time.Second, - }, - } -} - -func (t *GroqTranscriber) Transcribe(ctx context.Context, audioFilePath string) (*TranscriptionResponse, error) { - logger.InfoCF("voice", "Starting transcription", map[string]interface{}{"audio_file": audioFilePath}) - - audioFile, err := os.Open(audioFilePath) - if err != nil { - logger.ErrorCF("voice", "Failed to open audio file", map[string]interface{}{"path": audioFilePath, "error": err}) - return nil, fmt.Errorf("failed to open audio file: %w", err) - } - defer audioFile.Close() - - fileInfo, err := audioFile.Stat() - if err != nil { - logger.ErrorCF("voice", "Failed to get file info", map[string]interface{}{"path": audioFilePath, "error": err}) - return nil, fmt.Errorf("failed to get file info: %w", err) - } - - logger.DebugCF("voice", "Audio file details", map[string]interface{}{ - "size_bytes": fileInfo.Size(), - "file_name": filepath.Base(audioFilePath), - }) - - var requestBody bytes.Buffer - writer := multipart.NewWriter(&requestBody) - - part, err := writer.CreateFormFile("file", filepath.Base(audioFilePath)) - if err != nil { - logger.ErrorCF("voice", "Failed to create form file", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to create form file: %w", err) - } - - copied, err := io.Copy(part, audioFile) - if err != nil { - logger.ErrorCF("voice", "Failed to copy file content", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to copy file content: %w", err) - } - - logger.DebugCF("voice", "File copied to request", map[string]interface{}{"bytes_copied": copied}) - - if err := writer.WriteField("model", "whisper-large-v3"); err != nil { - logger.ErrorCF("voice", "Failed to write model field", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to write model field: %w", err) - } - - if err := writer.WriteField("response_format", "json"); err != nil { - logger.ErrorCF("voice", "Failed to write response_format field", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to write response_format field: %w", err) - } - - if err := writer.Close(); err != nil { - logger.ErrorCF("voice", "Failed to close multipart writer", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to close multipart writer: %w", err) - } - - url := t.apiBase + "/audio/transcriptions" - req, err := http.NewRequestWithContext(ctx, "POST", url, &requestBody) - if err != nil { - logger.ErrorCF("voice", "Failed to create request", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", writer.FormDataContentType()) - req.Header.Set("Authorization", "Bearer "+t.apiKey) - - logger.DebugCF("voice", "Sending transcription request to Groq API", map[string]interface{}{ - "url": url, - "request_size_bytes": requestBody.Len(), - "file_size_bytes": fileInfo.Size(), - }) - - resp, err := t.httpClient.Do(req) - if err != nil { - logger.ErrorCF("voice", "Failed to send request", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to send request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - logger.ErrorCF("voice", "Failed to read response", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to read response: %w", err) - } - - if resp.StatusCode != http.StatusOK { - logger.ErrorCF("voice", "API error", map[string]interface{}{ - "status_code": resp.StatusCode, - "response": string(body), - }) - return nil, fmt.Errorf("API error (status %d): %s", resp.StatusCode, string(body)) - } - - logger.DebugCF("voice", "Received response from Groq API", map[string]interface{}{ - "status_code": resp.StatusCode, - "response_size_bytes": len(body), - }) - - var result TranscriptionResponse - if err := json.Unmarshal(body, &result); err != nil { - logger.ErrorCF("voice", "Failed to unmarshal response", map[string]interface{}{"error": err}) - return nil, fmt.Errorf("failed to unmarshal response: %w", err) - } - - logger.InfoCF("voice", "Transcription completed successfully", map[string]interface{}{ - "text_length": len(result.Text), - "language": result.Language, - "duration_seconds": result.Duration, - "transcription_preview": utils.Truncate(result.Text, 50), - }) - - return &result, nil -} - -func (t *GroqTranscriber) IsAvailable() bool { - available := t.apiKey != "" - logger.DebugCF("voice", "Checking transcriber availability", map[string]interface{}{"available": available}) - return available -} diff --git a/render.yaml b/render.yaml index f3d9cac47..6d001aef4 100644 --- a/render.yaml +++ b/render.yaml @@ -4,4 +4,10 @@ services: env: docker plan: free dockerfilePath: ./Dockerfile -startCommand: ./app gateway + envVars: + - key: OPENROUTER_API_KEY + value: your_openrouter_api_key_here + - key: TELEGRAM_BOT_TOKEN + value: your_telegram_bot_token_here + - key: TELEGRAM_CHAT_ID + value: your_chat_id_here diff --git a/skills/summarize/SKILL.md b/skills/summarize/SKILL.md index 766ab5d0b..0ad380576 100644 --- a/skills/summarize/SKILL.md +++ b/skills/summarize/SKILL.md @@ -38,8 +38,6 @@ If the user asked for a transcript but it’s huge, return a tight summary first ## Model + keys Set the API key for your chosen provider: -- OpenAI: `OPENAI_API_KEY` -- Anthropic: `ANTHROPIC_API_KEY` - xAI: `XAI_API_KEY` - Google: `GEMINI_API_KEY` (aliases: `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`)