mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat: add interactive provider setup with TUI and documentation
This commit is contained in:
parent
f577742c04
commit
72aad2c77c
23 changed files with 3407 additions and 76 deletions
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
272
cmd/opencodereview/provider_cmd.go
Normal file
272
cmd/opencodereview/provider_cmd.go
Normal 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:]
|
||||
}
|
||||
1337
cmd/opencodereview/provider_tui.go
Normal file
1337
cmd/opencodereview/provider_tui.go
Normal file
File diff suppressed because it is too large
Load diff
514
cmd/opencodereview/provider_tui_test.go
Normal file
514
cmd/opencodereview/provider_tui_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue