open-code-review/cmd/opencodereview/provider_cmd.go
wxwxwxw_orange 39eb0f3984
Some checks are pending
CI / test (push) Waiting to run
fix(tui): persist official-tab models and refine saved secret hint (#260)
* fix(tui): persist official-tab models and refine saved secret hint

Persist user-added models to providers.<name>.models on the official tab.
When an API key or auth token is already saved, show a replace hint with a
prefix/suffix fingerprint (skipped for short keys), use a fixed mask placeholder,
and ensure typing or paste replaces the saved value instead of re-saving it.

* fix(tui): model add/delete UX and config wizard hardening
- Add model add/delete in config provider and config model (official + custom)
- Show d Delete only on model rows; green highlight when selected
- Improve Esc cancel text; track savedInSession to avoid misleading messages
- Reload config on save failure; read registry models fresh after reload
- Export llm.ModelListContains; fix config model persist using registry-only check

* fix(tui): defer provider config until confirm and harden API key UX
Only persist provider/model on wizard confirm; keep in-session picks via
sessionModelPick. Validate API key before quit, clear saved keys when emptied,
and improve official env-var hints and custom edit clear behavior.

* feat(tui): show active model suffix on official provider list
Align Official tab with Custom: display (model) next to the active preset
when cfg.Provider matches and a global model is configured.
2026-07-06 20:38:39 +08:00

397 lines
10 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
tea "charm.land/bubbletea/v2"
"github.com/open-code-review/open-code-review/internal/llm"
)
func runConfigProvider() error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
m := newProviderTUI(cfg, configPath)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("TUI error: %w", err)
}
final := finalModel.(providerTUIModel)
if !final.confirmed {
// TUI persists changes during the session; Esc only abandons the final
// provider/API-key confirmation step.
printWizardCancelled(final.savedInSession, "Configuration changes")
return nil
}
result := final.result()
if result.isManual {
return applyManualConfig(configPath, cfg, result)
}
if result.isCustom {
return applyCustomProviderConfig(configPath, cfg, result)
}
return applyOfficialProviderConfig(configPath, cfg, result)
}
// printWizardCancelled prints the standard Esc-cancel message for config wizards.
// changesDescription is a short noun phrase, e.g. "Configuration changes".
func printWizardCancelled(savedInSession bool, changesDescription string) {
if savedInSession {
fmt.Printf("Cancelled. (%s made during this session were kept.)\n", changesDescription)
return
}
fmt.Println("Cancelled.")
}
func applyProviderDeletions(configPath string, cfg *Config, names []string) (bool, error) {
clearedActive := false
for _, name := range names {
wasActive, err := deleteCustomProvider(cfg, name)
if err != nil {
fmt.Fprintf(os.Stderr, "[ocr] skip delete %q: %v\n", name, err)
continue
}
if wasActive {
clearedActive = true
}
fmt.Printf("Deleted custom provider %q.\n", name)
}
if err := saveConfig(configPath, cfg); err != nil {
return false, err
}
return clearedActive, nil
}
func removeModels(existing, toRemove []string) []string {
removeSet := make(map[string]struct{}, len(toRemove))
for _, m := range toRemove {
removeSet[m] = struct{}{}
}
result := make([]string, 0, len(existing))
for _, m := range existing {
if _, found := removeSet[m]; found {
continue
}
result = append(result, m)
}
return result
}
func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.url == "" {
return fmt.Errorf("URL is required for manual configuration")
}
if result.model == "" {
return fmt.Errorf("model is required for manual configuration")
}
cfg.Provider = ""
cfg.Model = ""
cfg.Llm.URL = result.url
cfg.Llm.Model = result.model
cfg.Llm.AuthToken = result.apiKey
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
if err != nil {
return fmt.Errorf("invalid auth_header: %w", err)
}
cfg.Llm.AuthHeader = authHeader
useAnthropic := result.protocol == "anthropic"
cfg.Llm.UseAnthropic = &useAnthropic
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Println("\nManual configuration saved.")
fmt.Printf("URL: %s\n", result.url)
fmt.Printf("Protocol: %s\n", result.protocol)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
return nil
}
func applyCustomProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.provider == "" {
return fmt.Errorf("provider name is required")
}
model := result.resolvedModel()
if model == "" {
return fmt.Errorf("model is required")
}
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[result.provider]
entry.Model = model
if len(result.models) > 0 {
entry.Models = append([]string(nil), result.models...)
}
entry.Models = ensureModelInList(entry.Models, model)
if result.url != "" {
entry.URL = result.url
}
if result.protocol != "" {
entry.Protocol = result.protocol
}
if result.authHeader != "" {
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
if err != nil {
return fmt.Errorf("invalid auth_header: %w", err)
}
entry.AuthHeader = authHeader
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
} else {
entry.APIKey = ""
}
cfg.CustomProviders[result.provider] = entry
if !result.isEdit {
cfg.Provider = result.provider
cfg.Model = model
} else if cfg.Provider == result.provider {
cfg.Model = model
}
if err := saveConfig(configPath, cfg); err != nil {
return err
}
if result.isEdit {
if cfg.Provider == result.provider {
fmt.Printf("\nActive provider %q updated.\n", result.provider)
} else {
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
}
fmt.Printf("Model: %s\n", model)
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
fmt.Printf("Model: %s\n", model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.provider == "" {
return fmt.Errorf("provider and model are required")
}
model := result.resolvedModel()
if model == "" {
return fmt.Errorf("provider and model are required")
}
preset, isPreset := llm.LookupProvider(result.provider)
if result.apiKey == "" {
if isPreset && preset.EnvVar != "" {
if os.Getenv(preset.EnvVar) == "" {
return fmt.Errorf("API key is required for provider %s (configure it or set $%s)", result.provider, preset.EnvVar)
}
} else {
return fmt.Errorf("API key is required for provider %s", result.provider)
}
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[result.provider]
entry.Model = model
if len(result.models) > 0 {
entry.Models = mergeModelLists(entry.Models, result.models)
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
} else {
// Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR.
entry.APIKey = ""
}
cfg.Providers[result.provider] = entry
if cfg.Provider != result.provider {
cfg.Model = ""
}
cfg.Provider = result.provider
cfg.Model = model
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nProvider set to: %s\n", result.provider)
fmt.Printf("Model: %s\n", model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
func runConfigModel() error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Provider == "" {
return fmt.Errorf("no provider configured. Run 'ocr config provider' first")
}
currentModel := ""
provider := llm.Provider{Name: cfg.Provider, DisplayName: cfg.Provider}
isCustom := false
registryModels := []string(nil)
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
provider = preset
registryModels = append([]string(nil), preset.Models...)
if entry, ok := cfg.Providers[cfg.Provider]; ok {
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
provider.Models = mergeModelLists(provider.Models, entry.Models)
}
} else {
isCustom = true
entry, ok := cfg.CustomProviders[cfg.Provider]
if !ok {
return fmt.Errorf("provider %q is not configured in custom_providers", cfg.Provider)
}
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
provider.DisplayName = cfg.Provider + " (custom)"
provider.Protocol = entry.Protocol
provider.BaseURL = entry.URL
provider.Models = mergeModelLists(entry.Models)
}
m := newModelTUIConfig(modelTUIConfig{
Provider: provider,
CurrentModel: currentModel,
RegistryModels: registryModels,
ExistingCfg: cfg,
ConfigPath: configPath,
ProviderName: cfg.Provider,
IsCustom: isCustom,
})
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("TUI error: %w", err)
}
final := finalModel.(modelTUIModel)
if final.cancelled {
printWizardCancelled(final.savedInSession, "Model list changes")
return nil
}
selectedModel := final.selectedModel()
if selectedModel == "" {
return fmt.Errorf("model name cannot be empty")
}
if isCustom {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[cfg.Provider]
entry.Model = selectedModel
entry.Models = ensureModelInList(entry.Models, selectedModel)
cfg.CustomProviders[cfg.Provider] = entry
} else {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
// Use registry-only list: provider.Models was captured before the TUI and
// may include stale entry.Models from add/delete during the session.
if !llm.ModelListContains(registryModels, selectedModel) {
entry.Models = ensureModelInList(entry.Models, selectedModel)
}
cfg.Providers[cfg.Provider] = entry
}
cfg.Model = selectedModel
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nModel set to: %s\n", selectedModel)
return nil
}
func saveConfig(path string, cfg *Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod config: %w", err)
}
return nil
}
func maskKey(key string) string {
if key == "" {
return "(not set)"
}
if len(key) <= 8 {
return "***"
}
return key[:4] + "***" + key[len(key)-4:]
}