mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-10-21 05:29:48 +00:00
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
* 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>
1867 lines
58 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|