927 lines
27 KiB
Go
927 lines
27 KiB
Go
package cli
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"reasonix/internal/config"
|
|
"reasonix/internal/i18n"
|
|
)
|
|
|
|
type providerSetupSession struct {
|
|
cfg *config.Config
|
|
originalProviders map[string]config.ProviderEntry
|
|
originalDefault string
|
|
pendingCredentials map[string]string
|
|
removed map[string]bool
|
|
accessDeclared bool
|
|
projectScoped bool
|
|
declaredProviders []string
|
|
operations []providerSetupOperation
|
|
}
|
|
|
|
const setupManagerContinue = 2
|
|
|
|
type providerSetupOperationKind uint8
|
|
|
|
const (
|
|
setupOpProvider providerSetupOperationKind = iota
|
|
setupOpDefaultModel
|
|
setupOpLanguage
|
|
setupOpMaterializeAccess
|
|
setupOpAccessMembership
|
|
)
|
|
|
|
type providerSetupOperation struct {
|
|
kind providerSetupOperationKind
|
|
providerName string
|
|
beforeProvider *config.ProviderEntry
|
|
afterProvider *config.ProviderEntry
|
|
beforeString string
|
|
afterString string
|
|
accessName string
|
|
projectScoped bool
|
|
beforeBool bool
|
|
afterBool bool
|
|
}
|
|
|
|
type providerSetupConflictError struct {
|
|
field string
|
|
}
|
|
|
|
type providerSetupFileSnapshot struct {
|
|
exists bool
|
|
body []byte
|
|
}
|
|
|
|
func (e *providerSetupConflictError) Error() string {
|
|
return e.field
|
|
}
|
|
|
|
func providerSetupEntryPtr(entry config.ProviderEntry) *config.ProviderEntry {
|
|
copy := config.ProviderEntryConfigSnapshot(entry)
|
|
return ©
|
|
}
|
|
|
|
func readProviderSetupFileSnapshot(path string) (providerSetupFileSnapshot, error) {
|
|
body, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return providerSetupFileSnapshot{}, nil
|
|
}
|
|
return providerSetupFileSnapshot{}, err
|
|
}
|
|
return providerSetupFileSnapshot{exists: true, body: body}, nil
|
|
}
|
|
|
|
func providerSetupFileSnapshotEqual(a, b providerSetupFileSnapshot) bool {
|
|
return a.exists == b.exists && bytes.Equal(a.body, b.body)
|
|
}
|
|
|
|
func newProviderSetupSession(cfg *config.Config) *providerSetupSession {
|
|
s := &providerSetupSession{
|
|
cfg: cfg,
|
|
originalProviders: make(map[string]config.ProviderEntry, len(cfg.Providers)),
|
|
originalDefault: cfg.DefaultModel,
|
|
pendingCredentials: map[string]string{},
|
|
removed: map[string]bool{},
|
|
}
|
|
for _, p := range cfg.Providers {
|
|
s.originalProviders[p.Name] = p
|
|
}
|
|
return s
|
|
}
|
|
|
|
func newProviderSetupSessionForPath(cfg *config.Config, path string) *providerSetupSession {
|
|
s := newProviderSetupSession(cfg)
|
|
s.projectScoped = !config.IsUserConfigPath(path)
|
|
declarations, err := config.InspectConfigFileDeclarations(path)
|
|
if err != nil {
|
|
// LoadForEdit already reports malformed/unreadable config and falls back;
|
|
// keep the conservative policy here so setup never enables hidden siblings.
|
|
s.accessDeclared = true
|
|
return s
|
|
}
|
|
s.accessDeclared = declarations.DesktopProviderAccessDeclared
|
|
s.declaredProviders = declarations.ProviderNames
|
|
return s
|
|
}
|
|
|
|
func (s *providerSetupSession) recordProviderMutation(name string, before, after *config.ProviderEntry) {
|
|
s.operations = append(s.operations, providerSetupOperation{
|
|
kind: setupOpProvider,
|
|
providerName: name,
|
|
beforeProvider: before,
|
|
afterProvider: after,
|
|
})
|
|
}
|
|
|
|
func (s *providerSetupSession) setLanguage(language string) {
|
|
if s.cfg.Language == language {
|
|
return
|
|
}
|
|
s.operations = append(s.operations, providerSetupOperation{
|
|
kind: setupOpLanguage,
|
|
beforeString: s.cfg.Language,
|
|
afterString: language,
|
|
})
|
|
s.cfg.Language = language
|
|
}
|
|
|
|
func (s *providerSetupSession) applyDeepSeekOfficialDefaultPricing() {
|
|
before := make(map[string]config.ProviderEntry, len(s.cfg.Providers))
|
|
for _, provider := range s.cfg.Providers {
|
|
before[provider.Name] = provider
|
|
}
|
|
s.cfg.ApplyDeepSeekOfficialDefaultPricing()
|
|
for i := range s.cfg.Providers {
|
|
after := s.cfg.Providers[i]
|
|
previous, existed := before[after.Name]
|
|
if existed && config.ProviderEntriesConfigEqual(previous, after) {
|
|
delete(before, after.Name)
|
|
continue
|
|
}
|
|
var previousPtr *config.ProviderEntry
|
|
if existed {
|
|
previousPtr = providerSetupEntryPtr(previous)
|
|
}
|
|
s.recordProviderMutation(after.Name, previousPtr, providerSetupEntryPtr(after))
|
|
delete(before, after.Name)
|
|
}
|
|
for name, previous := range before {
|
|
s.recordProviderMutation(name, providerSetupEntryPtr(previous), nil)
|
|
}
|
|
}
|
|
|
|
func (s *providerSetupSession) resetProviderSummaryBaseline() {
|
|
s.originalProviders = make(map[string]config.ProviderEntry, len(s.cfg.Providers))
|
|
for _, provider := range s.cfg.Providers {
|
|
s.originalProviders[provider.Name] = provider
|
|
}
|
|
}
|
|
|
|
func (s *providerSetupSession) upsert(entries []config.ProviderEntry) error {
|
|
for _, entry := range entries {
|
|
var before *config.ProviderEntry
|
|
if current, ok := s.cfg.Provider(entry.Name); ok {
|
|
before = providerSetupEntryPtr(*current)
|
|
}
|
|
if err := s.cfg.UpsertProvider(entry); err != nil {
|
|
return err
|
|
}
|
|
current, _ := s.cfg.Provider(entry.Name)
|
|
if before == nil || !config.ProviderEntriesConfigEqual(*before, *current) {
|
|
s.recordProviderMutation(entry.Name, before, providerSetupEntryPtr(*current))
|
|
}
|
|
delete(s.removed, entry.Name)
|
|
s.repairDanglingDefaultFor(*current)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// repairDanglingDefaultFor re-points default_model at the provider's own default
|
|
// when an edit or model refresh dropped the exact model the ref named, mirroring
|
|
// the repair RemoveProvider performs on removal.
|
|
func (s *providerSetupSession) repairDanglingDefaultFor(p config.ProviderEntry) {
|
|
if !config.ModelRefsProvider(s.cfg.DefaultModel, p.Name) || len(p.ModelList()) == 0 {
|
|
return
|
|
}
|
|
if _, ok := s.cfg.ResolveModel(s.cfg.DefaultModel); ok {
|
|
return
|
|
}
|
|
if err := s.setDefaultModel(p.Name); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
|
|
func (s *providerSetupSession) add(entries []config.ProviderEntry) error {
|
|
seen := make(map[string]bool, len(s.cfg.Providers)+len(entries))
|
|
for _, provider := range s.cfg.Providers {
|
|
seen[provider.Name] = true
|
|
}
|
|
for _, entry := range entries {
|
|
if seen[entry.Name] {
|
|
return fmt.Errorf(i18n.M.SetupProviderExistsFmt, entry.Name)
|
|
}
|
|
seen[entry.Name] = true
|
|
}
|
|
return s.upsert(entries)
|
|
}
|
|
|
|
func (s *providerSetupSession) remove(name string) error {
|
|
current, ok := s.cfg.Provider(name)
|
|
if !ok {
|
|
return fmt.Errorf("remove provider: no provider %q", name)
|
|
}
|
|
before := providerSetupEntryPtr(*current)
|
|
if err := s.cfg.RemoveProvider(name); err != nil {
|
|
return err
|
|
}
|
|
s.recordProviderMutation(name, before, nil)
|
|
s.removeProviderAccess(name)
|
|
if _, existed := s.originalProviders[name]; existed {
|
|
s.removed[name] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *providerSetupSession) addProviderAccess(entries []config.ProviderEntry) {
|
|
if len(entries) == 0 {
|
|
return
|
|
}
|
|
before := append([]string(nil), s.cfg.Desktop.ProviderAccess...)
|
|
// Preserve the legacy "undeclared means infer all configured providers"
|
|
// behavior before turning provider_access into an explicit list. Project
|
|
// setup only seeds providers declared by that project; cfg also contains
|
|
// built-in defaults, which must not override the user's global access policy.
|
|
if !s.accessDeclared && len(s.cfg.Desktop.ProviderAccess) == 0 {
|
|
if s.projectScoped {
|
|
for _, name := range s.declaredProviders {
|
|
provider, ok := s.cfg.Provider(name)
|
|
if ok && provider.Configured() && len(provider.ModelList()) > 0 {
|
|
s.cfg.Desktop.ProviderAccess = append(s.cfg.Desktop.ProviderAccess, name)
|
|
}
|
|
}
|
|
} else {
|
|
config.NormalizeLegacyDesktopProviderAccess(s.cfg)
|
|
}
|
|
s.accessDeclared = true
|
|
s.operations = append(s.operations, providerSetupOperation{
|
|
kind: setupOpMaterializeAccess,
|
|
projectScoped: s.projectScoped,
|
|
})
|
|
before = append([]string(nil), s.cfg.Desktop.ProviderAccess...)
|
|
}
|
|
seen := make(map[string]bool, len(s.cfg.Desktop.ProviderAccess)+len(entries))
|
|
for _, name := range s.cfg.Desktop.ProviderAccess {
|
|
name = strings.TrimSpace(name)
|
|
if name != "" {
|
|
seen[name] = true
|
|
}
|
|
}
|
|
for _, entry := range entries {
|
|
name := strings.TrimSpace(entry.Name)
|
|
if name == "" || seen[name] {
|
|
continue
|
|
}
|
|
s.cfg.Desktop.ProviderAccess = append(s.cfg.Desktop.ProviderAccess, name)
|
|
seen[name] = true
|
|
}
|
|
s.accessDeclared = true
|
|
s.recordAccessTransition(before)
|
|
}
|
|
|
|
func (s *providerSetupSession) removeProviderAccess(name string) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" || len(s.cfg.Desktop.ProviderAccess) == 0 {
|
|
return
|
|
}
|
|
before := append([]string(nil), s.cfg.Desktop.ProviderAccess...)
|
|
out := s.cfg.Desktop.ProviderAccess[:0]
|
|
for _, current := range s.cfg.Desktop.ProviderAccess {
|
|
if strings.TrimSpace(current) != name {
|
|
out = append(out, current)
|
|
}
|
|
}
|
|
s.cfg.Desktop.ProviderAccess = out
|
|
s.recordAccessTransition(before)
|
|
}
|
|
|
|
func (s *providerSetupSession) recordAccessTransition(before []string) {
|
|
beforeSet := make(map[string]bool, len(before))
|
|
afterSet := make(map[string]bool, len(s.cfg.Desktop.ProviderAccess))
|
|
var order []string
|
|
seen := map[string]bool{}
|
|
for _, names := range [][]string{before, s.cfg.Desktop.ProviderAccess} {
|
|
for _, name := range names {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if !seen[name] {
|
|
seen[name] = true
|
|
order = append(order, name)
|
|
}
|
|
}
|
|
}
|
|
for _, name := range before {
|
|
name = strings.TrimSpace(name)
|
|
if name != "" {
|
|
beforeSet[name] = true
|
|
}
|
|
}
|
|
for _, name := range s.cfg.Desktop.ProviderAccess {
|
|
name = strings.TrimSpace(name)
|
|
if name != "" {
|
|
afterSet[name] = true
|
|
}
|
|
}
|
|
for _, name := range order {
|
|
if beforeSet[name] == afterSet[name] {
|
|
continue
|
|
}
|
|
s.operations = append(s.operations, providerSetupOperation{
|
|
kind: setupOpAccessMembership,
|
|
accessName: name,
|
|
beforeBool: beforeSet[name],
|
|
afterBool: afterSet[name],
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *providerSetupSession) setCredential(key, value string) error {
|
|
key = strings.TrimSpace(key)
|
|
if !config.IsValidCredentialKey(key) {
|
|
return fmt.Errorf("invalid API key variable name %q", key)
|
|
}
|
|
if strings.ContainsAny(value, "\r\n") {
|
|
return fmt.Errorf("API key for %s contains a newline", key)
|
|
}
|
|
s.pendingCredentials[key] = value
|
|
return nil
|
|
}
|
|
|
|
func (s *providerSetupSession) setDefaultModel(model string) error {
|
|
before := s.cfg.DefaultModel
|
|
if err := s.cfg.SetDefaultModel(model); err != nil {
|
|
return err
|
|
}
|
|
if before != s.cfg.DefaultModel {
|
|
s.operations = append(s.operations, providerSetupOperation{
|
|
kind: setupOpDefaultModel,
|
|
beforeString: before,
|
|
afterString: s.cfg.DefaultModel,
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// providerUsable reports whether the provider would be selectable once this
|
|
// session saves: it lists models and either needs no key or has one resolvable
|
|
// from the credential store or staged in this session.
|
|
func (s *providerSetupSession) providerUsable(p *config.ProviderEntry) bool {
|
|
if p == nil || len(p.ModelList()) == 0 {
|
|
return false
|
|
}
|
|
return p.Configured() || s.pendingCredentials[p.APIKeyEnv] != ""
|
|
}
|
|
|
|
// defaultModelUsable reports whether default_model resolves to a provider the
|
|
// user could actually run once this session saves.
|
|
func (s *providerSetupSession) defaultModelUsable() bool {
|
|
entry, ok := s.cfg.ResolveModel(s.cfg.DefaultModel)
|
|
return ok && s.providerUsable(entry)
|
|
}
|
|
|
|
// promoteDefaultToNewProviders keeps the wizard's first-run contract: when the
|
|
// current default_model cannot run (unresolvable, or its key is neither stored
|
|
// nor staged), point it at the first usable provider the user just added, so a
|
|
// first run that only configures a custom provider boots on that provider
|
|
// instead of failing on the built-in default's missing key. A usable default is
|
|
// never hijacked.
|
|
func (s *providerSetupSession) promoteDefaultToNewProviders(entries []config.ProviderEntry) {
|
|
if s.defaultModelUsable() {
|
|
return
|
|
}
|
|
for _, entry := range entries {
|
|
current, ok := s.cfg.Provider(entry.Name)
|
|
if !ok || !s.providerUsable(current) {
|
|
continue
|
|
}
|
|
if err := s.setDefaultModel(current.Name); err == nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *providerSetupSession) credentialLines() []string {
|
|
keys := make([]string, 0, len(s.pendingCredentials))
|
|
for key := range s.pendingCredentials {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
lines := make([]string, 0, len(keys))
|
|
for _, key := range keys {
|
|
lines = append(lines, key+"="+s.pendingCredentials[key])
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (s *providerSetupSession) summary() []string {
|
|
var added, edited []string
|
|
for _, p := range s.cfg.Providers {
|
|
old, existed := s.originalProviders[p.Name]
|
|
switch {
|
|
case !existed:
|
|
added = append(added, p.Name)
|
|
case !providerSetupEqual(old, p):
|
|
edited = append(edited, p.Name)
|
|
}
|
|
}
|
|
var out []string
|
|
if len(added) > 0 {
|
|
out = append(out, fmt.Sprintf(i18n.M.SetupSummaryAddedFmt, strings.Join(added, ", ")))
|
|
}
|
|
if len(edited) > 0 {
|
|
out = append(out, fmt.Sprintf(i18n.M.SetupSummaryEditedFmt, strings.Join(edited, ", ")))
|
|
}
|
|
if len(s.removed) > 0 {
|
|
names := make([]string, 0, len(s.removed))
|
|
for name := range s.removed {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
out = append(out, fmt.Sprintf(i18n.M.SetupSummaryRemovedFmt, strings.Join(names, ", ")))
|
|
}
|
|
if s.cfg.DefaultModel != s.originalDefault {
|
|
out = append(out, fmt.Sprintf(i18n.M.SetupSummaryDefaultFmt, s.cfg.DefaultModel))
|
|
}
|
|
if len(s.pendingCredentials) > 0 {
|
|
out = append(out, fmt.Sprintf(i18n.M.SetupSummaryKeysFmt, len(s.pendingCredentials)))
|
|
}
|
|
if len(out) == 0 {
|
|
out = append(out, i18n.M.SetupSummaryNoChanges)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func providerSetupEqual(a, b config.ProviderEntry) bool {
|
|
// Render-level equality is unnecessary here: the manager only changes these
|
|
// fields, while advanced provider fields are preserved by editing a copy.
|
|
return a.Name == b.Name && a.Kind == b.Kind && a.BaseURL == b.BaseURL &&
|
|
a.Model == b.Model && strings.Join(a.Models, "\x00") == strings.Join(b.Models, "\x00") &&
|
|
a.Default == b.Default && a.APIKeyEnv == b.APIKeyEnv
|
|
}
|
|
|
|
func runProviderSetupManager(s *providerSetupSession, configPath, envPath string) int {
|
|
cfg := s.cfg
|
|
repaired, repairs := repairInvalidProviderKeyEnvs(cfg.Providers)
|
|
for i := range repaired {
|
|
if config.ProviderEntriesConfigEqual(cfg.Providers[i], repaired[i]) {
|
|
continue
|
|
}
|
|
before := providerSetupEntryPtr(cfg.Providers[i])
|
|
cfg.Providers[i] = repaired[i]
|
|
s.recordProviderMutation(repaired[i].Name, before, providerSetupEntryPtr(repaired[i]))
|
|
}
|
|
for _, repair := range repairs {
|
|
fmt.Fprintf(os.Stderr, " %s\n", dim(fmt.Sprintf(i18n.M.RepairedAPIKeyEnvFmt, repair.provider, repair.old, repair.new)))
|
|
}
|
|
for {
|
|
items := providerManagerItems(s)
|
|
idx, err := selectOne(i18n.M.SetupManagerTitle, items)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "\n"+i18n.M.SetupCancelled)
|
|
return 1
|
|
}
|
|
providerCount := len(cfg.Providers)
|
|
switch idx {
|
|
case providerCount:
|
|
if !addProviderToSession(s, false) {
|
|
continue
|
|
}
|
|
case providerCount + 1:
|
|
if !addProviderToSession(s, true) {
|
|
continue
|
|
}
|
|
case providerCount + 2:
|
|
rc := saveProviderSetupSession(s, configPath, envPath)
|
|
if rc == setupManagerContinue {
|
|
continue
|
|
}
|
|
return rc
|
|
case providerCount + 3:
|
|
fmt.Println(i18n.M.SetupCancelled)
|
|
return 1
|
|
default:
|
|
manageProvider(s, idx)
|
|
}
|
|
}
|
|
}
|
|
|
|
func providerManagerItems(s *providerSetupSession) []menuItem {
|
|
cfg := s.cfg
|
|
items := make([]menuItem, 0, len(cfg.Providers)+4)
|
|
for _, p := range cfg.Providers {
|
|
models := p.ModelList()
|
|
keyStatus := i18n.M.SetupKeyMissing
|
|
if p.APIKeyEnv == "" || config.CredentialIsSet(p.APIKeyEnv) || s.pendingCredentials[p.APIKeyEnv] != "" {
|
|
keyStatus = i18n.M.SetupKeySet
|
|
}
|
|
desc := fmt.Sprintf("%s · %d %s · %s", p.Kind, len(models), i18n.M.SetupModelsUnit, keyStatus)
|
|
if cfg.DefaultModel == p.Name || config.ModelRefsProvider(cfg.DefaultModel, p.Name) {
|
|
desc += " · " + i18n.M.SetupDefaultBadge
|
|
}
|
|
items = append(items, menuItem{name: p.Name, desc: desc})
|
|
}
|
|
return append(items,
|
|
menuItem{name: i18n.M.SetupAddOpenAI, desc: i18n.M.CustomProviderDesc},
|
|
menuItem{name: i18n.M.SetupAddAnthropic, desc: i18n.M.AnthropicProviderDesc},
|
|
menuItem{name: i18n.M.SetupSaveExit, desc: i18n.M.SetupSaveExitDesc},
|
|
menuItem{name: i18n.M.SetupCancel, desc: i18n.M.SetupCancelDesc},
|
|
)
|
|
}
|
|
|
|
func addProviderToSession(s *providerSetupSession, anthropic bool) bool {
|
|
var result providerPromptResult
|
|
var err error
|
|
if anthropic {
|
|
result, err = promptAnthropicProvider()
|
|
} else {
|
|
result, err = promptCustomProvider()
|
|
}
|
|
if err != nil {
|
|
if err != errCancelled {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
return false
|
|
}
|
|
for _, entry := range result.entries {
|
|
if !confirmSharedCredential(s.cfg, entry, "") {
|
|
return false
|
|
}
|
|
}
|
|
if err := s.add(result.entries); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return false
|
|
}
|
|
s.addProviderAccess(result.entries)
|
|
for key, value := range result.credentials {
|
|
if err := s.setCredential(key, value); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return false
|
|
}
|
|
}
|
|
// After the new keys are staged, so usability sees them.
|
|
s.promoteDefaultToNewProviders(result.entries)
|
|
return true
|
|
}
|
|
|
|
func manageProvider(s *providerSetupSession, providerIndex int) {
|
|
if providerIndex < 0 || providerIndex >= len(s.cfg.Providers) {
|
|
return
|
|
}
|
|
p := s.cfg.Providers[providerIndex]
|
|
idx, err := selectOne(fmt.Sprintf(i18n.M.SetupProviderActionsFmt, p.Name), []menuItem{
|
|
{name: i18n.M.SetupEditProvider},
|
|
{name: i18n.M.SetupUpdateKey},
|
|
{name: i18n.M.SetupTestRefresh},
|
|
{name: i18n.M.SetupSetDefault},
|
|
{name: i18n.M.SetupRemoveProvider},
|
|
{name: i18n.M.SetupBack},
|
|
})
|
|
if err != nil || idx == 5 {
|
|
return
|
|
}
|
|
switch idx {
|
|
case 0:
|
|
editProvider(s, p)
|
|
case 1:
|
|
updateProviderKey(s, p)
|
|
case 2:
|
|
testAndRefreshProvider(s, p)
|
|
case 3:
|
|
setDefaultProvider(s, p)
|
|
case 4:
|
|
removeProviderFromSession(s, p)
|
|
}
|
|
}
|
|
|
|
func editProvider(s *providerSetupSession, current config.ProviderEntry) {
|
|
in := bufio.NewScanner(os.Stdin)
|
|
edited := current
|
|
edited.BaseURL = ask(in, os.Stdout, i18n.M.CustomPromptBaseURL, current.BaseURL)
|
|
models := ask(in, os.Stdout, i18n.M.SetupPromptModels, strings.Join(current.ModelList(), ","))
|
|
edited.Models = splitModels(models)
|
|
if len(edited.Models) == 1 {
|
|
edited.Model = edited.Models[0]
|
|
} else {
|
|
edited.Model = ""
|
|
}
|
|
if len(edited.Models) > 0 && !containsString(edited.Models, edited.Default) {
|
|
edited.Default = edited.Models[0]
|
|
}
|
|
edited.APIKeyEnv = promptOptionalAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, current.APIKeyEnv)
|
|
if !confirmSharedCredential(s.cfg, edited, current.Name) {
|
|
return
|
|
}
|
|
if err := s.upsert([]config.ProviderEntry{edited}); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
|
|
func promptOptionalAPIKeyEnvName(in *bufio.Scanner, w io.Writer, label, def string) string {
|
|
for {
|
|
key := ask(in, w, label, def)
|
|
if key == "" || config.IsValidCredentialKey(key) {
|
|
return key
|
|
}
|
|
fmt.Fprintf(w, i18n.M.InvalidAPIKeyEnvFmt+"\n", key)
|
|
}
|
|
}
|
|
|
|
func splitModels(raw string) []string {
|
|
seen := map[string]bool{}
|
|
var models []string
|
|
for _, model := range strings.Split(raw, ",") {
|
|
model = strings.TrimSpace(model)
|
|
if model != "" && !seen[model] {
|
|
seen[model] = true
|
|
models = append(models, model)
|
|
}
|
|
}
|
|
return models
|
|
}
|
|
|
|
func confirmSharedCredential(cfg *config.Config, candidate config.ProviderEntry, ignoreName string) bool {
|
|
if candidate.APIKeyEnv == "" {
|
|
return true
|
|
}
|
|
for _, p := range cfg.Providers {
|
|
if p.Name == ignoreName || p.Name == candidate.Name || p.APIKeyEnv != candidate.APIKeyEnv || p.BaseURL == candidate.BaseURL {
|
|
continue
|
|
}
|
|
in := bufio.NewScanner(os.Stdin)
|
|
answer := ask(in, os.Stdout, fmt.Sprintf(i18n.M.SetupSharedKeyWarningFmt, candidate.APIKeyEnv, p.Name, p.BaseURL), "y/N")
|
|
return answer == "y" || answer == "Y"
|
|
}
|
|
return true
|
|
}
|
|
|
|
func updateProviderKey(s *providerSetupSession, p config.ProviderEntry) {
|
|
in := bufio.NewScanner(os.Stdin)
|
|
keyEnvChanged := false
|
|
if p.APIKeyEnv == "" {
|
|
p.APIKeyEnv = promptAPIKeyEnvName(in, os.Stdout, i18n.M.CustomPromptKeyEnv, apiKeyEnvFromProviderName(p.Name))
|
|
keyEnvChanged = true
|
|
}
|
|
if !confirmSharedCredential(s.cfg, p, p.Name) {
|
|
return
|
|
}
|
|
if keyEnvChanged {
|
|
if err := s.upsert([]config.ProviderEntry{p}); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return
|
|
}
|
|
}
|
|
value := ask(in, os.Stdout, fmt.Sprintf(i18n.M.SetupPromptAPIKeyFmt, p.APIKeyEnv), "")
|
|
if value == "" {
|
|
return
|
|
}
|
|
if err := s.setCredential(p.APIKeyEnv, value); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
|
|
func testAndRefreshProvider(s *providerSetupSession, p config.ProviderEntry) {
|
|
restore := temporarilySetCredential(p.APIKeyEnv, s.pendingCredentials[p.APIKeyEnv])
|
|
defer restore()
|
|
p.ResolveAPIKeyFromProcessEnvForProbe()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
models, err := p.FetchModels(ctx)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, i18n.M.FetchModelsFailedFmt+"\n", p.Name, err)
|
|
return
|
|
}
|
|
if len(models) == 0 {
|
|
fmt.Fprintln(os.Stderr, i18n.M.CustomFetchEmpty)
|
|
return
|
|
}
|
|
items := make([]menuItem, len(models))
|
|
for i, model := range models {
|
|
items[i] = menuItem{name: model}
|
|
}
|
|
idxs, err := selectMany(fmt.Sprintf(i18n.M.SelectModelsLabel, p.Name), items)
|
|
if err != nil || len(idxs) == 0 {
|
|
return
|
|
}
|
|
selected := make([]string, 0, len(idxs))
|
|
for _, idx := range idxs {
|
|
selected = append(selected, models[idx])
|
|
}
|
|
p.Models = selected
|
|
p.Model = ""
|
|
if !containsString(selected, p.Default) {
|
|
p.Default = selected[0]
|
|
}
|
|
if err := s.upsert([]config.ProviderEntry{p}); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
return
|
|
}
|
|
fmt.Printf(" %s\n", green(fmt.Sprintf(i18n.M.FetchModelsSuccessFmt, len(models), p.Name)))
|
|
}
|
|
|
|
func temporarilySetCredential(key, value string) func() {
|
|
if key == "" || value == "" {
|
|
return func() {}
|
|
}
|
|
old, existed := os.LookupEnv(key)
|
|
_ = os.Setenv(key, value)
|
|
return func() {
|
|
if existed {
|
|
_ = os.Setenv(key, old)
|
|
} else {
|
|
_ = os.Unsetenv(key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func setDefaultProvider(s *providerSetupSession, p config.ProviderEntry) {
|
|
models := p.ModelList()
|
|
if len(models) == 0 {
|
|
return
|
|
}
|
|
items := make([]menuItem, len(models))
|
|
for i, model := range models {
|
|
items[i] = menuItem{name: model}
|
|
}
|
|
idx, err := selectOne(i18n.M.SetupSelectDefaultModel, items)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if err := s.setDefaultModel(p.Name + "/" + models[idx]); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
|
|
func removeProviderFromSession(s *providerSetupSession, p config.ProviderEntry) {
|
|
in := bufio.NewScanner(os.Stdin)
|
|
answer := ask(in, os.Stdout, fmt.Sprintf(i18n.M.SetupConfirmRemoveFmt, p.Name), "y/N")
|
|
if answer != "y" && answer != "Y" {
|
|
return
|
|
}
|
|
if err := s.remove(p.Name); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
}
|
|
}
|
|
|
|
func (s *providerSetupSession) replayOperations(cfg *config.Config, accessDeclared *bool, declaredProviders []string) error {
|
|
for _, operation := range s.operations {
|
|
switch operation.kind {
|
|
case setupOpProvider:
|
|
current, exists := cfg.Provider(operation.providerName)
|
|
if operation.beforeProvider == nil {
|
|
if exists {
|
|
return &providerSetupConflictError{field: fmt.Sprintf("provider %q", operation.providerName)}
|
|
}
|
|
} else if !exists || !config.ProviderEntriesConfigEqual(*current, *operation.beforeProvider) {
|
|
return &providerSetupConflictError{field: fmt.Sprintf("provider %q", operation.providerName)}
|
|
}
|
|
if operation.afterProvider == nil {
|
|
if err := cfg.RemoveProvider(operation.providerName); err != nil {
|
|
return fmt.Errorf("replay remove provider %q: %w", operation.providerName, err)
|
|
}
|
|
} else if err := cfg.UpsertProviderPreservingRuntime(*operation.afterProvider); err != nil {
|
|
return fmt.Errorf("replay provider %q: %w", operation.providerName, err)
|
|
}
|
|
case setupOpDefaultModel:
|
|
if cfg.DefaultModel != operation.beforeString {
|
|
return &providerSetupConflictError{field: "default_model"}
|
|
}
|
|
if err := cfg.SetDefaultModel(operation.afterString); err != nil {
|
|
return fmt.Errorf("replay default_model: %w", err)
|
|
}
|
|
case setupOpLanguage:
|
|
if cfg.Language != operation.beforeString {
|
|
return &providerSetupConflictError{field: "language"}
|
|
}
|
|
cfg.Language = operation.afterString
|
|
case setupOpMaterializeAccess:
|
|
if *accessDeclared {
|
|
return &providerSetupConflictError{field: "desktop.provider_access"}
|
|
}
|
|
cfg.Desktop.ProviderAccess = nil
|
|
if operation.projectScoped {
|
|
for _, name := range declaredProviders {
|
|
provider, ok := cfg.Provider(name)
|
|
if ok && provider.Configured() && len(provider.ModelList()) > 0 {
|
|
cfg.Desktop.ProviderAccess = append(cfg.Desktop.ProviderAccess, name)
|
|
}
|
|
}
|
|
} else {
|
|
config.NormalizeLegacyDesktopProviderAccess(cfg)
|
|
}
|
|
*accessDeclared = true
|
|
if cfg.Desktop.ProviderAccess == nil {
|
|
cfg.Desktop.ProviderAccess = []string{}
|
|
}
|
|
case setupOpAccessMembership:
|
|
current := providerSetupAccessContains(cfg.Desktop.ProviderAccess, operation.accessName)
|
|
if current != operation.beforeBool {
|
|
return &providerSetupConflictError{field: fmt.Sprintf("desktop.provider_access[%q]", operation.accessName)}
|
|
}
|
|
if operation.afterBool {
|
|
cfg.Desktop.ProviderAccess = append(cfg.Desktop.ProviderAccess, operation.accessName)
|
|
} else {
|
|
out := cfg.Desktop.ProviderAccess[:0]
|
|
for _, name := range cfg.Desktop.ProviderAccess {
|
|
if strings.TrimSpace(name) != operation.accessName {
|
|
out = append(out, name)
|
|
}
|
|
}
|
|
cfg.Desktop.ProviderAccess = out
|
|
}
|
|
default:
|
|
return fmt.Errorf("unknown provider setup operation %d", operation.kind)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func providerSetupAccessContains(names []string, want string) bool {
|
|
want = strings.TrimSpace(want)
|
|
for _, name := range names {
|
|
if strings.TrimSpace(name) == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func commitProviderSetupSession(s *providerSetupSession, configPath string) (bool, error) {
|
|
if len(s.operations) == 0 {
|
|
return false, nil
|
|
}
|
|
unlock := func() {}
|
|
if config.IsUserConfigPath(configPath) {
|
|
unlock = config.LockUserConfigEdits()
|
|
}
|
|
defer unlock()
|
|
|
|
before, err := readProviderSetupFileSnapshot(configPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
declarations, err := config.InspectConfigFileDeclarations(configPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
fresh, err := config.LoadForEditReadOnlyStrict(configPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
accessDeclared := declarations.DesktopProviderAccessDeclared
|
|
if err := s.replayOperations(fresh, &accessDeclared, declarations.ProviderNames); err != nil {
|
|
return false, err
|
|
}
|
|
current, err := readProviderSetupFileSnapshot(configPath)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !providerSetupFileSnapshotEqual(before, current) {
|
|
return false, &providerSetupConflictError{field: "configuration file"}
|
|
}
|
|
if err := fresh.SaveTo(configPath); err != nil {
|
|
return false, err
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func saveProviderSetupSession(s *providerSetupSession, configPath, envPath string) int {
|
|
fmt.Println()
|
|
fmt.Println(i18n.M.SetupSummaryTitle)
|
|
for _, line := range s.summary() {
|
|
fmt.Println(" " + line)
|
|
}
|
|
in := bufio.NewScanner(os.Stdin)
|
|
answer := ask(in, os.Stdout, i18n.M.SetupConfirmSave, "Y/n")
|
|
if answer == "n" || answer == "N" {
|
|
return setupManagerContinue
|
|
}
|
|
configWritten, err := commitProviderSetupSession(s, configPath)
|
|
if err != nil {
|
|
var conflict *providerSetupConflictError
|
|
if errors.As(err, &conflict) {
|
|
fmt.Fprintf(os.Stderr, i18n.M.SetupConcurrentChangeFmt+"\n", conflict.field)
|
|
} else {
|
|
fmt.Fprintln(os.Stderr, i18n.M.WriteConfigErr, err)
|
|
}
|
|
return 1
|
|
}
|
|
if configWritten {
|
|
fmt.Printf("\n%s %s\n", green("✓"), fmt.Sprintf(i18n.M.WroteFileFmt, displayPath(configPath)))
|
|
}
|
|
if lines := s.credentialLines(); len(lines) > 0 {
|
|
target, err := config.StoreCredentialLines(lines)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, i18n.M.WriteEnvErr, err)
|
|
return 1
|
|
}
|
|
if target == "" {
|
|
target = envPath
|
|
}
|
|
fmt.Printf("%s %s\n", green("✓"), fmt.Sprintf(i18n.M.WroteFileFmt, displayPath(target)))
|
|
}
|
|
fmt.Printf("\n%s %s\n", accent("◆"), i18n.M.SetupComplete)
|
|
return 0
|
|
}
|