open-code-review/cmd/opencodereview/config_cmd.go
wxwxwxw_orange 39eb0f3984
Some checks are pending
CI / test (push) Waiting to run
fix(tui): persist official-tab models and refine saved secret hint (#260)
* fix(tui): persist official-tab models and refine saved secret hint

Persist user-added models to providers.<name>.models on the official tab.
When an API key or auth token is already saved, show a replace hint with a
prefix/suffix fingerprint (skipped for short keys), use a fixed mask placeholder,
and ensure typing or paste replaces the saved value instead of re-saving it.

* fix(tui): model add/delete UX and config wizard hardening
- Add model add/delete in config provider and config model (official + custom)
- Show d Delete only on model rows; green highlight when selected
- Improve Esc cancel text; track savedInSession to avoid misleading messages
- Reload config on save failure; read registry models fresh after reload
- Export llm.ModelListContains; fix config model persist using registry-only check

* fix(tui): defer provider config until confirm and harden API key UX
Only persist provider/model on wizard confirm; keep in-session picks via
sessionModelPick. Validate API key before quit, clear saved keys when emptied,
and improve official env-var hints and custom edit clear behavior.

* feat(tui): show active model suffix on official provider list
Align Official tab with Custom: display (model) next to the active preset
when cfg.Provider matches and a global model is configured.
2026-07-06 20:38:39 +08:00

597 lines
17 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/open-code-review/open-code-review/internal/llm"
)
// Default config file location: ~/.opencodereview/config.json
func defaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot determine home directory: %w", err)
}
return filepath.Join(home, ".opencodereview", "config.json"), nil
}
// resolveConfigPath returns OCR_CONFIG_PATH when set, otherwise the default user config path.
// Intentionally used only by read-only commands (e.g. ocr llm test). Write paths such as
// config set and review keep defaultConfigPath() so a leaked OCR_CONFIG_PATH cannot redirect writes.
func resolveConfigPath() (string, error) {
if p := strings.TrimSpace(os.Getenv("OCR_CONFIG_PATH")); p != "" {
return p, nil
}
return defaultConfigPath()
}
func runConfig(args []string) error {
if len(args) == 0 {
printConfigUsage()
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
}
switch action.subCmd {
case "set":
return runConfigSet(action.key, action.value)
case "unset":
return runConfigUnset(action.key)
default:
return fmt.Errorf("unknown config sub-command: %s", action.subCmd)
}
}
func runConfigSet(key, value string) error {
configPath, err := defaultConfigPath()
if err != nil {
return err
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if err := setConfigValue(cfg, key, value); err != nil {
return err
}
if err := saveConfig(configPath, cfg); err != nil {
return err
}
displayValue := value
normalizedKey := strings.ToLower(strings.ReplaceAll(key, "_", ""))
if strings.HasSuffix(normalizedKey, "apikey") || strings.HasSuffix(normalizedKey, "authtoken") {
displayValue = maskKey(value)
}
fmt.Printf("Set %s = %s\n", key, displayValue)
return nil
}
func runConfigUnset(key string) error {
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 || parts[1] == "" {
return fmt.Errorf("unset supports custom_providers.<name> and mcp_servers.<name>")
}
configPath, err := defaultConfigPath()
if err != nil {
return err
}
switch parts[0] {
case "custom_providers":
return unsetCustomProvider(configPath, parts[1])
case "mcp_servers":
return unsetMCPServer(configPath, parts[1])
default:
return fmt.Errorf("unset supports custom_providers.<name> and mcp_servers.<name>")
}
}
func unsetCustomProvider(configPath, name string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
wasActive, err := deleteCustomProvider(cfg, name)
if err != nil {
return err
}
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("Deleted custom provider %q.\n", name)
if wasActive {
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")
}
return nil
}
func unsetMCPServer(configPath, name string) error {
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
if cfg.MCPServers == nil {
return fmt.Errorf("MCP server %q not found", name)
}
if _, exists := cfg.MCPServers[name]; !exists {
return fmt.Errorf("MCP server %q not found", name)
}
delete(cfg.MCPServers, name)
if len(cfg.MCPServers) == 0 {
cfg.MCPServers = nil
}
if err := saveConfig(configPath, cfg); err != nil {
return err
}
fmt.Printf("Deleted MCP server %q.\n", name)
return nil
}
// deleteCustomProvider removes a custom provider from cfg in memory.
// Returns true if the deleted provider was the active one.
func deleteCustomProvider(cfg *Config, name string) (bool, error) {
if cfg.CustomProviders == nil {
return false, fmt.Errorf("custom provider %q not found", name)
}
if _, exists := cfg.CustomProviders[name]; !exists {
return false, fmt.Errorf("custom provider %q not found", name)
}
wasActive := cfg.Provider == name
delete(cfg.CustomProviders, name)
if len(cfg.CustomProviders) == 0 {
cfg.CustomProviders = nil
}
if wasActive {
cfg.Provider = ""
cfg.Model = ""
}
return wasActive, 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"`
Models []string `json:"models,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
}
// MCPServerConfig holds configuration for a single MCP server (stdio transport).
type MCPServerConfig struct {
Command string `json:"command"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
Tools []string `json:"tools,omitempty"`
Setup string `json:"setup,omitempty"`
}
// Config represents the user-level configuration file (~/.opencodereview/config.json).
type Config struct {
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"`
MCPServers map[string]MCPServerConfig `json:"mcp_servers,omitempty"`
}
type LlmConfig struct {
URL string `json:"url,omitempty"`
AuthToken string `json:"auth_token,omitempty"`
AuthHeader string `json:"auth_header,omitempty"`
Model string `json:"model,omitempty"`
UseAnthropic *bool `json:"use_anthropic,omitempty"` // nil = default true; false = OpenAI protocol
ExtraBody map[string]any `json:"extra_body,omitempty"`
ExtraHeaders map[string]string `json:"extra_headers,omitempty"`
}
// TelemetryConfig holds telemetry-specific settings.
type TelemetryConfig struct {
Enabled bool `json:"enabled,omitempty"` // Master switch for telemetry
Exporter string `json:"exporter,omitempty"` // "console" or "otlp"
OTLPEndpoint string `json:"otlp_endpoint,omitempty"` // OTLP collector address
ContentLog bool `json:"content_logging,omitempty"` // Include prompt/response content
}
func loadOrCreateConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return &Config{}, nil
}
return nil, err
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
return &cfg, nil
}
// LoadAppConfig loads config from path. Returns nil, nil if file does not exist.
func LoadAppConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read app config %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse app config: %w", err)
}
return &cfg, nil
}
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)
}
if strings.HasPrefix(key, "mcp_servers.") {
return setMCPServerValue(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":
cfg.Llm.AuthToken = value
case "llm.auth_header", "llm.AuthHeader":
normalized, err := llm.NormalizeAuthHeader(value)
if err != nil {
return err
}
cfg.Llm.AuthHeader = normalized
case "llm.extra_headers", "llm.ExtraHeaders":
parsed, err := llm.ParseExtraHeaders(value)
if err != nil {
return err
}
cfg.Llm.ExtraHeaders = parsed
case "llm.model", "llm.Model":
cfg.Llm.Model = value
case "llm.use_anthropic", "llm.UseAnthropic":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for llm.use_anthropic: %w", err)
}
cfg.Llm.UseAnthropic = &b
case "language", "Language":
cfg.Language = value
case "telemetry.enabled", "telemetry.Enabled":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.enabled: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.Enabled = b
case "telemetry.exporter", "telemetry.Exporter":
cfg.ensureTelemetry()
cfg.Telemetry.Exporter = value
case "telemetry.otlp_endpoint", "telemetry.OTLPEndpoint":
cfg.ensureTelemetry()
cfg.Telemetry.OTLPEndpoint = value
case "telemetry.content_logging", "telemetry.ContentLog":
b, err := strconv.ParseBool(value)
if err != nil {
return fmt.Errorf("invalid boolean for telemetry.content_logging: %w", err)
}
cfg.ensureTelemetry()
cfg.Telemetry.ContentLog = b
case "llm.extra_body", "llm.ExtraBody":
var m map[string]any
if err := json.Unmarshal([]byte(value), &m); err != nil {
return fmt.Errorf("invalid JSON for llm.extra_body: %w", err)
}
cfg.Llm.ExtraBody = m
default:
return fmt.Errorf("unknown config key: %s\nSupported keys: provider, model, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_header, llm.model, llm.use_anthropic, llm.extra_body, llm.extra_headers, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\nProvider fields: api_key, url, protocol, model, models, auth_header, extra_body, extra_headers\nMCP server fields: command, args, env, tools, setup", 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 "models":
models, err := parseModelListValue(value)
if err != nil {
return fmt.Errorf("invalid model list for %s: %w", key, err)
}
entry.Models = models
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
case "extra_headers":
parsed, err := llm.ParseExtraHeaders(value)
if err != nil {
return fmt.Errorf("invalid extra headers for %s: %w", key, err)
}
entry.ExtraHeaders = parsed
default:
return fmt.Errorf("unknown provider field %q: supported fields are api_key, url, protocol, model, models, auth_header, extra_body, extra_headers", field)
}
return nil
}
func parseModelListValue(value string) ([]string, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, nil
}
if strings.HasPrefix(value, "[") {
var models []string
if err := json.Unmarshal([]byte(value), &models); err == nil {
return normalizeModelList(models), nil
}
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "["), "]"))
}
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))
for _, model := range models {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
out = append(out, model)
}
return out
}
func mergeModelLists(lists ...[]string) []string {
var merged []string
for _, list := range lists {
merged = append(merged, list...)
}
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 llm.ModelListContains(models, model) {
return models
}
out := append([]string(nil), models...)
return append(out, model)
}
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 _, isPreset := llm.LookupProvider(parts[1]); !isPreset {
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
}
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)
}
return setCustomProviderField(cfg, parts[1], parts[2], key, value)
}
func setCustomProviderField(cfg *Config, name, field, key, value string) error {
if cfg.CustomProviders == nil {
cfg.CustomProviders = make(map[string]ProviderEntry)
}
entry := cfg.CustomProviders[name]
if err := applyProviderField(&entry, field, key, value); err != nil {
return err
}
cfg.CustomProviders[name] = entry
return nil
}
func setMCPServerValue(cfg *Config, key, value string) error {
parts := strings.SplitN(key, ".", 3)
if len(parts) != 3 || parts[1] == "" || parts[2] == "" {
return fmt.Errorf("invalid MCP server key %q: expected mcp_servers.<name>.<field>", key)
}
name, field := parts[1], parts[2]
if cfg.MCPServers == nil {
cfg.MCPServers = make(map[string]MCPServerConfig)
}
entry := cfg.MCPServers[name]
switch field {
case "command":
if value == "" {
return fmt.Errorf("MCP server command cannot be empty")
}
entry.Command = value
case "args":
var args []string
if err := json.Unmarshal([]byte(value), &args); err != nil {
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
}
entry.Args = args
case "env":
var env []string
if err := json.Unmarshal([]byte(value), &env); err != nil {
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
}
for _, e := range env {
idx := strings.Index(e, "=")
if idx <= 0 {
return fmt.Errorf("invalid env entry %q: must be in KEY=VALUE format", e)
}
}
entry.Env = env
case "tools":
var tools []string
if err := json.Unmarshal([]byte(value), &tools); err != nil {
return fmt.Errorf("invalid JSON array for %s: %w", key, err)
}
seen := make(map[string]struct{}, len(tools))
filtered := make([]string, 0, len(tools))
for _, t := range tools {
if t == "" {
return fmt.Errorf("tool names in %s must not be empty", key)
}
if _, dup := seen[t]; dup {
continue
}
seen[t] = struct{}{}
filtered = append(filtered, t)
}
entry.Tools = filtered
case "setup":
entry.Setup = value
default:
return fmt.Errorf("unknown MCP server field %q: supported fields are command, args, env, tools, setup", field)
}
cfg.MCPServers[name] = entry
return nil
}
func (c *Config) ensureTelemetry() {
if c.Telemetry == nil {
c.Telemetry = &TelemetryConfig{}
}
}