open-code-review/cmd/opencodereview/provider_tui_test.go
kite e2d75b732a
Some checks are pending
CI / test (push) Waiting to run
test: fix golangci-lint errcheck/staticcheck issues in test code (#323)
2026-07-08 22:46:18 +08:00

2694 lines
75 KiB
Go

package main
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/open-code-review/open-code-review/internal/llm"
)
func escKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyEscape}
}
func enterKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyEnter}
}
func leftKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyLeft}
}
func rightKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyRight}
}
func downKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyDown}
}
func tabKeyMsg() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: tea.KeyTab}
}
func charKey(c rune) tea.KeyPressMsg {
return tea.KeyPressMsg{Code: c, Text: string(c)}
}
// --- Tab switching tests ---
func TestProviderTUI_TabSwitchRight(t *testing.T) {
m := newProviderTUI(&Config{}, "")
if m.activeTab != tabOfficial {
t.Fatalf("initial tab = %d, want %d", m.activeTab, tabOfficial)
}
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
if m2.activeTab != tabCustom {
t.Errorf("after right, tab = %d, want %d", m2.activeTab, tabCustom)
}
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Errorf("after 2x right, tab = %d, want %d", m3.activeTab, tabManual)
}
// Should not go past last tab
result, _ = m3.Update(rightKey())
m4 := result.(providerTUIModel)
if m4.activeTab != tabManual {
t.Errorf("after 3x right, tab = %d, want %d (should clamp)", m4.activeTab, tabManual)
}
}
func TestProviderTUI_TabSwitchLeft(t *testing.T) {
m := newProviderTUI(&Config{}, "")
// Go to manual tab first
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Fatalf("setup: tab = %d, want %d", m3.activeTab, tabManual)
}
result, _ = m3.Update(leftKey())
m4 := result.(providerTUIModel)
if m4.activeTab != tabCustom {
t.Errorf("after left, tab = %d, want %d", m4.activeTab, tabCustom)
}
result, _ = m4.Update(leftKey())
m5 := result.(providerTUIModel)
if m5.activeTab != tabOfficial {
t.Errorf("after 2x left, tab = %d, want %d", m5.activeTab, tabOfficial)
}
// Should not go past first tab
result, _ = m5.Update(leftKey())
m6 := result.(providerTUIModel)
if m6.activeTab != tabOfficial {
t.Errorf("after 3x left, tab = %d, want %d (should clamp)", m6.activeTab, tabOfficial)
}
}
func TestProviderTUI_TabKeyCycles(t *testing.T) {
m := newProviderTUI(&Config{}, "")
result, _ := m.Update(tabKeyMsg())
m2 := result.(providerTUIModel)
if m2.activeTab != tabCustom {
t.Errorf("after tab, tab = %d, want %d", m2.activeTab, tabCustom)
}
result, _ = m2.Update(tabKeyMsg())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Errorf("after 2x tab, tab = %d, want %d", m3.activeTab, tabManual)
}
result, _ = m3.Update(tabKeyMsg())
m4 := result.(providerTUIModel)
if m4.activeTab != tabOfficial {
t.Errorf("after 3x tab, tab = %d, want %d (should wrap)", m4.activeTab, tabOfficial)
}
}
func TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {
m := newProviderTUI(&Config{}, "")
// Advance to stepModel
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepModel {
t.Fatalf("step = %d, want %d", m2.step, stepModel)
}
// Tab keys should not change tab
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabOfficial {
t.Errorf("right on stepModel should not change tab: got %d", m3.activeTab)
}
}
// --- Official tab tests (updated from original) ---
func TestProviderTUI_OfficialProvidersSortedByDisplayName(t *testing.T) {
m := newProviderTUI(&Config{}, "")
displayNames := make([]string, len(m.providers))
normalized := make([]string, len(m.providers))
for i, p := range m.providers {
displayNames[i] = p.DisplayName
normalized[i] = strings.ToLower(p.DisplayName)
}
if !sort.StringsAreSorted(normalized) {
t.Errorf("provider display names are not sorted: %v", displayNames)
}
}
func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {
m := newProviderTUI(&Config{}, "")
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepModel {
t.Fatalf("after Enter, step = %d, want %d (stepModel)", m2.step, stepModel)
}
result, _ = m2.Update(escKey())
m3 := result.(providerTUIModel)
if m3.step != stepProvider {
t.Errorf("after Esc on stepModel, step = %d, want %d (stepProvider)", m3.step, stepProvider)
}
if m3.cancelled {
t.Error("should not be cancelled when going back from stepModel")
}
}
func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {
m := newProviderTUI(&Config{}, "")
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if m3.step != stepAPIKey {
t.Fatalf("after 2x Enter, step = %d, want %d (stepAPIKey)", m3.step, stepAPIKey)
}
result, _ = m3.Update(escKey())
m4 := result.(providerTUIModel)
if m4.step != stepModel {
t.Errorf("after Esc on stepAPIKey, step = %d, want %d (stepModel)", m4.step, stepModel)
}
}
func TestProviderTUI_EscFromProviderCancels(t *testing.T) {
m := newProviderTUI(&Config{}, "")
result, cmd := m.Update(escKey())
m2 := result.(providerTUIModel)
if !m2.cancelled {
t.Error("Esc on stepProvider should set cancelled = true")
}
if cmd == nil {
t.Error("Esc on stepProvider should return tea.Quit")
}
}
func TestProviderTUI_EscKeyString(t *testing.T) {
esc := escKey()
if s := esc.String(); s != "esc" {
t.Errorf("escape key String() = %q, want %q", s, "esc")
}
}
// --- Manual tab tests ---
func TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {
m := newProviderTUI(&Config{}, "")
// Switch to manual tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
if m3.activeTab != tabManual {
t.Fatalf("tab = %d, want %d", m3.activeTab, tabManual)
}
// Press Enter to start form
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
if !m4.inManualForm {
t.Error("Enter on manual tab should set inManualForm = true")
}
if m4.manualStep != manualStepURL {
t.Errorf("manualStep = %d, want %d", m4.manualStep, manualStepURL)
}
}
func TestProviderTUI_ManualFormEscFromURLExitsForm(t *testing.T) {
m := newProviderTUI(&Config{}, "")
// Switch to manual tab and enter form
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(rightKey())
m3 := result.(providerTUIModel)
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
if !m4.inManualForm {
t.Fatalf("should be in manual form")
}
// Esc should exit form, not cancel
result, _ = m4.Update(escKey())
m5 := result.(providerTUIModel)
if m5.inManualForm {
t.Error("Esc from URL step should exit form")
}
if m5.cancelled {
t.Error("should not be cancelled when exiting form")
}
}
func TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg, "")
// Enter the form
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if !m2.inManualForm {
t.Fatalf("should be in manual form")
}
// Simulate editing by directly modifying the input value
m2.manualURLInput.SetValue("https://modified.example.com")
// Esc should restore original values
result, _ = m2.Update(escKey())
m3 := result.(providerTUIModel)
if m3.inManualForm {
t.Error("should have exited form")
}
if m3.manualURLInput.Value() != "https://example.com/v1" {
t.Errorf("URL not restored: got %q, want %q", m3.manualURLInput.Value(), "https://example.com/v1")
}
if m3.manualModelInput.Value() != "test-model" {
t.Errorf("Model not restored: got %q, want %q", m3.manualModelInput.Value(), "test-model")
}
if !m3.manualTokenMasked {
t.Error("Token should be masked after Esc restore")
}
if m3.manualTokenOriginal != "token-123" {
t.Errorf("Token original not restored: got %q, want %q", m3.manualTokenOriginal, "token-123")
}
}
func TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg, "")
if m.activeTab != tabManual {
t.Fatalf("should auto-select manual tab when Llm.URL is set, got %d", m.activeTab)
}
if m.manualURLInput.Value() != "https://example.com/v1" {
t.Errorf("URL not prefilled: got %q", m.manualURLInput.Value())
}
if m.manualModelInput.Value() != "test-model" {
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
}
if !m.manualTokenMasked {
t.Error("Token should be masked when prefilled")
}
if m.manualTokenOriginal != "token-123" {
t.Errorf("Token original not prefilled: got %q, want %q", m.manualTokenOriginal, "token-123")
}
if m.manualTokenInput.Value() != strings.Repeat("*", 20) {
t.Errorf("Token input not masked display: got %q", m.manualTokenInput.Value())
}
}
func TestProviderTUI_ManualResult(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg, "")
// Enter the form
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
m2.confirmed = true
r := m2.result()
if !r.isManual {
t.Error("result should have isManual = true")
}
if r.url != "https://example.com/v1" {
t.Errorf("result url = %q, want %q", r.url, "https://example.com/v1")
}
if r.model != "test-model" {
t.Errorf("result model = %q, want %q", r.model, "test-model")
}
}
func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {
cfg := &Config{
Provider: "my-gateway",
CustomProviders: map[string]ProviderEntry{
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
},
Llm: LlmConfig{
URL: "https://manual.example.com/v1",
Model: "manual-model",
AuthToken: "manual-token",
},
}
m := newProviderTUI(cfg, "")
if m.activeTab != tabCustom {
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
}
if m.manualURLInput.Value() != "https://manual.example.com/v1" {
t.Errorf("URL not prefilled: got %q", m.manualURLInput.Value())
}
if m.manualModelInput.Value() != "manual-model" {
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
}
if !m.manualTokenMasked {
t.Error("Token should be masked when prefilled")
}
if m.manualTokenOriginal != "manual-token" {
t.Errorf("Token original not prefilled: got %q, want %q", m.manualTokenOriginal, "manual-token")
}
}
func TestProviderTUI_ManualFormPrefillsAuthHeader(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://manual.example.com/v1",
Model: "manual-model",
AuthToken: "manual-token",
AuthHeader: "X-Custom-Auth",
},
}
m := newProviderTUI(cfg, "")
if got := m.manualAuthHeaderInput.Value(); got != "X-Custom-Auth" {
t.Errorf("manualAuthHeaderInput not prefilled: got %q, want %q", got, "X-Custom-Auth")
}
}
func TestProviderTUI_ManualFormSkipsEmptyTokenWhenOriginalExists(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "test-model",
AuthToken: "token-123",
},
}
m := newProviderTUI(cfg, "")
m.inManualForm = true
m.manualStep = manualStepAuthToken
m.manualTokenOriginal = "token-123"
m.manualTokenMasked = false
m.manualTokenInput.SetValue("")
m.manualTokenInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.manualStep != manualStepAuthHeader {
t.Errorf("manualStep = %d, want %d", m2.manualStep, manualStepAuthHeader)
}
m2.confirmed = true
r := m2.result()
if r.apiKey != "token-123" {
t.Errorf("result apiKey = %q, want %q", r.apiKey, "token-123")
}
}
func TestProviderTUI_ManualFormRequiresTokenOnFirstSetup(t *testing.T) {
m := newProviderTUI(&Config{}, "")
m.inManualForm = true
m.manualStep = manualStepAuthToken
m.manualTokenInput.SetValue("")
m.manualTokenInput.Focus()
result, cmd := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.manualStep != manualStepAuthToken {
t.Errorf("should stay on auth token step, got %d", m2.manualStep)
}
if m2.formError != manualAuthTokenRequiredError {
t.Errorf("formError = %q, want %q", m2.formError, manualAuthTokenRequiredError)
}
if cmd != nil {
t.Error("Enter with empty token should not quit")
}
}
func TestProviderTUI_ManualFormRejectsWhitespaceOnlyToken(t *testing.T) {
m := newProviderTUI(&Config{}, "")
m.inManualForm = true
m.manualStep = manualStepAuthToken
m.manualTokenInput.SetValue(" ")
m.manualTokenInput.Focus()
result, cmd := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.manualStep != manualStepAuthToken {
t.Errorf("should stay on auth token step, got %d", m2.manualStep)
}
if m2.formError != manualAuthTokenRequiredError {
t.Errorf("formError = %q, want %q", m2.formError, manualAuthTokenRequiredError)
}
if cmd != nil {
t.Error("Enter with whitespace-only token should not quit")
}
}
func TestProviderTUI_SessionModelPickSurvivesOfficialProviderSwitch(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
}
}
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
m2.modelIdx = modelIdxForName(t, m2, "glm-5")
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if got := m3.sessionModelPick["baidu-qianfan"]; got != "glm-5" {
t.Errorf("sessionModelPick = %q, want glm-5", got)
}
result, _ = m3.Update(escKey())
m4 := result.(providerTUIModel)
result, _ = m4.Update(escKey())
m5 := result.(providerTUIModel)
result, _ = m5.Update(enterKey())
m6 := result.(providerTUIModel)
if got := m6.models()[m6.modelIdx]; got != "glm-5" {
t.Errorf("model cursor = %q, want glm-5", got)
}
}
// --- Custom tab tests ---
func TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {
m := newProviderTUI(&Config{}, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
if m2.activeTab != tabCustom {
t.Fatalf("tab = %d, want %d", m2.activeTab, tabCustom)
}
// With no custom providers, only "Add" option exists at index 0
if m2.customListCount() != 1 {
t.Errorf("customListCount() = %d, want 1 (only add option)", m2.customListCount())
}
}
func TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {
m := newProviderTUI(&Config{}, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
// Enter on "Add" option
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if !m3.creatingCustom {
t.Error("Enter on add option should set creatingCustom = true")
}
if m3.cpStep != cpStepName {
t.Errorf("cpStep = %d, want %d", m3.cpStep, cpStepName)
}
}
func TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {
m := newProviderTUI(&Config{}, "")
// Switch to custom tab and start form
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if !m3.creatingCustom {
t.Fatalf("should be creating custom")
}
// Esc from name step should exit form
result, _ = m3.Update(escKey())
m4 := result.(providerTUIModel)
if m4.creatingCustom {
t.Error("Esc from name step should exit custom form")
}
if m4.cancelled {
t.Error("should not be cancelled")
}
}
func TestProviderTUI_CustomFormRejectsDuplicateName(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
CustomProviders: map[string]ProviderEntry{
"stepfun": {Model: "xxx"},
},
}
m := newProviderTUI(cfg, "")
result, _ := m.Update(downKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if !m3.creatingCustom {
t.Fatal("should be creating custom")
}
m3.cpNameInput.SetValue("stepfun")
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
if m4.cpStep != cpStepName {
t.Errorf("cpStep = %d, want %d", m4.cpStep, cpStepName)
}
if m4.formError == "" {
t.Error("expected formError for duplicate name")
}
if !strings.Contains(m4.formError, "stepfun") {
t.Errorf("formError = %q, want to mention stepfun", m4.formError)
}
result, _ = m4.Update(charKey('x'))
m4b := result.(providerTUIModel)
if m4b.formError != "" {
t.Errorf("formError should clear on keystroke, got %q", m4b.formError)
}
m4b.cpNameInput.SetValue("stepfun2")
result, _ = m4b.Update(enterKey())
m5 := result.(providerTUIModel)
if m5.cpStep != cpStepProtocol {
t.Errorf("cpStep = %d, want %d", m5.cpStep, cpStepProtocol)
}
if m5.formError != "" {
t.Errorf("formError = %q, want empty after valid name", m5.formError)
}
}
func TestProviderTUI_CustomFormRejectsInvalidAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
m := newProviderTUI(cfg, configPath)
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
m3.cpNameInput.SetValue("my-new")
result, _ = m3.Update(enterKey())
m4 := result.(providerTUIModel)
result, _ = m4.Update(enterKey())
m5 := result.(providerTUIModel)
m5.cpURLInput.SetValue("https://api.example.com")
result, _ = m5.Update(enterKey())
m6 := result.(providerTUIModel)
result, _ = m6.Update(enterKey())
m7 := result.(providerTUIModel)
if m7.cpStep != cpStepAuthHeader {
t.Fatalf("cpStep = %d, want %d", m7.cpStep, cpStepAuthHeader)
}
for _, c := range "bad-header" {
result, _ = m7.Update(charKey(c))
m7 = result.(providerTUIModel)
}
result, _ = m7.Update(enterKey())
m8 := result.(providerTUIModel)
if m8.cpStep != cpStepAuthHeader {
t.Errorf("cpStep = %d, want %d", m8.cpStep, cpStepAuthHeader)
}
if m8.formError == "" {
t.Error("expected formError for invalid auth header")
}
if !strings.Contains(m8.formError, "Unsupported Auth Header") {
t.Errorf("formError = %q, want unsupported auth header message", m8.formError)
}
if !m8.creatingCustom {
t.Error("creatingCustom should remain true when validation fails")
}
if _, err := os.Stat(configPath); err == nil {
t.Error("config should not be saved for invalid auth header")
}
}
func TestProviderTUI_CustomFormEditRejectsInvalidAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://api.example.com",
Protocol: "anthropic",
AuthHeader: "authorization",
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.enterEditCustomProvider()
m.cpStep = cpStepAuthHeader
m.cpAuthInput.SetValue("bad-header")
m.cpAuthInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.cpStep != cpStepAuthHeader {
t.Errorf("cpStep = %d, want %d", m2.cpStep, cpStepAuthHeader)
}
if m2.formError == "" {
t.Error("expected formError for invalid auth header")
}
if !m2.editingCustom {
t.Error("editingCustom should remain true when validation fails")
}
if got := cfg.CustomProviders["stepfun"].AuthHeader; got != "authorization" {
t.Errorf("AuthHeader = %q, want unchanged %q", got, "authorization")
}
}
func TestProviderTUI_EditCustomProviderSaveRejectsDuplicateRename(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://stepfun.example.com",
Protocol: "anthropic",
},
"other": {
URL: "https://other.example.com",
Protocol: "openai",
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.editingCustom = true
m.editTargetName = "other"
m.cpProtocolIdx = 1 // openai
m.cpNameInput.SetValue("stepfun")
m.cpURLInput.SetValue("https://other.example.com")
err := m.applyEditCustomProviderSave()
if err == nil {
t.Fatal("expected error when renaming to existing provider name")
}
if !strings.Contains(m.formError, "stepfun") {
t.Errorf("formError = %q, want to mention stepfun", m.formError)
}
if _, ok := cfg.CustomProviders["other"]; !ok {
t.Error("original provider 'other' should still exist")
}
if cfg.CustomProviders["other"].URL != "https://other.example.com" {
t.Errorf("provider 'other' URL = %q, want unchanged", cfg.CustomProviders["other"].URL)
}
}
func TestApplyEditCustomProviderSave_ClearsAPIKeyWhenEditedEmpty(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"aaa": {
URL: "https://example.com/v1",
Protocol: "anthropic",
APIKey: "old-saved-key",
Model: "test",
Models: []string{"test"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.editingCustom = true
m.editTargetName = "aaa"
m.cpProtocolIdx = 0
m.cpNameInput.SetValue("aaa")
m.cpURLInput.SetValue("https://example.com/v1")
m.beginAPIKeyReplace()
if err := m.applyEditCustomProviderSave(); err != nil {
t.Fatalf("applyEditCustomProviderSave: %v", err)
}
if got := cfg.CustomProviders["aaa"].APIKey; got != "" {
t.Errorf("APIKey = %q, want empty", got)
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
if got := diskCfg.CustomProviders["aaa"].APIKey; got != "" {
t.Errorf("persisted APIKey = %q, want empty", got)
}
}
func TestApplyEditCustomProviderSave_PreservesAPIKeyWhenMasked(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"aaa": {
URL: "https://example.com/v1",
Protocol: "anthropic",
APIKey: "keep-me",
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.enterEditCustomProvider()
if err := m.applyEditCustomProviderSave(); err != nil {
t.Fatalf("applyEditCustomProviderSave: %v", err)
}
if got := cfg.CustomProviders["aaa"].APIKey; got != "keep-me" {
t.Errorf("APIKey = %q, want keep-me", got)
}
}
func TestProviderTUI_EditCustomClearKey_NoMaskedOnStepAPIKey(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"aaa": {
URL: "https://example.com/v1",
Protocol: "anthropic",
APIKey: "old-saved-key",
Model: "test",
Models: []string{"test", "aaa"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
for i, cp := range m.customProviders {
if cp.name == "aaa" {
m.customIdx = i
break
}
}
m.enterEditCustomProvider()
m.cpStep = cpStepAPIKey
m.beginAPIKeyReplace()
m.cpStep = cpStepAuthHeader
m.cpAuthInput.SetValue("")
m.cpAuthInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepModel {
t.Fatalf("step = %d, want stepModel", m2.step)
}
if got := m2.customProviders[m2.customIdx].entry.APIKey; got != "" {
t.Errorf("saved APIKey = %q, want empty", got)
}
m2.modelIdx = modelIdxForName(t, m2, "test")
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
if m3.step != stepAPIKey {
t.Fatalf("step = %d, want stepAPIKey", m3.step)
}
if m3.apiKeyMasked {
t.Error("apiKeyMasked should be false after clearing key in edit")
}
got := stripANSI(m3.View().Content)
if strings.Contains(got, "Type or paste to replace the saved key") {
t.Errorf("view should not show replace hint; got:\n%s", got)
}
}
func TestProviderTUI_ReenterEditAfterClearKey_ShowsEmpty(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"aaa": {
URL: "https://example.com/v1",
Protocol: "anthropic",
APIKey: "old-saved-key",
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.editingCustom = true
m.editTargetName = "aaa"
m.cpProtocolIdx = 0
m.cpNameInput.SetValue("aaa")
m.cpURLInput.SetValue("https://example.com/v1")
m.beginAPIKeyReplace()
if err := m.applyEditCustomProviderSave(); err != nil {
t.Fatalf("applyEditCustomProviderSave: %v", err)
}
m.enterEditCustomProvider()
if m.apiKeyMasked {
t.Error("apiKeyMasked should be false when key was cleared")
}
if got := m.apiKeyInput.Value(); got != "" {
t.Errorf("apiKeyInput = %q, want empty", got)
}
}
func TestCustomAPIKeyForSave(t *testing.T) {
m := providerTUIModel{
apiKeyMasked: true,
apiKeyOriginal: "keep-me",
}
key, edited := m.customAPIKeyForSave()
if edited {
t.Fatal("masked key should not count as edited")
}
if key != "keep-me" {
t.Errorf("key = %q, want keep-me", key)
}
m.apiKeyMasked = false
m.apiKeyInput.SetValue(" ")
key, edited = m.customAPIKeyForSave()
if !edited {
t.Fatal("cleared field should count as edited")
}
if key != "" {
t.Errorf("key = %q, want empty", key)
}
}
func TestProviderTUI_CustomFormCreateReturnsToModelList(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
m := newProviderTUI(cfg, configPath)
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(enterKey())
m3 := result.(providerTUIModel)
m3.cpNameInput.SetValue("my-new")
result, _ = m3.Update(enterKey()) // name -> protocol
m4 := result.(providerTUIModel)
result, _ = m4.Update(enterKey()) // protocol -> URL
m5 := result.(providerTUIModel)
m5.cpURLInput.SetValue("https://api.example.com")
result, _ = m5.Update(enterKey()) // URL -> API key
m6 := result.(providerTUIModel)
m6.apiKeyInput.SetValue("key-123")
result, _ = m6.Update(enterKey()) // API key -> auth header
m7 := result.(providerTUIModel)
result, cmd := m7.Update(enterKey()) // auth header -> save
m8 := result.(providerTUIModel)
if cmd != nil {
t.Error("create should not quit TUI")
}
if m8.creatingCustom {
t.Error("creatingCustom should be false after create")
}
// Create should drop the user into the model selection step for the new
// provider so they can pick/add a model right away.
if m8.step != stepModel {
t.Errorf("step = %d, want stepModel", m8.step)
}
if len(m8.customProviders) != 1 {
t.Fatalf("expected 1 custom provider, got %d", len(m8.customProviders))
}
if m8.customProviders[0].name != "my-new" {
t.Errorf("provider name = %q, want %q", m8.customProviders[0].name, "my-new")
}
if cfg.Provider != "" {
t.Error("active provider should not be set when only creating")
}
if !m8.savedInSession {
t.Error("savedInSession should be true after create")
}
if _, err := os.Stat(configPath); err != nil {
t.Fatalf("config should be saved: %v", err)
}
}
func TestProviderTUI_CustomProviderExistsInList(t *testing.T) {
cfg := &Config{
Provider: "my-llm",
CustomProviders: map[string]ProviderEntry{
"my-llm": {
URL: "https://custom.api/v1",
Protocol: "openai",
Model: "custom-model",
APIKey: "key-123",
},
},
}
m := newProviderTUI(cfg, "")
if m.activeTab != tabCustom {
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
}
if len(m.customProviders) != 1 {
t.Fatalf("expected 1 custom provider, got %d", len(m.customProviders))
}
if m.customProviders[0].name != "my-llm" {
t.Errorf("custom provider name = %q, want %q", m.customProviders[0].name, "my-llm")
}
}
func TestProviderTUI_SelectExistingCustomGoesToModel(t *testing.T) {
cfg := &Config{
Provider: "my-llm",
CustomProviders: map[string]ProviderEntry{
"my-llm": {
URL: "https://custom.api/v1",
Protocol: "openai",
Model: "custom-model",
Models: []string{"custom-model", "custom-fast"},
APIKey: "key-123",
},
},
}
m := newProviderTUI(cfg, "")
// Enter on existing custom provider should go to model selection first.
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepModel {
t.Errorf("step = %d, want %d (stepModel)", m2.step, stepModel)
}
gotModels := m2.models()
if len(gotModels) != 2 || gotModels[0] != "custom-model" || gotModels[1] != "custom-fast" {
t.Errorf("models = %v, want [custom-model custom-fast] (config order)", gotModels)
}
}
// --- collectCustomProviders tests ---
func TestCollectCustomProviders_NilConfig(t *testing.T) {
result := collectCustomProviders(nil)
if result != nil {
t.Errorf("expected nil, got %v", result)
}
}
func TestCollectCustomProviders_ReadsCustomProviders(t *testing.T) {
cfg := &Config{
Providers: map[string]ProviderEntry{
"anthropic": {APIKey: "key1"},
"openai": {APIKey: "key2"},
},
CustomProviders: map[string]ProviderEntry{
"my-custom": {URL: "https://example.com", Protocol: "openai"},
},
}
result := collectCustomProviders(cfg)
if len(result) != 1 {
t.Fatalf("expected 1 custom provider, got %d", len(result))
}
if result[0].name != "my-custom" {
t.Errorf("name = %q, want %q", result[0].name, "my-custom")
}
}
func TestCollectCustomProviders_SortedByName(t *testing.T) {
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"zzz-provider": {URL: "https://z.example.com"},
"aaa-provider": {URL: "https://a.example.com"},
},
}
result := collectCustomProviders(cfg)
if len(result) != 2 {
t.Fatalf("expected 2, got %d", len(result))
}
if result[0].name != "aaa-provider" {
t.Errorf("first = %q, want %q", result[0].name, "aaa-provider")
}
if result[1].name != "zzz-provider" {
t.Errorf("second = %q, want %q", result[1].name, "zzz-provider")
}
}
// --- Delete custom provider tests ---
func dKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: 'd'}
}
func yKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: 'y'}
}
func nKey() tea.KeyPressMsg {
return tea.KeyPressMsg{Code: 'n'}
}
func TestProviderTUI_DeleteCustomProvider(t *testing.T) {
cfg := &Config{
Provider: "anthropic",
CustomProviders: map[string]ProviderEntry{
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
},
}
m := newProviderTUI(cfg, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
if m2.activeTab != tabCustom {
t.Fatalf("tab = %d, want %d", m2.activeTab, tabCustom)
}
// Select the existing provider (index 0), press d
m2.customIdx = 0
result, _ = m2.Update(dKey())
m3 := result.(providerTUIModel)
if !m3.confirmingDelete {
t.Fatal("pressing d should set confirmingDelete = true")
}
if m3.deleteTargetName != "my-llm" {
t.Errorf("deleteTargetName = %q, want %q", m3.deleteTargetName, "my-llm")
}
// Confirm with y
result, _ = m3.Update(yKey())
m4 := result.(providerTUIModel)
if m4.confirmingDelete {
t.Error("confirmingDelete should be false after y")
}
if len(m4.deletedProviders) != 1 || m4.deletedProviders[0] != "my-llm" {
t.Errorf("deletedProviders = %v, want [my-llm]", m4.deletedProviders)
}
if len(m4.customProviders) != 0 {
t.Errorf("customProviders length = %d, want 0", len(m4.customProviders))
}
}
func TestProviderTUI_DeleteCustomProviderCancel(t *testing.T) {
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
},
}
m := newProviderTUI(cfg, "")
// Force custom tab so this test is independent of init-time tab routing.
// Switch to custom tab, select provider, press d
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
m2.customIdx = 0
result, _ = m2.Update(dKey())
m3 := result.(providerTUIModel)
if !m3.confirmingDelete {
t.Fatal("should be confirming delete")
}
// Cancel with n
result, _ = m3.Update(nKey())
m4 := result.(providerTUIModel)
if m4.confirmingDelete {
t.Error("confirmingDelete should be false after n")
}
if len(m4.deletedProviders) != 0 {
t.Error("deletedProviders should be empty after cancel")
}
if len(m4.customProviders) != 1 {
t.Error("customProviders should still have 1 entry after cancel")
}
}
func TestProviderTUI_DeleteOnAddOptionIgnored(t *testing.T) {
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai"},
},
}
m := newProviderTUI(cfg, "")
// Switch to custom tab
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
// Move to "Add" option (index 1, since there's 1 provider)
m2.customIdx = len(m2.customProviders)
result, _ = m2.Update(dKey())
m3 := result.(providerTUIModel)
if m3.confirmingDelete {
t.Error("pressing d on Add option should not trigger delete confirmation")
}
}
func TestProviderTUI_DeleteActiveCustomProvider(t *testing.T) {
cfg := &Config{
Provider: "my-llm",
CustomProviders: map[string]ProviderEntry{
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
},
}
m := newProviderTUI(cfg, "")
// Should auto-select custom tab with active provider
if m.activeTab != tabCustom {
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
}
// Press d on the active provider
m.customIdx = 0
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
if !m2.confirmingDelete {
t.Fatal("should be confirming delete")
}
// Confirm
result, _ = m2.Update(yKey())
m3 := result.(providerTUIModel)
if len(m3.deletedProviders) != 1 || m3.deletedProviders[0] != "my-llm" {
t.Errorf("deletedProviders = %v, want [my-llm]", m3.deletedProviders)
}
}
func TestProviderTUI_DeleteEscCancels(t *testing.T) {
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai"},
},
}
m := newProviderTUI(cfg, "")
result, _ := m.Update(rightKey())
m2 := result.(providerTUIModel)
m2.customIdx = 0
result, _ = m2.Update(dKey())
m3 := result.(providerTUIModel)
// Esc should cancel confirmation
result, _ = m3.Update(escKey())
m4 := result.(providerTUIModel)
if m4.confirmingDelete {
t.Error("Esc should cancel delete confirmation")
}
if len(m4.deletedProviders) != 0 {
t.Error("no providers should be deleted after Esc")
}
}
func TestActiveModelForProvider_PrefersEntryModel(t *testing.T) {
cfg := &Config{Provider: "stepfun", Model: "step-3.7-flash"}
entry := ProviderEntry{Model: "step-3.5-flash"}
got := activeModelForProvider(cfg, "stepfun", entry)
if got != "step-3.5-flash" {
t.Errorf("got %q, want step-3.5-flash", got)
}
}
func TestActiveModelForProvider_FallsBackToCfgModel(t *testing.T) {
cfg := &Config{Provider: "stepfun", Model: "step-3.5-flash"}
entry := ProviderEntry{}
got := activeModelForProvider(cfg, "stepfun", entry)
if got != "step-3.5-flash" {
t.Errorf("got %q, want step-3.5-flash", got)
}
}
func TestProviderTUI_CustomModelInput_AddsSingleName(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"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepModel
m.modelIdx = len(m.models()) // land on "Enter custom model name..."
m.customModel = true
m.modelInput.SetValue("newmodel")
m.modelInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.customModel {
t.Error("customModel should be cleared after Enter")
}
if m2.formError != "" {
t.Errorf("formError = %q, want empty", m2.formError)
}
got := m2.existingCfg.CustomProviders["stepfun"].Models
want := []string{"step-3.5-flash", "newmodel"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("Models = %v, want %v", got, want)
}
if !m2.savedInSession {
t.Error("savedInSession should be true after add")
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load disk config: %v", err)
}
diskModels := diskCfg.CustomProviders["stepfun"].Models
if len(diskModels) != 2 || diskModels[1] != "newmodel" {
t.Errorf("disk Models = %v, want last=step-3.5-flash,newmodel", diskModels)
}
}
func TestProviderTUI_CustomModelInput_RejectsDuplicate(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"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepModel
m.modelIdx = len(m.models())
m.customModel = true
m.modelInput.SetValue("step-3.5-flash")
m.modelInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if !m2.customModel {
t.Error("customModel should stay true after duplicate reject")
}
if m2.formError != "Already in list: step-3.5-flash" {
t.Errorf("formError = %q, want %q", m2.formError, "Already in list: step-3.5-flash")
}
if m2.modelInput.Value() != "step-3.5-flash" {
t.Errorf("input should be preserved on dup; got %q", m2.modelInput.Value())
}
if len(m2.existingCfg.CustomProviders["stepfun"].Models) != 1 {
t.Errorf("Models mutated: %v", m2.existingCfg.CustomProviders["stepfun"].Models)
}
if _, err := os.Stat(configPath); err == nil {
t.Errorf("disk file should not exist; duplicate did not persist")
}
if m2.savedInSession {
t.Error("savedInSession should be false after rejected duplicate")
}
}
func TestProviderTUI_OfficialTab_CustomModelInput_PersistsName(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {
Model: "qwen3.7-max",
Models: []string{"qwen3.7-max"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabOfficial
// Land the cursor on the official provider we configured.
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepModel
m.modelIdx = len(m.models()) // "Enter custom model name..."
m.customModel = true
m.modelInput.SetValue("my-custom-model")
m.modelInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.customModel {
t.Error("customModel should be cleared after Enter")
}
if m2.formError != "" {
t.Errorf("formError = %q, want empty", m2.formError)
}
if !m2.savedInSession {
t.Error("savedInSession should be true after successful add")
}
got := m2.existingCfg.Providers["dashscope"].Models
want := []string{"qwen3.7-max", "my-custom-model"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("official Models = %v, want %v", got, want)
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load disk config: %v", err)
}
diskModels := diskCfg.Providers["dashscope"].Models
if len(diskModels) != 2 || diskModels[1] != "my-custom-model" {
t.Errorf("disk Models = %v, want [qwen3.7-max my-custom-model]", diskModels)
}
}
func TestProviderTUI_OfficialTab_CustomModelInput_RejectsDuplicate(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {
Model: "qwen3.7-max",
Models: []string{"qwen3.7-max"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepModel
m.modelIdx = len(m.models())
m.customModel = true
m.modelInput.SetValue("qwen3.7-max")
m.modelInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if !m2.customModel {
t.Error("customModel should stay true after duplicate reject")
}
if m2.formError != "Already in list: qwen3.7-max" {
t.Errorf("formError = %q, want %q", m2.formError, "Already in list: qwen3.7-max")
}
if _, err := os.Stat(configPath); err == nil {
t.Errorf("disk file should not exist; duplicate did not persist")
}
}
func officialDashscopeModelTUI(t *testing.T, configPath string, extraModels []string) providerTUIModel {
t.Helper()
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,
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepModel
return m
}
func customStepfunModelTUI(t *testing.T, configPath string, models []string) providerTUIModel {
t.Helper()
if len(models) == 0 {
models = []string{"step-3.5-flash"}
}
cfg := &Config{
Provider: "stepfun",
Model: models[0],
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://api.stepfun.com/v1",
Protocol: "openai",
Model: models[0],
Models: models,
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
for i, cp := range m.customProviders {
if cp.name == "stepfun" {
m.customIdx = i
break
}
}
m.step = stepModel
return m
}
func modelIdxForName(t *testing.T, m providerTUIModel, name string) int {
t.Helper()
for i, model := range m.models() {
if model == name {
return i
}
}
t.Fatalf("model %q not found in %v", name, m.models())
return -1
}
func TestProviderTUI_OfficialTab_DeleteUserAddedModel(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"})
m.modelIdx = modelIdxForName(t, m, "my-custom-model")
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
if !m2.confirmingDeleteModel {
t.Fatal("pressing d on user-added model should set confirmingDeleteModel = true")
}
if m2.deleteModelName != "my-custom-model" {
t.Errorf("deleteModelName = %q, want my-custom-model", m2.deleteModelName)
}
result, _ = m2.Update(yKey())
m3 := result.(providerTUIModel)
if m3.confirmingDeleteModel {
t.Error("confirmingDeleteModel should be false after y")
}
got := m3.existingCfg.Providers["dashscope"].Models
if len(got) != 1 || got[0] != "qwen3.7-max" {
t.Errorf("Models = %v, want [qwen3.7-max]", got)
}
if !m3.savedInSession {
t.Error("savedInSession should be true after delete")
}
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 TestProviderTUI_OfficialTab_DeleteBuiltInModelIgnored(t *testing.T) {
m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelIdxForName(t, m, "qwen3.7-max")
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on built-in model should not trigger delete confirmation")
}
}
func TestProviderTUI_OfficialTab_RegistryModelNotDeletable(t *testing.T) {
m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelIdxForName(t, m, "qwen3.7-max")
if m.isUserAddedOfficialModel("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.(providerTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on registry model should not trigger delete confirmation")
}
}
func TestProviderTUI_OfficialTab_DeleteOnCustomModelInputIgnored(t *testing.T) {
m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = len(m.models())
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
}
}
func TestProviderTUI_CustomTab_DeleteOnCustomModelInputIgnored(t *testing.T) {
m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"})
m.modelIdx = len(m.models())
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
if m2.confirmingDeleteModel {
t.Error("pressing d on Enter custom model name... should not trigger delete confirmation")
}
}
func TestProviderTUI_CustomTab_ModelShowsDeleteHint(t *testing.T) {
m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"})
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 = modelIdxForName(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 TestProviderTUI_OfficialTab_UserAddedModelShowsDeleteHint(t *testing.T) {
m := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelIdxForName(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 = modelIdxForName(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 TestProviderTUI_OfficialTab_DeleteModelPreservesActiveModel(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := officialDashscopeModelTUI(t, configPath, []string{"my-custom-model"})
m.existingCfg.Model = "qwen3.7-max"
m.existingCfg.Providers["dashscope"] = ProviderEntry{
Model: "qwen3.7-max",
Models: []string{"qwen3.7-max", "my-custom-model"},
}
m.modelIdx = modelIdxForName(t, m, "my-custom-model")
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(yKey())
m3 := result.(providerTUIModel)
if m3.existingCfg.Providers["dashscope"].Model != "qwen3.7-max" {
t.Errorf("entry.Model = %q, want qwen3.7-max", m3.existingCfg.Providers["dashscope"].Model)
}
if m3.existingCfg.Model != "qwen3.7-max" {
t.Errorf("cfg.Model = %q, want qwen3.7-max", m3.existingCfg.Model)
}
}
func TestProviderTUI_OfficialTab_DeleteUserAddedModelCancel(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 := officialDashscopeModelTUI(t, "", []string{"my-custom-model"})
m.modelIdx = modelIdxForName(t, m, "my-custom-model")
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
if !m2.confirmingDeleteModel {
t.Fatal("expected confirmingDeleteModel after d")
}
result, _ = m2.Update(tc.key)
m3 := result.(providerTUIModel)
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 TestProviderTUI_OfficialTab_DeleteActiveUserModelClearsCfg(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
m := officialDashscopeModelTUI(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 = modelIdxForName(t, m, "my-custom-model")
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
result, _ = m2.Update(yKey())
m3 := result.(providerTUIModel)
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)
}
}
func TestOfficialSelectedModelUsesRegistryNotStaleMerge(t *testing.T) {
registry := []string{"built-in"}
deletedCustom := "my-custom"
staleMerged := mergeModelLists(registry, []string{deletedCustom})
if llm.ModelListContains(registry, deletedCustom) {
t.Fatal("test setup: custom name should not be in registry")
}
if !llm.ModelListContains(staleMerged, deletedCustom) {
t.Fatal("test setup: stale merge should still list deleted custom name")
}
// runConfigModel must use registryModels, not staleMerged, or re-selected custom
// names would skip ensureModelInList when still present in the pre-TUI merge.
if llm.ModelListContains(staleMerged, deletedCustom) && llm.ModelListContains(registry, deletedCustom) {
t.Error("would skip persisting re-selected custom model")
}
if llm.ModelListContains(registry, deletedCustom) {
t.Error("registry-only check must not treat custom model as built-in")
}
}
func TestProviderTUI_CustomTab_DeleteModelSkipsSavedInSessionWhenNoModelDeleted(t *testing.T) {
m := customStepfunModelTUI(t, "", []string{"step-3.5-flash", "aaa"})
m.modelIdx = len(m.models()) // out of range — no model row selected
m.deleteModelName = "aaa"
m.confirmingDeleteModel = true
result, _ := m.confirmDeleteCustomModel()
m2 := result.(providerTUIModel)
if m2.savedInSession {
t.Error("savedInSession should be false when modelIdx is out of range")
}
}
func TestProviderTUI_CustomTab_DeleteModelViaDKey(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",
Protocol: "openai",
Model: "step-3.5-flash",
Models: []string{"step-3.5-flash", "aaa"},
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabCustom
for i, cp := range m.customProviders {
if cp.name == "stepfun" {
m.customIdx = i
break
}
}
m.step = stepModel
m.modelIdx = modelIdxForName(t, m, "aaa")
result, _ := m.Update(dKey())
m2 := result.(providerTUIModel)
if !m2.confirmingDeleteModel || m2.deleteModelName != "aaa" {
t.Fatalf("after d: confirming=%v deleteModelName=%q", m2.confirmingDeleteModel, m2.deleteModelName)
}
result, _ = m2.Update(yKey())
m3 := result.(providerTUIModel)
got := m3.existingCfg.CustomProviders["stepfun"].Models
if len(got) != 1 || got[0] != "step-3.5-flash" {
t.Errorf("Models = %v, want [step-3.5-flash]", got)
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load disk config: %v", err)
}
if len(diskCfg.CustomProviders["stepfun"].Models) != 1 {
t.Errorf("disk Models = %v, want [step-3.5-flash]", diskCfg.CustomProviders["stepfun"].Models)
}
}
func TestProviderTUI_PersistCustomModelName_SaveFailureRollsBack(t *testing.T) {
blockPath := filepath.Join(t.TempDir(), "blocked")
if err := os.Mkdir(blockPath, 0o755); err != nil {
t.Fatal(err)
}
m := officialDashscopeModelTUI(t, blockPath, nil)
before := append([]string(nil), m.existingCfg.Providers["dashscope"].Models...)
m.modelIdx = len(m.models())
m.customModel = true
m.modelInput.SetValue("failed-model")
m.modelInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.formError == "" {
t.Fatal("expected formError on save failure")
}
if !strings.Contains(m2.formError, "failed to save") {
t.Errorf("formError = %q, want save failure message", m2.formError)
}
got := m2.existingCfg.Providers["dashscope"].Models
if len(got) != len(before) {
t.Errorf("Models = %v, want unchanged %v", got, before)
}
if m2.savedInSession {
t.Error("savedInSession should be false after failed persist")
}
}
func TestProviderTUI_ManualFormPassesKToAuthHeaderInput(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{Llm: LlmConfig{URL: "https://example.com/v1", Model: "m", AuthToken: "k"}}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabManual
m.inManualForm = true
m.manualStep = manualStepAuthHeader
m.manualAuthHeaderInput.Focus()
result, _ := m.Update(charKey('x'))
m2 := result.(providerTUIModel)
result, _ = m2.Update(charKey('-'))
m3 := result.(providerTUIModel)
result, _ = m3.Update(charKey('a'))
m4 := result.(providerTUIModel)
result, _ = m4.Update(charKey('p'))
m5 := result.(providerTUIModel)
result, _ = m5.Update(charKey('i'))
m6 := result.(providerTUIModel)
result, _ = m6.Update(charKey('-'))
m7 := result.(providerTUIModel)
result, _ = m7.Update(charKey('k'))
m8 := result.(providerTUIModel)
result, _ = m8.Update(charKey('e'))
m9 := result.(providerTUIModel)
result, _ = m9.Update(charKey('y'))
m10 := result.(providerTUIModel)
if got := m10.manualAuthHeaderInput.Value(); got != "x-api-key" {
t.Errorf("manualAuthHeaderInput.Value() = %q, want %q", got, "x-api-key")
}
}
func TestProviderTUI_CustomFormPassesKToAuthHeaderInput(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
m := newProviderTUI(cfg, configPath)
m.creatingCustom = true
m.cpStep = cpStepAuthHeader
m.cpAuthInput.Focus()
result, _ := m.Update(charKey('k'))
m2 := result.(providerTUIModel)
result, _ = m2.Update(charKey('e'))
m3 := result.(providerTUIModel)
result, _ = m3.Update(charKey('y'))
m4 := result.(providerTUIModel)
if got := m4.cpAuthInput.Value(); got != "key" {
t.Errorf("cpAuthInput.Value() = %q, want %q", got, "key")
}
}
func TestProviderTUI_ViewAPIKey_MaskedShowsReplaceHintAndLastFour(t *testing.T) {
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {APIKey: "sk-secret-1234567890abcd"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
if !m.apiKeyMasked {
t.Fatal("apiKeyMasked should be true when an existing key is loaded")
}
got := stripANSI(m.View().Content)
if !strings.Contains(got, "Type or paste to replace the saved key") {
t.Errorf("view missing replace hint; got:\n%s", got)
}
if !strings.Contains(got, "(saved: sk-sec...abcd)") {
t.Errorf("view missing prefix+suffix fingerprint; got:\n%s", got)
}
}
func TestProviderTUI_ViewAPIKey_ShortKeyOmitsFingerprint(t *testing.T) {
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {APIKey: "12345678901234"}, // 14 runes — below min length 15
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
got := stripANSI(m.View().Content)
if !strings.Contains(got, "Type or paste to replace the saved key") {
t.Errorf("view missing replace hint; got:\n%s", got)
}
if strings.Contains(got, "(saved:") {
t.Errorf("view should omit fingerprint for keys shorter than 15 runes; got:\n%s", got)
}
}
func TestProviderTUI_ViewAPIKey_MinLenKeyShowsFingerprint(t *testing.T) {
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {APIKey: "123456789012345"}, // 15 runes — at min length
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
got := stripANSI(m.View().Content)
if !strings.Contains(got, "(saved: 123456...2345)") {
t.Errorf("view should show fingerprint at min length 15; got:\n%s", got)
}
}
func TestSavedSecretFingerprint_TrimsLeadingWhitespace(t *testing.T) {
const key = "sk-secret-1234567890abcd"
got := savedSecretFingerprint(" " + key)
want := "sk-sec...abcd"
if got != want {
t.Errorf("savedSecretFingerprint(%q) = %q, want %q", " "+key, got, want)
}
}
func TestProviderTUI_ViewAPIKey_FreshHidesReplaceHint(t *testing.T) {
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
if m.apiKeyMasked {
t.Fatal("apiKeyMasked should be false when no key is loaded")
}
got := stripANSI(m.View().Content)
if strings.Contains(got, "Type or paste to replace the saved key") {
t.Errorf("view should not show replace hint when fresh; got:\n%s", got)
}
}
func TestOfficialAPIKeyEnvSetHint(t *testing.T) {
const envVar = "DEEPSEEK_API_KEY"
if got := officialAPIKeyEnvSetHint(envVar, false); got != "$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override." {
t.Errorf("no saved key hint = %q", got)
}
if got := officialAPIKeyEnvSetHint(envVar, true); got != "$DEEPSEEK_API_KEY is set; used only when no key is saved here." {
t.Errorf("saved key hint = %q", got)
}
}
func TestProviderTUI_ViewAPIKey_EnvSetNoSavedKey(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
for i, p := range m.providers {
if p.Name == "deepseek" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
got := stripANSI(m.View().Content)
want := "$DEEPSEEK_API_KEY is set. Leave empty to use it; enter a key here to override."
if !strings.Contains(got, want) {
t.Errorf("view missing env hint; want %q; got:\n%s", want, got)
}
}
func TestProviderTUI_ViewAPIKey_EnvSetWithSavedKey(t *testing.T) {
t.Setenv("DEEPSEEK_API_KEY", "sk-from-env")
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {APIKey: "sk-secret-1234567890abcd", Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "deepseek" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
got := stripANSI(m.View().Content)
if !strings.Contains(got, "Type or paste to replace the saved key") {
t.Errorf("view missing replace hint; got:\n%s", got)
}
want := "$DEEPSEEK_API_KEY is set; used only when no key is saved here."
if !strings.Contains(got, want) {
t.Errorf("view missing env hint; want %q; got:\n%s", want, got)
}
if strings.Contains(got, "Leave empty to use it") {
t.Errorf("saved-key view should not show empty-env hint; got:\n%s", got)
}
}
func TestProviderTUI_ApiKeyPasteReplacesMaskedKey(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
Model: "step-3.5-flash",
CustomProviders: map[string]ProviderEntry{
"stepfun": {
URL: "https://api.stepfun.com/v1",
APIKey: "old-key-ssss",
Model: "step-3.5-flash",
Models: []string{"step-3.5-flash"},
},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
if !m.apiKeyMasked {
t.Fatal("expected masked key on load")
}
if m.apiKeyInput.Value() != maskedSecretPlaceholder() {
t.Fatalf("placeholder = %q, want fixed %d asterisks", m.apiKeyInput.Value(), maskedSecretDisplayLen)
}
result, _ := m.Update(tea.PasteMsg{Content: "sk-new-pasted-key"})
m2 := result.(providerTUIModel)
if m2.apiKeyMasked {
t.Fatal("paste should unmask the field")
}
if got := m2.apiKeyInput.Value(); got != "sk-new-pasted-key" {
t.Errorf("input value = %q, want pasted key", got)
}
if r := m2.result(); r.apiKey != "sk-new-pasted-key" {
t.Errorf("result().apiKey = %q, want pasted key", r.apiKey)
}
}
func TestProviderTUI_ManualTokenPasteReplacesMaskedToken(t *testing.T) {
cfg := &Config{
Llm: LlmConfig{
URL: "https://example.com/v1",
Model: "m",
AuthToken: "old-token-secret",
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabManual
m.inManualForm = true
m.manualStep = manualStepAuthToken
m.manualTokenMasked = true
m.manualTokenOriginal = "old-token-secret"
m.manualTokenInput.SetValue(maskedSecretPlaceholder())
m.manualTokenInput.Focus()
if !m.manualTokenMasked {
t.Fatal("expected masked token on load")
}
result, _ := m.Update(tea.PasteMsg{Content: "new-pasted-token"})
m2 := result.(providerTUIModel)
if m2.manualTokenMasked {
t.Fatal("paste should unmask the token field")
}
if got := m2.manualTokenInput.Value(); got != "new-pasted-token" {
t.Errorf("input value = %q, want pasted token", got)
}
if r := m2.result(); r.apiKey != "new-pasted-token" {
t.Errorf("result().apiKey = %q, want pasted token", r.apiKey)
}
}
func TestProviderTUI_ApiKeyTypingShowsOneStarPerChar(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
Model: "step-3.5-flash",
CustomProviders: map[string]ProviderEntry{
"stepfun": {APIKey: "old-key-ssss", Model: "step-3.5-flash"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
for _, ch := range "abc" {
result, _ := m.Update(charKey(ch))
m = result.(providerTUIModel)
}
if m.apiKeyInput.Value() != "abc" {
t.Errorf("value = %q, want abc", m.apiKeyInput.Value())
}
// EchoPassword renders one '*' per character in the input view.
masked := stripANSI(m.apiKeyInput.View())
starCount := strings.Count(masked, "*")
if starCount != 3 {
t.Errorf("masked view has %d asterisks, want 3 (one * per char)", starCount)
}
}
func TestProviderTUI_ApiKeyEnterWithoutEditKeepsOriginal(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
CustomProviders: map[string]ProviderEntry{
"stepfun": {APIKey: "keep-me"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
if r := m.result(); r.apiKey != "keep-me" {
t.Fatalf("before edit result().apiKey = %q", r.apiKey)
}
result, cmd := m.Update(enterKey())
m2 := result.(providerTUIModel)
if !m2.confirmed {
t.Error("Enter without edit should confirm")
}
if cmd == nil {
t.Error("Enter without edit should quit")
}
if r := m2.result(); r.apiKey != "keep-me" {
t.Errorf("after Enter result().apiKey = %q, want keep-me", r.apiKey)
}
}
func TestProviderTUI_ApiKeyClearSavedKeyReturnsEmpty(t *testing.T) {
cfg := &Config{
Provider: "deepseek",
Model: "deepseek-v4-flash",
Providers: map[string]ProviderEntry{
"deepseek": {APIKey: "old-saved-key", Model: "deepseek-v4-flash"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "deepseek" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.beginAPIKeyReplace()
if r := m.result(); r.apiKey != "" {
t.Errorf("result().apiKey = %q, want empty after clearing saved key", r.apiKey)
}
}
func TestProviderTUI_ApiKeyResultTrimSpace(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 == "deepseek" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.apiKeyInput.SetValue(" ")
if r := m.result(); r.apiKey != "" {
t.Errorf("result().apiKey = %q, want empty for whitespace-only input", r.apiKey)
}
}
func TestProviderTUI_OfficialApiKeyEmptyWithoutEnvBlocksEnter(t *testing.T) {
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
t.Setenv("DASHSCOPE_API_KEY", "")
result, cmd := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepAPIKey {
t.Errorf("step = %d, want stepAPIKey", m2.step)
}
if m2.formError != "API key is required (or set $DASHSCOPE_API_KEY)" {
t.Errorf("formError = %q", m2.formError)
}
if cmd != nil {
t.Error("Enter without key or env should not quit")
}
}
func TestProviderTUI_OfficialApiKeyEmptyWithEnvAllowsEnter(t *testing.T) {
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
t.Setenv("DASHSCOPE_API_KEY", "sk-from-env")
result, cmd := m.Update(enterKey())
m2 := result.(providerTUIModel)
if !m2.confirmed {
t.Error("Enter with env set should confirm")
}
if cmd == nil {
t.Error("Enter with env set should quit")
}
if m2.formError != "" {
t.Errorf("formError = %q, want empty", m2.formError)
}
}
func TestProviderTUI_CustomExistingApiKeyEmptyBlocksEnter(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
CustomProviders: map[string]ProviderEntry{
"stepfun": {APIKey: "old-key"},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepAPIKey
m.loadExistingAPIKey()
m.apiKeyInput.Focus()
m.beginAPIKeyReplace()
result, cmd := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepAPIKey {
t.Errorf("step = %d, want stepAPIKey", m2.step)
}
if m2.formError != "API key is required" {
t.Errorf("formError = %q, want %q", m2.formError, "API key is required")
}
if cmd != nil {
t.Error("Enter with cleared key should not quit")
}
}
func TestProviderTUI_CustomCreateApiKeyOptional(t *testing.T) {
m := newProviderTUI(&Config{}, "")
m.activeTab = tabCustom
m.creatingCustom = true
m.cpStep = cpStepAPIKey
m.apiKeyInput.SetValue("")
m.apiKeyInput.Focus()
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.cpStep != cpStepAuthHeader {
t.Errorf("cpStep = %d, want cpStepAuthHeader", m2.cpStep)
}
if m2.formError != "" {
t.Errorf("formError = %q, want empty for optional API key", m2.formError)
}
}
func TestProviderTUI_ViewAPIKey_ShowsFormError(t *testing.T) {
cfg := &Config{
Provider: "dashscope",
Model: "qwen3.7-max",
Providers: map[string]ProviderEntry{
"dashscope": {},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "dashscope" {
m.officialIdx = i
break
}
}
m.step = stepAPIKey
m.loadExistingAPIKey()
m.formError = "API key is required (or set $DASHSCOPE_API_KEY)"
m.apiKeyInput.Focus()
got := stripANSI(m.View().Content)
if !strings.Contains(got, "API key is required (or set $DASHSCOPE_API_KEY)") {
t.Errorf("view missing formError; got:\n%s", got)
}
}
func TestProviderTUI_CancelIncompleteOfficialProviderSwitch_NoPersistedChanges(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.step = stepModel
m.modelIdx = modelIdxForName(t, m, "glm-5")
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.savedInSession {
t.Error("savedInSession should be false for cross-provider navigation")
}
if m2.step != stepAPIKey {
t.Fatalf("step = %d, want stepAPIKey", m2.step)
}
result, _ = m2.Update(escKey())
m3 := result.(providerTUIModel)
result, _ = m3.Update(escKey())
m4 := result.(providerTUIModel)
result, cmd := m4.Update(escKey())
m5 := result.(providerTUIModel)
if !m5.cancelled {
t.Error("expected cancelled = true")
}
if m5.savedInSession {
t.Error("savedInSession should remain false after cancel")
}
if cmd == nil {
t.Error("expected tea.Quit on final Esc")
}
if _, err := os.Stat(configPath); err == nil {
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
if diskCfg.Provider != "deepseek" {
t.Errorf("Provider = %q, want deepseek", diskCfg.Provider)
}
if entry, ok := diskCfg.Providers["baidu-qianfan"]; ok && entry.Model != "" {
t.Errorf("baidu-qianfan model = %q, want no cross-provider draft persisted", entry.Model)
}
}
}
func TestProviderTUI_SameOfficialProviderModelChange_DefersPersistUntilConfirm(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
m.step = stepModel
m.modelIdx = modelIdxForName(t, m, "deepseek-v4-pro")
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.savedInSession {
t.Error("savedInSession should be false before API key confirm")
}
if m2.step != stepAPIKey {
t.Fatalf("step = %d, want stepAPIKey", m2.step)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatal("config 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 TestProviderTUI_OfficialModelChangeBlockedAtAPIKey_KeepsGlobalModel(t *testing.T) {
t.Setenv("ANTHROPIC_API_KEY", "")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Provider: "anthropic",
Model: "claude-opus-4-8",
Providers: map[string]ProviderEntry{
"anthropic": {
Model: "claude-opus-4-8",
APIKey: "sk-test-key",
},
},
}
m := newProviderTUI(cfg, configPath)
m.activeTab = tabOfficial
for i, p := range m.providers {
if p.Name == "anthropic" {
m.officialIdx = i
break
}
}
m.step = stepModel
m.modelIdx = modelIdxForName(t, m, "claude-opus-4-7")
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
m2.beginAPIKeyReplace()
result, cmd := m2.Update(enterKey())
m3 := result.(providerTUIModel)
if cmd != nil {
t.Error("Enter without key or env should not quit")
}
if m3.step != stepAPIKey {
t.Fatalf("step = %d, want stepAPIKey", m3.step)
}
if cfg.Model != "claude-opus-4-8" {
t.Errorf("cfg.Model = %q, want claude-opus-4-8", cfg.Model)
}
if got := cfg.Providers["anthropic"].Model; got != "claude-opus-4-8" {
t.Errorf("providers.anthropic.Model = %q, want claude-opus-4-8", got)
}
if _, err := os.Stat(configPath); err == nil {
t.Fatal("config should not be written when API key validation fails")
}
}
// stripANSI removes ANSI escape sequences from a string so tests can assert
// against plain text content.
func stripANSI(s string) string {
var b strings.Builder
inEscape := false
for _, r := range s {
if r == 0x1b {
inEscape = true
continue
}
if inEscape {
if r == 'm' {
inEscape = false
}
continue
}
b.WriteRune(r)
}
return b.String()
}
func TestProviderTUI_DeleteModelPreservesActiveModel(t *testing.T) {
cfg := &Config{
Provider: "stepfun",
Model: "step-3.5-flash",
CustomProviders: map[string]ProviderEntry{
"stepfun": {
Model: "step-3.5-flash",
Models: []string{"step-3.5-flash", "aaa"},
},
},
}
m := newProviderTUI(cfg, "")
m.activeTab = tabCustom
m.customIdx = 0
m.step = stepModel
m.modelIdx = 1 // aaa
m.confirmingDeleteModel = true
m.deleteModelName = "aaa"
result, _ := m.Update(yKey())
m2 := result.(providerTUIModel)
if m2.existingCfg.CustomProviders["stepfun"].Model != "step-3.5-flash" {
t.Errorf("entry.Model = %q, want step-3.5-flash", m2.existingCfg.CustomProviders["stepfun"].Model)
}
if m2.existingCfg.Model != "step-3.5-flash" {
t.Errorf("cfg.Model = %q, want step-3.5-flash", m2.existingCfg.Model)
}
if !m2.savedInSession {
t.Error("savedInSession should be true after deleting a model")
}
}
func TestApplyCustomProviderConfigPreservesModelOrder(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
cfg := &Config{
Provider: "test-provider",
Model: "test-model-2",
CustomProviders: map[string]ProviderEntry{
"test-provider": {
Model: "test-model-2",
Models: append([]string(nil), models...),
},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
result := providerTUIResult{
provider: "test-provider",
model: "test-model-3",
models: append([]string(nil), models...),
isCustom: true,
isEdit: true,
}
if err := applyCustomProviderConfig(configPath, cfg, result); err != nil {
t.Fatalf("applyCustomProviderConfig: %v", err)
}
got := cfg.CustomProviders["test-provider"].Models
if len(got) != len(models) {
t.Fatalf("Models length = %d, want %d: %v", len(got), len(models), got)
}
for i := range models {
if got[i] != models[i] {
t.Errorf("Models[%d] = %q, want %q", i, got[i], models[i])
}
}
if cfg.CustomProviders["test-provider"].Model != "test-model-3" {
t.Errorf("entry.Model = %q, want test-model-3", cfg.CustomProviders["test-provider"].Model)
}
}
func TestApplyManualConfigNormalizesAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}
result := providerTUIResult{
isManual: true,
url: "https://example.com/v1",
model: "test-model",
apiKey: "token",
protocol: "anthropic",
authHeader: "X-Api-Key",
}
if err := applyManualConfig(configPath, cfg, result); err != nil {
t.Fatalf("applyManualConfig: %v", err)
}
if got := cfg.Llm.AuthHeader; got != "x-api-key" {
t.Errorf("Llm.AuthHeader = %q, want %q", got, "x-api-key")
}
useAnthropic := true
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != useAnthropic {
t.Errorf("UseAnthropic = %v, want %v", cfg.Llm.UseAnthropic, useAnthropic)
}
}
func TestApplyCustomProviderConfigNormalizesAuthHeader(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"test-provider": {URL: "https://example.com", Model: "m"},
},
}
result := providerTUIResult{
provider: "test-provider",
model: "m",
url: "https://example.com",
protocol: "anthropic",
authHeader: "Authorization",
isCustom: true,
isEdit: true,
}
if err := applyCustomProviderConfig(configPath, cfg, result); err != nil {
t.Fatalf("applyCustomProviderConfig: %v", err)
}
if got := cfg.CustomProviders["test-provider"].AuthHeader; got != "authorization" {
t.Errorf("AuthHeader = %q, want %q", got, "authorization")
}
}