diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 31d8dadeb..17c4a271d 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -12,7 +12,9 @@ import ( "fmt" "io" "os" + "os/exec" "os/signal" + "os/user" "path/filepath" "runtime" "strings" @@ -179,7 +181,7 @@ func printHelp() { 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(" gateway Start gateway or install gateway service") fmt.Println(" status Show picoclaw status") fmt.Println(" cron Manage scheduled tasks") fmt.Println(" migrate Migrate from OpenClaw to PicoClaw") @@ -606,8 +608,19 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { } func gatewayCmd() { - // Check for --debug flag args := os.Args[2:] + if len(args) > 0 { + switch args[0] { + case "install": + gatewayInstallCmd() + return + case "--help", "-h", "help": + gatewayHelp() + return + } + } + + // Check for --debug flag for _, arg := range args { if arg == "--debug" || arg == "-d" { logger.SetLevel(logger.DEBUG) @@ -734,6 +747,109 @@ func gatewayCmd() { fmt.Println("✓ Gateway stopped") } +func gatewayHelp() { + fmt.Println("\nGateway commands:") + fmt.Println(" picoclaw gateway Start gateway") + fmt.Println(" picoclaw gateway install Install and start systemd service") + fmt.Println(" picoclaw gateway --debug Start gateway with debug logging") + fmt.Println() +} + +func gatewayInstallCmd() { + if runtime.GOOS != "linux" { + fmt.Println("Error: gateway install is only supported on Linux with systemd") + os.Exit(1) + } + + if os.Geteuid() != 0 { + fmt.Println("Error: gateway install requires root privileges") + fmt.Println("Run: sudo picoclaw gateway install") + os.Exit(1) + } + + if _, err := exec.LookPath("systemctl"); err != nil { + fmt.Println("Error: systemctl not found") + os.Exit(1) + } + + serviceUser, serviceHome, err := resolveGatewayServiceUser() + if err != nil { + fmt.Printf("Error resolving service user: %v\n", err) + os.Exit(1) + } + + execPath, err := os.Executable() + if err != nil { + fmt.Printf("Error resolving executable path: %v\n", err) + os.Exit(1) + } + if resolved, err := filepath.EvalSymlinks(execPath); err == nil { + execPath = resolved + } + + unitContent := buildGatewayServiceUnit(serviceUser, serviceHome, execPath) + servicePath := "/etc/systemd/system/picoclaw.service" + if err := os.WriteFile(servicePath, []byte(unitContent), 0644); err != nil { + fmt.Printf("Error writing service file: %v\n", err) + os.Exit(1) + } + + if out, err := exec.Command("systemctl", "daemon-reload").CombinedOutput(); err != nil { + fmt.Printf("Error running systemctl daemon-reload: %v\n%s\n", err, strings.TrimSpace(string(out))) + os.Exit(1) + } + if out, err := exec.Command("systemctl", "enable", "--now", "picoclaw").CombinedOutput(); err != nil { + fmt.Printf("Error enabling/starting service: %v\n%s\n", err, strings.TrimSpace(string(out))) + os.Exit(1) + } + + fmt.Println("✓ Installed systemd service: /etc/systemd/system/picoclaw.service") + fmt.Println("✓ Enabled and started: picoclaw") + fmt.Println("Check status: systemctl status picoclaw --no-pager") + fmt.Println("View logs: journalctl -u picoclaw -f") +} + +func resolveGatewayServiceUser() (string, string, error) { + if sudoUser := strings.TrimSpace(os.Getenv("SUDO_USER")); sudoUser != "" { + u, err := user.Lookup(sudoUser) + if err != nil { + return "", "", err + } + return u.Username, u.HomeDir, nil + } + + u, err := user.Current() + if err != nil { + return "", "", err + } + return u.Username, u.HomeDir, nil +} + +func buildGatewayServiceUnit(serviceUser, serviceHome, execPath string) string { + return fmt.Sprintf(`[Unit] +Description=PicoClaw Gateway +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=%s +WorkingDirectory=%s +ExecStart=%s gateway +Restart=always +RestartSec=5 +Environment=HOME=%s +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=full +ProtectHome=false +ReadWritePaths=%s/.picoclaw + +[Install] +WantedBy=multi-user.target +`, serviceUser, serviceHome, execPath, serviceHome, serviceHome) +} + func statusCmd() { cfg, err := loadConfig() if err != nil { @@ -766,6 +882,7 @@ func statusCmd() { hasOpenAI := cfg.Providers.OpenAI.APIKey != "" hasGemini := cfg.Providers.Gemini.APIKey != "" hasZhipu := cfg.Providers.Zhipu.APIKey != "" + hasZAI := cfg.Providers.ZAI.APIKey != "" hasGroq := cfg.Providers.Groq.APIKey != "" hasVLLM := cfg.Providers.VLLM.APIBase != "" @@ -780,6 +897,7 @@ func statusCmd() { fmt.Println("OpenAI API:", status(hasOpenAI)) fmt.Println("Gemini API:", status(hasGemini)) fmt.Println("Zhipu API:", status(hasZhipu)) + fmt.Println("Z.AI API:", status(hasZAI)) fmt.Println("Groq API:", status(hasGroq)) if hasVLLM { fmt.Printf("vLLM/Local: ✓ %s\n", cfg.Providers.VLLM.APIBase) diff --git a/config/config.example.json b/config/config.example.json index ed5cb7048..281f12342 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -74,6 +74,10 @@ "api_key": "YOUR_ZHIPU_API_KEY", "api_base": "" }, + "zai": { + "api_key": "YOUR_ZAI_API_KEY", + "api_base": "https://api.z.ai/api/paas/v4" + }, "gemini": { "api_key": "", "api_base": "" @@ -90,6 +94,10 @@ "moonshot": { "api_key": "sk-xxx", "api_base": "" + }, + "zen": { + "api_key": "YOUR_OPENCODE_API_KEY", + "api_base": "https://opencode.ai/zen/v1" } }, "tools": { diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 3ad4818c3..021807a3d 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -27,7 +27,6 @@ type TelegramChannel struct { config config.TelegramConfig chatIDs map[string]int64 transcriber *voice.GroqTranscriber - placeholders sync.Map // chatID -> messageID stopThinking sync.Map // chatID -> thinkingCancel } @@ -69,7 +68,6 @@ func NewTelegramChannel(cfg config.TelegramConfig, bus *bus.MessageBus) (*Telegr config: cfg, chatIDs: make(map[string]int64), transcriber: nil, - placeholders: sync.Map{}, stopThinking: sync.Map{}, }, nil } @@ -139,18 +137,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err htmlContent := markdownToTelegramHTML(msg.Content) - // Try to edit placeholder - if pID, ok := c.placeholders.Load(msg.ChatID); ok { - c.placeholders.Delete(msg.ChatID) - editMsg := tu.EditMessageText(tu.ID(chatID), pID.(int), htmlContent) - editMsg.ParseMode = telego.ModeHTML - - 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) tgMsg.ParseMode = telego.ModeHTML @@ -302,15 +288,7 @@ 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(), - }) - } - - // Stop any previous thinking animation + // Stop any previous typing indicator chatIDStr := fmt.Sprintf("%d", chatID) if prevStop, ok := c.stopThinking.Load(chatIDStr); ok { if cf, ok := prevStop.(*thinkingCancel); ok && cf != nil { @@ -318,38 +296,26 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, update telego.Updat } } - // Create new context for thinking animation with timeout + // Keep typing status active while processing the request. 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... 💭")) - 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() - for { - select { - case <-thinkCtx.Done(): - 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(), - }) - } - } + go func(cid int64) { + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + for { + if err := c.bot.SendChatAction(thinkCtx, tu.ChatAction(tu.ID(cid), telego.ChatActionTyping)); err != nil { + logger.DebugCF("telegram", "Failed to send chat action", map[string]interface{}{ + "error": err.Error(), + }) } - }(chatID, pID) - } + select { + case <-thinkCtx.Done(): + return + case <-ticker.C: + } + } + }(chatID) metadata := map[string]string{ "message_id": fmt.Sprintf("%d", message.MessageID), @@ -418,9 +384,9 @@ func markdownToTelegramHTML(text string) string { inlineCodes := extractInlineCodes(text) text = inlineCodes.text - text = regexp.MustCompile(`^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`^>\s*(.*)$`).ReplaceAllString(text, "$1") + text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1") text = escapeHTML(text) @@ -441,7 +407,9 @@ func markdownToTelegramHTML(text string) string { text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "$1") - text = regexp.MustCompile(`^[-*]\s+`).ReplaceAllString(text, "• ") + text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ") + + text = regexp.MustCompile(`(?m)^\d+\.\s+`).ReplaceAllString(text, "• ") for i, code := range inlineCodes.codes { escaped := escapeHTML(code) @@ -470,8 +438,11 @@ func extractCodeBlocks(text string) codeBlockMatch { codes = append(codes, match[1]) } + idx := 0 text = re.ReplaceAllStringFunc(text, func(m string) string { - return fmt.Sprintf("\x00CB%d\x00", len(codes)-1) + placeholder := fmt.Sprintf("\x00CB%d\x00", idx) + idx++ + return placeholder }) return codeBlockMatch{text: text, codes: codes} @@ -491,8 +462,11 @@ func extractInlineCodes(text string) inlineCodeMatch { codes = append(codes, match[1]) } + idx := 0 text = re.ReplaceAllStringFunc(text, func(m string) string { - return fmt.Sprintf("\x00IC%d\x00", len(codes)-1) + placeholder := fmt.Sprintf("\x00IC%d\x00", idx) + idx++ + return placeholder }) return inlineCodeMatch{text: text, codes: codes} diff --git a/pkg/channels/telegram_test.go b/pkg/channels/telegram_test.go new file mode 100644 index 000000000..158e56496 --- /dev/null +++ b/pkg/channels/telegram_test.go @@ -0,0 +1,45 @@ +package channels + +import ( + "strings" + "testing" +) + +func TestMarkdownToTelegramHTML_MultilineFormatting(t *testing.T) { + input := "## Titulo\n- item um\n- item dois\n1. item tres\n> citação" + got := markdownToTelegramHTML(input) + + if strings.Contains(got, "##") { + t.Fatalf("heading marker should be removed, got: %q", got) + } + if !strings.Contains(got, "• item um") || !strings.Contains(got, "• item dois") || !strings.Contains(got, "• item tres") { + t.Fatalf("list markers should be normalized, got: %q", got) + } + if strings.Contains(got, "> citação") { + t.Fatalf("blockquote marker should be removed, got: %q", got) + } +} + +func TestMarkdownToTelegramHTML_MultipleCodeBlocks(t *testing.T) { + input := "```go\nfmt.Println(1)\n```\ntexto\n```js\nconsole.log(2)\n```" + got := markdownToTelegramHTML(input) + + if strings.Count(got, "
") != 2 {
+		t.Fatalf("expected 2 code blocks, got: %q", got)
+	}
+	if !strings.Contains(got, "fmt.Println(1)") || !strings.Contains(got, "console.log(2)") {
+		t.Fatalf("missing code block contents, got: %q", got)
+	}
+}
+
+func TestMarkdownToTelegramHTML_MultipleInlineCodes(t *testing.T) {
+	input := "use `foo` and `bar`"
+	got := markdownToTelegramHTML(input)
+
+	if strings.Count(got, "") != 2 {
+		t.Fatalf("expected 2 inline code tags, got: %q", got)
+	}
+	if !strings.Contains(got, "foo") || !strings.Contains(got, "bar") {
+		t.Fatalf("missing inline code contents, got: %q", got)
+	}
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index 56f1e1958..a24f014bb 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -139,10 +139,12 @@ type ProvidersConfig struct {
 	OpenRouter ProviderConfig `json:"openrouter"`
 	Groq       ProviderConfig `json:"groq"`
 	Zhipu      ProviderConfig `json:"zhipu"`
+	ZAI        ProviderConfig `json:"zai"`
 	VLLM       ProviderConfig `json:"vllm"`
 	Gemini     ProviderConfig `json:"gemini"`
 	Nvidia     ProviderConfig `json:"nvidia"`
 	Moonshot   ProviderConfig `json:"moonshot"`
+	Zen        ProviderConfig `json:"zen"`
 }
 
 type ProviderConfig struct {
@@ -238,10 +240,12 @@ func DefaultConfig() *Config {
 			OpenRouter: ProviderConfig{},
 			Groq:       ProviderConfig{},
 			Zhipu:      ProviderConfig{},
+			ZAI:        ProviderConfig{},
 			VLLM:       ProviderConfig{},
 			Gemini:     ProviderConfig{},
 			Nvidia:     ProviderConfig{},
 			Moonshot:   ProviderConfig{},
+			Zen:        ProviderConfig{},
 		},
 		Gateway: GatewayConfig{
 			Host: "0.0.0.0",
diff --git a/pkg/migrate/config.go b/pkg/migrate/config.go
index d7fa63305..b2478af28 100644
--- a/pkg/migrate/config.go
+++ b/pkg/migrate/config.go
@@ -17,6 +17,7 @@ var supportedProviders = map[string]bool{
 	"openrouter": true,
 	"groq":       true,
 	"zhipu":      true,
+	"zai":        true,
 	"vllm":       true,
 	"gemini":     true,
 }
@@ -115,6 +116,8 @@ func ConvertConfig(data map[string]interface{}) (*config.Config, []string, error
 				cfg.Providers.Groq = pc
 			case "zhipu":
 				cfg.Providers.Zhipu = pc
+			case "zai":
+				cfg.Providers.ZAI = pc
 			case "vllm":
 				cfg.Providers.VLLM = pc
 			case "gemini":
@@ -242,6 +245,9 @@ func MergeConfig(existing, incoming *config.Config) *config.Config {
 	if existing.Providers.Zhipu.APIKey == "" {
 		existing.Providers.Zhipu = incoming.Providers.Zhipu
 	}
+	if existing.Providers.ZAI.APIKey == "" {
+		existing.Providers.ZAI = incoming.Providers.ZAI
+	}
 	if existing.Providers.VLLM.APIKey == "" && existing.Providers.VLLM.APIBase == "" {
 		existing.Providers.VLLM = incoming.Providers.VLLM
 	}
diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go
index 7179c4cc5..f5c128924 100644
--- a/pkg/providers/http_provider.go
+++ b/pkg/providers/http_provider.go
@@ -219,186 +219,5 @@ func createCodexAuthProvider() (LLMProvider, error) {
 }
 
 func CreateProvider(cfg *config.Config) (LLMProvider, error) {
-	model := cfg.Agents.Defaults.Model
-	providerName := strings.ToLower(cfg.Agents.Defaults.Provider)
-
-	var apiKey, apiBase, proxy string
-
-	lowerModel := strings.ToLower(model)
-
-	// First, try to use explicitly configured provider
-	if providerName != "" {
-		switch providerName {
-		case "groq":
-			if cfg.Providers.Groq.APIKey != "" {
-				apiKey = cfg.Providers.Groq.APIKey
-				apiBase = cfg.Providers.Groq.APIBase
-				if apiBase == "" {
-					apiBase = "https://api.groq.com/openai/v1"
-				}
-			}
-		case "openai", "gpt":
-			if cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != "" {
-				if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
-					return createCodexAuthProvider()
-				}
-				apiKey = cfg.Providers.OpenAI.APIKey
-				apiBase = cfg.Providers.OpenAI.APIBase
-				if apiBase == "" {
-					apiBase = "https://api.openai.com/v1"
-				}
-			}
-		case "anthropic", "claude":
-			if cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != "" {
-				if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
-					return createClaudeAuthProvider()
-				}
-				apiKey = cfg.Providers.Anthropic.APIKey
-				apiBase = cfg.Providers.Anthropic.APIBase
-				if apiBase == "" {
-					apiBase = "https://api.anthropic.com/v1"
-				}
-			}
-		case "openrouter":
-			if cfg.Providers.OpenRouter.APIKey != "" {
-				apiKey = cfg.Providers.OpenRouter.APIKey
-				if cfg.Providers.OpenRouter.APIBase != "" {
-					apiBase = cfg.Providers.OpenRouter.APIBase
-				} else {
-					apiBase = "https://openrouter.ai/api/v1"
-				}
-			}
-		case "zhipu", "glm":
-			if cfg.Providers.Zhipu.APIKey != "" {
-				apiKey = cfg.Providers.Zhipu.APIKey
-				apiBase = cfg.Providers.Zhipu.APIBase
-				if apiBase == "" {
-					apiBase = "https://open.bigmodel.cn/api/paas/v4"
-				}
-			}
-		case "gemini", "google":
-			if cfg.Providers.Gemini.APIKey != "" {
-				apiKey = cfg.Providers.Gemini.APIKey
-				apiBase = cfg.Providers.Gemini.APIBase
-				if apiBase == "" {
-					apiBase = "https://generativelanguage.googleapis.com/v1beta"
-				}
-			}
-		case "vllm":
-			if cfg.Providers.VLLM.APIBase != "" {
-				apiKey = cfg.Providers.VLLM.APIKey
-				apiBase = cfg.Providers.VLLM.APIBase
-			}
-		case "claude-cli", "claudecode", "claude-code":
-			workspace := cfg.Agents.Defaults.Workspace
-			if workspace == "" {
-				workspace = "."
-			}
-			return NewClaudeCliProvider(workspace), nil
-		}
-	}
-
-	// Fallback: detect provider from model name
-	if apiKey == "" && apiBase == "" {
-		switch {
-		case (strings.Contains(lowerModel, "kimi") || strings.Contains(lowerModel, "moonshot") || strings.HasPrefix(model, "moonshot/")) && cfg.Providers.Moonshot.APIKey != "":
-			apiKey = cfg.Providers.Moonshot.APIKey
-			apiBase = cfg.Providers.Moonshot.APIBase
-			proxy = cfg.Providers.Moonshot.Proxy
-			if apiBase == "" {
-				apiBase = "https://api.moonshot.cn/v1"
-			}
-
-		case strings.HasPrefix(model, "openrouter/") || strings.HasPrefix(model, "anthropic/") || strings.HasPrefix(model, "openai/") || strings.HasPrefix(model, "meta-llama/") || strings.HasPrefix(model, "deepseek/") || strings.HasPrefix(model, "google/"):
-			apiKey = cfg.Providers.OpenRouter.APIKey
-			proxy = cfg.Providers.OpenRouter.Proxy
-			if cfg.Providers.OpenRouter.APIBase != "" {
-				apiBase = cfg.Providers.OpenRouter.APIBase
-			} else {
-				apiBase = "https://openrouter.ai/api/v1"
-			}
-
-		case (strings.Contains(lowerModel, "claude") || strings.HasPrefix(model, "anthropic/")) && (cfg.Providers.Anthropic.APIKey != "" || cfg.Providers.Anthropic.AuthMethod != ""):
-			if cfg.Providers.Anthropic.AuthMethod == "oauth" || cfg.Providers.Anthropic.AuthMethod == "token" {
-				return createClaudeAuthProvider()
-			}
-			apiKey = cfg.Providers.Anthropic.APIKey
-			apiBase = cfg.Providers.Anthropic.APIBase
-			proxy = cfg.Providers.Anthropic.Proxy
-			if apiBase == "" {
-				apiBase = "https://api.anthropic.com/v1"
-			}
-
-		case (strings.Contains(lowerModel, "gpt") || strings.HasPrefix(model, "openai/")) && (cfg.Providers.OpenAI.APIKey != "" || cfg.Providers.OpenAI.AuthMethod != ""):
-			if cfg.Providers.OpenAI.AuthMethod == "oauth" || cfg.Providers.OpenAI.AuthMethod == "token" {
-				return createCodexAuthProvider()
-			}
-			apiKey = cfg.Providers.OpenAI.APIKey
-			apiBase = cfg.Providers.OpenAI.APIBase
-			proxy = cfg.Providers.OpenAI.Proxy
-			if apiBase == "" {
-				apiBase = "https://api.openai.com/v1"
-			}
-
-		case (strings.Contains(lowerModel, "gemini") || strings.HasPrefix(model, "google/")) && cfg.Providers.Gemini.APIKey != "":
-			apiKey = cfg.Providers.Gemini.APIKey
-			apiBase = cfg.Providers.Gemini.APIBase
-			proxy = cfg.Providers.Gemini.Proxy
-			if apiBase == "" {
-				apiBase = "https://generativelanguage.googleapis.com/v1beta"
-			}
-
-		case (strings.Contains(lowerModel, "glm") || strings.Contains(lowerModel, "zhipu") || strings.Contains(lowerModel, "zai")) && cfg.Providers.Zhipu.APIKey != "":
-			apiKey = cfg.Providers.Zhipu.APIKey
-			apiBase = cfg.Providers.Zhipu.APIBase
-			proxy = cfg.Providers.Zhipu.Proxy
-			if apiBase == "" {
-				apiBase = "https://open.bigmodel.cn/api/paas/v4"
-			}
-
-		case (strings.Contains(lowerModel, "groq") || strings.HasPrefix(model, "groq/")) && cfg.Providers.Groq.APIKey != "":
-			apiKey = cfg.Providers.Groq.APIKey
-			apiBase = cfg.Providers.Groq.APIBase
-			proxy = cfg.Providers.Groq.Proxy
-			if apiBase == "" {
-				apiBase = "https://api.groq.com/openai/v1"
-			}
-
-		case (strings.Contains(lowerModel, "nvidia") || strings.HasPrefix(model, "nvidia/")) && cfg.Providers.Nvidia.APIKey != "":
-			apiKey = cfg.Providers.Nvidia.APIKey
-			apiBase = cfg.Providers.Nvidia.APIBase
-			proxy = cfg.Providers.Nvidia.Proxy
-			if apiBase == "" {
-				apiBase = "https://integrate.api.nvidia.com/v1"
-			}
-
-		case cfg.Providers.VLLM.APIBase != "":
-			apiKey = cfg.Providers.VLLM.APIKey
-			apiBase = cfg.Providers.VLLM.APIBase
-			proxy = cfg.Providers.VLLM.Proxy
-
-		default:
-			if cfg.Providers.OpenRouter.APIKey != "" {
-				apiKey = cfg.Providers.OpenRouter.APIKey
-				proxy = cfg.Providers.OpenRouter.Proxy
-				if cfg.Providers.OpenRouter.APIBase != "" {
-					apiBase = cfg.Providers.OpenRouter.APIBase
-				} else {
-					apiBase = "https://openrouter.ai/api/v1"
-				}
-			} else {
-				return nil, fmt.Errorf("no API key configured for model: %s", model)
-			}
-		}
-	}
-
-	if apiKey == "" && !strings.HasPrefix(model, "bedrock/") {
-		return nil, fmt.Errorf("no API key configured for provider (model: %s)", model)
-	}
-
-	if apiBase == "" {
-		return nil, fmt.Errorf("no API base configured for provider (model: %s)", model)
-	}
-
-	return NewHTTPProvider(apiKey, apiBase, proxy), nil
+	return defaultProviderRegistry.Create(cfg)
 }
diff --git a/pkg/providers/provider_registry.go b/pkg/providers/provider_registry.go
new file mode 100644
index 000000000..925642079
--- /dev/null
+++ b/pkg/providers/provider_registry.go
@@ -0,0 +1,192 @@
+package providers
+
+import (
+	"fmt"
+	"strings"
+
+	"github.com/sipeed/picoclaw/pkg/config"
+)
+
+type providerCreator func(cfg *config.Config, model string) (LLMProvider, bool, error)
+
+type providerRegistration struct {
+	Name          string
+	Aliases       []string
+	ModelPrefixes []string
+	Creator       providerCreator
+}
+
+type providerRegistry struct {
+	byName  map[string]providerRegistration
+	ordered []*providerRegistration
+}
+
+func newProviderRegistry() *providerRegistry {
+	return &providerRegistry{
+		byName:  make(map[string]providerRegistration),
+		ordered: make([]*providerRegistration, 0, 16),
+	}
+}
+
+func (r *providerRegistry) Register(reg providerRegistration) {
+	normalized := reg
+	normalized.Name = strings.ToLower(strings.TrimSpace(normalized.Name))
+	if normalized.Name == "" {
+		return
+	}
+
+	r.byName[normalized.Name] = normalized
+	for _, alias := range normalized.Aliases {
+		a := strings.ToLower(strings.TrimSpace(alias))
+		if a != "" {
+			r.byName[a] = normalized
+		}
+	}
+
+	normalizedCopy := normalized
+	r.ordered = append(r.ordered, &normalizedCopy)
+}
+
+func (r *providerRegistry) Create(cfg *config.Config) (LLMProvider, error) {
+	model := strings.TrimSpace(cfg.Agents.Defaults.Model)
+	providerName := strings.ToLower(strings.TrimSpace(cfg.Agents.Defaults.Provider))
+
+	if providerName != "" {
+		reg, ok := r.byName[providerName]
+		if !ok {
+			return nil, fmt.Errorf("unknown provider: %s", providerName)
+		}
+		provider, configured, err := reg.Creator(cfg, model)
+		if err != nil {
+			return nil, err
+		}
+		if !configured {
+			return nil, fmt.Errorf("provider '%s' is not configured", reg.Name)
+		}
+		return provider, nil
+	}
+
+	modelLower := strings.ToLower(model)
+	for _, reg := range r.ordered {
+		if !matchesModelPrefix(modelLower, reg.ModelPrefixes) {
+			continue
+		}
+		provider, configured, err := reg.Creator(cfg, model)
+		if err != nil {
+			return nil, err
+		}
+		if configured {
+			return provider, nil
+		}
+	}
+
+	if provider, configured, err := openRouterCreator(cfg, model); err != nil {
+		return nil, err
+	} else if configured {
+		return provider, nil
+	}
+
+	return nil, fmt.Errorf("no API key configured for model: %s", model)
+}
+
+func matchesModelPrefix(model string, prefixes []string) bool {
+	for _, prefix := range prefixes {
+		if strings.HasPrefix(model, strings.ToLower(prefix)) {
+			return true
+		}
+	}
+	return false
+}
+
+func createHTTPProviderFromConfig(pc config.ProviderConfig, defaultBase string, requireAPIKey bool, requireAPIBase bool) (LLMProvider, bool, error) {
+	if requireAPIKey && pc.APIKey == "" {
+		return nil, false, nil
+	}
+	if requireAPIBase && pc.APIBase == "" {
+		return nil, false, nil
+	}
+
+	apiBase := pc.APIBase
+	if apiBase == "" {
+		apiBase = defaultBase
+	}
+	if apiBase == "" {
+		return nil, false, fmt.Errorf("no API base configured for provider")
+	}
+
+	return NewHTTPProvider(pc.APIKey, apiBase, pc.Proxy), true, nil
+}
+
+func claudeCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	pc := cfg.Providers.Anthropic
+	if pc.AuthMethod == "oauth" || pc.AuthMethod == "token" {
+		p, err := createClaudeAuthProvider()
+		if err != nil {
+			return nil, false, err
+		}
+		return p, true, nil
+	}
+	return createHTTPProviderFromConfig(pc, "https://api.anthropic.com/v1", true, false)
+}
+
+func openAICreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	pc := cfg.Providers.OpenAI
+	if pc.AuthMethod == "oauth" || pc.AuthMethod == "token" {
+		p, err := createCodexAuthProvider()
+		if err != nil {
+			return nil, false, err
+		}
+		return p, true, nil
+	}
+	return createHTTPProviderFromConfig(pc, "https://api.openai.com/v1", true, false)
+}
+
+func openRouterCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.OpenRouter, "https://openrouter.ai/api/v1", true, false)
+}
+
+func groqCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.Groq, "https://api.groq.com/openai/v1", true, false)
+}
+
+func zhipuCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.Zhipu, "https://open.bigmodel.cn/api/paas/v4", true, false)
+}
+
+func zaiCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.ZAI, "https://api.z.ai/api/paas/v4", true, false)
+}
+
+func geminiCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.Gemini, "https://generativelanguage.googleapis.com/v1beta", true, false)
+}
+
+func nvidiaCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.Nvidia, "https://integrate.api.nvidia.com/v1", true, false)
+}
+
+func moonshotCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.Moonshot, "https://api.moonshot.cn/v1", true, false)
+}
+
+func vllmCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.VLLM, "", false, true)
+}
+
+func zenCreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	return createHTTPProviderFromConfig(cfg.Providers.Zen, "https://opencode.ai/zen/v1", true, false)
+}
+
+func claudeCLICreator(cfg *config.Config, _ string) (LLMProvider, bool, error) {
+	workspace := cfg.Agents.Defaults.Workspace
+	if workspace == "" {
+		workspace = "."
+	}
+	return NewClaudeCliProvider(workspace), true, nil
+}
+
+var defaultProviderRegistry = newProviderRegistry()
+
+func RegisterProvider(reg providerRegistration) {
+	defaultProviderRegistry.Register(reg)
+}
diff --git a/pkg/providers/provider_registry_defaults.go b/pkg/providers/provider_registry_defaults.go
new file mode 100644
index 000000000..b92480ea4
--- /dev/null
+++ b/pkg/providers/provider_registry_defaults.go
@@ -0,0 +1,74 @@
+package providers
+
+func init() {
+	RegisterProvider(providerRegistration{
+		Name:          "moonshot",
+		ModelPrefixes: []string{"moonshot/", "moonshot-", "kimi-"},
+		Creator:       moonshotCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "openrouter",
+		ModelPrefixes: []string{"openrouter/", "anthropic/", "openai/", "meta-llama/", "deepseek/", "google/"},
+		Creator:       openRouterCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "anthropic",
+		Aliases:       []string{"claude"},
+		ModelPrefixes: []string{"claude-", "anthropic/"},
+		Creator:       claudeCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "openai",
+		Aliases:       []string{"gpt"},
+		ModelPrefixes: []string{"gpt-", "o1-", "o3-", "o4-", "chatgpt-", "openai/"},
+		Creator:       openAICreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "gemini",
+		Aliases:       []string{"google"},
+		ModelPrefixes: []string{"gemini-", "google/"},
+		Creator:       geminiCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "zhipu",
+		Aliases:       []string{"glm"},
+		ModelPrefixes: []string{"glm-", "zhipu/", "zai-"},
+		Creator:       zhipuCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "zai",
+		Aliases:       []string{"z.ai"},
+		ModelPrefixes: []string{"zai/"},
+		Creator:       zaiCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "groq",
+		ModelPrefixes: []string{"groq/"},
+		Creator:       groqCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "nvidia",
+		ModelPrefixes: []string{"nvidia/"},
+		Creator:       nvidiaCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:          "vllm",
+		ModelPrefixes: []string{"vllm/"},
+		Creator:       vllmCreator,
+	})
+
+	RegisterProvider(providerRegistration{
+		Name:    "claude-cli",
+		Aliases: []string{"claudecode", "claude-code"},
+		Creator: claudeCLICreator,
+	})
+}
diff --git a/pkg/providers/provider_registry_test.go b/pkg/providers/provider_registry_test.go
new file mode 100644
index 000000000..d59acda9e
--- /dev/null
+++ b/pkg/providers/provider_registry_test.go
@@ -0,0 +1,191 @@
+package providers
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/sipeed/picoclaw/pkg/config"
+)
+
+func TestCreateProvider_ZenExplicit(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Provider = "zen"
+	cfg.Agents.Defaults.Model = "claude-sonnet-4.5"
+	cfg.Providers.Zen.APIKey = "zen-key"
+
+	provider, err := CreateProvider(cfg)
+	if err != nil {
+		t.Fatalf("CreateProvider(zen) error = %v", err)
+	}
+
+	httpProvider, ok := provider.(*HTTPProvider)
+	if !ok {
+		t.Fatalf("CreateProvider(zen) returned %T, want *HTTPProvider", provider)
+	}
+	if httpProvider.apiBase != "https://opencode.ai/zen/v1" {
+		t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://opencode.ai/zen/v1")
+	}
+	if httpProvider.apiKey != "zen-key" {
+		t.Errorf("apiKey = %q, want %q", httpProvider.apiKey, "zen-key")
+	}
+}
+
+func TestCreateProvider_ZAIExplicit(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Provider = "zai"
+	cfg.Agents.Defaults.Model = "glm-4.6"
+	cfg.Providers.ZAI.APIKey = "zai-key"
+
+	provider, err := CreateProvider(cfg)
+	if err != nil {
+		t.Fatalf("CreateProvider(zai) error = %v", err)
+	}
+
+	httpProvider, ok := provider.(*HTTPProvider)
+	if !ok {
+		t.Fatalf("CreateProvider(zai) returned %T, want *HTTPProvider", provider)
+	}
+	if httpProvider.apiBase != "https://api.z.ai/api/paas/v4" {
+		t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://api.z.ai/api/paas/v4")
+	}
+	if httpProvider.apiKey != "zai-key" {
+		t.Errorf("apiKey = %q, want %q", httpProvider.apiKey, "zai-key")
+	}
+}
+
+func TestCreateProvider_ZAIByModelPrefix(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Model = "zai/glm-4.6"
+	cfg.Providers.ZAI.APIKey = "zai-key"
+
+	provider, err := CreateProvider(cfg)
+	if err != nil {
+		t.Fatalf("CreateProvider(zai/*) error = %v", err)
+	}
+
+	httpProvider, ok := provider.(*HTTPProvider)
+	if !ok {
+		t.Fatalf("CreateProvider(zai/*) returned %T, want *HTTPProvider", provider)
+	}
+	if httpProvider.apiBase != "https://api.z.ai/api/paas/v4" {
+		t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://api.z.ai/api/paas/v4")
+	}
+}
+
+func TestCreateProvider_ZenByModelPrefix(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Model = "zen/kimi-k2.5-free"
+	cfg.Providers.Zen.APIKey = "zen-key"
+
+	provider, err := CreateProvider(cfg)
+	if err != nil {
+		t.Fatalf("CreateProvider(zen/*) error = %v", err)
+	}
+
+	httpProvider, ok := provider.(*HTTPProvider)
+	if !ok {
+		t.Fatalf("CreateProvider(zen/*) returned %T, want *HTTPProvider", provider)
+	}
+	if httpProvider.apiBase != "https://opencode.ai/zen/v1" {
+		t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://opencode.ai/zen/v1")
+	}
+}
+
+func TestCreateProvider_OpencodePrefixDoesNotAutoSelectZen(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Model = "opencode/kimi-k2.5-free"
+	cfg.Providers.Zen.APIKey = "zen-key"
+
+	_, err := CreateProvider(cfg)
+	if err == nil {
+		t.Fatal("CreateProvider(opencode/*) expected error, got nil")
+	}
+}
+
+func TestCreateProvider_ExplicitProviderNotConfiguredFails(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Provider = "zen"
+	cfg.Agents.Defaults.Model = "kimi-k2.5-free"
+
+	_, err := CreateProvider(cfg)
+	if err == nil {
+		t.Fatal("CreateProvider(zen explicit without api key) expected error, got nil")
+	}
+	if !strings.Contains(err.Error(), "provider 'zen' is not configured") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestCreateProvider_ExplicitUnknownProviderFails(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Provider = "unknown-provider"
+	cfg.Agents.Defaults.Model = "gpt-4o"
+	cfg.Providers.OpenRouter.APIKey = "or-key"
+
+	_, err := CreateProvider(cfg)
+	if err == nil {
+		t.Fatal("CreateProvider(unknown provider) expected error, got nil")
+	}
+	if !strings.Contains(err.Error(), "unknown provider: unknown-provider") {
+		t.Fatalf("unexpected error: %v", err)
+	}
+}
+
+func TestCreateProvider_MyGeminiProxyDoesNotAutoSelectGemini(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Model = "mygeminiproxy-v1"
+	cfg.Providers.Gemini.APIKey = "gem-key"
+	cfg.Providers.OpenRouter.APIKey = "or-key"
+
+	provider, err := CreateProvider(cfg)
+	if err != nil {
+		t.Fatalf("CreateProvider(ambiguous gemini model) error = %v", err)
+	}
+
+	httpProvider, ok := provider.(*HTTPProvider)
+	if !ok {
+		t.Fatalf("CreateProvider(ambiguous gemini model) returned %T, want *HTTPProvider", provider)
+	}
+	if httpProvider.apiBase != "https://openrouter.ai/api/v1" {
+		t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://openrouter.ai/api/v1")
+	}
+}
+
+func TestCreateProvider_FallbackOpenRouter(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Model = "custom/non-standard-model"
+	cfg.Providers.OpenRouter.APIKey = "or-key"
+
+	provider, err := CreateProvider(cfg)
+	if err != nil {
+		t.Fatalf("CreateProvider(default fallback) error = %v", err)
+	}
+
+	httpProvider, ok := provider.(*HTTPProvider)
+	if !ok {
+		t.Fatalf("CreateProvider(default fallback) returned %T, want *HTTPProvider", provider)
+	}
+	if httpProvider.apiBase != "https://openrouter.ai/api/v1" {
+		t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://openrouter.ai/api/v1")
+	}
+}
+
+func TestCreateProvider_ModelPrefixAvoidsAmbiguousContains(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Agents.Defaults.Model = "myclaudeproxy-v1"
+	cfg.Providers.Anthropic.APIKey = "anthropic-key"
+	cfg.Providers.OpenRouter.APIKey = "or-key"
+
+	provider, err := CreateProvider(cfg)
+	if err != nil {
+		t.Fatalf("CreateProvider(ambiguous-model) error = %v", err)
+	}
+
+	httpProvider, ok := provider.(*HTTPProvider)
+	if !ok {
+		t.Fatalf("CreateProvider(ambiguous-model) returned %T, want *HTTPProvider", provider)
+	}
+	if httpProvider.apiBase != "https://openrouter.ai/api/v1" {
+		t.Errorf("apiBase = %q, want %q", httpProvider.apiBase, "https://openrouter.ai/api/v1")
+	}
+}
diff --git a/pkg/providers/zen_provider.go b/pkg/providers/zen_provider.go
new file mode 100644
index 000000000..aad30222c
--- /dev/null
+++ b/pkg/providers/zen_provider.go
@@ -0,0 +1,9 @@
+package providers
+
+func init() {
+	RegisterProvider(providerRegistration{
+		Name:          "zen",
+		ModelPrefixes: []string{"zen/"},
+		Creator:       zenCreator,
+	})
+}