mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
fix(tui): persist official-tab models and refine saved secret hint (#260)
Some checks are pending
CI / test (push) Waiting to run
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:
parent
964f2166d0
commit
39eb0f3984
8 changed files with 3417 additions and 249 deletions
|
|
@ -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] == "" {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue