open-code-review/cmd/opencodereview/config_cmd_test.go
Tao Xin 8eb83340a4
Some checks failed
plugin-contract / plugin-contract (push) Has been cancelled
CI / test (push) Has been cancelled
CI / windows (push) Has been cancelled
CI / cross-compile (amd64, darwin) (push) Has been cancelled
CI / cross-compile (amd64, windows) (push) Has been cancelled
CI / cross-compile (arm64, darwin) (push) Has been cancelled
CI / cross-compile (arm64, linux) (push) Has been cancelled
CI / cross-compile (arm64, windows) (push) Has been cancelled
CodeQL Advanced / Analyze (go) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Pages / build (push) Has been cancelled
Deploy Pages / deploy (push) Has been cancelled
fix(config): fix bad config for providers or keys that cotains dots (self-defined) (#1661)
* main fix

* chore: write tests (AI)

* fix(config): validate dotted provider names across CLI and IDEs

---------

Co-authored-by: Kite <254839944+lizhengfeng101@users.noreply.github.com>
2026-10-16 11:17:26 +08:00

1867 lines
58 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
package main
import (
"encoding/json"
"io"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"github.com/alibaba/open-code-review/internal/llm"
)
func TestSetConfigValueAuthHeaderNormalizesKnownValues(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.auth_header", " bearer "); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.AuthHeader != "authorization" {
t.Errorf("AuthHeader = %q, want %q", cfg.Llm.AuthHeader, "authorization")
}
}
func TestSetConfigValueAuthHeaderRejectsCustomHeader(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.auth_header", " X-Custom-Auth "); err == nil {
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 TestSetConfigValueProviderURLTrimsAndValidates(t *testing.T) {
t.Run("trims a valid URL before storing", func(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.litellm.url", " https://gateway.internal:8000/v1 "); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if got := cfg.Providers["litellm"].URL; got != "https://gateway.internal:8000/v1" {
t.Errorf("URL = %q, want trimmed URL", got)
}
})
for _, value := range []string{"api.example.com/v1", "ftp://gateway.internal/v1"} {
t.Run("rejects "+value, func(t *testing.T) {
if err := setConfigValue(&Config{}, "providers.litellm.url", value); err == nil {
t.Fatalf("setConfigValue accepted invalid URL %q", value)
}
})
}
}
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 TestSetConfigValueMaxTokens(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "max_tokens", "200000"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.MaxTokens != 200000 {
t.Errorf("MaxTokens = %d, want 200000", cfg.MaxTokens)
}
}
func TestSetConfigValueMaxTokensRejectsInvalidValues(t *testing.T) {
for _, value := range []string{"0", "-1", "not-a-number"} {
t.Run(value, func(t *testing.T) {
if err := setConfigValue(&Config{}, "max_tokens", value); err == nil {
t.Fatalf("expected max_tokens=%q to be rejected", value)
}
})
}
}
func TestMaxTokensConfigRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.json")
cfg := &Config{MaxTokens: 200000}
if err := saveConfig(path, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
loaded, err := LoadAppConfig(path)
if err != nil {
t.Fatalf("LoadAppConfig: %v", err)
}
if loaded.MaxTokens != 200000 {
t.Errorf("MaxTokens = %d, want 200000", loaded.MaxTokens)
}
}
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 TestSetConfigValueKeyCmdFields(t *testing.T) {
// A typo in any of these case labels would silently degrade to "unknown
// provider field" / "unknown config key", so assert the field each key writes.
const value = "op read op://dev/anthropic/api-key"
tests := []struct {
name string
key string
got func(cfg *Config) string
}{
{"preset provider api_key_cmd", "providers.anthropic.api_key_cmd", func(cfg *Config) string { return cfg.Providers["anthropic"].APIKeyCmd }},
{"custom provider api_key_cmd", "custom_providers.my-gateway.api_key_cmd", func(cfg *Config) string { return cfg.CustomProviders["my-gateway"].APIKeyCmd }},
{"llm auth_token_cmd", "llm.auth_token_cmd", func(cfg *Config) string { return cfg.Llm.AuthTokenCmd }},
{"llm AuthTokenCmd alias", "llm.AuthTokenCmd", func(cfg *Config) string { return cfg.Llm.AuthTokenCmd }},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, tt.key, value); err != nil {
t.Fatalf("setConfigValue %s: %v", tt.key, err)
}
if got := tt.got(cfg); got != value {
t.Errorf("%s = %q, want %q", tt.key, got, value)
}
})
}
}
func TestShouldMaskConfigValue(t *testing.T) {
// api_key/auth_token values are secrets; the *_cmd variants are command
// lines, so they print unmasked.
tests := []struct {
key string
want bool
}{
{"llm.auth_token", true},
{"llm.auth_token_cmd", false},
{"providers.x.api_key", true},
{"providers.x.api_key_cmd", false},
{"providers.x.APIKeyCmd", false},
{"llm.AuthToken", true},
}
for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
if got := shouldMaskConfigValue(tt.key); got != tt.want {
t.Errorf("shouldMaskConfigValue(%q) = %v, want %v", tt.key, got, tt.want)
}
})
}
}
func TestSetConfigValueProviderEntryNonPresetWritesCustomProvider(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.my-gateway.url", "https://gateway.internal.com/v1"); err != nil {
t.Fatalf("setConfigValue url: %v", err)
}
if cfg.Providers != nil {
if _, ok := cfg.Providers["my-gateway"]; ok {
t.Fatal("non-preset providers.<name> should be stored in CustomProviders, not Providers")
}
}
if cfg.CustomProviders["my-gateway"].URL != "https://gateway.internal.com/v1" {
t.Errorf("custom provider URL = %q", cfg.CustomProviders["my-gateway"].URL)
}
}
func TestSetConfigValueDottedProviderName(t *testing.T) {
fields := []struct {
field string
value string
}{
{"url", "https://example.invalid/v1"},
{"protocol", llm.ProtocolAnthropicBedrock},
{"model", "some-model"},
{"api_key", "test-key"},
{"api_key_cmd", "secret-tool lookup service x"},
{"models", `["some-model","other-model"]`},
{"auth_header", "authorization"},
{"timeout_sec", "120"},
{"extra_body", `{"temperature":0.2}`},
{"extra_headers", "X-Gateway=test"},
{"retry_codes", "400,403"},
{"aws_region", "us-west-2"},
{"aws_profile", "test-profile"},
}
want := ProviderEntry{
URL: "https://example.invalid/v1",
Protocol: llm.ProtocolAnthropicBedrock,
Model: "some-model",
APIKey: "test-key",
APIKeyCmd: "secret-tool lookup service x",
Models: []string{"some-model", "other-model"},
AuthHeader: "authorization",
TimeoutSec: 120,
ExtraBody: map[string]any{"temperature": 0.2},
ExtraHeaders: map[string]string{"X-Gateway": "test"},
RetryCodes: []int{400, 403},
AWSRegion: "us-west-2",
AWSProfile: "test-profile",
}
for _, prefix := range []string{"providers", "custom_providers"} {
for _, name := range []string{"mimo-v2.6-flash", "openai.gateway.v2", "foo.api_key"} {
t.Run(prefix+"."+name, func(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "provider", name); err != nil {
t.Fatal(err)
}
for _, tt := range fields {
key := prefix + "." + name + "." + tt.field
if err := setConfigValue(cfg, key, tt.value); err != nil {
t.Fatalf("setConfigValue(%q): %v", key, err)
}
}
if !reflect.DeepEqual(cfg.CustomProviders, map[string]ProviderEntry{name: want}) {
t.Errorf("CustomProviders = %#v, want only %q with %#v", cfg.CustomProviders, name, want)
}
if len(cfg.Providers) != 0 || cfg.Provider != name {
t.Errorf("Providers = %#v, Provider = %q", cfg.Providers, cfg.Provider)
}
})
}
}
}
func TestSetConfigValueRejectsInvalidProviderNames(t *testing.T) {
for _, name := range []string{" ", ".a", "a.", "a..b", "a. .b", "anthropic."} {
for _, key := range []string{"provider", "providers." + name + ".url", "custom_providers." + name + ".url"} {
t.Run(key+"/"+name, func(t *testing.T) {
cfg := &Config{Provider: "openai", Model: "saved-model", CustomProviders: map[string]ProviderEntry{name: {}}}
before, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
value := "https://example.invalid/v1"
if key == "provider" {
value = name
}
if err := setConfigValue(cfg, key, value); err == nil || !strings.Contains(err.Error(), "invalid provider name") {
t.Fatalf("setConfigValue(%q, %q) = %v, want invalid provider name", key, value, err)
}
after, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if string(after) != string(before) {
t.Fatalf("configuration changed after rejected write: %s", after)
}
})
}
}
}
func TestSetConfigValueDottedProviderRequiresExistingEntry(t *testing.T) {
for _, prefix := range []string{"providers", "custom_providers"} {
for _, name := range []string{"mimo-v2.6-flash", "foo.api_key", "openai.extra_body"} {
key := prefix + "." + name + ".model"
t.Run(key, func(t *testing.T) {
cfg := &Config{Provider: "openai", Model: "saved-model"}
want := *cfg
err := setConfigValue(cfg, key, "some-model")
if err == nil || !strings.Contains(err.Error(), key) || !strings.Contains(err.Error(), "ocr config set provider "+name) {
t.Fatalf("setConfigValue(%q) = %v, want explicit creation guidance", key, err)
}
if !reflect.DeepEqual(*cfg, want) {
t.Fatalf("configuration changed after rejected write: %#v", cfg)
}
cfg.CustomProviders = map[string]ProviderEntry{name: {}}
if err := setConfigValue(cfg, key, "some-model"); err != nil {
t.Fatal(err)
}
if cfg.Provider != "openai" || cfg.CustomProviders[name].Model != "some-model" {
t.Fatalf("unexpected configuration: %#v", cfg)
}
})
}
}
}
func TestSetConfigValueProviderEntryModelsJSON(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", `["llama-3-70b","llama-3-8b","llama-3-70b"]`); err != nil {
t.Fatalf("setConfigValue models: %v", err)
}
got := cfg.CustomProviders["my-gateway"].Models
want := []string{"llama-3-70b", "llama-3-8b"}
if len(got) != len(want) {
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestSetConfigValueProviderEntryModelsCommaSeparated(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", " llama-3-70b, llama-3-8b ,, llama-3-70b "); err != nil {
t.Fatalf("setConfigValue models: %v", err)
}
got := cfg.CustomProviders["my-gateway"].Models
want := []string{"llama-3-70b", "llama-3-8b"}
if len(got) != len(want) {
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
}
}
}
func TestSetConfigValueProviderEntryModelsUnquotedBracketList(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.my-gateway.models", "[llama-3-70b,llama-3-8b]"); err != nil {
t.Fatalf("setConfigValue models: %v", err)
}
got := cfg.CustomProviders["my-gateway"].Models
want := []string{"llama-3-70b", "llama-3-8b"}
if len(got) != len(want) {
t.Fatalf("models length = %d, want %d: %#v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], want[i])
}
}
}
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 != llm.ProtocolOpenAIChatCompletions {
t.Errorf("protocol = %q, want %q (openai alias normalized)", cfg.CustomProviders["custom"].Protocol, llm.ProtocolOpenAIChatCompletions)
}
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "invalid"); err == nil {
t.Fatal("expected error for invalid protocol")
}
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "anthropic-vertex"); err == nil {
t.Fatal("expected error for unsupported protocol anthropic-vertex")
}
if err := setConfigValue(cfg, "custom_providers.custom.protocol", "openai-responses"); err != nil {
t.Fatalf("setConfigValue openai-responses: %v", err)
}
if cfg.CustomProviders["custom"].Protocol != llm.ProtocolOpenAIResponses {
t.Errorf("protocol = %q, want %q", cfg.CustomProviders["custom"].Protocol, llm.ProtocolOpenAIResponses)
}
}
func TestSetConfigValueLlmProtocol(t *testing.T) {
// Each protocol mirrors use_anthropic so older binaries that predate
// llm.protocol still pick the right protocol family.
tests := []struct {
name string
value string
wantProtocol string
wantUseAnthro bool
}{
{"anthropic mirrors true", "anthropic", llm.ProtocolAnthropic, true},
{"openai alias mirrors false", "openai", llm.ProtocolOpenAIChatCompletions, false},
{"openai-responses mirrors false", "openai-responses", llm.ProtocolOpenAIResponses, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.protocol", tt.value); err != nil {
t.Fatalf("setConfigValue llm.protocol: %v", err)
}
if cfg.Llm.Protocol != tt.wantProtocol {
t.Errorf("cfg.Llm.Protocol = %q, want %q", cfg.Llm.Protocol, tt.wantProtocol)
}
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != tt.wantUseAnthro {
got := "<nil>"
if cfg.Llm.UseAnthropic != nil {
got = strconv.FormatBool(*cfg.Llm.UseAnthropic)
}
t.Errorf("cfg.Llm.UseAnthropic = %s, want %v", got, tt.wantUseAnthro)
}
})
}
t.Run("overwrites stale use_anthropic when switching protocol", func(t *testing.T) {
stale := true
cfg := &Config{}
cfg.Llm.UseAnthropic = &stale
if err := setConfigValue(cfg, "llm.protocol", "openai-responses"); err != nil {
t.Fatalf("setConfigValue llm.protocol: %v", err)
}
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic {
t.Error("UseAnthropic should be false (overwriting stale true)")
}
})
t.Run("rejects invalid protocol", func(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.protocol", "grpc"); err == nil {
t.Fatal("expected error for invalid llm.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) {
for _, prefix := range []string{"providers", "custom_providers"} {
for _, suffix := range []string{"", "anthropic", ".url", "anthropic.", "mimo-v2.6-flash."} {
key := prefix + "." + suffix
t.Run(key, func(t *testing.T) {
if err := setConfigValue(&Config{}, key, "value"); err == nil || !strings.Contains(err.Error(), "invalid") {
t.Fatalf("setConfigValue(%q) = %v, want invalid path error", key, err)
}
})
}
}
}
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)
}
}
func TestSetConfigValueLlmExtraHeaders(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.extra_headers", "X-Custom=val1, X-Org=val2"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.ExtraHeaders == nil {
t.Fatal("ExtraHeaders should not be nil")
}
if v := cfg.Llm.ExtraHeaders["X-Custom"]; v != "val1" {
t.Errorf("ExtraHeaders[\"X-Custom\"] = %q, want %q", v, "val1")
}
if v := cfg.Llm.ExtraHeaders["X-Org"]; v != "val2" {
t.Errorf("ExtraHeaders[\"X-Org\"] = %q, want %q", v, "val2")
}
}
func TestSetConfigValueLlmExtraHeadersInvalid(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.extra_headers", "no-equals-sign"); err == nil {
t.Fatal("expected error for invalid extra headers, got nil")
}
}
func TestSetConfigValueLlmExtraHeadersReservedRejected(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.extra_headers", "Authorization=bad"); err == nil {
t.Fatal("expected error for reserved header, got nil")
}
}
func TestSetConfigValueProviderExtraHeaders(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.extra_headers", "X-Custom=val1, X-Org=val2"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
entry := cfg.Providers["anthropic"]
if entry.ExtraHeaders == nil {
t.Fatal("ExtraHeaders should not be nil")
}
if v := entry.ExtraHeaders["X-Custom"]; v != "val1" {
t.Errorf("ExtraHeaders[\"X-Custom\"] = %q, want %q", v, "val1")
}
if v := entry.ExtraHeaders["X-Org"]; v != "val2" {
t.Errorf("ExtraHeaders[\"X-Org\"] = %q, want %q", v, "val2")
}
}
func TestSetConfigValueProviderExtraHeadersInvalid(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "providers.anthropic.extra_headers", "=missing-key"); err == nil {
t.Fatal("expected error for invalid extra headers, got nil")
}
}
func TestSetConfigValueCustomProviderExtraHeaders(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "custom_providers.my-gateway.protocol", llm.ProtocolAnthropicBedrock); err != nil {
t.Fatalf("setConfigValue protocol: %v", err)
}
if err := setConfigValue(cfg, "custom_providers.my-gateway.extra_headers", "X-Gateway=secret"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
entry := cfg.CustomProviders["my-gateway"]
if entry.ExtraHeaders == nil {
t.Fatal("ExtraHeaders should not be nil")
}
if v := entry.ExtraHeaders["X-Gateway"]; v != "secret" {
t.Errorf("ExtraHeaders[\"X-Gateway\"] = %q, want %q", v, "secret")
}
if entry.URL != "" {
t.Errorf("URL = %q, want empty for a custom Bedrock provider", entry.URL)
}
}
func TestSetConfigValueCustomProviderAuxiliaryFieldRequiresExistingProvider(t *testing.T) {
tests := []struct {
name string
provider string
key string
value string
coreKey string
}{
{"extra_body", "nonexistent", "providers.nonexistent.extra_body", `{"temperature":0.2}`, "providers.nonexistent.protocol"},
{"extra_headers", "nonexistent", "providers.nonexistent.extra_headers", "X-Custom=value", "providers.nonexistent.protocol"},
{"retry_codes", "nonexistent", "providers.nonexistent.retry_codes", "400", "providers.nonexistent.protocol"},
{"timeout_sec", "brandnew", "providers.brandnew.timeout_sec", "900", "providers.brandnew.protocol"},
{"custom timeout_sec", "brandnew", "custom_providers.brandnew.timeout_sec", "900", "custom_providers.brandnew.protocol"},
{"custom provider namespace", "my-gateway", "custom_providers.my-gateway.extra_headers", "X-Custom=value", "custom_providers.my-gateway.protocol"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{}
err := setConfigValue(cfg, tt.key, tt.value)
if err == nil {
t.Fatalf("expected error for %s on a missing provider", tt.key)
}
want := "provider \"" + tt.provider + "\" is not configured; set a core field first (protocol is required for every custom provider):\n" +
" ocr config set " + tt.coreKey + " <protocol>"
if err.Error() != want {
t.Errorf("error = %q, want %q", err, want)
}
if cfg.CustomProviders != nil {
t.Errorf("CustomProviders = %#v, want nil", cfg.CustomProviders)
}
})
}
}
func TestSetConfigValueCustomProviderRejectsPresetName(t *testing.T) {
tests := []struct {
name string
field string
value string
}{
{"openai", "url", "https://gateway.internal.com/v1"},
{"OpenAI", "extra_headers", "X-Custom=value"},
}
for _, tt := range tests {
t.Run(tt.name+" "+tt.field, func(t *testing.T) {
cfg := &Config{}
err := setConfigValue(cfg, "custom_providers."+tt.name+"."+tt.field, tt.value)
if err == nil {
t.Fatalf("expected preset-name conflict for %s", tt.field)
}
want := "custom provider name \"" + tt.name + "\" conflicts with a preset provider; use providers.openai." + tt.field +
" to configure the preset or choose a different custom provider name"
if err.Error() != want {
t.Errorf("error = %q, want %q", err, want)
}
if cfg.CustomProviders != nil {
t.Errorf("CustomProviders = %#v, want nil", cfg.CustomProviders)
}
})
}
}
// --- unset tests ---
func TestUnsetMaxTokens(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
cfg := &Config{Provider: "anthropic", MaxTokens: 200000}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetMaxTokens(configPath); err != nil {
t.Fatalf("unsetMaxTokens: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
if strings.Contains(string(data), "max_tokens") {
t.Errorf("max_tokens should be omitted after unset: %s", data)
}
loaded, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload: %v", err)
}
if loaded.Provider != "anthropic" {
t.Errorf("Provider = %q, want anthropic", loaded.Provider)
}
}
func TestUnsetCustomProvider(t *testing.T) {
dir := t.TempDir()
configPath := dir + "/config.json"
cfg := &Config{
Provider: "anthropic",
CustomProviders: map[string]ProviderEntry{
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetCustomProvider(configPath, "my-gateway"); err != nil {
t.Fatalf("unsetCustomProvider: %v", err)
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload: %v", err)
}
if cfg.CustomProviders != nil {
t.Errorf("CustomProviders should be nil after deleting the only entry, got %v", cfg.CustomProviders)
}
if cfg.Provider != "anthropic" {
t.Errorf("Provider = %q, want %q (should be untouched)", cfg.Provider, "anthropic")
}
}
func TestUnsetActiveCustomProvider(t *testing.T) {
dir := t.TempDir()
configPath := dir + "/config.json"
cfg := &Config{
Provider: "my-gateway",
Model: "fallback-model",
CustomProviders: map[string]ProviderEntry{
"my-gateway": {URL: "https://gw.example.com/v1", Protocol: "openai", Model: "llama-3"},
"other-gateway": {URL: "https://other.example.com/v1", Protocol: "openai", Model: "other-model"},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetCustomProvider(configPath, "my-gateway"); err != nil {
t.Fatalf("unsetCustomProvider: %v", err)
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload: %v", err)
}
if cfg.Provider != "" {
t.Errorf("Provider = %q, want empty after deleting active provider", cfg.Provider)
}
if cfg.Model != "" {
t.Errorf("Model = %q, want empty after deleting active provider", cfg.Model)
}
if _, exists := cfg.CustomProviders["my-gateway"]; exists {
t.Error("my-gateway should have been deleted")
}
if _, exists := cfg.CustomProviders["other-gateway"]; !exists {
t.Error("other-gateway should still exist")
}
}
func TestUnsetInvalidKey(t *testing.T) {
tests := []struct {
name string
wantErr bool
}{
{"my-gateway", false},
{"nonexistent", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
dir := t.TempDir()
configPath := dir + "/config.json"
cfg := &Config{
CustomProviders: map[string]ProviderEntry{
"my-gateway": {URL: "https://gw.example.com/v1"},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
err := unsetCustomProvider(configPath, tt.name)
if (err != nil) != tt.wantErr {
t.Errorf("unsetCustomProvider(%q): err=%v, wantErr=%v", tt.name, err, tt.wantErr)
}
})
}
}
func TestMergeModelLists(t *testing.T) {
tests := []struct {
name string
lists [][]string
want []string
}{
{"empty", nil, nil},
{"single list", [][]string{{"a", "b"}}, []string{"a", "b"}},
{"merge with dedup", [][]string{{"a", "b"}, {"b", "c"}}, []string{"a", "b", "c"}},
{"three lists", [][]string{{"x"}, {"y"}, {"x", "z"}}, []string{"x", "y", "z"}},
{"empty strings filtered", [][]string{{"a", "", "b"}}, []string{"a", "b"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := mergeModelLists(tc.lists...)
if len(got) != len(tc.want) {
t.Fatalf("mergeModelLists() = %v, want %v", got, tc.want)
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestSetMCPServerValue_Command(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
if cfg.MCPServers["my-server"].Command != "npx" {
t.Errorf("Command = %q, want %q", cfg.MCPServers["my-server"].Command, "npx")
}
}
func TestSetMCPServerValue_CommandEmpty(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.command", ""); err == nil {
t.Fatal("expected error for empty command")
}
}
func TestSetMCPServerValue_Args(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", `["--port","8080"]`); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
args := cfg.MCPServers["my-server"].Args
if len(args) != 2 || args[0] != "--port" || args[1] != "8080" {
t.Errorf("Args = %v", args)
}
}
func TestSetMCPServerValue_ArgsInvalidJSON(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.args", "not-json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestSetMCPServerValue_Env(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["FOO=bar","BAZ=qux"]`); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
env := cfg.MCPServers["my-server"].Env
if len(env) != 2 || env[0] != "FOO=bar" {
t.Errorf("Env = %v", env)
}
}
func TestSetMCPServerValue_EnvInvalidJSON(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", "not-json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestSetMCPServerValue_EnvInvalidFormat(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.env", `["NOEQUALS"]`); err == nil {
t.Fatal("expected error for env entry without KEY=VALUE format")
}
}
func TestSetMCPServerValue_Tools(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search","read","search"]`); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
tools := cfg.MCPServers["my-server"].Tools
if len(tools) != 2 || tools[0] != "search" || tools[1] != "read" {
t.Errorf("Tools = %v (expected deduped)", tools)
}
}
func TestSetMCPServerValue_ToolsInvalidJSON(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", "not-json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestSetMCPServerValue_ToolsEmptyName(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.tools", `["search",""]`); err == nil {
t.Fatal("expected error for empty tool name")
}
}
func TestSetMCPServerValue_Setup(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.setup", "init-script.sh"); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
if cfg.MCPServers["my-server"].Setup != "init-script.sh" {
t.Errorf("Setup = %q", cfg.MCPServers["my-server"].Setup)
}
}
func TestSetMCPServerValue_UnknownField(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.my-server.unknown", "val"); err == nil {
t.Fatal("expected error for unknown field")
}
}
func TestSetMCPServerValue_InvalidKey(t *testing.T) {
cfg := &Config{}
tests := []string{
"mcp_servers",
"mcp_servers.",
"mcp_servers..command",
"mcp_servers.name",
}
for _, key := range tests {
if err := setMCPServerValue(cfg, key, "val"); err == nil {
t.Errorf("expected error for key %q", key)
}
}
}
func TestSetMCPServerValue_ExistingServer(t *testing.T) {
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"srv": {Command: "old-cmd"},
},
}
if err := setMCPServerValue(cfg, "mcp_servers.srv.command", "new-cmd"); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
if cfg.MCPServers["srv"].Command != "new-cmd" {
t.Errorf("Command = %q, want %q", cfg.MCPServers["srv"].Command, "new-cmd")
}
}
func TestUnsetMCPServer(t *testing.T) {
dir := t.TempDir()
configPath := dir + "/config.json"
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"srv1": {Command: "cmd1"},
"srv2": {Command: "cmd2"},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetMCPServer(configPath, "srv1"); err != nil {
t.Fatalf("unsetMCPServer: %v", err)
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload: %v", err)
}
if _, exists := cfg.MCPServers["srv1"]; exists {
t.Error("srv1 should have been deleted")
}
if _, exists := cfg.MCPServers["srv2"]; !exists {
t.Error("srv2 should still exist")
}
}
func TestUnsetMCPServer_LastEntry(t *testing.T) {
dir := t.TempDir()
configPath := dir + "/config.json"
cfg := &Config{
MCPServers: map[string]MCPServerConfig{
"only": {Command: "cmd"},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetMCPServer(configPath, "only"); err != nil {
t.Fatalf("unsetMCPServer: %v", err)
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload: %v", err)
}
if cfg.MCPServers != nil {
t.Errorf("MCPServers should be nil after deleting last entry, got %v", cfg.MCPServers)
}
}
func TestUnsetMCPServer_NotFound(t *testing.T) {
dir := t.TempDir()
configPath := dir + "/config.json"
cfg := &Config{}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
t.Fatal("expected error for nil MCPServers")
}
cfg = &Config{
MCPServers: map[string]MCPServerConfig{
"other": {Command: "cmd"},
},
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
if err := unsetMCPServer(configPath, "nonexistent"); err == nil {
t.Fatal("expected error for missing server")
}
}
func TestRunConfigUnset_UnknownPrefix(t *testing.T) {
if err := runConfigUnset("providers.anthropic"); err == nil {
t.Fatal("expected error for unsupported prefix")
}
}
func TestSetConfigValueMCPServer(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "mcp_servers.my-server.command", "npx"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.MCPServers["my-server"].Command != "npx" {
t.Errorf("Command = %q", cfg.MCPServers["my-server"].Command)
}
}
func TestEnsureTelemetry(t *testing.T) {
cfg := &Config{}
if cfg.Telemetry != nil {
t.Fatal("Telemetry should be nil initially")
}
cfg.ensureTelemetry()
if cfg.Telemetry == nil {
t.Fatal("Telemetry should be non-nil after ensureTelemetry()")
}
cfg.ensureTelemetry()
if cfg.Telemetry == nil {
t.Fatal("Telemetry should remain non-nil on second call")
}
}
func TestSetConfigValueLlmURL(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.url", "https://example.com/v1"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.URL != "https://example.com/v1" {
t.Errorf("URL = %q", cfg.Llm.URL)
}
}
func TestSetConfigValueLlmAuthToken(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.auth_token", "tok-123"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.AuthToken != "tok-123" {
t.Errorf("AuthToken = %q", cfg.Llm.AuthToken)
}
}
func TestSetConfigValueLlmModel(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.model", "my-model"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.Model != "my-model" {
t.Errorf("Model = %q", cfg.Llm.Model)
}
}
func TestSetConfigValueLlmUseAnthropic(t *testing.T) {
// use_anthropic mirrors protocol so the two never disagree.
tests := []struct {
name string
value string
wantUseAnthro bool
wantProtocol string
}{
{"true mirrors anthropic", "true", true, llm.ProtocolAnthropic},
{"false mirrors openai", "false", false, llm.ProtocolOpenAIChatCompletions},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.use_anthropic", tt.value); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.UseAnthropic == nil || *cfg.Llm.UseAnthropic != tt.wantUseAnthro {
got := "<nil>"
if cfg.Llm.UseAnthropic != nil {
got = strconv.FormatBool(*cfg.Llm.UseAnthropic)
}
t.Errorf("UseAnthropic = %s, want %v", got, tt.wantUseAnthro)
}
if cfg.Llm.Protocol != tt.wantProtocol {
t.Errorf("Protocol = %q, want %q", cfg.Llm.Protocol, tt.wantProtocol)
}
})
}
t.Run("overwrites stale protocol when switching use_anthropic", func(t *testing.T) {
// Simulate a prior openai-responses config; setting use_anthropic=true
// must repoint protocol to anthropic so they never disagree.
cfg := &Config{Llm: LlmConfig{Protocol: llm.ProtocolOpenAIResponses}}
if err := setConfigValue(cfg, "llm.use_anthropic", "true"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.Protocol != llm.ProtocolAnthropic {
t.Errorf("Protocol = %q, want %q", cfg.Llm.Protocol, llm.ProtocolAnthropic)
}
})
t.Run("preserves openai-responses when setting use_anthropic false", func(t *testing.T) {
// A prior openai-responses config must not be silently downgraded to
// openai when the legacy use_anthropic=false is set.
cfg := &Config{Llm: LlmConfig{Protocol: llm.ProtocolOpenAIResponses}}
if err := setConfigValue(cfg, "llm.use_anthropic", "false"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.Protocol != llm.ProtocolOpenAIResponses {
t.Errorf("Protocol = %q, want %q (openai-responses must be preserved)", cfg.Llm.Protocol, llm.ProtocolOpenAIResponses)
}
})
}
func TestSetConfigValueLlmUseAnthropicInvalid(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.use_anthropic", "notbool"); err == nil {
t.Fatal("expected error for invalid boolean")
}
}
func TestSetConfigValueLanguage(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "language", "English"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Language != "English" {
t.Errorf("Language = %q", cfg.Language)
}
}
func TestSetConfigValueTelemetryEnabled(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "telemetry.enabled", "true"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Telemetry == nil || !cfg.Telemetry.Enabled {
t.Error("Telemetry.Enabled should be true")
}
}
func TestSetConfigValueTelemetryEnabledInvalid(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "telemetry.enabled", "notbool"); err == nil {
t.Fatal("expected error for invalid boolean")
}
}
func TestSetConfigValueTelemetryExporter(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "telemetry.exporter", "otlp"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Telemetry.Exporter != "otlp" {
t.Errorf("Exporter = %q", cfg.Telemetry.Exporter)
}
}
func TestSetConfigValueTelemetryOTLPEndpoint(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "telemetry.otlp_endpoint", "localhost:4317"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Telemetry.OTLPEndpoint != "localhost:4317" {
t.Errorf("OTLPEndpoint = %q", cfg.Telemetry.OTLPEndpoint)
}
}
func TestSetConfigValueTelemetryContentLogging(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "telemetry.content_logging", "true"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if !cfg.Telemetry.ContentLog {
t.Error("ContentLog should be true")
}
}
func TestSetConfigValueTelemetryContentLoggingInvalid(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "telemetry.content_logging", "notbool"); err == nil {
t.Fatal("expected error for invalid boolean")
}
}
func TestSetConfigValueLlmExtraBody(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.extra_body", `{"key":"val"}`); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Llm.ExtraBody == nil {
t.Fatal("ExtraBody should not be nil")
}
if cfg.Llm.ExtraBody["key"] != "val" {
t.Errorf("ExtraBody[\"key\"] = %v", cfg.Llm.ExtraBody["key"])
}
}
func TestSetConfigValueLlmExtraBodyInvalid(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "llm.extra_body", "not-json"); err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestSetConfigValueUnknownKey(t *testing.T) {
cfg := &Config{}
if err := setConfigValue(cfg, "unknown.key", "val"); err == nil {
t.Fatal("expected error for unknown key")
}
}
func TestSetConfigValueUnknownKeyMessage(t *testing.T) {
// The unknown-key error message must stay byte-identical after extracting
// supportedConfigKeys, and must be generated from that list.
err := setConfigValue(&Config{}, "bogus.key", "val")
if err == nil {
t.Fatal("expected error for unknown key")
}
want := "unknown config key: bogus.key\n" +
"Supported keys: provider, model, max_tokens, effort, providers.<name>.<field>, custom_providers.<name>.<field>, mcp_servers.<name>.<field>, llm.url, llm.auth_token, llm.auth_token_cmd, llm.auth_header, llm.model, llm.timeout_sec, llm.protocol, llm.use_anthropic, llm.extra_body, llm.extra_headers, llm.retry_codes, language, telemetry.enabled, telemetry.exporter, telemetry.otlp_endpoint, telemetry.content_logging\n" +
"Provider fields: api_key, api_key_cmd, url, protocol, model, models, auth_header, timeout_sec, extra_body, extra_headers, retry_codes, aws_region, aws_profile\n" +
"Protocol values: anthropic, anthropic-bedrock, openai, openai-responses\n" +
"MCP server fields: type, command, args, env, url, headers, tools, setup"
if err.Error() != want {
t.Errorf("unknown-key message drifted:\n got: %q\nwant: %q", err.Error(), want)
}
if !strings.Contains(err.Error(), strings.Join(supportedConfigKeys, ", ")) {
t.Error("message should be generated from supportedConfigKeys")
}
}
func TestSetConfigValueProviderClearsModel(t *testing.T) {
cfg := &Config{Provider: "old-provider", Model: "old-model"}
if err := setConfigValue(cfg, "provider", "new-provider"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if cfg.Model != "" {
t.Errorf("Model should be cleared on provider change, got %q", cfg.Model)
}
}
func TestRunConfigUnset_InvalidKey(t *testing.T) {
if err := runConfigUnset("custom_providers."); err == nil {
t.Fatal("expected error for empty provider name")
}
}
func TestRunConfigSetWarnsWhenActiveProviderShadowsLegacyLLMConfig(t *testing.T) {
setTestHome(t, t.TempDir())
configPath, err := defaultConfigPath()
if err != nil {
t.Fatal(err)
}
if err := saveConfig(configPath, &Config{Provider: "dashscope"}); err != nil {
t.Fatalf("save config: %v", err)
}
stderr := captureConfigStderr(t, func() {
if err := runConfigSet("llm.url", "https://gateway.example/v1"); err != nil {
t.Fatalf("runConfigSet: %v", err)
}
})
if !strings.Contains(stderr, `provider "dashscope" is active`) {
t.Errorf("warning = %q", stderr)
}
if !strings.Contains(stderr, "providers.dashscope.<field>") || !strings.Contains(stderr, "config unset provider") {
t.Errorf("warning does not explain how to resolve precedence: %q", stderr)
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload config: %v", err)
}
if cfg.Llm.URL != "https://gateway.example/v1" {
t.Errorf("llm.url = %q", cfg.Llm.URL)
}
}
func TestLegacyLLMShadowWarning(t *testing.T) {
if got := legacyLLMShadowWarning("", "llm.model"); got != "" {
t.Errorf("warning without active provider = %q", got)
}
if got := legacyLLMShadowWarning("dashscope", "providers.dashscope.url"); got != "" {
t.Errorf("warning for provider setting = %q", got)
}
if got := legacyLLMShadowWarning("dashscope", "Llm.model"); got != "" {
t.Errorf("warning for invalid mixed-case legacy key = %q", got)
}
if got := legacyLLMShadowWarning("dashscope", "llm.model"); !strings.Contains(got, "providers.dashscope.<field>") {
t.Errorf("preset-provider warning = %q", got)
}
if got := legacyLLMShadowWarning("my-gateway", "llm.model"); !strings.Contains(got, "custom_providers.my-gateway.<field>") {
t.Errorf("custom-provider warning = %q", got)
}
}
func TestRunConfigUnsetProviderClearsSelectionAndKeepsProviderEntries(t *testing.T) {
setTestHome(t, t.TempDir())
configPath, err := defaultConfigPath()
if err != nil {
t.Fatal(err)
}
if err := saveConfig(configPath, &Config{
Provider: "dashscope",
Model: "legacy-model",
Providers: map[string]ProviderEntry{
"dashscope": {APIKey: "secret", Model: "provider-model"},
},
}); err != nil {
t.Fatalf("save config: %v", err)
}
if err := runConfigUnset("provider"); err != nil {
t.Fatalf("runConfigUnset(provider): %v", err)
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload config: %v", err)
}
if cfg.Provider != "" || cfg.Model != "" {
t.Errorf("provider/model = %q/%q, want both empty", cfg.Provider, cfg.Model)
}
if got := cfg.Providers["dashscope"].APIKey; got != "secret" {
t.Errorf("provider entry was removed or changed: api_key = %q", got)
}
}
func captureConfigStderr(t *testing.T, fn func()) string {
t.Helper()
old := os.Stderr
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stderr = w
defer func() { os.Stderr = old }()
// Drained concurrently: reading only after fn returns caps the capture at the
// OS pipe buffer (64 KiB on Linux, far less on a Windows anonymous pipe) and
// a payload past that blocks the writer forever.
var data []byte
var readErr error
done := make(chan struct{})
go func() {
defer close(done)
data, readErr = io.ReadAll(r)
}()
fn()
if err := w.Close(); err != nil {
t.Fatal(err)
}
<-done
if readErr != nil {
t.Fatal(readErr)
}
if err := r.Close(); err != nil {
t.Fatal(err)
}
return string(data)
}
func TestRunConfig_EmptyArgs(t *testing.T) {
err := runConfig(nil)
if err != nil {
t.Fatalf("runConfig with nil args should print usage, got error: %v", err)
}
}
func TestRunConfig_ProviderWithArgs(t *testing.T) {
err := runConfig([]string{"provider", "extra"})
if err == nil {
t.Fatal("expected error when provider has args")
}
}
func TestRunConfig_ModelWithArgs(t *testing.T) {
err := runConfig([]string{"model", "extra"})
if err == nil {
t.Fatal("expected error when model has args")
}
}
func TestDeleteCustomProvider_NotFound(t *testing.T) {
cfg := &Config{}
_, err := deleteCustomProvider(cfg, "nonexistent")
if err == nil {
t.Fatal("expected error for nil CustomProviders")
}
cfg.CustomProviders = map[string]ProviderEntry{"other": {}}
_, err = deleteCustomProvider(cfg, "nonexistent")
if err == nil {
t.Fatal("expected error for missing provider")
}
}
func TestActiveModelForProvider(t *testing.T) {
tests := []struct {
name string
cfg *Config
provider string
entry ProviderEntry
want string
}{
{"entry model", nil, "p", ProviderEntry{Model: "m1"}, "m1"},
{"cfg model", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{}, "m2"},
{"entry takes precedence", &Config{Provider: "p", Model: "m2"}, "p", ProviderEntry{Model: "m1"}, "m1"},
{"different provider", &Config{Provider: "other", Model: "m2"}, "p", ProviderEntry{}, ""},
{"no model", &Config{Provider: "p"}, "p", ProviderEntry{}, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := activeModelForProvider(tc.cfg, tc.provider, tc.entry)
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
func TestNormalizeModelList(t *testing.T) {
tests := []struct {
name string
models []string
want []string
}{
{"dedup", []string{"a", "b", "a"}, []string{"a", "b"}},
{"trim spaces", []string{" a ", " b "}, []string{"a", "b"}},
{"filter empty", []string{"a", "", "b"}, []string{"a", "b"}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := normalizeModelList(tc.models)
if len(got) != len(tc.want) {
t.Fatalf("got %v, want %v", got, tc.want)
}
for i := range tc.want {
if got[i] != tc.want[i] {
t.Errorf("[%d] = %q, want %q", i, got[i], tc.want[i])
}
}
})
}
}
func TestParseModelListValue(t *testing.T) {
tests := []struct {
name string
value string
want int
}{
{"empty", "", 0},
{"json array", `["a","b"]`, 2},
{"comma separated", "a,b,c", 3},
{"bracket unquoted", "[a,b]", 2},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got, err := parseModelListValue(tc.value)
if err != nil {
t.Fatalf("parseModelListValue: %v", err)
}
if len(got) != tc.want {
t.Errorf("got %d models, want %d: %v", len(got), tc.want, got)
}
})
}
}
func TestLoadOrCreateConfig_NewFile(t *testing.T) {
cfg, err := loadOrCreateConfig(t.TempDir() + "/nonexistent.json")
if err != nil {
t.Fatalf("loadOrCreateConfig: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil config")
}
}
func TestLoadOrCreateConfig_InvalidJSON(t *testing.T) {
dir := t.TempDir()
path := dir + "/bad.json"
if err := os.WriteFile(path, []byte("{invalid"), 0644); err != nil {
t.Fatal(err)
}
_, err := loadOrCreateConfig(path)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestLoadAppConfig_NotExist(t *testing.T) {
cfg, err := LoadAppConfig(t.TempDir() + "/none.json")
if err != nil {
t.Fatalf("LoadAppConfig: %v", err)
}
if cfg != nil {
t.Fatal("expected nil config for non-existent file")
}
}
func TestLoadAppConfig_InvalidJSON(t *testing.T) {
dir := t.TempDir()
path := dir + "/bad.json"
if err := os.WriteFile(path, []byte("not json"), 0644); err != nil {
t.Fatal(err)
}
_, err := LoadAppConfig(path)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
}
func TestEnsureModelInList(t *testing.T) {
models := []string{"test-model", "test-model-2", "bbb", "aaa", "test-model-3"}
got := ensureModelInList(models, "test-model-3")
if len(got) != len(models) {
t.Fatalf("existing model should not reorder: got %v", got)
}
for i := range models {
if got[i] != models[i] {
t.Errorf("models[%d] = %q, want %q", i, got[i], models[i])
}
}
got = ensureModelInList(models, "new-model")
want := append(append([]string(nil), models...), "new-model")
if len(got) != len(want) || got[len(got)-1] != "new-model" {
t.Errorf("new model should append: got %v, want %v", got, want)
}
}
// TestConfigRoundTripPreservesTimeoutSec guards against silent config loss:
// the resolver reads providers.<name>.timeout_sec / llm.timeout_sec (the docs
// tell users to hand-edit them), but the cmd-side Config struct used to lack
// the field, so any loadOrCreateConfig + saveConfig cycle (every
// `ocr config set`, `ocr config model`, interactive provider setup, ...)
// silently dropped the key and reverted requests to the default timeout.
func TestConfigRoundTripPreservesTimeoutSec(t *testing.T) {
configPath := t.TempDir() + "/config.json"
original := `{
"provider": "ollama",
"providers": {
"ollama": {
"url": "http://127.0.0.1:11434/v1",
"protocol": "openai",
"model": "qwen3",
"timeout_sec": 900
}
},
"custom_providers": {
"my-gateway": {
"url": "https://gw.example.com/v1",
"protocol": "openai",
"timeout_sec": 120
}
},
"llm": {
"timeout_sec": 60
}
}`
if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
// Emulate an unrelated `ocr config set` round-trip.
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("loadOrCreateConfig: %v", err)
}
if err := setConfigValue(cfg, "language", "Chinese"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
reloaded, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("reload: %v", err)
}
if got := reloaded.Providers["ollama"].TimeoutSec; got != 900 {
t.Errorf("providers.ollama.timeout_sec = %d, want 900 (lost in round-trip)", got)
}
if got := reloaded.CustomProviders["my-gateway"].TimeoutSec; got != 120 {
t.Errorf("custom_providers.my-gateway.timeout_sec = %d, want 120 (lost in round-trip)", got)
}
if got := reloaded.Llm.TimeoutSec; got != 60 {
t.Errorf("llm.timeout_sec = %d, want 60 (lost in round-trip)", got)
}
}
func TestConfigRoundTripPreservesUnknownFields(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
original := `{
"provider": "anthropic",
"future_top_level": {"enabled": true},
"providers": {
"anthropic": {
"model": "claude-opus-4-6",
"future_provider": {"value": "preserve-me"}
}
},
"llm": {
"model": "claude-opus-4-6",
"future_llm": 7
},
"telemetry": {
"enabled": true,
"future_telemetry": "keep-me"
},
"mcp_servers": {
"docs": {
"command": "docs-server",
"future_mcp": {"version": 2}
}
}
}`
if err := os.WriteFile(configPath, []byte(original), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := loadOrCreateConfig(configPath)
if err != nil {
t.Fatalf("loadOrCreateConfig: %v", err)
}
if err := setConfigValue(cfg, "language", "English"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
if err := saveConfig(configPath, cfg); err != nil {
t.Fatalf("saveConfig: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
var got map[string]any
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("parse saved config: %v", err)
}
assertJSONValue(t, got, []string{"future_top_level", "enabled"}, true)
assertJSONValue(t, got, []string{"providers", "anthropic", "future_provider", "value"}, "preserve-me")
assertJSONValue(t, got, []string{"llm", "future_llm"}, float64(7))
assertJSONValue(t, got, []string{"telemetry", "future_telemetry"}, "keep-me")
assertJSONValue(t, got, []string{"mcp_servers", "docs", "future_mcp", "version"}, float64(2))
}
func assertJSONValue(t *testing.T, root map[string]any, path []string, want any) {
t.Helper()
var current any = root
for _, part := range path {
object, ok := current.(map[string]any)
if !ok {
t.Fatalf("JSON path %q entered %T at %q", strings.Join(path, "."), current, part)
}
current, ok = object[part]
if !ok {
t.Fatalf("JSON path %q is missing", strings.Join(path, "."))
}
}
if !reflect.DeepEqual(current, want) {
t.Errorf("JSON path %q = %#v, want %#v", strings.Join(path, "."), current, want)
}
}
func TestSetMCPServerValue_Type(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.type", "remote"); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
if cfg.MCPServers["gh"].Type != "remote" {
t.Errorf("Type = %q, want %q", cfg.MCPServers["gh"].Type, "remote")
}
}
func TestSetMCPServerValue_TypeInvalid(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.type", "invalid"); err == nil {
t.Fatal("expected error for invalid type, got nil")
}
}
func TestSetMCPServerValue_URL(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "https://api.example.com/mcp"); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
if cfg.MCPServers["gh"].URL != "https://api.example.com/mcp" {
t.Errorf("URL = %q, want %q", cfg.MCPServers["gh"].URL, "https://api.example.com/mcp")
}
}
func TestSetMCPServerValue_URLEmpty(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", ""); err == nil {
t.Fatal("expected error for empty URL, got nil")
}
}
func TestSetMCPServerValue_URLInvalidScheme(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "ftp://example.com/mcp"); err == nil {
t.Fatal("expected error for non-http scheme, got nil")
}
}
func TestSetMCPServerValue_Headers(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", `{"Authorization":"Bearer $TOKEN","X-Custom":"val"}`); err != nil {
t.Fatalf("setMCPServerValue: %v", err)
}
h := cfg.MCPServers["gh"].Headers
if h["Authorization"] != "Bearer $TOKEN" {
t.Errorf("Authorization = %q, want %q", h["Authorization"], "Bearer $TOKEN")
}
if h["X-Custom"] != "val" {
t.Errorf("X-Custom = %q, want %q", h["X-Custom"], "val")
}
}
func TestSetMCPServerValue_URLNoHost(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "http://"); err == nil {
t.Fatal("expected error for URL without host, got nil")
}
}
func TestSetMCPServerValue_URLParseError(t *testing.T) {
cfg := &Config{}
// "://bad" has no scheme, so url.Parse itself fails before the scheme check.
if err := setMCPServerValue(cfg, "mcp_servers.gh.url", "://bad"); err == nil {
t.Fatal("expected error for unparseable URL, got nil")
}
}
func TestSetMCPServerValue_HeadersEmptyName(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", `{"":"val"}`); err == nil {
t.Fatal("expected error for empty header name, got nil")
}
}
func TestSetMCPServerValue_HeadersInvalidJSON(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", "not-json"); err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
func TestSetMCPServerValue_HeadersEmptyValue(t *testing.T) {
cfg := &Config{}
if err := setMCPServerValue(cfg, "mcp_servers.gh.headers", `{"Authorization":""}`); err == nil {
t.Fatal("expected error for empty header value, got nil")
}
}
func TestSetConfigValueLlmRetryCodesRedundantWarning(t *testing.T) {
cfg := &Config{}
stderr := captureConfigStderr(t, func() {
if err := setConfigValue(cfg, "llm.retry_codes", "429,403"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
})
if !strings.Contains(stderr, "WARNING") || !strings.Contains(stderr, "429") {
t.Errorf("expected warning about 429 on stderr, got %q", stderr)
}
if len(cfg.Llm.RetryCodes) != 1 || cfg.Llm.RetryCodes[0] != 403 {
t.Errorf("RetryCodes = %v, want [403]", cfg.Llm.RetryCodes)
}
}
func TestSetConfigValueProviderRetryCodesRedundantWarning(t *testing.T) {
cfg := &Config{CustomProviders: map[string]ProviderEntry{"test": {URL: "https://example.com"}}}
stderr := captureConfigStderr(t, func() {
if err := setConfigValue(cfg, "custom_providers.test.retry_codes", "408,400"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
})
if !strings.Contains(stderr, "WARNING") || !strings.Contains(stderr, "408") {
t.Errorf("expected warning about 408 on stderr, got %q", stderr)
}
entry := cfg.CustomProviders["test"]
if len(entry.RetryCodes) != 1 || entry.RetryCodes[0] != 400 {
t.Errorf("RetryCodes = %v, want [400]", entry.RetryCodes)
}
}
func TestSetConfigValueLlmRetryCodesNoWarningForValidCodes(t *testing.T) {
cfg := &Config{}
stderr := captureConfigStderr(t, func() {
if err := setConfigValue(cfg, "llm.retry_codes", "403,400"); err != nil {
t.Fatalf("setConfigValue: %v", err)
}
})
if stderr != "" {
t.Errorf("expected no stderr output, got %q", stderr)
}
if len(cfg.Llm.RetryCodes) != 2 {
t.Errorf("RetryCodes = %v, want [403 400]", cfg.Llm.RetryCodes)
}
}
func TestSetConfigValueTimeoutSeconds(t *testing.T) {
cfg := &Config{CustomProviders: map[string]ProviderEntry{"gateway": {}}}
if err := setConfigValue(cfg, "llm.timeout_sec", "120"); err != nil {
t.Fatalf("setConfigValue() llm timeout error = %v", err)
}
if cfg.Llm.TimeoutSec != 120 {
t.Fatalf("llm.timeout_sec = %d, want 120", cfg.Llm.TimeoutSec)
}
if err := setConfigValue(cfg, "custom_providers.gateway.timeout_sec", "45"); err != nil {
t.Fatalf("setConfigValue() provider timeout error = %v", err)
}
if got := cfg.CustomProviders["gateway"].TimeoutSec; got != 45 {
t.Fatalf("provider timeout_sec = %d, want 45", got)
}
}
func TestSetConfigValueTimeoutSecondsRejectsInvalidValues(t *testing.T) {
for _, value := range []string{"not-a-number", "-1", "9223372037"} {
if err := setConfigValue(&Config{}, "llm.timeout_sec", value); err == nil {
t.Fatalf("setConfigValue() accepted timeout_sec=%q", value)
}
}
}