fix(tui): persist official-tab models and refine saved secret hint (#260)
Some checks are pending
CI / test (push) Waiting to run

* 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.
This commit is contained in:
wxwxwxw_orange 2026-07-06 20:38:39 +08:00 committed by GitHub
parent 964f2166d0
commit 39eb0f3984
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 3417 additions and 249 deletions

View file

@ -480,23 +480,13 @@ func ensureModelInList(models []string, model string) []string {
if model == "" {
return models
}
if modelListContains(models, model) {
if llm.ModelListContains(models, model) {
return models
}
out := append([]string(nil), models...)
return append(out, model)
}
func modelListContains(models []string, target string) bool {
target = strings.TrimSpace(target)
for _, model := range models {
if model == target {
return true
}
}
return false
}
func setProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {

View file

@ -32,13 +32,9 @@ func runConfigProvider() error {
final := finalModel.(providerTUIModel)
if !final.confirmed {
// TUI persists changes (create/edit/model/add/delete) directly to disk
// during the session, so the on-disk file is already up to date for any
// savedInSession operation. No additional post-TUI apply step is needed.
if final.savedInSession {
return nil
}
fmt.Println("Cancelled.")
// TUI persists changes during the session; Esc only abandons the final
// provider/API-key confirmation step.
printWizardCancelled(final.savedInSession, "Configuration changes")
return nil
}
@ -55,6 +51,16 @@ func runConfigProvider() error {
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 {
@ -133,7 +139,8 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
if result.provider == "" {
return fmt.Errorf("provider name is required")
}
if result.model == "" {
model := result.resolvedModel()
if model == "" {
return fmt.Errorf("model is required")
}
@ -142,11 +149,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
entry := cfg.CustomProviders[result.provider]
entry.Model = result.model
entry.Model = model
if len(result.models) > 0 {
entry.Models = append([]string(nil), result.models...)
}
entry.Models = ensureModelInList(entry.Models, result.model)
entry.Models = ensureModelInList(entry.Models, model)
if result.url != "" {
entry.URL = result.url
}
@ -162,14 +169,16 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
} else {
entry.APIKey = ""
}
cfg.CustomProviders[result.provider] = entry
if !result.isEdit {
cfg.Provider = result.provider
cfg.Model = result.model
cfg.Model = model
} else if cfg.Provider == result.provider {
cfg.Model = result.model
cfg.Model = model
}
if err := saveConfig(configPath, cfg); err != nil {
@ -182,13 +191,13 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
} else {
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
}
fmt.Printf("Model: %s\n", result.model)
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", result.model)
fmt.Printf("Model: %s\n", model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
@ -202,7 +211,11 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
}
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.provider == "" || result.model == "" {
if result.provider == "" {
return fmt.Errorf("provider and model are required")
}
model := result.resolvedModel()
if model == "" {
return fmt.Errorf("provider and model are required")
}
@ -223,12 +236,15 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
}
entry := cfg.Providers[result.provider]
entry.Model = result.model
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
@ -236,14 +252,14 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
cfg.Model = ""
}
cfg.Provider = result.provider
cfg.Model = result.model
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", result.model)
fmt.Printf("Model: %s\n", model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
@ -274,8 +290,10 @@ func runConfigModel() error {
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)
@ -293,7 +311,15 @@ func runConfigModel() error {
provider.Models = mergeModelLists(entry.Models)
}
m := newModelTUI(provider, currentModel)
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 {
@ -302,7 +328,7 @@ func runConfigModel() error {
final := finalModel.(modelTUIModel)
if final.cancelled {
fmt.Println("Cancelled.")
printWizardCancelled(final.savedInSession, "Model list changes")
return nil
}
@ -325,7 +351,9 @@ func runConfigModel() error {
}
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
if !modelListContains(provider.Models, 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

View file

@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"io"
"os"
"path/filepath"
"testing"
@ -203,3 +204,175 @@ func TestApplyOfficialProviderConfig_MissingFields(t *testing.T) {
t.Fatal("expected error for missing provider/model")
}
}
func TestApplyOfficialProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {
APIKey: "old-saved-key",
Model: "deepseek-v4-flash",
},
},
}
err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "deepseek",
model: "deepseek-v4-flash",
apiKey: "",
})
if err != nil {
t.Fatalf("applyOfficialProviderConfig: %v", err)
}
if got := cfg.Providers["deepseek"].APIKey; got != "" {
t.Errorf("in-memory APIKey = %q, want empty", got)
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
if got := diskCfg.Providers["deepseek"].APIKey; got != "" {
t.Errorf("persisted APIKey = %q, want empty", got)
}
}
func TestApplyCustomProviderConfig_EmptyKeyClearsSavedAPIKey(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "aaa",
Model: "test",
CustomProviders: map[string]ProviderEntry{
"aaa": {
URL: "https://example.com/v1",
Protocol: "openai",
APIKey: "old-saved-key",
Model: "test",
Models: []string{"test"},
},
},
}
err := applyCustomProviderConfig(configPath, cfg, providerTUIResult{
provider: "aaa",
model: "test",
models: []string{"test"},
apiKey: "",
isCustom: true,
url: "https://example.com/v1",
protocol: "openai",
})
if err != nil {
t.Fatalf("applyCustomProviderConfig: %v", err)
}
if got := cfg.CustomProviders["aaa"].APIKey; got != "" {
t.Errorf("APIKey = %q, want empty", got)
}
}
func TestProviderTUIResult_ResolvedModel(t *testing.T) {
r := providerTUIResult{
provider: "baidu-qianfan",
model: "glm-5",
}
if got := r.resolvedModel(); got != "glm-5" {
t.Errorf("resolvedModel() = %q, want glm-5", got)
}
r = providerTUIResult{
provider: "baidu-qianfan",
sessionModelPick: map[string]string{
"baidu-qianfan": "glm-5",
},
}
if got := r.resolvedModel(); got != "glm-5" {
t.Errorf("resolvedModel() from session pick = %q, want glm-5", got)
}
r = providerTUIResult{provider: "baidu-qianfan"}
if got := r.resolvedModel(); got != "" {
t.Errorf("resolvedModel() = %q, want empty", got)
}
}
func TestApplyOfficialProviderConfig_UsesSessionModelPick(t *testing.T) {
t.Setenv("QIANFAN_API_KEY", "sk-from-env")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "baidu-qianfan",
apiKey: "",
sessionModelPick: map[string]string{
"baidu-qianfan": "glm-5",
},
})
if err != nil {
t.Fatalf("applyOfficialProviderConfig: %v", err)
}
if cfg.Provider != "baidu-qianfan" {
t.Errorf("Provider = %q, want baidu-qianfan", cfg.Provider)
}
if cfg.Model != "glm-5" {
t.Errorf("Model = %q, want glm-5", cfg.Model)
}
}
func TestPrintWizardCancelled(t *testing.T) {
tests := []struct {
name string
savedInSession bool
scope string
want string
}{
{
name: "no changes",
savedInSession: false,
scope: "Configuration changes",
want: "Cancelled.\n",
},
{
name: "provider wizard kept changes",
savedInSession: true,
scope: "Configuration changes",
want: "Cancelled. (Configuration changes made during this session were kept.)\n",
},
{
name: "model wizard kept changes",
savedInSession: true,
scope: "Model list changes",
want: "Cancelled. (Model list changes made during this session were kept.)\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
printWizardCancelled(tc.savedInSession, tc.scope)
w.Close()
os.Stdout = old
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
}
if string(got) != tc.want {
t.Errorf("output = %q, want %q", string(got), tc.want)
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,8 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
@ -44,6 +46,73 @@ func TestCustomProviderActiveModel_MatchingProvider(t *testing.T) {
}
}
func TestOfficialProviderActiveModel_NilCfg(t *testing.T) {
m := providerTUIModel{existingCfg: nil}
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"})
if got != "" {
t.Errorf("expected empty string for nil cfg, got %q", got)
}
}
func TestOfficialProviderActiveModel_DifferentProvider(t *testing.T) {
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, "")
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"})
if got != "" {
t.Errorf("expected empty string for non-active provider, got %q", got)
}
}
func TestOfficialProviderActiveModel_MatchingProvider(t *testing.T) {
cfg := &Config{
Provider: "anthropic",
Model: "claude-opus-4-8",
Providers: map[string]ProviderEntry{
"anthropic": {Model: "claude-opus-4-8"},
},
}
m := newProviderTUI(cfg, "")
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic", DisplayName: "Anthropic Claude API"})
if got != "claude-opus-4-8" {
t.Errorf("expected claude-opus-4-8, got %q", got)
}
}
func TestOfficialProviderActiveModel_EmptyModel(t *testing.T) {
cfg := &Config{
Provider: "anthropic",
Providers: map[string]ProviderEntry{
"anthropic": {},
},
}
m := newProviderTUI(cfg, "")
got := m.officialProviderActiveModel(llm.Provider{Name: "anthropic"})
if got != "" {
t.Errorf("expected empty model, got %q", got)
}
}
func TestProviderTUIView_OfficialTab_ShowsActiveModelSuffix(t *testing.T) {
cfg := &Config{
Provider: "anthropic",
Model: "claude-opus-4-8",
Providers: map[string]ProviderEntry{
"anthropic": {Model: "claude-opus-4-8"},
},
}
m := newProviderTUI(cfg, "")
got := stripANSI(m.View().Content)
if !strings.Contains(got, "(claude-opus-4-8)") {
t.Errorf("view missing active model suffix; got:\n%s", got)
}
}
func TestModelProviderName_OfficialTab(t *testing.T) {
m := newProviderTUI(&Config{}, "")
name := m.modelProviderName()
@ -547,6 +616,12 @@ func TestNewModelTUI(t *testing.T) {
t.Skip("no providers")
}
m := newModelTUI(p[0], "")
if m.isCustomProvider {
t.Error("preset provider test helper should set isCustomProvider=false")
}
if len(m.registryModels) == 0 {
t.Error("registryModels should be populated for preset provider")
}
if m.modelIdx != 0 {
t.Errorf("modelIdx = %d, want 0 for empty currentModel", m.modelIdx)
}
@ -861,6 +936,447 @@ func TestModelTUI_View_CustomModel(t *testing.T) {
}
}
func officialConfigModelTUI(t *testing.T, configPath string, extraModels []string) modelTUIModel {
t.Helper()
preset, ok := llm.LookupProvider("dashscope")
if !ok {
t.Skip("dashscope provider not in registry")
}
models := []string{"qwen3.7-max"}
models = append(models, extraModels...)
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {
Model: "qwen3.7-max",
Models: models,
},
},
}
provider := preset
provider.Models = mergeModelLists(preset.Models, cfg.Providers["dashscope"].Models)
return newModelTUIConfig(modelTUIConfig{
Provider: provider,
RegistryModels: preset.Models,
ExistingCfg: cfg,
ConfigPath: configPath,
ProviderName: "dashscope",
IsCustom: false,
})
}
func customConfigModelTUI(t *testing.T, configPath string, models []string) modelTUIModel {
t.Helper()
cfg := &Config{
Provider: "my-llm",
Model: "m1",
CustomProviders: map[string]ProviderEntry{
"my-llm": {
URL: "https://custom.api/v1",
Protocol: "openai",
Model: "m1",
Models: append([]string(nil), models...),
},
},
}
provider := llm.Provider{
Name: "my-llm",
DisplayName: "my-llm (custom)",
Models: mergeModelLists(models),
}
return newModelTUIConfig(modelTUIConfig{
Provider: provider,
CurrentModel: "m1",
ExistingCfg: cfg,
ConfigPath: configPath,
ProviderName: "my-llm",
IsCustom: true,
})
}
func asModelTUIModel(t *testing.T, m tea.Model) modelTUIModel {
t.Helper()
switch v := m.(type) {
case modelTUIModel:
return v
case *modelTUIModel:
return *v
default:
t.Fatalf("expected modelTUIModel, got %T", m)
return modelTUIModel{}
}
}
func modelTUIEnterCustomModelName(t *testing.T, m modelTUIModel, name string) modelTUIModel {
t.Helper()
m.modelIdx = len(m.displayModels())
result, _ := m.Update(enterKey())
m2 := result.(modelTUIModel)
if !m2.customModel {
t.Fatal("expected customModel after enter on custom item")
}
m2.modelInput.SetValue(name)
result, _ = m2.Update(enterKey())
return result.(modelTUIModel)
}
func modelTUIIdxForName(t *testing.T, m modelTUIModel, name string) int {
t.Helper()
for i, model := range m.displayModels() {
if model == name {
return i
}
}
t.Fatalf("model %q not found in %v", name, m.displayModels())
return -1
}
func TestModelTUI_Official_AddCustomModelStaysOnList(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := officialConfigModelTUI(t, configPath, nil)
m3 := modelTUIEnterCustomModelName(t, m, "new-model")
if m3.confirmed {
t.Error("adding a model should not confirm and quit")
}
if m3.customModel {
t.Error("customModel should be cleared after add")
}
got := m3.existingCfg.Providers["dashscope"].Models
if !llm.ModelListContains(got, "new-model") {
t.Errorf("Models = %v, want new-model appended", got)
}
if m3.existingCfg.Model != "qwen3.7-max" {
t.Errorf("cfg.Model = %q, want active model unchanged", m3.existingCfg.Model)
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load disk config: %v", err)
}
if !llm.ModelListContains(diskCfg.Providers["dashscope"].Models, "new-model") {
t.Errorf("disk Models = %v, want new-model persisted", diskCfg.Providers["dashscope"].Models)
}
}
func TestModelTUI_Official_ListEnterConfirmsSelection(t *testing.T) {
m := officialConfigModelTUI(t, "", nil)
m2 := modelTUIEnterCustomModelName(t, m, "picked-model")
m2.modelIdx = modelTUIIdxForName(t, m2, "picked-model")
result, _ := m2.Update(enterKey())
m3 := result.(modelTUIModel)
if !m3.confirmed {
t.Error("enter on list item should confirm selection")
}
if m3.selectedModel() != "picked-model" {
t.Errorf("selectedModel() = %q, want picked-model", m3.selectedModel())
}
}
func TestModelTUI_CustomProvider_AddCustomModelStaysOnList(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := customConfigModelTUI(t, configPath, []string{"m1"})
m3 := modelTUIEnterCustomModelName(t, m, "new-custom-model")
if m3.confirmed {
t.Error("adding a model should not confirm and quit")
}
if !llm.ModelListContains(m3.existingCfg.CustomProviders["my-llm"].Models, "new-custom-model") {
t.Errorf("Models = %v, want new-custom-model appended", m3.existingCfg.CustomProviders["my-llm"].Models)
}
if m3.existingCfg.CustomProviders["my-llm"].Model != "m1" {
t.Errorf("entry.Model = %q, want active model unchanged", m3.existingCfg.CustomProviders["my-llm"].Model)
}
if !m3.savedInSession {
t.Error("savedInSession should be true after add")
}
}
func TestModelTUI_EscCancelWithoutChangesNoSavedInSession(t *testing.T) {
m := officialConfigModelTUI(t, "", nil)
result, _ := m.Update(escKey())
m2 := result.(modelTUIModel)
if !m2.cancelled {
t.Fatal("esc should cancel")
}
if m2.savedInSession {
t.Error("savedInSession should be false when no add/delete occurred")
}
}
func TestModelTUI_DeleteSetsSavedInSession(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
m.deleteModelName = "aaa"
m.confirmingDeleteModel = true
result, _ := m.confirmDeleteCustomProviderModel()
m2 := asModelTUIModel(t, result)
if !m2.savedInSession {
t.Error("savedInSession should be true after delete")
}
}
func TestModelTUI_AddCustomModelRejectsDuplicate(t *testing.T) {
m := officialConfigModelTUI(t, "", []string{"dup-model"})
m.modelIdx = len(m.displayModels())
result, _ := m.Update(enterKey())
m2 := result.(modelTUIModel)
m2.modelInput.SetValue("dup-model")
result, _ = m2.Update(enterKey())
m3 := result.(modelTUIModel)
if m3.formError != "Already in list: dup-model" {
t.Errorf("formError = %q, want duplicate message", m3.formError)
}
if !m3.customModel {
t.Error("customModel should stay open after duplicate reject")
}
}
func TestModelTUI_Official_DeleteUserAddedModel(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"})
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
if !m2.confirmingDeleteModel {
t.Fatal("pressing d on user-added model should set confirmingDeleteModel = true")
}
result, _ = m2.Update(yKey())
m3 := result.(modelTUIModel)
got := m3.existingCfg.Providers["dashscope"].Models
if len(got) != 1 || got[0] != "qwen3.7-max" {
t.Errorf("Models = %v, want [qwen3.7-max]", got)
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load disk config: %v", err)
}
if len(diskCfg.Providers["dashscope"].Models) != 1 {
t.Errorf("disk Models = %v, want [qwen3.7-max]", diskCfg.Providers["dashscope"].Models)
}
}
func TestModelTUI_Official_DeleteBuiltInModelIgnored(t *testing.T) {
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on built-in model should not trigger delete confirmation")
}
}
func TestIsUserAddedOfficialModelName_RegistryDuplicateNotUserAdded(t *testing.T) {
preset, ok := llm.LookupProvider("dashscope")
if !ok {
t.Skip("dashscope provider not in registry")
}
cfg := &Config{
Providers: map[string]ProviderEntry{
"dashscope": {Models: []string{"qwen3.7-max", "my-custom-model"}},
},
}
if isUserAddedOfficialModelName("qwen3.7-max", "dashscope", preset.Models, cfg) {
t.Error("registry model should not be treated as user-added even when listed in config")
}
if !isUserAddedOfficialModelName("my-custom-model", "dashscope", preset.Models, cfg) {
t.Error("config-only model should be user-added")
}
}
func TestModelTUI_Official_RegistryModelNotDeletable(t *testing.T) {
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
if m.isUserAddedModel("qwen3.7-max") {
t.Error("qwen3.7-max should not be user-added when it is in the registry")
}
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on registry model should not trigger delete confirmation")
}
}
func TestModelTUI_Official_DeleteOnCustomModelInputIgnored(t *testing.T) {
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = len(m.displayModels())
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
}
}
func TestModelTUI_CustomProvider_DeleteOnCustomModelInputIgnored(t *testing.T) {
m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
m.modelIdx = len(m.displayModels())
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
}
}
func TestModelTUI_Official_UserAddedModelShowsDeleteHint(t *testing.T) {
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelTUIIdxForName(t, m, "qwen3.7-max")
got := stripANSI(m.View().Content)
if strings.Contains(got, "d Delete") {
t.Errorf("built-in model should not show d Delete hint; got:\n%s", got)
}
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
got = stripANSI(m.View().Content)
if !strings.Contains(got, "d Delete") {
t.Errorf("user-added model should show d Delete hint; got:\n%s", got)
}
}
func TestModelTUI_CustomProvider_ShowsDeleteHint(t *testing.T) {
m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
m.modelIdx = len(m.displayModels())
got := stripANSI(m.View().Content)
if strings.Contains(got, "d Delete") {
t.Errorf("custom input row should not show d Delete hint; got:\n%s", got)
}
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
got = stripANSI(m.View().Content)
if !strings.Contains(got, "d Delete") {
t.Errorf("custom model row should show d Delete hint; got:\n%s", got)
}
}
func TestModelTUI_CustomProvider_DeleteClearsCustomInput(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
m.customModel = true
m.modelInput.SetValue("aaa")
m.deleteModelName = "aaa"
m.confirmingDeleteModel = true
result, _ := m.confirmDeleteCustomProviderModel()
m2 := asModelTUIModel(t, result)
if m2.customModel {
t.Error("customModel should be false after delete")
}
if m2.modelInput.Value() != "" {
t.Errorf("modelInput = %q, want empty after delete", m2.modelInput.Value())
}
}
func TestModelTUI_CustomProvider_DeleteModelViaDKey(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := customConfigModelTUI(t, configPath, []string{"m1", "aaa"})
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
if !m2.confirmingDeleteModel || m2.deleteModelName != "aaa" {
t.Fatalf("after d: confirming=%v deleteModelName=%q", m2.confirmingDeleteModel, m2.deleteModelName)
}
result, _ = m2.Update(yKey())
m3 := result.(modelTUIModel)
got := m3.existingCfg.CustomProviders["my-llm"].Models
if len(got) != 1 || got[0] != "m1" {
t.Errorf("Models = %v, want [m1]", got)
}
}
func TestModelTUI_CustomProvider_DeleteCancel(t *testing.T) {
m := customConfigModelTUI(t, "", []string{"m1", "aaa"})
m.modelIdx = modelTUIIdxForName(t, m, "aaa")
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
result, _ = m2.Update(nKey())
m3 := result.(modelTUIModel)
if m3.confirmingDeleteModel {
t.Error("confirmingDeleteModel should be false after n")
}
got := m3.existingCfg.CustomProviders["my-llm"].Models
if len(got) != 2 {
t.Errorf("Models = %v, want unchanged", got)
}
}
func TestModelTUI_Official_DeleteCancel(t *testing.T) {
cancelKeys := []struct {
name string
key tea.KeyPressMsg
}{
{"n", nKey()},
{"esc", escKey()},
}
for _, tc := range cancelKeys {
t.Run(tc.name, func(t *testing.T) {
m := officialConfigModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
if !m2.confirmingDeleteModel {
t.Fatal("expected confirmingDeleteModel after d")
}
result, _ = m2.Update(tc.key)
m3 := result.(modelTUIModel)
if m3.confirmingDeleteModel {
t.Error("confirmingDeleteModel should be false after cancel")
}
got := m3.existingCfg.Providers["dashscope"].Models
if len(got) != 2 || got[1] != "my-custom-model" {
t.Errorf("Models = %v, want model unchanged", got)
}
})
}
}
func TestModelTUI_Official_DeleteActiveUserModelClearsCfg(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := officialConfigModelTUI(t, configPath, []string{"my-custom-model"})
m.existingCfg.Model = "my-custom-model"
m.existingCfg.Providers["dashscope"] = ProviderEntry{
Model: "my-custom-model",
Models: []string{"qwen3.7-max", "my-custom-model"},
}
m.modelIdx = modelTUIIdxForName(t, m, "my-custom-model")
result, _ := m.Update(dKey())
m2 := result.(modelTUIModel)
result, _ = m2.Update(yKey())
m3 := result.(modelTUIModel)
if m3.existingCfg.Providers["dashscope"].Model != "" {
t.Errorf("entry.Model = %q, want empty", m3.existingCfg.Providers["dashscope"].Model)
}
if m3.existingCfg.Model != "" {
t.Errorf("cfg.Model = %q, want empty", m3.existingCfg.Model)
}
}
// --- result() tests ---
func TestResult_OfficialTab(t *testing.T) {
@ -1077,6 +1593,269 @@ func TestSyncSessionModelSelection_EmptyModel(t *testing.T) {
}
}
func TestSyncSessionModelSelection_CrossOfficialProviderNoPersist(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "baidu-qianfan" {
m.officialIdx = i
break
}
}
m.modelIdx = 0
for i, name := range m.models() {
if name == "glm-5" {
m.modelIdx = i
break
}
}
if err := m.syncSessionModelSelection(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.savedInSession {
t.Error("savedInSession should be false when browsing a non-active provider")
}
if _, err := os.Stat(configPath); err == nil {
t.Fatal("config file should not be written for cross-provider navigation")
}
if got := cfg.Providers["baidu-qianfan"].Model; got != "" {
t.Errorf("in-memory baidu model = %q, want empty", got)
}
}
func TestSyncSessionModelSelection_ActiveOfficialProviderDefersPersist(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabOfficial
for i, name := range m.models() {
if name == "deepseek-v4-pro" {
m.modelIdx = i
break
}
}
if err := m.syncSessionModelSelection(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.savedInSession {
t.Error("savedInSession should be false before wizard confirm")
}
if got := m.sessionModelPick["deepseek"]; got != "deepseek-v4-pro" {
t.Errorf("sessionModelPick = %q, want deepseek-v4-pro", got)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatal("config file should not be written before wizard confirm")
}
if cfg.Model != "deepseek-v4-flash" {
t.Errorf("cfg.Model = %q, want deepseek-v4-flash", cfg.Model)
}
}
func TestSyncSessionModelSelection_CrossCustomProviderNoPersist(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "stepfun",
Model: "step-3.5-flash",
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://api.stepfun.com/v1",
Model: "step-3.5-flash",
Models: []string{"step-3.5-flash", "step-3.7-flash"},
},
"other": {
URL: "https://example.com/v1",
Model: "step-3.7-flash",
Models: []string{"step-3.7-flash"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
for i, cp := range m.customProviders {
if cp.name == "other" {
m.customIdx = i
break
}
}
m.modelIdx = 0
if err := m.syncSessionModelSelection(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.savedInSession {
t.Error("savedInSession should be false when browsing a non-active custom provider")
}
if _, err := os.Stat(configPath); err == nil {
t.Fatal("config file should not be written for cross-provider navigation")
}
}
func TestSyncSessionModelSelection_RecordsSessionPickForInactiveOfficialProvider(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "baidu-qianfan" {
m.officialIdx = i
break
}
}
for i, name := range m.models() {
if name == "glm-5" {
m.modelIdx = i
break
}
}
if err := m.syncSessionModelSelection(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m.savedInSession {
t.Error("savedInSession should be false for inactive provider")
}
if got := m.sessionModelPick["baidu-qianfan"]; got != "glm-5" {
t.Errorf("sessionModelPick = %q, want glm-5", got)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatal("config file should not be written for inactive provider")
}
}
func TestProviderTUI_ResultUsesSessionModelPickWhenSelectionEmpty(t *testing.T) {
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "baidu-qianfan" {
m.officialIdx = i
break
}
}
m.sessionModelPick = map[string]string{"baidu-qianfan": "glm-5"}
m.modelIdx = 9999 // force selectedModelFromState() empty
r := m.result()
if r.model != "glm-5" {
t.Errorf("result().model = %q, want glm-5", r.model)
}
if got := r.resolvedModel(); got != "glm-5" {
t.Errorf("resolvedModel() = %q, want glm-5", got)
}
}
func TestApiKeyStepCanConfirm_OfficialEmptyWithoutEnv(t *testing.T) {
t.Setenv("DEEPSEEK_API_KEY", "")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
m.step = stepAPIKey
ok, errMsg := m.apiKeyStepCanConfirm()
if ok {
t.Fatal("expected confirmation to be blocked")
}
if errMsg != "API key is required (or set $DEEPSEEK_API_KEY)" {
t.Errorf("errMsg = %q", errMsg)
}
}
func TestApiKeyStepCanConfirm_OfficialEmptyWithEnv(t *testing.T) {
t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
m.step = stepAPIKey
ok, errMsg := m.apiKeyStepCanConfirm()
if !ok {
t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg)
}
}
func TestApiKeyStepCanConfirm_CustomEmpty(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
CustomProviders: map[string]ProviderEntry{
"stepfun": {APIKey: ""},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepAPIKey
ok, errMsg := m.apiKeyStepCanConfirm()
if ok {
t.Fatal("expected confirmation to be blocked")
}
if errMsg != "API key is required" {
t.Errorf("errMsg = %q", errMsg)
}
}
func TestApiKeyStepCanConfirm_MaskedSavedKey(t *testing.T) {
cfg := &Config{
Provider: "deepseek",
Providers: map[string]ProviderEntry{
"deepseek": {APIKey: "keep-me"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
m.step = stepAPIKey
m.loadExistingAPIKey()
ok, errMsg := m.apiKeyStepCanConfirm()
if !ok {
t.Fatalf("expected confirmation allowed, errMsg = %q", errMsg)
}
}
// --- viewCustomProviderForm field steps ---
func TestProviderTUIView_CustomForm_AllSteps(t *testing.T) {
@ -1146,15 +1925,23 @@ func TestProviderTUIView_ManualTab_WithExistingConfig(t *testing.T) {
func TestProviderTUIView_StepModel_CustomTabDeleteHelp(t *testing.T) {
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"cp": {URL: "http://cp", Models: []string{"m1"}},
"cp": {URL: "http://cp", Models: []string{"m1", "m2"}},
},
}
m := newProviderTUI(cfg, "")
m.step = stepModel
m.activeTab = tabCustom
m.customIdx = 0
v := m.View()
if !strings.Contains(v.Content, "Delete") {
t.Errorf("expected delete help for custom tab model view")
m.modelIdx = len(m.models())
got := stripANSI(m.View().Content)
if strings.Contains(got, "d Delete") {
t.Errorf("custom input row should not show d Delete hint; got:\n%s", got)
}
m.modelIdx = 0
got = stripANSI(m.View().Content)
if !strings.Contains(got, "d Delete") {
t.Errorf("custom model row should show d Delete hint; got:\n%s", got)
}
}

File diff suppressed because it is too large Load diff

View file

@ -288,16 +288,16 @@ func TestParseShellRC_NonexistentFile(t *testing.T) {
func TestModelListContains(t *testing.T) {
models := []string{"gpt-4", " claude-3-opus ", "gemini-pro"}
if !modelListContains(models, "claude-3-opus") {
if !ModelListContains(models, "claude-3-opus") {
t.Error("expected true for claude-3-opus")
}
if !modelListContains(models, "gpt-4") {
if !ModelListContains(models, "gpt-4") {
t.Error("expected true for gpt-4")
}
if modelListContains(models, "gpt-3.5") {
if ModelListContains(models, "gpt-3.5") {
t.Error("expected false for gpt-3.5")
}
if modelListContains(nil, "anything") {
if ModelListContains(nil, "anything") {
t.Error("expected false for nil list")
}
}

View file

@ -311,7 +311,7 @@ func tryProviderConfig(cfg configFile, modelOverride string) (ResolvedEndpoint,
// Apply model override with validation.
if modelOverride != "" {
if len(availableModels) > 0 {
if !modelListContains(availableModels, modelOverride) {
if !ModelListContains(availableModels, modelOverride) {
return ResolvedEndpoint{}, false, fmt.Errorf(
"model %q is not available for provider %q; available models: %s",
modelOverride,
@ -524,8 +524,8 @@ func defaultAuthHeader(protocol string) string {
return ""
}
// modelListContains checks if a model exists in the available models list.
func modelListContains(models []string, target string) bool {
// ModelListContains reports whether target matches any entry in models (trimmed).
func ModelListContains(models []string, target string) bool {
target = strings.TrimSpace(target)
for _, model := range models {
if strings.TrimSpace(model) == target {