// PicoClaw - Ultra-lightweight personal AI agent // License: MIT // // Copyright (c) 2026 PicoClaw contributors package providers import ( "context" "fmt" "strings" "time" "github.com/sipeed/picoclaw/pkg/config" anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages" "github.com/sipeed/picoclaw/pkg/providers/azure" "github.com/sipeed/picoclaw/pkg/providers/bedrock" ) // createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store. func createClaudeAuthProvider() (LLMProvider, error) { cred, err := getCredential("anthropic") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return nil, fmt.Errorf("no credentials for anthropic. Run: picoclaw auth login --provider anthropic") } return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } // createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store. func createCodexAuthProvider() (LLMProvider, error) { cred, err := getCredential("openai") if err != nil { return nil, fmt.Errorf("loading auth credentials: %w", err) } if cred == nil { return nil, fmt.Errorf("no credentials for openai. Run: picoclaw auth login --provider openai") } return NewCodexProviderWithTokenSource(cred.AccessToken, cred.AccountID, createCodexTokenSource()), nil } // ExtractProtocol extracts the protocol prefix and model identifier from a model string. // If no prefix is specified, it defaults to "openai". // Examples: // - "openai/gpt-4o" -> ("openai", "gpt-4o") // - "anthropic/claude-sonnet-4.6" -> ("anthropic", "claude-sonnet-4.6") // - "gpt-4o" -> ("openai", "gpt-4o") // default protocol func ExtractProtocol(model string) (protocol, modelID string) { model = strings.TrimSpace(model) protocol, modelID, found := strings.Cut(model, "/") if !found { return "openai", model } return protocol, modelID } // CreateProviderFromConfig creates a provider based on the ModelConfig. // It uses the protocol prefix in the Model field to determine which provider to create. // Supported protocol families include OpenAI-compatible prefixes (e.g., openai, openrouter, groq, gemini), // Azure OpenAI, Amazon Bedrock, Anthropic (including messages), and various CLI/compatibility shims. // See the switch on protocol in this function for the authoritative list. // Returns the provider, the model ID (without protocol prefix), and any error. func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, error) { if cfg == nil { return nil, "", fmt.Errorf("config is nil") } if cfg.Model == "" { return nil, "", fmt.Errorf("model is required") } protocol, modelID := ExtractProtocol(cfg.Model) switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { provider, err := createCodexAuthProvider() if err != nil { return nil, "", err } return provider, modelID, nil } // OpenAI with API key if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil case "azure", "azure-openai": // Azure OpenAI uses deployment-based URLs, api-key header auth, // and always sends max_completion_tokens. if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for azure protocol") } if cfg.APIBase == "" { return nil, "", fmt.Errorf( "api_base is required for azure protocol (e.g., https://your-resource.openai.azure.com)", ) } return azure.NewProviderWithTimeout( cfg.APIKey(), cfg.APIBase, cfg.Proxy, cfg.RequestTimeout, ), modelID, nil case "bedrock": // AWS Bedrock uses AWS SDK credentials (env vars, profiles, IAM roles, etc.) // api_base can be: // - A full endpoint URL: https://bedrock-runtime.us-east-1.amazonaws.com // - A region name: us-east-1 (AWS SDK resolves endpoint automatically) var opts []bedrock.Option if cfg.APIBase != "" { if !strings.Contains(cfg.APIBase, "://") { // Treat as region: let AWS SDK resolve the correct endpoint // (supports all AWS partitions: aws, aws-cn, aws-us-gov, etc.) opts = append(opts, bedrock.WithRegion(cfg.APIBase)) } else { // Full endpoint URL provided (for custom endpoints or testing) opts = append(opts, bedrock.WithBaseEndpoint(cfg.APIBase)) } } // Use a separate timeout for AWS config loading (credential resolution can block) initTimeout := 30 * time.Second if cfg.RequestTimeout > 0 { reqTimeout := time.Duration(cfg.RequestTimeout) * time.Second // Set request timeout for API calls opts = append(opts, bedrock.WithRequestTimeout(reqTimeout)) // Ensure init timeout is at least as large as request timeout if reqTimeout > initTimeout { initTimeout = reqTimeout } } ctx, cancel := context.WithTimeout(context.Background(), initTimeout) defer cancel() // Note: AWS_PROFILE env var is automatically used by AWS SDK provider, err := bedrock.NewProvider(ctx, opts...) if err != nil { return nil, "", fmt.Errorf("creating bedrock provider: %w", err) } return provider, modelID, nil case "litellm", "openrouter", "groq", "zhipu", "gemini", "nvidia", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "coding-plan", "alibaba-coding", "qwen-coding", "mimo": // All other OpenAI-compatible HTTP providers if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil case "minimax": // Minimax requires reasoning_split: true in the request body if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } extraBody := cfg.ExtraBody if extraBody == nil { extraBody = make(map[string]any) } if _, ok := extraBody["reasoning_split"]; !ok { extraBody["reasoning_split"] = true } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, extraBody, ), modelID, nil case "anthropic": if cfg.AuthMethod == "oauth" || cfg.AuthMethod == "token" { // Use OAuth credentials from auth store provider, err := createClaudeAuthProvider() if err != nil { return nil, "", err } return provider, modelID, nil } // Use API key with HTTP API apiBase := cfg.APIBase if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model) } return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( cfg.APIKey(), apiBase, cfg.Proxy, cfg.MaxTokensField, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil case "anthropic-messages": // Anthropic Messages API with native format (HTTP-based, no SDK) apiBase := cfg.APIBase if apiBase == "" { apiBase = "https://api.anthropic.com/v1" } if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for anthropic-messages protocol (model: %s)", cfg.Model) } return anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, cfg.RequestTimeout, ), modelID, nil case "coding-plan-anthropic", "alibaba-coding-anthropic": // Alibaba Coding Plan with Anthropic-compatible API apiBase := cfg.APIBase if apiBase == "" { apiBase = getDefaultAPIBase(protocol) } if cfg.APIKey() == "" { return nil, "", fmt.Errorf("api_key is required for %q protocol (model: %s)", protocol, cfg.Model) } return anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, cfg.RequestTimeout, ), modelID, nil case "antigravity": return NewAntigravityProvider(), modelID, nil case "claude-cli", "claudecli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return NewClaudeCliProvider(workspace), modelID, nil case "codex-cli", "codexcli": workspace := cfg.Workspace if workspace == "" { workspace = "." } return NewCodexCliProvider(workspace), modelID, nil case "github-copilot", "copilot": apiBase := cfg.APIBase if apiBase == "" { apiBase = "localhost:4321" } connectMode := cfg.ConnectMode if connectMode == "" { connectMode = "grpc" } provider, err := NewGitHubCopilotProvider(apiBase, connectMode, modelID) if err != nil { return nil, "", err } return provider, modelID, nil default: return nil, "", fmt.Errorf("unknown protocol %q in model %q", protocol, cfg.Model) } } // getDefaultAPIBase returns the default API base URL for a given protocol. func getDefaultAPIBase(protocol string) string { switch protocol { case "openai": return "https://api.openai.com/v1" case "openrouter": return "https://openrouter.ai/api/v1" case "litellm": return "http://localhost:4000/v1" case "novita": return "https://api.novita.ai/openai" case "groq": return "https://api.groq.com/openai/v1" case "zhipu": return "https://open.bigmodel.cn/api/paas/v4" case "gemini": return "https://generativelanguage.googleapis.com/v1beta" case "nvidia": return "https://integrate.api.nvidia.com/v1" case "ollama": return "http://localhost:11434/v1" case "moonshot": return "https://api.moonshot.cn/v1" case "shengsuanyun": return "https://router.shengsuanyun.com/api/v1" case "deepseek": return "https://api.deepseek.com/v1" case "cerebras": return "https://api.cerebras.ai/v1" case "vivgrid": return "https://api.vivgrid.com/v1" case "volcengine": return "https://ark.cn-beijing.volces.com/api/v3" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" case "qwen-intl", "qwen-international", "dashscope-intl": return "https://dashscope-intl.aliyuncs.com/compatible-mode/v1" case "qwen-us", "dashscope-us": return "https://dashscope-us.aliyuncs.com/compatible-mode/v1" case "coding-plan", "alibaba-coding", "qwen-coding": return "https://coding-intl.dashscope.aliyuncs.com/v1" case "coding-plan-anthropic", "alibaba-coding-anthropic": return "https://coding-intl.dashscope.aliyuncs.com/apps/anthropic" case "vllm": return "http://localhost:8000/v1" case "mistral": return "https://api.mistral.ai/v1" case "avian": return "https://api.avian.io/v1" case "minimax": return "https://api.minimaxi.com/v1" case "longcat": return "https://api.longcat.chat/openai" case "modelscope": return "https://api-inference.modelscope.cn/v1" case "mimo": return "https://api.xiaomimimo.com/v1" default: return "" } }