619 lines
17 KiB
Go
619 lines
17 KiB
Go
package lib
|
||
|
||
import (
|
||
"context"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"plandex-cli/api"
|
||
"plandex-cli/auth"
|
||
"plandex-cli/fs"
|
||
"plandex-cli/term"
|
||
"plandex-cli/types"
|
||
shared "plandex-shared"
|
||
"sort"
|
||
"strings"
|
||
|
||
"github.com/aws/aws-sdk-go-v2/config"
|
||
"github.com/fatih/color"
|
||
)
|
||
|
||
type ProviderAuthStatus int
|
||
|
||
const (
|
||
FullySatisfied ProviderAuthStatus = iota
|
||
PartiallySatisfied
|
||
FullyMissing
|
||
)
|
||
|
||
type ProviderCredentialStatus struct {
|
||
ProviderComposite string
|
||
Status ProviderAuthStatus
|
||
MissingVars []string
|
||
}
|
||
|
||
type PublisherCredentialStatus struct {
|
||
Publisher shared.ModelPublisher
|
||
SelectedProvider *ProviderCredentialStatus
|
||
PartialProviders []ProviderCredentialStatus
|
||
}
|
||
|
||
type CredentialCheckResult struct {
|
||
AllSatisfied bool
|
||
Publishers []PublisherCredentialStatus
|
||
AuthVars map[string]string
|
||
}
|
||
|
||
func CheckCredentialStatus(opts shared.ModelProviderOptions, claudeMaxEnabled bool) (CredentialCheckResult, error) {
|
||
publishersToProviders := groupProvidersByPublisher(opts)
|
||
|
||
selectedAuthVars := map[string]string{}
|
||
var publisherStatuses []PublisherCredentialStatus
|
||
allSatisfied := true
|
||
|
||
for publisher, providers := range publishersToProviders {
|
||
var selectedProvider *ProviderCredentialStatus
|
||
partialProviders := []ProviderCredentialStatus{}
|
||
|
||
for _, provider := range providers {
|
||
if provider.Config.HasClaudeMaxAuth && !claudeMaxEnabled {
|
||
continue
|
||
}
|
||
|
||
authVars, err := ResolveProviderAuthVars(provider.Config)
|
||
if err != nil {
|
||
return CredentialCheckResult{}, fmt.Errorf("error checking API keys/credentials: %v", err)
|
||
}
|
||
status, missingVars, err := checkProviderCredentialStatus(provider.Config, authVars)
|
||
if err != nil {
|
||
return CredentialCheckResult{}, fmt.Errorf("error checking API keys/credentials: %v", err)
|
||
}
|
||
|
||
providerStatus := ProviderCredentialStatus{
|
||
ProviderComposite: provider.Config.ToComposite(),
|
||
Status: status,
|
||
MissingVars: missingVars,
|
||
}
|
||
|
||
if status == FullySatisfied {
|
||
selectedProvider = &providerStatus
|
||
mergeAuthVars(selectedAuthVars, authVars)
|
||
break // first fully satisfied provider found, stop looking further
|
||
} else if status == PartiallySatisfied {
|
||
partialProviders = append(partialProviders, providerStatus)
|
||
}
|
||
// otherwise,if fully missing, don't set selected provider
|
||
}
|
||
|
||
if selectedProvider == nil {
|
||
allSatisfied = false
|
||
}
|
||
|
||
publisherStatuses = append(publisherStatuses, PublisherCredentialStatus{
|
||
Publisher: publisher,
|
||
SelectedProvider: selectedProvider,
|
||
PartialProviders: partialProviders,
|
||
})
|
||
}
|
||
|
||
return CredentialCheckResult{
|
||
AllSatisfied: allSatisfied,
|
||
Publishers: publisherStatuses,
|
||
AuthVars: selectedAuthVars,
|
||
}, nil
|
||
}
|
||
|
||
func groupProvidersByPublisher(opts shared.ModelProviderOptions) map[shared.ModelPublisher][]shared.ModelProviderOption {
|
||
grouped := map[shared.ModelPublisher][]shared.ModelProviderOption{}
|
||
for _, option := range opts {
|
||
for pub := range option.Publishers {
|
||
grouped[pub] = append(grouped[pub], option)
|
||
}
|
||
}
|
||
// stable priority sort
|
||
for pub := range grouped {
|
||
sort.SliceStable(grouped[pub], func(i, j int) bool {
|
||
return grouped[pub][i].Priority < grouped[pub][j].Priority
|
||
})
|
||
}
|
||
return grouped
|
||
}
|
||
|
||
func checkProviderCredentialStatus(cfg *shared.ModelProviderConfigSchema, authVars map[string]string) (ProviderAuthStatus, []string, error) {
|
||
var missing []string
|
||
|
||
if cfg.SkipAuth {
|
||
return FullySatisfied, nil, nil
|
||
}
|
||
|
||
if cfg.HasClaudeMaxAuth {
|
||
creds, err := GetAccountCredentials()
|
||
if err != nil {
|
||
return FullyMissing, nil, fmt.Errorf("error getting account credentials: %v", err)
|
||
}
|
||
if creds == nil || creds.ClaudeMax == nil {
|
||
return FullyMissing, nil, nil
|
||
}
|
||
}
|
||
|
||
if cfg.ApiKeyEnvVar != "" && authVars[cfg.ApiKeyEnvVar] == "" {
|
||
missing = append(missing, cfg.ApiKeyEnvVar)
|
||
}
|
||
|
||
for _, extra := range cfg.ExtraAuthVars {
|
||
if extra.Required && authVars[extra.Var] == "" {
|
||
missing = append(missing, extra.Var)
|
||
}
|
||
}
|
||
|
||
numRequired := 0
|
||
for _, extra := range cfg.ExtraAuthVars {
|
||
if extra.Required {
|
||
numRequired++
|
||
}
|
||
}
|
||
if cfg.ApiKeyEnvVar != "" {
|
||
numRequired++
|
||
}
|
||
|
||
switch {
|
||
case len(missing) == 0:
|
||
return FullySatisfied, nil, nil
|
||
case len(missing) == numRequired:
|
||
return FullyMissing, missing, nil
|
||
default:
|
||
return PartiallySatisfied, missing, nil
|
||
}
|
||
}
|
||
|
||
func MustVerifyAuthVars(integratedModels bool) map[string]string {
|
||
return mustVerifyAuthVars(integratedModels, false)
|
||
}
|
||
|
||
func MustVerifyAuthVarsSilent(integratedModels bool) map[string]string {
|
||
return mustVerifyAuthVars(integratedModels, true)
|
||
}
|
||
|
||
func mustVerifyAuthVars(integratedModels, silent bool) map[string]string {
|
||
if !silent {
|
||
term.StartSpinner("")
|
||
}
|
||
|
||
planSettings, apiErr := api.Client.GetSettings(CurrentPlanId, CurrentBranch)
|
||
if apiErr != nil {
|
||
term.OutputErrorAndExit("Error getting settings: %v", apiErr)
|
||
}
|
||
|
||
orgUserConfig := MustGetOrgUserConfig()
|
||
|
||
opts := planSettings.GetModelProviderOptions()
|
||
|
||
if !silent {
|
||
if hasAnthropicModels(opts) {
|
||
didConnect := promptClaudeMaxIfNeeded()
|
||
term.StartSpinner("")
|
||
if !didConnect && orgUserConfig.UseClaudeSubscription {
|
||
didConnect = connectClaudeMaxIfNeeded()
|
||
if !didConnect {
|
||
refreshClaudeMaxCredsIfNeeded()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// For IntegratedModelsMode on Cloud, we only send the connected Claude subscription api key—nothing else
|
||
// If we're in IntegratedModelsMode and there's no connected Claude sub, return nil
|
||
if integratedModels {
|
||
if orgUserConfig.UseClaudeSubscription {
|
||
creds, err := GetAccountCredentials()
|
||
if err != nil {
|
||
term.OutputErrorAndExit("Error getting Claude subscription credentials: %v", err)
|
||
}
|
||
|
||
if creds != nil && creds.ClaudeMax != nil {
|
||
return map[string]string{
|
||
shared.AnthropicClaudeMaxTokenEnvVar: creds.ClaudeMax.AccessToken,
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
checkResult, err := CheckCredentialStatus(opts, orgUserConfig.UseClaudeSubscription)
|
||
if err != nil {
|
||
term.OutputErrorAndExit("Error checking API keys/credentials: %v", err)
|
||
}
|
||
if checkResult.AllSatisfied {
|
||
return checkResult.AuthVars
|
||
}
|
||
|
||
showCredentialErrorMessage(checkResult, opts)
|
||
os.Exit(1)
|
||
return nil
|
||
}
|
||
|
||
func ResolveProviderAuthVars(cfg *shared.ModelProviderConfigSchema) (map[string]string, error) {
|
||
authVars := map[string]string{}
|
||
|
||
if cfg.SkipAuth {
|
||
return authVars, nil
|
||
}
|
||
|
||
if cfg.HasAWSAuth {
|
||
// PLANDEX_AWS_PROFILE enables credential file loading from ~/.aws/credentials
|
||
// this ensures it's opt-in so we only use bedrock if user explicitly intends to
|
||
profile := os.Getenv("PLANDEX_AWS_PROFILE")
|
||
if profile != "" {
|
||
os.Setenv("AWS_PROFILE", profile)
|
||
if err := loadAWSVars(authVars); err == nil {
|
||
return authVars, nil
|
||
}
|
||
}
|
||
// if no PLANDEX_AWS_PROFILE is set OR loading aws vars fails, just silently fall through to the default env var checks
|
||
|
||
// because we're disabling the EC2 metadata service, the aws check will fail unless appropriate env vars or credentials file is found, but it's not actually a problem—just indicates AWS creds aren't set
|
||
}
|
||
|
||
if cfg.HasClaudeMaxAuth {
|
||
creds, err := GetAccountCredentials()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("error getting account credentials: %v", err)
|
||
}
|
||
|
||
if creds != nil && creds.ClaudeMax != nil {
|
||
token := creds.ClaudeMax.AccessToken
|
||
authVars[shared.AnthropicClaudeMaxTokenEnvVar] = token
|
||
}
|
||
}
|
||
|
||
if cfg.ApiKeyEnvVar != "" {
|
||
val := os.Getenv(cfg.ApiKeyEnvVar)
|
||
if val != "" {
|
||
authVars[cfg.ApiKeyEnvVar] = val
|
||
}
|
||
}
|
||
|
||
for _, extra := range cfg.ExtraAuthVars {
|
||
val := os.Getenv(extra.Var)
|
||
if val == "" && extra.Default != "" {
|
||
val = extra.Default
|
||
}
|
||
|
||
if extra.MaybeJSONFilePath {
|
||
if val == "" {
|
||
continue
|
||
}
|
||
content, err := maybeLoadFile(val)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to load file for %s: %v", extra.Var, err)
|
||
}
|
||
authVars[extra.Var] = content
|
||
} else if val != "" {
|
||
authVars[extra.Var] = val
|
||
}
|
||
}
|
||
|
||
return authVars, nil
|
||
}
|
||
|
||
func maybeLoadFile(pathOrJson string) (string, error) {
|
||
if strings.HasPrefix(strings.TrimSpace(pathOrJson), "{") {
|
||
// var contains json directly, so we can return it as is
|
||
return pathOrJson, nil
|
||
}
|
||
|
||
// see if it's base64 encoded json
|
||
decoded, err := base64.StdEncoding.DecodeString(pathOrJson)
|
||
if err == nil {
|
||
s := string(decoded)
|
||
if strings.HasPrefix(strings.TrimSpace(s), "{") {
|
||
return s, nil
|
||
}
|
||
}
|
||
|
||
content, err := os.ReadFile(pathOrJson)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
|
||
return string(content), nil
|
||
}
|
||
|
||
func loadAWSVars(vars map[string]string) error {
|
||
// disable IMDS to prevent slow request
|
||
os.Setenv("AWS_EC2_METADATA_DISABLED", "true")
|
||
|
||
cfg, err := config.LoadDefaultConfig(context.Background())
|
||
if err != nil {
|
||
return fmt.Errorf("failed to load AWS config: %v", err)
|
||
}
|
||
|
||
creds, err := cfg.Credentials.Retrieve(context.Background())
|
||
if err != nil {
|
||
return fmt.Errorf("failed to retrieve AWS credentials: %v", err)
|
||
}
|
||
|
||
vars["AWS_ACCESS_KEY_ID"] = creds.AccessKeyID
|
||
vars["AWS_SECRET_ACCESS_KEY"] = creds.SecretAccessKey
|
||
vars["AWS_REGION"] = cfg.Region
|
||
if creds.SessionToken != "" {
|
||
vars["AWS_SESSION_TOKEN"] = creds.SessionToken
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func mergeAuthVars(dest, src map[string]string) {
|
||
for k, v := range src {
|
||
dest[k] = v
|
||
}
|
||
}
|
||
|
||
func showCredentialErrorMessage(res CredentialCheckResult, opts shared.ModelProviderOptions) {
|
||
term.StopSpinner()
|
||
boldRed := color.New(color.Bold, term.ColorHiRed)
|
||
cyanChip := color.New(color.BgCyan, color.FgHiWhite)
|
||
fmt.Println(boldRed.Sprint("🚨 Required API key(s) or model credentials are missing"))
|
||
|
||
someOK, someMissing := false, false
|
||
for _, p := range res.Publishers {
|
||
if p.SelectedProvider != nil && p.SelectedProvider.Status == FullySatisfied {
|
||
someOK = true
|
||
} else {
|
||
someMissing = true
|
||
}
|
||
}
|
||
if someOK && someMissing {
|
||
fmt.Println()
|
||
fmt.Println(color.New(color.Bold, term.ColorHiYellow).Sprint("⚠️ Some models are missing a provider"))
|
||
sorted := make([]PublisherCredentialStatus, 0, len(res.Publishers))
|
||
for _, p := range res.Publishers {
|
||
sorted = append(sorted, p)
|
||
}
|
||
sort.Slice(sorted, func(i, j int) bool {
|
||
readyI := sorted[i].SelectedProvider != nil && sorted[i].SelectedProvider.Status == FullySatisfied
|
||
readyJ := sorted[j].SelectedProvider != nil && sorted[j].SelectedProvider.Status == FullySatisfied
|
||
return readyI && !readyJ
|
||
})
|
||
for _, p := range sorted {
|
||
ready := p.SelectedProvider != nil && p.SelectedProvider.Status == FullySatisfied
|
||
var lbl string
|
||
if ready {
|
||
lbl = p.SelectedProvider.ProviderComposite
|
||
} else {
|
||
lbl = "missing"
|
||
}
|
||
fmt.Printf("%s %s models → %s\n", mark(ready), p.Publisher, lbl)
|
||
}
|
||
}
|
||
|
||
var partialLines []string
|
||
added := map[string]bool{}
|
||
for _, pub := range res.Publishers {
|
||
partialProviders := pub.PartialProviders
|
||
for _, sp := range partialProviders {
|
||
// already added this provider
|
||
if added[sp.ProviderComposite] {
|
||
continue
|
||
}
|
||
|
||
// compute vars set vs missing
|
||
opt, ok := opts[sp.ProviderComposite]
|
||
if !ok {
|
||
continue
|
||
}
|
||
req := requiredVars(opt.Config)
|
||
var setVars []string
|
||
for _, v := range req {
|
||
missing := false
|
||
for _, mv := range sp.MissingVars {
|
||
if mv == v {
|
||
missing = true
|
||
break
|
||
}
|
||
}
|
||
if !missing {
|
||
setVars = append(setVars, v)
|
||
}
|
||
}
|
||
added[sp.ProviderComposite] = true
|
||
partialLines = append(partialLines,
|
||
fmt.Sprintf("%s\n set → %s\n missing → %s",
|
||
color.New(color.Bold).Sprint(sp.ProviderComposite),
|
||
strings.Join(setVars, ", "),
|
||
strings.Join(sp.MissingVars, ", "),
|
||
))
|
||
}
|
||
}
|
||
if len(partialLines) > 0 {
|
||
sort.Strings(partialLines)
|
||
fmt.Println()
|
||
fmt.Println(color.New(term.ColorHiYellow, color.Bold).Sprint("⚠️ Providers with partial credentials"))
|
||
for _, l := range partialLines {
|
||
fmt.Println(l)
|
||
}
|
||
}
|
||
|
||
byPub := providersByPublisher(opts)
|
||
|
||
byPubWithoutOpenRouter := map[shared.ModelPublisher][]shared.ModelProvider{}
|
||
for pub, providers := range byPub {
|
||
nonOrProviders := []shared.ModelProvider{}
|
||
for _, provider := range providers {
|
||
if provider != shared.ModelProviderOpenRouter {
|
||
nonOrProviders = append(nonOrProviders, provider)
|
||
}
|
||
}
|
||
if len(nonOrProviders) > 0 {
|
||
byPubWithoutOpenRouter[pub] = nonOrProviders
|
||
}
|
||
}
|
||
|
||
allPublishersHaveOpenRouter := allPublishersHaveProvider(byPub, shared.ModelProviderOpenRouter)
|
||
|
||
if allPublishersHaveOpenRouter {
|
||
fmt.Println()
|
||
fmt.Println(color.New(term.ColorHiCyan, color.Bold).Sprint("🚀 Quick option → OpenRouter.ai"))
|
||
fmt.Println("OpenRouter allows you to use all models in the current model pack with a single account and API key. To get started:")
|
||
fmt.Println()
|
||
step := func(n int, txt string) { fmt.Printf("%d. %s\n", n, txt) }
|
||
step(1, "Sign up at "+color.New(color.Bold).Sprint("https://openrouter.ai/sign-up"))
|
||
step(2, "Buy some credits at "+color.New(color.Bold).Sprint("https://openrouter.ai/settings/credits"))
|
||
step(3, "Generate an API key at "+color.New(color.Bold).Sprint("https://openrouter.ai/settings/keys"))
|
||
if term.IsRepl {
|
||
step(4, "Quit the REPL with "+cyanChip.Sprint(" \\quit "))
|
||
step(5, "Run "+cyanChip.Sprint(" export OPENROUTER_API_KEY=… "))
|
||
step(6, "Restart the REPL with "+cyanChip.Sprint(" plandex "))
|
||
} else {
|
||
step(4, "Run "+cyanChip.Sprint(" export OPENROUTER_API_KEY=… "))
|
||
}
|
||
}
|
||
|
||
if len(byPubWithoutOpenRouter) > 0 {
|
||
fmt.Println()
|
||
fmt.Println(color.New(term.ColorHiCyan, color.Bold).Sprint("🔑 Other model providers"))
|
||
if allPublishersHaveOpenRouter {
|
||
fmt.Println("You can also use the following providers for the current model pack:")
|
||
} else {
|
||
fmt.Println("You can use the following providers for the current model pack:")
|
||
}
|
||
|
||
fmt.Println()
|
||
pubs := make([]string, 0, len(byPubWithoutOpenRouter))
|
||
for p := range byPubWithoutOpenRouter {
|
||
pubs = append(pubs, string(p))
|
||
}
|
||
sort.Strings(pubs)
|
||
for _, p := range pubs {
|
||
providers := byPubWithoutOpenRouter[shared.ModelPublisher(p)]
|
||
providerNames := make([]string, 0, len(providers))
|
||
for _, provider := range providers {
|
||
providerNames = append(providerNames, string(provider))
|
||
}
|
||
fmt.Printf("%s → %s\n", color.New(color.Bold).Sprint(p+" models"), strings.Join(providerNames, ", "))
|
||
}
|
||
|
||
fmt.Println(color.New(color.Bold, term.ColorHiCyan).Sprint("\n📖 Per-provider instructions"))
|
||
fmt.Println("For details on the API key/credentials required for each provider, go to:\n" + color.New(color.Bold).Sprint("https://docs.plandex.ai/models/model-providers"))
|
||
}
|
||
|
||
fmt.Println()
|
||
}
|
||
|
||
// return required env‑var names (API key + required extras)
|
||
func requiredVars(cfg *shared.ModelProviderConfigSchema) []string {
|
||
var vars []string
|
||
if cfg.ApiKeyEnvVar != "" {
|
||
vars = append(vars, cfg.ApiKeyEnvVar)
|
||
}
|
||
for _, ex := range cfg.ExtraAuthVars {
|
||
if ex.Required {
|
||
vars = append(vars, ex.Var)
|
||
}
|
||
}
|
||
return vars
|
||
}
|
||
|
||
func providersByPublisher(opts shared.ModelProviderOptions) map[shared.ModelPublisher][]shared.ModelProvider {
|
||
byPub := map[shared.ModelPublisher][]shared.ModelProvider{}
|
||
sortedOpts := make([]shared.ModelProviderOption, 0, len(opts))
|
||
for _, opt := range opts {
|
||
sortedOpts = append(sortedOpts, opt)
|
||
}
|
||
sort.Slice(sortedOpts, func(i, j int) bool {
|
||
return sortedOpts[i].Priority < sortedOpts[j].Priority
|
||
})
|
||
for _, opt := range sortedOpts {
|
||
for pub := range opt.Publishers {
|
||
byPub[pub] = append(byPub[pub], opt.Config.Provider)
|
||
}
|
||
}
|
||
return byPub
|
||
}
|
||
|
||
func allPublishersHaveProvider(byPub map[shared.ModelPublisher][]shared.ModelProvider, p shared.ModelProvider) bool {
|
||
if len(byPub) == 0 {
|
||
return false
|
||
}
|
||
|
||
for _, providers := range byPub {
|
||
found := false
|
||
for _, provider := range providers {
|
||
if provider == p {
|
||
found = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !found {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// emoji for satisfied vs missing
|
||
func mark(ok bool) string {
|
||
if ok {
|
||
return "✅"
|
||
}
|
||
return "❌"
|
||
}
|
||
|
||
var cachedAccountCredentials *types.AccountCredentials
|
||
|
||
func SetAccountCredentials(creds *types.AccountCredentials) error {
|
||
if auth.Current == nil {
|
||
return fmt.Errorf("no authenticated user")
|
||
}
|
||
dir := filepath.Join(fs.HomePlandexDir, auth.Current.UserId, auth.Current.OrgId)
|
||
err := os.MkdirAll(dir, 0700)
|
||
if err != nil {
|
||
return fmt.Errorf("error creating account credentials directory: %v", err)
|
||
}
|
||
path := filepath.Join(dir, "creds.json")
|
||
bytes, err := json.MarshalIndent(creds, "", " ")
|
||
if err != nil {
|
||
return fmt.Errorf("error marshalling account credentials: %v", err)
|
||
}
|
||
err = os.WriteFile(path, bytes, 0600)
|
||
if err != nil {
|
||
return fmt.Errorf("error writing account credentials: %v", err)
|
||
}
|
||
|
||
cachedAccountCredentials = creds
|
||
|
||
return nil
|
||
}
|
||
|
||
func GetAccountCredentials() (*types.AccountCredentials, error) {
|
||
if cachedAccountCredentials != nil {
|
||
return cachedAccountCredentials, nil
|
||
}
|
||
|
||
if auth.Current == nil {
|
||
return nil, fmt.Errorf("no authenticated user")
|
||
}
|
||
path := filepath.Join(fs.HomePlandexDir, auth.Current.UserId, auth.Current.OrgId, "creds.json")
|
||
|
||
bytes, err := os.ReadFile(path)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, err
|
||
}
|
||
|
||
var creds types.AccountCredentials
|
||
err = json.Unmarshal(bytes, &creds)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
cachedAccountCredentials = &creds
|
||
|
||
return &creds, nil
|
||
}
|