Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions cmd/opencodereview/provider_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package main
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"

tea "charm.land/bubbletea/v2"

Expand Down Expand Up @@ -260,6 +262,18 @@ func applyOfficialProviderConfig(configPath string, cfg *Config, result provider
// Confirmed empty key: clear saved api_key so resolver falls back to $ENV_VAR.
entry.APIKey = ""
}
// Persist a Base URL override only when it differs from the preset default.
// An empty/unchanged value clears any prior override so the preset BaseURL
// remains the default, matching the "preset is the fallback" contract.
trimmedURL := strings.TrimSpace(result.url)
if isPreset && trimmedURL != "" && trimmedURL != preset.BaseURL {
if err := validateBaseURL(trimmedURL); err != nil {
return err
}
entry.URL = trimmedURL
} else {
entry.URL = ""
}
cfg.Providers[result.provider] = entry

if cfg.Provider != result.provider {
Expand Down Expand Up @@ -311,6 +325,12 @@ func runConfigModel() error {
if entry, ok := cfg.Providers[cfg.Provider]; ok {
currentModel = activeModelForProvider(cfg, cfg.Provider, entry)
provider.Models = mergeModelLists(provider.Models, entry.Models)
// Surface the effective Base URL: a configured override takes
// precedence over the preset default so users can confirm their
// gateway is in use from the model picker.
if entry.URL != "" {
provider.BaseURL = entry.URL
}
}
} else {
isCustom = true
Expand Down Expand Up @@ -409,3 +429,20 @@ func maskKey(key string) string {
}
return key[:4] + "***" + key[len(key)-4:]
}

// validateBaseURL checks that a provider Base URL has an http or https scheme
// and a non-empty host, giving the user immediate feedback in the TUI rather
// than a runtime failure when the LLM client tries to use it.
func validateBaseURL(raw string) error {
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("invalid Base URL %q: %w", raw, err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("Base URL must use http or https scheme, got %q", parsed.Scheme)
}
if parsed.Host == "" {
return fmt.Errorf("Base URL %q must include a host", raw)
}
return nil
}
144 changes: 144 additions & 0 deletions cmd/opencodereview/provider_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"testing"

"github.com/alibaba/open-code-review/internal/llm"
)

func TestMaskKey(t *testing.T) {
Expand Down Expand Up @@ -376,3 +378,145 @@ func TestPrintWizardCancelled(t *testing.T) {
})
}
}

// TestApplyOfficialProviderConfig_PersistsURLOverride verifies that a custom
// Base URL entered in the wizard is persisted to providers.<name>.url, while a
// value equal to the preset default is cleared so the preset remains the default.
func TestApplyOfficialProviderConfig_PersistsURLOverride(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "sk-litellm")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}

err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "litellm",
model: "openai/gpt-5.4",
apiKey: "sk-litellm",
url: "https://gateway.internal:8000/v1",
})
if err != nil {
t.Fatalf("applyOfficialProviderConfig: %v", err)
}
if got := cfg.Providers["litellm"].URL; got != "https://gateway.internal:8000/v1" {
t.Errorf("persisted URL = %q, want https://gateway.internal:8000/v1", got)
}
diskCfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("load config: %v", err)
}
if got := diskCfg.Providers["litellm"].URL; got != "https://gateway.internal:8000/v1" {
t.Errorf("disk URL = %q, want https://gateway.internal:8000/v1", got)
}
}

// TestApplyOfficialProviderConfig_ClearsURLWhenPresetDefault verifies that
// submitting the preset default Base URL writes no url field, so the preset
// BaseURL remains the resolver default.
func TestApplyOfficialProviderConfig_ClearsURLWhenPresetDefault(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "sk-litellm")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{
Providers: map[string]ProviderEntry{
"litellm": {URL: "https://old-gateway.internal:9000/v1"},
},
}

preset, _ := llm.LookupProvider("litellm")
err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "litellm",
model: "openai/gpt-5.4",
apiKey: "sk-litellm",
url: preset.BaseURL,
})
if err != nil {
t.Fatalf("applyOfficialProviderConfig: %v", err)
}
if got := cfg.Providers["litellm"].URL; got != "" {
t.Errorf("persisted URL = %q, want empty (preset default should not persist a url)", got)
}
}

// TestApplyOfficialProviderConfig_TrimsURLWhitespace verifies that a Base URL
// with surrounding whitespace is trimmed before comparison and persistence, so
// whitespace-polluted values are never written to the config file.
func TestApplyOfficialProviderConfig_TrimsURLWhitespace(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "sk-litellm")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}

err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "litellm",
model: "openai/gpt-5.4",
apiKey: "sk-litellm",
url: " https://gateway.internal:8000/v1 ",
})
if err != nil {
t.Fatalf("applyOfficialProviderConfig: %v", err)
}
if got := cfg.Providers["litellm"].URL; got != "https://gateway.internal:8000/v1" {
t.Errorf("persisted URL = %q, want trimmed value", got)
}
}

// TestApplyOfficialProviderConfig_RejectsInvalidScheme verifies that a Base URL
// without an http/https scheme is rejected with a clear error at config time
// rather than failing later at runtime.
func TestApplyOfficialProviderConfig_RejectsInvalidScheme(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "sk-litellm")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}

err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "litellm",
model: "openai/gpt-5.4",
apiKey: "sk-litellm",
url: "ftp://example.com/v1",
})
if err == nil {
t.Fatal("expected error for non-http scheme, got nil")
}
}

// TestApplyOfficialProviderConfig_RejectsMissingScheme verifies that a Base URL
// lacking a scheme (e.g. a bare host) is rejected at config time.
func TestApplyOfficialProviderConfig_RejectsMissingScheme(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "sk-litellm")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}

err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "litellm",
model: "openai/gpt-5.4",
apiKey: "sk-litellm",
url: "api.example.com/v1",
})
if err == nil {
t.Fatal("expected error for URL missing scheme, got nil")
}
}

// TestApplyOfficialProviderConfig_AcceptsHTTP verifies that plain http:// URLs
// are accepted (the default litellm preset is http://localhost:4000/v1).
func TestApplyOfficialProviderConfig_AcceptsHTTP(t *testing.T) {
t.Setenv("LITELLM_API_KEY", "sk-litellm")
dir := t.TempDir()
configPath := filepath.Join(dir, "config.json")
cfg := &Config{}

err := applyOfficialProviderConfig(configPath, cfg, providerTUIResult{
provider: "litellm",
model: "openai/gpt-5.4",
apiKey: "sk-litellm",
url: "http://my-litellm.local:4000/v1",
})
if err != nil {
t.Fatalf("applyOfficialProviderConfig: %v", err)
}
if got := cfg.Providers["litellm"].URL; got != "http://my-litellm.local:4000/v1" {
t.Errorf("persisted URL = %q, want http URL", got)
}
}
Loading