From 39a30e43acdeddcee9a51c381ed9cc2b89ba2a0a Mon Sep 17 00:00:00 2001 From: Dmitrii Mukhutdinov Date: Fri, 27 Mar 2026 15:28:24 -0300 Subject: [PATCH] feat(auth): generalize token login to all OpenAI-compatible HTTP providers Add `picoclaw auth login --provider ` support for any OpenAI-compatible HTTP provider (openrouter, groq, deepseek, mistral, gemini, novita, etc.) rather than only OAuth-based providers. Changes: - `pkg/providers/factory_provider.go`: replace `createOpenRouterAuthProvider` with generic `createHTTPTokenAuthProvider(protocol, cfg)`; all HTTP provider cases now check `auth_method: "token"` and read credentials from auth store - `cmd/picoclaw/internal/auth/helpers.go`: replace openrouter-specific login path with generic `authLoginHTTPTokenProvider(provider)` backed by an `httpTokenProviders` map; logout handler generalized via `isProviderModel`; `supportedProvidersMsg` now lists all supported providers dynamically - `pkg/auth/token.go`: add openrouter.ai/keys display hint - `cmd/picoclaw/internal/auth/login.go`: update --provider flag description Any provider in `httpTokenProviders` now works with: picoclaw auth login --provider Co-Authored-By: Claude Sonnet 4.6 (1M context) --- cmd/picoclaw/internal/auth/helpers.go | 73 ++++++++++++++++++++++++++- cmd/picoclaw/internal/auth/login.go | 2 +- pkg/auth/token.go | 2 + pkg/providers/factory_provider.go | 41 ++++++++++++++- 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/cmd/picoclaw/internal/auth/helpers.go b/cmd/picoclaw/internal/auth/helpers.go index 531cb76aa..b8e614c58 100644 --- a/cmd/picoclaw/internal/auth/helpers.go +++ b/cmd/picoclaw/internal/auth/helpers.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "sort" "strings" "time" @@ -17,10 +18,32 @@ import ( ) const ( - supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity" defaultAnthropicModel = "claude-sonnet-4.6" ) +// httpTokenProviders lists all OpenAI-compatible HTTP providers that support +// `picoclaw auth login --provider ` with API-key (token) auth. +var httpTokenProviders = map[string]bool{ + "openrouter": true, "groq": true, "deepseek": true, "mistral": true, + "gemini": true, "nvidia": true, "ollama": true, "moonshot": true, + "zhipu": true, "novita": true, "cerebras": true, "minimax": true, + "vivgrid": true, "volcengine": true, "vllm": true, "litellm": true, + "qwen": true, "qwen-intl": true, "qwen-international": true, "dashscope-intl": true, + "qwen-us": true, "dashscope-us": true, "avian": true, "longcat": true, + "modelscope": true, "shengsuanyun": true, "mimo": true, + "coding-plan": true, "alibaba-coding": true, "qwen-coding": true, +} + +func supportedProvidersMsg() string { + static := []string{"openai", "anthropic", "google-antigravity"} + dynamic := make([]string, 0, len(httpTokenProviders)) + for p := range httpTokenProviders { + dynamic = append(dynamic, p) + } + sort.Strings(dynamic) + return "supported providers: " + strings.Join(append(static, dynamic...), ", ") +} + func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { switch provider { case "openai": @@ -30,7 +53,10 @@ func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error { case "google-antigravity", "antigravity": return authLoginGoogleAntigravity() default: - return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg) + if httpTokenProviders[provider] { + return authLoginHTTPTokenProvider(provider) + } + return fmt.Errorf("unsupported provider: %s (%s)", provider, supportedProvidersMsg()) } } @@ -353,6 +379,10 @@ func authLogoutCmd(provider string) error { if isAntigravityModel(appCfg.ModelList[i].Model) { appCfg.ModelList[i].AuthMethod = "" } + default: + if isProviderModel(provider, appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "" + } } } config.SaveConfig(internal.GetConfigPath(), appCfg) @@ -484,6 +514,45 @@ func authModelsCmd() error { return nil } +func authLoginHTTPTokenProvider(provider string) error { + cred, err := auth.LoginPasteToken(provider, os.Stdin) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + + if err = auth.SetCredential(provider, cred); err != nil { + return fmt.Errorf("failed to save credentials: %w", err) + } + + appCfg, err := internal.LoadConfig() + if err == nil { + updated := 0 + for i := range appCfg.ModelList { + if isProviderModel(provider, appCfg.ModelList[i].Model) { + appCfg.ModelList[i].AuthMethod = "token" + updated++ + } + } + if err := config.SaveConfig(internal.GetConfigPath(), appCfg); err != nil { + return fmt.Errorf("could not update config: %w", err) + } + if updated > 0 { + fmt.Printf("Updated %d %s model(s) to use token auth.\n", updated, provider) + } else { + fmt.Printf("No %s models found in config. Add models with protocol %s/ to use this credential.\n", provider, provider) + } + } + + fmt.Printf("Token saved for %s!\n", provider) + + return nil +} + +// isProviderModel reports whether a model string belongs to the given provider protocol. +func isProviderModel(protocol, model string) bool { + return model == protocol || strings.HasPrefix(model, protocol+"/") +} + // isAntigravityModel checks if a model string belongs to antigravity provider func isAntigravityModel(model string) bool { return model == "antigravity" || diff --git a/cmd/picoclaw/internal/auth/login.go b/cmd/picoclaw/internal/auth/login.go index afbe098aa..d30d9392d 100644 --- a/cmd/picoclaw/internal/auth/login.go +++ b/cmd/picoclaw/internal/auth/login.go @@ -18,7 +18,7 @@ func newLoginCommand() *cobra.Command { }, } - cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic)") + cmd.Flags().StringVarP(&provider, "provider", "p", "", "Provider to login with (openai, anthropic, google-antigravity, openrouter, groq, deepseek, mistral, …)") cmd.Flags().BoolVar(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)") cmd.Flags().BoolVar( &useOauth, "setup-token", false, diff --git a/pkg/auth/token.go b/pkg/auth/token.go index 0e69e60ac..ffb26ec89 100644 --- a/pkg/auth/token.go +++ b/pkg/auth/token.go @@ -66,6 +66,8 @@ func providerDisplayName(provider string) string { return "console.anthropic.com" case "openai": return "platform.openai.com" + case "openrouter": + return "openrouter.ai/keys" default: return provider } diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 962e6ae19..446201f1e 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -29,6 +29,30 @@ func createClaudeAuthProvider() (LLMProvider, error) { return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil } +// createHTTPTokenAuthProvider creates an OpenAI-compatible HTTP provider using credentials +// from the auth store. Used for any provider configured with auth_method: "token". +func createHTTPTokenAuthProvider(protocol string, cfg *config.ModelConfig) (LLMProvider, error) { + cred, err := getCredential(protocol) + if err != nil { + return nil, fmt.Errorf("loading auth credentials: %w", err) + } + if cred == nil { + return nil, fmt.Errorf("no credentials for %s. Run: picoclaw auth login --provider %s", protocol, protocol) + } + apiBase := cfg.APIBase + if apiBase == "" { + apiBase = getDefaultAPIBase(protocol) + } + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( + cred.AccessToken, + apiBase, + cfg.Proxy, + cfg.MaxTokensField, + cfg.RequestTimeout, + cfg.ExtraBody, + ), nil +} + // createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store. func createCodexAuthProvider() (LLMProvider, error) { cred, err := getCredential("openai") @@ -159,7 +183,15 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err "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 + // OpenAI-compatible HTTP providers. If auth_method is "token", credentials are + // read from the auth store (set via `picoclaw auth login --provider `). + if cfg.AuthMethod == "token" { + provider, err := createHTTPTokenAuthProvider(protocol, cfg) + if err != nil { + return nil, "", err + } + return provider, modelID, nil + } if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) } @@ -178,6 +210,13 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err case "minimax": // Minimax requires reasoning_split: true in the request body + if cfg.AuthMethod == "token" { + provider, err := createHTTPTokenAuthProvider(protocol, cfg) + if err != nil { + return nil, "", err + } + return provider, modelID, nil + } if cfg.APIKey() == "" && cfg.APIBase == "" { return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) }