Files
2026-07-13 13:33:09 +08:00

619 lines
17 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 envvar 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
}