From f660143ddd65bffe78899df4a75b86576cfa8487 Mon Sep 17 00:00:00 2001 From: Henrique Costa Date: Thu, 12 Feb 2026 16:33:19 -0300 Subject: [PATCH] refactor provider resolution with registry and add gateway install --- cmd/picoclaw/main.go | 120 ++++++++++++- config/config.example.json | 4 + pkg/config/config.go | 2 + pkg/providers/http_provider.go | 183 +------------------ pkg/providers/provider_registry.go | 188 ++++++++++++++++++++ pkg/providers/provider_registry_defaults.go | 67 +++++++ pkg/providers/provider_registry_test.go | 149 ++++++++++++++++ pkg/providers/zen_provider.go | 9 + 8 files changed, 538 insertions(+), 184 deletions(-) create mode 100644 pkg/providers/provider_registry.go create mode 100644 pkg/providers/provider_registry_defaults.go create mode 100644 pkg/providers/provider_registry_test.go create mode 100644 pkg/providers/zen_provider.go diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index 31d8dadeb..50b15d5bf 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 { diff --git a/config/config.example.json b/config/config.example.json index ed5cb7048..64d3dd834 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -90,6 +90,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/config/config.go b/pkg/config/config.go index 56f1e1958..0e89b3d10 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -143,6 +143,7 @@ type ProvidersConfig struct { Gemini ProviderConfig `json:"gemini"` Nvidia ProviderConfig `json:"nvidia"` Moonshot ProviderConfig `json:"moonshot"` + Zen ProviderConfig `json:"zen"` } type ProviderConfig struct { @@ -242,6 +243,7 @@ func DefaultConfig() *Config { Gemini: ProviderConfig{}, Nvidia: ProviderConfig{}, Moonshot: ProviderConfig{}, + Zen: ProviderConfig{}, }, Gateway: GatewayConfig{ Host: "0.0.0.0", 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..87455366b --- /dev/null +++ b/pkg/providers/provider_registry.go @@ -0,0 +1,188 @@ +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 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..3a6c11473 --- /dev/null +++ b/pkg/providers/provider_registry_defaults.go @@ -0,0 +1,67 @@ +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: "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..9d3520e41 --- /dev/null +++ b/pkg/providers/provider_registry_test.go @@ -0,0 +1,149 @@ +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_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, + }) +}