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,
+ })
+}