feat: add interactive provider setup with TUI and documentation

This commit is contained in:
kite 2026-06-14 10:41:35 +08:00
parent f577742c04
commit 72aad2c77c
23 changed files with 3407 additions and 76 deletions

View file

@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strconv"
"strings"
"github.com/open-code-review/open-code-review/internal/llm"
)
@ -25,6 +26,19 @@ func runConfig(args []string) error {
return nil
}
switch args[0] {
case "provider":
if len(args) != 1 {
return fmt.Errorf("config provider does not accept arguments; use 'ocr config set provider <name>' for non-interactive setup")
}
return runConfigProvider()
case "model":
if len(args) != 1 {
return fmt.Errorf("config model does not accept arguments; use 'ocr config set model <name>' for non-interactive setup")
}
return runConfigModel()
}
action, err := parseConfigArgs(args)
if err != nil {
return err
@ -53,29 +67,38 @@ func runConfigSet(key, value string) error {
return err
}
dir := filepath.Dir(configPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
if err := saveConfig(configPath, cfg); err != nil {
return err
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
displayValue := value
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken") {
displayValue = maskKey(value)
}
if err := os.WriteFile(configPath, data, 0o644); err != nil {
return fmt.Errorf("write config: %w", err)
}
fmt.Printf("Set %s = %s\n", key, value)
fmt.Printf("Set %s = %s\n", key, displayValue)
return nil
}
// ProviderEntry holds per-provider configuration in the providers map.
type ProviderEntry struct {
APIKey string `json:"api_key,omitempty"`
URL string `json:"url,omitempty"`
Protocol string `json:"protocol,omitempty"`
Model string `json:"model,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
}
// Config represents the user-level configuration file (~/.opencodereview/config.json).
type Config struct {
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"` // Output language, defaults to Chinese when empty
Telemetry *TelemetryConfig `json:"telemetry,omitempty"` // Telemetry/observability settings
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
Providers map[string]ProviderEntry `json:"providers,omitempty"`
CustomProviders map[string]ProviderEntry `json:"custom_providers,omitempty"`
Llm LlmConfig `json:"llm,omitempty"`
Language string `json:"language,omitempty"`
Telemetry *TelemetryConfig `json:"telemetry,omitempty"`
}
type LlmConfig struct {
@ -127,7 +150,55 @@ func LoadAppConfig(path string) (*Config, error) {
}
func setConfigValue(cfg *Config, key, value string) error {
// Handle providers.<name>.<field> paths.
if strings.HasPrefix(key, "providers.") {
return setProviderValue(cfg, key, value)
}
if strings.HasPrefix(key, "custom_providers.") {
return setCustomProviderValue(cfg, key, value)
}
switch key {
case "provider":
if cfg.Provider != value {
cfg.Model = ""
}
cfg.Provider = value
if _, isPreset := llm.LookupProvider(value); isPreset {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
if _, exists := cfg.Providers[value]; !exists {
cfg.Providers[value] = ProviderEntry{}
}
} else {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
if _, exists := cfg.CustomProviders[value]; !exists {
cfg.CustomProviders[value] = ProviderEntry{}
}
}
case "model":
if cfg.Provider != "" {
if _, isPreset := llm.LookupProvider(cfg.Provider); isPreset {
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[cfg.Provider]
entry.Model = value
cfg.Providers[cfg.Provider] = entry
} else {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[cfg.Provider]
entry.Model = value
cfg.CustomProviders[cfg.Provider] = entry
}
} else {
cfg.Model = value
}
case "llm.url", "llm.URL":
cfg.Llm.URL = value
case "llm.auth_token", "llm.AuthToken":
@ -175,11 +246,74 @@ func setConfigValue(cfg *Config, key, value string) error {
}
cfg.Llm.ExtraBody = m
default:
return fmt.Errorf("unknown config key: %s\nSupported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging", key)
}
return nil
}
func applyProviderField(entry *ProviderEntry, field, key, value string) error {
switch field {
case "api_key":
entry.APIKey = value
case "url":
entry.URL = value
case "protocol":
if value != "anthropic" && value != "openai" {
return fmt.Errorf("invalid protocol %q: must be \"anthropic\" or \"openai\"", value)
}
entry.Protocol = value
case "model":
entry.Model = value
case "auth_header":
normalized, err := llm.NormalizeAuthHeader(value)
if err != nil {
return err
}
entry.AuthHeader = normalized
case "extra_body":
var m map[string]any
if err := json.Unmarshal([]byte(value), &m); err != nil {
return fmt.Errorf("invalid JSON for %s: %w", key, err)
}
entry.ExtraBody = m
default:
return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, auth_header, extra_body", field)
}
return nil
}
func setProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid provider key %q: expected providers.<name>.<field>", key)
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[parts[1]]
if err := applyProviderField(&entry, parts[2], key, value); err != nil {
return err
}
cfg.Providers[parts[1]] = entry
return nil
}
func setCustomProviderValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid custom provider key %q: expected custom_providers.<name>.<field>", key)
}
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[parts[1]]
if err := applyProviderField(&entry, parts[2], key, value); err != nil {
return err
}
cfg.CustomProviders[parts[1]] = entry
return nil
}
func (c *Config) ensureTelemetry() {
if c.Telemetry == nil {
c.Telemetry = &TelemetryConfig{}

View file

@ -21,3 +21,126 @@ func TestSetConfigValueAuthHeaderRejectsCustomHeader(t *testing.T) {
t.Fatal("expected error for unsupported auth_header, got nil")
}
}
func TestSetConfigValueProvider(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "provider", "anthropic"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Provider != "anthropic" {
t.Errorf("Provider = %q, want %q", cfg.Provider, "anthropic")
}
}
func TestSetConfigValueModel(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Model != "claude-opus-4-6" {
t.Errorf("Model = %q, want %q", cfg.Model, "claude-opus-4-6")
}
}
func TestSetConfigValueModelWithProvider(t *testing.T) {
cfg := &Config{
Provider: "anthropic",
Providers: map[string]ProviderEntry{
"anthropic": {APIKey: "sk-test"},
},
}
if err := setConfigValue(cfg, "model", "claude-opus-4-6"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
t.Errorf("entry Model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
}
if cfg.Model != "" {
t.Errorf("top-level Model = %q, want empty (should write to provider entry)", cfg.Model)
}
}
func TestSetConfigValueProviderEntry(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.api_key", "sk-ant-test"); err != nil {
t.Fatalf("setConfigValue api_key: %v", err)
}
if cfg.Providers["anthropic"].APIKey != "sk-ant-test" {
t.Errorf("api_key = %q, want %q", cfg.Providers["anthropic"].APIKey, "sk-ant-test")
}
if err := setConfigValue(cfg, "providers.anthropic.model", "claude-opus-4-6"); err != nil {
t.Fatalf("setConfigValue model: %v", err)
}
if cfg.Providers["anthropic"].Model != "claude-opus-4-6" {
t.Errorf("model = %q, want %q", cfg.Providers["anthropic"].Model, "claude-opus-4-6")
}
}
func TestSetConfigValueProviderEntryProtocol(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "openai"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.CustomProviders["custom"].Protocol != "openai" {
t.Errorf("protocol = %q, want %q", cfg.CustomProviders["custom"].Protocol, "openai")
}
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "invalid"); err == nil {
t.Fatal("expected error for invalid protocol")
}
}
func TestSetConfigValueProviderEntryInvalidKey(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.unknown_field", "value"); err == nil {
t.Fatal("expected error for unknown provider field")
}
}
func TestSetConfigValueProviderEntryInvalidPath(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic", "value"); err == nil {
t.Fatal("expected error for incomplete provider path")
}
}
func TestSetConfigValueProviderEntryExtraBody(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.extra_body", `{"thinking":{"type":"disabled"}}`); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Providers["anthropic"].ExtraBody == nil {
t.Fatal("extra_body should not be nil")
}
if _, ok := cfg.Providers["anthropic"].ExtraBody["thinking"]; !ok {
t.Error("extra_body missing 'thinking' key")
}
}
func TestSetConfigValueModelWithCustomProvider(t *testing.T) {
cfg := &Config{
Provider: "my-gateway",
CustomProviders: map[string]ProviderEntry{
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai"},
},
}
if err := setConfigValue(cfg, "model", "llama-3-70b"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.CustomProviders["my-gateway"].Model != "llama-3-70b" {
t.Errorf("entry Model = %q, want %q", cfg.CustomProviders["my-gateway"].Model, "llama-3-70b")
}
if cfg.Model != "" {
t.Errorf("top-level Model = %q, want empty (should write to custom provider entry)", cfg.Model)
}
}

View file

@ -249,7 +249,7 @@ func parseConfigArgs(args []string) (configAction, error) {
value: args[2],
}, nil
default:
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set", subCmd)
return configAction{}, fmt.Errorf("unknown config sub-command: %s\nAvailable: set, provider, model", subCmd)
}
}
@ -258,8 +258,29 @@ func printConfigUsage() {
Usage:
ocr config set <key> <value>
ocr config provider Interactive provider setup
ocr config model Interactive model selection
Examples:
# Provider setup (interactive)
ocr config provider
ocr config model
# Provider setup (non-interactive)
ocr config set provider anthropic
ocr config set model claude-opus-4-6
# Set API key via environment variable (recommended) or config:
# export ANTHROPIC_API_KEY=sk-ant-xxx
ocr config set providers.anthropic.api_key "$ANTHROPIC_API_KEY"
# Custom provider
ocr config set provider my-gateway
ocr config set providers.my-gateway.url https://gateway.internal.com/v1
ocr config set providers.my-gateway.protocol openai
ocr config set providers.my-gateway.model llama-3-70b
ocr config set providers.my-gateway.api_key "$MY_API_KEY"
# Legacy endpoint configuration
ocr config set llm.url https://xx/v1/openai/chat/completions
ocr config set llm.auth_token xxxxxxxxxx
ocr config set llm.auth_header x-api-key
@ -268,5 +289,5 @@ Examples:
ocr config set language English
ocr config set telemetry.enabled true
Supported keys: llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`)
Supported keys: provider, model, providers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging`)
}

View file

@ -5,7 +5,6 @@ import (
"fmt"
"time"
"github.com/open-code-review/open-code-review/internal/config/template"
"github.com/open-code-review/open-code-review/internal/config/testconnection"
"github.com/open-code-review/open-code-review/internal/llm"
)
@ -19,6 +18,9 @@ func runLLM(args []string) error {
switch args[0] {
case "test":
return runLLMTest()
case "providers":
runLLMProviders()
return nil
default:
return fmt.Errorf("unknown llm sub-command: %s\nRun 'ocr llm' for usage", args[0])
}
@ -53,11 +55,6 @@ func runLLMTest() error {
timeout = time.Duration(task.Timeout) * time.Second
}
tpl, err := template.LoadDefault()
if err != nil {
return fmt.Errorf("load default template: %w", err)
}
llmClient := llm.NewLLMClient(ep)
messages := make([]llm.Message, 0, len(task.Messages))
@ -71,7 +68,7 @@ func runLLMTest() error {
return llmClient.CompletionsWithCtx(ctx, llm.ChatRequest{
Model: ep.Model,
Messages: messages,
MaxTokens: tpl.MaxTokens,
MaxTokens: 256,
})
}()
if err != nil {
@ -89,6 +86,18 @@ func runLLMTest() error {
return nil
}
func runLLMProviders() {
providers := llm.ListProviders()
fmt.Println("\nBuilt-in providers:")
fmt.Printf(" %-14s %-10s %s\n", "NAME", "PROTOCOL", "BASE URL")
fmt.Printf(" %-14s %-10s %s\n", "----", "--------", "--------")
for _, p := range providers {
fmt.Printf(" %-14s %-10s %s\n", p.Name, p.Protocol, p.BaseURL)
}
fmt.Println("\nUse 'ocr config provider' to configure a provider interactively.")
fmt.Println("Use 'ocr config set provider <name>' to switch providers non-interactively.")
}
func printLLMUsage() {
fmt.Println(`LLM utility commands.
@ -97,7 +106,9 @@ Usage:
Sub-commands:
test Send a test conversation to the configured LLM model
providers List all built-in LLM providers
Examples:
ocr llm test Verify LLM connectivity and configuration`)
ocr llm test Verify LLM connectivity and configuration
ocr llm providers List available built-in providers`)
}

View file

@ -79,8 +79,11 @@ Commands:
Examples:
ocr review --from master --to dev Review diff range
ocr review --commit abc123 Review a single commit
ocr config provider Interactive provider setup
ocr config model Interactive model selection
ocr config set llm.model opus-4-6 Set a config value
ocr llm test Test LLM connectivity
ocr llm providers List built-in providers
ocr version Show version info
Use "ocr review -h" for more information about review.

View file

@ -0,0 +1,272 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
tea "charm.land/bubbletea/v2"
"github.com/open-code-review/open-code-review/internal/llm"
)
func runConfigProvider() error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
m := newProviderTUI(cfg)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("TUI error: %w", err)
}
final := finalModel.(providerTUIModel)
if !final.confirmed {
fmt.Println("Cancelled.")
return nil
}
result := final.result()
if result.isManual {
return applyManualConfig(configPath, cfg, result)
}
if result.isCustom {
return applyCustomProviderConfig(configPath, cfg, result)
}
return applyOfficialProviderConfig(configPath, cfg, result)
}
func applyManualConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.url == "" {
return fmt.Errorf("URL is required for manual configuration")
}
if result.model == "" {
return fmt.Errorf("model is required for manual configuration")
}
cfg.Provider = ""
cfg.Model = ""
cfg.Llm.URL = result.url
cfg.Llm.Model = result.model
cfg.Llm.AuthToken = result.apiKey
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Println("\nManual configuration saved.")
fmt.Printf("URL: %s\n", result.url)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
return nil
}
func applyCustomProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.provider == "" {
return fmt.Errorf("provider name is required")
}
if result.model == "" {
return fmt.Errorf("model is required")
}
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[result.provider]
entry.Model = result.model
if result.url != "" {
entry.URL = result.url
}
if result.protocol != "" {
entry.Protocol = result.protocol
}
if result.authHeader != "" {
entry.AuthHeader = result.authHeader
}
if result.apiKey != "" {
entry.APIKey = result.apiKey
}
cfg.CustomProviders[result.provider] = entry
if cfg.Provider != result.provider {
cfg.Model = ""
}
cfg.Provider = result.provider
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nProvider set to: %s (custom)\n", result.provider)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
func applyOfficialProviderConfig(configPath string, cfg *Config, result providerTUIResult) error {
if result.provider == "" || result.model == "" {
return fmt.Errorf("provider and model are required")
}
preset, isPreset := llm.LookupProvider(result.provider)
if result.apiKey == "" {
if isPreset && preset.EnvVar != "" {
if os.Getenv(preset.EnvVar) == "" {
return fmt.Errorf("API key is required for provider %s (configure it or set $%s)", result.provider, preset.EnvVar)
}
} else {
return fmt.Errorf("API key is required for provider %s", result.provider)
}
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[result.provider]
entry.Model = result.model
if result.apiKey != "" {
entry.APIKey = result.apiKey
}
cfg.Providers[result.provider] = entry
if cfg.Provider != result.provider {
cfg.Model = ""
}
cfg.Provider = result.provider
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nProvider set to: %s\n", result.provider)
fmt.Printf("Model: %s\n", result.model)
fmt.Println("\nTesting connection...")
if err := runLLMTest(); err != nil {
fmt.Fprintf(os.Stderr, "Connection test failed: %v\n", err)
fmt.Fprintln(os.Stderr, "Provider configuration has been saved. Fix the issue and run 'ocr llm test' to re-verify.")
return nil
}
fmt.Println("\nTip: run 'ocr config model' to switch model later.")
return nil
}
func runConfigModel() error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.Provider == "" {
return fmt.Errorf("no provider configured. Run 'ocr config provider' first")
}
preset, isPreset := llm.LookupProvider(cfg.Provider)
if !isPreset {
return fmt.Errorf("provider %q is not a preset provider; use 'ocr config set providers.%s.model <name>' instead", cfg.Provider, cfg.Provider)
}
currentModel := ""
if entry, ok := cfg.Providers[cfg.Provider]; ok {
currentModel = entry.Model
}
if currentModel == "" {
currentModel = cfg.Model
}
m := newModelTUI(preset, currentModel)
p := tea.NewProgram(m)
finalModel, err := p.Run()
if err != nil {
return fmt.Errorf("TUI error: %w", err)
}
final := finalModel.(modelTUIModel)
if final.cancelled {
fmt.Println("Cancelled.")
return nil
}
selectedModel := final.selectedModel()
if selectedModel == "" {
return fmt.Errorf("model name cannot be empty")
}
if cfg.Providers == nil {
cfg.Providers = make(map[string]ProviderEntry)
}
entry := cfg.Providers[cfg.Provider]
entry.Model = selectedModel
cfg.Providers[cfg.Provider] = entry
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("\nModel set to: %s\n", selectedModel)
return nil
}
func saveConfig(path string, cfg *Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := os.Chmod(path, 0o600); err != nil {
return fmt.Errorf("chmod config: %w", err)
}
return nil
}
func maskKey(key string) string {
if key == "" {
return "(not set)"
}
if len(key) <= 8 {
return "***"
}
return key[:4] + "***" + key[len(key)-4:]
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,514 @@
package main
import (
"testing"
tea "charm.land/bubbletea/v2"
)
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}
}
// --- 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_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.manualTokenInput.Value() != "token-123" {
t.Errorf("Token not restored: got %q, want %q", m3.manualTokenInput.Value(), "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.manualTokenInput.Value() != "token-123" {
t.Errorf("Token not prefilled: 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.manualTokenInput.Value() != "manual-token" {
t.Errorf("Token not prefilled: got %q", m.manualTokenInput.Value())
}
}
// --- 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_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_SelectExistingCustomGoesToAPIKey(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)
// Enter on existing custom provider should go to API key step
result, _ := m.Update(enterKey())
m2 := result.(providerTUIModel)
if m2.step != stepAPIKey {
t.Errorf("step = %d, want %d (stepAPIKey)", m2.step, stepAPIKey)
}
}
// --- 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")
}
}