a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
551 lines
21 KiB
Go
551 lines
21 KiB
Go
package llm
|
|
|
|
import (
|
|
"reflect"
|
|
"testing"
|
|
)
|
|
|
|
func TestConfig_ProviderName_DefaultsToLocal(t *testing.T) {
|
|
if got := (Config{}).ProviderName(); got != "local" {
|
|
t.Fatalf("empty provider: got %q want local", got)
|
|
}
|
|
if got := (Config{Provider: " Anthropic "}).ProviderName(); got != "anthropic" {
|
|
t.Fatalf("normalisation: got %q want anthropic", got)
|
|
}
|
|
}
|
|
|
|
func TestConfig_IsEnabled(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg Config
|
|
want bool
|
|
}{
|
|
{"empty", Config{}, false},
|
|
{"local with model", Config{Provider: "local", Local: LocalConfig{Model: "/m.gguf"}}, true},
|
|
{"local no model", Config{Provider: "local"}, false},
|
|
{"anthropic with model", Config{Provider: "anthropic", Anthropic: AnthropicConfig{RemoteConfig: RemoteConfig{Model: "claude"}}}, true},
|
|
{"anthropic no model", Config{Provider: "anthropic"}, false},
|
|
{"openai with model", Config{Provider: "openai", OpenAI: RemoteConfig{Model: "gpt"}}, true},
|
|
{"azure with deployment", Config{Provider: "azure", Azure: AzureConfig{Deployment: "gpt4o"}}, true},
|
|
{"azure no deployment", Config{Provider: "azure"}, false},
|
|
{"ollama with model", Config{Provider: "ollama", Ollama: OllamaConfig{Model: "qwen"}}, true},
|
|
{"ollama no model", Config{Provider: "ollama"}, false},
|
|
{"claudecli no model", Config{Provider: "claudecli"}, true},
|
|
{"claudecli with model", Config{Provider: "claudecli", ClaudeCLI: ClaudeCLIConfig{Model: "sonnet"}}, true},
|
|
{"unknown provider", Config{Provider: "bogus", Local: LocalConfig{Model: "/m.gguf"}}, false},
|
|
{"gemini with model", Config{Provider: "gemini", Gemini: RemoteConfig{Model: "gemini-2.5-pro"}}, true},
|
|
{"gemini no model", Config{Provider: "gemini"}, false},
|
|
{"bedrock with model_id", Config{Provider: "bedrock", Bedrock: BedrockConfig{ModelID: "anthropic.claude-sonnet-4-20250514-v1:0"}}, true},
|
|
{"bedrock no model_id", Config{Provider: "bedrock"}, false},
|
|
{"deepseek with model", Config{Provider: "deepseek", DeepSeek: RemoteConfig{Model: "deepseek-chat"}}, true},
|
|
{"deepseek no model", Config{Provider: "deepseek"}, false},
|
|
{"codex no model", Config{Provider: "codex"}, true},
|
|
{"codex with model", Config{Provider: "codex", Codex: CodexConfig{Model: "gpt-5-codex"}}, true},
|
|
{"copilot no model", Config{Provider: "copilot"}, true},
|
|
{"cursor no model", Config{Provider: "cursor"}, true},
|
|
{"opencode no model", Config{Provider: "opencode"}, true},
|
|
{"custom with model", Config{Provider: "mygw", Custom: map[string]CustomProvider{"mygw": {BaseURL: "https://x/v1", Model: "m"}}}, true},
|
|
{"custom no model", Config{Provider: "mygw", Custom: map[string]CustomProvider{"mygw": {BaseURL: "https://x/v1"}}}, false},
|
|
{"custom name not registered", Config{Provider: "mygw"}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := tc.cfg.IsEnabled(); got != tc.want {
|
|
t.Fatalf("IsEnabled=%v want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfig_ApplyDefaults(t *testing.T) {
|
|
c := Config{}.ApplyDefaults()
|
|
if c.Provider != "local" {
|
|
t.Errorf("provider=%q want local", c.Provider)
|
|
}
|
|
if c.MaxSteps != 16 {
|
|
t.Errorf("max_steps=%d want 16", c.MaxSteps)
|
|
}
|
|
if c.Local.Ctx != 4096 || c.Local.GPULayers != 999 || c.Local.Template != "chatml" {
|
|
t.Errorf("local defaults wrong: %+v", c.Local)
|
|
}
|
|
if c.Anthropic.Model != defaultAnthropicModel || c.Anthropic.APIKeyEnv != defaultAnthropicKeyEnv || c.Anthropic.BaseURL != defaultAnthropicBaseURL {
|
|
t.Errorf("anthropic defaults wrong: %+v", c.Anthropic)
|
|
}
|
|
if c.OpenAI.Model != defaultOpenAIModel || c.OpenAI.APIKeyEnv != defaultOpenAIKeyEnv || c.OpenAI.BaseURL != defaultOpenAIBaseURL {
|
|
t.Errorf("openai defaults wrong: %+v", c.OpenAI)
|
|
}
|
|
if c.Azure.APIVersion != defaultAzureAPIVersion || c.Azure.EndpointEnv != defaultAzureEndpointEnv || c.Azure.APIKeyEnv != defaultAzureKeyEnv {
|
|
t.Errorf("azure defaults wrong: %+v", c.Azure)
|
|
}
|
|
if c.Ollama.Host != defaultOllamaHost {
|
|
t.Errorf("ollama host=%q want %q", c.Ollama.Host, defaultOllamaHost)
|
|
}
|
|
if c.ClaudeCLI.Binary != defaultClaudeCLIBinary {
|
|
t.Errorf("claudecli binary=%q want %q", c.ClaudeCLI.Binary, defaultClaudeCLIBinary)
|
|
}
|
|
if c.Codex.Binary != defaultCodexBinary {
|
|
t.Errorf("codex binary=%q want %q", c.Codex.Binary, defaultCodexBinary)
|
|
}
|
|
if c.Copilot.Binary != defaultCopilotBinary || c.Cursor.Binary != defaultCursorBinary || c.Opencode.Binary != defaultOpencodeBinary {
|
|
t.Errorf("cli provider binary defaults wrong: copilot=%q cursor=%q opencode=%q", c.Copilot.Binary, c.Cursor.Binary, c.Opencode.Binary)
|
|
}
|
|
if c.Gemini.Model != defaultGeminiModel || c.Gemini.APIKeyEnv != defaultGeminiKeyEnv || c.Gemini.BaseURL != defaultGeminiBaseURL {
|
|
t.Errorf("gemini defaults wrong: %+v", c.Gemini)
|
|
}
|
|
if c.Bedrock.Region != defaultBedrockRegion || c.Bedrock.AccessKeyEnv != defaultBedrockAccessKeyEnv || c.Bedrock.SecretKeyEnv != defaultBedrockSecretKeyEnv || c.Bedrock.SessionTokenEnv != defaultBedrockSessionTokenEnv {
|
|
t.Errorf("bedrock defaults wrong: %+v", c.Bedrock)
|
|
}
|
|
if c.DeepSeek.Model != defaultDeepSeekModel || c.DeepSeek.APIKeyEnv != defaultDeepSeekKeyEnv || c.DeepSeek.BaseURL != defaultDeepSeekBaseURL {
|
|
t.Errorf("deepseek defaults wrong: %+v", c.DeepSeek)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_GeminiModel(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "gemini")
|
|
t.Setenv("GORTEX_LLM_MODEL", "gemini-2.5-flash")
|
|
c := Config{}.MergeEnv()
|
|
if c.Gemini.Model != "gemini-2.5-flash" {
|
|
t.Errorf("gemini model=%q — GORTEX_LLM_MODEL should target the active provider", c.Gemini.Model)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_BedrockModelAndRegion(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "bedrock")
|
|
t.Setenv("GORTEX_LLM_MODEL", "anthropic.claude-opus-4-20250514-v1:0")
|
|
t.Setenv("GORTEX_LLM_BEDROCK_REGION", "eu-west-1")
|
|
c := Config{}.MergeEnv()
|
|
if c.Bedrock.ModelID != "anthropic.claude-opus-4-20250514-v1:0" {
|
|
t.Errorf("bedrock model_id=%q", c.Bedrock.ModelID)
|
|
}
|
|
if c.Bedrock.Region != "eu-west-1" {
|
|
t.Errorf("bedrock region=%q want eu-west-1", c.Bedrock.Region)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_DeepSeekModel(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "deepseek")
|
|
t.Setenv("GORTEX_LLM_MODEL", "deepseek-reasoner")
|
|
c := Config{}.MergeEnv()
|
|
if c.DeepSeek.Model != "deepseek-reasoner" {
|
|
t.Errorf("deepseek model=%q", c.DeepSeek.Model)
|
|
}
|
|
}
|
|
|
|
func TestConfig_CustomProvider_ActiveModelAndWithModel(t *testing.T) {
|
|
base := Config{
|
|
Provider: "mygw",
|
|
Custom: map[string]CustomProvider{
|
|
"mygw": {BaseURL: "https://x/v1", Model: "base-model"},
|
|
"other": {BaseURL: "https://y/v1", Model: "other-model"},
|
|
},
|
|
}
|
|
if got := base.ActiveModel(); got != "base-model" {
|
|
t.Errorf("ActiveModel=%q want base-model", got)
|
|
}
|
|
routed := base.WithModel("routed-model")
|
|
if got := routed.ActiveModel(); got != "routed-model" {
|
|
t.Errorf("WithModel/ActiveModel round-trip failed: %q", got)
|
|
}
|
|
// Copy-on-write: the base config and other providers are untouched.
|
|
if base.Custom["mygw"].Model != "base-model" {
|
|
t.Errorf("WithModel mutated the base config's custom map: %q", base.Custom["mygw"].Model)
|
|
}
|
|
if routed.Custom["other"].Model != "other-model" {
|
|
t.Errorf("WithModel must not disturb other custom providers: %q", routed.Custom["other"].Model)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith_CustomProviders(t *testing.T) {
|
|
global := Config{
|
|
Provider: "mygw",
|
|
Custom: map[string]CustomProvider{
|
|
"mygw": {BaseURL: "https://global/v1", Model: "global-model"},
|
|
"shared": {BaseURL: "https://global/v1", Model: "global-shared"},
|
|
},
|
|
}
|
|
local := Config{
|
|
Custom: map[string]CustomProvider{
|
|
"shared": {BaseURL: "https://local/v1", Model: "local-shared"},
|
|
"local": {BaseURL: "https://local/v1", Model: "local-only"},
|
|
},
|
|
}
|
|
got := local.MergedWith(global)
|
|
if got.Custom["mygw"].Model != "global-model" {
|
|
t.Error("global-only custom provider should be filled in")
|
|
}
|
|
if got.Custom["local"].Model != "local-only" {
|
|
t.Error("local-only custom provider should survive")
|
|
}
|
|
if got.Custom["shared"].Model != "local-shared" {
|
|
t.Errorf("local must win for a name in both, got %q", got.Custom["shared"].Model)
|
|
}
|
|
}
|
|
|
|
func TestConfig_Anthropic_CachingThinking_EnvAndMerge(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "anthropic")
|
|
t.Setenv("GORTEX_LLM_ANTHROPIC_PROMPT_CACHING", "1")
|
|
t.Setenv("GORTEX_LLM_ANTHROPIC_THINKING_MODE", "auto")
|
|
c := Config{}.MergeEnv()
|
|
if !c.Anthropic.PromptCaching {
|
|
t.Error("GORTEX_LLM_ANTHROPIC_PROMPT_CACHING should enable caching")
|
|
}
|
|
if c.Anthropic.ThinkingMode != "auto" {
|
|
t.Errorf("thinking_mode=%q want auto", c.Anthropic.ThinkingMode)
|
|
}
|
|
|
|
// MergedWith fills the Anthropic-only knobs (and the embedded model)
|
|
// from global; local wins where set.
|
|
global := Config{
|
|
Provider: "anthropic",
|
|
Anthropic: AnthropicConfig{
|
|
RemoteConfig: RemoteConfig{Model: "claude-sonnet", APIKeyEnv: "ANTHROPIC_API_KEY"},
|
|
PromptCaching: true,
|
|
CacheTTL: "1h",
|
|
ThinkingMode: "manual",
|
|
},
|
|
}
|
|
local := Config{Anthropic: AnthropicConfig{ThinkingMode: "adaptive"}}
|
|
got := local.MergedWith(global)
|
|
if got.Anthropic.Model != "claude-sonnet" {
|
|
t.Errorf("embedded model not merged: %q", got.Anthropic.Model)
|
|
}
|
|
if !got.Anthropic.PromptCaching || got.Anthropic.CacheTTL != "1h" {
|
|
t.Errorf("caching knobs not merged: %+v", got.Anthropic)
|
|
}
|
|
if got.Anthropic.ThinkingMode != "adaptive" {
|
|
t.Errorf("thinking_mode=%q want adaptive (local wins)", got.Anthropic.ThinkingMode)
|
|
}
|
|
}
|
|
|
|
func TestConfig_Effort_EnvAndMerge(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "anthropic")
|
|
t.Setenv("GORTEX_LLM_EFFORT", "high")
|
|
c := Config{}.MergeEnv()
|
|
if c.Anthropic.Effort != "high" {
|
|
t.Errorf("GORTEX_LLM_EFFORT should target the active provider, got anthropic.effort=%q", c.Anthropic.Effort)
|
|
}
|
|
|
|
global := Config{Provider: "openai", OpenAI: RemoteConfig{Effort: "medium"}}
|
|
local := Config{}
|
|
if got := local.MergedWith(global); got.OpenAI.Effort != "medium" {
|
|
t.Errorf("effort should merge from global, got %q", got.OpenAI.Effort)
|
|
}
|
|
}
|
|
|
|
func TestConfig_IsBuiltinProvider(t *testing.T) {
|
|
for _, name := range []string{"openai", "azure", "copilot", " Anthropic ", "BEDROCK"} {
|
|
if !IsBuiltinProvider(name) {
|
|
t.Errorf("IsBuiltinProvider(%q) = false, want true", name)
|
|
}
|
|
}
|
|
for _, name := range []string{"mygw", "groq", ""} {
|
|
if IsBuiltinProvider(name) {
|
|
t.Errorf("IsBuiltinProvider(%q) = true, want false", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_AzureDeploymentEndpointVersion(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "azure")
|
|
t.Setenv("GORTEX_LLM_MODEL", "gpt4o-routed")
|
|
t.Setenv("GORTEX_LLM_AZURE_ENDPOINT", "https://r.openai.azure.com")
|
|
t.Setenv("GORTEX_LLM_AZURE_DEPLOYMENT", "gpt4o-deploy")
|
|
t.Setenv("GORTEX_LLM_AZURE_API_VERSION", "2025-01-01-preview")
|
|
c := Config{}.MergeEnv()
|
|
// GORTEX_LLM_MODEL targets the active provider — for azure that's
|
|
// the deployment (the routing key). The dedicated
|
|
// GORTEX_LLM_AZURE_DEPLOYMENT then overrides it.
|
|
if c.Azure.Deployment != "gpt4o-deploy" {
|
|
t.Errorf("azure deployment=%q want gpt4o-deploy", c.Azure.Deployment)
|
|
}
|
|
if c.Azure.Endpoint != "https://r.openai.azure.com" {
|
|
t.Errorf("azure endpoint=%q", c.Azure.Endpoint)
|
|
}
|
|
if c.Azure.APIVersion != "2025-01-01-preview" {
|
|
t.Errorf("azure api_version=%q", c.Azure.APIVersion)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_CLIProviderBinaryAndModel(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "copilot")
|
|
t.Setenv("GORTEX_LLM_MODEL", "claude-opus-4.1")
|
|
t.Setenv("GORTEX_LLM_COPILOT_BINARY", "/opt/gh/copilot")
|
|
t.Setenv("GORTEX_LLM_CURSOR_BINARY", "/opt/cursor/cursor-agent")
|
|
t.Setenv("GORTEX_LLM_OPENCODE_BINARY", "/opt/oc/opencode")
|
|
c := Config{}.MergeEnv()
|
|
if c.Copilot.Model != "claude-opus-4.1" {
|
|
t.Errorf("copilot model=%q — GORTEX_LLM_MODEL should target the active provider", c.Copilot.Model)
|
|
}
|
|
if c.Copilot.Binary != "/opt/gh/copilot" {
|
|
t.Errorf("copilot binary=%q", c.Copilot.Binary)
|
|
}
|
|
if c.Cursor.Binary != "/opt/cursor/cursor-agent" {
|
|
t.Errorf("cursor binary=%q", c.Cursor.Binary)
|
|
}
|
|
if c.Opencode.Binary != "/opt/oc/opencode" {
|
|
t.Errorf("opencode binary=%q", c.Opencode.Binary)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith_CLIProviders(t *testing.T) {
|
|
global := Config{
|
|
Provider: "opencode",
|
|
Opencode: CLIConfig{Binary: "/usr/local/bin/opencode", Args: []string{"--mode", "ask"}, TimeoutSeconds: 90},
|
|
}
|
|
local := Config{Opencode: CLIConfig{Model: "anthropic/claude-sonnet-4-6"}}
|
|
got := local.MergedWith(global)
|
|
if got.Opencode.Binary != "/usr/local/bin/opencode" {
|
|
t.Errorf("binary=%q — global should fill", got.Opencode.Binary)
|
|
}
|
|
if got.Opencode.Model != "anthropic/claude-sonnet-4-6" {
|
|
t.Errorf("model=%q — local should win", got.Opencode.Model)
|
|
}
|
|
if len(got.Opencode.Args) != 2 || got.Opencode.TimeoutSeconds != 90 {
|
|
t.Errorf("args/timeout not filled from global: %+v", got.Opencode)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith_Azure(t *testing.T) {
|
|
global := Config{
|
|
Provider: "azure",
|
|
Azure: AzureConfig{Endpoint: "https://r.openai.azure.com", APIVersion: "2024-10-21", APIKeyEnv: "AZURE_OPENAI_API_KEY"},
|
|
}
|
|
local := Config{Azure: AzureConfig{Deployment: "gpt4o"}}
|
|
got := local.MergedWith(global)
|
|
if got.Provider != "azure" {
|
|
t.Errorf("provider=%q want azure", got.Provider)
|
|
}
|
|
if got.Azure.Deployment != "gpt4o" {
|
|
t.Errorf("deployment=%q want gpt4o (local should win)", got.Azure.Deployment)
|
|
}
|
|
if got.Azure.Endpoint != "https://r.openai.azure.com" || got.Azure.APIVersion != "2024-10-21" {
|
|
t.Errorf("azure block not filled from global: %+v", got.Azure)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith_NewProviders(t *testing.T) {
|
|
global := Config{
|
|
Provider: "bedrock",
|
|
Bedrock: BedrockConfig{ModelID: "anthropic.claude-sonnet-4-20250514-v1:0", Region: "us-east-1"},
|
|
Gemini: RemoteConfig{APIKeyEnv: "GEMINI_API_KEY", Model: "gemini-2.5-pro"},
|
|
DeepSeek: RemoteConfig{APIKeyEnv: "DEEPSEEK_API_KEY"},
|
|
}
|
|
local := Config{Bedrock: BedrockConfig{Region: "eu-west-1"}}
|
|
got := local.MergedWith(global)
|
|
if got.Bedrock.Region != "eu-west-1" {
|
|
t.Errorf("bedrock region=%q want eu-west-1 (local should win)", got.Bedrock.Region)
|
|
}
|
|
if got.Bedrock.ModelID != "anthropic.claude-sonnet-4-20250514-v1:0" {
|
|
t.Errorf("bedrock model_id=%q — global should fill", got.Bedrock.ModelID)
|
|
}
|
|
if got.Gemini.Model != "gemini-2.5-pro" {
|
|
t.Errorf("gemini model=%q — global should fill", got.Gemini.Model)
|
|
}
|
|
if got.DeepSeek.APIKeyEnv != "DEEPSEEK_API_KEY" {
|
|
t.Errorf("deepseek api_key_env=%q — global should fill", got.DeepSeek.APIKeyEnv)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_ClaudeCLIModel(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "claudecli")
|
|
t.Setenv("GORTEX_LLM_MODEL", "opus")
|
|
t.Setenv("GORTEX_LLM_CLAUDECLI_BINARY", "/opt/anthropic/claude")
|
|
c := Config{}.MergeEnv()
|
|
if c.ClaudeCLI.Model != "opus" {
|
|
t.Errorf("claudecli model=%q want opus", c.ClaudeCLI.Model)
|
|
}
|
|
if c.ClaudeCLI.Binary != "/opt/anthropic/claude" {
|
|
t.Errorf("claudecli binary=%q want /opt/anthropic/claude", c.ClaudeCLI.Binary)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_CodexModel(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "codex")
|
|
t.Setenv("GORTEX_LLM_MODEL", "o4-mini")
|
|
t.Setenv("GORTEX_LLM_CODEX_BINARY", "/opt/openai/codex")
|
|
c := Config{}.MergeEnv()
|
|
if c.Codex.Model != "o4-mini" {
|
|
t.Errorf("codex model=%q want o4-mini", c.Codex.Model)
|
|
}
|
|
if c.Codex.Binary != "/opt/openai/codex" {
|
|
t.Errorf("codex binary=%q want /opt/openai/codex", c.Codex.Binary)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith_Codex(t *testing.T) {
|
|
global := Config{
|
|
Provider: "codex",
|
|
Codex: CodexConfig{Binary: "/usr/local/bin/codex", Args: []string{"--sandbox", "read-only"}, TimeoutSeconds: 90},
|
|
}
|
|
local := Config{Codex: CodexConfig{Model: "gpt-5-codex"}}
|
|
got := local.MergedWith(global)
|
|
if got.Provider != "codex" {
|
|
t.Errorf("provider=%q want codex", got.Provider)
|
|
}
|
|
if got.Codex.Binary != "/usr/local/bin/codex" {
|
|
t.Errorf("binary=%q — global should fill", got.Codex.Binary)
|
|
}
|
|
if got.Codex.Model != "gpt-5-codex" {
|
|
t.Errorf("model=%q want gpt-5-codex (local should win)", got.Codex.Model)
|
|
}
|
|
if len(got.Codex.Args) != 2 {
|
|
t.Errorf("args=%v — global should fill when local is empty", got.Codex.Args)
|
|
}
|
|
if got.Codex.TimeoutSeconds != 90 {
|
|
t.Errorf("timeout=%d — global should fill", got.Codex.TimeoutSeconds)
|
|
}
|
|
}
|
|
|
|
func TestConfig_ActiveModelAndWithModel(t *testing.T) {
|
|
cases := []struct {
|
|
provider string
|
|
cfg Config
|
|
}{
|
|
{"anthropic", Config{Provider: "anthropic"}},
|
|
{"openai", Config{Provider: "openai"}},
|
|
{"azure", Config{Provider: "azure"}},
|
|
{"ollama", Config{Provider: "ollama"}},
|
|
{"claudecli", Config{Provider: "claudecli"}},
|
|
{"codex", Config{Provider: "codex"}},
|
|
{"copilot", Config{Provider: "copilot"}},
|
|
{"cursor", Config{Provider: "cursor"}},
|
|
{"opencode", Config{Provider: "opencode"}},
|
|
{"gemini", Config{Provider: "gemini"}},
|
|
{"bedrock", Config{Provider: "bedrock"}},
|
|
{"deepseek", Config{Provider: "deepseek"}},
|
|
{"local", Config{Provider: "local"}},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.provider, func(t *testing.T) {
|
|
got := c.cfg.WithModel("routed-model-x")
|
|
if got.ActiveModel() != "routed-model-x" {
|
|
t.Errorf("WithModel/ActiveModel round-trip failed for %s: %q", c.provider, got.ActiveModel())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestConfig_WithModel_EmptyIsNoOp(t *testing.T) {
|
|
c := Config{Provider: "anthropic", Anthropic: AnthropicConfig{RemoteConfig: RemoteConfig{Model: "claude-sonnet-4-6"}}}
|
|
if got := c.WithModel("").ActiveModel(); got != "claude-sonnet-4-6" {
|
|
t.Errorf("WithModel(\"\") must be a no-op, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestConfig_WithModel_DoesNotTouchOtherProviders(t *testing.T) {
|
|
c := Config{
|
|
Provider: "anthropic",
|
|
Anthropic: AnthropicConfig{RemoteConfig: RemoteConfig{Model: "claude-sonnet-4-6"}},
|
|
OpenAI: RemoteConfig{Model: "gpt-4o"},
|
|
}
|
|
got := c.WithModel("claude-haiku-4-5")
|
|
if got.OpenAI.Model != "gpt-4o" {
|
|
t.Errorf("WithModel must not disturb the openai sub-block, got %q", got.OpenAI.Model)
|
|
}
|
|
if got.Anthropic.Model != "claude-haiku-4-5" {
|
|
t.Errorf("WithModel = %q, want claude-haiku-4-5", got.Anthropic.Model)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith_Routing(t *testing.T) {
|
|
global := Config{
|
|
Provider: "anthropic",
|
|
Routing: RoutingConfig{Enabled: true, SimpleModel: "claude-haiku-4-5", ComplexModel: "claude-opus-4-7"},
|
|
}
|
|
local := Config{Routing: RoutingConfig{ComplexModel: "claude-opus-4-custom"}}
|
|
got := local.MergedWith(global)
|
|
if !got.Routing.Enabled {
|
|
t.Error("routing enabled should be filled from global")
|
|
}
|
|
if got.Routing.SimpleModel != "claude-haiku-4-5" {
|
|
t.Errorf("simple_model=%q — global should fill", got.Routing.SimpleModel)
|
|
}
|
|
if got.Routing.ComplexModel != "claude-opus-4-custom" {
|
|
t.Errorf("complex_model=%q — local should win", got.Routing.ComplexModel)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith_ClaudeCLI(t *testing.T) {
|
|
global := Config{
|
|
Provider: "claudecli",
|
|
ClaudeCLI: ClaudeCLIConfig{Binary: "/usr/local/bin/claude", Args: []string{"--allowed-tools", ""}, TimeoutSeconds: 60},
|
|
}
|
|
local := Config{ClaudeCLI: ClaudeCLIConfig{Model: "sonnet"}}
|
|
got := local.MergedWith(global)
|
|
if got.Provider != "claudecli" {
|
|
t.Errorf("provider=%q want claudecli", got.Provider)
|
|
}
|
|
if got.ClaudeCLI.Binary != "/usr/local/bin/claude" {
|
|
t.Errorf("binary=%q — global should fill", got.ClaudeCLI.Binary)
|
|
}
|
|
if got.ClaudeCLI.Model != "sonnet" {
|
|
t.Errorf("model=%q want sonnet (local should win)", got.ClaudeCLI.Model)
|
|
}
|
|
if len(got.ClaudeCLI.Args) != 2 {
|
|
t.Errorf("args=%v — global should fill when local is empty", got.ClaudeCLI.Args)
|
|
}
|
|
if got.ClaudeCLI.TimeoutSeconds != 60 {
|
|
t.Errorf("timeout=%d — global should fill", got.ClaudeCLI.TimeoutSeconds)
|
|
}
|
|
}
|
|
|
|
func TestConfig_ApplyDefaults_Idempotent(t *testing.T) {
|
|
once := Config{Provider: "anthropic", Anthropic: AnthropicConfig{RemoteConfig: RemoteConfig{Model: "m"}}}.ApplyDefaults()
|
|
twice := once.ApplyDefaults()
|
|
if !reflect.DeepEqual(once, twice) {
|
|
t.Fatalf("ApplyDefaults not idempotent:\n once=%+v\n twice=%+v", once, twice)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "anthropic")
|
|
t.Setenv("GORTEX_LLM_MODEL", "claude-opus-4-7")
|
|
t.Setenv("GORTEX_LLM_MAX_STEPS", "8")
|
|
c := Config{}.MergeEnv()
|
|
if c.Provider != "anthropic" {
|
|
t.Errorf("provider=%q want anthropic", c.Provider)
|
|
}
|
|
if c.Anthropic.Model != "claude-opus-4-7" {
|
|
t.Errorf("anthropic model=%q — GORTEX_LLM_MODEL should target the active provider", c.Anthropic.Model)
|
|
}
|
|
if c.MaxSteps != 8 {
|
|
t.Errorf("max_steps=%d want 8", c.MaxSteps)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergeEnv_ModelTargetsLocalByDefault(t *testing.T) {
|
|
t.Setenv("GORTEX_LLM_PROVIDER", "")
|
|
t.Setenv("GORTEX_LLM_MODEL", "/local/m.gguf")
|
|
c := Config{}.MergeEnv()
|
|
if c.Local.Model != "/local/m.gguf" {
|
|
t.Errorf("local model=%q want /local/m.gguf", c.Local.Model)
|
|
}
|
|
}
|
|
|
|
func TestConfig_MergedWith(t *testing.T) {
|
|
global := Config{
|
|
Provider: "local",
|
|
MaxSteps: 16,
|
|
Local: LocalConfig{Model: "/g.gguf", Template: "chatml", Ctx: 4096},
|
|
Anthropic: AnthropicConfig{RemoteConfig: RemoteConfig{APIKeyEnv: "ANTHROPIC_API_KEY"}},
|
|
}
|
|
local := Config{Local: LocalConfig{Model: "/repo.gguf"}} // overrides only the model
|
|
|
|
got := local.MergedWith(global)
|
|
if got.Provider != "local" {
|
|
t.Errorf("provider=%q — global should fill", got.Provider)
|
|
}
|
|
if got.Local.Model != "/repo.gguf" {
|
|
t.Errorf("local model=%q — repo should win", got.Local.Model)
|
|
}
|
|
if got.Local.Template != "chatml" || got.Local.Ctx != 4096 {
|
|
t.Errorf("local sub-fields not filled from global: %+v", got.Local)
|
|
}
|
|
if got.Anthropic.APIKeyEnv != "ANTHROPIC_API_KEY" {
|
|
t.Errorf("anthropic block not merged: %+v", got.Anthropic)
|
|
}
|
|
if got.MaxSteps != 16 {
|
|
t.Errorf("max_steps=%d — global should fill", got.MaxSteps)
|
|
}
|
|
}
|