feat(auth): generalize token login to all OpenAI-compatible HTTP providers

Add `picoclaw auth login --provider <name>` 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 <name>

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dmitrii Mukhutdinov 2026-03-27 15:28:24 -03:00
parent 60d7ec20a5
commit 39a30e43ac
4 changed files with 114 additions and 4 deletions

View file

@ -7,6 +7,7 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"sort"
"strings" "strings"
"time" "time"
@ -17,10 +18,32 @@ import (
) )
const ( const (
supportedProvidersMsg = "supported providers: openai, anthropic, google-antigravity"
defaultAnthropicModel = "claude-sonnet-4.6" defaultAnthropicModel = "claude-sonnet-4.6"
) )
// httpTokenProviders lists all OpenAI-compatible HTTP providers that support
// `picoclaw auth login --provider <name>` 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 { func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
switch provider { switch provider {
case "openai": case "openai":
@ -30,7 +53,10 @@ func authLoginCmd(provider string, useDeviceCode bool, useOauth bool) error {
case "google-antigravity", "antigravity": case "google-antigravity", "antigravity":
return authLoginGoogleAntigravity() return authLoginGoogleAntigravity()
default: 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) { if isAntigravityModel(appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = "" appCfg.ModelList[i].AuthMethod = ""
} }
default:
if isProviderModel(provider, appCfg.ModelList[i].Model) {
appCfg.ModelList[i].AuthMethod = ""
}
} }
} }
config.SaveConfig(internal.GetConfigPath(), appCfg) config.SaveConfig(internal.GetConfigPath(), appCfg)
@ -484,6 +514,45 @@ func authModelsCmd() error {
return nil 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 // isAntigravityModel checks if a model string belongs to antigravity provider
func isAntigravityModel(model string) bool { func isAntigravityModel(model string) bool {
return model == "antigravity" || return model == "antigravity" ||

View file

@ -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(&useDeviceCode, "device-code", false, "Use device code flow (for headless environments)")
cmd.Flags().BoolVar( cmd.Flags().BoolVar(
&useOauth, "setup-token", false, &useOauth, "setup-token", false,

View file

@ -66,6 +66,8 @@ func providerDisplayName(provider string) string {
return "console.anthropic.com" return "console.anthropic.com"
case "openai": case "openai":
return "platform.openai.com" return "platform.openai.com"
case "openrouter":
return "openrouter.ai/keys"
default: default:
return provider return provider
} }

View file

@ -29,6 +29,30 @@ func createClaudeAuthProvider() (LLMProvider, error) {
return NewClaudeProviderWithTokenSource(cred.AccessToken, createClaudeTokenSource()), nil 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. // createCodexAuthProvider creates a Codex provider using OAuth credentials from auth store.
func createCodexAuthProvider() (LLMProvider, error) { func createCodexAuthProvider() (LLMProvider, error) {
cred, err := getCredential("openai") 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", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl",
"qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita",
"coding-plan", "alibaba-coding", "qwen-coding", "mimo": "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 <name>`).
if cfg.AuthMethod == "token" {
provider, err := createHTTPTokenAuthProvider(protocol, cfg)
if err != nil {
return nil, "", err
}
return provider, modelID, nil
}
if cfg.APIKey() == "" && cfg.APIBase == "" { if cfg.APIKey() == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) 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": case "minimax":
// Minimax requires reasoning_split: true in the request body // 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 == "" { if cfg.APIKey() == "" && cfg.APIBase == "" {
return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol) return nil, "", fmt.Errorf("api_key or api_base is required for HTTP-based protocol %q", protocol)
} }