mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat: improve config provider TUI interaction (#213)
* feat: improve config provider TUI interaction - Support wrap-around navigation in provider, custom provider, and model lists (up at first item jumps to last, down at last item jumps to first) - Add d key to delete custom models in model selection step with confirmation prompt - Add protocol selection (anthropic/openai) to manual configuration flow and respect UseAnthropic setting in saved config - Preserve manual protocol selection when re-entering the form - Fix manual form input fields losing focus after returning from next step - Use safe slice removal in removeFromSlice to avoid backing array corruption * fix: address code review feedback - Restore Blur() calls in handleManualFormEnter to prevent visual focus artifacts when transitioning between manual form steps - Add empty-providers guard in handleUp/handleDown to avoid negative index - Consolidate redundant protocolIdx checks into single if/else in viewManualTab and viewCustomProviderForm for clarity * fix: persist custom model deletions and support masked auth token editing - Track deleted custom models in TUI and apply them on exit (both confirm and cancel paths) to keep config.json in sync - Clear custom provider's active Model field when that model is deleted, preventing stale "model" reference in the provider list label - Add masked display for manual config Auth Token to allow easy re-entry, matching the official provider flow (any key clears and starts fresh) * test: update manual form tests for masked auth token behavior Update TestProviderTUI_ManualFormPrefilledValues, TestProviderTUI_ManualFormEscRestoresOriginalValues, and TestProviderTUI_ManualFormPrefilledWhenProviderSet to assert the new masked display state (manualTokenMasked + manualTokenOriginal) instead of the raw token value. * feat: refine config provider TUI flows and session persistence Custom provider interactions: - Simplify create/edit form to Name → Protocol → URL → API Key → Auth Header - After create or edit save, jump straight into the model list for that provider - Support comma-separated model names in the custom model input - Show masked API key in edit form; empty Auth Header defaults to (Authorization) - Populate edit form from existingCfg to avoid stale list data after in-session saves Model selection and highlight: - Green highlight follows the persisted active model, not the cursor position - Prefer provider entry.model over global cfg.model when resolving active model - Deleting a non-active model keeps the current green highlight unchanged Delete and navigation: - Delete custom providers (d) and custom models (d) with confirmation prompts - Persist create/edit/model-select/add/delete changes to disk during the session - Fix custom provider deletion not surviving Esc exe-entry Tests: - Add coverage for model highlight, delete-model behavior, and create→model-list flow - Update manual/custom form tests for masked token and session save behavior * refactor: address provider TUI code review feedback - Remove dead helper removeFromSlice (superseded by removeModels) - Move misplaced doc comment to applyEditCustomProviderSave - Drop redundant applyProviderDeletions post-TUI call so provider deletions rely solely on the in-session save - Fix brace/indent drift in updateDeleteModelConfirm and cache the model list instead of recomputing m.models() twice * feat: refine provider TUI flows and manual config form Custom provider flow: - Default protocol to anthropic on the new-provider form - Single-name model input; reject duplicates with inline error and preserve the typed value so the user can edit instead of re-typing - Drop the global green highlight; cursor/blue is the only selection cue Manual configuration form: - Add Auth Header step (URL → Protocol → Model → Auth Token → Auth Header) and persist it to Llm.AuthHeader on confirm - Reorder so Auth Header is always entered last - Make Auth Token required; empty Enter stays on the field - Show every field's label on every render, even when empty, matching the custom-provider form style Tests: - Cover custom model input add / duplicate paths - Cover manual form prefill of Llm.AuthHeader - Cover that deleting a non-active model keeps the active model intact * Improve provider TUI validation, persistence, and code clarity. Address code review feedback: align custom Auth Header validation with manual mode; fix savedInSession after model deletion; refactor applyEditCustomProviderSave to return error; remove dead applyModelDeletions/deletedModels; document ExtraBody shallow-copy limit. Also fix manual/custom form UX (token skip on edit, k key input, formError scoping, switch indentation). * Fix provider/model TUI list ordering and add test coverage. Address review and UX feedback: remove model list sorting in provider and config model TUIs; preserve Models list order when selecting active model (ensureModelInList); add test for duplicate rename on custom provider edit. Includes prior review fixes for savedInSession, applyEditCustomProviderSave error return, and dead code removal. * Normalize AuthHeader at apply layer and simplify UseAnthropic assignment. Call NormalizeAuthHeader in applyManualConfig and applyCustomProviderConfig before save; simplify UseAnthropic assignment in manual config; add unit tests. * Address review: lowercase error strings and newProviderTUI signature. Use lowercase "failed to save" errors; replace variadic configPath with string and remove configPathFromArgs; pass "" in tests when no path; gofmt provider_tui.go.
This commit is contained in:
parent
a1e4cf6b43
commit
751602776e
5 changed files with 1595 additions and 272 deletions
|
|
@ -394,6 +394,16 @@ func parseModelListValue(value string) ([]string, error) {
|
|||
return normalizeModelList(strings.Split(value, ",")), nil
|
||||
}
|
||||
|
||||
func activeModelForProvider(cfg *Config, providerName string, entry ProviderEntry) string {
|
||||
if entry.Model != "" {
|
||||
return entry.Model
|
||||
}
|
||||
if cfg != nil && cfg.Provider == providerName && cfg.Model != "" {
|
||||
return cfg.Model
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeModelList(models []string) []string {
|
||||
out := make([]string, 0, len(models))
|
||||
seen := make(map[string]struct{}, len(models))
|
||||
|
|
@ -419,6 +429,19 @@ func mergeModelLists(lists ...[]string) []string {
|
|||
return normalizeModelList(merged)
|
||||
}
|
||||
|
||||
// ensureModelInList appends model to the end when missing; never reorders existing entries.
|
||||
func ensureModelInList(models []string, model string) []string {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
return models
|
||||
}
|
||||
if 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 {
|
||||
|
|
|
|||
|
|
@ -416,3 +416,23 @@ func TestUnsetInvalidKey(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureModelInList(t *testing.T) {
|
||||
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
|
||||
|
||||
got := ensureModelInList(models, "test-model-3")
|
||||
if len(got) != len(models) {
|
||||
t.Fatalf("existing model should not reorder: got %v", got)
|
||||
}
|
||||
for i := range models {
|
||||
if got[i] != models[i] {
|
||||
t.Errorf("models[%d] = %q, want %q", i, got[i], models[i])
|
||||
}
|
||||
}
|
||||
|
||||
got = ensureModelInList(models, "new-model")
|
||||
want := append(append([]string(nil), models...), "new-model")
|
||||
if len(got) != len(want) || got[len(got)-1] != "new-model" {
|
||||
t.Errorf("new model should append: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ func runConfigProvider() error {
|
|||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, configPath)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
|
|
@ -31,19 +31,11 @@ func runConfigProvider() error {
|
|||
|
||||
final := finalModel.(providerTUIModel)
|
||||
|
||||
if len(final.deletedProviders) > 0 {
|
||||
clearedActive, err := applyProviderDeletions(configPath, cfg, final.deletedProviders)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if clearedActive && !final.confirmed {
|
||||
fmt.Fprintf(os.Stderr, "[ocr] WARNING: active provider was deleted; 'provider' and 'model' have been cleared.\n")
|
||||
fmt.Fprintf(os.Stderr, "[ocr] Run 'ocr config provider' to select a new provider.\n")
|
||||
}
|
||||
}
|
||||
|
||||
if !final.confirmed {
|
||||
if len(final.deletedProviders) > 0 {
|
||||
// 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.")
|
||||
|
|
@ -82,6 +74,21 @@ func applyProviderDeletions(configPath string, cfg *Config, names []string) (boo
|
|||
return clearedActive, nil
|
||||
}
|
||||
|
||||
func removeModels(existing, toRemove []string) []string {
|
||||
removeSet := make(map[string]struct{}, len(toRemove))
|
||||
for _, m := range toRemove {
|
||||
removeSet[m] = struct{}{}
|
||||
}
|
||||
result := make([]string, 0, len(existing))
|
||||
for _, m := range existing {
|
||||
if _, found := removeSet[m]; found {
|
||||
continue
|
||||
}
|
||||
result = append(result, m)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error {
|
||||
if result.url == "" {
|
||||
return fmt.Errorf("URL is required for manual configuration")
|
||||
|
|
@ -95,6 +102,13 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult)
|
|||
cfg.Llm.URL = result.url
|
||||
cfg.Llm.Model = result.model
|
||||
cfg.Llm.AuthToken = result.apiKey
|
||||
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid auth_header: %w", err)
|
||||
}
|
||||
cfg.Llm.AuthHeader = authHeader
|
||||
useAnthropic := result.protocol == "anthropic"
|
||||
cfg.Llm.UseAnthropic = &useAnthropic
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
|
|
@ -102,6 +116,7 @@ func applyManualConfig(configPath string, cfg *Config, result providerTUIResult)
|
|||
|
||||
fmt.Println("\nManual configuration saved.")
|
||||
fmt.Printf("URL: %s\n", result.url)
|
||||
fmt.Printf("Protocol: %s\n", result.protocol)
|
||||
fmt.Printf("Model: %s\n", result.model)
|
||||
|
||||
fmt.Println("\nTesting connection...")
|
||||
|
|
@ -129,8 +144,9 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
|||
entry := cfg.CustomProviders[result.provider]
|
||||
entry.Model = result.model
|
||||
if len(result.models) > 0 {
|
||||
entry.Models = mergeModelLists([]string{result.model}, result.models)
|
||||
entry.Models = append([]string(nil), result.models...)
|
||||
}
|
||||
entry.Models = ensureModelInList(entry.Models, result.model)
|
||||
if result.url != "" {
|
||||
entry.URL = result.url
|
||||
}
|
||||
|
|
@ -138,22 +154,39 @@ func applyCustomProviderConfig(configPath string, cfg *Config, result providerTU
|
|||
entry.Protocol = result.protocol
|
||||
}
|
||||
if result.authHeader != "" {
|
||||
entry.AuthHeader = result.authHeader
|
||||
authHeader, err := llm.NormalizeAuthHeader(result.authHeader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid auth_header: %w", err)
|
||||
}
|
||||
entry.AuthHeader = authHeader
|
||||
}
|
||||
if result.apiKey != "" {
|
||||
entry.APIKey = result.apiKey
|
||||
}
|
||||
cfg.CustomProviders[result.provider] = entry
|
||||
|
||||
if cfg.Provider != result.provider {
|
||||
cfg.Model = ""
|
||||
if !result.isEdit {
|
||||
cfg.Provider = result.provider
|
||||
cfg.Model = result.model
|
||||
} else if cfg.Provider == result.provider {
|
||||
cfg.Model = result.model
|
||||
}
|
||||
cfg.Provider = result.provider
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result.isEdit {
|
||||
if cfg.Provider == result.provider {
|
||||
fmt.Printf("\nActive provider %q updated.\n", result.provider)
|
||||
} else {
|
||||
fmt.Printf("\nCustom provider %q updated (not currently active).\n", result.provider)
|
||||
}
|
||||
fmt.Printf("Model: %s\n", result.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)
|
||||
|
||||
|
|
@ -203,6 +236,7 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
|
|||
cfg.Model = ""
|
||||
}
|
||||
cfg.Provider = result.provider
|
||||
cfg.Model = result.model
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
|
|
@ -243,7 +277,7 @@ func runConfigModel() error {
|
|||
if preset, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
|
||||
provider = preset
|
||||
if entry, ok := cfg.Providers[cfg.Provider]; ok {
|
||||
currentModel = entry.Model
|
||||
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
|
||||
provider.Models = mergeModelLists(provider.Models, entry.Models)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -252,15 +286,12 @@ func runConfigModel() error {
|
|||
if !ok {
|
||||
return fmt.Errorf("provider %q is not configured in custom_providers", cfg.Provider)
|
||||
}
|
||||
currentModel = entry.Model
|
||||
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
|
||||
provider.DisplayName = cfg.Provider + " (custom)"
|
||||
provider.Protocol = entry.Protocol
|
||||
provider.BaseURL = entry.URL
|
||||
provider.Models = mergeModelLists(entry.Models)
|
||||
}
|
||||
if currentModel == "" {
|
||||
currentModel = cfg.Model
|
||||
}
|
||||
|
||||
m := newModelTUI(provider, currentModel)
|
||||
p := tea.NewProgram(m)
|
||||
|
|
@ -286,7 +317,7 @@ func runConfigModel() error {
|
|||
}
|
||||
entry := cfg.CustomProviders[cfg.Provider]
|
||||
entry.Model = selectedModel
|
||||
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
|
||||
entry.Models = ensureModelInList(entry.Models, selectedModel)
|
||||
cfg.CustomProviders[cfg.Provider] = entry
|
||||
} else {
|
||||
if cfg.Providers == nil {
|
||||
|
|
@ -295,10 +326,11 @@ func runConfigModel() error {
|
|||
entry := cfg.Providers[cfg.Provider]
|
||||
entry.Model = selectedModel
|
||||
if !modelListContains(provider.Models, selectedModel) {
|
||||
entry.Models = mergeModelLists([]string{selectedModel}, entry.Models)
|
||||
entry.Models = ensureModelInList(entry.Models, selectedModel)
|
||||
}
|
||||
cfg.Providers[cfg.Provider] = entry
|
||||
}
|
||||
cfg.Model = selectedModel
|
||||
|
||||
if err := saveConfig(configPath, cfg); err != nil {
|
||||
return err
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,8 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -33,13 +35,13 @@ func tabKeyMsg() tea.KeyPressMsg {
|
|||
}
|
||||
|
||||
func charKey(c rune) tea.KeyPressMsg {
|
||||
return tea.KeyPressMsg{Code: c}
|
||||
return tea.KeyPressMsg{Code: c, Text: string(c)}
|
||||
}
|
||||
|
||||
// --- Tab switching tests ---
|
||||
|
||||
func TestProviderTUI_TabSwitchRight(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
if m.activeTab != tabOfficial {
|
||||
t.Fatalf("initial tab = %d, want %d", m.activeTab, tabOfficial)
|
||||
}
|
||||
|
|
@ -65,7 +67,7 @@ func TestProviderTUI_TabSwitchRight(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_TabSwitchLeft(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
// Go to manual tab first
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -97,7 +99,7 @@ func TestProviderTUI_TabSwitchLeft(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_TabKeyCycles(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
result, _ := m.Update(tabKeyMsg())
|
||||
m2 := result.(providerTUIModel)
|
||||
|
|
@ -119,7 +121,7 @@ func TestProviderTUI_TabKeyCycles(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
// Advance to stepModel
|
||||
result, _ := m.Update(enterKey())
|
||||
|
|
@ -139,7 +141,7 @@ func TestProviderTUI_TabSwitchOnlyOnStepProvider(t *testing.T) {
|
|||
// --- Official tab tests (updated from original) ---
|
||||
|
||||
func TestProviderTUI_OfficialProvidersSortedByDisplayName(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
displayNames := make([]string, len(m.providers))
|
||||
normalized := make([]string, len(m.providers))
|
||||
|
|
@ -154,7 +156,7 @@ func TestProviderTUI_OfficialProvidersSortedByDisplayName(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
|
|
@ -173,7 +175,7 @@ func TestProviderTUI_EscFromModelGoesBackToProvider(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
result, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
|
|
@ -192,7 +194,7 @@ func TestProviderTUI_EscFromAPIKeyGoesBackToModel(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_EscFromProviderCancels(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
result, cmd := m.Update(escKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
|
|
@ -214,7 +216,7 @@ func TestProviderTUI_EscKeyString(t *testing.T) {
|
|||
// --- Manual tab tests ---
|
||||
|
||||
func TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
// Switch to manual tab
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -237,7 +239,7 @@ func TestProviderTUI_ManualTabEnterStartsForm(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_ManualFormEscFromURLExitsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
// Switch to manual tab and enter form
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -269,7 +271,7 @@ func TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {
|
|||
AuthToken: "token-123",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
// Enter the form
|
||||
result, _ := m.Update(enterKey())
|
||||
|
|
@ -293,8 +295,11 @@ func TestProviderTUI_ManualFormEscRestoresOriginalValues(t *testing.T) {
|
|||
if m3.manualModelInput.Value() != "test-model" {
|
||||
t.Errorf("Model not restored: got %q, want %q", m3.manualModelInput.Value(), "test-model")
|
||||
}
|
||||
if m3.manualTokenInput.Value() != "token-123" {
|
||||
t.Errorf("Token not restored: got %q, want %q", m3.manualTokenInput.Value(), "token-123")
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -306,7 +311,7 @@ func TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {
|
|||
AuthToken: "token-123",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
if m.activeTab != tabManual {
|
||||
t.Fatalf("should auto-select manual tab when Llm.URL is set, got %d", m.activeTab)
|
||||
|
|
@ -317,8 +322,14 @@ func TestProviderTUI_ManualFormPrefilledValues(t *testing.T) {
|
|||
if m.manualModelInput.Value() != "test-model" {
|
||||
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
|
||||
}
|
||||
if m.manualTokenInput.Value() != "token-123" {
|
||||
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +341,7 @@ func TestProviderTUI_ManualResult(t *testing.T) {
|
|||
AuthToken: "token-123",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
// Enter the form
|
||||
result, _ := m.Update(enterKey())
|
||||
|
|
@ -361,7 +372,7 @@ func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {
|
|||
AuthToken: "manual-token",
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
if m.activeTab != tabCustom {
|
||||
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
|
||||
|
|
@ -372,15 +383,77 @@ func TestProviderTUI_ManualFormPrefilledWhenProviderSet(t *testing.T) {
|
|||
if m.manualModelInput.Value() != "manual-model" {
|
||||
t.Errorf("Model not prefilled: got %q", m.manualModelInput.Value())
|
||||
}
|
||||
if m.manualTokenInput.Value() != "manual-token" {
|
||||
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.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, _ := m.Update(enterKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
if m2.manualStep != manualStepAuthToken {
|
||||
t.Errorf("should stay on auth token step, got %d", m2.manualStep)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom tab tests ---
|
||||
|
||||
func TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
// Switch to custom tab
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -396,7 +469,7 @@ func TestProviderTUI_CustomTabShowsAddOption(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
// Switch to custom tab
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -414,7 +487,7 @@ func TestProviderTUI_CustomTabSelectAddStartsForm(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {
|
||||
m := newProviderTUI(&Config{})
|
||||
m := newProviderTUI(&Config{}, "")
|
||||
|
||||
// Switch to custom tab and start form
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -436,6 +509,231 @@ func TestProviderTUI_CustomFormEscFromNameExitsForm(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
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 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",
|
||||
|
|
@ -448,7 +746,7 @@ func TestProviderTUI_CustomProviderExistsInList(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
if m.activeTab != tabCustom {
|
||||
t.Fatalf("should auto-select custom tab, got %d", m.activeTab)
|
||||
|
|
@ -474,7 +772,7 @@ func TestProviderTUI_SelectExistingCustomGoesToModel(t *testing.T) {
|
|||
},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
// Enter on existing custom provider should go to model selection first.
|
||||
result, _ := m.Update(enterKey())
|
||||
|
|
@ -482,8 +780,9 @@ func TestProviderTUI_SelectExistingCustomGoesToModel(t *testing.T) {
|
|||
if m2.step != stepModel {
|
||||
t.Errorf("step = %d, want %d (stepModel)", m2.step, stepModel)
|
||||
}
|
||||
if m2.models()[0] != "custom-model" {
|
||||
t.Errorf("first model = %q, want %q", m2.models()[0], "custom-model")
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -555,7 +854,7 @@ func TestProviderTUI_DeleteCustomProvider(t *testing.T) {
|
|||
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
// Switch to custom tab
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -595,8 +894,9 @@ func TestProviderTUI_DeleteCustomProviderCancel(t *testing.T) {
|
|||
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
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)
|
||||
|
|
@ -627,7 +927,7 @@ func TestProviderTUI_DeleteOnAddOptionIgnored(t *testing.T) {
|
|||
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
// Switch to custom tab
|
||||
result, _ := m.Update(rightKey())
|
||||
|
|
@ -649,7 +949,7 @@ func TestProviderTUI_DeleteActiveCustomProvider(t *testing.T) {
|
|||
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai", Model: "custom-model"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
// Should auto-select custom tab with active provider
|
||||
if m.activeTab != tabCustom {
|
||||
|
|
@ -678,7 +978,7 @@ func TestProviderTUI_DeleteEscCancels(t *testing.T) {
|
|||
"my-llm": {URL: "https://custom.api/v1", Protocol: "openai"},
|
||||
},
|
||||
}
|
||||
m := newProviderTUI(cfg)
|
||||
m := newProviderTUI(cfg, "")
|
||||
|
||||
result, _ := m.Update(rightKey())
|
||||
m2 := result.(providerTUIModel)
|
||||
|
|
@ -696,3 +996,300 @@ func TestProviderTUI_DeleteEscCancels(t *testing.T) {
|
|||
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_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_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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue