refactor provider resolution with registry and add gateway install
This commit is contained in:
parent
d83fb6e081
commit
f660143ddd
8 changed files with 538 additions and 184 deletions
|
|
@ -12,7 +12,9 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -179,7 +181,7 @@ func printHelp() {
|
||||||
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
|
||||||
fmt.Println(" agent Interact with the agent directly")
|
fmt.Println(" agent Interact with the agent directly")
|
||||||
fmt.Println(" auth Manage authentication (login, logout, status)")
|
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(" status Show picoclaw status")
|
||||||
fmt.Println(" cron Manage scheduled tasks")
|
fmt.Println(" cron Manage scheduled tasks")
|
||||||
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
|
||||||
|
|
@ -606,8 +608,19 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func gatewayCmd() {
|
func gatewayCmd() {
|
||||||
// Check for --debug flag
|
|
||||||
args := os.Args[2:]
|
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 {
|
for _, arg := range args {
|
||||||
if arg == "--debug" || arg == "-d" {
|
if arg == "--debug" || arg == "-d" {
|
||||||
logger.SetLevel(logger.DEBUG)
|
logger.SetLevel(logger.DEBUG)
|
||||||
|
|
@ -734,6 +747,109 @@ func gatewayCmd() {
|
||||||
fmt.Println("✓ Gateway stopped")
|
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() {
|
func statusCmd() {
|
||||||
cfg, err := loadConfig()
|
cfg, err := loadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,10 @@
|
||||||
"moonshot": {
|
"moonshot": {
|
||||||
"api_key": "sk-xxx",
|
"api_key": "sk-xxx",
|
||||||
"api_base": ""
|
"api_base": ""
|
||||||
|
},
|
||||||
|
"zen": {
|
||||||
|
"api_key": "YOUR_OPENCODE_API_KEY",
|
||||||
|
"api_base": "https://opencode.ai/zen/v1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ type ProvidersConfig struct {
|
||||||
Gemini ProviderConfig `json:"gemini"`
|
Gemini ProviderConfig `json:"gemini"`
|
||||||
Nvidia ProviderConfig `json:"nvidia"`
|
Nvidia ProviderConfig `json:"nvidia"`
|
||||||
Moonshot ProviderConfig `json:"moonshot"`
|
Moonshot ProviderConfig `json:"moonshot"`
|
||||||
|
Zen ProviderConfig `json:"zen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProviderConfig struct {
|
type ProviderConfig struct {
|
||||||
|
|
@ -242,6 +243,7 @@ func DefaultConfig() *Config {
|
||||||
Gemini: ProviderConfig{},
|
Gemini: ProviderConfig{},
|
||||||
Nvidia: ProviderConfig{},
|
Nvidia: ProviderConfig{},
|
||||||
Moonshot: ProviderConfig{},
|
Moonshot: ProviderConfig{},
|
||||||
|
Zen: ProviderConfig{},
|
||||||
},
|
},
|
||||||
Gateway: GatewayConfig{
|
Gateway: GatewayConfig{
|
||||||
Host: "0.0.0.0",
|
Host: "0.0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -219,186 +219,5 @@ func createCodexAuthProvider() (LLMProvider, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
func CreateProvider(cfg *config.Config) (LLMProvider, error) {
|
||||||
model := cfg.Agents.Defaults.Model
|
return defaultProviderRegistry.Create(cfg)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
188
pkg/providers/provider_registry.go
Normal file
188
pkg/providers/provider_registry.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
67
pkg/providers/provider_registry_defaults.go
Normal file
67
pkg/providers/provider_registry_defaults.go
Normal file
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
149
pkg/providers/provider_registry_test.go
Normal file
149
pkg/providers/provider_registry_test.go
Normal file
|
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
9
pkg/providers/zen_provider.go
Normal file
9
pkg/providers/zen_provider.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
package providers
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterProvider(providerRegistration{
|
||||||
|
Name: "zen",
|
||||||
|
ModelPrefixes: []string{"zen/"},
|
||||||
|
Creator: zenCreator,
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue